Compare commits

..
97 Commits
Author SHA1 Message Date
opencode 3331b0600a release: v1.0.222
Update Nix Hashes / update (push) Has been cancelled
2026-01-01 01:21:04 +00:00
Luke ParkerandGitHub c131dd0829 fix(desktop): Windows support for PTY and cross-platform build scripts (#6508) 2025-12-31 19:18:07 -06:00
Adam 1c25f1fae0 feat(desktop): in-app update toasts 2025-12-31 18:19:53 -06:00
Adam 2da71e0a50 feat(desktop): lose the summaries 2025-12-31 18:16:52 -06:00
Daniel PolitoandGitHub 87978b1c17 Desktop: Add Subagents Mention Support (#6540) 2025-12-31 18:07:45 -06:00
GitHub Action 63d2b21b8f chore: generate 2025-12-31 22:16:32 +00:00
Aiden Cline 9a1dc1ffe4 core: prevent TimeoutOverflowWarning by capping setTimeout delay to max 32-bit signed integer 2025-12-31 16:15:49 -06:00
opencode c93e7621be release: v1.0.221
Update Nix Hashes / update (push) Has been cancelled
2025-12-31 21:10:33 +00:00
Adam e842205550 chore: cleanup 2025-12-31 15:08:00 -06:00
Adam b2aa387376 feat(app): better model selector 2025-12-31 15:07:59 -06:00
Aiden Cline 34aecda47c tweak: default to ai-sdk/opeai-compatible if no npm package provided 2025-12-31 14:54:21 -06:00
Aiden Cline b419b0ec55 Reapply "tweak: adjust keys for uniqueness calculations to use provider/model"
This reverts commit 9d32a0354f1db3ea4893f4ad00900c6420ab78c6.
2025-12-31 14:54:21 -06:00
Aiden Cline 538ac208e1 Revert "tweak: adjust keys for uniqueness calculations to use provider/model"
This reverts commit eb81994a18.
2025-12-31 14:54:21 -06:00
Adam 16957fd107 fix(app): auto-accept colors 2025-12-31 14:35:41 -06:00
Adam 7f3a0b8e5c fix(desktop): tooltip colors 2025-12-31 14:20:21 -06:00
Adam d4a2652eda feat(desktop): better affordance for auto-accept 2025-12-31 14:04:44 -06:00
Adam 7a4bfbe56d fix(app): text selection 2025-12-31 13:32:44 -06:00
Adam 31e2c8b5e9 wip: input changes 2025-12-31 13:31:46 -06:00
Adam eab23738a8 chore: cleanup 2025-12-31 13:13:50 -06:00
Adam 93845db462 fix(desktop): don't show notifs if auto-accepting 2025-12-31 13:12:31 -06:00
Adam 65bc72098b fix(desktop): more defensive access 2025-12-31 13:12:30 -06:00
Adam b5546dce80 wip(app): better variant toggle 2025-12-31 13:12:30 -06:00
Adam 3807364e73 chore(app): tool args cleanup 2025-12-31 13:12:29 -06:00
Adam 3a1cfa6c73 chore(app): keybind tooltip component 2025-12-31 13:12:29 -06:00
Adam a2857bba83 fix(desktop): prompt input cleanup 2025-12-31 13:12:28 -06:00
Aiden Cline 97a0fd1d54 Revert "fix(tui): don't show 'Agent not found' toast for subagents (#6528)"
This reverts commit 87f9ebd17c.
2025-12-31 13:11:12 -06:00
Vlad TemianandGitHub 87f9ebd17c fix(tui): don't show 'Agent not found' toast for subagents (#6528) 2025-12-31 13:04:23 -06:00
Aiden Cline e7422ee782 chore: rm unused import 2025-12-31 12:11:42 -06:00
Aiden Cline dc3e731afe ci: tweak changelog ensure all cmd/ things fall under tui section 2025-12-31 12:05:35 -06:00
GitHub Action de50e7f9b9 chore: generate 2025-12-31 17:35:47 +00:00
f3db966317 Add support for LSP workspace/didChangeWatchedFiles (#6524)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-31 11:35:09 -06:00
opencode decc616c80 release: v1.0.220
Update Nix Hashes / update (push) Has been cancelled
2025-12-31 17:30:57 +00:00
Aiden Cline e0450e74fb fix: plugin & mode globs 2025-12-31 11:28:06 -06:00
GitHub Action 094af4dc7d chore: generate 2025-12-31 17:19:02 +00:00
Michael RamosandGitHub 2dc14718aa docs: Add plannotator plugin (#6510) 2025-12-31 11:18:28 -06:00
Eric GuoandGitHub b97d20f252 feat(cli): New debug agent <name> subcommand (#6529) 2025-12-31 11:16:03 -06:00
Anzul AqeelandGitHub fcfcdd95e9 fix: Clarify agent-name placeholder in tips (#6520) 2025-12-31 11:15:07 -06:00
opencode-agent[bot]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
c42bd492ea docs: new configurable CORS option (#6522)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2025-12-31 11:11:20 -06:00
Frank 840fe030ab zen: fix fee instruction 2025-12-31 11:54:01 -05:00
Adam a7c4f83ca2 fix(desktop): remove status bar, new elements in header 2025-12-31 10:22:17 -06:00
Adam ed70a07201 fix(desktop): cleanup user message style 2025-12-31 10:22:16 -06:00
Adam 18a5eb205f fix(desktop): better new session button 2025-12-31 10:22:16 -06:00
Paolo RicciutiandGitHub 5249f04ea0 fix: display MCP tag for prompts in autocomplete but not in prompt (#6531) 2025-12-31 09:34:36 -06:00
Adam 6e3ead198e fix(ui): radio group styles 2025-12-31 09:26:14 -06:00
GitHub Action 4309d439f5 chore: generate 2025-12-31 15:24:05 +00:00
Adam 2ec6a21cc0 feat(desktop): unified diff toggle 2025-12-31 09:23:24 -06:00
Adam ebf5ad25c5 fix(desktop): not rendering large sessions 2025-12-31 08:29:36 -06:00
opencode 8aa34ab9f3 release: v1.0.219
Update Nix Hashes / update (push) Has been cancelled
2025-12-31 14:01:59 +00:00
Adam 50ef866a02 fix(core): mdns fails if service already registered 2025-12-31 07:33:49 -06:00
Adam 3650fefe2d fix(desktop): don't expand tools by default 2025-12-31 07:10:47 -06:00
GitHub Action 22091c29f1 ignore: update download stats 2025-12-31 2025-12-31 12:04:56 +00:00
Adam e7e89dc5a6 chore: cleanup 2025-12-31 04:47:24 -06:00
Adam 34e9392bb4 chore: daytona skip preview warning 2025-12-31 04:22:53 -06:00
GitHub Action 05c3bc27ff chore: generate 2025-12-31 09:51:12 +00:00
Adam b1a6333d17 feat(core): configurable cors hosts 2025-12-31 03:50:29 -06:00
5c9d619620 docs: add variants docs (#6516)
Co-authored-by: David Hill <iamdavidhill@gmail.com>
2025-12-31 01:17:50 -06:00
GitHub Action dfb9caa2a9 chore: generate 2025-12-31 06:51:59 +00:00
57a2b5f444 feat: mcp prompts as slash commands (alternative) (#5767)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-31 00:51:25 -06:00
Github Action 977c9a3e2c Update Nix flake.lock and hashes 2025-12-31 06:11:51 +00:00
db84ee17f4 feat: add gemini-3-flash to fast models list (#6497)
Co-authored-by: qio <handsomehust@gmail.com>
2025-12-31 00:11:47 -06:00
OpeOginniandGitHub 0b1f6a7d2d feat: bundle in @ai-sdk/vercel version 1.0.31 for aisdk v5 support (#6512) 2025-12-31 00:10:42 -06:00
a6d225558c fix: cleaner view subagents hint text (#6437)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2025-12-30 22:47:16 -06:00
Brian ClinkenbeardandGitHub 2434965b7f Update mise install command (#6504) 2025-12-30 22:43:15 -06:00
GitHub Action d4cf78bceb chore: generate 2025-12-31 04:06:43 +00:00
Dax Raad ed4ce67cdc core: add configurable timeout for MCP tool calls to prevent hanging requests 2025-12-30 23:06:07 -05:00
Adam 94dca309e9 fix(app): don't open native folder select with remote server 2025-12-30 20:15:57 -06:00
Adam 52e4dd110b feat(app): hide reasoning once agent is done 2025-12-30 20:09:32 -06:00
Adam 1e74560796 feat(app): model variants 2025-12-30 20:06:03 -06:00
Adam 48f2419d9d fix(desktop): better notification icon 2025-12-30 19:40:14 -06:00
Aiden Cline b9ef09a0f4 tweak: read plurals too and stop erroring on them 2025-12-30 18:58:31 -06:00
Aiden Cline eb81994a18 tweak: adjust keys for uniqueness calculations to use provider/model 2025-12-30 18:41:28 -06:00
opencode-agent[bot]GitHubopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>Aiden Cline
a3819e088c docs: for stats --models flag (#6492)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2025-12-30 18:31:48 -06:00
Aiden Cline 324ae9c471 fix: openai variants for codex models 2025-12-30 18:18:16 -06:00
processtraderandGitHub 7349626757 feat: add model usage statistics with input/output token breakdown to stats command (#6296) 2025-12-30 17:52:41 -06:00
Farhad OmidandGitHub 76c25ef286 feat(format): add rustfmt formatter for Rust files (#6482) 2025-12-30 17:06:55 -06:00
GitHub Action c8b3b31d27 chore: generate 2025-12-30 22:38:06 +00:00
81fef60266 fix: ensure variants also work for completely custom models (#6481)
Co-authored-by: Daniel Smolsky <dannysmo@gmail.com>
2025-12-30 16:37:32 -06:00
GitHub Action 3fe5d91372 chore: generate 2025-12-30 20:32:02 +00:00
Adam 7adb6e495a feat(desktop): upgrade to latest version on error page 2025-12-30 14:31:16 -06:00
opencode 2039c6936f release: v1.0.218
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 20:17:26 +00:00
Adam a02fefe9dc fix(core): cors exception for tauri 2025-12-30 14:14:44 -06:00
Jay V cb0e05db26 docs: add auto-reload and monthly limits documentation to Zen guide 2025-12-30 12:57:58 -07:00
GitHub Action b9cdcaa9db chore: generate 2025-12-30 19:14:09 +00:00
ravshansboxandGitHub 94453eb1bd Add prisma language server (#6462) 2025-12-30 13:13:36 -06:00
YtzhakandGitHub 8f629db988 feat: add extract reasoning middleware (#6463) 2025-12-30 13:13:18 -06:00
opencode 585378cba0 release: v1.0.217
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 19:01:36 +00:00
Dax Raad 8cd8393339 core: allow CORS requests from tauri://localhost 2025-12-30 13:58:41 -05:00
GitHub Action b184b2fb73 chore: generate 2025-12-30 18:34:33 +00:00
Aiden Cline c88c2da9be fix: move variant toggle to command bar 2025-12-30 12:33:50 -06:00
opencode 9b04081ae0 release: v1.0.216
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 18:07:37 +00:00
Dax Raad 7d2d87fa2c core: allow CORS requests from *.opencode.ai subdomains 2025-12-30 13:04:18 -05:00
GitHub Action 787f37b382 chore: generate 2025-12-30 17:59:01 +00:00
jaandGitHub 8fa1af851c style(nix): use idiomatic inherit syntax (#6457) 2025-12-30 11:58:28 -06:00
opencode 73bc3e704e release: v1.0.215
Update Nix Hashes / update (push) Has been cancelled
2025-12-30 17:09:08 +00:00
Adam 8d2feed30e fix(desktop): more defensive agent access 2025-12-30 11:03:34 -06:00
GitHub Action 2d8d4e5dee chore: generate 2025-12-30 16:52:42 +00:00
Fayçal MitidjiandGitHub b3784588ae Fix: High CPU / memory leak when filtering model list window to empty results (#6435) 2025-12-30 10:52:06 -06:00
109 changed files with 3531 additions and 1204 deletions
+4
View File
@@ -2,6 +2,10 @@
- To test opencode in the `packages/opencode` directory you can run `bun dev`
## SDK
To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
## Tool Calling
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
+1 -1
View File
@@ -30,7 +30,7 @@ scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use -g github:sst/opencode # Any OS
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:sst/opencode for latest dev branch
```
+1
View File
@@ -186,3 +186,4 @@
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
+26 -17
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -98,7 +98,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -125,7 +125,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -149,7 +149,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -173,7 +173,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@solid-primitives/storage": "catalog:",
@@ -201,7 +201,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -230,7 +230,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -246,7 +246,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.0.214",
"version": "1.0.222",
"bin": {
"opencode": "./bin/opencode",
},
@@ -271,6 +271,7 @@
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.19",
"@ai-sdk/togetherai": "1.0.30",
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
@@ -293,7 +294,7 @@
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.2",
"bun-pty": "0.4.4",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
@@ -347,7 +348,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -367,7 +368,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.0.214",
"version": "1.0.222",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -378,7 +379,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -391,7 +392,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -429,7 +430,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"zod": "catalog:",
},
@@ -440,7 +441,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -571,6 +572,8 @@
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.30", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9bxQbIXnWSN4bNismrza3NvIo+ui/Y3pj3UN6e9vCszCWFCN45RgISi4oDe10RqmzaJ/X8cfO/Tem+K8MT3wGQ=="],
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="],
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.42", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wlwO4yRoZ/d+ca29vN8SDzxus7POdnL7GBTyRdSrt6icUF0hooLesauC8qRUC4aLxtqvMEc1YHtJOU7ZnLWbTQ=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -2041,7 +2044,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.2", "", {}, "sha512-sHImDz6pJDsHAroYpC9ouKVgOyqZ7FP3N+stX5IdMddHve3rf9LIZBDomQcXrACQ7sQDNuwZQHG8BKR7w8krkQ=="],
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
@@ -3917,6 +3920,12 @@
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@ai-sdk/vercel/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
"@ai-sdk/vercel/@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=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
+3 -3
View File
@@ -17,7 +17,7 @@
"aarch64-darwin"
"x86_64-darwin"
];
lib = nixpkgs.lib;
inherit (nixpkgs) lib;
forEachSystem = lib.genAttrs systems;
pkgsFor = system: nixpkgs.legacyPackages.${system};
packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
@@ -70,12 +70,12 @@
in
{
default = mkPackage {
version = packageJson.version;
inherit (packageJson) version;
src = ./.;
scripts = ./nix/scripts;
target = bunTarget.${system};
modelsDev = "${modelsDev.${system}}/dist/_api.json";
mkNodeModules = mkNodeModules;
inherit mkNodeModules;
};
}
);
+1 -1
View File
@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-2Wbnxy9SPcZkO03Sis3uiypPXa87jc5TzKbo6PvMlxY="
"nodeModules": "sha256-7zMUWgMCnoe2As8WdEKazkKiGEcUIk5rP4zFvX9USgA="
}
+18 -10
View File
@@ -1,18 +1,26 @@
{ hash, lib, stdenvNoCC, bun, cacert, curl }:
{
hash,
lib,
stdenvNoCC,
bun,
cacert,
curl,
}:
args:
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
version = args.version;
src = args.src;
inherit (args) version src;
impureEnvVars =
lib.fetchers.proxyImpureEnvVars
++ [
"GIT_PROXY_COMMAND"
"SOCKS_SERVER"
];
impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
"GIT_PROXY_COMMAND"
"SOCKS_SERVER"
];
nativeBuildInputs = [ bun cacert curl ];
nativeBuildInputs = [
bun
cacert
curl
];
dontConfigure = true;
+10 -7
View File
@@ -1,7 +1,13 @@
{ lib, stdenvNoCC, bun, ripgrep, makeBinaryWrapper }:
{
lib,
stdenvNoCC,
bun,
ripgrep,
makeBinaryWrapper,
}:
args:
let
scripts = args.scripts;
inherit (args) scripts;
mkModules =
attrs:
args.mkNodeModules (
@@ -14,13 +20,10 @@ let
in
stdenvNoCC.mkDerivation (finalAttrs: {
pname = "opencode";
version = args.version;
src = args.src;
inherit (args) version src;
node_modules = mkModules {
version = finalAttrs.version;
src = finalAttrs.src;
inherit (finalAttrs) version src;
};
nativeBuildInputs = [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.0.214",
"version": "1.0.222",
"description": "",
"type": "module",
"exports": {
+30 -27
View File
@@ -10,6 +10,7 @@ import { Diff } from "@opencode-ai/ui/diff"
import { Code } from "@opencode-ai/ui/code"
import { ThemeProvider } from "@opencode-ai/ui/theme"
import { GlobalSyncProvider } from "@/context/global-sync"
import { PermissionProvider } from "@/context/permission"
import { LayoutProvider } from "@/context/layout"
import { GlobalSDKProvider } from "@/context/global-sdk"
import { ServerProvider, useServer } from "@/context/server"
@@ -66,34 +67,36 @@ export function App() {
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<LayoutProvider>
<NotificationProvider>
<Router
root={(props) => (
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
<Router
root={(props) => (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)}
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<PromptProvider>
<Session />
</PromptProvider>
</TerminalProvider>
</Show>
)}
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<PromptProvider>
<Session />
</PromptProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</NotificationProvider>
</LayoutProvider>
/>
</Route>
</Router>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
@@ -1,4 +1,5 @@
import { Component, createMemo, Show } from "solid-js"
import { Popover as Kobalte } from "@kobalte/core/popover"
import { Component, createMemo, createSignal, JSX, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { popularProviders } from "@/hooks/use-providers"
@@ -9,9 +10,12 @@ import { List } from "@opencode-ai/ui/list"
import { DialogSelectProvider } from "./dialog-select-provider"
import { DialogManageModels } from "./dialog-manage-models"
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
const ModelList: Component<{
provider?: string
class?: string
onSelect: () => void
}> = (props) => {
const local = useLocal()
const dialog = useDialog()
const models = createMemo(() =>
local.model
@@ -20,6 +24,70 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
.filter((m) => (props.provider ? m.provider.id === props.provider : true)),
)
return (
<List
class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
search={{ placeholder: "Search models", autofocus: true }}
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
props.onSelect()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-2 text-13-regular">
<span class="truncate">{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
</Show>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
)
}
export const ModelSelectorPopover: Component<{
provider?: string
children: JSX.Element
}> = (props) => {
const [open, setOpen] = createSignal(false)
return (
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
<Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
</Kobalte.Content>
</Kobalte.Portal>
</Kobalte>
)
}
export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
const dialog = useDialog()
return (
<Dialog
title="Select model"
@@ -34,43 +102,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
</Button>
}
>
<List
search={{ placeholder: "Search models", autofocus: true }}
emptyMessage="No model results"
key={(x) => `${x.provider.id}:${x.id}`}
items={models}
current={local.model.current()}
filterKeys={["provider.name", "name", "id"]}
sortBy={(a, b) => a.name.localeCompare(b.name)}
groupBy={(x) => x.provider.name}
sortGroupsBy={(a, b) => {
if (a.category === "Recent" && b.category !== "Recent") return -1
if (b.category === "Recent" && a.category !== "Recent") return 1
const aProvider = a.items[0].provider.id
const bProvider = b.items[0].provider.id
if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
}}
onSelect={(x) => {
local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
recent: true,
})
dialog.close()
}}
>
{(i) => (
<div class="w-full flex items-center gap-x-3">
<span>{i.name}</span>
<Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
<Tag>Free</Tag>
</Show>
<Show when={i.latest}>
<Tag>Latest</Tag>
</Show>
</div>
)}
</List>
<ModelList provider={props.provider} onSelect={() => dialog.close()} />
<Button
variant="ghost"
class="ml-3 mt-5 mb-6 text-text-base self-start"
-215
View File
@@ -1,215 +0,0 @@
import { useGlobalSync } from "@/context/global-sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { useLayout } from "@/context/layout"
import { Session } from "@opencode-ai/sdk/v2/client"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Mark } from "@opencode-ai/ui/logo"
import { Popover } from "@opencode-ai/ui/popover"
import { Select } from "@opencode-ai/ui/select"
import { TextField } from "@opencode-ai/ui/text-field"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { base64Decode } from "@opencode-ai/util/encode"
import { useCommand } from "@/context/command"
import { getFilename } from "@opencode-ai/util/path"
import { A, useParams } from "@solidjs/router"
import { createMemo, createResource, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { iife } from "@opencode-ai/util/iife"
export function Header(props: {
navigateToProject: (directory: string) => void
navigateToSession: (session: Session | undefined) => void
onMobileMenuToggle?: () => void
}) {
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const command = useCommand()
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
onClick={props.onMobileMenuToggle}
>
<Icon name="menu" size="small" />
</button>
<A
href="/"
classList={{
"hidden xl:flex": true,
"w-12 shrink-0 px-4 py-3.5": true,
"items-center justify-start self-stretch": true,
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
data-tauri-drag-region
>
<Mark class="shrink-0" />
</A>
<div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
<Show when={layout.projects.list().length > 0 && params.dir}>
{(directory) => {
const currentDirectory = createMemo(() => base64Decode(directory()))
const store = createMemo(() => globalSync.child(currentDirectory())[0])
const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => store().config.share !== "disabled")
return (
<>
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
current={currentDirectory()}
label={(x) => getFilename(x)}
onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</Select>
<div class="text-text-weaker">/</div>
</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={props.navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<Tooltip
class="hidden xl:block"
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
New session
</Button>
</Tooltip>
</Show>
</div>
<div class="flex items-center gap-4">
<Show when={currentSession()?.summary?.files}>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle review</span>
<span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
</Show>
<Tooltip
class="hidden md:block shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle terminal</span>
<span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
</div>
}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</Tooltip>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: currentDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
)
return (
<Show when={url()}>
{(url) => <TextField value={url()} readOnly copyable class="w-72" />}
</Show>
)
})}
</Popover>
</Show>
</div>
</>
)
}}
</Show>
</div>
</header>
)
}
+278 -117
View File
@@ -3,7 +3,15 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat
import { createStore, produce } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
import {
ContentPart,
DEFAULT_PROMPT,
isPromptEqual,
Prompt,
usePrompt,
ImageAttachmentPart,
AgentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
import { useNavigate, useParams } from "@solidjs/router"
@@ -11,18 +19,19 @@ import { useSync } from "@/context/sync"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id"
import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission"
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -80,6 +89,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const dialog = useDialog()
const providers = useProviders()
const command = useCommand()
const permission = usePermission()
let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement
let scrollRef!: HTMLDivElement
@@ -126,7 +136,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const working = createMemo(() => status()?.type !== "idle")
const [store, setStore] = createStore<{
popover: "file" | "slash" | null
popover: "at" | "slash" | null
historyIndex: number
savedPrompt: Prompt | null
placeholder: number
@@ -169,6 +179,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
prompt.map((part) => {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: part.selection ? { ...part.selection } : undefined,
@@ -319,15 +330,43 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!isFocused()) setStore("popover", null)
})
const handleFileSelect = (path: string | undefined) => {
if (!path) return
addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string }
const agentList = createMemo(() =>
sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary")
.map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
)
const handleAtSelect = (option: AtOption | undefined) => {
if (!option) return
if (option.type === "agent") {
addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
} else {
addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
}
}
const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
items: local.file.searchFilesAndDirectories,
key: (x) => x,
onSelect: handleFileSelect,
const atKey = (x: AtOption | undefined) => {
if (!x) return ""
return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
}
const {
flat: atFlat,
active: atActive,
onInput: atOnInput,
onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({
items: async (query) => {
const agents = agentList()
const files = await local.file.searchFilesAndDirectories(query)
const fileOptions: AtOption[] = files.map((path) => ({ type: "file", path, display: path }))
return [...agents, ...fileOptions]
},
key: atKey,
filterKeys: ["display"],
onSelect: handleAtSelect,
})
const slashCommands = createMemo<SlashCommand[]>(() => {
@@ -413,6 +452,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement
if (el.dataset.type === "file") return true
if (el.dataset.type === "agent") return true
return el.tagName === "BR"
})
if (normalized && isPromptEqual(currentParts, domParts)) return
@@ -436,6 +476,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pill.style.userSelect = "text"
pill.style.cursor = "default"
editorRef.appendChild(pill)
} else if (part.type === "agent") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "agent")
pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
editorRef.appendChild(pill)
}
})
@@ -471,6 +520,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
position += content.length
}
const pushAgent = (agent: HTMLElement) => {
const content = agent.textContent ?? ""
parts.push({
type: "agent",
name: agent.dataset.name!,
content,
start: position,
end: position + content.length,
})
position += content.length
}
const visit = (node: Node) => {
if (node.nodeType === Node.TEXT_NODE) {
buffer += node.textContent ?? ""
@@ -484,6 +545,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
pushFile(el)
return
}
if (el.dataset.type === "agent") {
flushText()
pushAgent(el)
return
}
if (el.tagName === "BR") {
buffer += "\n"
return
@@ -537,8 +603,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const slashMatch = rawText.match(/^\/(\S*)$/)
if (atMatch) {
onInput(atMatch[1])
setStore("popover", "file")
atOnInput(atMatch[1])
setStore("popover", "at")
} else if (slashMatch) {
slashOnInput(slashMatch[1])
setStore("popover", "slash")
@@ -558,6 +624,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
queueScroll()
}
const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => {
let remaining = offset
const nodes = Array.from(editorRef.childNodes)
for (const node of nodes) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isPill =
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
if (edge === "end") range.setEnd(node, remaining)
return
}
if ((isPill || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
if (edge === "end" && remaining > 0) range.setEndAfter(node)
return
}
remaining -= length
}
}
const addPart = (part: ContentPart) => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
@@ -580,38 +676,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
const setEdge = (edge: "start" | "end", offset: number) => {
let remaining = offset
const nodes = Array.from(editorRef.childNodes)
for (const node of nodes) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
if (edge === "end") range.setEnd(node, remaining)
return
}
if ((isFile || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
if (edge === "end" && remaining > 0) range.setEndAfter(node)
return
}
remaining -= length
}
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
setRangeEdge(range, "start", start)
setRangeEdge(range, "end", cursorPosition)
}
range.deleteContents()
range.insertNode(gap)
range.insertNode(pill)
range.setStartAfter(gap)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
} else if (part.type === "agent") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "agent")
pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
setEdge("start", start)
setEdge("end", cursorPosition)
setRangeEdge(range, "start", start)
setRangeEdge(range, "end", cursorPosition)
}
range.deleteContents()
@@ -832,8 +925,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
if (store.popover === "file") {
onKeyDown(event)
if (store.popover === "at") {
atOnKeyDown(event)
} else {
slashOnKeyDown(event)
}
@@ -1073,11 +1166,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!existing) return
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const attachments = currentPrompt.filter(
const fileAttachments = currentPrompt.filter(
(part) => part.type === "file",
) as import("@/context/prompt").FileAttachmentPart[]
const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
const fileAttachmentParts = attachments.map((attachment) => {
const fileAttachmentParts = fileAttachments.map((attachment) => {
const absolute = toAbsolutePath(attachment.path)
const query = attachment.selection
? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
@@ -1100,6 +1194,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
})
const agentAttachmentParts = agentAttachments.map((attachment) => ({
id: Identifier.ascending("part"),
type: "agent" as const,
name: attachment.name,
source: {
value: attachment.content,
start: attachment.start,
end: attachment.end,
},
}))
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
@@ -1115,11 +1220,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("imageAttachments", [])
setStore("mode", "normal")
const model = {
modelID: local.model.current()!.id,
providerID: local.model.current()!.provider.id,
const currentModel = local.model.current()
const currentAgent = local.agent.current()
if (!currentModel || !currentAgent) {
console.warn("No agent or model available for prompt submission")
return
}
const agent = local.agent.current()!.name
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const variant = local.model.variant.current()
if (isShellMode) {
sdk.client.session
@@ -1147,6 +1259,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
})
.catch((e) => {
console.error("Failed to send command", e)
@@ -1161,7 +1274,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type: "text" as const,
text,
}
const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
const requestParts = [textPart, ...fileAttachmentParts, ...agentAttachmentParts, ...imageAttachmentParts]
const optimisticParts = requestParts.map((part) => ({
...part,
sessionID: existing.id,
@@ -1183,6 +1296,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
model,
messageID,
parts: requestParts,
variant,
})
.catch((e) => {
console.error("Failed to send prompt", e)
@@ -1198,24 +1312,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
>
<Switch>
<Match when={store.popover === "file"}>
<Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}>
<For each={flat()}>
{(i) => (
<Match when={store.popover === "at"}>
<Show
when={atFlat().length > 0}
fallback={<div class="text-text-weak px-2 py-1">No matching results</div>}
>
<For each={atFlat().slice(0, 10)}>
{(item) => (
<button
classList={{
"w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
"bg-surface-raised-base-hover": active() === i,
"bg-surface-raised-base-hover": atActive() === atKey(item),
}}
onClick={() => handleFileSelect(i)}
onClick={() => handleAtSelect(item)}
>
<FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span>
<Show when={!i.endsWith("/")}>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</Show>
</div>
<Show
when={item.type === "agent"}
fallback={
<>
<FileIcon
node={{ path: (item as { type: "file"; path: string }).path, type: "file" }}
class="shrink-0 size-4"
/>
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap truncate min-w-0">
{getDirectory((item as { type: "file"; path: string }).path)}
</span>
<Show when={!(item as { type: "file"; path: string }).path.endsWith("/")}>
<span class="text-text-strong whitespace-nowrap">
{getFilename((item as { type: "file"; path: string }).path)}
</span>
</Show>
</div>
</>
}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">
@{(item as { type: "agent"; name: string }).name}
</span>
</Show>
</button>
)}
</For>
@@ -1262,6 +1398,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<form
onSubmit={handleSubmit}
classList={{
"group/prompt-input": true,
"bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
"rounded-md overflow-clip focus-within:shadow-xs-border": true,
"border-icon-info-active border-dashed": store.dragging,
@@ -1323,7 +1460,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
classList={{
"select-text": true,
"w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
"[&_[data-type=file]]:text-icon-info-active": true,
"[&_[data-type=file]]:text-syntax-property": true,
"[&_[data-type=agent]]:text-syntax-type": true,
"font-mono!": store.mode === "shell",
}}
/>
@@ -1334,12 +1472,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
: `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
</div>
</Show>
<div class="absolute top-4.5 right-4">
<SessionContextUsage />
</div>
</div>
<div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-1">
<div class="flex items-center justify-start gap-0.5">
<Switch>
<Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6">
@@ -1349,52 +1484,77 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</Match>
<Match when={store.mode === "normal"}>
<Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>Cycle agent</span>
<span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
</div>
}
>
<TooltipKeybind placement="top" title="Cycle agent" keybind={command.keybind("agent.cycle")}>
<Select
options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current().name}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class="capitalize"
variant="ghost"
/>
</Tooltip>
<Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>Choose model</span>
<span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
</div>
</TooltipKeybind>
<Show
when={providers.paid().length > 0}
fallback={
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
{local.model.current()?.name ?? "Select model"}
<Icon name="chevron-down" size="small" />
</Button>
</TooltipKeybind>
}
>
<Button
as="div"
variant="ghost"
onClick={() =>
dialog.show(() =>
providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
)
}
<ModelSelectorPopover>
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
<Button as="div" variant="ghost">
{local.model.current()?.name ?? "Select model"}
<Icon name="chevron-down" size="small" />
</Button>
</TooltipKeybind>
</ModelSelectorPopover>
</Show>
<Show when={local.model.variant.list().length > 0}>
<TooltipKeybind
placement="top"
title="Thinking effort"
keybind={command.keybind("model.variant.cycle")}
>
{local.model.current()?.name ?? "Select model"}
<span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
{local.model.current()?.provider.name}
</span>
<Icon name="chevron-down" size="small" />
</Button>
</Tooltip>
<Button
variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block"
onClick={() => local.model.variant.cycle()}
>
<span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
</Button>
</TooltipKeybind>
</Show>
<Show when={permission.permissionsEnabled() && params.id}>
<TooltipKeybind
placement="top"
title="Auto-accept edits"
keybind={command.keybind("permissions.autoaccept")}
>
<Button
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
}}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }}
/>
</Button>
</TooltipKeybind>
</Show>
</Match>
</Switch>
</div>
<div class="flex items-center gap-1 absolute right-2 bottom-2">
<div class="flex items-center gap-3 absolute right-2 bottom-2">
<input
ref={fileInputRef}
type="file"
@@ -1406,17 +1566,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = ""
}}
/>
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value="Attach image">
<IconButton
type="button"
icon="photo"
variant="ghost"
class="h-10 w-8"
onClick={() => fileInputRef.click()}
/>
</Tooltip>
</Show>
<div class="flex items-center gap-2">
<SessionContextUsage />
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value="Attach image">
<Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}>
<Icon name="photo" class="size-4.5" />
</Button>
</Tooltip>
</Show>
</div>
<Tooltip
placement="top"
inactive={!prompt.dirty() && !working()}
@@ -1442,7 +1601,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-10 w-8"
class="h-6 w-4.5"
/>
</Tooltip>
</div>
@@ -1500,7 +1659,9 @@ function setCursorPosition(parent: HTMLElement, position: number) {
while (node) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
const isPill =
node.nodeType === Node.ELEMENT_NODE &&
((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
@@ -1513,13 +1674,13 @@ function setCursorPosition(parent: HTMLElement, position: number) {
return
}
if ((isFile || isBreak) && remaining <= length) {
if ((isPill || isBreak) && remaining <= length) {
const range = document.createRange()
const selection = window.getSelection()
if (remaining === 0) {
range.setStartBefore(node)
}
if (remaining > 0 && isFile) {
if (remaining > 0 && isPill) {
range.setStartAfter(node)
}
if (remaining > 0 && isBreak) {
@@ -35,20 +35,25 @@ export function SessionContextUsage() {
{(ctx) => (
<Tooltip
value={
<div class="grid grid-cols-2 gap-x-3 gap-y-1">
<span class="opacity-70 text-right">Tokens</span>
<span class="text-left">{ctx().tokens}</span>
<span class="opacity-70 text-right">Usage</span>
<span class="text-left">{ctx().percentage ?? 0}%</span>
<span class="opacity-70 text-right">Cost</span>
<span class="text-left">{cost()}</span>
<div class="">
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().tokens}</span>
<span class="text-text-invert-base">Tokens</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
<span class="text-text-invert-base">Usage</span>
</div>
<div class="flex items-center gap-2">
<span class="text-text-invert-strong">{cost()}</span>
<span class="text-text-invert-base">Cost</span>
</div>
</div>
}
placement="top"
>
<div class="flex items-center gap-1.5">
<div class="p-1">
<ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
{/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
</div>
</Tooltip>
)}
@@ -1,53 +0,0 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useSync } from "@/context/sync"
import { useGlobalSync } from "@/context/global-sync"
import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Button } from "@opencode-ai/ui/button"
import { DialogSelectServer } from "@/components/dialog-select-server"
export function StatusBar(props: ParentProps) {
const dialog = useDialog()
const server = useServer()
const sync = useSync()
const globalSync = useGlobalSync()
const directoryDisplay = createMemo(() => {
const directory = sync.data.path.directory || ""
const home = globalSync.data.path.home || ""
const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
const branch = sync.data.vcs?.branch
return branch ? `${short}:${branch}` : short
})
return (
<div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
<div class="flex items-center gap-3">
<div class="flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<span class="text-12-regular text-text-weak">{server.name}</span>
</Button>
</div>
<Show when={directoryDisplay()}>
<span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
</Show>
</div>
<div class="flex items-center">{props.children}</div>
</div>
)
}
+14 -7
View File
@@ -115,13 +115,14 @@ function createGlobalSync() {
.then((x) => {
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.slice()
.filter((s) => !s.time.archived)
.sort((a, b) => a.id.localeCompare(b.id))
// Include up to the limit, plus any updated in the last 4 hours
const sessions = nonArchived.filter((s, i) => {
if (i < store.limit) return true
const updated = new Date(s.time.updated).getTime()
const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
return updated > fourHoursAgo
})
setStore("session", reconcile(sessions, { key: "id" }))
@@ -169,6 +170,7 @@ function createGlobalSync() {
sdk.permission.list().then((x) => {
const grouped: Record<string, Permission[]> = {}
for (const perm of x.data ?? []) {
if (!perm?.id || !perm.sessionID) continue
const existing = grouped[perm.sessionID]
if (existing) {
existing.push(perm)
@@ -187,7 +189,10 @@ function createGlobalSync() {
"permission",
sessionID,
reconcile(
permissions.slice().sort((a, b) => a.id.localeCompare(b.id)),
permissions
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@@ -414,10 +419,12 @@ function createGlobalSync() {
),
retry(() =>
globalSDK.client.project.list().then(async (x) => {
setGlobalStore(
"project",
x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
)
const projects = (x.data ?? [])
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
setGlobalStore("project", projects)
}),
),
retry(() =>
+11
View File
@@ -30,6 +30,8 @@ type SessionTabs = {
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type ReviewDiffStyle = "unified" | "split"
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
@@ -49,6 +51,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
review: {
opened: true,
diffStyle: "split" as ReviewDiffStyle,
},
session: {
width: 600,
@@ -156,6 +159,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
review: {
opened: createMemo(() => store.review?.opened ?? true),
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) {
setStore("review", { opened: true, diffStyle })
return
}
setStore("review", "diffStyle", diffStyle)
},
open() {
setStore("review", "opened", true)
},
+71 -10
View File
@@ -65,23 +65,40 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = (() => {
const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const [store, setStore] = createStore<{
current: string
current?: string
}>({
current: list()[0].name,
current: list()[0]?.name,
})
return {
list,
current() {
return list().find((x) => x.name === store.current)!
const available = list()
if (available.length === 0) return undefined
return available.find((x) => x.name === store.current) ?? available[0]
},
set(name: string | undefined) {
setStore("current", name ?? list()[0].name)
const available = list()
if (available.length === 0) {
setStore("current", undefined)
return
}
if (name && available.some((x) => x.name === name)) {
setStore("current", name)
return
}
setStore("current", available[0].name)
},
move(direction: 1 | -1) {
let next = list().findIndex((x) => x.name === store.current) + direction
if (next < 0) next = list().length - 1
if (next >= list().length) next = 0
const value = list()[next]
const available = list()
if (available.length === 0) {
setStore("current", undefined)
return
}
let next = available.findIndex((x) => x.name === store.current) + direction
if (next < 0) next = available.length - 1
if (next >= available.length) next = 0
const value = available[next]
if (!value) return
setStore("current", value.name)
if (value.model)
model.set({
@@ -98,9 +115,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
variant?: Record<string, string | undefined>
}>({
user: [],
recent: [],
variant: {},
}),
)
@@ -182,11 +201,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const current = createMemo(() => {
const a = agent.current()
if (!a) return undefined
const key = getFirstValidModel(
() => ephemeral.model[a.name],
() => a.model,
fallbackModel,
)!
)
if (!key) return undefined
return find(key)
})
@@ -232,7 +253,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
cycle,
set(model: ModelKey | undefined, options?: { recent?: boolean }) {
batch(() => {
setEphemeral("model", agent.current().name, model ?? fallbackModel())
const currentAgent = agent.current()
if (currentAgent) setEphemeral("model", currentAgent.name, model ?? fallbackModel())
if (model) updateVisibility(model, "show")
if (options?.recent && model) {
const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
@@ -252,6 +274,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide")
},
variant: {
current() {
const m = current()
if (!m) return undefined
const key = `${m.provider.id}/${m.id}`
return store.variant?.[key]
},
list() {
const m = current()
if (!m) return []
if (!m.variants) return []
return Object.keys(m.variants)
},
set(value: string | undefined) {
const m = current()
if (!m) return
const key = `${m.provider.id}/${m.id}`
if (!store.variant) {
setStore("variant", { [key]: value })
} else {
setStore("variant", key, value)
}
},
cycle() {
const variants = this.list()
if (variants.length === 0) return
const currentVariant = this.current()
if (!currentVariant) {
this.set(variants[0])
return
}
const index = variants.indexOf(currentVariant)
if (index === -1 || index === variants.length - 1) {
this.set(undefined)
return
}
this.set(variants[index + 1])
},
},
}
})()
+67 -74
View File
@@ -1,17 +1,17 @@
import { createEffect, createRoot, onCleanup } from "solid-js"
import { createMemo, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { Permission } from "@opencode-ai/sdk/v2/client"
import { persisted } from "@/utils/persist"
type PermissionsBySession = {
[sessionID: string]: Permission[]
}
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
type PermissionRespondFn = (input: {
sessionID: string
permissionID: string
response: "once" | "always" | "reject"
directory?: string
}) => void
const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
@@ -22,109 +22,102 @@ function shouldAutoAccept(perm: Permission) {
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
init: (props: { permissions: PermissionsBySession; onRespond: PermissionRespondFn }) => {
init: () => {
const params = useParams()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const permissionsEnabled = createMemo(() => {
if (!params.dir) return false
const [store] = globalSync.child(params.dir)
return store.config.permission !== undefined
})
const [store, setStore, _, ready] = persisted(
"permission.v1",
"permission.v3",
createStore({
autoAcceptEdits: {} as Record<string, boolean>,
}),
)
const responded = new Set<string>()
const watches = new Map<string, () => void>()
function respond(perm: Permission) {
if (responded.has(perm.id)) return
responded.add(perm.id)
props.onRespond({
sessionID: perm.sessionID,
permissionID: perm.id,
response: "once",
const respond: PermissionRespondFn = (input) => {
globalSDK.client.permission.respond(input).catch(() => {
responded.delete(input.permissionID)
})
}
function watch(sessionID: string) {
if (watches.has(sessionID)) return
function respondOnce(permission: Permission, directory?: string) {
if (responded.has(permission.id)) return
responded.add(permission.id)
respond({
sessionID: permission.sessionID,
permissionID: permission.id,
response: "once",
directory,
})
}
const dispose = createRoot((dispose) => {
createEffect(() => {
if (!store.autoAcceptEdits[sessionID]) return
function isAutoAccepting(sessionID: string) {
return store.autoAcceptEdits[sessionID] ?? false
}
const permissions = props.permissions[sessionID] ?? []
permissions.length
const unsubscribe = globalSDK.event.listen((e) => {
const event = e.details
if (event?.type !== "permission.updated") return
for (const perm of permissions) {
const perm = event.properties
if (!isAutoAccepting(perm.sessionID)) return
if (!shouldAutoAccept(perm)) return
respondOnce(perm, e.name)
})
onCleanup(unsubscribe)
function enable(sessionID: string, directory: string) {
setStore("autoAcceptEdits", sessionID, true)
globalSDK.client.permission
.list({ directory })
.then((x) => {
for (const perm of x.data ?? []) {
if (!perm?.id) continue
if (perm.sessionID !== sessionID) continue
if (!shouldAutoAccept(perm)) continue
respond(perm)
respondOnce(perm, directory)
}
})
return dispose
})
watches.set(sessionID, dispose)
}
function unwatch(sessionID: string) {
const dispose = watches.get(sessionID)
if (!dispose) return
dispose()
watches.delete(sessionID)
}
createEffect(() => {
if (!ready()) return
for (const sessionID in store.autoAcceptEdits) {
if (!store.autoAcceptEdits[sessionID]) continue
watch(sessionID)
}
})
onCleanup(() => {
for (const dispose of watches.values()) dispose()
watches.clear()
})
function enable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, true)
watch(sessionID)
const permissions = props.permissions[sessionID] ?? []
for (const perm of permissions) {
if (!shouldAutoAccept(perm)) continue
respond(perm)
}
.catch(() => undefined)
}
function disable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, false)
unwatch(sessionID)
}
return {
get permissions() {
return props.permissions
ready,
respond,
autoResponds(permission: Permission) {
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
},
respond: props.onRespond,
isAutoAccepting(sessionID: string) {
return store.autoAcceptEdits[sessionID] ?? false
},
toggleAutoAccept(sessionID: string) {
if (store.autoAcceptEdits[sessionID]) {
isAutoAccepting,
toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID)) {
disable(sessionID)
return
}
enable(sessionID)
enable(sessionID, directory)
},
enableAutoAccept(sessionID: string) {
if (store.autoAcceptEdits[sessionID]) return
enable(sessionID)
enableAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string) {
disable(sessionID)
},
permissionsEnabled,
}
},
})
+10 -1
View File
@@ -21,6 +21,11 @@ export interface FileAttachmentPart extends PartBase {
selection?: TextSelection
}
export interface AgentPart extends PartBase {
type: "agent"
name: string
}
export interface ImageAttachmentPart {
type: "image"
id: string
@@ -29,7 +34,7 @@ export interface ImageAttachmentPart {
dataUrl: string
}
export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
export type Prompt = ContentPart[]
export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -46,6 +51,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
return false
}
if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
return false
}
if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
return false
}
@@ -61,6 +69,7 @@ function cloneSelection(selection?: TextSelection) {
function clonePart(part: ContentPart): ContentPart {
if (part.type === "text") return { ...part }
if (part.type === "image") return { ...part }
if (part.type === "agent") return { ...part }
return {
...part,
selection: cloneSelection(part.selection),
+2
View File
@@ -117,10 +117,12 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const origin = createMemo(() => projectsKey(active()))
const projectsList = createMemo(() => store.projects[origin()] ?? [])
const isLocal = createMemo(() => origin() === "local")
return {
ready: isReady,
healthy,
isLocal,
get url() {
return active()
},
+12 -3
View File
@@ -56,14 +56,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
draft.part[input.messageID] = input.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
}),
)
},
async sync(sessionID: string, _isRetry = false) {
const [session, messages, todo, diff] = await Promise.all([
retry(() => sdk.client.session.get({ sessionID })),
retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })),
])
@@ -88,6 +91,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
reconcile(
(messages.data ?? [])
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
@@ -95,11 +99,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
)
for (const message of messages.data ?? []) {
if (!message?.info?.id) continue
setStore(
"part",
message.info.id,
reconcile(
message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)),
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@@ -112,6 +120,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
setStore("limit", (x) => x + count)
await sdk.client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
.slice(0, store.limit)
+4 -6
View File
@@ -3,7 +3,7 @@ import { useParams } from "@solidjs/router"
import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { PermissionProvider } from "@/context/permission"
import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
@@ -27,11 +27,9 @@ export default function Layout(props: ParentProps) {
}) => sdk.client.permission.respond(input)
return (
<PermissionProvider permissions={sync.data.permission} onRespond={respond}>
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
</PermissionProvider>
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
})}
</SyncProvider>
+39 -3
View File
@@ -2,6 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button"
import { Component, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Icon } from "@opencode-ai/ui/icon"
@@ -181,6 +182,25 @@ interface ErrorPageProps {
export const ErrorPage: Component<ErrorPageProps> = (props) => {
const platform = usePlatform()
const [store, setStore] = createStore({
checking: false,
version: undefined as string | undefined,
})
async function checkForUpdates() {
if (!platform.checkUpdate) return
setStore("checking", true)
const result = await platform.checkUpdate()
setStore("checking", false)
if (result.updateAvailable && result.version) setStore("version", result.version)
}
async function installUpdate() {
if (!platform.update || !platform.restart) return
await platform.update()
await platform.restart()
}
return (
<div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
<div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
@@ -198,9 +218,25 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
label="Error Details"
hideLabel
/>
<Button size="large" onClick={platform.restart}>
Restart
</Button>
<div class="flex items-center gap-3">
<Button size="large" onClick={platform.restart}>
Restart
</Button>
<Show when={platform.checkUpdate}>
<Show
when={store.version}
fallback={
<Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
{store.checking ? "Checking..." : "Check for updates"}
</Button>
}
>
<Button size="large" onClick={installUpdate}>
Update to {store.version}
</Button>
</Show>
</Show>
</div>
<div class="flex flex-col items-center gap-2">
<div class="flex items-center justify-center gap-1">
Please report this error to the OpenCode team
+1 -1
View File
@@ -38,7 +38,7 @@ export default function Home() {
}
}
if (platform.openDirectoryPickerDialog) {
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: "Open project",
multiple: true,
+61 -51
View File
@@ -22,10 +22,11 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Mark } from "@opencode-ai/ui/logo"
import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session } from "@opencode-ai/sdk/v2/client"
@@ -44,8 +45,9 @@ import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { Header } from "@/components/header"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
@@ -54,6 +56,7 @@ import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { useServer } from "@/context/server"
export default function Layout(props: ParentProps) {
const [store, setStore] = createStore({
@@ -88,7 +91,9 @@ export default function Layout(props: ParentProps) {
const globalSync = useGlobalSync()
const layout = useLayout()
const platform = usePlatform()
const server = useServer()
const notification = useNotification()
const permission = usePermission()
const navigate = useNavigate()
const providers = useProviders()
const dialog = useDialog()
@@ -129,11 +134,15 @@ export default function Layout(props: ParentProps) {
})
}
onMount(async () => {
if (platform.checkUpdate && platform.update && platform.restart) {
const { updateAvailable, version } = await platform.checkUpdate()
if (updateAvailable) {
showToast({
onMount(() => {
if (!platform.checkUpdate || !platform.update || !platform.restart) return
let toastId: number | undefined
async function pollUpdate() {
const { updateAvailable, version } = await platform.checkUpdate!()
if (updateAvailable && toastId === undefined) {
toastId = showToast({
persistent: true,
icon: "download",
title: "Update available",
@@ -154,31 +163,48 @@ export default function Layout(props: ParentProps) {
})
}
}
pollUpdate()
const interval = setInterval(pollUpdate, 10 * 60 * 1000)
onCleanup(() => clearInterval(interval))
})
onMount(() => {
const seenSessions = new Set<string>()
const toastBySession = new Map<string, number>()
const alertedAtBySession = new Map<string, number>()
const permissionAlertCooldownMs = 5000
const unsub = globalSDK.event.listen((e) => {
if (e.details?.type !== "permission.updated") return
const directory = e.name
const permission = e.details.properties
const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id
const perm = e.details.properties
if (permission.autoResponds(perm)) return
const sessionKey = `${directory}:${perm.sessionID}`
const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === permission.sessionID)
const session = store.session.find((s) => s.id === perm.sessionID)
const sessionTitle = session?.title ?? "New session"
const projectName = getFilename(directory)
const description = `${sessionTitle} in ${projectName} needs permission`
const href = `/${base64Encode(directory)}/session/${permission.sessionID}`
const href = `/${base64Encode(directory)}/session/${perm.sessionID}`
const now = Date.now()
const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
if (now - lastAlerted < permissionAlertCooldownMs) return
alertedAtBySession.set(sessionKey, now)
void platform.notify("Permission required", description, href)
if (directory === currentDir && permission.sessionID === currentSession) return
const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id
if (directory === currentDir && perm.sessionID === currentSession) return
if (directory === currentDir && session?.parentID === currentSession) return
const sessionKey = `${directory}:${permission.sessionID}`
if (seenSessions.has(sessionKey)) return
seenSessions.add(sessionKey)
const existingToastId = toastBySession.get(sessionKey)
if (existingToastId !== undefined) {
toaster.dismiss(existingToastId)
}
const toastId = showToast({
persistent: true,
@@ -211,7 +237,7 @@ export default function Layout(props: ParentProps) {
if (toastId !== undefined) {
toaster.dismiss(toastId)
toastBySession.delete(sessionKey)
seenSessions.delete(sessionKey)
alertedAtBySession.delete(sessionKey)
}
const [store] = globalSync.child(currentDir)
const childSessions = store.session.filter((s) => s.parentID === currentSession)
@@ -221,7 +247,7 @@ export default function Layout(props: ParentProps) {
if (childToastId !== undefined) {
toaster.dismiss(childToastId)
toastBySession.delete(childKey)
seenSessions.delete(childKey)
alertedAtBySession.delete(childKey)
}
}
})
@@ -476,7 +502,7 @@ export default function Layout(props: ParentProps) {
}
}
if (platform.openDirectoryPickerDialog) {
if (platform.openDirectoryPickerDialog && server.isLocal()) {
const result = await platform.openDirectoryPickerDialog?.({
title: "Open project",
multiple: true,
@@ -706,17 +732,13 @@ export default function Layout(props: ParentProps) {
</A>
</Tooltip>
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
<Tooltip
<TooltipKeybind
placement={props.mobile ? "bottom" : "right"}
value={
<div class="flex items-center gap-2">
<span>Archive session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
</div>
}
title="Archive session"
keybind={command.keybind("session.archive")}
>
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</Tooltip>
</TooltipKeybind>
</div>
</div>
</>
@@ -784,17 +806,9 @@ export default function Layout(props: ParentProps) {
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<Tooltip
placement="top"
value={
<div class="flex items-center gap-2">
<span>New session</span>
<span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
</div>
}
>
<TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
</Tooltip>
</TooltipKeybind>
</div>
</Button>
<Collapsible.Content>
@@ -872,15 +886,16 @@ export default function Layout(props: ParentProps) {
<>
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
<Show when={!sidebarProps.mobile}>
<Tooltip
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2" data-tauri-drag-region>
<Mark class="shrink-0" />
</A>
</Show>
<Show when={!sidebarProps.mobile}>
<TooltipKeybind
class="shrink-0"
placement="right"
value={
<div class="flex items-center gap-2">
<span>Toggle sidebar</span>
<span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
</div>
}
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
inactive={expanded()}
>
<Button
@@ -912,7 +927,7 @@ export default function Layout(props: ParentProps) {
</div>
</Show>
</Button>
</Tooltip>
</TooltipKeybind>
</Show>
<DragDropProvider
onDragStart={handleDragStart}
@@ -1016,11 +1031,6 @@ export default function Layout(props: ParentProps) {
return (
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Header
navigateToProject={navigateToProject}
navigateToSession={navigateToSession}
onMobileMenuToggle={mobileSidebar.toggle}
/>
<div class="flex-1 min-h-0 flex">
<div
classList={{
+238 -46
View File
@@ -21,7 +21,7 @@ import { DateTime } from "luxon"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
@@ -51,17 +51,26 @@ import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router"
import { A, useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { extractPromptFromParts } from "@/utils/prompt"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { StatusBar } from "@/components/status-bar"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { usePermission } from "@/context/permission"
import { showToast } from "@opencode-ai/ui/toast"
import { useServer } from "@/context/server"
import { Button } from "@opencode-ai/ui/button"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import { useGlobalSDK } from "@/context/global-sdk"
import { Popover } from "@opencode-ai/ui/popover"
import { Select } from "@opencode-ai/ui/select"
import { TextField } from "@opencode-ai/ui/text-field"
import { base64Encode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Session } from "@opencode-ai/sdk/v2/client"
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
@@ -69,6 +78,196 @@ function same<T>(a: readonly T[], b: readonly T[]) {
return a.every((x, i) => x === b[i])
}
function Header(props: { onMobileMenuToggle?: () => void }) {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const command = useCommand()
const server = useServer()
const dialog = useDialog()
const sync = useSync()
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const branch = createMemo(() => sync.data.vcs?.branch)
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
}
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session.id}`)
}
return (
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
onClick={props.onMobileMenuToggle}
>
<Icon name="menu" size="small" />
</button>
<div class="px-4 flex items-center justify-between gap-4 w-full">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
current={sync.directory}
label={(x) => {
const name = getFilename(x)
const b = x === sync.directory ? branch() : undefined
return b ? `${name}:${b}` : name
}}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</Select>
<div class="text-text-weaker">/</div>
</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</TooltipKeybind>
</Show>
</div>
<div class="flex items-center gap-3">
<div class="hidden md:flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<Icon name="server" size="small" class="text-icon-weak" />
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
</Button>
<SessionLspIndicator />
<SessionMcpIndicator />
</div>
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: sync.directory })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
)
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
})}
</Popover>
</Show>
</div>
</div>
</header>
)
}
export default function Page() {
const layout = useLayout()
const local = useLocal()
@@ -248,21 +447,6 @@ export default function Page() {
slash: "open",
onSelect: () => dialog.show(() => <DialogSelectFile />),
},
// {
// id: "theme.toggle",
// title: "Toggle theme",
// description: "Switch between themes",
// category: "View",
// keybind: "ctrl+t",
// slash: "theme",
// onSelect: () => {
// const currentTheme = localStorage.getItem("theme") ?? "oc-1"
// const themes = ["oc-1", "oc-2-paper"]
// const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
// localStorage.setItem("theme", nextTheme)
// document.documentElement.setAttribute("data-theme", nextTheme)
// },
// },
{
id: "terminal.toggle",
title: "Toggle terminal",
@@ -351,17 +535,33 @@ export default function Page() {
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
},
{
id: "model.variant.cycle",
title: "Cycle thinking effort",
description: "Switch to the next effort level",
category: "Model",
keybind: "shift+mod+t",
onSelect: () => {
local.model.variant.cycle()
showToast({
title: "Thinking effort changed",
description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"),
})
},
},
{
id: "permissions.autoaccept",
title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
category: "Permissions",
disabled: !params.id,
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
onSelect: () => {
if (!params.id) return
permission.toggleAutoAccept(params.id)
const sessionID = params.id
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
title: permission.isAutoAccepting(params.id) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
description: permission.isAutoAccepting(params.id)
title: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
description: permission.isAutoAccepting(sessionID)
? "Edit and write permissions will be automatically approved"
: "Edit and write permissions will require approval",
})
@@ -718,6 +918,7 @@ export default function Page() {
return (
<div class="relative bg-background-base size-full overflow-hidden flex flex-col">
<Header />
<div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
<Switch>
<Match when={!params.id}>
@@ -742,6 +943,8 @@ export default function Page() {
<div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
<SessionReview
diffs={diffs()}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
classes={{
root: "pb-32",
header: "px-4",
@@ -838,13 +1041,9 @@ export default function Page() {
</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<Tooltip
value={
<div class="flex items-center gap-2">
<span>Open file</span>
<span class="text-icon-base text-12-medium">{command.keybind("file.open")}</span>
</div>
}
<TooltipKeybind
title="Open file"
keybind={command.keybind("file.open")}
class="flex items-center"
>
<IconButton
@@ -853,7 +1052,7 @@ export default function Page() {
iconSize="large"
onClick={() => dialog.show(() => <DialogSelectFile />)}
/>
</Tooltip>
</TooltipKeybind>
</div>
</Tabs.List>
</div>
@@ -867,7 +1066,8 @@ export default function Page() {
container: "px-6",
}}
diffs={diffs()}
split
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
/>
</div>
</Tabs.Content>
@@ -959,17 +1159,13 @@ export default function Page() {
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<Tooltip
value={
<div class="flex items-center gap-2">
<span>New terminal</span>
<span class="text-icon-base text-12-medium">{command.keybind("terminal.new")}</span>
</div>
}
<TooltipKeybind
title="New terminal"
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
</Tooltip>
</TooltipKeybind>
</div>
</Tabs.List>
<For each={terminal.all()}>
@@ -999,10 +1195,6 @@ export default function Page() {
</DragDropProvider>
</div>
</Show>
<StatusBar>
<SessionLspIndicator />
<SessionMcpIndicator />
</StatusBar>
</div>
)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.214",
"version": "1.0.222",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
@@ -1,5 +1,5 @@
import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
import { createEffect, Show } from "solid-js"
import { createEffect, Show, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
import { Billing } from "@opencode-ai/console-core/billing.js"
@@ -68,6 +68,12 @@ export function ReloadSection() {
reloadTrigger: "",
})
const processingFee = createMemo(() => {
const reloadAmount = billingInfo()?.reloadAmount
if (!reloadAmount) return "0.00"
return (((reloadAmount + 0.3) / 0.956) * 0.044 + 0.3).toFixed(2)
})
createEffect(() => {
if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
setStore("show", false)
@@ -104,8 +110,8 @@ export function ReloadSection() {
}
>
<p>
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+$1.23 processing fee)
when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
</p>
</Show>
<button data-color="primary" type="button" onClick={() => show()}>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.214",
"version": "1.0.222",
"private": true,
"type": "module",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.214",
"version": "1.0.222",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.214",
"version": "1.0.222",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.0.214",
"version": "1.0.222",
"type": "module",
"scripts": {
"typecheck": "tsgo -b",
+1 -1
View File
@@ -6,7 +6,7 @@ const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE
const sidecarConfig = getCurrentSidecar(RUST_TARGET)
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`
const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`
await $`cd ../opencode && bun run build --single`
+77 -17
View File
@@ -57,19 +57,71 @@ const platform: Platform = {
},
openLink(url: string) {
shellOpen(url)
void shellOpen(url).catch(() => undefined)
},
storage: (name = "default.dat") => {
const api: AsyncStorage = {
type StoreLike = {
get(key: string): Promise<string | null | undefined>
set(key: string, value: string): Promise<unknown>
delete(key: string): Promise<unknown>
clear(): Promise<unknown>
keys(): Promise<string[]>
length(): Promise<number>
}
const memory = () => {
const data = new Map<string, string>()
const store: StoreLike = {
get: async (key) => data.get(key),
set: async (key, value) => {
data.set(key, value)
},
delete: async (key) => {
data.delete(key)
},
clear: async () => {
data.clear()
},
keys: async () => Array.from(data.keys()),
length: async () => data.size,
}
return store
}
const api: AsyncStorage & { _store: Promise<StoreLike> | null; _getStore: () => Promise<StoreLike> } = {
_store: null,
_getStore: async () => api._store || (api._store = Store.load(name)),
getItem: async (key: string) => (await (await api._getStore()).get(key)) ?? null,
setItem: async (key: string, value: string) => await (await api._getStore()).set(key, value),
removeItem: async (key: string) => await (await api._getStore()).delete(key),
clear: async () => await (await api._getStore()).clear(),
key: async (index: number) => (await (await api._getStore()).keys())[index],
getLength: async () => (await api._getStore()).length(),
_getStore: async () => {
if (api._store) return api._store
api._store = Store.load(name).catch(() => memory())
return api._store
},
getItem: async (key: string) => {
const store = await api._getStore()
const value = await store.get(key).catch(() => null)
if (value === undefined) return null
return value
},
setItem: async (key: string, value: string) => {
const store = await api._getStore()
await store.set(key, value).catch(() => undefined)
},
removeItem: async (key: string) => {
const store = await api._getStore()
await store.delete(key).catch(() => undefined)
},
clear: async () => {
const store = await api._getStore()
await store.clear().catch(() => undefined)
},
key: async (index: number) => {
const store = await api._getStore()
return (await store.keys().catch(() => []))[index]
},
getLength: async () => {
const store = await api._getStore()
return await store.length().catch(() => 0)
},
get length() {
return api.getLength()
},
@@ -79,20 +131,25 @@ const platform: Platform = {
checkUpdate: async () => {
if (!UPDATER_ENABLED) return { updateAvailable: false }
update = await check()
if (!update) return { updateAvailable: false }
await update.download()
return { updateAvailable: true, version: update.version }
const next = await check().catch(() => null)
if (!next) return { updateAvailable: false }
const ok = await next
.download()
.then(() => true)
.catch(() => false)
if (!ok) return { updateAvailable: false }
update = next
return { updateAvailable: true, version: next.version }
},
update: async () => {
if (!UPDATER_ENABLED || !update) return
if (ostype() === "windows") await invoke("kill_sidecar")
await update.install()
if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined)
await update.install().catch(() => undefined)
},
restart: async () => {
await invoke("kill_sidecar")
await invoke("kill_sidecar").catch(() => undefined)
await relaunch()
},
@@ -107,7 +164,10 @@ const platform: Platform = {
await Promise.resolve()
.then(() => {
const notification = new Notification(title, { body: description ?? "" })
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96.png",
})
notification.onclick = () => {
const win = getCurrentWindow()
void win.show().catch(() => undefined)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.0.214",
"version": "1.0.222",
"private": true,
"type": "module",
"scripts": {
@@ -33,7 +33,7 @@ const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m)
const ClientOnlyWorkerPoolProvider = clientOnly(() =>
import("@opencode-ai/ui/pierre/worker").then((m) => ({
default: (props: { children: any }) => (
<WorkerPoolProvider pool={m.workerPool}>{props.children}</WorkerPoolProvider>
<WorkerPoolProvider pools={m.getWorkerPools()}>{props.children}</WorkerPoolProvider>
),
})),
)
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.0.214"
version = "1.0.222"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.214/opencode-darwin-arm64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.222/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.214/opencode-darwin-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.222/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.214/opencode-linux-arm64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.222/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.214/opencode-linux-x64.tar.gz"
archive = "https://github.com/sst/opencode/releases/download/v1.0.222/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/sst/opencode/releases/download/v1.0.214/opencode-windows-x64.zip"
archive = "https://github.com/sst/opencode/releases/download/v1.0.222/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.0.214",
"version": "1.0.222",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+3 -2
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.214",
"version": "1.0.222",
"name": "opencode",
"type": "module",
"private": true,
@@ -66,6 +66,7 @@
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.19",
"@ai-sdk/togetherai": "1.0.30",
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1",
"@hono/standard-validator": "0.1.5",
@@ -88,7 +89,7 @@
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.2",
"bun-pty": "0.4.4",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
@@ -0,0 +1,51 @@
import { EOL } from "os"
import { basename } from "path"
import { Agent } from "../../../agent/agent"
import { Provider } from "../../../provider/provider"
import { ToolRegistry } from "../../../tool/registry"
import { Wildcard } from "../../../util/wildcard"
import { bootstrap } from "../../bootstrap"
import { cmd } from "../cmd"
export const AgentCommand = cmd({
command: "agent <name>",
builder: (yargs) =>
yargs.positional("name", {
type: "string",
demandOption: true,
description: "Agent name",
}),
async handler(args) {
await bootstrap(process.cwd(), async () => {
const agentName = args.name as string
const agent = await Agent.get(agentName)
if (!agent) {
process.stderr.write(
`Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
)
process.exit(1)
}
const resolvedTools = await resolveTools(agent)
const output = {
...agent,
tools: resolvedTools,
toolOverrides: agent.tools,
}
process.stdout.write(JSON.stringify(output, null, 2) + EOL)
})
},
})
async function resolveTools(agent: Agent.Info) {
const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
const toolOverrides = {
...agent.tools,
...(await ToolRegistry.enabled(agent)),
}
const availableTools = await ToolRegistry.tools(providerID, agent)
const resolved: Record<string, boolean> = {}
for (const tool of availableTools) {
resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false
}
return resolved
}
@@ -8,6 +8,7 @@ import { RipgrepCommand } from "./ripgrep"
import { ScrapCommand } from "./scrap"
import { SkillCommand } from "./skill"
import { SnapshotCommand } from "./snapshot"
import { AgentCommand } from "./agent"
export const DebugCommand = cmd({
command: "debug",
@@ -20,6 +21,7 @@ export const DebugCommand = cmd({
.command(ScrapCommand)
.command(SkillCommand)
.command(SnapshotCommand)
.command(AgentCommand)
.command(PathsCommand)
.command({
command: "wait",
+89 -2
View File
@@ -20,6 +20,17 @@ interface SessionStats {
}
}
toolUsage: Record<string, number>
modelUsage: Record<
string,
{
messages: number
tokens: {
input: number
output: number
}
cost: number
}
>
dateRange: {
earliest: number
latest: number
@@ -43,6 +54,9 @@ export const StatsCommand = cmd({
describe: "number of tools to show (default: all)",
type: "number",
})
.option("models", {
describe: "show model statistics (default: hidden). Pass a number to show top N, otherwise shows all",
})
.option("project", {
describe: "filter by project (default: all projects, empty string: current project)",
type: "string",
@@ -51,7 +65,15 @@ export const StatsCommand = cmd({
handler: async (args) => {
await bootstrap(process.cwd(), async () => {
const stats = await aggregateSessionStats(args.days, args.project)
displayStats(stats, args.tools)
let modelLimit: number | undefined
if (args.models === true) {
modelLimit = Infinity
} else if (typeof args.models === "number") {
modelLimit = args.models
}
displayStats(stats, args.tools, modelLimit)
})
},
})
@@ -121,6 +143,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
},
},
toolUsage: {},
modelUsage: {},
dateRange: {
earliest: Date.now(),
latest: Date.now(),
@@ -154,17 +177,43 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
let sessionCost = 0
let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
let sessionToolUsage: Record<string, number> = {}
let sessionModelUsage: Record<
string,
{
messages: number
tokens: {
input: number
output: number
}
cost: number
}
> = {}
for (const message of messages) {
if (message.info.role === "assistant") {
sessionCost += message.info.cost || 0
const modelKey = `${message.info.providerID}/${message.info.modelID}`
if (!sessionModelUsage[modelKey]) {
sessionModelUsage[modelKey] = {
messages: 0,
tokens: { input: 0, output: 0 },
cost: 0,
}
}
sessionModelUsage[modelKey].messages++
sessionModelUsage[modelKey].cost += message.info.cost || 0
if (message.info.tokens) {
sessionTokens.input += message.info.tokens.input || 0
sessionTokens.output += message.info.tokens.output || 0
sessionTokens.reasoning += message.info.tokens.reasoning || 0
sessionTokens.cache.read += message.info.tokens.cache?.read || 0
sessionTokens.cache.write += message.info.tokens.cache?.write || 0
sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
sessionModelUsage[modelKey].tokens.output +=
(message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
}
}
@@ -181,6 +230,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
sessionTokens,
sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
sessionToolUsage,
sessionModelUsage,
earliestTime: session.time.created,
latestTime: session.time.updated,
}
@@ -204,6 +254,20 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
}
for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
if (!stats.modelUsage[model]) {
stats.modelUsage[model] = {
messages: 0,
tokens: { input: 0, output: 0 },
cost: 0,
}
}
stats.modelUsage[model].messages += usage.messages
stats.modelUsage[model].tokens.input += usage.tokens.input
stats.modelUsage[model].tokens.output += usage.tokens.output
stats.modelUsage[model].cost += usage.cost
}
}
}
@@ -228,7 +292,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
return stats
}
export function displayStats(stats: SessionStats, toolLimit?: number) {
export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
const width = 56
function renderRow(label: string, value: string): string {
@@ -267,6 +331,29 @@ export function displayStats(stats: SessionStats, toolLimit?: number) {
console.log("└────────────────────────────────────────────────────────┘")
console.log()
// Model Usage section
if (modelLimit !== undefined && Object.keys(stats.modelUsage).length > 0) {
const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
const modelsToDisplay = modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit)
console.log("┌────────────────────────────────────────────────────────┐")
console.log("│ MODEL USAGE │")
console.log("├────────────────────────────────────────────────────────┤")
for (const [model, usage] of modelsToDisplay) {
console.log(`${model.padEnd(54)}`)
console.log(renderRow(" Messages", usage.messages.toLocaleString()))
console.log(renderRow(" Input Tokens", formatNumber(usage.tokens.input)))
console.log(renderRow(" Output Tokens", formatNumber(usage.tokens.output)))
console.log(renderRow(" Cost", `$${usage.cost.toFixed(4)}`))
console.log("├────────────────────────────────────────────────────────┤")
}
// Remove last separator and add bottom border
process.stdout.write("\x1B[1A") // Move up one line
console.log("└────────────────────────────────────────────────────────┘")
}
console.log()
// Tool Usage section
if (Object.keys(stats.toolUsage).length > 0) {
const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)
@@ -372,6 +372,15 @@ function App() {
local.agent.move(1)
},
},
{
title: "Variant cycle",
value: "variant.cycle",
keybind: "variant_cycle",
category: "Agent",
onSelect: () => {
local.model.variant.cycle()
},
},
{
title: "Agent cycle reverse",
value: "agent.cycle.reverse",
@@ -259,7 +259,7 @@ export function Autocomplete(props: {
const s = session()
for (const command of sync.data.command) {
results.push({
display: "/" + command.name,
display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
description: command.description,
onSelect: () => {
const newText = "/" + command.name + " "
@@ -877,12 +877,6 @@ export function Prompt(props: PromptProps) {
return
}
}
if (keybind.match("variant_cycle", e)) {
e.preventDefault()
if (local.model.variant.list().length === 0) return
local.model.variant.cycle()
return
}
if (store.mode === "normal") autocomplete.onKeyDown(e)
if (!autocomplete.visible) {
if (
@@ -28,7 +28,7 @@ export const TIPS = [
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
"Press {highlight}Escape{/highlight} to stop the AI mid-response.",
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
"Use {highlight}@<agent-name>{/highlight} in prompts to invoke specialized subagents.",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",
@@ -253,7 +253,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const next = favorites[index]
if (!next) return
setModelStore("model", agent.current().name, { ...next })
const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID)
const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
setModelStore(
"recent",
@@ -273,7 +273,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
setModelStore("model", agent.current().name, model)
if (options?.recent) {
const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
if (uniq.length > 10) uniq.pop()
setModelStore(
"recent",
@@ -319,9 +319,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const provider = sync.data.provider.find((x) => x.id === m.providerID)
const info = provider?.models[m.modelID]
if (!info?.variants) return []
return Object.entries(info.variants)
.filter(([_, v]) => !v.disabled)
.map(([name]) => name)
return Object.keys(info.variants)
},
set(value: string | undefined) {
const m = currentModel()
@@ -1716,8 +1716,8 @@ ToolRegistry.register<typeof TaskTool>({
</box>
</Show>
<text fg={theme.text}>
{keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
<span style={{ fg: theme.textMuted }}> to navigate between subagent sessions</span>
{keybind.print("session_child_cycle")}
<span style={{ fg: theme.textMuted }}> view subagents</span>
</text>
</>
)
@@ -115,11 +115,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("selected", currentIndex)
}
}
scroll.scrollTo(0)
scroll?.scrollTo(0)
}),
)
function move(direction: number) {
if (flat().length === 0) return
let next = store.selected + direction
if (next < 0) next = flat().length - 1
if (next >= flat().length) next = 0
@@ -129,6 +130,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
function moveTo(next: number) {
setStore("selected", next)
props.onMove?.(selected()!)
if (!scroll) return
const target = scroll.getChildren().find((child) => {
return child.id === JSON.stringify(selected()?.value)
})
@@ -172,7 +174,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}
})
let scroll: ScrollBoxRenderable
let scroll: ScrollBoxRenderable | undefined
const ref: DialogSelectRef<T> = {
get filter() {
return store.filter
@@ -213,61 +215,70 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
/>
</box>
</box>
<scrollbox
paddingLeft={1}
paddingRight={1}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()}
<Show
when={grouped().length > 0}
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No results found</text>
</box>
}
>
<For each={grouped()}>
{([category, options], index) => (
<>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
</box>
</Show>
<For each={options}>
{(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
const current = createMemo(() => isDeepEqual(option.value, props.current))
return (
<box
id={JSON.stringify(option.value)}
flexDirection="row"
onMouseUp={() => {
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
>
<Option
title={option.title}
footer={option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
</box>
)
}}
</For>
</>
)}
</For>
</scrollbox>
<scrollbox
paddingLeft={1}
paddingRight={1}
scrollbarOptions={{ visible: false }}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()}
>
<For each={grouped()}>
{([category, options], index) => (
<>
<Show when={category}>
<box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
<text fg={theme.accent} attributes={TextAttributes.BOLD}>
{category}
</text>
</box>
</Show>
<For each={options}>
{(option) => {
const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
const current = createMemo(() => isDeepEqual(option.value, props.current))
return (
<box
id={JSON.stringify(option.value)}
flexDirection="row"
onMouseUp={() => {
option.onSelect?.(dialog)
props.onSelect?.(option)
}}
onMouseOver={() => {
const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
if (index === -1) return
moveTo(index)
}}
backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
paddingLeft={current() || option.gutter ? 1 : 3}
paddingRight={3}
gap={1}
>
<Option
title={option.title}
footer={option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
gutter={option.gutter}
/>
</box>
)
}}
</For>
</>
)}
</For>
</scrollbox>
</Show>
<Show when={keybinds().length} fallback={<box flexShrink={0} />}>
<box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
<For each={keybinds()}>
+11 -1
View File
@@ -17,6 +17,12 @@ const options = {
describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
default: false,
},
cors: {
type: "string" as const,
array: true,
describe: "additional domains to allow for CORS",
default: [] as string[],
},
}
export type NetworkOptions = InferredOptionTypes<typeof options>
@@ -30,6 +36,7 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
const portExplicitlySet = process.argv.includes("--port")
const hostnameExplicitlySet = process.argv.includes("--hostname")
const mdnsExplicitlySet = process.argv.includes("--mdns")
const corsExplicitlySet = process.argv.includes("--cors")
const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
@@ -38,6 +45,9 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
: mdns && !config?.server?.hostname
? "0.0.0.0"
: (config?.server?.hostname ?? args.hostname)
const configCors = config?.server?.cors ?? []
const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
const cors = [...configCors, ...argsCors]
return { hostname, port, mdns }
return { hostname, port, mdns, cors }
}
+57 -6
View File
@@ -1,4 +1,3 @@
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
import { Config } from "../config/config"
@@ -6,6 +5,7 @@ import { Instance } from "../project/instance"
import { Identifier } from "../id/id"
import PROMPT_INITIALIZE from "./template/initialize.txt"
import PROMPT_REVIEW from "./template/review.txt"
import { MCP } from "../mcp"
export namespace Command {
export const Event = {
@@ -26,13 +26,29 @@ export namespace Command {
description: z.string().optional(),
agent: z.string().optional(),
model: z.string().optional(),
template: z.string(),
mcp: z.boolean().optional(),
// workaround for zod not supporting async functions natively so we use getters
// https://zod.dev/v4/changelog?id=zfunction
template: z.promise(z.string()).or(z.string()),
subtask: z.boolean().optional(),
hints: z.array(z.string()),
})
.meta({
ref: "Command",
})
export type Info = z.infer<typeof Info>
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
export function hints(template: string): string[] {
const result: string[] = []
const numbered = template.match(/\$\d+/g)
if (numbered) {
for (const match of [...new Set(numbered)].sort()) result.push(match)
}
if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
return result
}
export const Default = {
INIT: "init",
@@ -46,13 +62,19 @@ export namespace Command {
[Default.INIT]: {
name: Default.INIT,
description: "create/update AGENTS.md",
template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
get template() {
return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
},
hints: hints(PROMPT_INITIALIZE),
},
[Default.REVIEW]: {
name: Default.REVIEW,
description: "review changes [commit|branch|pr], defaults to uncommitted",
template: PROMPT_REVIEW.replace("${path}", Instance.worktree),
get template() {
return PROMPT_REVIEW.replace("${path}", Instance.worktree)
},
subtask: true,
hints: hints(PROMPT_REVIEW),
},
}
@@ -62,8 +84,37 @@ export namespace Command {
agent: command.agent,
model: command.model,
description: command.description,
template: command.template,
get template() {
return command.template
},
subtask: command.subtask,
hints: hints(command.template),
}
}
for (const [name, prompt] of Object.entries(await MCP.prompts())) {
result[name] = {
name,
mcp: true,
description: prompt.description,
get template() {
// since a getter can't be async we need to manually return a promise here
return new Promise<string>(async (resolve, reject) => {
const template = await MCP.getPrompt(
prompt.client,
prompt.name,
prompt.arguments
? // substitute each argument with $1, $2, etc.
Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
: {},
).catch(reject)
resolve(
template?.messages
.map((message) => (message.content.type === "text" ? message.content.text : ""))
.join("\n") || "",
)
})
},
hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
}
}
+29 -24
View File
@@ -92,8 +92,6 @@ export namespace Config {
const promises: Promise<void>[] = []
for (const dir of unique(directories)) {
await assertValid(dir)
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
@@ -155,23 +153,6 @@ export namespace Config {
}
})
const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools", "skills"].join(",")}}/`)
async function assertValid(dir: string) {
const invalid = await Array.fromAsync(
INVALID_DIRS.scan({
onlyFiles: false,
cwd: dir,
}),
)
for (const item of invalid) {
throw new ConfigDirectoryTypoError({
path: dir,
dir: item,
suggestion: item.substring(0, item.length - 1),
})
}
}
async function installDependencies(dir: string) {
if (Installation.isLocal()) return
@@ -197,7 +178,7 @@ export namespace Config {
await BunProc.run(["install"], { cwd: dir }).catch(() => {})
}
const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
async function loadCommand(dir: string) {
const result: Record<string, Command> = {}
for await (const item of COMMAND_GLOB.scan({
@@ -235,7 +216,7 @@ export namespace Config {
return result
}
const AGENT_GLOB = new Bun.Glob("agent/**/*.md")
const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md")
async function loadAgent(dir: string) {
const result: Record<string, Agent> = {}
@@ -278,7 +259,7 @@ export namespace Config {
return result
}
const MODE_GLOB = new Bun.Glob("mode/*.md")
const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md")
async function loadMode(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of MODE_GLOB.scan({
@@ -307,7 +288,7 @@ export namespace Config {
return result
}
const PLUGIN_GLOB = new Bun.Glob("plugin/*.{ts,js}")
const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}")
async function loadPlugin(dir: string) {
const plugins: string[] = []
@@ -605,6 +586,7 @@ export namespace Config {
port: z.number().int().positive().optional().describe("Port to listen on"),
hostname: z.string().optional().describe("Hostname to listen on"),
mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
})
.strict()
.meta({
@@ -620,7 +602,24 @@ export namespace Config {
.extend({
whitelist: z.array(z.string()).optional(),
blacklist: z.array(z.string()).optional(),
models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
models: z
.record(
z.string(),
ModelsDev.Model.partial().extend({
variants: z
.record(
z.string(),
z
.object({
disabled: z.boolean().optional().describe("Disable this variant for the model"),
})
.catchall(z.any()),
)
.optional()
.describe("Variant-specific configuration"),
}),
)
.optional(),
options: z
.object({
apiKey: z.string().optional(),
@@ -846,6 +845,12 @@ export namespace Config {
.optional()
.describe("Tools that should only be available to primary agents."),
continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
mcp_timeout: z
.number()
.int()
.positive()
.optional()
.describe("Timeout in milliseconds for model context protocol (MCP) requests"),
})
.optional(),
})
@@ -331,3 +331,12 @@ export const nixfmt: Info = {
return Bun.which("nixfmt") !== null
},
}
export const rustfmt: Info = {
name: "rustfmt",
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
return Bun.which("rustfmt") !== null
},
}
+23
View File
@@ -98,6 +98,9 @@ export namespace LSPClient {
},
workspace: {
configuration: true,
didChangeWatchedFiles: {
dynamicRegistration: true,
},
},
textDocument: {
synchronization: {
@@ -151,6 +154,16 @@ export namespace LSPClient {
const version = files[input.path]
if (version !== undefined) {
log.info("workspace/didChangeWatchedFiles", input)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
type: 2, // Changed
},
],
})
const next = version + 1
files[input.path] = next
log.info("textDocument/didChange", {
@@ -167,6 +180,16 @@ export namespace LSPClient {
return
}
log.info("workspace/didChangeWatchedFiles", input)
await connection.sendNotification("workspace/didChangeWatchedFiles", {
changes: [
{
uri: pathToFileURL(input.path).href,
type: 1, // Created
},
],
})
log.info("textDocument/didOpen", input)
diagnostics.delete(input.path)
await connection.sendNotification("textDocument/didOpen", {
+18
View File
@@ -1437,6 +1437,24 @@ export namespace LSPServer {
},
}
export const Prisma: Info = {
id: "prisma",
extensions: [".prisma"],
root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
async spawn(root) {
const prisma = Bun.which("prisma")
if (!prisma) {
log.info("prisma not found, please install prisma")
return
}
return {
process: spawn(prisma, ["language-server"], {
cwd: root,
}),
}
},
}
export const Dart: Info = {
id: "dart",
extensions: [".dart"],
+94 -7
View File
@@ -4,7 +4,11 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
import { type Tool as MCPToolDef, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
import {
CallToolResultSchema,
type Tool as MCPToolDef,
ToolListChangedNotificationSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { Config } from "../config/config"
import { Log } from "../util/log"
import { NamedError } from "@opencode-ai/util/error"
@@ -93,7 +97,7 @@ export namespace MCP {
}
// Convert MCP tool definition to AI SDK Tool type
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Tool {
async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Promise<Tool> {
const inputSchema = mcpTool.inputSchema
// Spread first, then override type to ensure it's always "object"
@@ -103,15 +107,23 @@ export namespace MCP {
properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
additionalProperties: false,
}
const config = await Config.get()
return dynamicTool({
description: mcpTool.description ?? "",
inputSchema: jsonSchema(schema),
execute: async (args: unknown) => {
return client.callTool({
name: mcpTool.name,
arguments: args as Record<string, unknown>,
})
return client.callTool(
{
name: mcpTool.name,
arguments: args as Record<string, unknown>,
},
CallToolResultSchema,
{
resetTimeoutOnProgress: true,
timeout: config.experimental?.mcp_timeout,
},
)
},
})
}
@@ -120,6 +132,9 @@ export namespace MCP {
type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
const pendingOAuthTransports = new Map<string, TransportWithAuth>()
// Prompt cache types
type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
const state = Instance.state(
async () => {
const cfg = await Config.get()
@@ -164,6 +179,29 @@ export namespace MCP {
},
)
// Helper function to fetch prompts for a specific client
async function fetchPromptsForClient(clientName: string, client: Client) {
const prompts = await client.listPrompts().catch((e) => {
log.error("failed to get prompts", { clientName, error: e.message })
return undefined
})
if (!prompts) {
return
}
const commands: Record<string, PromptInfo & { client: string }> = {}
for (const prompt of prompts.prompts) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_")
const key = sanitizedClientName + ":" + sanitizedPromptName
commands[key] = { ...prompt, client: clientName }
}
return commands
}
export async function add(name: string, mcp: Config.Mcp) {
const s = await state()
const result = await create(name, mcp)
@@ -474,12 +512,61 @@ export namespace MCP {
for (const mcpTool of toolsResult.tools) {
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client)
result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client)
}
}
return result
}
export async function prompts() {
const s = await state()
const clientsSnapshot = await clients()
const prompts = Object.fromEntries<PromptInfo & { client: string }>(
(
await Promise.all(
Object.entries(clientsSnapshot).map(async ([clientName, client]) => {
if (s.status[clientName]?.status !== "connected") {
return []
}
return Object.entries((await fetchPromptsForClient(clientName, client)) ?? {})
}),
)
).flat(),
)
return prompts
}
export async function getPrompt(clientName: string, name: string, args?: Record<string, string>) {
const clientsSnapshot = await clients()
const client = clientsSnapshot[clientName]
if (!client) {
log.warn("client not found for prompt", {
clientName,
})
return undefined
}
const result = await client
.getPrompt({
name: name,
arguments: args,
})
.catch((e) => {
log.error("failed to get prompt from MCP server", {
clientName,
promptName: name,
error: e.message,
})
return undefined
})
return result
}
/**
* Start OAuth authentication flow for an MCP server.
* Returns the authorization URL that should be opened in a browser.
+1
View File
@@ -60,6 +60,7 @@ export namespace ModelsDev {
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()).optional(),
provider: z.object({ npm: z.string() }).optional(),
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
})
export type Model = z.infer<typeof Model>
+28 -15
View File
@@ -1,7 +1,7 @@
import z from "zod"
import fuzzysort from "fuzzysort"
import { Config } from "../config/config"
import { mapValues, mergeDeep, sortBy } from "remeda"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
@@ -34,6 +34,7 @@ import { createCohere } from "@ai-sdk/cohere"
import { createGateway } from "@ai-sdk/gateway"
import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel"
import { ProviderTransform } from "./transform"
export namespace Provider {
@@ -58,6 +59,7 @@ export namespace Provider {
"@ai-sdk/gateway": createGateway,
"@ai-sdk/togetherai": createTogetherAI,
"@ai-sdk/perplexity": createPerplexity,
"@ai-sdk/vercel": createVercel,
// @ts-ignore (TODO: kill this code so we dont have to maintain it)
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
}
@@ -405,16 +407,6 @@ export namespace Provider {
},
}
export const Variant = z
.object({
disabled: z.boolean(),
})
.catchall(z.any())
.meta({
ref: "Variant",
})
export type Variant = z.infer<typeof Variant>
export const Model = z
.object({
id: z.string(),
@@ -478,7 +470,7 @@ export namespace Provider {
options: z.record(z.string(), z.any()),
headers: z.record(z.string(), z.string()),
release_date: z.string(),
variants: z.record(z.string(), Variant).optional(),
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
})
.meta({
ref: "Model",
@@ -509,7 +501,7 @@ export namespace Provider {
api: {
id: model.id,
url: provider.api!,
npm: model.provider?.npm ?? provider.npm ?? provider.id,
npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
},
status: model.status ?? "active",
headers: model.headers ?? {},
@@ -561,7 +553,7 @@ export namespace Provider {
variants: {},
}
m.variants = mapValues(ProviderTransform.variants(m), (v) => ({ disabled: false, ...v }))
m.variants = mapValues(ProviderTransform.variants(m), (v) => v)
return m
}
@@ -654,7 +646,11 @@ export namespace Provider {
api: {
id: model.id ?? existingModel?.api.id ?? modelID,
npm:
model.provider?.npm ?? provider.npm ?? existingModel?.api.npm ?? modelsDev[providerID]?.npm ?? providerID,
model.provider?.npm ??
provider.npm ??
existingModel?.api.npm ??
modelsDev[providerID]?.npm ??
"@ai-sdk/openai-compatible",
url: provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
},
status: model.status ?? existingModel?.status ?? "active",
@@ -697,7 +693,13 @@ export namespace Provider {
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
family: model.family ?? existingModel?.family ?? "",
release_date: model.release_date ?? existingModel?.release_date ?? "",
variants: {},
}
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
parsedModel.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
)
parsed.models[modelID] = parsedModel
}
database[providerID] = parsed
@@ -822,6 +824,16 @@ export namespace Provider {
(configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
)
delete provider.models[modelID]
// Filter out disabled variants from config
const configVariants = configProvider?.models?.[modelID]?.variants
if (configVariants && model.variants) {
const merged = mergeDeep(model.variants, configVariants)
model.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
)
}
}
if (Object.keys(provider.models).length === 0) {
@@ -1010,6 +1022,7 @@ export namespace Provider {
"claude-haiku-4.5",
"3-5-haiku",
"3.5-haiku",
"gemini-3-flash",
"gemini-2.5-flash",
"gpt-5-nano",
]
+17 -10
View File
@@ -3,6 +3,7 @@ import { unique } from "remeda"
import type { JSONSchema } from "zod/v4/core"
import type { Provider } from "./provider"
import type { ModelsDev } from "./models"
import { iife } from "@/util/iife"
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
@@ -229,7 +230,6 @@ export namespace ProviderTransform {
const id = model.id.toLowerCase()
if (id.includes("qwen")) return 1
if (id.includes("minimax-m2")) {
if (id.includes("m2.1")) return 0.9
return 0.95
}
if (id.includes("gemini")) return 0.95
@@ -238,7 +238,10 @@ export namespace ProviderTransform {
export function topK(model: Provider.Model) {
const id = model.id.toLowerCase()
if (id.includes("minimax-m2")) return 20
if (id.includes("minimax-m2")) {
if (id.includes("m2.1")) return 40
return 20
}
if (id.includes("gemini")) return 64
return undefined
}
@@ -246,7 +249,7 @@ export namespace ProviderTransform {
const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
export function variants(model: Provider.Model) {
export function variants(model: Provider.Model): Record<string, Record<string, any>> {
if (!model.capabilities.reasoning) return {}
const id = model.id.toLowerCase()
@@ -292,13 +295,17 @@ export namespace ProviderTransform {
case "@ai-sdk/openai":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
if (id === "gpt-5-pro") return {}
const openaiEfforts = ["minimal", ...WIDELY_SUPPORTED_EFFORTS]
if (model.release_date >= "2025-11-13") {
openaiEfforts.unshift("none")
}
if (model.release_date >= "2025-12-04") {
openaiEfforts.push("xhigh")
}
const openaiEfforts = iife(() => {
if (model.id.includes("codex")) return WIDELY_SUPPORTED_EFFORTS
const arr = ["minimal", ...WIDELY_SUPPORTED_EFFORTS]
if (model.release_date >= "2025-11-13") {
arr.unshift("none")
}
if (model.release_date >= "2025-12-04") {
arr.push("xhigh")
}
return arr
})
return Object.fromEntries(
openaiEfforts.map((effort) => [
effort,
+1 -20
View File
@@ -7,32 +7,12 @@ import { Log } from "../util/log"
import type { WSContext } from "hono/ws"
import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy"
import {} from "process"
import { Installation } from "@/installation"
import { Shell } from "@/shell/shell"
export namespace Pty {
const log = Log.create({ service: "pty" })
const pty = lazy(async () => {
if (!Installation.isLocal()) {
const path = require(
`bun-pty/rust-pty/target/release/${
process.platform === "win32"
? "rust_pty.dll"
: process.platform === "linux" && process.arch === "x64"
? "librust_pty.so"
: process.platform === "darwin" && process.arch === "x64"
? "librust_pty.dylib"
: process.platform === "darwin" && process.arch === "arm64"
? "librust_pty_arm64.dylib"
: process.platform === "linux" && process.arch === "arm64"
? "librust_pty_arm64.so"
: ""
}`,
)
process.env.BUN_PTY_LIB = path
}
const { spawn } = await import("bun-pty")
return spawn
})
@@ -128,6 +108,7 @@ export namespace Pty {
cwd,
env,
})
const info = {
id,
title: input.title || `Terminal ${id.slice(-4)}`,
+26 -4
View File
@@ -45,7 +45,6 @@ import { Snapshot } from "@/snapshot"
import { SessionSummary } from "@/session/summary"
import { SessionStatus } from "@/session/status"
import { upgradeWebSocket, websocket } from "hono/bun"
import type { BunWebSocketData } from "hono/bun"
import { errors } from "./error"
import { Pty } from "@/pty"
import { Installation } from "@/installation"
@@ -58,6 +57,7 @@ export namespace Server {
const log = Log.create({ service: "server" })
let _url: URL | undefined
let _corsWhitelist: string[] = []
export function url(): URL {
return _url ?? new URL("http://localhost:4096")
@@ -104,7 +104,27 @@ export namespace Server {
timer.stop()
}
})
.use(cors())
.use(
cors({
origin(input) {
if (!input) return
if (input.startsWith("http://localhost:")) return input
if (input.startsWith("http://127.0.0.1:")) return input
if (input === "tauri://localhost" || input === "http://tauri.localhost") return input
// *.opencode.ai (https only, adjust if needed)
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
return input
}
if (_corsWhitelist.includes(input)) {
return input
}
return
},
}),
)
.get(
"/global/health",
describeRoute({
@@ -2660,7 +2680,9 @@ export namespace Server {
return result
}
export function listen(opts: { port: number; hostname: string; mdns?: boolean }) {
export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
_corsWhitelist = opts.cors ?? []
const args = {
hostname: opts.hostname,
idleTimeout: 0,
@@ -2686,7 +2708,7 @@ export namespace Server {
opts.hostname !== "localhost" &&
opts.hostname !== "::1"
if (shouldPublishMDNS) {
MDNS.publish(server.port!)
MDNS.publish(server.port!, `opencode-${server.port!}`)
} else if (opts.mdns) {
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
}
+14 -4
View File
@@ -1,6 +1,14 @@
import { Provider } from "@/provider/provider"
import { Log } from "@/util/log"
import { streamText, wrapLanguageModel, type ModelMessage, type StreamTextResult, type Tool, type ToolSet } from "ai"
import {
streamText,
wrapLanguageModel,
type ModelMessage,
type StreamTextResult,
type Tool,
type ToolSet,
extractReasoningMiddleware,
} from "ai"
import { clone, mergeDeep, pipe } from "remeda"
import { ProviderTransform } from "@/provider/transform"
import { Config } from "@/config/config"
@@ -74,13 +82,14 @@ export namespace LLM {
}
const provider = await Provider.getProvider(input.model.providerID)
const variant = input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : undefined
const small = input.small ? ProviderTransform.smallOptions(input.model) : {}
const variant = input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
const options = pipe(
ProviderTransform.options(input.model, input.sessionID, provider.options),
mergeDeep(input.small ? ProviderTransform.smallOptions(input.model) : {}),
mergeDeep(small),
mergeDeep(input.model.options),
mergeDeep(input.agent.options),
mergeDeep(variant && !variant.disabled ? variant : {}),
mergeDeep(variant),
)
const params = await Plugin.trigger(
@@ -183,6 +192,7 @@ export namespace LLM {
return args.params
},
},
extractReasoningMiddleware({ tagName: "think", startWithReasoning: false }),
],
}),
experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry },
+4 -2
View File
@@ -1290,7 +1290,9 @@ export namespace SessionPrompt {
const raw = input.arguments.match(argsRegex) ?? []
const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
const placeholders = command.template.match(placeholderRegex) ?? []
const templateCommand = await command.template
const placeholders = templateCommand.match(placeholderRegex) ?? []
let last = 0
for (const item of placeholders) {
const value = Number(item.slice(1))
@@ -1298,7 +1300,7 @@ export namespace SessionPrompt {
}
// Let the final placeholder swallow any extra arguments so prompts read naturally
const withArgs = command.template.replaceAll(placeholderRegex, (_, index) => {
const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
const position = Number(index)
const argIndex = position - 1
if (argIndex >= args.length) return ""
+2 -1
View File
@@ -5,10 +5,11 @@ export namespace SessionRetry {
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout
export async function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(resolve, ms)
const timeout = setTimeout(resolve, Math.min(ms, RETRY_MAX_DELAY))
signal.addEventListener(
"abort",
() => {
+1 -1
View File
@@ -35,7 +35,7 @@ export namespace Skill {
}),
)
const OPENCODE_SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
export const state = Instance.state(async () => {
@@ -1807,3 +1807,321 @@ test("custom model inherits api.url from models.dev provider", async () => {
},
})
})
test("model variants are generated for reasoning models", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await Provider.list()
// Claude sonnet 4 has reasoning capability
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.capabilities.reasoning).toBe(true)
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
},
})
})
test("model variants can be disabled via config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
models: {
"claude-sonnet-4-20250514": {
variants: {
high: { disabled: true },
},
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await Provider.list()
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
// max variant should still exist
expect(model.variants!["max"]).toBeDefined()
},
})
})
test("model variants can be customized via config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
models: {
"claude-sonnet-4-20250514": {
variants: {
high: {
thinking: {
type: "enabled",
budgetTokens: 20000,
},
},
},
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await Provider.list()
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
},
})
})
test("disabled key is stripped from variant config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
models: {
"claude-sonnet-4-20250514": {
variants: {
max: {
disabled: false,
customField: "test",
},
},
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await Provider.list()
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants!["max"]).toBeDefined()
expect(model.variants!["max"].disabled).toBeUndefined()
expect(model.variants!["max"].customField).toBe("test")
},
})
})
test("all variants can be disabled via config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
models: {
"claude-sonnet-4-20250514": {
variants: {
high: { disabled: true },
max: { disabled: true },
},
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await Provider.list()
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBe(0)
},
})
})
test("variant config merges with generated variants", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
anthropic: {
models: {
"claude-sonnet-4-20250514": {
variants: {
high: {
extraOption: "custom-value",
},
},
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("ANTHROPIC_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await Provider.list()
const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
// Should have both the generated thinking config and the custom option
expect(model.variants!["high"].thinking).toBeDefined()
expect(model.variants!["high"].extraOption).toBe("custom-value")
},
})
})
test("variants filtered in second pass for database models", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
openai: {
models: {
"gpt-5": {
variants: {
high: { disabled: true },
},
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("OPENAI_API_KEY", "test-api-key")
},
fn: async () => {
const providers = await Provider.list()
const model = providers["openai"].models["gpt-5"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
// Other variants should still exist
expect(model.variants!["medium"]).toBeDefined()
},
})
})
test("custom model with variants enabled and disabled", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify({
$schema: "https://opencode.ai/config.json",
provider: {
"custom-reasoning": {
name: "Custom Reasoning Provider",
npm: "@ai-sdk/openai-compatible",
env: [],
models: {
"reasoning-model": {
name: "Reasoning Model",
tool_call: true,
reasoning: true,
limit: { context: 128000, output: 16000 },
variants: {
low: { reasoningEffort: "low" },
medium: { reasoningEffort: "medium" },
high: { reasoningEffort: "high", disabled: true },
custom: { reasoningEffort: "custom", budgetTokens: 5000 },
},
},
},
options: { apiKey: "test-key" },
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
const model = providers["custom-reasoning"].models["reasoning-model"]
expect(model.variants).toBeDefined()
// Enabled variants should exist
expect(model.variants!["low"]).toBeDefined()
expect(model.variants!["low"].reasoningEffort).toBe("low")
expect(model.variants!["medium"]).toBeDefined()
expect(model.variants!["medium"].reasoningEffort).toBe("medium")
expect(model.variants!["custom"]).toBeDefined()
expect(model.variants!["custom"].reasoningEffort).toBe("custom")
expect(model.variants!["custom"].budgetTokens).toBe(5000)
// Disabled variant should not exist
expect(model.variants!["high"]).toBeUndefined()
// disabled key should be stripped from all variants
expect(model.variants!["low"].disabled).toBeUndefined()
expect(model.variants!["medium"].disabled).toBeUndefined()
expect(model.variants!["custom"].disabled).toBeUndefined()
},
})
})
@@ -409,3 +409,572 @@ describe("ProviderTransform.message - empty image handling", () => {
})
})
})
describe("ProviderTransform.variants", () => {
const createMockModel = (overrides: Partial<any> = {}): any => ({
id: "test/test-model",
providerID: "test",
api: {
id: "test-model",
url: "https://api.test.com",
npm: "@ai-sdk/openai",
},
name: "Test Model",
capabilities: {
temperature: true,
reasoning: true,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: false },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: {
input: 0.001,
output: 0.002,
cache: { read: 0.0001, write: 0.0002 },
},
limit: {
context: 128000,
output: 8192,
},
status: "active",
options: {},
headers: {},
release_date: "2024-01-01",
...overrides,
})
test("returns empty object when model has no reasoning capabilities", () => {
const model = createMockModel({
capabilities: { reasoning: false },
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
test("deepseek returns empty object", () => {
const model = createMockModel({
id: "deepseek/deepseek-chat",
providerID: "deepseek",
api: {
id: "deepseek-chat",
url: "https://api.deepseek.com",
npm: "@ai-sdk/openai-compatible",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
test("minimax returns empty object", () => {
const model = createMockModel({
id: "minimax/minimax-model",
providerID: "minimax",
api: {
id: "minimax-model",
url: "https://api.minimax.com",
npm: "@ai-sdk/openai-compatible",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
test("glm returns empty object", () => {
const model = createMockModel({
id: "glm/glm-4",
providerID: "glm",
api: {
id: "glm-4",
url: "https://api.glm.com",
npm: "@ai-sdk/openai-compatible",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
test("mistral returns empty object", () => {
const model = createMockModel({
id: "mistral/mistral-large",
providerID: "mistral",
api: {
id: "mistral-large-latest",
url: "https://api.mistral.com",
npm: "@ai-sdk/mistral",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
describe("@openrouter/ai-sdk-provider", () => {
test("returns empty object for non-qualifying models", () => {
const model = createMockModel({
id: "openrouter/test-model",
providerID: "openrouter",
api: {
id: "test-model",
url: "https://openrouter.ai",
npm: "@openrouter/ai-sdk-provider",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
test("gpt models return OPENAI_EFFORTS with reasoning", () => {
const model = createMockModel({
id: "openrouter/gpt-4",
providerID: "openrouter",
api: {
id: "gpt-4",
url: "https://openrouter.ai",
npm: "@openrouter/ai-sdk-provider",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
expect(result.low).toEqual({ reasoning: { effort: "low" } })
expect(result.high).toEqual({ reasoning: { effort: "high" } })
})
test("gemini-3 returns OPENAI_EFFORTS with reasoning", () => {
const model = createMockModel({
id: "openrouter/gemini-3-5-pro",
providerID: "openrouter",
api: {
id: "gemini-3-5-pro",
url: "https://openrouter.ai",
npm: "@openrouter/ai-sdk-provider",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
})
test("grok-4 returns OPENAI_EFFORTS with reasoning", () => {
const model = createMockModel({
id: "openrouter/grok-4",
providerID: "openrouter",
api: {
id: "grok-4",
url: "https://openrouter.ai",
npm: "@openrouter/ai-sdk-provider",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
})
})
describe("@ai-sdk/gateway", () => {
test("returns OPENAI_EFFORTS with reasoningEffort", () => {
const model = createMockModel({
id: "gateway/gateway-model",
providerID: "gateway",
api: {
id: "gateway-model",
url: "https://gateway.ai",
npm: "@ai-sdk/gateway",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" })
})
})
describe("@ai-sdk/cerebras", () => {
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
const model = createMockModel({
id: "cerebras/llama-4",
providerID: "cerebras",
api: {
id: "llama-4-sc",
url: "https://api.cerebras.ai",
npm: "@ai-sdk/cerebras",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" })
})
})
describe("@ai-sdk/togetherai", () => {
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
const model = createMockModel({
id: "togetherai/llama-4",
providerID: "togetherai",
api: {
id: "llama-4-sc",
url: "https://api.togetherai.com",
npm: "@ai-sdk/togetherai",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" })
})
})
describe("@ai-sdk/xai", () => {
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
const model = createMockModel({
id: "xai/grok-3",
providerID: "xai",
api: {
id: "grok-3",
url: "https://api.x.ai",
npm: "@ai-sdk/xai",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" })
})
})
describe("@ai-sdk/deepinfra", () => {
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
const model = createMockModel({
id: "deepinfra/llama-4",
providerID: "deepinfra",
api: {
id: "llama-4-sc",
url: "https://api.deepinfra.com",
npm: "@ai-sdk/deepinfra",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" })
})
})
describe("@ai-sdk/openai-compatible", () => {
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningEffort", () => {
const model = createMockModel({
id: "custom-provider/custom-model",
providerID: "custom-provider",
api: {
id: "custom-model",
url: "https://api.custom.com",
npm: "@ai-sdk/openai-compatible",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" })
})
})
describe("@ai-sdk/azure", () => {
test("o1-mini returns empty object", () => {
const model = createMockModel({
id: "o1-mini",
providerID: "azure",
api: {
id: "o1-mini",
url: "https://azure.com",
npm: "@ai-sdk/azure",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
test("standard azure models return custom efforts with reasoningSummary", () => {
const model = createMockModel({
id: "azure/gpt-4o",
providerID: "azure",
api: {
id: "gpt-4o",
url: "https://azure.com",
npm: "@ai-sdk/azure",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
expect(result.low).toEqual({
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
})
})
test("gpt-5 adds minimal effort", () => {
const model = createMockModel({
id: "azure/gpt-5",
providerID: "azure",
api: {
id: "gpt-5",
url: "https://azure.com",
npm: "@ai-sdk/azure",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"])
})
})
describe("@ai-sdk/openai", () => {
test("gpt-5-pro returns empty object", () => {
const model = createMockModel({
id: "gpt-5-pro",
providerID: "openai",
api: {
id: "gpt-5-pro",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
test("standard openai models return custom efforts with reasoningSummary", () => {
const model = createMockModel({
id: "openai/gpt-4o",
providerID: "openai",
api: {
id: "gpt-4o",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
release_date: "2024-06-01",
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["minimal", "low", "medium", "high"])
expect(result.low).toEqual({
reasoningEffort: "low",
reasoningSummary: "auto",
include: ["reasoning.encrypted_content"],
})
})
test("models after 2025-11-13 include 'none' effort", () => {
const model = createMockModel({
id: "openai/gpt-4.5",
providerID: "openai",
api: {
id: "gpt-4.5",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
release_date: "2025-11-14",
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high"])
})
test("models after 2025-12-04 include 'xhigh' effort", () => {
const model = createMockModel({
id: "openai/gpt-5-chat",
providerID: "openai",
api: {
id: "gpt-5-chat",
url: "https://api.openai.com",
npm: "@ai-sdk/openai",
},
release_date: "2025-12-05",
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "minimal", "low", "medium", "high", "xhigh"])
})
})
describe("@ai-sdk/anthropic", () => {
test("returns high and max with thinking config", () => {
const model = createMockModel({
id: "anthropic/claude-4",
providerID: "anthropic",
api: {
id: "claude-4",
url: "https://api.anthropic.com",
npm: "@ai-sdk/anthropic",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["high", "max"])
expect(result.high).toEqual({
thinking: {
type: "enabled",
budgetTokens: 16000,
},
})
expect(result.max).toEqual({
thinking: {
type: "enabled",
budgetTokens: 31999,
},
})
})
})
describe("@ai-sdk/amazon-bedrock", () => {
test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => {
const model = createMockModel({
id: "bedrock/llama-4",
providerID: "bedrock",
api: {
id: "llama-4-sc",
url: "https://bedrock.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "medium", "high"])
expect(result.low).toEqual({
reasoningConfig: {
type: "enabled",
maxReasoningEffort: "low",
},
})
})
})
describe("@ai-sdk/google", () => {
test("gemini-2.5 returns high and max with thinkingConfig and thinkingBudget", () => {
const model = createMockModel({
id: "google/gemini-2.5-pro",
providerID: "google",
api: {
id: "gemini-2.5-pro",
url: "https://generativelanguage.googleapis.com",
npm: "@ai-sdk/google",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["high", "max"])
expect(result.high).toEqual({
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 16000,
},
})
expect(result.max).toEqual({
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 24576,
},
})
})
test("other gemini models return low and high with thinkingLevel", () => {
const model = createMockModel({
id: "google/gemini-2.0-pro",
providerID: "google",
api: {
id: "gemini-2.0-pro",
url: "https://generativelanguage.googleapis.com",
npm: "@ai-sdk/google",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "high"])
expect(result.low).toEqual({
includeThoughts: true,
thinkingLevel: "low",
})
expect(result.high).toEqual({
includeThoughts: true,
thinkingLevel: "high",
})
})
})
describe("@ai-sdk/google-vertex", () => {
test("gemini-2.5 returns high and max with thinkingConfig and thinkingBudget", () => {
const model = createMockModel({
id: "google-vertex/gemini-2.5-pro",
providerID: "google-vertex",
api: {
id: "gemini-2.5-pro",
url: "https://vertexai.googleapis.com",
npm: "@ai-sdk/google-vertex",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["high", "max"])
})
test("other vertex models return low and high with thinkingLevel", () => {
const model = createMockModel({
id: "google-vertex/gemini-2.0-pro",
providerID: "google-vertex",
api: {
id: "gemini-2.0-pro",
url: "https://vertexai.googleapis.com",
npm: "@ai-sdk/google-vertex",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["low", "high"])
})
})
describe("@ai-sdk/cohere", () => {
test("returns empty object", () => {
const model = createMockModel({
id: "cohere/command-r",
providerID: "cohere",
api: {
id: "command-r",
url: "https://api.cohere.com",
npm: "@ai-sdk/cohere",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
})
describe("@ai-sdk/groq", () => {
test("returns none and WIDELY_SUPPORTED_EFFORTS with thinkingLevel", () => {
const model = createMockModel({
id: "groq/llama-4",
providerID: "groq",
api: {
id: "llama-4-sc",
url: "https://api.groq.com",
npm: "@ai-sdk/groq",
},
})
const result = ProviderTransform.variants(model)
expect(Object.keys(result)).toEqual(["none", "low", "medium", "high"])
expect(result.none).toEqual({
includeThoughts: true,
thinkingLevel: "none",
})
expect(result.low).toEqual({
includeThoughts: true,
thinkingLevel: "low",
})
})
})
describe("@ai-sdk/perplexity", () => {
test("returns empty object", () => {
const model = createMockModel({
id: "perplexity/sonar-plus",
providerID: "perplexity",
api: {
id: "sonar-plus",
url: "https://api.perplexity.ai",
npm: "@ai-sdk/perplexity",
},
})
const result = ProviderTransform.variants(model)
expect(result).toEqual({})
})
})
})
@@ -58,6 +58,26 @@ describe("session.retry.delay", () => {
const longError = apiError({ "retry-after-ms": "700000" })
expect(SessionRetry.delay(1, longError)).toBe(700000)
})
test("sleep caps delay to max 32-bit signed integer to avoid TimeoutOverflowWarning", async () => {
const controller = new AbortController()
const warnings: string[] = []
const originalWarn = process.emitWarning
process.emitWarning = (warning: string | Error) => {
warnings.push(typeof warning === "string" ? warning : warning.message)
}
const promise = SessionRetry.sleep(2_560_914_000, controller.signal)
controller.abort()
try {
await promise
} catch {}
process.emitWarning = originalWarn
expect(warnings.some((w) => w.includes("TimeoutOverflowWarning"))).toBe(false)
})
})
describe("session.message-v2.fromError", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.0.214",
"version": "1.0.222",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.0.214",
"version": "1.0.222",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",
+30 -6
View File
@@ -1177,6 +1177,10 @@ export type ServerConfig = {
* Enable mDNS service discovery
*/
mdns?: boolean
/**
* Additional domains to allow for CORS
*/
cors?: Array<string>
}
export type AgentConfig = {
@@ -1309,6 +1313,18 @@ export type ProviderConfig = {
provider?: {
npm: string
}
/**
* Variant-specific configuration
*/
variants?: {
[key: string]: {
/**
* Disable this variant for the model
*/
disabled?: boolean
[key: string]: unknown | boolean | undefined
}
}
}
}
whitelist?: Array<string>
@@ -1639,6 +1655,10 @@ export type Config = {
* Continue the agent loop when a tool call is denied
*/
continue_loop_on_deny?: boolean
/**
* Timeout in milliseconds for model context protocol (MCP) requests
*/
mcp_timeout?: number
}
}
@@ -1713,13 +1733,10 @@ export type Command = {
description?: string
agent?: string
model?: string
mcp?: boolean
template: string
subtask?: boolean
}
export type Variant = {
disabled: boolean
[key: string]: unknown | boolean
hints: Array<string>
}
export type Model = {
@@ -1786,7 +1803,9 @@ export type Model = {
}
release_date: string
variants?: {
[key: string]: Variant
[key: string]: {
[key: string]: unknown
}
}
}
@@ -3497,6 +3516,11 @@ export type ProviderListResponses = {
provider?: {
npm: string
}
variants?: {
[key: string]: {
[key: string]: unknown
}
}
}
}
}>
+66 -13
View File
@@ -3209,6 +3209,19 @@
}
},
"required": ["npm"]
},
"variants": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
}
},
"required": [
@@ -7780,6 +7793,13 @@
"mdns": {
"description": "Enable mDNS service discovery",
"type": "boolean"
},
"cors": {
"description": "Additional domains to allow for CORS",
"type": "array",
"items": {
"type": "string"
}
}
},
"additionalProperties": false
@@ -8061,6 +8081,23 @@
}
},
"required": ["npm"]
},
"variants": {
"description": "Variant-specific configuration",
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"properties": {
"disabled": {
"description": "Disable this variant for the model",
"type": "boolean"
}
},
"additionalProperties": {}
}
}
}
}
@@ -8737,6 +8774,12 @@
"continue_loop_on_deny": {
"description": "Continue the agent loop when a tool call is denied",
"type": "boolean"
},
"mcp_timeout": {
"description": "Timeout in milliseconds for model context protocol (MCP) requests",
"type": "integer",
"exclusiveMinimum": 0,
"maximum": 9007199254740991
}
}
}
@@ -8939,24 +8982,30 @@
"model": {
"type": "string"
},
"mcp": {
"type": "boolean"
},
"template": {
"type": "string"
"anyOf": [
{
"type": "string"
},
{
"type": "string"
}
]
},
"subtask": {
"type": "boolean"
},
"hints": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["name", "template"]
},
"Variant": {
"type": "object",
"properties": {
"disabled": {
"type": "boolean"
}
},
"required": ["disabled"],
"additionalProperties": {}
"required": ["name", "template", "hints"]
},
"Model": {
"type": "object",
@@ -9154,7 +9203,11 @@
"type": "string"
},
"additionalProperties": {
"$ref": "#/components/schemas/Variant"
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
}
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.0.214",
"version": "1.0.222",
"type": "module",
"scripts": {
"dev": "bun run src/index.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.214",
"version": "1.0.222",
"type": "module",
"exports": {
"./*": "./src/components/*.tsx",
+12 -2
View File
@@ -36,12 +36,12 @@
display: flex;
align-items: center;
gap: 8px;
white-space: nowrap;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
[data-slot="basic-tool-tool-title"] {
flex-shrink: 0;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
@@ -56,6 +56,11 @@
}
[data-slot="basic-tool-tool-subtitle"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
@@ -66,6 +71,11 @@
}
[data-slot="basic-tool-tool-arg"] {
flex-shrink: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-style: normal;
+2 -2
View File
@@ -1,7 +1,7 @@
import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/diffs"
import { ComponentProps, createEffect, createMemo, splitProps } from "solid-js"
import { createDefaultOptions, styleVariables } from "../pierre"
import { workerPool } from "../pierre/worker"
import { getWorkerPool } from "../pierre/worker"
export type CodeProps<T = {}> = FileOptions<T> & {
file: FileContents
@@ -21,7 +21,7 @@ export function Code<T>(props: CodeProps<T>) {
...createDefaultOptions<T>("unified"),
...others,
},
workerPool,
getWorkerPool("unified"),
),
)
+1 -3
View File
@@ -38,14 +38,12 @@
}
[data-slot="collapsible-arrow"] {
flex-shrink: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
/* [data-slot="collapsible-arrow-icon"] { */
/* } */
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ export function Diff<T>(props: SSRDiffProps<T>) {
let container!: HTMLDivElement
let fileDiffRef!: HTMLElement
const [local, others] = splitProps(props, ["before", "after", "class", "classList", "annotations"])
const workerPool = useWorkerPool()
const workerPool = useWorkerPool(props.diffStyle)
let fileDiffInstance: FileDiff<T> | undefined
const cleanupFunctions: Array<() => void> = []
+7 -10
View File
@@ -1,7 +1,7 @@
import { FileDiff } from "@pierre/diffs"
import { createEffect, createMemo, onCleanup, splitProps } from "solid-js"
import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
import { workerPool } from "../pierre/worker"
import { getWorkerPool } from "../pierre/worker"
// interface ThreadMetadata {
// threadId: string
@@ -20,26 +20,23 @@ export function Diff<T>(props: DiffProps<T>) {
...createDefaultOptions(props.diffStyle),
...others,
},
workerPool,
getWorkerPool(props.diffStyle),
),
)
const cleanupFunctions: Array<() => void> = []
createEffect(() => {
const diff = fileDiff()
container.innerHTML = ""
fileDiff().render({
diff.render({
oldFile: local.before,
newFile: local.after,
lineAnnotations: local.annotations,
containerWrapper: container,
})
})
onCleanup(() => {
// Clean up FileDiff event handlers and dispose SolidJS components
fileDiff()?.cleanUp()
cleanupFunctions.forEach((dispose) => dispose())
onCleanup(() => {
diff.cleanUp()
})
})
return <div data-component="diff" style={styleVariables} ref={container} />
+2
View File
@@ -12,6 +12,7 @@ const icons = {
"chevron-down": `<path d="M6.6665 8.33325L9.99984 11.6666L13.3332 8.33325" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-right": `<path d="M8.33301 13.3327L11.6663 9.99935L8.33301 6.66602" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-grabber-vertical": `<path d="M6.66675 12.4998L10.0001 15.8332L13.3334 12.4998M6.66675 7.49984L10.0001 4.1665L13.3334 7.49984" stroke="currentColor" stroke-linecap="square"/>`,
"chevron-double-right": `<path d="M11.6654 13.3346L14.9987 10.0013L11.6654 6.66797M5.83203 13.3346L9.16536 10.0013L5.83203 6.66797" stroke="currentColor" stroke-linecap="square"/>`,
"circle-x": `<path fill-rule="evenodd" clip-rule="evenodd" d="M1.6665 10.0003C1.6665 5.39795 5.39746 1.66699 9.99984 1.66699C14.6022 1.66699 18.3332 5.39795 18.3332 10.0003C18.3332 14.6027 14.6022 18.3337 9.99984 18.3337C5.39746 18.3337 1.6665 14.6027 1.6665 10.0003ZM7.49984 6.91107L6.91058 7.50033L9.41058 10.0003L6.91058 12.5003L7.49984 13.0896L9.99984 10.5896L12.4998 13.0896L13.0891 12.5003L10.5891 10.0003L13.0891 7.50033L12.4998 6.91107L9.99984 9.41107L7.49984 6.91107Z" fill="currentColor"/>`,
close: `<path d="M3.75 3.75L16.25 16.25M16.25 3.75L3.75 16.25" stroke="currentColor" stroke-linecap="square"/>`,
checklist: `<path d="M9.58342 13.7498H17.0834M9.58342 6.24984H17.0834M2.91675 6.6665L4.58341 7.9165L7.08341 4.1665M2.91675 14.1665L4.58341 15.4165L7.08341 11.6665" stroke="currentColor" stroke-linecap="square"/>`,
@@ -57,6 +58,7 @@ const icons = {
share: `<path d="M10.0013 12.0846L10.0013 3.33464M13.7513 6.66797L10.0013 2.91797L6.2513 6.66797M17.0846 10.418V17.0846H2.91797V10.418" stroke="currentColor" stroke-linecap="square"/>`,
download: `<path d="M13.9583 10.6257L10 14.584L6.04167 10.6257M10 2.08398V13.959M16.25 17.9173H3.75" stroke="currentColor" stroke-linecap="square"/>`,
menu: `<path d="M2.5 5H17.5M2.5 10H17.5M2.5 15H17.5" stroke="currentColor" stroke-linecap="square"/>`,
server: `<rect x="3.35547" y="1.92969" width="13.2857" height="16.1429" stroke="currentColor"/><rect x="3.35547" y="11.9297" width="13.2857" height="6.14286" stroke="currentColor"/><rect x="12.8555" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/><rect x="10" y="14.2852" width="1.42857" height="1.42857" fill="currentColor"/>`,
}
export interface IconProps extends ComponentProps<"svg"> {
+3 -11
View File
@@ -78,10 +78,9 @@
[data-slot="user-message-text"] {
white-space: pre-wrap;
overflow: hidden;
background: var(--surface-inset-base);
padding: 6px 12px;
background: var(--surface-base);
padding: 8px 12px;
border-radius: 4px;
border: 0.5px solid var(--border-weak-base);
}
.text-text-strong {
@@ -386,6 +385,7 @@
[data-component="write-content"],
[data-component="todos"],
[data-component="diagnostics"] {
-webkit-user-select: text;
user-select: text;
}
@@ -469,14 +469,6 @@
background-color: var(--surface-raised-strong);
border-radius: 0 0 6px 6px;
[data-slot="permission-message"] {
display: none;
font-family: var(--font-family-sans);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
}
[data-slot="permission-actions"] {
display: flex;
align-items: center;
+76 -65
View File
@@ -12,6 +12,7 @@ import {
} from "solid-js"
import { Dynamic } from "solid-js/web"
import {
AgentPart,
AssistantMessage,
FilePart,
Message as MessageType,
@@ -300,6 +301,8 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
}),
)
const agents = createMemo(() => (props.parts?.filter((p) => p.type === "agent") as AgentPart[]) ?? [])
const openImagePreview = (url: string, alt?: string) => {
dialog.show(() => <ImagePreview src={url} alt={alt} />)
}
@@ -337,33 +340,40 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
</Show>
<Show when={text()}>
<div data-slot="user-message-text">
<HighlightedText text={text()} references={inlineFiles()} />
<HighlightedText text={text()} references={inlineFiles()} agents={agents()} />
</div>
</Show>
</div>
)
}
function HighlightedText(props: { text: string; references: FilePart[] }) {
type HighlightSegment = { text: string; type?: "file" | "agent" }
function HighlightedText(props: { text: string; references: FilePart[]; agents: AgentPart[] }) {
const segments = createMemo(() => {
const text = props.text
const refs = [...props.references].sort((a, b) => (a.source?.text?.start ?? 0) - (b.source?.text?.start ?? 0))
const result: { text: string; highlight?: boolean }[] = []
const allRefs: { start: number; end: number; type: "file" | "agent" }[] = [
...props.references
.filter((r) => r.source?.text?.start !== undefined && r.source?.text?.end !== undefined)
.map((r) => ({ start: r.source!.text!.start, end: r.source!.text!.end, type: "file" as const })),
...props.agents
.filter((a) => a.source?.start !== undefined && a.source?.end !== undefined)
.map((a) => ({ start: a.source!.start, end: a.source!.end, type: "agent" as const })),
].sort((a, b) => a.start - b.start)
const result: HighlightSegment[] = []
let lastIndex = 0
for (const ref of refs) {
const start = ref.source?.text?.start
const end = ref.source?.text?.end
for (const ref of allRefs) {
if (ref.start < lastIndex) continue
if (start === undefined || end === undefined || start < lastIndex) continue
if (start > lastIndex) {
result.push({ text: text.slice(lastIndex, start) })
if (ref.start > lastIndex) {
result.push({ text: text.slice(lastIndex, ref.start) })
}
result.push({ text: text.slice(start, end), highlight: true })
lastIndex = end
result.push({ text: text.slice(ref.start, ref.end), type: ref.type })
lastIndex = ref.end
}
if (lastIndex < text.length) {
@@ -375,7 +385,16 @@ function HighlightedText(props: { text: string; references: FilePart[] }) {
return (
<For each={segments()}>
{(segment) => <span classList={{ "text-text-strong font-medium": segment.highlight }}>{segment.text}</span>}
{(segment) => (
<span
classList={{
"text-syntax-property": segment.type === "file",
"text-syntax-type": segment.type === "agent",
}}
>
{segment.text}
</span>
)}
</For>
)
}
@@ -520,22 +539,19 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
</Match>
</Switch>
<Show when={showPermission() && permission()}>
{(perm) => (
<div data-component="permission-prompt">
<div data-slot="permission-message">{perm().title}</div>
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
Deny
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
Allow always
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
Allow once
</Button>
</div>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
Deny
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
Allow always
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
Allow once
</Button>
</div>
)}
</div>
</Show>
</div>
)
@@ -639,7 +655,7 @@ ToolRegistry.register({
ToolRegistry.register({
name: "grep",
render(props) {
const args = []
const args: string[] = []
if (props.input.pattern) args.push("pattern=" + props.input.pattern)
if (props.input.include) args.push("include=" + props.input.include)
return (
@@ -768,40 +784,37 @@ ToolRegistry.register({
<div data-component="tool-part-wrapper" data-permission={!!childPermission()}>
<Switch>
<Match when={childPermission()}>
{(perm) => (
<>
<Show
when={childToolPart()}
fallback={
<BasicTool
icon="task"
defaultOpen={true}
trigger={{
title: `${props.input.subagent_type || props.tool} Agent`,
titleClass: "capitalize",
subtitle: props.input.description,
}}
/>
}
>
{renderChildToolPart()}
</Show>
<div data-component="permission-prompt">
<div data-slot="permission-message">{perm().title}</div>
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
Deny
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
Allow always
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
Allow once
</Button>
</div>
<>
<Show
when={childToolPart()}
fallback={
<BasicTool
icon="task"
defaultOpen={true}
trigger={{
title: `${props.input.subagent_type || props.tool} Agent`,
titleClass: "capitalize",
subtitle: props.input.description,
}}
/>
}
>
{renderChildToolPart()}
</Show>
<div data-component="permission-prompt">
<div data-slot="permission-actions">
<Button variant="ghost" size="small" onClick={() => respond("reject")}>
Deny
</Button>
<Button variant="secondary" size="small" onClick={() => respond("always")}>
Allow always
</Button>
<Button variant="primary" size="small" onClick={() => respond("once")}>
Allow once
</Button>
</div>
</>
)}
</div>
</>
</Match>
<Match when={true}>
<BasicTool
@@ -874,7 +887,6 @@ ToolRegistry.register({
return (
<BasicTool
{...props}
defaultOpen
icon="code-lines"
trigger={
<div data-component="edit-trigger">
@@ -926,7 +938,6 @@ ToolRegistry.register({
return (
<BasicTool
{...props}
defaultOpen
icon="code-lines"
trigger={
<div data-component="write-trigger">
+3 -6
View File
@@ -7,7 +7,7 @@
all: unset;
background-color: var(--surface-base);
border-radius: var(--radius-md);
box-shadow: inset 0 0 0 1px var(--border-weak-base);
box-shadow: var(--shadow-xs-border);
margin: 0;
padding: 0;
position: relative;
@@ -23,10 +23,7 @@
[data-slot="radio-group-indicator"] {
background: var(--button-secondary-base);
border-radius: var(--radius-md);
box-shadow:
var(--shadow-xs),
inset 0 0 0 var(--indicator-focus-width, 0px) var(--border-selected),
inset 0 0 0 1px var(--border-base);
box-shadow: var(--shadow-xs-border);
content: "";
opacity: var(--indicator-opacity, 1);
position: absolute;
@@ -115,7 +112,7 @@
/* Focus state */
[data-slot="radio-group-wrapper"]:has([data-slot="radio-group-item-input"]:focus-visible)
[data-slot="radio-group-indicator"] {
--indicator-focus-width: 2px;
box-shadow: var(--shadow-xs-border-focus);
}
/* Hide indicator when nothing is checked */
@@ -66,6 +66,7 @@
}
[data-slot="accordion-content"] {
-webkit-user-select: text;
user-select: text;
}
+17 -1
View File
@@ -1,5 +1,6 @@
import { Accordion } from "./accordion"
import { Button } from "./button"
import { RadioGroup } from "./radio-group"
import { DiffChanges } from "./diff-changes"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
@@ -13,8 +14,12 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { Dynamic } from "solid-js/web"
import { checksum } from "@opencode-ai/util/encode"
export type SessionReviewDiffStyle = "unified" | "split"
export interface SessionReviewProps {
split?: boolean
diffStyle?: SessionReviewDiffStyle
onDiffStyleChange?: (diffStyle: SessionReviewDiffStyle) => void
class?: string
classList?: Record<string, boolean | undefined>
classes?: { root?: string; header?: string; container?: string }
@@ -28,6 +33,8 @@ export const SessionReview = (props: SessionReviewProps) => {
open: props.diffs.length > 10 ? [] : props.diffs.map((d) => d.file),
})
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
const handleChange = (open: string[]) => {
setStore("open", open)
}
@@ -60,6 +67,15 @@ export const SessionReview = (props: SessionReviewProps) => {
>
<div data-slot="session-review-title">Session changes</div>
<div data-slot="session-review-actions">
<Show when={props.onDiffStyleChange}>
<RadioGroup
options={["unified", "split"] as const}
current={diffStyle()}
value={(style) => style}
label={(style) => (style === "unified" ? "Unified" : "Split")}
onSelect={(style) => style && props.onDiffStyleChange?.(style)}
/>
</Show>
<Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
<Switch>
<Match when={store.open.length > 0}>Collapse all</Match>
@@ -102,7 +118,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Dynamic
component={diffComponent}
preloadedDiff={diff.preloaded}
diffStyle={props.split ? "split" : "unified"}
diffStyle={diffStyle()}
before={{
name: diff.file!,
contents: diff.before!,
@@ -118,6 +118,7 @@
[data-slot="session-turn-markdown"],
[data-slot="session-turn-accordion"] [data-slot="accordion-content"] {
-webkit-user-select: text;
user-select: text;
}
+14 -76
View File
@@ -78,6 +78,7 @@ function AssistantMessageItem(props: {
message: AssistantMessage
responsePartId: string | undefined
hideResponsePart: boolean
hideReasoning: boolean
}) {
const data = useData()
const emptyParts: PartType[] = []
@@ -92,7 +93,12 @@ function AssistantMessageItem(props: {
})
const filteredParts = createMemo(() => {
const parts = msgParts()
let parts = msgParts()
if (props.hideReasoning) {
parts = parts.filter((part) => part?.type !== "reasoning")
}
if (!props.hideResponsePart) return parts
const responsePartId = props.responsePartId
@@ -315,11 +321,10 @@ export function SessionTurn(
return s
})
const summary = createMemo(() => message()?.summary?.body)
const response = createMemo(() => lastTextPart()?.text)
const responsePartId = createMemo(() => lastTextPart()?.id)
const hasDiffs = createMemo(() => message()?.summary?.diffs?.length)
const hideResponsePart = createMemo(() => !working() && !summary() && !!responsePartId())
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
function duration() {
const msg = message()
@@ -350,7 +355,6 @@ export function SessionTurn(
retrySeconds: 0,
status: rawStatus(),
duration: duration(),
summaryWaitTimedOut: false,
})
createEffect(() => {
@@ -391,12 +395,6 @@ export function SessionTurn(
onCleanup(() => clearInterval(timer))
})
createEffect(() => {
if (working()) {
setStore("summaryWaitTimedOut", false)
}
})
createEffect(
on(permissionCount, (count, prev) => {
if (!count) return
@@ -405,42 +403,6 @@ export function SessionTurn(
}),
)
createEffect(() => {
if (working() || !isLastUserMessage()) return
const diffs = message()?.summary?.diffs
if (!diffs?.length) return
if (summary()) return
if (store.summaryWaitTimedOut) return
const completed = lastAssistantMessage()?.time.completed
if (completed && Date.now() - completed > 2000) {
setStore("summaryWaitTimedOut", true)
return
}
const timer = setTimeout(() => {
setStore("summaryWaitTimedOut", true)
}, 6000)
onCleanup(() => clearTimeout(timer))
})
const waitingForSummary = createMemo(() => {
if (!isLastUserMessage()) return false
if (working()) return false
const diffs = message()?.summary?.diffs
if (!diffs?.length) return false
if (summary()) return false
return !store.summaryWaitTimedOut
})
const showSummarySection = createMemo(() => {
if (working()) return false
return !waitingForSummary()
})
let lastStatusChange = Date.now()
let statusTimeout: number | undefined
createEffect(() => {
@@ -517,7 +479,7 @@ export function SessionTurn(
size="small"
onClick={props.onStepsExpandedToggle ?? (() => {})}
>
<Show when={working() || waitingForSummary()}>
<Show when={working()}>
<Spinner />
</Show>
<Switch>
@@ -534,7 +496,6 @@ export function SessionTurn(
</span>
<span data-slot="session-turn-retry-attempt">(#{retry()?.attempt})</span>
</Match>
<Match when={waitingForSummary()}>Generating summary</Match>
<Match when={working()}>{store.status ?? "Considering next steps"}</Match>
<Match when={props.stepsExpanded}>Hide steps</Match>
<Match when={!props.stepsExpanded}>Show steps</Match>
@@ -556,6 +517,7 @@ export function SessionTurn(
message={assistantMessage}
responsePartId={responsePartId()}
hideResponsePart={hideResponsePart()}
hideReasoning={!working()}
/>
)}
</For>
@@ -573,36 +535,12 @@ export function SessionTurn(
</For>
</div>
</Show>
{/* Summary */}
<Show when={showSummarySection()}>
{/* Response */}
<Show when={!working() && (response() || hasDiffs())}>
<div data-slot="session-turn-summary-section">
<div data-slot="session-turn-summary-header">
<Switch>
<Match when={summary()}>
{(summary) => (
<>
<h2 data-slot="session-turn-summary-title">Summary</h2>
<Markdown
data-slot="session-turn-markdown"
data-diffs={hasDiffs()}
text={summary()}
/>
</>
)}
</Match>
<Match when={response()}>
{(response) => (
<>
<h2 data-slot="session-turn-summary-title">Response</h2>
<Markdown
data-slot="session-turn-markdown"
data-diffs={hasDiffs()}
text={response()}
/>
</>
)}
</Match>
</Switch>
<h2 data-slot="session-turn-summary-title">Response</h2>
<Markdown data-slot="session-turn-markdown" data-diffs={hasDiffs()} text={response() ?? ""} />
</div>
<Accordion data-slot="session-turn-accordion" multiple>
<For each={msg().summary?.diffs ?? []}>
+18 -4
View File
@@ -2,15 +2,29 @@
display: flex;
}
[data-slot="tooltip-keybind"] {
display: flex;
align-items: center;
gap: 8px;
}
[data-slot="tooltip-keybind-key"] {
color: var(--text-invert-base);
font-size: var(--font-size-small);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
}
[data-component="tooltip"] {
z-index: 1000;
max-width: 320px;
border-radius: var(--radius-md);
background-color: var(--surface-float-base);
color: var(--text-inverted-base);
color: rgba(253, 252, 252, 0.94);
padding: 2px 8px;
border: 0.5px solid rgba(253, 252, 252, 0.2);
color: var(--text-invert-strong);
background: var(--surface-float-base);
padding: 6px 12px;
border: 1px solid var(--border-weak-base, rgba(0, 0, 0, 0.07));
box-shadow: var(--shadow-md);
pointer-events: none !important;
/* transition: all 150ms ease-out; */
+20
View File
@@ -8,6 +8,26 @@ export interface TooltipProps extends ComponentProps<typeof KobalteTooltip> {
inactive?: boolean
}
export interface TooltipKeybindProps extends Omit<TooltipProps, "value"> {
title: string
keybind: string
}
export function TooltipKeybind(props: TooltipKeybindProps) {
const [local, others] = splitProps(props, ["title", "keybind"])
return (
<Tooltip
{...others}
value={
<div data-slot="tooltip-keybind">
<span>{local.title}</span>
<span data-slot="tooltip-keybind-key">{local.keybind}</span>
</div>
}
/>
)
}
export function Tooltip(props: TooltipProps) {
const [open, setOpen] = createSignal(false)
const [local, others] = splitProps(props, ["children", "class", "inactive"])
+13 -3
View File
@@ -1,10 +1,20 @@
import type { WorkerPoolManager } from "@pierre/diffs/worker"
import { createSimpleContext } from "./helper"
const ctx = createSimpleContext<WorkerPoolManager | undefined, { pool: WorkerPoolManager | undefined }>({
export type WorkerPools = {
unified: WorkerPoolManager | undefined
split: WorkerPoolManager | undefined
}
const ctx = createSimpleContext<WorkerPools, { pools: WorkerPools }>({
name: "WorkerPool",
init: (props) => props.pool,
init: (props) => props.pools,
})
export const WorkerPoolProvider = ctx.provider
export const useWorkerPool = ctx.use
export function useWorkerPool(diffStyle: "unified" | "split" | undefined) {
const pools = ctx.use()
if (diffStyle === "split") return pools.split
return pools.unified
}
+35 -12
View File
@@ -1,16 +1,15 @@
import { getOrCreateWorkerPoolSingleton, WorkerPoolManager } from "@pierre/diffs/worker"
import { WorkerPoolManager } from "@pierre/diffs/worker"
import ShikiWorkerUrl from "@pierre/diffs/worker/worker.js?worker&url"
export type WorkerPoolStyle = "unified" | "split"
export function workerFactory(): Worker {
return new Worker(ShikiWorkerUrl, { type: "module" })
}
export const workerPool: WorkerPoolManager | undefined = (() => {
if (typeof window === "undefined") {
return undefined
}
return getOrCreateWorkerPoolSingleton({
poolOptions: {
function createPool(lineDiffType: "none" | "word-alt") {
const pool = new WorkerPoolManager(
{
workerFactory,
// poolSize defaults to 8. More workers = more parallelism but
// also more memory. Too many can actually slow things down.
@@ -19,10 +18,34 @@ export const workerPool: WorkerPoolManager | undefined = (() => {
// boot up time for workers
poolSize: 2,
},
highlighterOptions: {
{
theme: "OpenCode",
// Optionally preload languages to avoid lazy-loading delays
// langs: ["typescript", "javascript", "css", "html"],
lineDiffType,
},
})
})()
)
pool.initialize()
return pool
}
let unified: WorkerPoolManager | undefined
let split: WorkerPoolManager | undefined
export function getWorkerPool(style: WorkerPoolStyle | undefined): WorkerPoolManager | undefined {
if (typeof window === "undefined") return
if (style === "split") {
if (!split) split = createPool("word-alt")
return split
}
if (!unified) unified = createPool("none")
return unified
}
export function getWorkerPools() {
return {
unified: getWorkerPool("unified"),
split: getWorkerPool("split"),
}
}
@@ -77,6 +77,10 @@
--color-text-weaker: var(--text-weaker);
--color-text-strong: var(--text-strong);
--color-text-interactive-base: var(--text-interactive-base);
--color-text-invert-base: var(--text-invert-base);
--color-text-invert-weak: var(--text-invert-weak);
--color-text-invert-weaker: var(--text-invert-weaker);
--color-text-invert-strong: var(--text-invert-strong);
--color-text-on-brand-base: var(--text-on-brand-base);
--color-text-on-interactive-base: var(--text-on-interactive-base);
--color-text-on-interactive-weak: var(--text-on-interactive-weak);
+5 -5
View File
@@ -314,9 +314,9 @@
"surface-interactive-hover": "var(--cobalt-light-4)",
"surface-interactive-weak": "var(--cobalt-light-2)",
"surface-interactive-weak-hover": "var(--cobalt-light-3)",
"surface-success-base": "var(--apple-light-3)",
"surface-success-weak": "var(--apple-light-2)",
"surface-success-strong": "var(--apple-light-9)",
"surface-success-base": "var(--apple-dark-3)",
"surface-success-weak": "var(--apple-dark-2)",
"surface-success-strong": "var(--apple-dark-9)",
"surface-warning-base": "var(--solaris-light-3)",
"surface-warning-weak": "var(--solaris-light-2)",
"surface-warning-strong": "var(--solaris-light-9)",
@@ -439,8 +439,8 @@
"icon-strong-focus": "#fdfcfc",
"icon-brand-base": "var(--white)",
"icon-interactive-base": "var(--cobalt-dark-9)",
"icon-success-base": "var(--apple-dark-7)",
"icon-success-hover": "var(--apple-dark-8)",
"icon-success-base": "var(--apple-dark-9)",
"icon-success-hover": "var(--apple-dark-10)",
"icon-success-active": "var(--apple-dark-11)",
"icon-warning-base": "var(--amber-dark-7)",
"icon-warning-hover": "var(--amber-dark-8)",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.0.214",
"version": "1.0.222",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/web",
"type": "module",
"version": "1.0.214",
"version": "1.0.222",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+18 -15
View File
@@ -362,11 +362,12 @@ This starts an HTTP server that provides API access to opencode functionality wi
#### Flags
| Flag | Description |
| ------------ | --------------------- |
| `--port` | Port to listen on |
| `--hostname` | Hostname to listen on |
| `--mdns` | Enable mDNS discovery |
| Flag | Description |
| ------------ | ------------------------------------------ |
| `--port` | Port to listen on |
| `--hostname` | Hostname to listen on |
| `--mdns` | Enable mDNS discovery |
| `--cors` | Additional browser origin(s) to allow CORS |
---
@@ -407,11 +408,12 @@ opencode stats
#### Flags
| Flag | Description |
| ----------- | --------------------------------------------------------------- |
| `--days` | Show stats for the last N days (all time) |
| `--tools` | Number of tools to show (all) |
| `--project` | Filter by project (all projects, empty string: current project) |
| Flag | Description |
| ----------- | --------------------------------------------------------------------------- |
| `--days` | Show stats for the last N days (all time) |
| `--tools` | Number of tools to show (all) |
| `--models` | Show model usage breakdown (hidden by default). Pass a number to show top N |
| `--project` | Filter by project (all projects, empty string: current project) |
---
@@ -456,11 +458,12 @@ This starts an HTTP server and opens a web browser to access OpenCode through a
#### Flags
| Flag | Description |
| ------------ | --------------------- |
| `--port` | Port to listen on |
| `--hostname` | Hostname to listen on |
| `--mdns` | Enable mDNS discovery |
| Flag | Description |
| ------------ | ------------------------------------------ |
| `--port` | Port to listen on |
| `--hostname` | Hostname to listen on |
| `--mdns` | Enable mDNS discovery |
| `--cors` | Additional browser origin(s) to allow CORS |
---
+3 -1
View File
@@ -132,7 +132,8 @@ You can configure server settings for the `opencode serve` and `opencode web` co
"server": {
"port": 4096,
"hostname": "0.0.0.0",
"mdns": true
"mdns": true,
"cors": ["http://localhost:5173"]
}
}
```
@@ -142,6 +143,7 @@ Available options:
- `port` - Port to listen on.
- `hostname` - Hostname to listen on. When `mdns` is enabled and no hostname is set, defaults to `0.0.0.0`.
- `mdns` - Enable mDNS service discovery. This allows other devices on the network to discover your OpenCode server.
- `cors` - Additional origins to allow for CORS when using the HTTP server from a browser-based client. Values must be full origins (scheme + host + optional port), eg `https://app.example.com`.
[Learn more about the server here](/docs/server).
+23 -22
View File
@@ -15,28 +15,29 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
## Plugins
| Name | Description |
| ------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities |
| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events |
| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection |
| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory |
| Name | Description |
| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
| [opencode-skills](https://github.com/malhashemi/opencode-skills) | Manage and organize OpenCode skills and capabilities |
| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events |
| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection |
| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory |
| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing |
---
@@ -21,6 +21,7 @@ OpenCode comes with several built-in formatters for popular languages and framew
| clang-format | .c, .cpp, .h, .hpp, .ino, and [more](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` config file |
| ktlint | .kt, .kts | `ktlint` command available |
| ruff | .py, .pyi | `ruff` command available with config |
| rustfmt | .rs | `rustfmt` command available |
| uv | .py, .pyi | `uv` command available |
| rubocop | .rb, .rake, .gemspec, .ru | `rubocop` command available |
| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` command available |

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