Compare commits

...
Author SHA1 Message Date
opencode ec64ceabec release: v1.3.1 2026-03-24 22:07:24 +00:00
Dax 56644be95a fix(core): restore SIGHUP exit handler (#16057) (#18527) 2026-03-24 17:42:58 -04:00
Kamil Jopek 00d3b831fc feat: add Poe OAuth auth plugin (#18477) 2026-03-24 16:17:47 -05:00
Adam b848b7ebae fix(app): session timeline jumping on scroll (#18993) 2026-03-24 13:51:09 -05:00
opencode-agent[bot] e837dcc1c5 chore: generate 2026-03-24 18:43:20 +00:00
Nicholas Hansen 024979f3fd feat(bedrock): Add token caching for any amazon-bedrock provider (#18959) 2026-03-24 13:42:20 -05:00
opencode-agent[bot] bc608fb081 chore: update nix node_modules hashes 2026-03-24 18:40:28 +00:00
Adam 9838f56a6f fix(app): sidebar ux 2026-03-24 13:35:20 -05:00
Adam 98b3340cee fix(app): more startup efficiency (#18985) 2026-03-24 13:23:41 -05:00
Aiden ClineandKit Langton 5e684c6e80 chore: effectify agent.ts (#18971)
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-03-24 18:15:23 +00:00
Caleb Norton 2c1d8a90d5 fix: nix hash update parsing... again (#18989) 2026-03-24 13:06:46 -05:00
opencode-agent[bot] 8994cbfc0f chore: generate 2026-03-24 18:05:43 +00:00
Adam 42a773481e fix(app): sidebar truncation 2026-03-24 13:04:32 -05:00
Kit Langton 539b01f20f effectify Project service (#18808) 2026-03-24 14:04:22 -04:00
Ryan SkidmoreandAiden Cline 814a515a8a fix: improve plugin system robustness — agent/command resolution, async errors, hook timing, two-phase init (#18280)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-24 12:50:55 -05:00
Caleb NortonandAiden Cline 235a82aea9 chore: update flake.lock (#18976)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-24 12:50:25 -05:00
Vladimir Glafirov 9330bc5339 fix: route GitLab Duo Workflow system prompt via flowConfig (#18928) 2026-03-24 12:33:18 -05:00
Caleb Norton 1238d1f61a fix: nix hash update parsing (#18979) 2026-03-24 12:32:48 -05:00
opencode-agent[bot] 1d3232b388 chore: generate 2026-03-24 17:05:02 +00:00
Kit Langton 5c1bb5de86 fix: remove flaky cross-spawn spawner tests (#18977) 2026-03-24 13:04:04 -04:00
Jack 7c5ed771c3 fix: update Feishu community links for zh locales (#18975) 2026-03-25 01:03:01 +08:00
opencode-agent[bot] 31c4a4fb47 chore: update nix node_modules hashes 2026-03-24 16:43:24 +00:00
Caleb Norton 037077285a fix: better nix hash detection (#18957) 2026-03-24 11:30:39 -05:00
Kit Langton 41c77ccb33 fix: restore cross-spawn behavior for effect child processes (#18798) 2026-03-24 10:35:24 -04:00
Adam 546748a461 fix(app): startup efficiency (#18854) 2026-03-24 09:10:24 -05:00
Burak Yigit Kaya c9c93eac00 fix(ui): eliminate N+1 reactive subscriptions in SessionTurn (#18924) 2026-03-24 09:02:22 -05:00
Burak Yigit Kaya 3f1a4abe6d fix(app): use optional chaining for model.current() in ProviderIcon (#18927) 2026-03-24 09:01:58 -05:00
Burak Yigit Kaya 431e0586ad fix(app): filter non-renderable part types from browser store (#18926) 2026-03-24 09:01:25 -05:00
Shoubhit Dash fde201c286 fix(app): stop terminal autofocus on shortcuts (#18931) 2026-03-24 11:16:16 +00:00
Sebastian d3debc191f manually lock/unlock theme mode (#18905) 2026-03-24 10:00:19 +01:00
Frank 34f43fff89 sync 2026-03-24 01:00:20 -04:00
opencode-agent[bot] 49623aa519 chore: update nix node_modules hashes 2026-03-24 03:14:22 +00:00
Vladimir Glafirov f1340472ec chore: bump gitlab-ai-provider to 5.3.1 for GPT-5.4 model support (#18849) 2026-03-23 22:00:36 -05:00
Frank a8b28826a0 wip: zen 2026-03-23 22:24:58 -04:00
Frank a03a2b6eab Zen: adjust cache tokens 2026-03-23 20:33:11 -04:00
Sebastian ad78b79b8a use renderer theme mode to switch dark/light mode (#18851) 2026-03-24 00:32:48 +01:00
opencode-agent[bot] 9a006d8700 chore: generate 2026-03-23 17:12:55 +00:00
Kit Langton 3a0bf2f39f fix console account URL handling (#18809) 2026-03-23 13:11:38 -04:00
Frank b556979634 ci: fix 2026-03-23 12:47:42 -04:00
Aiden Cline 691644eeeb tweak: add back setting user agent in requests (#18795) 2026-03-23 15:34:59 +00:00
Abhishek Keshri 4aebaaf067 feat(tui): add syntax highlighting for kotlin, hcl, lua, toml (#18198) 2026-03-23 16:15:24 +01:00
David Hill 77b3b46788 tui: keep file tree open at its minimum resized width (#18777) 2026-03-23 20:06:43 +08:00
Brendan Allan 36dfe1646b fix(app): only navigate prompt history when input is empty (#18775) 2026-03-23 11:48:34 +00:00
opencode-agent[bot] 6926dc26d1 chore: update nix node_modules hashes 2026-03-23 10:52:56 +00:00
opencode-agent[bot] eb74e4a6d2 chore: update nix node_modules hashes 2026-03-23 10:37:23 +00:00
opencode-agent[bot] 85d8e143bf chore: generate 2026-03-23 10:35:30 +00:00
Brendan Allan 8e1b53b32c fix(app): handle session busy state better (#18758) 2026-03-23 10:34:32 +00:00
Brendan Allan 0a7dfc03ee fix(app): lift up project hover state to layout (#18732) 2026-03-23 08:58:20 +00:00
Brendan Allan 4c27e7fc64 electron: more robust sidecar kill handling (#18742) 2026-03-23 08:44:23 +00:00
Shoubhit Dash 0f5626d2e4 fix(app): prefer cmd+k for command palette (#18731) 2026-03-23 08:00:24 +00:00
Shoubhit Dash 5ea95451dd fix(app): prevent stale session hover preview on refocus (#18727) 2026-03-23 07:25:30 +00:00
Shoubhit Dash 9239d877b9 fix(app): batch multi-file prompt attachments (#18722) 2026-03-23 06:44:17 +00:00
github-actions[bot] fc68c24433 Update VOUCHED list
https://github.com/anomalyco/opencode/issues/18718#issuecomment-4108322776
2026-03-23 06:28:47 +00:00
Luke Parker db9619dad6 Add 'write' role to vouch manage action (#18718) 2026-03-23 06:27:35 +00:00
James Long 84d9b38873 fix(core): fix file watcher test (#18698) 2026-03-23 03:35:17 +00:00
opencode-agent[bot] 8035c3435b chore: update nix node_modules hashes 2026-03-23 01:03:20 +00:00
Sebastian 71e7603d71 Upgrade opentui to 0.1.90 (#18551) 2026-03-23 01:45:34 +01:00
David Hill 40e49c5b49 tui: keep patch tool counts visible with long filenames (#18678) 2026-03-23 00:45:11 +00:00
Luke Parker afe9b97274 fix(app): restore keyboard project switching in open sidebar (#18682) 2026-03-23 00:39:46 +00:00
opencode-agent[bot] 3b3549902d chore: update nix node_modules hashes 2026-03-23 00:29:45 +00:00
David Hill e9a9c75c1f tweak(ui): fix padding bottom on the context tab (#18680) 2026-03-23 00:23:45 +00:00
David HillandLukeParkerDev 2b171828b0 tui: prevent project avatar popover flicker when switching projects (#18660)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-03-23 10:20:49 +10:00
Luke Parker 8dd817023a chore: bump Bun to 1.3.11 (#18144) 2026-03-23 10:19:21 +10:00
Dax Raad 0d6c601365 changelog slash command 2026-03-22 19:45:23 -04:00
opencode-agent[bot] 5460bf9989 chore: generate 2026-03-22 23:32:09 +00:00
opencode eb3bfffad4 release: v1.3.0 2026-03-22 23:32:01 +00:00
Dax e2d03ce38c feat: interactive update flow for non-patch releases (#18662) 2026-03-22 23:12:40 +00:00
David Hill 32f9dc6383 fix(ui): stop auto close of sidebar on resize (#18647) 2026-03-23 08:53:12 +10:00
137 changed files with 4324 additions and 1858 deletions
+2 -1
View File
@@ -10,6 +10,8 @@
adamdotdevin adamdotdevin
-agusbasari29 AI PR slop -agusbasari29 AI PR slop
ariane-emory ariane-emory
-atharvau AI review spamming literally every PR
-danieljoshuanazareth
-danieljoshuanazareth -danieljoshuanazareth
edemaine edemaine
-florianleibert -florianleibert
@@ -23,4 +25,3 @@ r44vc0rp
rekram1-node rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team -spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr thdxr
-danieljoshuanazareth
+1 -1
View File
@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs: jobs:
deploy: deploy:
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
# Extract hash from build log with portability # Extract hash from build log with portability
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)" HASH="$(nix run --inputs-from . nixpkgs#gnugrep -- -oP 'got:\s*\Ksha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
if [ -z "$HASH" ]; then if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM}" echo "::error::Failed to compute hash for ${SYSTEM}"
+1 -1
View File
@@ -33,6 +33,6 @@ jobs:
with: with:
issue-id: ${{ github.event.issue.number }} issue-id: ${{ github.event.issue.number }}
comment-id: ${{ github.event.comment.id }} comment-id: ${{ github.event.comment.id }}
roles: admin,maintain roles: admin,maintain,write
env: env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }} GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
+5
View File
@@ -0,0 +1,5 @@
go through each PR merged since the last tag
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md
once that is done, read UPCOMING_CHANGELOG.md and group it into sections for better readability. make sure all PR references are preserved
+1 -1
View File
@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
--- ---
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode) **加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
+1 -1
View File
@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
--- ---
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode) **加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
+44 -32
View File
@@ -26,7 +26,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -41,6 +41,7 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1", "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
@@ -78,7 +79,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -112,7 +113,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -129,7 +130,7 @@
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "catalog:", "@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"@typescript/native-preview": "catalog:", "@typescript/native-preview": "catalog:",
"drizzle-kit": "catalog:", "drizzle-kit": "catalog:",
@@ -139,7 +140,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -163,7 +164,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -187,7 +188,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -220,7 +221,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -251,7 +252,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -280,7 +281,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -296,7 +297,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.2.27", "version": "1.3.1",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -337,8 +338,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4", "@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.88", "@opentui/core": "0.1.90",
"@opentui/solid": "0.1.88", "@opentui/solid": "0.1.90",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -357,7 +358,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.2.2", "gitlab-ai-provider": "5.3.2",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -369,6 +370,7 @@
"minimatch": "10.0.3", "minimatch": "10.0.3",
"open": "10.1.2", "open": "10.1.2",
"opencode-gitlab-auth": "2.0.0", "opencode-gitlab-auth": "2.0.0",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"remeda": "catalog:", "remeda": "catalog:",
@@ -420,7 +422,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -444,7 +446,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.2.27", "version": "1.3.1",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.90.10", "@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -455,7 +457,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -490,7 +492,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -536,7 +538,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -547,7 +549,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -611,7 +613,7 @@
"@tailwindcss/vite": "4.1.11", "@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.9", "@types/bun": "1.3.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
"@types/node": "22.13.9", "@types/node": "22.13.9",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
@@ -1447,21 +1449,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.88", "", { "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.88", "@opentui/core-darwin-x64": "0.1.88", "@opentui/core-linux-arm64": "0.1.88", "@opentui/core-linux-x64": "0.1.88", "@opentui/core-win32-arm64": "0.1.88", "@opentui/core-win32-x64": "0.1.88", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-eaDVZfAzZraddOIkgWSHMVkyaY0O20foYnPWKPQx1TY4t7G1oatIoan2zkytx67epW+4BZQ9vGib+61/uNM1MA=="], "@opentui/core": ["@opentui/core@0.1.90", "", { "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.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.88", "", { "os": "darwin", "cpu": "arm64" }, "sha512-oGRexWwZFeQJymOK5ORrLrwJUbPHMYaFa0EcLnlhvPnymm1xyMcRKm39ez0WSIdtiCCi/PmMHX95CfyyJB5VMA=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.88", "", { "os": "darwin", "cpu": "x64" }, "sha512-ddnruYpXt7gXsAqZoQzNrHtZ50niYQfESVT3rhE5qgsz7zoWBdKe/RxLKcb6zQmHMZML6SjSh0NrMG86lsH4dQ=="], "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.90", "", { "os": "darwin", "cpu": "x64" }, "sha512-vbDpUsnlZ+0CeVKyBBXE+l2+X1XoVncMxMOhXTiMtud2/Cwu+Vfs/g3LC/6Zv08yaytA+9g7Z8sdf0QCqFyQ4w=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.88", "", { "os": "linux", "cpu": "arm64" }, "sha512-jfcU/Sw8re3aWWb9cQ4OXmVNp/pchu6lgDRqvfy0EKTpzd7CNIu6a0xm+rcUKiPO7BrTrwtumT5/jZWWgCdHlg=="], "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.88", "", { "os": "linux", "cpu": "x64" }, "sha512-nyfilOYLu6XWRlPl1R0Y6WzdL+jVdIFnwShBWcZL+QC5HiJnQc6LKy5yX8uv0fVbY5xs1wBvlHVeUj1UwFQyFQ=="], "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.88", "", { "os": "win32", "cpu": "arm64" }, "sha512-jv/dQwcku7YZ4lNnYjivVvjPwTfDfzGfcplUqHxmirnv1Q1pZL1qS5wH1PV6RhAKN779vHTvnYMD4OgHWzqVaA=="], "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.88", "", { "os": "win32", "cpu": "x64" }, "sha512-saGvsQqwL8H7B0VBCQ+szMCKh9WIfTebOR8cwPa2+DR+1FnrEG2I4kiikoj4hfYfRMX18A0A11vQxSh3vvy8Ig=="], "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="],
"@opentui/solid": ["@opentui/solid@0.1.88", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.88", "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-hAqMBk3u/MnUapOmRPdMZinXPOFC+5ccmW1rEQRf9HpShRlZfyg9/u+wUI5rUavyeNFtka92Mtjf/N4AKQpwuA=="], "@opentui/solid": ["@opentui/solid@0.1.90", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.90", "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-zEHDpJOTGS707ts5j4diqoWuFLSqV6yARKl1H0FJkwWOotu+rxCyksL+C0gX0jJUonAw2cjlZ2NNtZY8g78zkg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="], "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1889,6 +1891,8 @@
"@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="], "@solid-primitives/storage": ["@solid-primitives/storage@4.3.3", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "@tauri-apps/plugin-store": "*", "solid-js": "^1.6.12" }, "optionalPeers": ["@tauri-apps/plugin-store"] }, "sha512-ACbNwMZ1s8VAvld6EUXkDkX/US3IhtlPLxg6+B2s9MwNUugwdd51I98LPEaHrdLpqPmyzqgoJe0TxEFlf3Dqrw=="],
"@solid-primitives/timer": ["@solid-primitives/timer@1.4.4", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ayjyb3+v1hyU92vuLUN0tVHq2mmTCPGxSDLGJMsDydRqx9ZfJIc9xj6cxK4XvdY3pif3ps2mIv52pjgToybEpQ=="],
"@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="], "@solid-primitives/trigger": ["@solid-primitives/trigger@1.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Za2JebEiDyfamjmDwRaESYqBBYOlgYGzB8kHYH0QrkXyLf2qNADlKdGN+z3vWSLCTDcKxChS43Kssjuc0OZhng=="],
"@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="], "@solid-primitives/utils": ["@solid-primitives/utils@6.4.0", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-AeGTBg8Wtkh/0s+evyLtP8piQoS4wyqqQaAFs2HJcFMMjYAtUgo+ZPduRXLjPlqKVc2ejeR544oeqpbn8Egn8A=="],
@@ -2057,7 +2061,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
"@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="],
@@ -2453,7 +2457,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
"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": ["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=="],
@@ -3033,7 +3037,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"gitlab-ai-provider": ["gitlab-ai-provider@5.2.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-ejwnie62rimfVHbjYZ2tsnqwLjF9YLgXD3OQA458gHz8hUvw7vEnhuyuMv5PmWQtyS3ISAghiX7r5SBhUWeCTA=="], "gitlab-ai-provider": ["gitlab-ai-provider@5.3.2", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-EiAipDMa4Ngsxp4MMaua5YHWsHhc9kGXKmBxulJg1Gueb+5IZmMwxaVtgWTGWZITxC3tzKEeRt/3U4McE2vTIA=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
@@ -3789,6 +3793,8 @@
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="], "opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
"opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
"opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="], "opencontrol": ["opencontrol@0.0.6", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.6.1", "@tsconfig/bun": "1.0.7", "hono": "4.7.4", "zod": "3.24.2", "zod-to-json-schema": "3.24.3" }, "bin": { "opencontrol": "bin/index.mjs" } }, "sha512-QeCrpOK5D15QV8kjnGVeD/BHFLwcVr+sn4T6KKmP0WAMs2pww56e4h+eOGHb5iPOufUQXbdbBKi6WV2kk7tefQ=="],
"openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="], "openid-client": ["openid-client@5.6.4", "", { "dependencies": { "jose": "^4.15.4", "lru-cache": "^6.0.0", "object-hash": "^2.2.0", "oidc-token-hash": "^5.0.3" } }, "sha512-T1h3B10BRPKfcObdBklX639tVz+xh34O7GjofqrqiAQdm7eHsQ00ih18x6wuJ/E6FxdtS2u3FmUGPDeEcMwzNA=="],
@@ -3921,6 +3927,8 @@
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
"poe-oauth": ["poe-oauth@0.0.3", "", {}, "sha512-KgxDylcuq/mov8URSplrBGjrIjkQwjN/Ml8BhqaGsAvHzYN3yhuROdv1sDRfwqncg7TT8XzJvMeJAWmv/4NDLw=="],
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
@@ -5541,6 +5549,8 @@
"opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], "opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencode-poe-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="], "opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="], "opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
@@ -6293,6 +6303,8 @@
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], "opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode-poe-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "opencode/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": { "nodes": {
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1772091128, "lastModified": 1773909469,
"narHash": "sha256-TnrYykX8Mf/Ugtkix6V+PjW7miU2yClA6uqWl/v6KWM=", "narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "3f0336406035444b4a24b942788334af5f906259", "rev": "7149c06513f335be57f26fcbbbe34afda923882b",
"type": "github" "type": "github"
}, },
"original": { "original": {
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-u+uZX7mhtm5eywGybB7/MjBMG2xl4Ve9VG33AAFgNno=", "x86_64-linux": "sha256-SXuq4e2Nl7qpTeTo9aOJqVhYdDdUPHAGUbRvKGN7bjo=",
"aarch64-linux": "sha256-pc1Xhd2bkwNohGMtzRnEuS5ZN1qWhJncYhNVAXega1g=", "aarch64-linux": "sha256-2i+Uhz4GjL/eHd2L9/N/igEeY4wwhtZ9qwR9xXnQKyc=",
"aarch64-darwin": "sha256-A5qUpqgm9ZFvWVhn/WdiX4lVs4ihbAclJDvCFAmx5Wg=", "aarch64-darwin": "sha256-s+v8PPzZYCm5Oa0pgUmBuGK9B3D5rFMcwLd9s1nBnkg=",
"x86_64-darwin": "sha256-ECLrMGE51AlYJ4JKDtziDKxhyK7WLt8R+8RVFdXH1WU=" "x86_64-darwin": "sha256-YDAY3KM1d01Yh8/YqVRZC1dMKBDegiPtUCPg3rsZ1oo="
} }
} }
+2 -2
View File
@@ -4,7 +4,7 @@
"description": "AI-powered development tool", "description": "AI-powered development tool",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "bun@1.3.10", "packageManager": "bun@1.3.11",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:desktop": "bun --cwd packages/desktop tauri dev",
@@ -26,7 +26,7 @@
], ],
"catalog": { "catalog": {
"@effect/platform-node": "4.0.0-beta.35", "@effect/platform-node": "4.0.0-beta.35",
"@types/bun": "1.3.9", "@types/bun": "1.3.11",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"ulid": "3.0.1", "ulid": "3.0.1",
+2 -2
View File
@@ -175,9 +175,9 @@ export async function runTerminal(page: Page, input: { cmd: string; token: strin
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true) await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
} }
export async function openPalette(page: Page) { export async function openPalette(page: Page, key = "K") {
await defocus(page) await defocus(page)
await page.keyboard.press(`${modKey}+P`) await page.keyboard.press(`${modKey}+${key}`)
const dialog = page.getByRole("dialog") const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible() await expect(dialog).toBeVisible()
+10 -1
View File
@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openPalette } from "../actions" import { closeDialog, openPalette } from "../actions"
test("search palette opens and closes", async ({ page, gotoSession }) => { test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
@@ -9,3 +9,12 @@ test("search palette opens and closes", async ({ page, gotoSession }) => {
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0) await expect(dialog).toHaveCount(0)
}) })
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page, "P")
await closeDialog(page, dialog)
await expect(dialog).toHaveCount(0)
})
@@ -108,7 +108,10 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await page.keyboard.type(draft) await page.keyboard.type(draft)
await wait(page, draft) await wait(page, draft)
await edge(page, "start") // Clear the draft before navigating history (ArrowUp only works when prompt is empty)
await prompt.fill("")
await wait(page, "")
await page.keyboard.press("ArrowUp") await page.keyboard.press("ArrowUp")
await wait(page, second) await wait(page, second)
@@ -119,7 +122,7 @@ test("prompt history restores unsent draft with arrow navigation", async ({ page
await wait(page, second) await wait(page, second)
await page.keyboard.press("ArrowDown") await page.keyboard.press("ArrowDown")
await wait(page, draft) await wait(page, "")
}) })
}) })
@@ -241,7 +241,7 @@ test("changing file open keybind works", async ({ page, gotoSession }) => {
await expect(keybindButton).toBeVisible() await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent() const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P") expect(initialKeybind).toContain("K")
await keybindButton.click() await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i) await expect(keybindButton).toHaveText(/press/i)
@@ -1,6 +1,16 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { cleanupSession, closeSidebar, hoverSessionItem } from "../actions" import {
defocus,
cleanupSession,
cleanupTestProject,
closeSidebar,
createTestProject,
hoverSessionItem,
openSidebar,
waitSession,
} from "../actions"
import { projectSwitchSelector } from "../selectors" import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => { test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now() const stamp = Date.now()
@@ -37,3 +47,72 @@ test("collapsed sidebar popover stays open when archiving a session", async ({ p
await cleanupSession({ sdk, sessionID: two.id }) await cleanupSession({ sdk, sessionID: two.id })
} }
}) })
test("open sidebar project popover stays closed after clicking avatar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
const card = page.locator('[data-component="hover-card-content"]')
await expect(project).toBeVisible()
await project.hover()
await expect(card.getByText(/recent sessions/i)).toBeVisible()
await page.mouse.down()
await expect(card).toHaveCount(0)
await page.mouse.up()
await waitSession(page, { directory: other })
await expect(card).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("open sidebar project switch activates on first tabbed enter", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const slug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
await defocus(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
let hit = false
for (let i = 0; i < 20; i++) {
hit = await project.evaluate((el) => {
return el.matches(":focus") || !!el.parentElement?.matches(":focus")
})
if (hit) break
await page.keyboard.press("Tab")
}
expect(hit).toBe(true)
await page.keyboard.press("Enter")
await waitSession(page, { directory: other })
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.2.27", "version": "1.3.1",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -51,6 +51,7 @@
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
"@solid-primitives/websocket": "1.3.1", "@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:", "@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:", "@solidjs/router": "catalog:",
+4 -4
View File
@@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { File } from "@opencode-ai/ui/file" import { File } from "@opencode-ai/ui/file"
import { Font } from "@opencode-ai/ui/font" import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo" import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme" import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta" import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
@@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file"
import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSDKProvider } from "@/context/global-sdk"
import { GlobalSyncProvider } from "@/context/global-sync" import { GlobalSyncProvider } from "@/context/global-sync"
import { HighlightsProvider } from "@/context/highlights" import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, useLanguage } from "@/context/language" import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout" import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models" import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification" import { NotificationProvider } from "@/context/notification"
@@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
) )
} }
export function AppBaseProviders(props: ParentProps) { export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
return ( return (
<MetaProvider> <MetaProvider>
<Font /> <Font />
@@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) {
void window.api?.setTitlebar?.({ mode }) void window.api?.setTitlebar?.({ mode })
}} }}
> >
<LanguageProvider> <LanguageProvider locale={props.locale}>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}> <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<QueryProvider> <QueryProvider>
@@ -1,4 +1,4 @@
import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
@@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { Spinner } from "@opencode-ai/ui/spinner" import { Spinner } from "@opencode-ai/ui/spinner"
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { Link } from "@/components/link" import { Link } from "@/components/link"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
@@ -34,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) {
}) })
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
const methods = createMemo( const fallback = createMemo<ProviderAuthMethod[]>(() => [
() => {
globalSync.data.provider_auth[props.provider] ?? [ type: "api" as const,
{ label: language.t("provider.connect.method.apiKey"),
type: "api", },
label: language.t("provider.connect.method.apiKey"), ])
}, const [auth] = createResource(
], () => props.provider,
async () => {
const cached = globalSync.data.provider_auth[props.provider]
if (cached) return cached
const res = await globalSDK.client.provider.auth()
if (!alive.value) return fallback()
globalSync.set("provider_auth", res.data ?? {})
return res.data?.[props.provider] ?? fallback()
},
) )
const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider])
const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback())
const [store, setStore] = createStore({ const [store, setStore] = createStore({
methodIndex: undefined as undefined | number, methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization, authorization: undefined as undefined | ProviderAuthAuthorization,
@@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) {
index: 0, index: 0,
}) })
const prompts = createMemo(() => method()?.prompts ?? []) const prompts = createMemo<NonNullable<ProviderAuthMethod["prompts"]>>(() => {
const value = method()
if (value?.type !== "oauth") return []
return value.prompts ?? []
})
const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => { const matches = (prompt: NonNullable<ReturnType<typeof prompts>[number]>, value: Record<string, string>) => {
if (!prompt.when) return true if (!prompt.when) return true
const actual = value[prompt.when.key] const actual = value[prompt.when.key]
@@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) {
listRef?.onKeyDown(e) listRef?.onKeyDown(e)
} }
onMount(() => { let auto = false
createEffect(() => {
if (auto) return
if (loading()) return
if (methods().length === 1) { if (methods().length === 1) {
auto = true
selectMethod(0) selectMethod(0)
} }
}) })
@@ -573,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
<div class="px-2.5 pb-10 flex flex-col gap-6"> <div class="px-2.5 pb-10 flex flex-col gap-6">
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}> <div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
<Switch> <Switch>
<Match when={loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={store.methodIndex === undefined}> <Match when={store.methodIndex === undefined}>
<MethodSelection /> <MethodSelection />
</Match> </Match>
+5 -8
View File
@@ -572,6 +572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const open = recent() const open = recent()
const seen = new Set(open) const seen = new Set(open)
const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true }))
if (!query.trim()) return [...agents, ...pinned]
const paths = await files.searchFilesAndDirectories(query) const paths = await files.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = paths const fileOptions: AtOption[] = paths
.filter((path) => !seen.has(path)) .filter((path) => !seen.has(path))
@@ -1043,7 +1044,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return true return true
} }
const { addAttachment, removeAttachment, handlePaste } = createPromptAttachments({ const { addAttachments, removeAttachment, handlePaste } = createPromptAttachments({
editor: () => editorRef, editor: () => editorRef,
isDialogActive: () => !!dialog.active, isDialogActive: () => !!dialog.active,
setDraggingType: (type) => setStore("draggingType", type), setDraggingType: (type) => setStore("draggingType", type),
@@ -1388,11 +1389,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="hidden" class="hidden"
onChange={(e) => { onChange={(e) => {
const list = e.currentTarget.files const list = e.currentTarget.files
if (list) { if (list) void addAttachments(Array.from(list))
for (const file of Array.from(list)) {
void addAttachment(file)
}
}
e.currentTarget.value = "" e.currentTarget.value = ""
}} }}
/> />
@@ -1501,7 +1498,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
id={local.model.current()!.provider.id} id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }} style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/> />
@@ -1533,7 +1530,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon <ProviderIcon
id={local.model.current()!.provider.id} id={local.model.current()?.provider?.id ?? ""}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150" class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }} style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/> />
@@ -71,6 +71,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const addAttachment = (file: File) => add(file) const addAttachment = (file: File) => add(file)
const addAttachments = async (files: File[], toast = true) => {
let found = false
for (const file of files) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && files.length > 0 && toast) warn()
return found
}
const removeAttachment = (id: string) => { const removeAttachment = (id: string) => {
const current = prompt.current() const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id) const next = current.filter((part) => part.type !== "image" || part.id !== id)
@@ -84,18 +96,14 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
const items = Array.from(clipboardData.items) const files = Array.from(clipboardData.items).flatMap((item) => {
const fileItems = items.filter((item) => item.kind === "file") if (item.kind !== "file") return []
const file = item.getAsFile()
return file ? [file] : []
})
if (fileItems.length > 0) { if (files.length > 0) {
let found = false await addAttachments(files)
for (const item of fileItems) {
const file = item.getAsFile()
if (!file) continue
const ok = await add(file, false)
if (ok) found = true
}
if (!found) warn()
return return
} }
@@ -169,12 +177,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
const dropped = event.dataTransfer?.files const dropped = event.dataTransfer?.files
if (!dropped) return if (!dropped) return
let found = false await addAttachments(Array.from(dropped))
for (const file of Array.from(dropped)) {
const ok = await add(file, false)
if (ok) found = true
}
if (!found && dropped.length > 0) warn()
} }
onMount(() => { onMount(() => {
@@ -191,6 +194,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
return { return {
addAttachment, addAttachment,
addAttachments,
removeAttachment, removeAttachment,
handlePaste, handlePaste,
} }
@@ -49,6 +49,32 @@ describe("buildRequestParts", () => {
expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true) expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
}) })
test("keeps multiple uploaded attachments in order", () => {
const result = buildRequestParts({
prompt: [{ type: "text", content: "check these", start: 0, end: 11 }],
context: [],
images: [
{ type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{
type: "image",
id: "img_2",
filename: "b.pdf",
mime: "application/pdf",
dataUrl: "data:application/pdf;base64,BBB",
},
],
text: "check these",
messageID: "msg_multi",
sessionID: "ses_multi",
sessionDirectory: "/repo",
})
const files = result.requestParts.filter((part) => part.type === "file" && part.url.startsWith("data:"))
expect(files).toHaveLength(2)
expect(files.map((part) => (part.type === "file" ? part.filename : ""))).toEqual(["a.png", "b.pdf"])
})
test("deduplicates context files when prompt already includes same path", () => { test("deduplicates context files when prompt already includes same path", () => {
const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }] const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
@@ -126,7 +126,7 @@ describe("prompt-input history", () => {
test("canNavigateHistoryAtCursor only allows prompt boundaries", () => { test("canNavigateHistoryAtCursor only allows prompt boundaries", () => {
const value = "a\nb\nc" const value = "a\nb\nc"
expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true) expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false) expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false)
expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false)
@@ -135,11 +135,14 @@ describe("prompt-input history", () => {
expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false) expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false)
expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true) expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 0)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true) expect(canNavigateHistoryAtCursor("down", "abc", 3)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false) expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false) expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(false)
expect(canNavigateHistoryAtCursor("up", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "", 0)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 0, true)).toBe(true)
expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true) expect(canNavigateHistoryAtCursor("up", "abc", 3, true)).toBe(true)
expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true) expect(canNavigateHistoryAtCursor("down", "abc", 0, true)).toBe(true)
@@ -27,7 +27,7 @@ export function canNavigateHistoryAtCursor(direction: "up" | "down", text: strin
const atStart = position === 0 const atStart = position === 0
const atEnd = position === text.length const atEnd = position === text.length
if (inHistory) return atStart || atEnd if (inHistory) return atStart || atEnd
if (direction === "up") return position === 0 if (direction === "up") return position === 0 && text.length === 0
return position === text.length return position === text.length
} }
@@ -267,14 +267,14 @@ export function SessionContextTab() {
return ( return (
<ScrollView <ScrollView
class="@container h-full pb-10" class="@container h-full"
viewportRef={(el) => { viewportRef={(el) => {
scroll = el scroll = el
restoreScroll() restoreScroll()
}} }}
onScroll={handleScroll} onScroll={handleScroll}
> >
<div class="px-6 pt-4 flex flex-col gap-10"> <div class="px-6 pt-4 pb-10 flex flex-col gap-10">
<div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4"> <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
<For each={stats}> <For each={stats}>
{(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />} {(stat) => <Stat label={language.t(stat.label as Parameters<typeof language.t>[0])} value={stat.value()} />}
@@ -1,27 +1,41 @@
import { Component, Show, createMemo, createResource, type JSX } from "solid-js" import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings" import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link" import { Link } from "./link"
import { SettingsList } from "./settings-list" import { SettingsList } from "./settings-list"
let demoSoundState = { let demoSoundState = {
cleanup: undefined as (() => void) | undefined, cleanup: undefined as (() => void) | undefined,
timeout: undefined as NodeJS.Timeout | undefined, timeout: undefined as NodeJS.Timeout | undefined,
run: 0,
}
type ThemeOption = {
id: string
name: string
}
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
function loadFont() {
font ??= import("@opencode-ai/ui/font-loader")
return font
} }
// To prevent audio from overlapping/playing very quickly when navigating the settings menus, // To prevent audio from overlapping/playing very quickly when navigating the settings menus,
// delay the playback by 100ms during quick selection changes and pause existing sounds. // delay the playback by 100ms during quick selection changes and pause existing sounds.
const stopDemoSound = () => { const stopDemoSound = () => {
demoSoundState.run += 1
if (demoSoundState.cleanup) { if (demoSoundState.cleanup) {
demoSoundState.cleanup() demoSoundState.cleanup()
} }
@@ -29,12 +43,19 @@ const stopDemoSound = () => {
demoSoundState.cleanup = undefined demoSoundState.cleanup = undefined
} }
const playDemoSound = (src: string | undefined) => { const playDemoSound = (id: string | undefined) => {
stopDemoSound() stopDemoSound()
if (!src) return if (!id) return
const run = ++demoSoundState.run
demoSoundState.timeout = setTimeout(() => { demoSoundState.timeout = setTimeout(() => {
demoSoundState.cleanup = playSound(src) void playSoundById(id).then((cleanup) => {
if (demoSoundState.run !== run) {
cleanup?.()
return
}
demoSoundState.cleanup = cleanup
})
}, 100) }, 100)
} }
@@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => {
const platform = usePlatform() const platform = usePlatform()
const settings = useSettings() const settings = useSettings()
onMount(() => {
void theme.loadThemes()
})
const [store, setStore] = createStore({ const [store, setStore] = createStore({
checking: false, checking: false,
}) })
@@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => {
.finally(() => setStore("checking", false)) .finally(() => setStore("checking", false))
} }
const themeOptions = createMemo(() => const themeOptions = createMemo<ThemeOption[]>(() => theme.ids().map((id) => ({ id, name: theme.name(id) })))
Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })),
)
const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [
{ value: "system", label: language.t("theme.scheme.system") }, { value: "system", label: language.t("theme.scheme.system") },
@@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => {
] as const ] as const
const fontOptionsList = [...fontOptions] const fontOptionsList = [...fontOptions]
const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const const noneSound = { id: "none", label: "sound.option.none" } as const
const soundOptions = [noneSound, ...SOUND_OPTIONS] const soundOptions = [noneSound, ...SOUND_OPTIONS]
const soundSelectProps = ( const soundSelectProps = (
@@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => {
label: (o: (typeof soundOptions)[number]) => language.t(o.label), label: (o: (typeof soundOptions)[number]) => language.t(o.label),
onHighlight: (option: (typeof soundOptions)[number] | undefined) => { onHighlight: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return if (!option) return
playDemoSound(option.src) playDemoSound(option.id === "none" ? undefined : option.id)
}, },
onSelect: (option: (typeof soundOptions)[number] | undefined) => { onSelect: (option: (typeof soundOptions)[number] | undefined) => {
if (!option) return if (!option) return
@@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => {
} }
setEnabled(true) setEnabled(true)
set(option.id) set(option.id)
playDemoSound(option.src) playDemoSound(option.id)
}, },
variant: "secondary" as const, variant: "secondary" as const,
size: "small" as const, size: "small" as const,
@@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => {
current={fontOptionsList.find((o) => o.value === settings.appearance.font())} current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value} value={(o) => o.value}
label={(o) => language.t(o.label)} label={(o) => language.t(o.label)}
onHighlight={(option) => {
void loadFont().then((x) => x.ensureMonoFont(option?.value))
}}
onSelect={(option) => option && settings.appearance.setFont(option.value)} onSelect={(option) => option && settings.appearance.setFont(option.value)}
variant="secondary" variant="secondary"
size="small" size="small"
+19 -4
View File
@@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { DialogSelectServer } from "./dialog-select-server"
const pollMs = 10_000 const pollMs = 10_000
@@ -54,11 +53,15 @@ const listServersByHealth = (
}) })
} }
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>) => { const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
const checkServerHealth = useCheckServerHealth() const checkServerHealth = useCheckServerHealth()
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>) const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
createEffect(() => { createEffect(() => {
if (!enabled()) {
setStatus(reconcile({}))
return
}
const list = servers() const list = servers()
let dead = false let dead = false
@@ -162,6 +165,12 @@ export function StatusPopover() {
const navigate = useNavigate() const navigate = useNavigate()
const [shown, setShown] = createSignal(false) const [shown, setShown] = createSignal(false)
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const servers = createMemo(() => { const servers = createMemo(() => {
const current = server.current const current = server.current
const list = server.list const list = server.list
@@ -169,7 +178,7 @@ export function StatusPopover() {
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
}) })
const health = useServerHealth(servers) const health = useServerHealth(servers, shown)
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
const toggleMcp = useMcpToggleMutation() const toggleMcp = useMcpToggleMutation()
const defaultServer = useDefaultServerKey(platform.getDefaultServer) const defaultServer = useDefaultServerKey(platform.getDefaultServer)
@@ -300,7 +309,13 @@ export function StatusPopover() {
<Button <Button
variant="secondary" variant="secondary"
class="mt-3 self-start h-8 px-3 py-1.5" class="mt-3 self-start h-8 px-3 py-1.5"
onClick={() => dialog.show(() => <DialogSelectServer />, defaultServer.refresh)} onClick={() => {
const run = ++dialogRun
void import("./dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
})
}}
> >
{language.t("status.popover.action.manageServers")} {language.t("status.popover.action.manageServers")}
</Button> </Button>
+4 -1
View File
@@ -1,4 +1,7 @@
import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" import { withAlpha } from "@opencode-ai/ui/theme/color"
import { useTheme } from "@opencode-ai/ui/theme/context"
import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve"
import type { HexColor } from "@opencode-ai/ui/theme/types"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web"
import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js"
+1 -1
View File
@@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme" import { useTheme } from "@opencode-ai/ui/theme/context"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
@@ -40,4 +40,11 @@ describe("command keybind helpers", () => {
expect(display.includes("Alt") || display.includes("⌥")).toBe(true) expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
expect(formatKeybind("none")).toBe("") expect(formatKeybind("none")).toBe("")
}) })
test("formatKeybind prefers the first combo", () => {
const display = formatKeybind("mod+k,mod+p")
expect(display.includes("K") || display.includes("k")).toBe(true)
expect(display.includes("P") || display.includes("p")).toBe(false)
})
}) })
+28 -30
View File
@@ -9,17 +9,7 @@ import type {
} from "@opencode-ai/sdk/v2/client" } from "@opencode-ai/sdk/v2/client"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path" import { getFilename } from "@opencode-ai/util/path"
import { import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js"
createContext,
getOwner,
Match,
onCleanup,
onMount,
type ParentProps,
Switch,
untrack,
useContext,
} from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store" import { createStore, produce, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
@@ -80,6 +70,8 @@ function createGlobalSync() {
let active = true let active = true
let projectWritten = false let projectWritten = false
let bootedAt = 0
let bootingRoot = false
onCleanup(() => { onCleanup(() => {
active = false active = false
@@ -258,6 +250,11 @@ function createGlobalSync() {
const sdk = sdkFor(directory) const sdk = sdkFor(directory)
await bootstrapDirectory({ await bootstrapDirectory({
directory, directory,
global: {
config: globalStore.config,
project: globalStore.project,
provider: globalStore.provider,
},
sdk, sdk,
store: child[0], store: child[0],
setStore: child[1], setStore: child[1],
@@ -278,15 +275,20 @@ function createGlobalSync() {
const unsub = globalSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const event = e.details const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500
if (directory === "global") { if (directory === "global") {
applyGlobalEvent({ applyGlobalEvent({
event, event,
project: globalStore.project, project: globalStore.project,
refresh: queue.refresh, refresh: () => {
if (recent) return
queue.refresh()
},
setGlobalProject: setProjects, setGlobalProject: setProjects,
}) })
if (event.type === "server.connected" || event.type === "global.disposed") { if (event.type === "server.connected" || event.type === "global.disposed") {
if (recent) return
for (const directory of Object.keys(children.children)) { for (const directory of Object.keys(children.children)) {
queue.push(directory) queue.push(directory)
} }
@@ -325,17 +327,19 @@ function createGlobalSync() {
}) })
async function bootstrap() { async function bootstrap() {
await bootstrapGlobal({ bootingRoot = true
globalSDK: globalSDK.client, try {
connectErrorTitle: language.t("dialog.server.add.error"), await bootstrapGlobal({
connectErrorDescription: language.t("error.globalSync.connectFailed", { globalSDK: globalSDK.client,
url: globalSDK.url, requestFailedTitle: language.t("common.requestFailed"),
}), translate: language.t,
requestFailedTitle: language.t("common.requestFailed"), formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }),
translate: language.t, setGlobalStore: setBootStore,
formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), })
setGlobalStore: setBootStore, bootedAt = Date.now()
}) } finally {
bootingRoot = false
}
} }
onMount(() => { onMount(() => {
@@ -392,13 +396,7 @@ const GlobalSyncContext = createContext<ReturnType<typeof createGlobalSync>>()
export function GlobalSyncProvider(props: ParentProps) { export function GlobalSyncProvider(props: ParentProps) {
const value = createGlobalSync() const value = createGlobalSync()
return ( return <GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
<Switch>
<Match when={value.ready}>
<GlobalSyncContext.Provider value={value}>{props.children}</GlobalSyncContext.Provider>
</Match>
</Switch>
)
} }
export function useGlobalSync() { export function useGlobalSync() {
+207 -132
View File
@@ -31,73 +31,102 @@ type GlobalStore = {
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
} }
function waitForPaint() {
return new Promise<void>((resolve) => {
let done = false
const finish = () => {
if (done) return
done = true
resolve()
}
const timer = setTimeout(finish, 50)
if (typeof requestAnimationFrame !== "function") return
requestAnimationFrame(() => {
clearTimeout(timer)
finish()
})
})
}
function errors(list: PromiseSettledResult<unknown>[]) {
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
}
function runAll(list: Array<() => Promise<unknown>>) {
return Promise.allSettled(list.map((item) => item()))
}
function showErrors(input: {
errors: unknown[]
title: string
translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string
}) {
if (input.errors.length === 0) return
const message = formatServerError(input.errors[0], input.translate)
const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : ""
showToast({
variant: "error",
title: input.title,
description: message + more,
})
}
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
globalSDK: OpencodeClient globalSDK: OpencodeClient
connectErrorTitle: string
connectErrorDescription: string
requestFailedTitle: string requestFailedTitle: string
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
formatMoreCount: (count: number) => string formatMoreCount: (count: number) => string
setGlobalStore: SetStoreFunction<GlobalStore> setGlobalStore: SetStoreFunction<GlobalStore>
}) { }) {
const health = await input.globalSDK.global const fast = [
.health() () =>
.then((x) => x.data) retry(() =>
.catch(() => undefined) input.globalSDK.path.get().then((x) => {
if (!health?.healthy) { input.setGlobalStore("path", x.data!)
showToast({ }),
variant: "error", ),
title: input.connectErrorTitle, () =>
description: input.connectErrorDescription, retry(() =>
}) input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("ready", true) input.setGlobalStore("config", x.data!)
return }),
} ),
() =>
const tasks = [ retry(() =>
retry(() => input.globalSDK.provider.list().then((x) => {
input.globalSDK.path.get().then((x) => { input.setGlobalStore("provider", normalizeProviderList(x.data!))
input.setGlobalStore("path", x.data!) }),
}), ),
),
retry(() =>
input.globalSDK.global.config.get().then((x) => {
input.setGlobalStore("config", x.data!)
}),
),
retry(() =>
input.globalSDK.project.list().then((x) => {
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
input.setGlobalStore("project", projects)
}),
),
retry(() =>
input.globalSDK.provider.list().then((x) => {
input.setGlobalStore("provider", normalizeProviderList(x.data!))
}),
),
retry(() =>
input.globalSDK.provider.auth().then((x) => {
input.setGlobalStore("provider_auth", x.data ?? {})
}),
),
] ]
const results = await Promise.allSettled(tasks) const slow = [
const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) () =>
if (errors.length) { retry(() =>
const message = formatServerError(errors[0], input.translate) input.globalSDK.project.list().then((x) => {
const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" const projects = (x.data ?? [])
showToast({ .filter((p) => !!p?.id)
variant: "error", .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
title: input.requestFailedTitle, .slice()
description: message + more, .sort((a, b) => cmp(a.id, b.id))
}) input.setGlobalStore("project", projects)
} }),
),
]
showErrors({
errors: errors(await runAll(fast)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
await waitForPaint()
showErrors({
errors: errors(await runAll(slow)),
title: input.requestFailedTitle,
translate: input.translate,
formatMoreCount: input.formatMoreCount,
})
input.setGlobalStore("ready", true) input.setGlobalStore("ready", true)
} }
@@ -111,6 +140,10 @@ function groupBySession<T extends { id: string; sessionID: string }>(input: T[])
}, {}) }, {})
} }
function projectID(directory: string, projects: Project[]) {
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
}
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
directory: string directory: string
sdk: OpencodeClient sdk: OpencodeClient
@@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: {
vcsCache: VcsCache vcsCache: VcsCache
loadSessions: (directory: string) => Promise<void> | void loadSessions: (directory: string) => Promise<void> | void
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
}) { global: {
if (input.store.status !== "complete") input.setStore("status", "loading") config: Config
project: Project[]
const blockingRequests = { provider: ProviderListResponse
project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
provider: () =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
} }
}) {
const loading = input.store.status !== "complete"
const seededProject = projectID(input.directory, input.global.project)
if (seededProject) input.setStore("project", seededProject)
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", input.global.config)
}
if (loading) input.setStore("status", "partial")
try { const fast = [
await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) () =>
} catch (err) { seededProject
console.error("Failed to bootstrap instance", err) ? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() =>
retry(() =>
input.sdk.path.get().then((x) => {
input.setStore("path", x.data!)
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
if (next) input.setStore("project", next)
}),
),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
),
() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))),
() =>
retry(() =>
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
),
() =>
retry(() =>
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
),
]
const slow = [
() =>
retry(() =>
input.sdk.provider.list().then((x) => {
input.setStore("provider", normalizeProviderList(x.data!))
}),
),
() => Promise.resolve(input.loadSessions(input.directory)),
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
]
const errs = errors(await runAll(fast))
if (errs.length > 0) {
console.error("Failed to bootstrap instance", errs[0])
const project = getFilename(input.directory) const project = getFilename(input.directory)
showToast({ showToast({
variant: "error", variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }), title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(err, input.translate), description: formatServerError(errs[0], input.translate),
}) })
input.setStore("status", "partial")
return
} }
if (input.store.status !== "complete") input.setStore("status", "partial") await waitForPaint()
const slowErrs = errors(await runAll(slow))
if (slowErrs.length > 0) {
console.error("Failed to finish bootstrap instance", slowErrs[0])
const project = getFilename(input.directory)
showToast({
variant: "error",
title: input.translate("toast.project.reloadFailed.title", { project }),
description: formatServerError(slowErrs[0], input.translate),
})
}
Promise.all([ if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
input.loadSessions(input.directory),
input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
input.sdk.vcs.get().then((x) => {
const next = x.data ?? input.store.vcs
input.setStore("vcs", next)
if (next?.branch) input.vcsCache.setStore("value", next)
}),
input.sdk.permission.list().then((x) => {
const grouped = groupBySession(
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
)
batch(() => {
for (const sessionID of Object.keys(input.store.permission)) {
if (grouped[sessionID]) continue
input.setStore("permission", sessionID, [])
}
for (const [sessionID, permissions] of Object.entries(grouped)) {
input.setStore(
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
input.sdk.question.list().then((x) => {
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
batch(() => {
for (const sessionID of Object.keys(input.store.question)) {
if (grouped[sessionID]) continue
input.setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
input.setStore(
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
input.setStore("status", "complete")
})
} }
@@ -15,6 +15,8 @@ import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim" import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache" import { dropSessionCaches } from "./session-cache"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
export function applyGlobalEvent(input: { export function applyGlobalEvent(input: {
event: { type: string; properties?: unknown } event: { type: string; properties?: unknown }
project: Project[] project: Project[]
@@ -211,6 +213,7 @@ export function applyDirectoryEvent(input: {
} }
case "message.part.updated": { case "message.part.updated": {
const part = (event.properties as { part: Part }).part const part = (event.properties as { part: Part }).part
if (SKIP_PARTS.has(part.type)) break
const parts = input.store.part[part.messageID] const parts = input.store.part[part.messageID]
if (!parts) { if (!parts) {
input.setStore("part", part.messageID, [part]) input.setStore("part", part.messageID, [part])
+66 -78
View File
@@ -1,42 +1,10 @@
import * as i18n from "@solid-primitives/i18n" import * as i18n from "@solid-primitives/i18n"
import { createEffect, createMemo } from "solid-js" import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
import { dict as ko } from "@/i18n/ko"
import { dict as de } from "@/i18n/de"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as da } from "@/i18n/da"
import { dict as ja } from "@/i18n/ja"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as ar } from "@/i18n/ar"
import { dict as no } from "@/i18n/no"
import { dict as br } from "@/i18n/br"
import { dict as th } from "@/i18n/th"
import { dict as bs } from "@/i18n/bs"
import { dict as tr } from "@/i18n/tr"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en" import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
import { dict as uiKo } from "@opencode-ai/ui/i18n/ko"
import { dict as uiDe } from "@opencode-ai/ui/i18n/de"
import { dict as uiEs } from "@opencode-ai/ui/i18n/es"
import { dict as uiFr } from "@opencode-ai/ui/i18n/fr"
import { dict as uiDa } from "@opencode-ai/ui/i18n/da"
import { dict as uiJa } from "@opencode-ai/ui/i18n/ja"
import { dict as uiPl } from "@opencode-ai/ui/i18n/pl"
import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
import { dict as uiTr } from "@opencode-ai/ui/i18n/tr"
export type Locale = export type Locale =
| "en" | "en"
@@ -59,6 +27,7 @@ export type Locale =
type RawDictionary = typeof en & typeof uiEn type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary> type Dictionary = i18n.Flatten<RawDictionary>
type Source = { dict: Record<string, string> }
function cookie(locale: Locale) { function cookie(locale: Locale) {
return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax`
@@ -125,24 +94,43 @@ const LABEL_KEY: Record<Locale, keyof Dictionary> = {
} }
const base = i18n.flatten({ ...en, ...uiEn }) const base = i18n.flatten({ ...en, ...uiEn })
const DICT: Record<Locale, Dictionary> = { const dicts = new Map<Locale, Dictionary>([["en", base]])
en: base,
zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }, const merge = (app: Promise<Source>, ui: Promise<Source>) =>
zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }, Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary)
ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) },
de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) }, const loaders: Record<Exclude<Locale, "en">, () => Promise<Dictionary>> = {
es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) }, zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")),
fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }, zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")),
da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) }, ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")),
ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }, de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")),
pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }, es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")),
ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }, fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")),
ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }, da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")),
no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) }, ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")),
br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) }, pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")),
th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) }, ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")),
bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }, ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")),
tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) }, no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")),
br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")),
th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")),
bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")),
tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")),
}
function loadDict(locale: Locale) {
const hit = dicts.get(locale)
if (hit) return Promise.resolve(hit)
if (locale === "en") return Promise.resolve(base)
const load = loaders[locale]
return load().then((next: Dictionary) => {
dicts.set(locale, next)
return next
})
}
export function loadLocaleDict(locale: Locale) {
return loadDict(locale).then(() => undefined)
} }
const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [
@@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole
{ locale: "tr", match: (language) => language.startsWith("tr") }, { locale: "tr", match: (language) => language.startsWith("tr") },
] ]
type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
const PARITY_CHECK: Record<Exclude<Locale, "en">, Record<ParityKey, string>> = {
zh,
zht,
ko,
de,
es,
fr,
da,
ja,
pl,
ru,
ar,
no,
br,
th,
bs,
tr,
}
void PARITY_CHECK
function detectLocale(): Locale { function detectLocale(): Locale {
if (typeof navigator !== "object") return "en" if (typeof navigator !== "object") return "en"
@@ -203,27 +170,48 @@ function detectLocale(): Locale {
return "en" return "en"
} }
function normalizeLocale(value: string): Locale { export function normalizeLocale(value: string): Locale {
return LOCALES.includes(value as Locale) ? (value as Locale) : "en" return LOCALES.includes(value as Locale) ? (value as Locale) : "en"
} }
function readStoredLocale() {
if (typeof localStorage !== "object") return
try {
const raw = localStorage.getItem("opencode.global.dat:language")
if (!raw) return
const next = JSON.parse(raw) as { locale?: string }
if (typeof next?.locale !== "string") return
return normalizeLocale(next.locale)
} catch {
return
}
}
const warm = readStoredLocale() ?? detectLocale()
if (warm !== "en") void loadDict(warm)
export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({
name: "Language", name: "Language",
init: () => { init: (props: { locale?: Locale }) => {
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
Persist.global("language", ["language.v1"]), Persist.global("language", ["language.v1"]),
createStore({ createStore({
locale: detectLocale() as Locale, locale: initial,
}), }),
) )
const locale = createMemo<Locale>(() => normalizeLocale(store.locale)) const locale = createMemo<Locale>(() => normalizeLocale(store.locale))
console.log("locale", locale())
const intl = createMemo(() => INTL[locale()]) const intl = createMemo(() => INTL[locale()])
const dict = createMemo<Dictionary>(() => DICT[locale()]) const [dict] = createResource(locale, loadDict, {
initialValue: dicts.get(initial) ?? base,
})
const t = i18n.translator(dict, i18n.resolveTemplate) const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as (
key: keyof Dictionary,
params?: Record<string, string | number | boolean>,
) => string
const label = (value: Locale) => t(LABEL_KEY[value]) const label = (value: Locale) => t(LABEL_KEY[value])
+3 -3
View File
@@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2" import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound" import { playSoundById } from "@/utils/sound"
type NotificationBase = { type NotificationBase = {
directory?: string directory?: string
@@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session.parentID) return if (session.parentID) return
if (settings.sounds.agentEnabled()) { if (settings.sounds.agentEnabled()) {
playSound(soundSrc(settings.sounds.agent())) void playSoundById(settings.sounds.agent())
} }
append({ append({
@@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
if (session?.parentID) return if (session?.parentID) return
if (settings.sounds.errorsEnabled()) { if (settings.sounds.errorsEnabled()) {
playSound(soundSrc(settings.sounds.errors())) void playSoundById(settings.sounds.errors())
} }
const error = "error" in event.properties ? event.properties.error : undefined const error = "error" in event.properties ? event.properties.error : undefined
+12 -1
View File
@@ -104,6 +104,13 @@ function withFallback<T>(read: () => T | undefined, fallback: T) {
return createMemo(() => read() ?? fallback) return createMemo(() => read() ?? fallback)
} }
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
function loadFont() {
font ??= import("@opencode-ai/ui/font-loader")
return font
}
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
name: "Settings", name: "Settings",
init: () => { init: () => {
@@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => { createEffect(() => {
if (typeof document === "undefined") return if (typeof document === "undefined") return
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) const id = store.appearance?.font ?? defaultSettings.appearance.font
if (id !== defaultSettings.appearance.font) {
void loadFont().then((x) => x.ensureMonoFont(id))
}
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
}) })
return { return {
+8 -4
View File
@@ -14,6 +14,8 @@ import { useSDK } from "./sdk"
import type { Message, Part } from "@opencode-ai/sdk/v2/client" import type { Message, Part } from "@opencode-ai/sdk/v2/client"
import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache" import { SESSION_CACHE_LIMIT, dropSessionCaches, pickSessionCacheEvictions } from "./global-sync/session-cache"
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
function sortParts(parts: Part[]) { function sortParts(parts: Part[]) {
return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)) return parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id))
} }
@@ -178,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return globalSync.child(directory) return globalSync.child(directory)
} }
const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/")
const messagePageSize = 200 const initialMessagePageSize = 80
const historyMessagePageSize = 200
const inflight = new Map<string, Promise<void>>() const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>() const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>() const inflightTodo = new Map<string, Promise<void>>()
@@ -336,7 +339,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
batch(() => { batch(() => {
input.setStore("message", input.sessionID, reconcile(message, { key: "id" })) input.setStore("message", input.sessionID, reconcile(message, { key: "id" }))
for (const p of next.part) { for (const p of next.part) {
input.setStore("part", p.id, p.part) const filtered = p.part.filter((x) => !SKIP_PARTS.has(x.type))
if (filtered.length) input.setStore("part", p.id, filtered)
} }
setMeta("limit", key, message.length) setMeta("limit", key, message.length)
setMeta("cursor", key, next.cursor) setMeta("cursor", key, next.cursor)
@@ -460,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined
if (cached && hasSession && !opts?.force) return if (cached && hasSession && !opts?.force) return
const limit = meta.limit[key] ?? messagePageSize const limit = meta.limit[key] ?? initialMessagePageSize
const sessionReq = const sessionReq =
hasSession && !opts?.force hasSession && !opts?.force
? Promise.resolve() ? Promise.resolve()
@@ -557,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const [, setStore] = globalSync.child(directory) const [, setStore] = globalSync.child(directory)
touch(directory, setStore, sessionID) touch(directory, setStore, sessionID)
const key = keyFor(directory, sessionID) const key = keyFor(directory, sessionID)
const step = count ?? messagePageSize const step = count ?? historyMessagePageSize
if (meta.loading[key]) return if (meta.loading[key]) return
if (meta.complete[key]) return if (meta.complete[key]) return
const before = meta.cursor[key] const before = meta.cursor[key]
+12 -39
View File
@@ -1,45 +1,18 @@
import { dict as ar } from "@/i18n/ar" const template = "Terminal {{number}}"
import { dict as br } from "@/i18n/br"
import { dict as bs } from "@/i18n/bs"
import { dict as da } from "@/i18n/da"
import { dict as de } from "@/i18n/de"
import { dict as en } from "@/i18n/en"
import { dict as es } from "@/i18n/es"
import { dict as fr } from "@/i18n/fr"
import { dict as ja } from "@/i18n/ja"
import { dict as ko } from "@/i18n/ko"
import { dict as no } from "@/i18n/no"
import { dict as pl } from "@/i18n/pl"
import { dict as ru } from "@/i18n/ru"
import { dict as th } from "@/i18n/th"
import { dict as tr } from "@/i18n/tr"
import { dict as zh } from "@/i18n/zh"
import { dict as zht } from "@/i18n/zht"
const numbered = Array.from( const numbered = [
new Set([ template,
en["terminal.title.numbered"], "محطة طرفية {{number}}",
ar["terminal.title.numbered"], "Терминал {{number}}",
br["terminal.title.numbered"], "ターミナル {{number}}",
bs["terminal.title.numbered"], "터미널 {{number}}",
da["terminal.title.numbered"], "เทอร์มินัล {{number}}",
de["terminal.title.numbered"], "终端 {{number}}",
es["terminal.title.numbered"], "終端機 {{number}}",
fr["terminal.title.numbered"], ]
ja["terminal.title.numbered"],
ko["terminal.title.numbered"],
no["terminal.title.numbered"],
pl["terminal.title.numbered"],
ru["terminal.title.numbered"],
th["terminal.title.numbered"],
tr["terminal.title.numbered"],
zh["terminal.title.numbered"],
zht["terminal.title.numbered"],
]),
)
export function defaultTitle(number: number) { export function defaultTitle(number: number) {
return en["terminal.title.numbered"].replace("{{number}}", String(number)) return template.replace("{{number}}", String(number))
} }
export function isDefaultTitle(title: string, number: number) { export function isDefaultTitle(title: string, number: number) {
+8 -3
View File
@@ -97,10 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) {
throw new Error(getRootNotFoundError()) throw new Error(getRootNotFoundError())
} }
const localUrl = () =>
`http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname)
const getCurrentUrl = () => { const getCurrentUrl = () => {
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" if (location.hostname.includes("opencode.ai")) return localUrl()
if (import.meta.env.DEV) if (import.meta.env.DEV) return localUrl()
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` if (isLocalHost()) return localUrl()
return location.origin return location.origin
} }
+1 -1
View File
@@ -22,7 +22,7 @@ export function useProviders() {
const providers = () => { const providers = () => {
if (dir()) { if (dir()) {
const [projectStore] = globalSync.child(dir()) const [projectStore] = globalSync.child(dir())
return projectStore.provider if (projectStore.provider.all.length > 0) return projectStore.provider
} }
return globalSync.data.provider return globalSync.data.provider
} }
+1 -1
View File
@@ -276,7 +276,7 @@ export const dict = {
"prompt.context.includeActiveFile": "Include active file", "prompt.context.includeActiveFile": "Include active file",
"prompt.context.removeActiveFile": "Remove active file from context", "prompt.context.removeActiveFile": "Remove active file from context",
"prompt.context.removeFile": "Remove file from context", "prompt.context.removeFile": "Remove file from context",
"prompt.action.attachFile": "Add file", "prompt.action.attachFile": "Add files",
"prompt.attachment.remove": "Remove attachment", "prompt.attachment.remove": "Remove attachment",
"prompt.action.send": "Send", "prompt.action.send": "Send",
"prompt.action.stop": "Stop", "prompt.action.stop": "Stop",
+1
View File
@@ -1,6 +1,7 @@
export { AppBaseProviders, AppInterface } from "./app" export { AppBaseProviders, AppInterface } from "./app"
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
export { useCommand } from "./context/command" export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform"
export { ServerConnection } from "./context/server" export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click" export { handleNotificationClick } from "./utils/notification-click"
+29 -41
View File
@@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode" import { base64Encode } from "@opencode-ai/util/encode"
import { useLocation, useNavigate, useParams } from "@solidjs/router" import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { createMemo, createResource, type ParentProps, Show } from "solid-js" import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { LocalProvider } from "@/context/local" import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk" import { SDKProvider } from "@/context/sdk"
@@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const sync = useSync() const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory)) const slug = createMemo(() => base64Encode(props.directory))
createEffect(() => {
const next = sync.data.path.directory
if (!next || next === props.directory) return
const path = location.pathname.slice(slug().length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
return ( return (
<DataProvider <DataProvider
data={sync.data} data={sync.data}
@@ -29,50 +36,31 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) { export default function Layout(props: ParentProps) {
const params = useParams() const params = useParams()
const location = useLocation()
const language = useLanguage() const language = useLanguage()
const globalSDK = useGlobalSDK()
const navigate = useNavigate() const navigate = useNavigate()
let invalid = "" let invalid = ""
const [resolved] = createResource( const resolved = createMemo(() => {
() => { if (!params.dir) return ""
if (params.dir) return [location.pathname, params.dir] as const return decode64(params.dir) ?? ""
}, })
async ([pathname, b64Dir]) => {
const directory = decode64(b64Dir)
if (!directory) { createEffect(() => {
if (invalid === params.dir) return const dir = params.dir
invalid = b64Dir if (!dir) return
showToast({ if (resolved()) {
variant: "error", invalid = ""
title: language.t("common.requestFailed"), return
description: language.t("directory.error.invalidUrl"), }
}) if (invalid === dir) return
navigate("/", { replace: true }) invalid = dir
return showToast({
} variant: "error",
title: language.t("common.requestFailed"),
return await globalSDK description: language.t("directory.error.invalidUrl"),
.createClient({ })
directory, navigate("/", { replace: true })
throwOnError: true, })
})
.path.get()
.then((x) => {
const next = x.data?.directory ?? directory
invalid = ""
if (next === directory) return next
const path = pathname.slice(b64Dir.length + 1)
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
})
.catch(() => {
invalid = ""
return directory
})
},
)
return ( return (
<Show when={resolved()} keyed> <Show when={resolved()} keyed>
+8
View File
@@ -113,6 +113,14 @@ export default function Home() {
</ul> </ul>
</div> </div>
</Match> </Match>
<Match when={!sync.ready}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3">
<div class="text-12-regular text-text-weak">{language.t("common.loading")}</div>
<Button class="px-3" onClick={chooseProject}>
{language.t("command.project.open")}
</Button>
</div>
</Match>
<Match when={true}> <Match when={true}>
<div class="mt-30 mx-auto flex flex-col items-center gap-3"> <div class="mt-30 mx-auto flex flex-col items-center gap-3">
<Icon name="folder-add-left" size="large" /> <Icon name="folder-add-left" size="large" />
+93 -47
View File
@@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission" import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary" import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry" import { retry } from "@opencode-ai/util/retry"
import { playSound, soundSrc } from "@/utils/sound" import { playSoundById } from "@/utils/sound"
import { createAim } from "@/utils/aim" import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click" import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree" import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff" import { setSessionHandoff } from "@/pages/session/handoff"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { DialogSettings } from "@/components/dialog-settings"
import { useCommand, type CommandOption } from "@/context/command" import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { DebugBar } from "@/components/debug-bar" import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar" import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
@@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) {
const pageReady = createMemo(() => ready()) const pageReady = createMemo(() => ready())
let scrollContainerRef: HTMLDivElement | undefined let scrollContainerRef: HTMLDivElement | undefined
let dialogRun = 0
let dialogDead = false
const params = useParams() const params = useParams()
const globalSDK = useGlobalSDK() const globalSDK = useGlobalSDK()
@@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) {
dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir,
} }
}) })
const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const))
const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = { const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
system: "theme.scheme.system", system: "theme.scheme.system",
@@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) {
}) })
onCleanup(() => { onCleanup(() => {
dialogDead = true
dialogRun += 1
if (navLeave.current !== undefined) clearTimeout(navLeave.current) if (navLeave.current !== undefined) clearTimeout(navLeave.current)
clearTimeout(sortNowTimeout) clearTimeout(sortNowTimeout)
if (sortNowInterval) clearInterval(sortNowInterval) if (sortNowInterval) clearInterval(sortNowInterval)
@@ -211,13 +210,22 @@ export default function Layout(props: ParentProps) {
onMount(() => { onMount(() => {
const stop = () => setState("sizing", false) const stop = () => setState("sizing", false)
const blur = () => reset()
const hide = () => {
if (document.visibilityState !== "hidden") return
reset()
}
window.addEventListener("pointerup", stop) window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop) window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop) window.addEventListener("blur", stop)
window.addEventListener("blur", blur)
document.addEventListener("visibilitychange", hide)
onCleanup(() => { onCleanup(() => {
window.removeEventListener("pointerup", stop) window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop) window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop) window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
}) })
}) })
@@ -237,6 +245,12 @@ export default function Layout(props: ParentProps) {
navLeave.current = undefined navLeave.current = undefined
} }
const reset = () => {
disarm()
setState("hoverSession", undefined)
setHoverProject(undefined)
}
const arm = () => { const arm = () => {
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
if (state.hoverProject === undefined) return if (state.hoverProject === undefined) return
@@ -305,8 +319,7 @@ export default function Layout(props: ParentProps) {
const clearSidebarHoverState = () => { const clearSidebarHoverState = () => {
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
setState("hoverSession", undefined) reset()
setHoverProject(undefined)
} }
const navigateWithSidebarReset = (href: string) => { const navigateWithSidebarReset = (href: string) => {
@@ -322,10 +335,9 @@ export default function Layout(props: ParentProps) {
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length
const nextThemeId = ids[nextIndex] const nextThemeId = ids[nextIndex]
theme.setTheme(nextThemeId) theme.setTheme(nextThemeId)
const nextTheme = theme.themes()[nextThemeId]
showToast({ showToast({
title: language.t("toast.theme.title"), title: language.t("toast.theme.title"),
description: nextTheme?.name ?? nextThemeId, description: theme.name(nextThemeId),
}) })
} }
@@ -480,7 +492,7 @@ export default function Layout(props: ParentProps) {
if (e.details.type === "permission.asked") { if (e.details.type === "permission.asked") {
if (settings.sounds.permissionsEnabled()) { if (settings.sounds.permissionsEnabled()) {
playSound(soundSrc(settings.sounds.permissions())) void playSoundById(settings.sounds.permissions())
} }
if (settings.notifications.permissions()) { if (settings.notifications.permissions()) {
void platform.notify(title, description, href) void platform.notify(title, description, href)
@@ -1138,10 +1150,10 @@ export default function Layout(props: ParentProps) {
}, },
] ]
for (const [id, definition] of availableThemeEntries()) { for (const [id] of availableThemeEntries()) {
commands.push({ commands.push({
id: `theme.set.${id}`, id: `theme.set.${id}`,
title: language.t("command.theme.set", { theme: definition.name ?? id }), title: language.t("command.theme.set", { theme: theme.name(id) }),
category: language.t("command.category.theme"), category: language.t("command.category.theme"),
onSelect: () => theme.commitPreview(), onSelect: () => theme.commitPreview(),
onHighlight: () => { onHighlight: () => {
@@ -1192,15 +1204,27 @@ export default function Layout(props: ParentProps) {
}) })
function connectProvider() { function connectProvider() {
dialog.show(() => <DialogSelectProvider />) const run = ++dialogRun
void import("@/components/dialog-select-provider").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectProvider />)
})
} }
function openServer() { function openServer() {
dialog.show(() => <DialogSelectServer />) const run = ++dialogRun
void import("@/components/dialog-select-server").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSelectServer />)
})
} }
function openSettings() { function openSettings() {
dialog.show(() => <DialogSettings />) const run = ++dialogRun
void import("@/components/dialog-settings").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogSettings />)
})
} }
function projectRoot(directory: string) { function projectRoot(directory: string) {
@@ -1427,7 +1451,13 @@ export default function Layout(props: ParentProps) {
layout.sidebar.toggleWorkspaces(project.worktree) layout.sidebar.toggleWorkspaces(project.worktree)
} }
const showEditProjectDialog = (project: LocalProject) => dialog.show(() => <DialogEditProject project={project} />) const showEditProjectDialog = (project: LocalProject) => {
const run = ++dialogRun
void import("@/components/dialog-edit-project").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogEditProject project={project} />)
})
}
async function chooseProject() { async function chooseProject() {
function resolve(result: string | string[] | null) { function resolve(result: string | string[] | null) {
@@ -1448,10 +1478,14 @@ export default function Layout(props: ParentProps) {
}) })
resolve(result) resolve(result)
} else { } else {
dialog.show( const run = ++dialogRun
() => <DialogSelectDirectory multiple={true} onSelect={resolve} />, void import("@/components/dialog-select-directory").then((x) => {
() => resolve(null), if (dialogDead || dialogRun !== run) return
) dialog.show(
() => <x.DialogSelectDirectory multiple={true} onSelect={resolve} />,
() => resolve(null),
)
})
} }
} }
@@ -1784,6 +1818,9 @@ export default function Layout(props: ParentProps) {
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
}) })
const side = createMemo(() => Math.max(layout.sidebar.width(), 244))
const panel = createMemo(() => Math.max(side() - 64, 0))
const loadedSessionDirs = new Set<string>() const loadedSessionDirs = new Set<string>()
createEffect( createEffect(
@@ -1975,6 +2012,10 @@ export default function Layout(props: ParentProps) {
onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event), onProjectMouseEnter: (worktree, event) => aim.enter(worktree, event),
onProjectMouseLeave: (worktree) => aim.leave(worktree), onProjectMouseLeave: (worktree) => aim.leave(worktree),
onProjectFocus: (worktree) => aim.activate(worktree), onProjectFocus: (worktree) => aim.activate(worktree),
onHoverOpenChanged: (worktree, hoverOpen) => {
if (!hoverOpen && state.hoverProject && state.hoverProject !== worktree) return
setState("hoverProject", hoverOpen ? worktree : undefined)
},
navigateToProject, navigateToProject,
openSidebar: () => layout.sidebar.open(), openSidebar: () => layout.sidebar.open(),
closeProject, closeProject,
@@ -2056,7 +2097,7 @@ export default function Layout(props: ParentProps) {
"max-w-full overflow-hidden": panelProps.mobile, "max-w-full overflow-hidden": panelProps.mobile,
}} }}
style={{ style={{
width: panelProps.mobile ? undefined : `${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px`, width: panelProps.mobile ? undefined : `${panel()}px`,
}} }}
> >
<Show <Show
@@ -2119,9 +2160,11 @@ export default function Layout(props: ParentProps) {
variant="ghost" variant="ghost"
data-action="project-menu" data-action="project-menu"
data-project={slug()} data-project={slug()}
class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active" class="shrink-0 size-6 rounded-md transition-opacity data-[expanded]:bg-surface-base-active"
classList={{ classList={{
"opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile, "opacity-100": panelProps.mobile || merged(),
"opacity-0 group-hover/project:opacity-100 group-focus-within/project:opacity-100 data-[expanded]:opacity-100":
!panelProps.mobile && !merged(),
}} }}
aria-label={language.t("common.moreOptions")} aria-label={language.t("common.moreOptions")}
/> />
@@ -2346,7 +2389,7 @@ export default function Layout(props: ParentProps) {
"absolute inset-y-0 left-0": true, "absolute inset-y-0 left-0": true,
"z-10": true, "z-10": true,
}} }}
style={{ width: `${Math.max(layout.sidebar.width(), 244)}px` }} style={{ width: `${side()}px` }}
ref={(el) => { ref={(el) => {
setState("nav", el) setState("nav", el)
}} }}
@@ -2361,26 +2404,29 @@ export default function Layout(props: ParentProps) {
}} }}
> >
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div> <div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
<Show when={layout.sidebar.opened()}>
<div onPointerDown={() => setState("sizing", true)}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
onCollapse={layout.sidebar.close}
/>
</div>
</Show>
</nav> </nav>
<Show when={layout.sidebar.opened()}>
<div
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
style={{ left: `${side()}px` }}
onPointerDown={() => setState("sizing", true)}
>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
onResize={(w) => {
setState("sizing", true)
if (sizet !== undefined) clearTimeout(sizet)
sizet = window.setTimeout(() => setState("sizing", false), 120)
layout.sidebar.resize(w)
}}
/>
</div>
</Show>
<div <div
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base" class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
style={{ left: "calc(4rem + 12px)" }} style={{ left: "calc(4rem + 12px)" }}
@@ -2420,7 +2466,7 @@ export default function Layout(props: ParentProps) {
!state.sizing, !state.sizing,
}} }}
style={{ style={{
"--main-left": layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "4rem", "--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
}} }}
> >
<main <main
@@ -2467,7 +2513,7 @@ export default function Layout(props: ParentProps) {
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(), "duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(), "duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
}} }}
style={{ left: `calc(4rem + ${Math.max(Math.max(layout.sidebar.width(), 244) - 64, 0)}px)` }} style={{ left: `calc(4rem + ${panel()}px)` }}
> >
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} /> <div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
</div> </div>
+129 -113
View File
@@ -104,7 +104,7 @@ const SessionRow = (props: {
}): JSX.Element => ( }): JSX.Element => (
<A <A
href={`/${props.slug}/session/${props.session.id}`} href={`/${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onPointerDown={props.warmPress} onPointerDown={props.warmPress}
onPointerEnter={props.warmHover} onPointerEnter={props.warmHover}
onPointerLeave={props.cancelHoverPrefetch} onPointerLeave={props.cancelHoverPrefetch}
@@ -115,30 +115,26 @@ const SessionRow = (props: {
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
}} }}
> >
<div class="flex items-center gap-1 w-full"> <div
<div class="shrink-0 size-6 flex items-center justify-center"
class="shrink-0 size-6 flex items-center justify-center" style={{ color: props.tint() ?? "var(--icon-interactive-base)" }}
style={{ color: props.tint() ?? "var(--icon-interactive-base)" }} >
> <Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}>
<Switch fallback={<Icon name="dash" size="small" class="text-icon-weak" />}> <Match when={props.isWorking()}>
<Match when={props.isWorking()}> <Spinner class="size-[15px]" />
<Spinner class="size-[15px]" /> </Match>
</Match> <Match when={props.hasPermissions()}>
<Match when={props.hasPermissions()}> <div class="size-1.5 rounded-full bg-surface-warning-strong" />
<div class="size-1.5 rounded-full bg-surface-warning-strong" /> </Match>
</Match> <Match when={props.hasError()}>
<Match when={props.hasError()}> <div class="size-1.5 rounded-full bg-text-diff-delete-base" />
<div class="size-1.5 rounded-full bg-text-diff-delete-base" /> </Match>
</Match> <Match when={props.unseenCount() > 0}>
<Match when={props.unseenCount() > 0}> <div class="size-1.5 rounded-full bg-text-interactive-base" />
<div class="size-1.5 rounded-full bg-text-interactive-base" /> </Match>
</Match> </Switch>
</Switch>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
</div> </div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{props.session.title}</span>
</A> </A>
) )
@@ -157,34 +153,49 @@ const SessionHoverPreview = (props: {
messageLabel: (message: Message) => string | undefined messageLabel: (message: Message) => string | undefined
onMessageSelect: (message: Message) => void onMessageSelect: (message: Message) => void
trigger: JSX.Element trigger: JSX.Element
}): JSX.Element => ( }): JSX.Element => {
<HoverCard let ref: HTMLDivElement | undefined
openDelay={1000}
closeDelay={props.sidebarHovering() ? 600 : 0} return (
placement="right-start" <HoverCard
gutter={16} openDelay={1000}
shift={-2} closeDelay={props.sidebarHovering() ? 600 : 0}
trigger={props.trigger} placement="right-start"
open={props.hoverSession() === props.session.id} gutter={16}
onOpenChange={(open) => props.setHoverSession(open ? props.session.id : undefined)} shift={-2}
> trigger={
<Show <div ref={ref} class="min-w-0 w-full">
when={props.hoverReady()} {props.trigger}
fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>} </div>
}
open={props.hoverSession() === props.session.id}
onOpenChange={(open) => {
if (!open) {
props.setHoverSession(undefined)
return
}
if (!ref?.matches(":hover")) return
props.setHoverSession(props.session.id)
}}
> >
<div class="overflow-y-auto overflow-x-hidden max-h-72 h-full"> <Show
<MessageNav when={props.hoverReady()}
messages={props.hoverMessages() ?? []} fallback={<div class="text-12-regular text-text-weak">{props.language.t("session.messages.loading")}</div>}
current={undefined} >
getLabel={props.messageLabel} <div class="overflow-y-auto overflow-x-hidden max-h-72 h-full">
onMessageSelect={props.onMessageSelect} <MessageNav
size="normal" messages={props.hoverMessages() ?? []}
class="w-60" current={undefined}
/> getLabel={props.messageLabel}
</div> onMessageSelect={props.onMessageSelect}
</Show> size="normal"
</HoverCard> class="w-60"
) />
</div>
</Show>
</HoverCard>
)
}
export const SessionItem = (props: SessionItemProps): JSX.Element => { export const SessionItem = (props: SessionItemProps): JSX.Element => {
const params = useParams() const params = useParams()
@@ -298,62 +309,71 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
return ( return (
<div <div
data-session-id={props.session.id} data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default pl-2 pr-3 transition-colors class="group/session relative w-full min-w-0 rounded-md cursor-default pl-2 pr-3 transition-colors
hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active" hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[[data-expanded]]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
> >
<Show <div class="flex min-w-0 items-center gap-1">
when={hoverEnabled()} <div class="min-w-0 flex-1">
fallback={ <Show
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}> when={hoverEnabled()}
{item} fallback={
</Tooltip> <Tooltip
} placement={props.mobile ? "bottom" : "right"}
> value={props.session.title}
<SessionHoverPreview gutter={10}
mobile={props.mobile} class="min-w-0 w-full"
nav={props.nav} >
hoverSession={props.hoverSession} {item}
session={props.session} </Tooltip>
sidebarHovering={props.sidebarHovering} }
hoverReady={hoverReady} >
hoverMessages={hoverMessages} <SessionHoverPreview
language={language} mobile={props.mobile}
isActive={isActive} nav={props.nav}
slug={props.slug} hoverSession={props.hoverSession}
setHoverSession={props.setHoverSession} session={props.session}
messageLabel={messageLabel} sidebarHovering={props.sidebarHovering}
onMessageSelect={(message) => { hoverReady={hoverReady}
if (!isActive()) hoverMessages={hoverMessages}
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id) language={language}
isActive={isActive}
slug={props.slug}
setHoverSession={props.setHoverSession}
messageLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive())
layout.pendingMessage.set(`${base64Encode(props.session.directory)}/${props.session.id}`, message.id)
navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`) navigate(`${props.slug}/session/${props.session.id}#message-${message.id}`)
}}
trigger={item}
/>
</Show>
</div>
<div
class="shrink-0 overflow-hidden transition-[width,opacity]"
classList={{
"w-6 opacity-100 pointer-events-auto": !!props.mobile,
"w-0 opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:w-6 group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:w-6 group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}} }}
trigger={item} >
/> <Tooltip value={language.t("common.archive")} placement="top">
</Show> <IconButton
icon="archive"
<div variant="ghost"
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`} class="size-6 rounded-md"
classList={{ aria-label={language.t("common.archive")}
"opacity-100 pointer-events-auto": !!props.mobile, onClick={(event) => {
"opacity-0 pointer-events-none": !props.mobile, event.preventDefault()
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true, event.stopPropagation()
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, void props.archiveSession(props.session)
}} }}
> />
<Tooltip value={language.t("common.archive")} placement="top"> </Tooltip>
<IconButton </div>
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void props.archiveSession(props.session)
}}
/>
</Tooltip>
</div> </div>
</div> </div>
) )
@@ -375,30 +395,26 @@ export const NewSessionItem = (props: {
<A <A
href={`/${props.slug}/session`} href={`/${props.slug}/session`}
end end
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center gap-1 min-w-0 w-full text-left focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`}
onClick={() => { onClick={() => {
props.setHoverSession(undefined) props.setHoverSession(undefined)
if (layout.sidebar.opened()) return if (layout.sidebar.opened()) return
props.clearHoverProjectSoon() props.clearHoverProjectSoon()
}} }}
> >
<div class="flex items-center gap-1 w-full"> <div class="shrink-0 size-6 flex items-center justify-center">
<div class="shrink-0 size-6 flex items-center justify-center"> <Icon name="new-session" size="small" class="text-icon-weak" />
<Icon name="new-session" size="small" class="text-icon-weak" />
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{label}
</span>
</div> </div>
<span class="text-14-regular text-text-strong min-w-0 flex-1 truncate">{label}</span>
</A> </A>
) )
return ( return (
<div class="group/session relative w-full rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"> <div class="group/session relative w-full min-w-0 rounded-md cursor-default transition-colors pl-2 pr-3 hover:bg-surface-raised-base-hover [&:has(:focus-visible)]:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active">
<Show <Show
when={!tooltip()} when={!tooltip()}
fallback={ fallback={
<Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10}> <Tooltip placement={props.mobile ? "bottom" : "right"} value={label} gutter={10} class="min-w-0 w-full">
{item} {item}
</Tooltip> </Tooltip>
} }
@@ -23,6 +23,7 @@ export type ProjectSidebarContext = {
onProjectMouseEnter: (worktree: string, event: MouseEvent) => void onProjectMouseEnter: (worktree: string, event: MouseEvent) => void
onProjectMouseLeave: (worktree: string) => void onProjectMouseLeave: (worktree: string) => void
onProjectFocus: (worktree: string) => void onProjectFocus: (worktree: string) => void
onHoverOpenChanged: (worktree: string, hovered: boolean) => void
navigateToProject: (directory: string) => void navigateToProject: (directory: string) => void
openSidebar: () => void openSidebar: () => void
closeProject: (directory: string) => void closeProject: (directory: string) => void
@@ -109,8 +110,14 @@ const ProjectTile = (props: {
"bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(), "bg-surface-base-hover border border-border-weak-base": !props.selected() && props.active(),
}} }}
onPointerDown={(event) => { onPointerDown={(event) => {
if (event.button === 0 && !event.ctrlKey) {
props.setOpen(false)
props.setSuppressHover(true)
return
}
if (!props.overlay()) return if (!props.overlay()) return
if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return if (event.button !== 2 && !(event.button === 0 && event.ctrlKey)) return
props.setOpen(false)
props.setSuppressHover(true) props.setSuppressHover(true)
event.preventDefault() event.preventDefault()
}} }}
@@ -130,12 +137,11 @@ const ProjectTile = (props: {
props.onProjectFocus(props.project.worktree) props.onProjectFocus(props.project.worktree)
}} }}
onClick={() => { onClick={() => {
props.setOpen(false)
if (props.selected()) { if (props.selected()) {
props.setSuppressHover(true)
layout.sidebar.toggle() layout.sidebar.toggle()
return return
} }
props.setSuppressHover(false)
props.navigateToProject(props.project.worktree) props.navigateToProject(props.project.worktree)
}} }}
onBlur={() => props.setOpen(false)} onBlur={() => props.setOpen(false)}
@@ -192,7 +198,6 @@ const ProjectPreviewPanel = (props: {
projectChildren: Accessor<Map<string, string[]>> projectChildren: Accessor<Map<string, string[]>>
workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions> workspaceSessions: (directory: string) => ReturnType<typeof sortedRootSessions>
workspaceChildren: (directory: string) => Map<string, string[]> workspaceChildren: (directory: string) => Map<string, string[]>
setOpen: (value: boolean) => void
ctx: ProjectSidebarContext ctx: ProjectSidebarContext
language: ReturnType<typeof useLanguage> language: ReturnType<typeof useLanguage>
}): JSX.Element => ( }): JSX.Element => (
@@ -259,7 +264,7 @@ const ProjectPreviewPanel = (props: {
class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent" class="flex w-full text-left justify-start text-text-base px-2 hover:bg-transparent active:bg-transparent"
onClick={() => { onClick={() => {
props.ctx.openSidebar() props.ctx.openSidebar()
props.setOpen(false) props.ctx.onHoverOpenChanged(props.project.worktree, false)
if (props.selected()) return if (props.selected()) return
props.ctx.navigateToProject(props.project.worktree) props.ctx.navigateToProject(props.project.worktree)
}} }}
@@ -284,28 +289,16 @@ export const SortableProject = (props: {
const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project)) const workspaceEnabled = createMemo(() => props.ctx.workspacesEnabled(props.project))
const dirs = createMemo(() => props.ctx.workspaceIds(props.project)) const dirs = createMemo(() => props.ctx.workspaceIds(props.project))
const [state, setState] = createStore({ const [state, setState] = createStore({
open: false,
menu: false, menu: false,
suppressHover: false, suppressHover: false,
}) })
const isHoverProject = () => props.ctx.hoverProject() === props.project.worktree
const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened()) const preview = createMemo(() => !props.mobile && props.ctx.sidebarOpened())
const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened()) const overlay = createMemo(() => !props.mobile && !props.ctx.sidebarOpened())
const active = createMemo( const active = createMemo(() => state.menu || (preview() ? isHoverProject() : overlay() && isHoverProject()))
() => state.menu || (preview() ? state.open : overlay() && props.ctx.hoverProject() === props.project.worktree),
)
createEffect(() => { const hoverOpen = () => isHoverProject() && preview() && !selected() && !state.menu
if (preview()) return
if (!state.open) return
setState("open", false)
})
createEffect(() => {
if (!selected()) return
if (!state.open) return
setState("open", false)
})
const label = (directory: string) => { const label = (directory: string) => {
const [data] = globalSync.child(directory, { bootstrap: false }) const [data] = globalSync.child(directory, { bootstrap: false })
@@ -346,7 +339,7 @@ export const SortableProject = (props: {
workspacesEnabled={props.ctx.workspacesEnabled} workspacesEnabled={props.ctx.workspacesEnabled}
closeProject={props.ctx.closeProject} closeProject={props.ctx.closeProject}
setMenu={(value) => setState("menu", value)} setMenu={(value) => setState("menu", value)}
setOpen={(value) => setState("open", value)} setOpen={(value) => props.ctx.onHoverOpenChanged(props.project.worktree, value)}
setSuppressHover={(value) => setState("suppressHover", value)} setSuppressHover={(value) => setState("suppressHover", value)}
language={language} language={language}
/> />
@@ -357,7 +350,7 @@ export const SortableProject = (props: {
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}> <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Show when={preview() && !selected()} fallback={tile()}> <Show when={preview() && !selected()} fallback={tile()}>
<HoverCard <HoverCard
open={!state.suppressHover && state.open && !state.menu} open={!state.suppressHover && hoverOpen() && !state.menu}
openDelay={0} openDelay={0}
closeDelay={0} closeDelay={0}
placement="right-start" placement="right-start"
@@ -366,7 +359,7 @@ export const SortableProject = (props: {
onOpenChange={(value) => { onOpenChange={(value) => {
if (state.menu) return if (state.menu) return
if (value && state.suppressHover) return if (value && state.suppressHover) return
setState("open", value) props.ctx.onHoverOpenChanged(props.project.worktree, value)
if (value) props.ctx.setHoverSession(undefined) if (value) props.ctx.setHoverSession(undefined)
}} }}
> >
@@ -381,7 +374,6 @@ export const SortableProject = (props: {
projectChildren={projectChildren} projectChildren={projectChildren}
workspaceSessions={workspaceSessions} workspaceSessions={workspaceSessions}
workspaceChildren={workspaceChildren} workspaceChildren={workspaceChildren}
setOpen={(value) => setState("open", value)}
ctx={props.ctx} ctx={props.ctx}
language={language} language={language}
/> />
+21 -9
View File
@@ -41,7 +41,13 @@ import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal" import { useTerminal } from "@/context/terminal"
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit" import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" import {
createOpenReviewFile,
createSessionTabs,
createSizing,
focusTerminalById,
shouldFocusTerminalOnKeyDown,
} from "@/pages/session/helpers"
import { MessageTimeline } from "@/pages/session/message-timeline" import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout" import { useSessionLayout } from "@/pages/session/session-layout"
@@ -240,14 +246,19 @@ function createSessionHistoryWindow(input: SessionHistoryWindowInput) {
if (added <= 0) return if (added <= 0) return
if (growth <= 0) return if (growth <= 0) return
if (opts?.prefetch) {
const current = turnStart()
preserveScroll(() => setTurnStart(current + growth))
return
}
if (turnStart() !== start) return if (turnStart() !== start) return
const reveal = !opts?.prefetch
const currentRendered = renderedUserMessages().length const currentRendered = renderedUserMessages().length
const base = Math.max(beforeRendered, currentRendered) const base = Math.max(beforeRendered, currentRendered)
const target = reveal ? Math.min(afterVisible, base + turnBatch) : base const target = Math.min(afterVisible, base + turnBatch)
const nextStart = Math.max(0, afterVisible - target) preserveScroll(() => setTurnStart(Math.max(0, afterVisible - target)))
preserveScroll(() => setTurnStart(nextStart))
} }
const onScrollerScroll = () => { const onScrollerScroll = () => {
@@ -850,7 +861,7 @@ export default function Page() {
// Prefer the open terminal over the composer when it can take focus // Prefer the open terminal over the composer when it can take focus
if (view().terminal.opened()) { if (view().terminal.opened()) {
const id = terminal.active() const id = terminal.active()
if (id && focusTerminalById(id)) return if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
} }
// Only treat explicit scroll keys as potential "user scroll" gestures. // Only treat explicit scroll keys as potential "user scroll" gestures.
@@ -1173,8 +1184,6 @@ export default function Page() {
on( on(
() => sdk.directory, () => sdk.directory,
() => { () => {
void file.tree.list("")
const tab = activeFileTab() const tab = activeFileTab()
if (!tab) return if (!tab) return
const path = file.pathFromTab(tab) const path = file.pathFromTab(tab)
@@ -1629,6 +1638,9 @@ export default function Page() {
sessionID: () => params.id, sessionID: () => params.id,
messagesReady, messagesReady,
visibleUserMessages, visibleUserMessages,
historyMore,
historyLoading,
loadMore: (sessionID) => sync.session.history.loadMore(sessionID),
turnStart: historyWindow.turnStart, turnStart: historyWindow.turnStart,
currentMessageId: () => store.messageId, currentMessageId: () => store.messageId,
pendingMessage: () => ui.pendingMessage, pendingMessage: () => ui.pendingMessage,
@@ -1700,7 +1712,7 @@ export default function Page() {
<div class="flex-1 min-h-0 overflow-hidden"> <div class="flex-1 min-h-0 overflow-hidden">
<Switch> <Switch>
<Match when={params.id}> <Match when={params.id}>
<Show when={lastUserMessage()}> <Show when={messagesReady()}>
<MessageTimeline <MessageTimeline
mobileChanges={mobileChanges()} mobileChanges={mobileChanges()}
mobileFallback={reviewContent({ mobileFallback={reviewContent({
@@ -7,6 +7,7 @@ import {
createSessionTabs, createSessionTabs,
focusTerminalById, focusTerminalById,
getTabReorderIndex, getTabReorderIndex,
shouldFocusTerminalOnKeyDown,
} from "./helpers" } from "./helpers"
describe("createOpenReviewFile", () => { describe("createOpenReviewFile", () => {
@@ -86,6 +87,26 @@ describe("focusTerminalById", () => {
}) })
}) })
describe("shouldFocusTerminalOnKeyDown", () => {
test("skips pure modifier keys", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Meta", metaKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true }))).toBe(false)
})
test("skips shortcut key combos", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", metaKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(false)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false)
})
test("keeps plain typing focused on terminal", () => {
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "A", shiftKey: true }))).toBe(true)
})
})
describe("getTabReorderIndex", () => { describe("getTabReorderIndex", () => {
test("returns target index for valid drag reorder", () => { test("returns target index for valid drag reorder", () => {
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2) expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)
@@ -93,6 +93,13 @@ export const focusTerminalById = (id: string) => {
return true return true
} }
const skip = new Set(["Alt", "Control", "Meta", "Shift"])
export const shouldFocusTerminalOnKeyDown = (event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">) => {
if (skip.has(event.key)) return false
return !(event.ctrlKey || event.metaKey || event.altKey)
}
export const createOpenReviewFile = (input: { export const createOpenReviewFile = (input: {
showAllFiles: () => void showAllFiles: () => void
tabForPath: (path: string) => string tabForPath: (path: string) => string
@@ -1,4 +1,4 @@
import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX } from "solid-js" import { For, createEffect, createMemo, on, onCleanup, Show, Index, type JSX, createSignal } from "solid-js"
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { useNavigate } from "@solidjs/router" import { useNavigate } from "@solidjs/router"
import { useMutation } from "@tanstack/solid-query" import { useMutation } from "@tanstack/solid-query"
@@ -30,6 +30,7 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { messageAgentColor } from "@/utils/agent" import { messageAgentColor } from "@/utils/agent"
import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note"
import { makeTimer } from "@solid-primitives/timer"
type MessageComment = { type MessageComment = {
path: string path: string
@@ -250,38 +251,21 @@ export function MessageTimeline(props: {
const working = createMemo(() => !!pending() || sessionStatus().type !== "idle") const working = createMemo(() => !!pending() || sessionStatus().type !== "idle")
const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent)) const tint = createMemo(() => messageAgentColor(sessionMessages(), sync.data.agent))
const [slot, setSlot] = createStore({ const [timeoutDone, setTimeoutDone] = createSignal(true)
open: false,
show: false, const workingStatus = createMemo<"hidden" | "showing" | "hiding">((prev) => {
fade: false, if (working()) return "showing"
if (prev === "showing" || !timeoutDone()) return "hiding"
return "hidden"
}) })
let f: number | undefined createEffect(() => {
const clear = () => { if (workingStatus() !== "hiding") return
if (f !== undefined) window.clearTimeout(f)
f = undefined setTimeoutDone(false)
} makeTimer(() => setTimeoutDone(true), 260, setTimeout)
})
onCleanup(clear)
createEffect(
on(
working,
(on, prev) => {
clear()
if (on) {
setSlot({ open: true, show: true, fade: false })
return
}
if (prev) {
setSlot({ open: false, show: true, fade: true })
f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260)
return
}
setSlot({ open: false, show: false, fade: false })
},
{ defer: true },
),
)
const activeMessageID = createMemo(() => { const activeMessageID = createMemo(() => {
const parentID = pending()?.parentID const parentID = pending()?.parentID
if (parentID) { if (parentID) {
@@ -676,17 +660,15 @@ export function MessageTimeline(props: {
<div <div
class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]" class="shrink-0 flex items-center justify-center overflow-hidden transition-[width,margin] duration-300 ease-[cubic-bezier(0.22,1,0.36,1)]"
style={{ style={{
width: slot.open ? "16px" : "0px", width: working() ? "16px" : "0px",
"margin-right": slot.open ? "8px" : "0px", "margin-right": working() ? "8px" : "0px",
}} }}
aria-hidden="true" aria-hidden="true"
> >
<Show when={slot.show}> <Show when={workingStatus() !== "hidden"}>
<div <div
class="transition-opacity duration-200 ease-out" class="transition-opacity duration-200 ease-out"
classList={{ classList={{ "opacity-0": workingStatus() === "hiding" }}
"opacity-0": slot.fade,
}}
> >
<Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} /> <Spinner class="size-4" style={{ color: tint() ?? "var(--icon-interactive-base)" }} />
</div> </div>
@@ -912,7 +894,6 @@ export function MessageTimeline(props: {
</div> </div>
</div> </div>
</Show> </Show>
<div <div
role="log" role="log"
class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]" class="flex flex-col gap-12 items-start justify-start pb-16 transition-[margin]"
@@ -942,7 +923,15 @@ export function MessageTimeline(props: {
{(messageID) => { {(messageID) => {
const active = createMemo(() => activeMessageID() === messageID) const active = createMemo(() => activeMessageID() === messageID)
const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], { const comments = createMemo(() => messageComments(sync.data.part[messageID] ?? []), [], {
equals: (a, b) => JSON.stringify(a) === JSON.stringify(b), equals: (a, b) =>
a.length === b.length &&
a.every(
(c, i) =>
c.path === b[i].path &&
c.comment === b[i].comment &&
c.selection?.startLine === b[i].selection?.startLine &&
c.selection?.endLine === b[i].selection?.endLine,
),
}) })
const commentCount = createMemo(() => comments().length) const commentCount = createMemo(() => comments().length)
return ( return (
@@ -998,6 +987,7 @@ export function MessageTimeline(props: {
<SessionTurn <SessionTurn
sessionID={sessionID() ?? ""} sessionID={sessionID() ?? ""}
messageID={messageID} messageID={messageID}
messages={sessionMessages()}
actions={props.actions} actions={props.actions}
active={active()} active={active()}
status={active() ? sessionStatus() : undefined} status={active() ? sessionStatus() : undefined}
@@ -438,12 +438,10 @@ export function SessionSidePanel(props: {
size={layout.fileTree.width()} size={layout.fileTree.width()}
min={200} min={200}
max={480} max={480}
collapseThreshold={160}
onResize={(width) => { onResize={(width) => {
props.size.touch() props.size.touch()
layout.fileTree.resize(width) layout.fileTree.resize(width)
}} }}
onCollapse={layout.fileTree.close}
/> />
</div> </div>
</Show> </Show>
@@ -255,7 +255,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
id: "file.open", id: "file.open",
title: language.t("command.file.open"), title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"), description: language.t("palette.search.placeholder"),
keybind: "mod+p", keybind: "mod+k,mod+p",
slash: "open", slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />), onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
}), }),
@@ -8,6 +8,9 @@ export const useSessionHashScroll = (input: {
sessionID: () => string | undefined sessionID: () => string | undefined
messagesReady: () => boolean messagesReady: () => boolean
visibleUserMessages: () => UserMessage[] visibleUserMessages: () => UserMessage[]
historyMore: () => boolean
historyLoading: () => boolean
loadMore: (sessionID: string) => Promise<void>
turnStart: () => number turnStart: () => number
currentMessageId: () => string | undefined currentMessageId: () => string | undefined
pendingMessage: () => string | undefined pendingMessage: () => string | undefined
@@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: {
queue(() => scrollToMessage(msg, "auto")) queue(() => scrollToMessage(msg, "auto"))
}) })
createEffect(() => {
const sessionID = input.sessionID()
if (!sessionID || !input.messagesReady()) return
visibleUserMessages()
let targetId = input.pendingMessage()
if (!targetId && !clearing) targetId = messageIdFromHash(location.hash)
if (!targetId) return
if (messageById().has(targetId)) return
if (!input.historyMore() || input.historyLoading()) return
void input.loadMore(sessionID)
})
onMount(() => { onMount(() => {
if (typeof window !== "undefined" && "scrollRestoration" in window.history) { if (typeof window !== "undefined" && "scrollRestoration" in window.history) {
window.history.scrollRestoration = "manual" window.history.scrollRestoration = "manual"
+44
View File
@@ -0,0 +1,44 @@
import { describe, expect, test } from "bun:test"
import type { Part } from "@opencode-ai/sdk/v2"
import { extractPromptFromParts } from "./prompt"
describe("extractPromptFromParts", () => {
test("restores multiple uploaded attachments", () => {
const parts = [
{
id: "text_1",
type: "text",
text: "check these",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_1",
type: "file",
mime: "image/png",
url: "data:image/png;base64,AAA",
filename: "a.png",
sessionID: "ses_1",
messageID: "msg_1",
},
{
id: "file_2",
type: "file",
mime: "application/pdf",
url: "data:application/pdf;base64,BBB",
filename: "b.pdf",
sessionID: "ses_1",
messageID: "msg_1",
},
] satisfies Part[]
const result = extractPromptFromParts(parts)
expect(result).toHaveLength(3)
expect(result[0]).toMatchObject({ type: "text", content: "check these" })
expect(result.slice(1)).toMatchObject([
{ type: "image", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
{ type: "image", filename: "b.pdf", mime: "application/pdf", dataUrl: "data:application/pdf;base64,BBB" },
])
})
})
+23 -1
View File
@@ -14,6 +14,15 @@ interface CheckServerHealthOptions {
const defaultTimeoutMs = 3000 const defaultTimeoutMs = 3000
const defaultRetryCount = 2 const defaultRetryCount = 2
const defaultRetryDelayMs = 100 const defaultRetryDelayMs = 100
const cacheMs = 750
const healthCache = new Map<
string,
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
>()
function cacheKey(server: ServerConnection.HttpBase) {
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
}
function timeoutSignal(timeoutMs: number) { function timeoutSignal(timeoutMs: number) {
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
@@ -87,5 +96,18 @@ export function useCheckServerHealth() {
const platform = usePlatform() const platform = usePlatform()
const fetcher = platform.fetch ?? globalThis.fetch const fetcher = platform.fetch ?? globalThis.fetch
return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher) return (http: ServerConnection.HttpBase) => {
const key = cacheKey(http)
const hit = healthCache.get(key)
const now = Date.now()
if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
const promise = checkServerHealth(http, fetcher).finally(() => {
const next = healthCache.get(key)
if (!next || next.promise !== promise) return
next.done = true
next.at = Date.now()
})
healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
return promise
}
} }
+81 -96
View File
@@ -1,106 +1,89 @@
import alert01 from "@opencode-ai/ui/audio/alert-01.aac" let files: Record<string, () => Promise<string>> | undefined
import alert02 from "@opencode-ai/ui/audio/alert-02.aac" let loads: Record<SoundID, () => Promise<string>> | undefined
import alert03 from "@opencode-ai/ui/audio/alert-03.aac"
import alert04 from "@opencode-ai/ui/audio/alert-04.aac" function getFiles() {
import alert05 from "@opencode-ai/ui/audio/alert-05.aac" if (files) return files
import alert06 from "@opencode-ai/ui/audio/alert-06.aac" files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record<
import alert07 from "@opencode-ai/ui/audio/alert-07.aac" string,
import alert08 from "@opencode-ai/ui/audio/alert-08.aac" () => Promise<string>
import alert09 from "@opencode-ai/ui/audio/alert-09.aac" >
import alert10 from "@opencode-ai/ui/audio/alert-10.aac" return files
import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac" }
import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac"
import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac"
import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac"
import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac"
import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac"
import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac"
import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac"
import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac"
import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac"
import nope01 from "@opencode-ai/ui/audio/nope-01.aac"
import nope02 from "@opencode-ai/ui/audio/nope-02.aac"
import nope03 from "@opencode-ai/ui/audio/nope-03.aac"
import nope04 from "@opencode-ai/ui/audio/nope-04.aac"
import nope05 from "@opencode-ai/ui/audio/nope-05.aac"
import nope06 from "@opencode-ai/ui/audio/nope-06.aac"
import nope07 from "@opencode-ai/ui/audio/nope-07.aac"
import nope08 from "@opencode-ai/ui/audio/nope-08.aac"
import nope09 from "@opencode-ai/ui/audio/nope-09.aac"
import nope10 from "@opencode-ai/ui/audio/nope-10.aac"
import nope11 from "@opencode-ai/ui/audio/nope-11.aac"
import nope12 from "@opencode-ai/ui/audio/nope-12.aac"
import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac"
import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac"
import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac"
import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac"
import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac"
import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac"
import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac"
import yup01 from "@opencode-ai/ui/audio/yup-01.aac"
import yup02 from "@opencode-ai/ui/audio/yup-02.aac"
import yup03 from "@opencode-ai/ui/audio/yup-03.aac"
import yup04 from "@opencode-ai/ui/audio/yup-04.aac"
import yup05 from "@opencode-ai/ui/audio/yup-05.aac"
import yup06 from "@opencode-ai/ui/audio/yup-06.aac"
export const SOUND_OPTIONS = [ export const SOUND_OPTIONS = [
{ id: "alert-01", label: "sound.option.alert01", src: alert01 }, { id: "alert-01", label: "sound.option.alert01" },
{ id: "alert-02", label: "sound.option.alert02", src: alert02 }, { id: "alert-02", label: "sound.option.alert02" },
{ id: "alert-03", label: "sound.option.alert03", src: alert03 }, { id: "alert-03", label: "sound.option.alert03" },
{ id: "alert-04", label: "sound.option.alert04", src: alert04 }, { id: "alert-04", label: "sound.option.alert04" },
{ id: "alert-05", label: "sound.option.alert05", src: alert05 }, { id: "alert-05", label: "sound.option.alert05" },
{ id: "alert-06", label: "sound.option.alert06", src: alert06 }, { id: "alert-06", label: "sound.option.alert06" },
{ id: "alert-07", label: "sound.option.alert07", src: alert07 }, { id: "alert-07", label: "sound.option.alert07" },
{ id: "alert-08", label: "sound.option.alert08", src: alert08 }, { id: "alert-08", label: "sound.option.alert08" },
{ id: "alert-09", label: "sound.option.alert09", src: alert09 }, { id: "alert-09", label: "sound.option.alert09" },
{ id: "alert-10", label: "sound.option.alert10", src: alert10 }, { id: "alert-10", label: "sound.option.alert10" },
{ id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 }, { id: "bip-bop-01", label: "sound.option.bipbop01" },
{ id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 }, { id: "bip-bop-02", label: "sound.option.bipbop02" },
{ id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 }, { id: "bip-bop-03", label: "sound.option.bipbop03" },
{ id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 }, { id: "bip-bop-04", label: "sound.option.bipbop04" },
{ id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 }, { id: "bip-bop-05", label: "sound.option.bipbop05" },
{ id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 }, { id: "bip-bop-06", label: "sound.option.bipbop06" },
{ id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 }, { id: "bip-bop-07", label: "sound.option.bipbop07" },
{ id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 }, { id: "bip-bop-08", label: "sound.option.bipbop08" },
{ id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 }, { id: "bip-bop-09", label: "sound.option.bipbop09" },
{ id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 }, { id: "bip-bop-10", label: "sound.option.bipbop10" },
{ id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 }, { id: "staplebops-01", label: "sound.option.staplebops01" },
{ id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 }, { id: "staplebops-02", label: "sound.option.staplebops02" },
{ id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 }, { id: "staplebops-03", label: "sound.option.staplebops03" },
{ id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 }, { id: "staplebops-04", label: "sound.option.staplebops04" },
{ id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 }, { id: "staplebops-05", label: "sound.option.staplebops05" },
{ id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 }, { id: "staplebops-06", label: "sound.option.staplebops06" },
{ id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 }, { id: "staplebops-07", label: "sound.option.staplebops07" },
{ id: "nope-01", label: "sound.option.nope01", src: nope01 }, { id: "nope-01", label: "sound.option.nope01" },
{ id: "nope-02", label: "sound.option.nope02", src: nope02 }, { id: "nope-02", label: "sound.option.nope02" },
{ id: "nope-03", label: "sound.option.nope03", src: nope03 }, { id: "nope-03", label: "sound.option.nope03" },
{ id: "nope-04", label: "sound.option.nope04", src: nope04 }, { id: "nope-04", label: "sound.option.nope04" },
{ id: "nope-05", label: "sound.option.nope05", src: nope05 }, { id: "nope-05", label: "sound.option.nope05" },
{ id: "nope-06", label: "sound.option.nope06", src: nope06 }, { id: "nope-06", label: "sound.option.nope06" },
{ id: "nope-07", label: "sound.option.nope07", src: nope07 }, { id: "nope-07", label: "sound.option.nope07" },
{ id: "nope-08", label: "sound.option.nope08", src: nope08 }, { id: "nope-08", label: "sound.option.nope08" },
{ id: "nope-09", label: "sound.option.nope09", src: nope09 }, { id: "nope-09", label: "sound.option.nope09" },
{ id: "nope-10", label: "sound.option.nope10", src: nope10 }, { id: "nope-10", label: "sound.option.nope10" },
{ id: "nope-11", label: "sound.option.nope11", src: nope11 }, { id: "nope-11", label: "sound.option.nope11" },
{ id: "nope-12", label: "sound.option.nope12", src: nope12 }, { id: "nope-12", label: "sound.option.nope12" },
{ id: "yup-01", label: "sound.option.yup01", src: yup01 }, { id: "yup-01", label: "sound.option.yup01" },
{ id: "yup-02", label: "sound.option.yup02", src: yup02 }, { id: "yup-02", label: "sound.option.yup02" },
{ id: "yup-03", label: "sound.option.yup03", src: yup03 }, { id: "yup-03", label: "sound.option.yup03" },
{ id: "yup-04", label: "sound.option.yup04", src: yup04 }, { id: "yup-04", label: "sound.option.yup04" },
{ id: "yup-05", label: "sound.option.yup05", src: yup05 }, { id: "yup-05", label: "sound.option.yup05" },
{ id: "yup-06", label: "sound.option.yup06", src: yup06 }, { id: "yup-06", label: "sound.option.yup06" },
] as const ] as const
export type SoundOption = (typeof SOUND_OPTIONS)[number] export type SoundOption = (typeof SOUND_OPTIONS)[number]
export type SoundID = SoundOption["id"] export type SoundID = SoundOption["id"]
const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record<SoundID, string> function getLoads() {
if (loads) return loads
loads = Object.fromEntries(
Object.entries(getFiles()).flatMap(([path, load]) => {
const file = path.split("/").at(-1)
if (!file) return []
return [[file.replace(/\.aac$/, ""), load] as const]
}),
) as Record<SoundID, () => Promise<string>>
return loads
}
const cache = new Map<SoundID, Promise<string | undefined>>()
export function soundSrc(id: string | undefined) { export function soundSrc(id: string | undefined) {
if (!id) return const loads = getLoads()
if (!(id in soundById)) return if (!id || !(id in loads)) return Promise.resolve(undefined)
return soundById[id as SoundID] const key = id as SoundID
const hit = cache.get(key)
if (hit) return hit
const next = loads[key]().catch(() => undefined)
cache.set(key, next)
return next
} }
export function playSound(src: string | undefined) { export function playSound(src: string | undefined) {
@@ -108,10 +91,12 @@ export function playSound(src: string | undefined) {
if (!src) return if (!src) return
const audio = new Audio(src) const audio = new Audio(src)
audio.play().catch(() => undefined) audio.play().catch(() => undefined)
// Return a cleanup function to pause the sound.
return () => { return () => {
audio.pause() audio.pause()
audio.currentTime = 0 audio.currentTime = 0
} }
} }
export function playSoundById(id: string | undefined) {
return soundSrc(id).then((src) => playSound(src))
}
+12
View File
@@ -1,7 +1,10 @@
import { readFileSync } from "node:fs"
import solidPlugin from "vite-plugin-solid" import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite" import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url))
/** /**
* @type {import("vite").PluginOption} * @type {import("vite").PluginOption}
*/ */
@@ -21,6 +24,15 @@ export default [
} }
}, },
}, },
{
name: "opencode-desktop:theme-preload",
transformIndexHtml(html) {
return html.replace(
'<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>',
`<script id="oc-theme-preload-script">${readFileSync(theme, "utf8")}</script>`,
)
},
},
tailwindcss(), tailwindcss(),
solidPlugin(), solidPlugin(),
] ]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.2.27", "version": "1.3.1",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -2,6 +2,6 @@ import { redirect } from "@solidjs/router"
export async function GET() { export async function GET() {
return redirect( return redirect(
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true", "https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true",
) )
} }
@@ -340,6 +340,13 @@ export async function handler(
"error.message": error.message, "error.message": error.message,
"error.cause": error.cause?.toString(), "error.cause": error.cause?.toString(),
}) })
if (error.message.startsWith("Failed query")) {
try {
logger.metric({
"error.cause2": JSON.stringify(error.cause),
})
} catch (e) {}
}
// Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message. // Note: both top level "type" and "error.type" fields are used by the @ai-sdk/anthropic client to render the error message.
if ( if (
@@ -461,12 +468,17 @@ export async function handler(
...modelProvider, ...modelProvider,
...zenData.providers[modelProvider.id], ...zenData.providers[modelProvider.id],
...(() => { ...(() => {
const format = zenData.providers[modelProvider.id].format const providerProps = zenData.providers[modelProvider.id]
const format = providerProps.format
const providerModel = modelProvider.model const providerModel = modelProvider.model
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel }) if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
if (format === "google") return googleHelper({ reqModel, providerModel }) if (format === "google") return googleHelper({ reqModel, providerModel })
if (format === "openai") return openaiHelper({ reqModel, providerModel }) if (format === "openai") return openaiHelper({ reqModel, providerModel })
return oaCompatHelper({ reqModel, providerModel }) return oaCompatHelper({
reqModel,
providerModel,
adjustCacheUsage: providerProps.adjustCacheUsage,
})
})(), })(),
} }
} }
@@ -21,7 +21,7 @@ type Usage = {
} }
} }
export const oaCompatHelper: ProviderHelper = () => ({ export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
format: "oa-compat", format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions", modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => { modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
@@ -57,10 +57,15 @@ export const oaCompatHelper: ProviderHelper = () => ({
} }
}, },
normalizeUsage: (usage: Usage) => { normalizeUsage: (usage: Usage) => {
const inputTokens = usage.prompt_tokens ?? 0 let inputTokens = usage.prompt_tokens ?? 0
const outputTokens = usage.completion_tokens ?? 0 const outputTokens = usage.completion_tokens ?? 0
const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined
const cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined let cacheReadTokens = usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? undefined
if (adjustCacheUsage && !cacheReadTokens) {
cacheReadTokens = Math.floor(inputTokens * 0.9)
}
return { return {
inputTokens: inputTokens - (cacheReadTokens ?? 0), inputTokens: inputTokens - (cacheReadTokens ?? 0),
outputTokens, outputTokens,
@@ -33,7 +33,7 @@ export type UsageInfo = {
cacheWrite1hTokens?: number cacheWrite1hTokens?: number
} }
export type ProviderHelper = (input: { reqModel: string; providerModel: string }) => { export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
format: ZenData.Format format: ZenData.Format
modifyUrl: (providerApi: string, isStream?: boolean) => string modifyUrl: (providerApi: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.2.27", "version": "1.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -42,7 +42,7 @@
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "catalog:", "@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.0", "@types/bun": "catalog:",
"@types/node": "catalog:", "@types/node": "catalog:",
"drizzle-kit": "catalog:", "drizzle-kit": "catalog:",
"mysql2": "3.14.4", "mysql2": "3.14.4",
+30 -14
View File
@@ -1,7 +1,14 @@
import { Database, and, eq, sql } from "../src/drizzle/index.js" import { Database, and, eq, sql } from "../src/drizzle/index.js"
import { AuthTable } from "../src/schema/auth.sql.js" import { AuthTable } from "../src/schema/auth.sql.js"
import { UserTable } from "../src/schema/user.sql.js" import { UserTable } from "../src/schema/user.sql.js"
import { BillingTable, PaymentTable, SubscriptionTable, BlackPlans, UsageTable } from "../src/schema/billing.sql.js" import {
BillingTable,
PaymentTable,
SubscriptionTable,
BlackPlans,
UsageTable,
LiteTable,
} from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js" import { WorkspaceTable } from "../src/schema/workspace.sql.js"
import { KeyTable } from "../src/schema/key.sql.js" import { KeyTable } from "../src/schema/key.sql.js"
import { BlackData } from "../src/black.js" import { BlackData } from "../src/black.js"
@@ -72,11 +79,13 @@ else {
workspaceID: UserTable.workspaceID, workspaceID: UserTable.workspaceID,
workspaceName: WorkspaceTable.name, workspaceName: WorkspaceTable.name,
role: UserTable.role, role: UserTable.role,
subscribed: SubscriptionTable.timeCreated, black: SubscriptionTable.timeCreated,
lite: LiteTable.timeCreated,
}) })
.from(UserTable) .from(UserTable)
.rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID)) .rightJoin(WorkspaceTable, eq(WorkspaceTable.id, UserTable.workspaceID))
.leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id)) .leftJoin(SubscriptionTable, eq(SubscriptionTable.userID, UserTable.id))
.leftJoin(LiteTable, eq(LiteTable.userID, UserTable.id))
.where(eq(UserTable.accountID, accountID)) .where(eq(UserTable.accountID, accountID))
.then((rows) => .then((rows) =>
rows.map((row) => ({ rows.map((row) => ({
@@ -84,7 +93,8 @@ else {
workspaceID: row.workspaceID, workspaceID: row.workspaceID,
workspaceName: row.workspaceName, workspaceName: row.workspaceName,
role: row.role, role: row.role,
subscribed: formatDate(row.subscribed), black: formatDate(row.black),
lite: formatDate(row.lite),
})), })),
), ),
) )
@@ -151,13 +161,14 @@ async function printWorkspace(workspaceID: string) {
balance: BillingTable.balance, balance: BillingTable.balance,
customerID: BillingTable.customerID, customerID: BillingTable.customerID,
reload: BillingTable.reload, reload: BillingTable.reload,
subscriptionID: BillingTable.subscriptionID, blackSubscriptionID: BillingTable.subscriptionID,
subscription: { blackSubscription: {
plan: BillingTable.subscriptionPlan, plan: BillingTable.subscriptionPlan,
booked: BillingTable.timeSubscriptionBooked, booked: BillingTable.timeSubscriptionBooked,
enrichment: BillingTable.subscription, enrichment: BillingTable.subscription,
}, },
timeSubscriptionSelected: BillingTable.timeSubscriptionSelected, timeBlackSubscriptionSelected: BillingTable.timeSubscriptionSelected,
liteSubscriptionID: BillingTable.liteSubscriptionID,
}) })
.from(BillingTable) .from(BillingTable)
.where(eq(BillingTable.workspaceID, workspace.id)) .where(eq(BillingTable.workspaceID, workspace.id))
@@ -167,16 +178,21 @@ async function printWorkspace(workspaceID: string) {
balance: `$${(row.balance / 100000000).toFixed(2)}`, balance: `$${(row.balance / 100000000).toFixed(2)}`,
reload: row.reload ? "yes" : "no", reload: row.reload ? "yes" : "no",
customerID: row.customerID, customerID: row.customerID,
subscriptionID: row.subscriptionID, liteSubscriptionID: row.liteSubscriptionID,
subscription: row.subscriptionID blackSubscriptionID: row.blackSubscriptionID,
blackSubscription: row.blackSubscriptionID
? [ ? [
`Black ${row.subscription.enrichment!.plan}`, `Black ${row.blackSubscription.enrichment!.plan}`,
row.subscription.enrichment!.seats > 1 ? `X ${row.subscription.enrichment!.seats} seats` : "", row.blackSubscription.enrichment!.seats > 1
row.subscription.enrichment!.coupon ? `(coupon: ${row.subscription.enrichment!.coupon})` : "", ? `X ${row.blackSubscription.enrichment!.seats} seats`
`(ref: ${row.subscriptionID})`, : "",
row.blackSubscription.enrichment!.coupon
? `(coupon: ${row.blackSubscription.enrichment!.coupon})`
: "",
`(ref: ${row.blackSubscriptionID})`,
].join(" ") ].join(" ")
: row.subscription.booked : row.blackSubscription.booked
? `Waitlist ${row.subscription.plan} plan${row.timeSubscriptionSelected ? " (selected)" : ""}` ? `Waitlist ${row.blackSubscription.plan} plan${row.timeBlackSubscriptionSelected ? " (selected)" : ""}`
: undefined, : undefined,
}))[0], }))[0],
), ),
+1
View File
@@ -48,6 +48,7 @@ export namespace ZenData {
headerMappings: z.record(z.string(), z.string()).optional(), headerMappings: z.record(z.string(), z.string()).optional(),
payloadModifier: z.record(z.string(), z.any()).optional(), payloadModifier: z.record(z.string(), z.any()).optional(),
payloadMappings: z.record(z.string(), z.string()).optional(), payloadMappings: z.record(z.string(), z.string()).optional(),
adjustCacheUsage: z.boolean().optional(),
}) })
const ModelsSchema = z.object({ const ModelsSchema = z.object({
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.2.27", "version": "1.3.1",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.2.27", "version": "1.3.1",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04
SHELL ["/bin/bash", "-lc"] SHELL ["/bin/bash", "-lc"]
ARG NODE_VERSION=24.4.0 ARG NODE_VERSION=24.4.0
ARG BUN_VERSION=1.3.5 ARG BUN_VERSION=1.3.11
ENV BUN_INSTALL=/opt/bun ENV BUN_INSTALL=/opt/bun
ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.2.27", "version": "1.3.1",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+2 -1
View File
@@ -35,6 +35,7 @@ export type CommandEvent =
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" } export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
export type CommandChild = { export type CommandChild = {
pid: number | undefined
kill: () => void kill: () => void
} }
@@ -191,7 +192,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
treeKill(child.pid) treeKill(child.pid)
} }
return { events, child: { kill }, exit } return { events, child: { pid: child.pid, kill }, exit }
} }
function handleSqliteProgress(events: EventEmitter, line: string) { function handleSqliteProgress(events: EventEmitter, line: string) {
@@ -81,6 +81,17 @@ function setupApp() {
killSidecar() killSidecar()
}) })
app.on("will-quit", () => {
killSidecar()
})
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
killSidecar()
app.exit(0)
})
}
void app.whenReady().then(async () => { void app.whenReady().then(async () => {
// migrate() // migrate()
app.setAsDefaultProtocolClient("opencode") app.setAsDefaultProtocolClient("opencode")
@@ -234,8 +245,15 @@ registerIpcHandlers({
function killSidecar() { function killSidecar() {
if (!sidecar) return if (!sidecar) return
const pid = sidecar.pid
sidecar.kill() sidecar.kill()
sidecar = null sidecar = null
// tree-kill is async; also send process group signal as immediate fallback
if (pid && process.platform !== "win32") {
try {
process.kill(-pid, "SIGTERM")
} catch {}
}
} }
function ensureLoopbackNoProxy() { function ensureLoopbackNoProxy() {
@@ -6,6 +6,9 @@ import {
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
handleNotificationClick, handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
type Platform, type Platform,
PlatformProvider, PlatformProvider,
ServerConnection, ServerConnection,
@@ -246,6 +249,17 @@ listenForDeepLinks()
render(() => { render(() => {
const platform = createPlatform() const platform = createPlatform()
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
if (!locale) return
const next = normalizeLocale(locale)
if (next !== "en") await loadLocaleDict(next)
return next satisfies Locale
}
const [windowCount] = createResource(() => window.api.getWindowCount()) const [windowCount] = createResource(() => window.api.getWindowCount())
@@ -257,6 +271,7 @@ render(() => {
if (url) return ServerConnection.key({ type: "http", http: { url } }) if (url) return ServerConnection.key({ type: "http", http: { url } })
}), }),
) )
const [locale] = createResource(loadLocale)
const servers = () => { const servers = () => {
const data = sidecar() const data = sidecar()
@@ -309,15 +324,14 @@ render(() => {
return ( return (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading}> <Show when={!defaultServer.loading && !sidecar.loading && !windowCount.loading && !locale.loading}>
{(_) => { {(_) => {
return ( return (
<AppInterface <AppInterface
defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")} defaultServer={defaultServer.latest ?? ServerConnection.Key.make("sidecar")}
servers={servers()} servers={servers()}
router={MemoryRouter} router={MemoryRouter}
disableHealthCheck={(windowCount() ?? 0) > 1}
> >
<Inner /> <Inner />
</AppInterface> </AppInterface>
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.2.27", "version": "1.3.1",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+17 -2
View File
@@ -6,6 +6,9 @@ import {
AppBaseProviders, AppBaseProviders,
AppInterface, AppInterface,
handleNotificationClick, handleNotificationClick,
loadLocaleDict,
normalizeLocale,
type Locale,
type Platform, type Platform,
PlatformProvider, PlatformProvider,
ServerConnection, ServerConnection,
@@ -414,6 +417,17 @@ void listenForDeepLinks()
render(() => { render(() => {
const platform = createPlatform() const platform = createPlatform()
const loadLocale = async () => {
const current = await platform.storage?.("opencode.global.dat").getItem("language")
const legacy = current ? undefined : await platform.storage?.().getItem("language.v1")
const raw = current ?? legacy
if (!raw) return
const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1]
if (!locale) return
const next = normalizeLocale(locale)
if (next !== "en") await loadLocaleDict(next)
return next satisfies Locale
}
// Fetch sidecar credentials from Rust (available immediately, before health check) // Fetch sidecar credentials from Rust (available immediately, before health check)
const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any)) const [sidecar] = createResource(() => commands.awaitInitialization(new Channel<InitStep>() as any))
@@ -423,6 +437,7 @@ render(() => {
if (url) return ServerConnection.key({ type: "http", http: { url } }) if (url) return ServerConnection.key({ type: "http", http: { url } })
}), }),
) )
const [locale] = createResource(loadLocale)
// Build the sidecar server connection once credentials arrive // Build the sidecar server connection once credentials arrive
const servers = () => { const servers = () => {
@@ -465,8 +480,8 @@ render(() => {
return ( return (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
<AppBaseProviders> <AppBaseProviders locale={locale.latest}>
<Show when={!defaultServer.loading && !sidecar.loading}> <Show when={!defaultServer.loading && !sidecar.loading && !locale.loading}>
{(_) => { {(_) => {
return ( return (
<AppInterface <AppInterface
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.2.27", "version": "1.3.1",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.2.27" version = "1.3.1"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.1/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.1/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.1/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.1/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.2.27/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.1/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.2.27", "version": "1.3.1",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
View File
+5 -4
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.2.27", "version": "1.3.1",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -101,8 +101,8 @@
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4", "@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.88", "@opentui/core": "0.1.90",
"@opentui/solid": "0.1.88", "@opentui/solid": "0.1.90",
"@parcel/watcher": "2.5.1", "@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:", "@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
@@ -121,7 +121,7 @@
"drizzle-orm": "catalog:", "drizzle-orm": "catalog:",
"effect": "catalog:", "effect": "catalog:",
"fuzzysort": "3.1.0", "fuzzysort": "3.1.0",
"gitlab-ai-provider": "5.2.2", "gitlab-ai-provider": "5.3.2",
"glob": "13.0.5", "glob": "13.0.5",
"google-auth-library": "10.5.0", "google-auth-library": "10.5.0",
"gray-matter": "4.0.3", "gray-matter": "4.0.3",
@@ -135,6 +135,7 @@
"opencode-gitlab-auth": "2.0.0", "opencode-gitlab-auth": "2.0.0",
"opentui-spinner": "0.0.6", "opentui-spinner": "0.0.6",
"partial-json": "0.1.7", "partial-json": "0.1.7",
"opencode-poe-auth": "0.0.1",
"remeda": "catalog:", "remeda": "catalog:",
"semver": "^7.6.3", "semver": "^7.6.3",
"solid-js": "catalog:", "solid-js": "catalog:",
+36
View File
@@ -101,6 +101,14 @@ export default {
], ],
}, },
}, },
{
filetype: "kotlin",
wasm: "https://github.com/fwcd/tree-sitter-kotlin/releases/download/0.3.8/tree-sitter-kotlin.wasm",
queries: {
highlights: ["https://raw.githubusercontent.com/fwcd/tree-sitter-kotlin/0.3.8/queries/highlights.scm"],
locals: ["https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/kotlin/locals.scm"],
},
},
{ {
filetype: "ruby", filetype: "ruby",
wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm", wasm: "https://github.com/tree-sitter/tree-sitter-ruby/releases/download/v0.23.1/tree-sitter-ruby.wasm",
@@ -158,6 +166,15 @@ export default {
// }, // },
// }, // },
}, },
{
filetype: "hcl",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-hcl/releases/download/v1.2.0/tree-sitter-hcl.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/hcl/highlights.scm",
],
},
},
{ {
filetype: "json", filetype: "json",
wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm", wasm: "https://github.com/tree-sitter/tree-sitter-json/releases/download/v0.24.8/tree-sitter-json.wasm",
@@ -203,6 +220,16 @@ export default {
], ],
}, },
}, },
{
filetype: "lua",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-lua/releases/download/v0.5.0/tree-sitter-lua.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/highlights.scm",
],
locals: ["https://raw.githubusercontent.com/tree-sitter-grammars/tree-sitter-lua/v0.5.0/queries/locals.scm"],
},
},
{ {
filetype: "ocaml", filetype: "ocaml",
wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm", wasm: "https://github.com/tree-sitter/tree-sitter-ocaml/releases/download/v0.24.2/tree-sitter-ocaml.wasm",
@@ -236,6 +263,15 @@ export default {
], ],
}, },
}, },
{
filetype: "toml",
wasm: "https://github.com/tree-sitter-grammars/tree-sitter-toml/releases/download/v0.7.0/tree-sitter-toml.wasm",
queries: {
highlights: [
"https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/master/queries/toml/highlights.scm",
],
},
},
{ {
filetype: "nix", filetype: "nix",
// TODO: Replace with official tree-sitter-nix WASM when published // TODO: Replace with official tree-sitter-nix WASM when published
+1 -1
View File
@@ -173,6 +173,6 @@ Still open and likely worth migrating:
- [ ] `SessionPrompt` - [ ] `SessionPrompt`
- [ ] `SessionCompaction` - [ ] `SessionCompaction`
- [ ] `Provider` - [ ] `Provider`
- [ ] `Project` - [x] `Project`
- [ ] `LSP` - [ ] `LSP`
- [ ] `MCP` - [ ] `MCP`
+340 -269
View File
@@ -3,7 +3,6 @@ import z from "zod"
import { Provider } from "../provider/provider" import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema" import { ModelID, ProviderID } from "../provider/schema"
import { generateObject, streamObject, type ModelMessage } from "ai" import { generateObject, streamObject, type ModelMessage } from "ai"
import { SystemPrompt } from "../session/system"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Truncate } from "../tool/truncate" import { Truncate } from "../tool/truncate"
import { Auth } from "../auth" import { Auth } from "../auth"
@@ -20,6 +19,9 @@ import { Global } from "@/global"
import path from "path" import path from "path"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Skill } from "../skill" import { Skill } from "../skill"
import { Effect, ServiceMap, Layer } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service"
export namespace Agent { export namespace Agent {
export const Info = z export const Info = z
@@ -49,295 +51,364 @@ export namespace Agent {
}) })
export type Info = z.infer<typeof Info> export type Info = z.infer<typeof Info>
const state = Instance.state(async () => { export interface Interface {
const cfg = await Config.get() readonly get: (agent: string) => Effect.Effect<Agent.Info>
readonly list: () => Effect.Effect<Agent.Info[]>
readonly defaultAgent: () => Effect.Effect<string>
readonly generate: (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) => Effect.Effect<{
identifier: string
whenToUse: string
systemPrompt: string
}>
}
const skillDirs = await Skill.dirs() type State = Omit<Interface, "generate">
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
const defaults = Permission.fromConfig({
"*": "allow",
doom_loop: "ask",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
question: "deny",
plan_enter: "deny",
plan_exit: "deny",
// mirrors github.com/github/gitignore Node.gitignore pattern for .env files
read: {
"*": "allow",
"*.env": "ask",
"*.env.*": "ask",
"*.env.example": "allow",
},
})
const user = Permission.fromConfig(cfg.permission ?? {})
const result: Record<string, Info> = { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Agent") {}
build: {
name: "build", export const layer = Layer.effect(
description: "The default agent. Executes tools based on configured permissions.", Service,
options: {}, Effect.gen(function* () {
permission: Permission.merge( const config = () => Effect.promise(() => Config.get())
defaults, const auth = yield* Auth.Service
Permission.fromConfig({
question: "allow", const state = yield* InstanceState.make<State>(
plan_enter: "allow", Effect.fn("Agent.state")(function* (ctx) {
}), const cfg = yield* config()
user, const skillDirs = yield* Effect.promise(() => Skill.dirs())
), const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
mode: "primary",
native: true, const defaults = Permission.fromConfig({
}, "*": "allow",
plan: { doom_loop: "ask",
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: { external_directory: {
"*": "ask", "*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])), ...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
}, },
}), question: "deny",
user, plan_enter: "deny",
), plan_exit: "deny",
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`, // mirrors github.com/github/gitignore Node.gitignore pattern for .env files
prompt: PROMPT_EXPLORE, read: {
options: {}, "*": "allow",
mode: "subagent", "*.env": "ask",
native: true, "*.env.*": "ask",
}, "*.env.example": "allow",
compaction: { },
name: "compaction", })
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
for (const [key, value] of Object.entries(cfg.agent ?? {})) { const user = Permission.fromConfig(cfg.permission ?? {})
if (value.disable) {
delete result[key]
continue
}
let item = result[key]
if (!item)
item = result[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.GLOB is allowed unless explicitly configured const agents: Record<string, Info> = {
for (const name in result) { build: {
const agent = result[name] name: "build",
const explicit = agent.permission.some((r) => { description: "The default agent. Executes tools based on configured permissions.",
if (r.permission !== "external_directory") return false options: {},
if (r.action !== "deny") return false permission: Permission.merge(
return r.pattern === Truncate.GLOB defaults,
}) Permission.fromConfig({
if (explicit) continue question: "allow",
plan_enter: "allow",
}),
user,
),
mode: "primary",
native: true,
},
plan: {
name: "plan",
description: "Plan mode. Disallows all edit tools.",
options: {},
permission: Permission.merge(
defaults,
Permission.fromConfig({
question: "allow",
plan_exit: "allow",
external_directory: {
[path.join(Global.Path.data, "plans", "*")]: "allow",
},
edit: {
"*": "deny",
[path.join(".opencode", "plans", "*.md")]: "allow",
[path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]:
"allow",
},
}),
user,
),
mode: "primary",
native: true,
},
general: {
name: "general",
description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
permission: Permission.merge(
defaults,
Permission.fromConfig({
todoread: "deny",
todowrite: "deny",
}),
user,
),
options: {},
mode: "subagent",
native: true,
},
explore: {
name: "explore",
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
grep: "allow",
glob: "allow",
list: "allow",
bash: "allow",
webfetch: "allow",
websearch: "allow",
codesearch: "allow",
read: "allow",
external_directory: {
"*": "ask",
...Object.fromEntries(whitelistedDirs.map((dir) => [dir, "allow"])),
},
}),
user,
),
description: `Fast agent specialized for exploring codebases. Use this when you need to quickly find files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, or "very thorough" for comprehensive analysis across multiple locations and naming conventions.`,
prompt: PROMPT_EXPLORE,
options: {},
mode: "subagent",
native: true,
},
compaction: {
name: "compaction",
mode: "primary",
native: true,
hidden: true,
prompt: PROMPT_COMPACTION,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
options: {},
},
title: {
name: "title",
mode: "primary",
options: {},
native: true,
hidden: true,
temperature: 0.5,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_TITLE,
},
summary: {
name: "summary",
mode: "primary",
options: {},
native: true,
hidden: true,
permission: Permission.merge(
defaults,
Permission.fromConfig({
"*": "deny",
}),
user,
),
prompt: PROMPT_SUMMARY,
},
}
result[name].permission = Permission.merge( for (const [key, value] of Object.entries(cfg.agent ?? {})) {
result[name].permission, if (value.disable) {
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }), delete agents[key]
continue
}
let item = agents[key]
if (!item)
item = agents[key] = {
name: key,
mode: "all",
permission: Permission.merge(defaults, user),
options: {},
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
item.topP = value.top_p ?? item.topP
item.mode = value.mode ?? item.mode
item.color = value.color ?? item.color
item.hidden = value.hidden ?? item.hidden
item.name = value.name ?? item.name
item.steps = value.steps ?? item.steps
item.options = mergeDeep(item.options, value.options ?? {})
item.permission = Permission.merge(item.permission, Permission.fromConfig(value.permission ?? {}))
}
// Ensure Truncate.GLOB is allowed unless explicitly configured
for (const name in agents) {
const agent = agents[name]
const explicit = agent.permission.some((r) => {
if (r.permission !== "external_directory") return false
if (r.action !== "deny") return false
return r.pattern === Truncate.GLOB
})
if (explicit) continue
agents[name].permission = Permission.merge(
agents[name].permission,
Permission.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
)
}
const get = Effect.fnUntraced(function* (agent: string) {
return agents[agent]
})
const list = Effect.fnUntraced(function* () {
const cfg = yield* config()
return pipe(
agents,
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
})
const defaultAgent = Effect.fnUntraced(function* () {
const c = yield* config()
if (c.default_agent) {
const agent = agents[c.default_agent]
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${c.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${c.default_agent}" is hidden`)
return agent.name
}
const visible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!visible) throw new Error("no primary visible agent found")
return visible.name
})
return {
get,
list,
defaultAgent,
} satisfies State
}),
) )
}
return result return Service.of({
}) get: Effect.fn("Agent.get")(function* (agent: string) {
return yield* InstanceState.useEffect(state, (s) => s.get(agent))
}),
list: Effect.fn("Agent.list")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.list())
}),
defaultAgent: Effect.fn("Agent.defaultAgent")(function* () {
return yield* InstanceState.useEffect(state, (s) => s.defaultAgent())
}),
generate: Effect.fn("Agent.generate")(function* (input: {
description: string
model?: { providerID: ProviderID; modelID: ModelID }
}) {
const cfg = yield* config()
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
const system = [PROMPT_GENERATE]
yield* Effect.promise(() =>
Plugin.trigger("experimental.chat.system.transform", { model: resolved }, { system }),
)
const existing = yield* InstanceState.useEffect(state, (s) => s.list())
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
userId: cfg.username ?? "unknown",
},
},
temperature: 0.3,
messages: [
...system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
),
{
role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over
const authInfo = yield* auth.get(model.providerID).pipe(Effect.orDie)
if (model.providerID === "openai" && authInfo?.type === "oauth") {
return yield* Effect.promise(async () => {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(resolved, {
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
})
}
return yield* Effect.promise(() => generateObject(params).then((r) => r.object))
}),
})
}),
)
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
const runPromise = makeRunPromise(Service, defaultLayer)
export async function get(agent: string) { export async function get(agent: string) {
return state().then((x) => x[agent]) return runPromise((svc) => svc.get(agent))
} }
export async function list() { export async function list() {
const cfg = await Config.get() return runPromise((svc) => svc.list())
return pipe(
await state(),
values(),
sortBy(
[(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
[(x) => x.name, "asc"],
),
)
} }
export async function defaultAgent() { export async function defaultAgent() {
const cfg = await Config.get() return runPromise((svc) => svc.defaultAgent())
const agents = await state()
if (cfg.default_agent) {
const agent = agents[cfg.default_agent]
if (!agent) throw new Error(`default agent "${cfg.default_agent}" not found`)
if (agent.mode === "subagent") throw new Error(`default agent "${cfg.default_agent}" is a subagent`)
if (agent.hidden === true) throw new Error(`default agent "${cfg.default_agent}" is hidden`)
return agent.name
}
const primaryVisible = Object.values(agents).find((a) => a.mode !== "subagent" && a.hidden !== true)
if (!primaryVisible) throw new Error("no primary visible agent found")
return primaryVisible.name
} }
export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) { export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) {
const cfg = await Config.get() return runPromise((svc) => svc.generate(input))
const defaultModel = input.model ?? (await Provider.defaultModel())
const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
const language = await Provider.getLanguage(model)
const system = [PROMPT_GENERATE]
await Plugin.trigger("experimental.chat.system.transform", { model }, { system })
const existing = await list()
const params = {
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
metadata: {
userId: cfg.username ?? "unknown",
},
},
temperature: 0.3,
messages: [
...system.map(
(item): ModelMessage => ({
role: "system",
content: item,
}),
),
{
role: "user",
content: `Create an agent configuration based on this request: \"${input.description}\".\n\nIMPORTANT: The following identifiers already exist and must NOT be used: ${existing.map((i) => i.name).join(", ")}\n Return ONLY the JSON object, no other text, do not wrap in backticks`,
},
],
model: language,
schema: z.object({
identifier: z.string(),
whenToUse: z.string(),
systemPrompt: z.string(),
}),
} satisfies Parameters<typeof generateObject>[0]
// TODO: clean this up so provider specific logic doesnt bleed over
if (defaultModel.providerID === "openai" && (await Auth.get(defaultModel.providerID))?.type === "oauth") {
const result = streamObject({
...params,
providerOptions: ProviderTransform.providerOptions(model, {
store: false,
}),
onError: () => {},
})
for await (const part of result.fullStream) {
if (part.type === "error") throw part.error
}
return result.object
}
const result = await generateObject(params)
return result.object
} }
} }
+46 -10
View File
@@ -10,6 +10,26 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
const println = (msg: string) => Effect.sync(() => UI.println(msg)) const println = (msg: string) => Effect.sync(() => UI.println(msg))
const dim = (value: string) => UI.Style.TEXT_DIM + value + UI.Style.TEXT_NORMAL
const activeSuffix = (isActive: boolean) => (isActive ? dim(" (active)") : "")
export const formatAccountLabel = (account: { email: string; url: string }, isActive: boolean) =>
`${account.email} ${dim(account.url)}${activeSuffix(isActive)}`
const formatOrgChoiceLabel = (account: { email: string }, org: { name: string }, isActive: boolean) =>
`${org.name} (${account.email})${activeSuffix(isActive)}`
export const formatOrgLine = (
account: { email: string; url: string },
org: { id: string; name: string },
isActive: boolean,
) => {
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
return ` ${dot} ${name} ${dim(account.email)} ${dim(account.url)} ${dim(org.id)}`
}
const isActiveOrgChoice = ( const isActiveOrgChoice = (
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>, active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
choice: { accountID: AccountID; orgID: OrgID }, choice: { accountID: AccountID; orgID: OrgID },
@@ -76,10 +96,9 @@ const logoutEffect = Effect.fn("logout")(function* (email?: string) {
const opts = accounts.map((a) => { const opts = accounts.map((a) => {
const isActive = Option.isSome(activeID) && activeID.value === a.id const isActive = Option.isSome(activeID) && activeID.value === a.id
const server = UI.Style.TEXT_DIM + a.url + UI.Style.TEXT_NORMAL
return { return {
value: a, value: a,
label: isActive ? `${a.email} ${server}` + UI.Style.TEXT_DIM + " (active)" : `${a.email} ${server}`, label: formatAccountLabel(a, isActive),
} }
}) })
@@ -109,9 +128,7 @@ const switchEffect = Effect.fn("switch")(function* () {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
return { return {
value: { orgID: org.id, accountID: group.account.id, label: org.name }, value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive label: formatOrgChoiceLabel(group.account, org, isActive),
? `${org.name} (${group.account.email})` + UI.Style.TEXT_DIM + " (active)"
: `${org.name} (${group.account.email})`,
} }
}), }),
) )
@@ -139,15 +156,21 @@ const orgsEffect = Effect.fn("orgs")(function* () {
for (const group of groups) { for (const group of groups) {
for (const org of group.orgs) { for (const org of group.orgs) {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id }) const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " " yield* println(formatOrgLine(group.account, org, isActive))
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL
const id = UI.Style.TEXT_DIM + org.id + UI.Style.TEXT_NORMAL
yield* println(` ${dot} ${name} ${email} ${id}`)
} }
} }
}) })
const openEffect = Effect.fn("open")(function* () {
const service = yield* Account.Service
const active = yield* service.active()
if (Option.isNone(active)) return yield* println("No active account")
const url = active.value.url
yield* openBrowser(url)
yield* Prompt.outro("Opened " + url)
})
export const LoginCommand = cmd({ export const LoginCommand = cmd({
command: "login <url>", command: "login <url>",
describe: false, describe: false,
@@ -195,6 +218,15 @@ export const OrgsCommand = cmd({
}, },
}) })
export const OpenCommand = cmd({
command: "open",
describe: false,
async handler() {
UI.empty()
await Account.runPromise((_svc) => openEffect())
},
})
export const ConsoleCommand = cmd({ export const ConsoleCommand = cmd({
command: "console", command: "console",
describe: false, describe: false,
@@ -216,6 +248,10 @@ export const ConsoleCommand = cmd({
...OrgsCommand, ...OrgsCommand,
describe: "list orgs", describe: "list orgs",
}) })
.command({
...OpenCommand,
describe: "open active console account",
})
.demandCommand(), .demandCommand(),
async handler() {}, async handler() {},
}) })
+57 -7
View File
@@ -5,8 +5,8 @@ import { MouseButton, TextAttributes } from "@opentui/core"
import { RouteProvider, useRoute } from "@tui/context/route" import { RouteProvider, useRoute } from "@tui/context/route"
import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js"
import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32"
import { Installation } from "@/installation"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import semver from "semver"
import { DialogProvider, useDialog } from "@tui/ui/dialog" import { DialogProvider, useDialog } from "@tui/ui/dialog"
import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider" import { DialogProvider as DialogProviderList } from "@tui/component/dialog-provider"
import { SDKProvider, useSDK } from "@tui/context/sdk" import { SDKProvider, useSDK } from "@tui/context/sdk"
@@ -29,6 +29,7 @@ import { PromptHistoryProvider } from "./component/prompt/history"
import { FrecencyProvider } from "./component/prompt/frecency" import { FrecencyProvider } from "./component/prompt/frecency"
import { PromptStashProvider } from "./component/prompt/stash" import { PromptStashProvider } from "./component/prompt/stash"
import { DialogAlert } from "./ui/dialog-alert" import { DialogAlert } from "./ui/dialog-alert"
import { DialogConfirm } from "./ui/dialog-confirm"
import { ToastProvider, useToast } from "./ui/toast" import { ToastProvider, useToast } from "./ui/toast"
import { ExitProvider, useExit } from "./context/exit" import { ExitProvider, useExit } from "./context/exit"
import { Session as SessionApi } from "@/session" import { Session as SessionApi } from "@/session"
@@ -103,6 +104,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
} }
import type { EventSource } from "./context/sdk" import type { EventSource } from "./context/sdk"
import { Installation } from "@/installation"
export function tui(input: { export function tui(input: {
url: string url: string
@@ -210,7 +212,7 @@ function App() {
const command = useCommandDialog() const command = useCommandDialog()
const sdk = useSDK() const sdk = useSDK()
const toast = useToast() const toast = useToast()
const { theme, mode, setMode } = useTheme() const { theme, mode, setMode, locked, lock, unlock } = useTheme()
const sync = useSync() const sync = useSync()
const exit = useExit() const exit = useExit()
const promptRef = usePromptRef() const promptRef = usePromptRef()
@@ -555,7 +557,7 @@ function App() {
category: "System", category: "System",
}, },
{ {
title: "Toggle appearance", title: "Toggle Theme Mode",
value: "theme.switch_mode", value: "theme.switch_mode",
onSelect: (dialog) => { onSelect: (dialog) => {
setMode(mode() === "dark" ? "light" : "dark") setMode(mode() === "dark" ? "light" : "dark")
@@ -563,6 +565,16 @@ function App() {
}, },
category: "System", category: "System",
}, },
{
title: locked() ? "Unlock Theme Mode" : "Lock Theme Mode",
value: "theme.mode.lock",
onSelect: (dialog) => {
if (locked()) unlock()
else lock()
dialog.clear()
},
category: "System",
},
{ {
title: "Help", title: "Help",
value: "help.show", value: "help.show",
@@ -729,13 +741,51 @@ function App() {
}) })
}) })
sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => { sdk.event.on("installation.update-available", async (evt) => {
const version = evt.properties.version
const skipped = kv.get("skipped_version")
if (skipped && !semver.gt(version, skipped)) return
const choice = await DialogConfirm.show(
dialog,
`Update Available`,
`A new release v${version} is available. Would you like to update now?`,
"skip",
)
if (choice === false) {
kv.set("skipped_version", version)
return
}
if (choice !== true) return
toast.show({ toast.show({
variant: "info", variant: "info",
title: "Update Available", message: `Updating to v${version}...`,
message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`, duration: 30000,
duration: 10000,
}) })
const result = await sdk.client.global.upgrade({ target: version })
if (result.error || !result.data?.success) {
toast.show({
variant: "error",
title: "Update Failed",
message: "Update failed",
duration: 10000,
})
return
}
await DialogAlert.show(
dialog,
"Update Complete",
`Successfully updated to OpenCode v${result.data.version}. Please restart the application.`,
)
exit()
}) })
return ( return (
@@ -53,6 +53,7 @@ export const { use: useExit, provider: ExitProvider } = createSimpleContext({
message: store, message: store,
}, },
) )
process.on("SIGHUP", () => exit())
return exit return exit
}, },
}) })
@@ -1,6 +1,6 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core" import { CliRenderEvents, SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path" import path from "path"
import { createEffect, createMemo, onMount } from "solid-js" import { createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createSimpleContext } from "./helper" import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob" import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" } import aura from "./theme/aura.json" with { type: "json" }
@@ -280,11 +280,18 @@ function ansiToRgba(code: number): RGBA {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme", name: "Theme",
init: (props: { mode: "dark" | "light" }) => { init: (props: { mode: "dark" | "light" }) => {
const renderer = useRenderer()
const config = useTuiConfig() const config = useTuiConfig()
const kv = useKV() const kv = useKV()
const pick = (value: unknown) => {
if (value === "dark" || value === "light") return value
return
}
const lock = pick(kv.get("theme_mode_lock"))
const [store, setStore] = createStore({ const [store, setStore] = createStore({
themes: DEFAULT_THEMES, themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode), mode: lock ?? pick(kv.get("theme_mode", props.mode)) ?? props.mode,
lock,
active: (config.theme ?? kv.get("theme", "opencode")) as string, active: (config.theme ?? kv.get("theme", "opencode")) as string,
ready: false, ready: false,
}) })
@@ -295,7 +302,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}) })
function init() { function init() {
resolveSystemTheme() resolveSystemTheme(store.mode)
getCustomThemes() getCustomThemes()
.then((custom) => { .then((custom) => {
setStore( setStore(
@@ -316,14 +323,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
onMount(init) onMount(init)
function resolveSystemTheme() { function resolveSystemTheme(mode: "dark" | "light" = store.mode) {
console.log("resolveSystemTheme")
renderer renderer
.getPalette({ .getPalette({
size: 16, size: 16,
}) })
.then((colors) => { .then((colors) => {
console.log(colors.palette)
if (!colors.palette[0]) { if (!colors.palette[0]) {
if (store.active === "system") { if (store.active === "system") {
setStore( setStore(
@@ -337,7 +342,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
} }
setStore( setStore(
produce((draft) => { produce((draft) => {
draft.themes.system = generateSystem(colors, store.mode) draft.themes.system = generateSystem(colors, mode)
if (store.active === "system") { if (store.active === "system") {
draft.ready = true draft.ready = true
} }
@@ -346,16 +351,44 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
}) })
} }
const renderer = useRenderer() function apply(mode: "dark" | "light") {
process.on("SIGUSR2", async () => { kv.set("theme_mode", mode)
if (store.mode === mode) return
setStore("mode", mode)
renderer.clearPaletteCache() renderer.clearPaletteCache()
init() resolveSystemTheme(mode)
}
function pin(mode: "dark" | "light" = store.mode) {
setStore("lock", mode)
kv.set("theme_mode_lock", mode)
apply(mode)
}
function free() {
setStore("lock", undefined)
kv.set("theme_mode_lock", undefined)
const mode = renderer.themeMode
if (mode) apply(mode)
}
const handle = (mode: "dark" | "light") => {
if (store.lock) return
apply(mode)
}
renderer.on(CliRenderEvents.THEME_MODE, handle)
onCleanup(() => {
renderer.off(CliRenderEvents.THEME_MODE, handle)
}) })
const values = createMemo(() => { const values = createMemo(() => {
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode) return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
}) })
createEffect(() => {
renderer.setBackgroundColor(values().background)
})
const syntax = createMemo(() => generateSyntax(values())) const syntax = createMemo(() => generateSyntax(values()))
const subtleSyntax = createMemo(() => generateSubtleSyntax(values())) const subtleSyntax = createMemo(() => generateSubtleSyntax(values()))
@@ -377,9 +410,17 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
mode() { mode() {
return store.mode return store.mode
}, },
locked() {
return store.lock !== undefined
},
lock() {
pin(store.mode)
},
unlock() {
free()
},
setMode(mode: "dark" | "light") { setMode(mode: "dark" | "light") {
setStore("mode", mode) pin(mode)
kv.set("theme_mode", mode)
}, },
set(theme: string) { set(theme: string) {
setStore("active", theme) setStore("active", theme)
@@ -428,7 +469,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson { function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!) const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!) const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
const transparent = RGBA.fromInts(0, 0, 0, 0) const transparent = RGBA.fromValues(bg.r, bg.g, bg.b, 0)
const isDark = mode == "dark" const isDark = mode == "dark"
const col = (i: number) => { const col = (i: number) => {
@@ -1465,6 +1465,8 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
streaming={true} streaming={true}
content={props.part.text.trim()} content={props.part.text.trim()}
conceal={ctx.conceal()} conceal={ctx.conceal()}
fg={theme.markdownText}
bg={theme.background}
/> />
</Match> </Match>
<Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}> <Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
@@ -11,8 +11,11 @@ export type DialogConfirmProps = {
message: string message: string
onConfirm?: () => void onConfirm?: () => void
onCancel?: () => void onCancel?: () => void
label?: string
} }
export type DialogConfirmResult = boolean | undefined
export function DialogConfirm(props: DialogConfirmProps) { export function DialogConfirm(props: DialogConfirmProps) {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
@@ -45,7 +48,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
<text fg={theme.textMuted}>{props.message}</text> <text fg={theme.textMuted}>{props.message}</text>
</box> </box>
<box flexDirection="row" justifyContent="flex-end" paddingBottom={1}> <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
<For each={["cancel", "confirm"]}> <For each={["cancel", "confirm"] as const}>
{(key) => ( {(key) => (
<box <box
paddingLeft={1} paddingLeft={1}
@@ -58,7 +61,7 @@ export function DialogConfirm(props: DialogConfirmProps) {
}} }}
> >
<text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}> <text fg={key === store.active ? theme.selectedListItemText : theme.textMuted}>
{Locale.titlecase(key)} {Locale.titlecase(key === "cancel" ? (props.label ?? key) : key)}
</text> </text>
</box> </box>
)} )}
@@ -68,8 +71,8 @@ export function DialogConfirm(props: DialogConfirmProps) {
) )
} }
DialogConfirm.show = (dialog: DialogContext, title: string, message: string) => { DialogConfirm.show = (dialog: DialogContext, title: string, message: string, label?: string) => {
return new Promise<boolean>((resolve) => { return new Promise<DialogConfirmResult>((resolve) => {
dialog.replace( dialog.replace(
() => ( () => (
<DialogConfirm <DialogConfirm
@@ -77,9 +80,10 @@ DialogConfirm.show = (dialog: DialogContext, title: string, message: string) =>
message={message} message={message}
onConfirm={() => resolve(true)} onConfirm={() => resolve(true)}
onCancel={() => resolve(false)} onCancel={() => resolve(false)}
label={label}
/> />
), ),
() => resolve(false), () => resolve(undefined),
) )
}) })
} }
+9 -3
View File
@@ -8,12 +8,18 @@ export async function upgrade() {
const method = await Installation.method() const method = await Installation.method()
const latest = await Installation.latest(method).catch(() => {}) const latest = await Installation.latest(method).catch(() => {})
if (!latest) return if (!latest) return
if (Installation.VERSION === latest) return
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) { if (Flag.OPENCODE_ALWAYS_NOTIFY_UPDATE) {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return return
} }
if (config.autoupdate === "notify") {
if (Installation.VERSION === latest) return
if (config.autoupdate === false || Flag.OPENCODE_DISABLE_AUTOUPDATE) return
const kind = Installation.getReleaseType(Installation.VERSION, latest)
if (config.autoupdate === "notify" || kind !== "patch") {
await Bus.publish(Installation.Event.UpdateAvailable, { version: latest }) await Bus.publish(Installation.Event.UpdateAvailable, { version: latest })
return return
} }
@@ -0,0 +1,476 @@
import type * as Arr from "effect/Array"
import { NodeSink, NodeStream } from "@effect/platform-node"
import * as Deferred from "effect/Deferred"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as FileSystem from "effect/FileSystem"
import * as Layer from "effect/Layer"
import * as Path from "effect/Path"
import * as PlatformError from "effect/PlatformError"
import * as Predicate from "effect/Predicate"
import type * as Scope from "effect/Scope"
import * as Sink from "effect/Sink"
import * as Stream from "effect/Stream"
import * as ChildProcess from "effect/unstable/process/ChildProcess"
import type { ChildProcessHandle } from "effect/unstable/process/ChildProcessSpawner"
import {
ChildProcessSpawner,
ExitCode,
make as makeSpawner,
makeHandle,
ProcessId,
} from "effect/unstable/process/ChildProcessSpawner"
import * as NodeChildProcess from "node:child_process"
import { PassThrough } from "node:stream"
import launch from "cross-spawn"
const toError = (err: unknown): Error => (err instanceof globalThis.Error ? err : new globalThis.Error(String(err)))
const toTag = (err: NodeJS.ErrnoException): PlatformError.SystemErrorTag => {
switch (err.code) {
case "ENOENT":
return "NotFound"
case "EACCES":
return "PermissionDenied"
case "EEXIST":
return "AlreadyExists"
case "EISDIR":
return "BadResource"
case "ENOTDIR":
return "BadResource"
case "EBUSY":
return "Busy"
case "ELOOP":
return "BadResource"
default:
return "Unknown"
}
}
const flatten = (command: ChildProcess.Command) => {
const commands: Array<ChildProcess.StandardCommand> = []
const opts: Array<ChildProcess.PipeOptions> = []
const walk = (cmd: ChildProcess.Command): void => {
switch (cmd._tag) {
case "StandardCommand":
commands.push(cmd)
return
case "PipedCommand":
walk(cmd.left)
opts.push(cmd.options)
walk(cmd.right)
return
}
}
walk(command)
if (commands.length === 0) throw new Error("flatten produced empty commands array")
const [head, ...tail] = commands
return {
commands: [head, ...tail] as Arr.NonEmptyReadonlyArray<ChildProcess.StandardCommand>,
opts,
}
}
const toPlatformError = (
method: string,
err: NodeJS.ErrnoException,
command: ChildProcess.Command,
): PlatformError.PlatformError => {
const cmd = flatten(command)
.commands.map((x) => `${x.command} ${x.args.join(" ")}`)
.join(" | ")
return PlatformError.systemError({
_tag: toTag(err),
module: "ChildProcess",
method,
pathOrDescriptor: cmd,
syscall: err.syscall,
cause: err,
})
}
type ExitSignal = Deferred.Deferred<readonly [code: number | null, signal: NodeJS.Signals | null]>
export const make = Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const cwd = Effect.fnUntraced(function* (opts: ChildProcess.CommandOptions) {
if (Predicate.isUndefined(opts.cwd)) return undefined
yield* fs.access(opts.cwd)
return path.resolve(opts.cwd)
})
const env = (opts: ChildProcess.CommandOptions) =>
opts.extendEnv ? { ...globalThis.process.env, ...opts.env } : opts.env
const input = (x: ChildProcess.CommandInput | undefined): NodeChildProcess.IOType | undefined =>
Stream.isStream(x) ? "pipe" : x
const output = (x: ChildProcess.CommandOutput | undefined): NodeChildProcess.IOType | undefined =>
Sink.isSink(x) ? "pipe" : x
const stdin = (opts: ChildProcess.CommandOptions): ChildProcess.StdinConfig => {
const cfg: ChildProcess.StdinConfig = { stream: "pipe", encoding: "utf-8", endOnDone: true }
if (Predicate.isUndefined(opts.stdin)) return cfg
if (typeof opts.stdin === "string") return { ...cfg, stream: opts.stdin }
if (Stream.isStream(opts.stdin)) return { ...cfg, stream: opts.stdin }
return {
stream: opts.stdin.stream,
encoding: opts.stdin.encoding ?? cfg.encoding,
endOnDone: opts.stdin.endOnDone ?? cfg.endOnDone,
}
}
const stdio = (opts: ChildProcess.CommandOptions, key: "stdout" | "stderr"): ChildProcess.StdoutConfig => {
const cfg = opts[key]
if (Predicate.isUndefined(cfg)) return { stream: "pipe" }
if (typeof cfg === "string") return { stream: cfg }
if (Sink.isSink(cfg)) return { stream: cfg }
return { stream: cfg.stream }
}
const fds = (opts: ChildProcess.CommandOptions) => {
if (Predicate.isUndefined(opts.additionalFds)) return []
return Object.entries(opts.additionalFds)
.flatMap(([name, config]) => {
const fd = ChildProcess.parseFdName(name)
return Predicate.isUndefined(fd) ? [] : [{ fd, config }]
})
.toSorted((a, b) => a.fd - b.fd)
}
const stdios = (
sin: ChildProcess.StdinConfig,
sout: ChildProcess.StdoutConfig,
serr: ChildProcess.StderrConfig,
extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
): NodeChildProcess.StdioOptions => {
const pipe = (x: NodeChildProcess.IOType | undefined) =>
process.platform === "win32" && x === "pipe" ? "overlapped" : x
const arr: Array<NodeChildProcess.IOType | undefined> = [
pipe(input(sin.stream)),
pipe(output(sout.stream)),
pipe(output(serr.stream)),
]
if (extra.length === 0) return arr as NodeChildProcess.StdioOptions
const max = extra.reduce((acc, x) => Math.max(acc, x.fd), 2)
for (let i = 3; i <= max; i++) arr[i] = "ignore"
for (const x of extra) arr[x.fd] = pipe("pipe")
return arr as NodeChildProcess.StdioOptions
}
const setupFds = Effect.fnUntraced(function* (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
extra: ReadonlyArray<{ fd: number; config: ChildProcess.AdditionalFdConfig }>,
) {
if (extra.length === 0) {
return {
getInputFd: () => Sink.drain,
getOutputFd: () => Stream.empty,
}
}
const ins = new Map<number, Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError>>()
const outs = new Map<number, Stream.Stream<Uint8Array, PlatformError.PlatformError>>()
for (const x of extra) {
const node = proc.stdio[x.fd]
switch (x.config.type) {
case "input": {
let sink: Sink.Sink<void, Uint8Array, never, PlatformError.PlatformError> = Sink.drain
if (node && "write" in node) {
sink = NodeSink.fromWritable({
evaluate: () => node,
onError: (err) => toPlatformError(`fromWritable(fd${x.fd})`, toError(err), command),
endOnDone: true,
})
}
if (x.config.stream) yield* Effect.forkScoped(Stream.run(x.config.stream, sink))
ins.set(x.fd, sink)
break
}
case "output": {
let stream: Stream.Stream<Uint8Array, PlatformError.PlatformError> = Stream.empty
if (node && "read" in node) {
const tap = new PassThrough()
node.on("error", (err) => tap.destroy(toError(err)))
node.pipe(tap)
stream = NodeStream.fromReadable({
evaluate: () => tap,
onError: (err) => toPlatformError(`fromReadable(fd${x.fd})`, toError(err), command),
})
}
if (x.config.sink) stream = Stream.transduce(stream, x.config.sink)
outs.set(x.fd, stream)
break
}
}
}
return {
getInputFd: (fd: number) => ins.get(fd) ?? Sink.drain,
getOutputFd: (fd: number) => outs.get(fd) ?? Stream.empty,
}
})
const setupStdin = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
cfg: ChildProcess.StdinConfig,
) =>
Effect.suspend(() => {
let sink: Sink.Sink<void, unknown, never, PlatformError.PlatformError> = Sink.drain
if (Predicate.isNotNull(proc.stdin)) {
sink = NodeSink.fromWritable({
evaluate: () => proc.stdin!,
onError: (err) => toPlatformError("fromWritable(stdin)", toError(err), command),
endOnDone: cfg.endOnDone,
encoding: cfg.encoding,
})
}
if (Stream.isStream(cfg.stream)) return Effect.as(Effect.forkScoped(Stream.run(cfg.stream, sink)), sink)
return Effect.succeed(sink)
})
const setupOutput = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
out: ChildProcess.StdoutConfig,
err: ChildProcess.StderrConfig,
) => {
let stdout = proc.stdout
? NodeStream.fromReadable({
evaluate: () => proc.stdout!,
onError: (cause) => toPlatformError("fromReadable(stdout)", toError(cause), command),
})
: Stream.empty
let stderr = proc.stderr
? NodeStream.fromReadable({
evaluate: () => proc.stderr!,
onError: (cause) => toPlatformError("fromReadable(stderr)", toError(cause), command),
})
: Stream.empty
if (Sink.isSink(out.stream)) stdout = Stream.transduce(stdout, out.stream)
if (Sink.isSink(err.stream)) stderr = Stream.transduce(stderr, err.stream)
return { stdout, stderr, all: Stream.merge(stdout, stderr) }
}
const spawn = (command: ChildProcess.StandardCommand, opts: NodeChildProcess.SpawnOptions) =>
Effect.callback<readonly [NodeChildProcess.ChildProcess, ExitSignal], PlatformError.PlatformError>((resume) => {
const signal = Deferred.makeUnsafe<readonly [code: number | null, signal: NodeJS.Signals | null]>()
const proc = launch(command.command, command.args, opts)
let end = false
let exit: readonly [code: number | null, signal: NodeJS.Signals | null] | undefined
proc.on("error", (err) => {
resume(Effect.fail(toPlatformError("spawn", err, command)))
})
proc.on("exit", (...args) => {
exit = args
})
proc.on("close", (...args) => {
if (end) return
end = true
Deferred.doneUnsafe(signal, Exit.succeed(exit ?? args))
})
proc.on("spawn", () => {
resume(Effect.succeed([proc, signal]))
})
return Effect.sync(() => {
proc.kill("SIGTERM")
})
})
const killGroup = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals,
) => {
if (globalThis.process.platform === "win32") {
return Effect.callback<void, PlatformError.PlatformError>((resume) => {
NodeChildProcess.exec(`taskkill /pid ${proc.pid} /T /F`, { windowsHide: true }, (err) => {
if (err) return resume(Effect.fail(toPlatformError("kill", toError(err), command)))
resume(Effect.void)
})
})
}
return Effect.try({
try: () => {
globalThis.process.kill(-proc.pid!, signal)
},
catch: (err) => toPlatformError("kill", toError(err), command),
})
}
const killOne = (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals,
) =>
Effect.suspend(() => {
if (proc.kill(signal)) return Effect.void
return Effect.fail(toPlatformError("kill", new Error("Failed to kill child process"), command))
})
const timeout =
(
proc: NodeChildProcess.ChildProcess,
command: ChildProcess.StandardCommand,
opts: ChildProcess.KillOptions | undefined,
) =>
<A, E, R>(
f: (
command: ChildProcess.StandardCommand,
proc: NodeChildProcess.ChildProcess,
signal: NodeJS.Signals,
) => Effect.Effect<A, E, R>,
) => {
const signal = opts?.killSignal ?? "SIGTERM"
if (Predicate.isUndefined(opts?.forceKillAfter)) return f(command, proc, signal)
return Effect.timeoutOrElse(f(command, proc, signal), {
duration: opts.forceKillAfter,
onTimeout: () => f(command, proc, "SIGKILL"),
})
}
const source = (handle: ChildProcessHandle, from: ChildProcess.PipeFromOption | undefined) => {
const opt = from ?? "stdout"
switch (opt) {
case "stdout":
return handle.stdout
case "stderr":
return handle.stderr
case "all":
return handle.all
default: {
const fd = ChildProcess.parseFdName(opt)
return Predicate.isNotUndefined(fd) ? handle.getOutputFd(fd) : handle.stdout
}
}
}
const spawnCommand: (
command: ChildProcess.Command,
) => Effect.Effect<ChildProcessHandle, PlatformError.PlatformError, Scope.Scope> = Effect.fnUntraced(
function* (command) {
switch (command._tag) {
case "StandardCommand": {
const sin = stdin(command.options)
const sout = stdio(command.options, "stdout")
const serr = stdio(command.options, "stderr")
const extra = fds(command.options)
const dir = yield* cwd(command.options)
const [proc, signal] = yield* Effect.acquireRelease(
spawn(command, {
cwd: dir,
env: env(command.options),
stdio: stdios(sin, sout, serr, extra),
detached: command.options.detached ?? process.platform !== "win32",
shell: command.options.shell,
windowsHide: process.platform === "win32",
}),
Effect.fnUntraced(function* ([proc, signal]) {
const done = yield* Deferred.isDone(signal)
const kill = timeout(proc, command, command.options)
if (done) {
const [code] = yield* Deferred.await(signal)
if (process.platform === "win32") return yield* Effect.void
if (code !== 0 && Predicate.isNotNull(code)) return yield* Effect.ignore(kill(killGroup))
return yield* Effect.void
}
return yield* kill((command, proc, signal) =>
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
).pipe(Effect.andThen(Deferred.await(signal)), Effect.ignore)
}),
)
const fd = yield* setupFds(command, proc, extra)
const out = setupOutput(command, proc, sout, serr)
return makeHandle({
pid: ProcessId(proc.pid!),
stdin: yield* setupStdin(command, proc, sin),
stdout: out.stdout,
stderr: out.stderr,
all: out.all,
getInputFd: fd.getInputFd,
getOutputFd: fd.getOutputFd,
isRunning: Effect.map(Deferred.isDone(signal), (done) => !done),
exitCode: Effect.flatMap(Deferred.await(signal), ([code, signal]) => {
if (Predicate.isNotNull(code)) return Effect.succeed(ExitCode(code))
return Effect.fail(
toPlatformError(
"exitCode",
new Error(`Process interrupted due to receipt of signal: '${signal}'`),
command,
),
)
}),
kill: (opts?: ChildProcess.KillOptions) =>
timeout(
proc,
command,
opts,
)((command, proc, signal) =>
Effect.catch(killGroup(command, proc, signal), () => killOne(command, proc, signal)),
).pipe(Effect.andThen(Deferred.await(signal)), Effect.asVoid),
})
}
case "PipedCommand": {
const flat = flatten(command)
const [head, ...tail] = flat.commands
let handle = spawnCommand(head)
for (let i = 0; i < tail.length; i++) {
const next = tail[i]
const opts = flat.opts[i] ?? {}
const sin = stdin(next.options)
const stream = Stream.unwrap(Effect.map(handle, (x) => source(x, opts.from)))
const to = opts.to ?? "stdin"
if (to === "stdin") {
handle = spawnCommand(
ChildProcess.make(next.command, next.args, {
...next.options,
stdin: { ...sin, stream },
}),
)
continue
}
const fd = ChildProcess.parseFdName(to)
if (Predicate.isUndefined(fd)) {
handle = spawnCommand(
ChildProcess.make(next.command, next.args, {
...next.options,
stdin: { ...sin, stream },
}),
)
continue
}
handle = spawnCommand(
ChildProcess.make(next.command, next.args, {
...next.options,
additionalFds: {
...next.options.additionalFds,
[ChildProcess.fdName(fd) as `fd${number}`]: { type: "input", stream },
},
}),
)
}
return yield* handle
}
}
},
)
return makeSpawner(spawnCommand)
})
export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSystem | Path.Path> = Layer.effect(
ChildProcessSpawner,
make,
)
+1
View File
@@ -18,6 +18,7 @@ export namespace Flag {
export declare const OPENCODE_CONFIG_DIR: string | undefined export declare const OPENCODE_CONFIG_DIR: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
export const OPENCODE_ALWAYS_NOTIFY_UPDATE = truthy("OPENCODE_ALWAYS_NOTIFY_UPDATE")
export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE") export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE")
export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE") export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE")
export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"] export const OPENCODE_PERMISSION = process.env["OPENCODE_PERMISSION"]
+18 -2
View File
@@ -1,6 +1,7 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node" import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect" import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
import { makeRunPromise } from "@/effect/run-service" import { makeRunPromise } from "@/effect/run-service"
import { withTransientReadRetry } from "@/util/effect-http-client" import { withTransientReadRetry } from "@/util/effect-http-client"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
@@ -15,11 +16,15 @@ declare global {
const OPENCODE_CHANNEL: string const OPENCODE_CHANNEL: string
} }
import semver from "semver"
export namespace Installation { export namespace Installation {
const log = Log.create({ service: "installation" }) const log = Log.create({ service: "installation" })
export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown" export type Method = "curl" | "npm" | "yarn" | "pnpm" | "bun" | "brew" | "scoop" | "choco" | "unknown"
export type ReleaseType = "patch" | "minor" | "major"
export const Event = { export const Event = {
Updated: BusEvent.define( Updated: BusEvent.define(
"installation.updated", "installation.updated",
@@ -35,6 +40,17 @@ export namespace Installation {
), ),
} }
export function getReleaseType(current: string, latest: string): ReleaseType {
const currMajor = semver.major(current)
const currMinor = semver.minor(current)
const newMajor = semver.major(latest)
const newMinor = semver.minor(latest)
if (newMajor > currMajor) return "major"
if (newMinor > currMinor) return "minor"
return "patch"
}
export const Info = z export const Info = z
.object({ .object({
version: z.string(), version: z.string(),
@@ -325,7 +341,7 @@ export namespace Installation {
export const defaultLayer = layer.pipe( export const defaultLayer = layer.pipe(
Layer.provide(FetchHttpClient.layer), Layer.provide(FetchHttpClient.layer),
Layer.provide(NodeChildProcessSpawner.layer), Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(NodeFileSystem.layer), Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer), Layer.provide(NodePath.layer),
) )
+7 -2
View File
@@ -11,6 +11,7 @@ import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot" import { CopilotAuthPlugin } from "./copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth"
import { Effect, Layer, ServiceMap } from "effect" import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRunPromise } from "@/effect/run-service" import { makeRunPromise } from "@/effect/run-service"
@@ -44,7 +45,7 @@ export namespace Plugin {
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {} export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Plugin") {}
// Built-in plugins that are directly imported (not installed from npm) // Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin] const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin, PoeAuthPlugin]
// Old npm package names for plugins that are now built-in — skip if users still have them in config // Old npm package names for plugins that are now built-in — skip if users still have them in config
const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"] const DEPRECATED_PLUGIN_PACKAGES = ["opencode-openai-codex-auth", "opencode-copilot-auth"]
@@ -136,7 +137,11 @@ export namespace Plugin {
// Notify plugins of current config // Notify plugins of current config
for (const hook of hooks) { for (const hook of hooks) {
await (hook as any).config?.(cfg) try {
await (hook as any).config?.(cfg)
} catch (err) {
log.error("plugin config hook failed", { error: err })
}
} }
}) })
+398 -334
View File
@@ -1,36 +1,23 @@
import z from "zod" import z from "zod"
import { Filesystem } from "../util/filesystem"
import path from "path"
import { and, Database, eq } from "../storage/db" import { and, Database, eq } from "../storage/db"
import { ProjectTable } from "./project.sql" import { ProjectTable } from "./project.sql"
import { SessionTable } from "../session/session.sql" import { SessionTable } from "../session/session.sql"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { fn } from "@opencode-ai/util/fn"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
import { which } from "../util/which" import { which } from "../util/which"
import { ProjectID } from "./schema" import { ProjectID } from "./schema"
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { makeRunPromise } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
export namespace Project { export namespace Project {
const log = Log.create({ service: "project" }) const log = Log.create({ service: "project" })
function gitpath(cwd: string, name: string) {
if (!name) return cwd
// git output includes trailing newlines; keep path whitespace intact.
name = name.replace(/[\r\n]+$/, "")
if (!name) return cwd
name = Filesystem.windowsPath(name)
if (path.isAbsolute(name)) return path.normalize(name)
return path.resolve(cwd, name)
}
export const Info = z export const Info = z
.object({ .object({
id: ProjectID.zod, id: ProjectID.zod,
@@ -73,7 +60,7 @@ export namespace Project {
? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined } ? { url: row.icon_url ?? undefined, color: row.icon_color ?? undefined }
: undefined : undefined
return { return {
id: ProjectID.make(row.id), id: row.id,
worktree: row.worktree, worktree: row.worktree,
vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined, vcs: row.vcs ? Info.shape.vcs.parse(row.vcs) : undefined,
name: row.name ?? undefined, name: row.name ?? undefined,
@@ -88,245 +75,405 @@ export namespace Project {
} }
} }
function readCachedId(dir: string) { export const UpdateInput = z.object({
return Filesystem.readText(path.join(dir, "opencode")) projectID: ProjectID.zod,
.then((x) => x.trim()) name: z.string().optional(),
.then(ProjectID.make) icon: Info.shape.icon.optional(),
.catch(() => undefined) commands: Info.shape.commands.optional(),
})
export type UpdateInput = z.infer<typeof UpdateInput>
// ---------------------------------------------------------------------------
// Effect service
// ---------------------------------------------------------------------------
export interface Interface {
readonly fromDirectory: (directory: string) => Effect.Effect<{ project: Info; sandbox: string }>
readonly discover: (input: Info) => Effect.Effect<void>
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ProjectID) => Effect.Effect<Info | undefined>
readonly update: (input: UpdateInput) => Effect.Effect<Info>
readonly initGit: (input: { directory: string; project: Info }) => Effect.Effect<Info>
readonly setInitialized: (id: ProjectID) => Effect.Effect<void>
readonly sandboxes: (id: ProjectID) => Effect.Effect<string[]>
readonly addSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
readonly removeSandbox: (id: ProjectID, directory: string) => Effect.Effect<void>
} }
export async function fromDirectory(directory: string) { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Project") {}
log.info("fromDirectory", { directory })
const data = await iife(async () => { type GitResult = { code: number; text: string; stderr: string }
const matches = Filesystem.up({ targets: [".git"], start: directory })
const dotgit = await matches.next().then((x) => x.value)
await matches.return()
if (dotgit) {
let sandbox = path.dirname(dotgit)
const gitBinary = which("git") export const layer: Layer.Layer<
Service,
never,
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const pathSvc = yield* Path.Path
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
// cached id calculation const git = Effect.fnUntraced(
let id = await readCachedId(dotgit) function* (args: string[], opts?: { cwd?: string }) {
const handle = yield* spawner.spawn(
ChildProcess.make("git", args, { cwd: opts?.cwd, extendEnv: true, stdin: "ignore" }),
)
const [text, stderr] = yield* Effect.all(
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
{ concurrency: 2 },
)
const code = yield* handle.exitCode
return { code, text, stderr } satisfies GitResult
},
Effect.scoped,
Effect.catch(() => Effect.succeed({ code: 1, text: "", stderr: "" } satisfies GitResult)),
)
if (!gitBinary) { const db = <T>(fn: (d: Parameters<typeof Database.use>[0] extends (trx: infer D) => any ? D : never) => T) =>
return { Effect.sync(() => Database.use(fn))
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
const worktree = await git(["rev-parse", "--git-common-dir"], { const emitUpdated = (data: Info) =>
cwd: sandbox, Effect.sync(() =>
}) GlobalBus.emit("event", {
.then(async (result) => { payload: { type: Event.Updated.type, properties: data },
const common = gitpath(sandbox, await result.text()) }),
// Avoid going to parent of sandbox when git-common-dir is empty. )
return common === sandbox ? sandbox : path.dirname(common)
})
.catch(() => undefined)
if (!worktree) { const fakeVcs = Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS)
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
}
// In the case of a git worktree, it can't cache the id const resolveGitPath = (cwd: string, name: string) => {
// because `.git` is not a folder, but it always needs the if (!name) return cwd
// same project id as the common dir, so we resolve it now name = name.replace(/[\r\n]+$/, "")
if (id == null) { if (!name) return cwd
id = await readCachedId(path.join(worktree, ".git")) name = AppFileSystem.windowsPath(name)
} if (pathSvc.isAbsolute(name)) return pathSvc.normalize(name)
return pathSvc.resolve(cwd, name)
}
// generate id from root commit const scope = yield* Scope.Scope
if (!id) {
const roots = await git(["rev-list", "--max-parents=0", "HEAD"], {
cwd: sandbox,
})
.then(async (result) =>
(await result.text())
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted(),
)
.catch(() => undefined)
if (!roots) { const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe(
Effect.map((x) => x.trim()),
Effect.map(ProjectID.make),
Effect.catch(() => Effect.succeed(undefined)),
)
})
const fromDirectory = Effect.fn("Project.fromDirectory")(function* (directory: string) {
log.info("fromDirectory", { directory })
// Phase 1: discover git info
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
const data: DiscoveryResult = yield* Effect.gen(function* () {
const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
const dotgit = dotgitMatches[0]
if (!dotgit) {
return { return {
id: ProjectID.global, id: ProjectID.global,
worktree: sandbox, worktree: "/",
sandbox, sandbox: "/",
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), vcs: fakeVcs,
} }
} }
id = roots[0] ? ProjectID.make(roots[0]) : undefined let sandbox = pathSvc.dirname(dotgit)
if (id) { const gitBinary = yield* Effect.sync(() => which("git"))
// Write to common dir so the cache is shared across worktrees. let id = yield* readCachedProjectId(dotgit)
await Filesystem.write(path.join(worktree, ".git", "opencode"), id).catch(() => undefined)
}
}
if (!id) { if (!gitBinary) {
return { return {
id: ProjectID.global, id: id ?? ProjectID.global,
worktree: sandbox, worktree: sandbox,
sandbox, sandbox,
vcs: "git", vcs: fakeVcs,
}
} }
}
const top = await git(["rev-parse", "--show-toplevel"], { const commonDir = yield* git(["rev-parse", "--git-common-dir"], { cwd: sandbox })
cwd: sandbox, if (commonDir.code !== 0) {
return {
id: id ?? ProjectID.global,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
const worktree = (() => {
const common = resolveGitPath(sandbox, commonDir.text.trim())
return common === sandbox ? sandbox : pathSvc.dirname(common)
})()
if (id == null) {
id = yield* readCachedProjectId(pathSvc.join(worktree, ".git"))
}
if (!id) {
const revList = yield* git(["rev-list", "--max-parents=0", "HEAD"], { cwd: sandbox })
const roots = revList.text
.split("\n")
.filter(Boolean)
.map((x) => x.trim())
.toSorted()
id = roots[0] ? ProjectID.make(roots[0]) : undefined
if (id) {
yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
}
}
if (!id) {
return { id: ProjectID.global, worktree: sandbox, sandbox, vcs: "git" as const }
}
const topLevel = yield* git(["rev-parse", "--show-toplevel"], { cwd: sandbox })
if (topLevel.code !== 0) {
return {
id,
worktree: sandbox,
sandbox,
vcs: fakeVcs,
}
}
sandbox = resolveGitPath(sandbox, topLevel.text.trim())
return { id, sandbox, worktree, vcs: "git" as const }
}) })
.then(async (result) => gitpath(sandbox, await result.text()))
.catch(() => undefined)
if (!top) { // Phase 2: upsert
return { const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
id, const existing = row
worktree: sandbox, ? fromRow(row)
sandbox, : {
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS), id: data.id,
} worktree: data.worktree,
} vcs: data.vcs,
sandboxes: [] as string[],
time: { created: Date.now(), updated: Date.now() },
}
sandbox = top if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY)
yield* discover(existing).pipe(Effect.ignore, Effect.forkIn(scope))
return { const result: Info = {
id, ...existing,
sandbox,
worktree,
vcs: "git",
}
}
return {
id: ProjectID.global,
worktree: "/",
sandbox: "/",
vcs: Info.shape.vcs.parse(Flag.OPENCODE_FAKE_VCS),
}
})
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, data.id)).get())
const existing = row
? fromRow(row)
: {
id: data.id,
worktree: data.worktree, worktree: data.worktree,
vcs: data.vcs as Info["vcs"], vcs: data.vcs,
sandboxes: [] as string[], time: { ...existing.time, updated: Date.now() },
time: { }
created: Date.now(), if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox))
updated: Date.now(), result.sandboxes.push(data.sandbox)
}, result.sandboxes = yield* Effect.forEach(
result.sandboxes,
(s) =>
fsys.exists(s).pipe(
Effect.orDie,
Effect.map((exists) => (exists ? s : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
yield* db((d) =>
d
.insert(ProjectTable)
.values({
id: result.id,
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_created: result.time.created,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
})
.onConflictDoUpdate({
target: ProjectTable.id,
set: {
worktree: result.worktree,
vcs: result.vcs ?? null,
name: result.name,
icon_url: result.icon?.url,
icon_color: result.icon?.color,
time_updated: result.time.updated,
time_initialized: result.time.initialized,
sandboxes: result.sandboxes,
commands: result.commands,
},
})
.run(),
)
if (data.id !== ProjectID.global) {
yield* db((d) =>
d
.update(SessionTable)
.set({ project_id: data.id })
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree)))
.run(),
)
} }
if (Flag.OPENCODE_EXPERIMENTAL_ICON_DISCOVERY) discover(existing) yield* emitUpdated(result)
return { project: result, sandbox: data.sandbox }
})
const result: Info = { const discover = Effect.fn("Project.discover")(function* (input: Info) {
...existing, if (input.vcs !== "git") return
worktree: data.worktree, if (input.icon?.override) return
vcs: data.vcs as Info["vcs"], if (input.icon?.url) return
time: {
...existing.time, const matches = yield* fsys
updated: Date.now(), .glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
}, cwd: input.worktree,
} absolute: true,
if (data.sandbox !== result.worktree && !result.sandboxes.includes(data.sandbox)) include: "file",
result.sandboxes.push(data.sandbox) })
result.sandboxes = result.sandboxes.filter((x) => existsSync(x)) .pipe(Effect.orDie)
const insert = { const shortest = matches.sort((a, b) => a.length - b.length)[0]
id: result.id, if (!shortest) return
worktree: result.worktree,
vcs: result.vcs ?? null, const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
name: result.name, const base64 = Buffer.from(buffer).toString("base64")
icon_url: result.icon?.url, const mime = AppFileSystem.mimeType(shortest)
icon_color: result.icon?.color, const url = `data:${mime};base64,${base64}`
time_created: result.time.created, yield* update({ projectID: input.id, icon: { url } })
time_updated: result.time.updated, })
time_initialized: result.time.initialized,
sandboxes: result.sandboxes, const list = Effect.fn("Project.list")(function* () {
commands: result.commands, return yield* db((d) => d.select().from(ProjectTable).all().map(fromRow))
} })
const updateSet = {
worktree: result.worktree, const get = Effect.fn("Project.get")(function* (id: ProjectID) {
vcs: result.vcs ?? null, const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
name: result.name, return row ? fromRow(row) : undefined
icon_url: result.icon?.url, })
icon_color: result.icon?.color,
time_updated: result.time.updated, const update = Effect.fn("Project.update")(function* (input: UpdateInput) {
time_initialized: result.time.initialized, const result = yield* db((d) =>
sandboxes: result.sandboxes, d
commands: result.commands, .update(ProjectTable)
} .set({
Database.use((db) => name: input.name,
db.insert(ProjectTable).values(insert).onConflictDoUpdate({ target: ProjectTable.id, set: updateSet }).run(), icon_url: input.icon?.url,
) icon_color: input.icon?.color,
// Runs after upsert so the target project row exists (FK constraint). commands: input.commands,
// Runs on every startup because sessions created before git init time_updated: Date.now(),
// accumulate under "global" and need migrating whenever they appear. })
if (data.id !== ProjectID.global) { .where(eq(ProjectTable.id, input.projectID))
Database.use((db) => .returning()
db .get(),
.update(SessionTable) )
.set({ project_id: data.id }) if (!result) throw new Error(`Project not found: ${input.projectID}`)
.where(and(eq(SessionTable.project_id, ProjectID.global), eq(SessionTable.directory, data.worktree))) const data = fromRow(result)
.run(), yield* emitUpdated(data)
) return data
} })
GlobalBus.emit("event", {
payload: { const initGit = Effect.fn("Project.initGit")(function* (input: { directory: string; project: Info }) {
type: Event.Updated.type, if (input.project.vcs === "git") return input.project
properties: result, if (!(yield* Effect.sync(() => which("git")))) throw new Error("Git is not installed")
}, const result = yield* git(["init", "--quiet"], { cwd: input.directory })
}) if (result.code !== 0) {
return { project: result, sandbox: data.sandbox } throw new Error(result.stderr.trim() || result.text.trim() || "Failed to initialize git repository")
}
const { project } = yield* fromDirectory(input.directory)
return project
})
const setInitialized = Effect.fn("Project.setInitialized")(function* (id: ProjectID) {
yield* db((d) =>
d.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
)
})
const sandboxes = Effect.fn("Project.sandboxes")(function* (id: ProjectID) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
return yield* Effect.forEach(
data.sandboxes,
(dir) =>
fsys.isDir(dir).pipe(
Effect.orDie,
Effect.map((ok) => (ok ? dir : undefined)),
),
{ concurrency: "unbounded" },
).pipe(Effect.map((arr) => arr.filter((x): x is string => x !== undefined)))
})
const addSandbox = Effect.fn("Project.addSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = [...row.sandboxes]
if (!sboxes.includes(directory)) sboxes.push(directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
const removeSandbox = Effect.fn("Project.removeSandbox")(function* (id: ProjectID, directory: string) {
const row = yield* db((d) => d.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sboxes = row.sandboxes.filter((s) => s !== directory)
const result = yield* db((d) =>
d
.update(ProjectTable)
.set({ sandboxes: sboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${id}`)
yield* emitUpdated(fromRow(result))
})
return Service.of({
fromDirectory,
discover,
list,
get,
update,
initGit,
setInitialized,
sandboxes,
addSandbox,
removeSandbox,
})
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(CrossSpawnSpawner.layer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
const runPromise = makeRunPromise(Service, defaultLayer)
// ---------------------------------------------------------------------------
// Promise-based API (delegates to Effect service via runPromise)
// ---------------------------------------------------------------------------
export function fromDirectory(directory: string) {
return runPromise((svc) => svc.fromDirectory(directory))
} }
export async function discover(input: Info) { export function discover(input: Info) {
if (input.vcs !== "git") return return runPromise((svc) => svc.discover(input))
if (input.icon?.override) return
if (input.icon?.url) return
const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
cwd: input.worktree,
absolute: true,
include: "file",
})
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
const buffer = await Filesystem.readBytes(shortest)
const base64 = buffer.toString("base64")
const mime = Filesystem.mimeType(shortest) || "image/png"
const url = `data:${mime};base64,${base64}`
await update({
projectID: input.id,
icon: {
url,
},
})
return
}
export function setInitialized(id: ProjectID) {
Database.use((db) =>
db
.update(ProjectTable)
.set({
time_initialized: Date.now(),
})
.where(eq(ProjectTable.id, id))
.run(),
)
} }
export function list() { export function list() {
@@ -345,112 +492,29 @@ export namespace Project {
return fromRow(row) return fromRow(row)
} }
export async function initGit(input: { directory: string; project: Info }) { export function setInitialized(id: ProjectID) {
if (input.project.vcs === "git") return input.project Database.use((db) =>
if (!which("git")) throw new Error("Git is not installed") db.update(ProjectTable).set({ time_initialized: Date.now() }).where(eq(ProjectTable.id, id)).run(),
const result = await git(["init", "--quiet"], {
cwd: input.directory,
})
if (result.exitCode !== 0) {
const text = result.stderr.toString().trim() || result.text().trim()
throw new Error(text || "Failed to initialize git repository")
}
return (await fromDirectory(input.directory)).project
}
export const update = fn(
z.object({
projectID: ProjectID.zod,
name: z.string().optional(),
icon: Info.shape.icon.optional(),
commands: Info.shape.commands.optional(),
}),
async (input) => {
const id = ProjectID.make(input.projectID)
const result = Database.use((db) =>
db
.update(ProjectTable)
.set({
name: input.name,
icon_url: input.icon?.url,
icon_color: input.icon?.color,
commands: input.commands,
time_updated: Date.now(),
})
.where(eq(ProjectTable.id, id))
.returning()
.get(),
)
if (!result) throw new Error(`Project not found: ${input.projectID}`)
const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: data,
},
})
return data
},
)
export async function sandboxes(id: ProjectID) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) return []
const data = fromRow(row)
const valid: string[] = []
for (const dir of data.sandboxes) {
const s = Filesystem.stat(dir)
if (s?.isDirectory()) valid.push(dir)
}
return valid
}
export async function addSandbox(id: ProjectID, directory: string) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get())
if (!row) throw new Error(`Project not found: ${id}`)
const sandboxes = [...row.sandboxes]
if (!sandboxes.includes(directory)) sandboxes.push(directory)
const result = Database.use((db) =>
db
.update(ProjectTable)
.set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id))
.returning()
.get(),
) )
if (!result) throw new Error(`Project not found: ${id}`)
const data = fromRow(result)
GlobalBus.emit("event", {
payload: {
type: Event.Updated.type,
properties: data,
},
})
return data
} }
export async function removeSandbox(id: ProjectID, directory: string) { export function initGit(input: { directory: string; project: Info }) {
const row = Database.use((db) => db.select().from(ProjectTable).where(eq(ProjectTable.id, id)).get()) return runPromise((svc) => svc.initGit(input))
if (!row) throw new Error(`Project not found: ${id}`) }
const sandboxes = row.sandboxes.filter((s) => s !== directory)
const result = Database.use((db) => export function update(input: UpdateInput) {
db return runPromise((svc) => svc.update(input))
.update(ProjectTable) }
.set({ sandboxes, time_updated: Date.now() })
.where(eq(ProjectTable.id, id)) export function sandboxes(id: ProjectID) {
.returning() return runPromise((svc) => svc.sandboxes(id))
.get(), }
)
if (!result) throw new Error(`Project not found: ${id}`) export function addSandbox(id: ProjectID, directory: string) {
const data = fromRow(result) return runPromise((svc) => svc.addSandbox(id, directory))
GlobalBus.emit("event", { }
payload: {
type: Event.Updated.type, export function removeSandbox(id: ProjectID, directory: string) {
properties: data, return runPromise((svc) => svc.removeSandbox(id, directory))
},
})
return data
} }
} }
+4 -1
View File
@@ -194,7 +194,10 @@ export namespace ProviderTransform {
} }
for (const msg of unique([...system, ...final])) { for (const msg of unique([...system, ...final])) {
const useMessageLevelOptions = model.providerID === "anthropic" || model.providerID.includes("bedrock") const useMessageLevelOptions =
model.providerID === "anthropic" ||
model.providerID.includes("bedrock") ||
model.api.npm === "@ai-sdk/amazon-bedrock"
const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0 const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
if (shouldUseContentOptions) { if (shouldUseContentOptions) {
+59 -1
View File
@@ -1,7 +1,8 @@
import { Hono } from "hono" import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi" import { describeRoute, validator, resolver } from "hono-openapi"
import { streamSSE } from "hono/streaming" import { streamSSE } from "hono/streaming"
import z from "zod" import z from "zod"
import { Bus } from "../../bus"
import { BusEvent } from "@/bus/bus-event" import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { AsyncQueue } from "@/util/queue" import { AsyncQueue } from "@/util/queue"
@@ -195,5 +196,62 @@ export const GlobalRoutes = lazy(() =>
}) })
return c.json(true) return c.json(true)
}, },
)
.post(
"/upgrade",
describeRoute({
summary: "Upgrade opencode",
description: "Upgrade opencode to the specified version or latest if not specified.",
operationId: "global.upgrade",
responses: {
200: {
description: "Upgrade result",
content: {
"application/json": {
schema: resolver(
z.union([
z.object({
success: z.literal(true),
version: z.string(),
}),
z.object({
success: z.literal(false),
error: z.string(),
}),
]),
),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
target: z.string().optional(),
}),
),
async (c) => {
const method = await Installation.method()
if (method === "unknown") {
return c.json({ success: false, error: "Unknown installation method" }, 400)
}
const target = c.req.valid("json").target || (await Installation.latest(method))
const result = await Installation.upgrade(method, target)
.then(() => ({ success: true as const, version: target }))
.catch((e) => ({ success: false as const, error: e instanceof Error ? e.message : String(e) }))
if (result.success) {
GlobalBus.emit("event", {
directory: "global",
payload: {
type: Installation.Event.Updated.type,
properties: { version: target },
},
})
return c.json(result)
}
return c.json(result, 500)
},
), ),
) )
@@ -107,7 +107,7 @@ export const ProjectRoutes = lazy(() =>
}, },
}), }),
validator("param", z.object({ projectID: ProjectID.zod })), validator("param", z.object({ projectID: ProjectID.zod })),
validator("json", Project.update.schema.omit({ projectID: true })), validator("json", Project.UpdateInput.omit({ projectID: true })),
async (c) => { async (c) => {
const projectID = c.req.valid("param").projectID const projectID = c.req.valid("param").projectID
const body = c.req.valid("json") const body = c.req.valid("json")
@@ -19,6 +19,8 @@ import { PermissionID } from "@/permission/schema"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { errors } from "../error" import { errors } from "../error"
import { lazy } from "../../util/lazy" import { lazy } from "../../util/lazy"
import { Bus } from "../../bus"
import { NamedError } from "@opencode-ai/util/error"
const log = Log.create({ service: "server" }) const log = Log.create({ service: "server" })
@@ -846,7 +848,13 @@ export const SessionRoutes = lazy(() =>
return stream(c, async () => { return stream(c, async () => {
const sessionID = c.req.valid("param").sessionID const sessionID = c.req.valid("param").sessionID
const body = c.req.valid("json") const body = c.req.valid("json")
SessionPrompt.prompt({ ...body, sessionID }) SessionPrompt.prompt({ ...body, sessionID }).catch((err) => {
log.error("prompt_async failed", { sessionID, error: err })
Bus.publish(Session.Event.Error, {
sessionID,
error: new NamedError.Unknown({ message: err instanceof Error ? err.message : String(err) }).toObject(),
})
})
}) })
}, },
) )
+23 -15
View File
@@ -113,17 +113,20 @@ export namespace LLM {
options.instructions = system.join("\n") options.instructions = system.join("\n")
} }
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
const messages = isOpenaiOauth const messages = isOpenaiOauth
? input.messages ? input.messages
: [ : isWorkflow
...system.map( ? input.messages
(x): ModelMessage => ({ : [
role: "system", ...system.map(
content: x, (x): ModelMessage => ({
}), role: "system",
), content: x,
...input.messages, }),
] ),
...input.messages,
]
const params = await Plugin.trigger( const params = await Plugin.trigger(
"chat.params", "chat.params",
@@ -190,6 +193,7 @@ export namespace LLM {
// and results sent back over the WebSocket. // and results sent back over the WebSocket.
if (language instanceof GitLabWorkflowLanguageModel) { if (language instanceof GitLabWorkflowLanguageModel) {
const workflowModel = language const workflowModel = language
workflowModel.systemPrompt = system.join("\n")
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
const t = tools[toolName] const t = tools[toolName]
if (!t || !t.execute) { if (!t || !t.execute) {
@@ -250,12 +254,16 @@ export namespace LLM {
maxOutputTokens, maxOutputTokens,
abortSignal: input.abort, abortSignal: input.abort,
headers: { headers: {
...(input.model.providerID.startsWith("opencode") && { ...(input.model.providerID.startsWith("opencode")
"x-opencode-project": Instance.project.id, ? {
"x-opencode-session": input.sessionID, "x-opencode-project": Instance.project.id,
"x-opencode-request": input.user.id, "x-opencode-session": input.sessionID,
"x-opencode-client": Flag.OPENCODE_CLIENT, "x-opencode-request": input.user.id,
}), "x-opencode-client": Flag.OPENCODE_CLIENT,
}
: {
"User-Agent": `opencode/${Installation.VERSION}`,
}),
...input.model.headers, ...input.model.headers,
...headers, ...headers,
}, },

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