Compare commits

..
66 Commits
Author SHA1 Message Date
Ryan VogelandGitHub f49834f301 Merge branch 'dev' into docs-export 2026-02-04 09:38:12 -05:00
R44VC0RP 93356021f4 docs(cli): add example for redirecting export output to file 2026-02-04 09:37:23 -05:00
Adam c8622df762 fix(app): file tree not staying in sync across projects/sessions 2026-02-04 07:59:46 -06:00
Adam c277ee8cbf fix(app): move session options to the session page 2026-02-04 07:12:19 -06:00
Adam a2face30f4 wip(app): session options 2026-02-04 07:12:18 -06:00
Adam a219615fe5 fix(app): opened tabs follow created session 2026-02-04 06:22:55 -06:00
opencode-agent[bot] af06175b1f chore: generate 2026-02-04 11:35:05 +00:00
Goni ZahavyandGitHub 2e8d8de58b fix(desktop): removed compression from rpm bundle to save 15m in CI (#12097) 2026-02-04 05:34:18 -06:00
opencode-agent[bot] 310de8b1ea chore: update nix node_modules hashes 2026-02-04 11:12:01 +00:00
Lucas (TaeYoung) JoandGitHub 891875402c fix(terminal): support remote server connections and fix GLIBC compatibility (#11906) 2026-02-04 05:01:10 -06:00
opencode 154cbf6996 release: v1.1.51 2026-02-04 07:06:48 +00:00
SebastianandGitHub 64bafce665 restore direct osc52 (#12071) 2026-02-04 00:35:57 -06:00
Aiden ClineandGitHub 5588453cbe fix: revert change that caused headers to be double merged if provider was authed in multiple places (#12072) 2026-02-04 06:30:32 +00:00
Gary ChuandGitHub 5aaf8f8247 docs: add agent-compatible paths to skills documentation (#12067) 2026-02-04 00:12:24 -06:00
Aiden ClineandGitHub 8c1f1f13dc docs: document the built in agents (#12066) 2026-02-04 00:00:05 -06:00
Sergiy DybskiyandGitHub b942e0b4dc fix: prevent double-prefixing of Bedrock cross-region inference models (#12056) 2026-02-03 23:06:17 -06:00
f282613746 fix(app): tighten up session padding-top for mobile (#11247)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-02-04 12:10:56 +08:00
Brendan Allan 7c440ae82c chore: add brendonovich as rust codeowner 2026-02-04 10:40:08 +08:00
Brendan Allan b7bd561eaa ci: use numeric release id instead of gql one 2026-02-04 10:39:17 +08:00
Łukasz Gładyszandopencode 6daa962aaa fix: prioritize OPENCODE_CONFIG_DIR for AGENTS.md (#11536) 2026-02-04 01:35:22 +00:00
opencode 93a07e5a2a release: v1.1.50 2026-02-04 01:35:15 +00:00
Max LeiterandGitHub 93e060272a fix: prevent memory leaks from AbortController closures (#12024) 2026-02-03 17:51:26 -06:00
FilipandGitHub acac05f22e refactor(e2e): faster tests (#12021) 2026-02-03 17:45:49 -06:00
Aiden ClineandGitHub b5a4671c64 Revert "feat: add Trinity model system prompt support" (#12029) 2026-02-03 16:39:03 -06:00
Aiden ClineandGitHub a68fedd4a6 chore: adjust way skill dirs are whitelisted (#12026) 2026-02-03 16:33:36 -06:00
015cd404e4 feat: add Trinity model system prompt support (#12025)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-03 16:33:27 -06:00
opencode-agent[bot] 9921809565 chore: update nix node_modules hashes 2026-02-03 21:28:16 +00:00
opencode-agent[bot] 137336f373 chore: generate 2026-02-03 21:22:33 +00:00
Sayeed MahmoudandGitHub d940d17918 docs: fix grammar and formatting in README (#11985) 2026-02-03 15:21:51 -06:00
Tomáš LinhartandGitHub 0a5d5bc524 docs: fix logging example for plugin (#11989) 2026-02-03 15:21:15 -06:00
Tyler GannonandGitHub a30696f9bf feat(plugin): add shell.env hook for manipulating environment in tools and shell (#12012) 2026-02-03 15:18:41 -06:00
Matt SilverlockandGitHub 25bdd77b1d fix(opencode): use official ai-gateway-provider package for Cloudflare AI Gateway (#12014) 2026-02-03 15:18:28 -06:00
opencode-agent[bot] 2f12e8ee92 chore: generate 2026-02-03 21:10:08 +00:00
Idris GadiandGitHub 95211a8854 feat(tui): allow theme colors in agent customization (#11444) 2026-02-03 15:09:21 -06:00
Idris GadiandGitHub 6b5cf936a2 docs: add missing environmental flags to the list (#11146) 2026-02-03 15:07:40 -06:00
17e62b050f feat: add support for reading skills from .agents/skills directories (#11842)
Co-authored-by: Filip <34747899+neriousy@users.noreply.github.com>
2026-02-03 15:51:54 -05:00
DaxandGitHub ee84eb44ee cli: add --thinking flag to show reasoning blocks in run command (#12013) 2026-02-03 20:49:15 +00:00
MartinWieandGitHub 82dd4b6908 fix: always fall back to native clipboard after OSC52 (#11994) 2026-02-03 20:53:04 +01:00
opencode-agent[bot] 185858749b chore: generate 2026-02-03 19:10:44 +00:00
CloudyanandGitHub 39a504773c fix: provider headers from config not applied to fetch requests (#11788) 2026-02-03 13:09:07 -06:00
Aiden ClineandGitHub b7b734f51f fix: ensure mcp tools are sanitized (#11984) 2026-02-03 12:22:00 -06:00
Adam dcff5b6596 fix(app): command palette placeholder text 2026-02-03 11:58:58 -06:00
Adam 4f7da2b757 fix(app): model selector truncating too soon 2026-02-03 11:51:30 -06:00
AlexandGitHub 60e616ec81 feat: Add .slnx to C#/F# LSP root detection (#11928) 2026-02-03 11:46:04 -06:00
Adam 416964acd0 chore: gitignore codex 2026-02-03 11:44:03 -06:00
Adam 017ad2c7f7 fix(app): spacing 2026-02-03 10:27:53 -06:00
Adam e33eb1b058 chore: cleanup 2026-02-03 10:27:53 -06:00
Aiden ClineandGitHub 857b8a4b56 test: add test for new skill improvements (#11969) 2026-02-03 16:05:10 +00:00
Aiden ClineandGitHub 3975329629 feat: improve skills, better prompting, fix permission asks after invoking skills, ensure agent knows where scripts/resources are (#11737) 2026-02-03 09:58:31 -06:00
neavoandGitHub 54e14c1a17 fix: exclude k2p5 from reasoning variants (#11918) 2026-02-03 09:26:37 -06:00
Muhammad Mugni HadiandGitHub 3741516fe3 fix: handle nested array items for Gemini schema validation (#11952) 2026-02-03 09:24:52 -06:00
Daniel PolitoandGitHub 76381f33d5 feat(desktop): Allow empty prompt with review comments (#11953) 2026-02-03 09:06:02 -06:00
Luiz Guilherme D'Abruzzo PereiraandGitHub 95d0d476e3 docs: add --mdns-domain flag documentation (#11933) 2026-02-03 07:26:42 -06:00
Adam 7508839b70 fix(app): terminal serialization bug 2026-02-03 07:03:34 -06:00
Adam e88cbefabe fix(app): terminal serialization bug 2026-02-03 07:03:34 -06:00
Adam a38bae684f chore(app): don't forceMount tooltips 2026-02-03 07:01:12 -06:00
Rahul A MistryandGitHub 08671e3155 fix(app): session tabs to open the previous opened (#11914) 2026-02-03 06:19:56 -06:00
Rahul A MistryandGitHub 0d557721cf fix(app): edit project dialog icon on hover (#11921) 2026-02-03 06:19:07 -06:00
Adam e709808b32 fix(app): move session search to command palette 2026-02-03 05:19:43 -06:00
0d22068c90 fix(app): custom providers overflow (#11252)
Co-authored-by: Brendan Allan <brendonovich@outlook.com>
2026-02-03 16:17:13 +08:00
FilipandGitHub d116c227e0 fix(core): plugins are always reinstalled (#9675) 2026-02-03 00:47:52 -06:00
opencode-agent[bot] 3f07dffbb0 chore: generate 2026-02-03 05:19:12 +00:00
Frank 801e4a8a9d wip: zen 2026-02-03 00:17:06 -05:00
Kiyoung ChangandGitHub 3adeed8f97 fix(provider): strip properties/required from non-object types in Gemini schema (#11888) 2026-02-02 23:13:24 -06:00
DaxandGitHub 1275c71a63 cli: make run non-interactive (#11814) 2026-02-02 23:35:23 -05:00
Aiden Clineandopencode ca8c23dd71 ci: add license to npm (#11883) 2026-02-03 03:44:30 +00:00
106 changed files with 3028 additions and 1244 deletions
+1
View File
@@ -1,4 +1,5 @@
# web + desktop packages # web + desktop packages
packages/app/ @adamdotdevin packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin packages/tauri/ @adamdotdevin
packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin packages/desktop/ @adamdotdevin
+1
View File
@@ -5,6 +5,7 @@ node_modules
.env .env
.idea .idea
.vscode .vscode
.codex
*~ *~
playground playground
tmp tmp
+1 -7
View File
@@ -1,6 +1,5 @@
{ {
"$schema": "https://opencode.ai/config.json", "$schema": "https://opencode.ai/config.json",
// "plugin": ["opencode-openai-codex-auth"],
// "enterprise": { // "enterprise": {
// "url": "https://enterprise.dev.opencode.ai", // "url": "https://enterprise.dev.opencode.ai",
// }, // },
@@ -9,12 +8,7 @@
"options": {}, "options": {},
}, },
}, },
"mcp": { "mcp": {},
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
},
},
"tools": { "tools": {
"github-triage": false, "github-triage": false,
"github-pr-search": false, "github-pr-search": false,
+8 -8
View File
@@ -82,7 +82,7 @@ The install script respects the following priority order for the installation pa
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory 1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path 2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path
3. `$HOME/bin` - Standard user binary directory (if exists or can be created) 3. `$HOME/bin` - Standard user binary directory (if it exists or can be created)
4. `$HOME/.opencode/bin` - Default fallback 4. `$HOME/.opencode/bin` - Default fallback
```bash ```bash
@@ -95,20 +95,20 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
OpenCode includes two built-in agents you can switch between with the `Tab` key. OpenCode includes two built-in agents you can switch between with the `Tab` key.
- **build** - Default, full access agent for development work - **build** - Default, full-access agent for development work
- **plan** - Read-only agent for analysis and code exploration - **plan** - Read-only agent for analysis and code exploration
- Denies file edits by default - Denies file edits by default
- Asks permission before running bash commands - Asks permission before running bash commands
- Ideal for exploring unfamiliar codebases or planning changes - Ideal for exploring unfamiliar codebases or planning changes
Also, included is a **general** subagent for complex searches and multistep tasks. Also included is a **general** subagent for complex searches and multistep tasks.
This is used internally and can be invoked using `@general` in messages. This is used internally and can be invoked using `@general` in messages.
Learn more about [agents](https://opencode.ai/docs/agents). Learn more about [agents](https://opencode.ai/docs/agents).
### Documentation ### Documentation
For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs). For more info on how to configure OpenCode, [**head over to our docs**](https://opencode.ai/docs).
### Contributing ### Contributing
@@ -116,7 +116,7 @@ If you're interested in contributing to OpenCode, please read our [contributing
### Building on OpenCode ### Building on OpenCode
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way. If you are working on a project that's related to OpenCode and is using "opencode" as part of its name, for example "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
### FAQ ### FAQ
@@ -125,10 +125,10 @@ If you are working on a project that's related to OpenCode and is using "opencod
It's very similar to Claude Code in terms of capability. Here are the key differences: It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source - 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important. - Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
- Out of the box LSP support - Out-of-the-box LSP support
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal. - A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients. - A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients.
--- ---
+46 -17
View File
@@ -23,7 +23,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "2.0.0", "@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2", "@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -213,7 +213,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -242,7 +242,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -258,7 +258,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.1.49", "version": "1.1.51",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -307,8 +307,9 @@
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0", "bonjour-service": "1.3.0",
"bun-pty": "0.4.4", "bun-pty": "0.4.8",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
@@ -362,7 +363,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -382,7 +383,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.49", "version": "1.1.51",
"devDependencies": { "devDependencies": {
"@hey-api/openapi-ts": "0.90.10", "@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:", "@tsconfig/node22": "catalog:",
@@ -393,7 +394,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -406,7 +407,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -448,7 +449,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -459,7 +460,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
@@ -569,8 +570,16 @@
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="], "@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="], "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="],
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4d5EKu0OW7Gf5WFpGo4ixn0iWEwA+GpteqUjEznWGmi7qdLE5zdkbRik5B1HrDDiw5P90yO51xBex/Fp50JcVA=="],
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WWOz5Kj+5fVe94h7WeReqjUOVtAquDE2kM575FUc8CsVxH2tRfA5cLa8nu3bknSezsKt3i67YM6mvCRxiXCkWA=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="], "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="], "@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
@@ -1949,6 +1958,8 @@
"ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="], "ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -2097,7 +2108,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="], "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
@@ -3989,6 +4000,8 @@
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="], "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], "@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
@@ -4273,6 +4286,14 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -4805,6 +4826,14 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-yIrljJgOR1GZCAXi5bx+YvrIAjSkTAMTSzlhLFY/ufE=", "x86_64-linux": "sha256-9XlAYCNdBhw8NmfJoYNjvQYhSn02rFhWvbJtlOnnCjc=",
"aarch64-linux": "sha256-Xa3BgqbuD5Cx5OpyVSN1v7Klge449hPqR1GY9E9cAX0=", "aarch64-linux": "sha256-Mdz3gAy8auN7mhMHRaWyH/exHGO9eYDyUMQKqscg6Xc=",
"aarch64-darwin": "sha256-Q3FKm7+4Jr3PL+TnQngrTtv/xdek2st5HmgeoEOHUis=", "aarch64-darwin": "sha256-NDB6+NVZ4+9+Yds/cjEGQAn9Tl/LRuEjEH6wV5dTdVg=",
"x86_64-darwin": "sha256-asJ8DBvIgkqh8HhrN48M/L4xj1kwv+uyQMy9bN2HxuM=" "x86_64-darwin": "sha256-LGJ5TJYgyK8Vn0BliEeJdoblcubj5ZIjvJoUtdVXfvU="
} }
} }
+44 -13
View File
@@ -1,5 +1,5 @@
import { test as base, expect } from "@playwright/test" import { test as base, expect, type Page } from "@playwright/test"
import { seedProjects } from "./actions" import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
import { promptSelector } from "./selectors" import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
@@ -8,6 +8,14 @@ export const settingsKey = "settings.v3"
type TestFixtures = { type TestFixtures = {
sdk: ReturnType<typeof createSdk> sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void> gotoSession: (sessionID?: string) => Promise<void>
withProject: <T>(
callback: (project: {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
}) => Promise<T>,
options?: { extra?: string[] },
) => Promise<T>
} }
type WorkerFixtures = { type WorkerFixtures = {
@@ -33,17 +41,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await use(createSdk(directory)) await use(createSdk(directory))
}, },
gotoSession: async ({ page, directory }, use) => { gotoSession: async ({ page, directory }, use) => {
await seedProjects(page, { directory }) await seedStorage(page, { directory })
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
user: [],
variant: {},
}),
)
})
const gotoSession = async (sessionID?: string) => { const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID)) await page.goto(sessionPath(directory, sessionID))
@@ -51,6 +49,39 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
} }
await use(gotoSession) await use(gotoSession)
}, },
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const directory = await createTestProject()
const slug = dirSlug(directory)
await seedStorage(page, { directory, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
}
try {
await gotoSession()
return await callback({ directory, slug, gotoSession })
} finally {
await cleanupTestProject(directory)
}
})
},
}) })
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
user: [],
variant: {},
}),
)
})
}
export { expect } export { expect }
+44 -43
View File
@@ -1,52 +1,53 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { openSidebar } from "../actions" import { openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => { test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await gotoSession()
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await openSidebar(page) await withProject(async () => {
await openSidebar(page)
const open = async () => {
const header = page.locator(".group\\/project").first()
await header.hover()
const trigger = header.getByRole("button", { name: "More options" }).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible()
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
await expect(editItem).toBeVisible()
await editItem.click({ force: true })
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
return dialog
}
const name = `e2e project ${Date.now()}`
const startup = `echo e2e_${Date.now()}`
const dialog = await open()
const nameInput = dialog.getByLabel("Name")
await nameInput.fill(name)
const startupInput = dialog.getByLabel("Workspace startup script")
await startupInput.fill(startup)
await dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0)
const open = async () => {
const header = page.locator(".group\\/project").first() const header = page.locator(".group\\/project").first()
await header.hover() await expect(header).toContainText(name)
const trigger = header.getByRole("button", { name: "More options" }).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
const menu = page.locator('[data-component="dropdown-menu-content"]').first() const reopened = await open()
await expect(menu).toBeVisible() await expect(reopened.getByLabel("Name")).toHaveValue(name)
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first() await reopened.getByRole("button", { name: "Cancel" }).click()
await expect(editItem).toBeVisible() await expect(reopened).toHaveCount(0)
await editItem.click({ force: true }) })
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
return dialog
}
const name = `e2e project ${Date.now()}`
const startup = `echo e2e_${Date.now()}`
const dialog = await open()
const nameInput = dialog.getByLabel("Name")
await nameInput.fill(name)
const startupInput = dialog.getByLabel("Workspace startup script")
await startupInput.fill(startup)
await dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0)
const header = page.locator(".group\\/project").first()
await expect(header).toContainText(name)
const reopened = await open()
await expect(reopened.getByLabel("Name")).toHaveValue(name)
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
await reopened.getByRole("button", { name: "Cancel" }).click()
await expect(reopened).toHaveCount(0)
}) })
@@ -1,69 +1,73 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions" import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors" import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils" import { dirSlug } from "../utils"
test("can close a project via hover card close button", async ({ page, directory, gotoSession }) => { test("can close a project via hover card close button", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
await seedProjects(page, { directory, extra: [other] })
try { try {
await gotoSession() await withProject(
async () => {
await openSidebar(page)
await openSidebar(page) const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.hover()
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
await expect(otherButton).toBeVisible() await expect(close).toBeVisible()
await otherButton.hover() await close.click()
const close = page.locator(projectCloseHoverSelector(otherSlug)).first() await expect(otherButton).toHaveCount(0)
await expect(close).toBeVisible() },
await close.click() { extra: [other] },
)
await expect(otherButton).toHaveCount(0)
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
}) })
test("can close a project via project header more options menu", async ({ page, directory, gotoSession }) => { test("can close a project via project header more options menu", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherName = other.split("/").pop() ?? other const otherName = other.split("/").pop() ?? other
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
await seedProjects(page, { directory, extra: [other] })
try { try {
await gotoSession() await withProject(
async () => {
await openSidebar(page)
await openSidebar(page) const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) const header = page
.locator(".group\\/project")
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
.first()
await expect(header).toContainText(otherName)
const header = page const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
.locator(".group\\/project") await expect(trigger).toHaveCount(1)
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) }) await trigger.focus()
.first() await page.keyboard.press("Enter")
await expect(header).toContainText(otherName)
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first() const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(trigger).toHaveCount(1) await expect(menu).toBeVisible({ timeout: 10_000 })
await trigger.focus()
await page.keyboard.press("Enter")
const menu = page.locator('[data-component="dropdown-menu-content"]').first() await clickMenuItem(menu, /^Close$/i, { force: true })
await expect(menu).toBeVisible({ timeout: 10_000 }) await expect(otherButton).toHaveCount(0)
},
await clickMenuItem(menu, /^Close$/i, { force: true }) { extra: [other] },
await expect(otherButton).toHaveCount(0) )
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
@@ -1,33 +1,34 @@
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions" import { defocus, createTestProject, cleanupTestProject } from "../actions"
import { projectSwitchSelector } from "../selectors" import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils" import { dirSlug } from "../utils"
test("can switch between projects from sidebar", async ({ page, directory, gotoSession }) => { test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject() const other = await createTestProject()
const otherSlug = dirSlug(other) const otherSlug = dirSlug(other)
await seedProjects(page, { directory, extra: [other] })
try { try {
await gotoSession() await withProject(
async ({ directory }) => {
await defocus(page)
await defocus(page) const currentSlug = dirSlug(directory)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
const currentSlug = dirSlug(directory) await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
await expect(currentButton).toBeVisible()
await currentButton.click()
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first() await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
await expect(currentButton).toBeVisible() },
await currentButton.click() { extra: [other] },
)
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
} finally { } finally {
await cleanupTestProject(other) await cleanupTestProject(other)
} }
+114 -172
View File
@@ -10,33 +10,20 @@ import {
cleanupTestProject, cleanupTestProject,
clickMenuItem, clickMenuItem,
confirmDialog, confirmDialog,
createTestProject,
openSidebar, openSidebar,
openWorkspaceMenu, openWorkspaceMenu,
seedProjects,
setWorkspacesEnabled, setWorkspacesEnabled,
} from "../actions" } from "../actions"
import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors" import { inlineInputSelector, workspaceItemSelector } from "../selectors"
import { dirSlug } from "../utils"
function slugFromUrl(url: string) { function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
} }
async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise<void>) { async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const project = await createTestProject() const rootSlug = project.slug
const rootSlug = dirSlug(project)
await seedProjects(page, { directory, extra: [project] })
await gotoSession()
await openSidebar(page) await openSidebar(page)
const target = page.locator(projectSwitchSelector(rootSlug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true) await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click() await page.getByRole("button", { name: "New workspace" }).first().click()
@@ -70,25 +57,13 @@ async function setupWorkspaceTest(page: Page, directory: string, gotoSession: ()
) )
.toBe(true) .toBe(true)
return { project, rootSlug, slug, directory: dir } return { rootSlug, slug, directory: dir }
} }
test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => { test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const project = await createTestProject() await withProject(async ({ slug }) => {
const slug = dirSlug(project)
await seedProjects(page, { directory, extra: [project] })
try {
await gotoSession()
await openSidebar(page)
const target = page.locator(projectSwitchSelector(slug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await openSidebar(page) await openSidebar(page)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
@@ -101,27 +76,13 @@ test("can enable and disable workspaces from project menu", async ({ page, direc
await setWorkspacesEnabled(page, slug, false) await setWorkspacesEnabled(page, slug, false)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible() await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
} finally { })
await cleanupTestProject(project)
}
}) })
test("can create a workspace", async ({ page, directory, gotoSession }) => { test("can create a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const project = await createTestProject() await withProject(async ({ slug }) => {
const slug = dirSlug(project)
await seedProjects(page, { directory, extra: [project] })
try {
await gotoSession()
await openSidebar(page)
const target = page.locator(projectSwitchSelector(slug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, slug, true) await setWorkspacesEnabled(page, slug, true)
@@ -162,17 +123,15 @@ test("can create a workspace", async ({ page, directory, gotoSession }) => {
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
await cleanupTestProject(workspaceDir) await cleanupTestProject(workspaceDir)
} finally { })
await cleanupTestProject(project)
}
}) })
test("can rename a workspace", async ({ page, directory, gotoSession }) => { test("can rename a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession) await withProject(async (project) => {
const { slug } = await setupWorkspaceTest(page, project)
try {
const rename = `e2e workspace ${Date.now()}` const rename = `e2e workspace ${Date.now()}`
const menu = await openWorkspaceMenu(page, slug) const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Rename$/i, { force: true }) await clickMenuItem(menu, /^Rename$/i, { force: true })
@@ -186,17 +145,15 @@ test("can rename a workspace", async ({ page, directory, gotoSession }) => {
await input.fill(rename) await input.fill(rename)
await input.press("Enter") await input.press("Enter")
await expect(item).toContainText(rename) await expect(item).toContainText(rename)
} finally { })
await cleanupTestProject(project)
}
}) })
test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => { test("can reset a workspace", async ({ page, sdk, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession) await withProject(async (project) => {
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
try {
const readme = path.join(createdDir, "README.md") const readme = path.join(createdDir, "README.md")
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`) const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
const original = await fs.readFile(readme, "utf8") const original = await fs.readFile(readme, "utf8")
@@ -250,17 +207,15 @@ test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
.catch(() => false) .catch(() => false)
}) })
.toBe(false) .toBe(false)
} finally { })
await cleanupTestProject(project)
}
}) })
test("can delete a workspace", async ({ page, directory, gotoSession }) => { test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession) await withProject(async (project) => {
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
try {
const menu = await openWorkspaceMenu(page, slug) const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true }) await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i) await confirmDialog(page, /^Delete workspace$/i)
@@ -268,124 +223,111 @@ test("can delete a workspace", async ({ page, directory, gotoSession }) => {
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`)) await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0) await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible() await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
} finally { })
await cleanupTestProject(project)
}
}) })
test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => { test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 }) await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: rootSlug }) => {
const workspaces = [] as { directory: string; slug: string }[]
const project = await createTestProject() const listSlugs = async () => {
const rootSlug = dirSlug(project) const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
await seedProjects(page, { directory, extra: [project] }) const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const workspaces = [] as { directory: string; slug: string }[] const waitReady = async (slug: string) => {
const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const waitReady = async (slug: string) => {
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
const drag = async (from: string, to: string) => {
const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first()
await src.scrollIntoViewIfNeeded()
await dst.scrollIntoViewIfNeeded()
const a = await src.boundingBox()
const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
await page.mouse.down()
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
await page.mouse.up()
}
try {
await gotoSession()
await openSidebar(page)
const target = page.locator(projectSwitchSelector(rootSlug)).first()
await expect(target).toBeVisible()
await target.click()
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect await expect
.poll( .poll(
() => { async () => {
const slug = slugFromUrl(page.url()) const item = page.locator(workspaceItemSelector(slug)).first()
return slug.length > 0 && slug !== rootSlug && slug !== prev try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
}, },
{ timeout: 45_000 }, { timeout: 60_000 },
) )
.toBe(true) .toBe(true)
}
const slug = slugFromUrl(page.url()) const drag = async (from: string, to: string) => {
const dir = base64Decode(slug) const src = page.locator(workspaceItemSelector(from)).first()
workspaces.push({ slug, directory: dir }) const dst = page.locator(workspaceItemSelector(to)).first()
await src.scrollIntoViewIfNeeded()
await dst.scrollIntoViewIfNeeded()
const a = await src.boundingBox()
const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
await page.mouse.down()
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
await page.mouse.up()
}
try {
await openSidebar(page) await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug && slug !== prev
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })
await openSidebar(page)
}
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
} }
})
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
await cleanupTestProject(project)
}
}) })
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.1.49", "version": "1.1.51",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+1 -3
View File
@@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096" const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}` const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI const reuse = !process.env.CI
const win = process.platform === "win32"
export default defineConfig({ export default defineConfig({
testDir: "./e2e", testDir: "./e2e",
@@ -15,8 +14,7 @@ export default defineConfig({
expect: { expect: {
timeout: 10_000, timeout: 10_000,
}, },
fullyParallel: !win, fullyParallel: true,
workers: win ? 1 : undefined,
forbidOnly: !!process.env.CI, forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0, retries: process.env.CI ? 2 : 0,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
+28 -16
View File
@@ -241,19 +241,19 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _rowEnd(row: number, isLastRow: boolean): void { protected _rowEnd(row: number, isLastRow: boolean): void {
let rowSeparator = "" let rowSeparator = ""
if (this._nullCellCount > 0) { const nextLine = isLastRow ? undefined : this._buffer.getLine(row + 1)
const wrapped = !!nextLine?.isWrapped
if (this._nullCellCount > 0 && wrapped) {
this._currentRow += " ".repeat(this._nullCellCount) this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
} }
if (!isLastRow) { this._nullCellCount = 0
const nextLine = this._buffer.getLine(row + 1)
if (!nextLine?.isWrapped) { if (!isLastRow && !wrapped) {
rowSeparator = "\r\n" rowSeparator = "\r\n"
this._lastCursorRow = row + 1 this._lastCursorRow = row + 1
this._lastCursorCol = 0 this._lastCursorCol = 0
}
} }
this._allRows[this._rowIndex] = this._currentRow this._allRows[this._rowIndex] = this._currentRow
@@ -389,7 +389,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
const sgrSeq = this._diffStyle(cell, this._cursorStyle) const sgrSeq = this._diffStyle(cell, this._cursorStyle)
const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0 const styleChanged = sgrSeq.length > 0
if (styleChanged) { if (styleChanged) {
if (this._nullCellCount > 0) { if (this._nullCellCount > 0) {
@@ -442,12 +442,24 @@ class StringSerializeHandler extends BaseSerializeHandler {
} }
} }
if (!excludeFinalCursorPosition) { if (excludeFinalCursorPosition) return content
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER) const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
const cursorCol = this._buffer.cursorX + 1 const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
content += `\u001b[${cursorRow};${cursorCol}H` const cursorCol = this._buffer.cursorX + 1
} content += `\u001b[${cursorRow};${cursorCol}H`
const line = this._buffer.getLine(absoluteCursorRow)
const cell = line?.getCell(this._buffer.cursorX)
const style = (() => {
if (!cell) return this._buffer.getNullCell()
if (cell.getWidth() !== 0) return cell
if (this._buffer.cursorX > 0) return line?.getCell(this._buffer.cursorX - 1) ?? cell
return cell
})()
const sgrSeq = this._diffStyle(style, this._cursorStyle)
if (sgrSeq.length) content += `\u001b[${sgrSeq.join(";")}m`
return content return content
} }
@@ -158,22 +158,22 @@ export function DialogEditProject(props: { project: LocalProject }) {
</Show> </Show>
</div> </div>
<div <div
class="absolute inset-0 size-16 bg-black/60 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity" class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{ classList={{
"opacity-100": store.iconHover && !store.iconUrl, "opacity-100": store.iconHover && !store.iconUrl,
"opacity-0": !(store.iconHover && !store.iconUrl), "opacity-0": !(store.iconHover && !store.iconUrl),
}} }}
> >
<Icon name="cloud-upload" size="large" class="text-icon-invert-base" /> <Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div> </div>
<div <div
class="absolute inset-0 size-16 bg-black/60 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity" class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{ classList={{
"opacity-100": store.iconHover && !!store.iconUrl, "opacity-100": store.iconHover && !!store.iconUrl,
"opacity-0": !(store.iconHover && !!store.iconUrl), "opacity-0": !(store.iconHover && !!store.iconUrl),
}} }}
> >
<Icon name="trash" size="large" class="text-icon-invert-base" /> <Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
</div> </div>
</div> </div>
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} /> <input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />
@@ -1,17 +1,22 @@
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog" import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon" import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind" import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path" import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, createSignal, onCleanup, Show } from "solid-js" import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command" import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file" import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
type EntryType = "command" | "file" type EntryType = "command" | "file" | "session"
type Entry = { type Entry = {
id: string id: string
@@ -22,6 +27,9 @@ type Entry = {
category: string category: string
option?: CommandOption option?: CommandOption
path?: string path?: string
directory?: string
sessionID?: string
archived?: number
} }
type DialogSelectFileMode = "all" | "files" type DialogSelectFileMode = "all" | "files"
@@ -33,6 +41,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const file = useFile() const file = useFile()
const dialog = useDialog() const dialog = useDialog()
const params = useParams() const params = useParams()
const navigate = useNavigate()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const filesOnly = () => props.mode === "files" const filesOnly = () => props.mode === "files"
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey)) const tabs = createMemo(() => layout.tabs(sessionKey))
@@ -73,6 +84,52 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
path, path,
}) })
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => globalSync.data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = globalSync.child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
}
const sessionItem = (input: {
directory: string
id: string
title: string
description: string
archived?: number
}): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category: language.t("command.category.session"),
directory: input.directory,
sessionID: input.id,
archived: input.archived,
})
const list = createMemo(() => allowed().map(commandItem)) const list = createMemo(() => allowed().map(commandItem))
const picks = createMemo(() => { const picks = createMemo(() => {
@@ -122,6 +179,68 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return out return out
} }
const sessionToken = { value: 0 }
let sessionInflight: Promise<Entry[]> | undefined
let sessionAll: Entry[] | undefined
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
sessionToken.value += 1
sessionInflight = undefined
sessionAll = undefined
return [] as Entry[]
}
if (sessionAll) return sessionAll
if (sessionInflight) return sessionInflight
const current = sessionToken.value
const dirs = workspaces()
if (dirs.length === 0) return [] as Entry[]
sessionInflight = Promise.all(
dirs.map((directory) => {
const description = label(directory)
return globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
})),
)
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
}),
)
.then((results) => {
if (sessionToken.value !== current) return [] as Entry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(sessionItem)
sessionAll = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
sessionInflight = undefined
})
return sessionInflight
}
const items = async (text: string) => { const items = async (text: string) => {
const query = text.trim() const query = text.trim()
setGrouped(query.length > 0) setGrouped(query.length > 0)
@@ -146,9 +265,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const files = await file.searchFiles(query) const files = await file.searchFiles(query)
return files.map(fileItem) return files.map(fileItem)
} }
const files = await file.searchFiles(query)
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const entries = files.map(fileItem) const entries = files.map(fileItem)
return [...list(), ...entries] return [...list(), ...nextSessions, ...entries]
} }
const handleMove = (item: Entry | undefined) => { const handleMove = (item: Entry | undefined) => {
@@ -178,6 +298,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return return
} }
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return if (!item.path) return
open(item.path) open(item.path)
} }
@@ -202,13 +328,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
items={items} items={items}
key={(item) => item.id} key={(item) => item.id}
filterKeys={["title", "description", "category"]} filterKeys={["title", "description", "category"]}
groupBy={(item) => item.category} groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove} onMove={handleMove}
onSelect={handleSelect} onSelect={handleSelect}
> >
{(item) => ( {(item) => (
<Show <Switch
when={item.type === "command"}
fallback={ fallback={
<div class="w-full flex items-center justify-between rounded-md pl-1"> <div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0"> <div class="flex items-center gap-x-3 grow min-w-0">
@@ -223,18 +348,43 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</div> </div>
} }
> >
<div class="w-full flex items-center justify-between gap-4"> <Match when={item.type === "command"}>
<div class="flex items-center gap-2 min-w-0"> <div class="w-full flex items-center justify-between gap-4">
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span> <div class="flex items-center gap-2 min-w-0">
<Show when={item.description}> <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
<span class="text-14-regular text-text-weak truncate">{item.description}</span> <Show when={item.description}>
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
</Show> </Show>
</div> </div>
<Show when={item.keybind}> </Match>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind> <Match when={item.type === "session"}>
</Show> <div class="w-full flex items-center justify-between rounded-md pl-1">
</div> <div class="flex items-center gap-x-3 grow min-w-0">
</Show> <Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
<div class="flex items-center gap-2 min-w-0">
<span
class="text-14-regular text-text-strong truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.title}
</span>
<Show when={item.description}>
<span
class="text-14-regular text-text-weak truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.description}
</span>
</Show>
</div>
</div>
</div>
</Match>
</Switch>
)} )}
</List> </List>
</Dialog> </Dialog>
@@ -54,7 +54,6 @@ const ModelList: Component<{
class="w-full" class="w-full"
placement="right-start" placement="right-start"
gutter={12} gutter={12}
forceMount={false}
value={ value={
<ModelTooltip <ModelTooltip
model={item} model={item}
@@ -214,7 +213,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
class="p-1" class="p-1"
action={ action={
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<Tooltip placement="top" forceMount={false} value={language.t("command.provider.connect")}> <Tooltip placement="top" value={language.t("command.provider.connect")}>
<IconButton <IconButton
icon="plus-small" icon="plus-small"
variant="ghost" variant="ghost"
@@ -224,7 +223,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
onClick={handleConnectProvider} onClick={handleConnectProvider}
/> />
</Tooltip> </Tooltip>
<Tooltip placement="top" forceMount={false} value={language.t("dialog.model.manage")}> <Tooltip placement="top" value={language.t("dialog.model.manage")}>
<IconButton <IconButton
icon="sliders" icon="sliders"
variant="ghost" variant="ghost"
@@ -321,7 +321,6 @@ export default function FileTree(props: {
return ( return (
<Tooltip <Tooltip
forceMount={false}
openDelay={2000} openDelay={2000}
placement="bottom-start" placement="bottom-start"
class="w-full" class="w-full"
+10 -7
View File
@@ -1132,7 +1132,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const images = imageAttachments().slice() const images = imageAttachments().slice()
const mode = store.mode const mode = store.mode
if (text.trim().length === 0 && images.length === 0) { if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) {
if (working()) abort() if (working()) abort()
return return
} }
@@ -1220,7 +1220,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}) })
return undefined return undefined
}) })
if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) if (session) {
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
} }
if (!session) return if (!session) return
@@ -1934,13 +1937,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Button <Button
as="div" as="div"
variant="ghost" variant="ghost"
class="px-2 min-w-0 max-w-[140px]" class="px-2 min-w-0 max-w-[240px]"
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)} onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" /> <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show> </Show>
<span class="truncate max-w-[100px]"> <span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span> </span>
<Icon name="chevron-down" size="small" class="shrink-0" /> <Icon name="chevron-down" size="small" class="shrink-0" />
@@ -1956,12 +1959,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<ModelSelectorPopover <ModelSelectorPopover
triggerAs={Button} triggerAs={Button}
triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[140px]" }} triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }}
> >
<Show when={local.model.current()?.provider?.id}> <Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" /> <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show> </Show>
<span class="truncate max-w-[100px]"> <span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")} {local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span> </span>
<Icon name="chevron-down" size="small" class="shrink-0" /> <Icon name="chevron-down" size="small" class="shrink-0" />
@@ -2068,7 +2071,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
> >
<IconButton <IconButton
type="submit" type="submit"
disabled={!prompt.dirty() && !working()} disabled={!prompt.dirty() && !working() && commentCount() === 0}
icon={working() ? "stop" : "arrow-up"} icon={working() ? "stop" : "arrow-up"}
variant="primary" variant="primary"
class="h-6 w-4.5" class="h-6 w-4.5"
@@ -226,11 +226,11 @@ export const SettingsProviders: Component = () => {
</For> </For>
<div <div
class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none" class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section" data-component="custom-provider-section"
> >
<div class="flex flex-col min-w-0"> <div class="flex flex-col min-w-0">
<div class="flex items-center gap-x-3"> <div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" /> <ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">Custom provider</span> <span class="text-14-medium text-text-strong">Custom provider</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag> <Tag>{language.t("settings.providers.tag.custom")}</Tag>
+1
View File
@@ -146,6 +146,7 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false } const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) { if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode" url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword url.password = window.__OPENCODE__?.serverPassword
+72 -4
View File
@@ -33,6 +33,14 @@ type SessionTabs = {
type SessionView = { type SessionView = {
scroll: Record<string, SessionScroll> scroll: Record<string, SessionScroll>
reviewOpen?: string[] reviewOpen?: string[]
pendingMessage?: string
pendingMessageAt?: number
}
type TabHandoff = {
dir: string
id: string
at: number
} }
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean } export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
@@ -115,10 +123,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}, },
sessionTabs: {} as Record<string, SessionTabs>, sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>, sessionView: {} as Record<string, SessionView>,
handoff: {
tabs: undefined as TabHandoff | undefined,
},
}), }),
) )
const MAX_SESSION_KEYS = 50 const MAX_SESSION_KEYS = 50
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
const meta = { active: undefined as string | undefined, pruned: false } const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>() const used = new Map<string, number>()
@@ -411,6 +423,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return { return {
ready, ready,
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { dir, id, at: Date.now() })
},
clearTabs() {
if (!store.handoff?.tabs) return
setStore("handoff", "tabs", undefined)
},
},
projects: { projects: {
list, list,
open(directory: string) { open(directory: string) {
@@ -536,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("mobileSidebar", "opened", (x) => !x) setStore("mobileSidebar", "opened", (x) => !x)
}, },
}, },
pendingMessage: {
set(sessionKey: string, messageID: string) {
const at = Date.now()
touch(sessionKey)
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, {
scroll: {},
pendingMessage: messageID,
pendingMessageAt: at,
})
prune(meta.active ?? sessionKey)
return
}
setStore(
"sessionView",
sessionKey,
produce((draft) => {
draft.pendingMessage = messageID
draft.pendingMessageAt = at
}),
)
},
consume(sessionKey: string) {
const current = store.sessionView[sessionKey]
const message = current?.pendingMessage
const at = current?.pendingMessageAt
if (!message || !at) return
setStore(
"sessionView",
sessionKey,
produce((draft) => {
delete draft.pendingMessage
delete draft.pendingMessageAt
}),
)
if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
return message
},
},
view(sessionKey: string | Accessor<string>) { view(sessionKey: string | Accessor<string>) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
@@ -682,12 +747,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
if (!current) return if (!current) return
const all = current.all.filter((x) => x !== tab) const all = current.all.filter((x) => x !== tab)
if (current.active !== tab) {
setStore("sessionTabs", session, "all", all)
return
}
const index = current.all.findIndex((f) => f === tab)
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
batch(() => { batch(() => {
setStore("sessionTabs", session, "all", all) setStore("sessionTabs", session, "all", all)
if (current.active !== tab) return
const index = current.all.findIndex((f) => f === tab)
const next = all[index - 1] ?? all[0]
setStore("sessionTabs", session, "active", next) setStore("sessionTabs", session, "active", next)
}) })
}, },
+1 -2
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.session.new": "جلسة جديدة", "command.session.new": "جلسة جديدة",
"command.file.open": "فتح ملف", "command.file.open": "فتح ملف",
"command.file.open.description": "البحث في الملفات والأوامر",
"command.context.addSelection": "إضافة التحديد إلى السياق", "command.context.addSelection": "إضافة التحديد إلى السياق",
"command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي", "command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي",
"command.terminal.toggle": "تبديل المحطة الطرفية", "command.terminal.toggle": "تبديل المحطة الطرفية",
@@ -84,7 +83,7 @@ export const dict = {
"command.session.unshare": "إلغاء مشاركة الجلسة", "command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة", "command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"palette.search.placeholder": "البحث في الملفات والأوامر", "palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.empty": "لا توجد نتائج", "palette.empty": "لا توجد نتائج",
"palette.group.commands": "الأوامر", "palette.group.commands": "الأوامر",
"palette.group.files": "الملفات", "palette.group.files": "الملفات",
+1 -2
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.session.new": "Nova sessão", "command.session.new": "Nova sessão",
"command.file.open": "Abrir arquivo", "command.file.open": "Abrir arquivo",
"command.file.open.description": "Buscar arquivos e comandos",
"command.context.addSelection": "Adicionar seleção ao contexto", "command.context.addSelection": "Adicionar seleção ao contexto",
"command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual", "command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual",
"command.terminal.toggle": "Alternar terminal", "command.terminal.toggle": "Alternar terminal",
@@ -84,7 +83,7 @@ export const dict = {
"command.session.unshare": "Parar de compartilhar sessão", "command.session.unshare": "Parar de compartilhar sessão",
"command.session.unshare.description": "Parar de compartilhar esta sessão", "command.session.unshare.description": "Parar de compartilhar esta sessão",
"palette.search.placeholder": "Buscar arquivos e comandos", "palette.search.placeholder": "Buscar arquivos, comandos e sessões",
"palette.empty": "Nenhum resultado encontrado", "palette.empty": "Nenhum resultado encontrado",
"palette.group.commands": "Comandos", "palette.group.commands": "Comandos",
"palette.group.files": "Arquivos", "palette.group.files": "Arquivos",
+1 -2
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.session.new": "Ny session", "command.session.new": "Ny session",
"command.file.open": "Åbn fil", "command.file.open": "Åbn fil",
"command.file.open.description": "Søg i filer og kommandoer",
"command.context.addSelection": "Tilføj markering til kontekst", "command.context.addSelection": "Tilføj markering til kontekst",
"command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil", "command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil",
"command.terminal.toggle": "Skift terminal", "command.terminal.toggle": "Skift terminal",
@@ -84,7 +83,7 @@ export const dict = {
"command.session.unshare": "Stop deling af session", "command.session.unshare": "Stop deling af session",
"command.session.unshare.description": "Stop med at dele denne session", "command.session.unshare.description": "Stop med at dele denne session",
"palette.search.placeholder": "Søg i filer og kommandoer", "palette.search.placeholder": "Søg i filer, kommandoer og sessioner",
"palette.empty": "Ingen resultater fundet", "palette.empty": "Ingen resultater fundet",
"palette.group.commands": "Kommandoer", "palette.group.commands": "Kommandoer",
"palette.group.files": "Filer", "palette.group.files": "Filer",
+1 -2
View File
@@ -48,7 +48,6 @@ export const dict = {
"command.session.new": "Neue Sitzung", "command.session.new": "Neue Sitzung",
"command.file.open": "Datei öffnen", "command.file.open": "Datei öffnen",
"command.file.open.description": "Dateien und Befehle durchsuchen",
"command.context.addSelection": "Auswahl zum Kontext hinzufügen", "command.context.addSelection": "Auswahl zum Kontext hinzufügen",
"command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen", "command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen",
"command.terminal.toggle": "Terminal umschalten", "command.terminal.toggle": "Terminal umschalten",
@@ -88,7 +87,7 @@ export const dict = {
"command.session.unshare": "Teilen der Sitzung aufheben", "command.session.unshare": "Teilen der Sitzung aufheben",
"command.session.unshare.description": "Teilen dieser Sitzung beenden", "command.session.unshare.description": "Teilen dieser Sitzung beenden",
"palette.search.placeholder": "Dateien und Befehle durchsuchen", "palette.search.placeholder": "Dateien, Befehle und Sitzungen durchsuchen",
"palette.empty": "Keine Ergebnisse gefunden", "palette.empty": "Keine Ergebnisse gefunden",
"palette.group.commands": "Befehle", "palette.group.commands": "Befehle",
"palette.group.files": "Dateien", "palette.group.files": "Dateien",
+1 -2
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.session.new": "New session", "command.session.new": "New session",
"command.file.open": "Open file", "command.file.open": "Open file",
"command.file.open.description": "Search files and commands",
"command.tab.close": "Close tab", "command.tab.close": "Close tab",
"command.context.addSelection": "Add selection to context", "command.context.addSelection": "Add selection to context",
"command.context.addSelection.description": "Add selected lines from the current file", "command.context.addSelection.description": "Add selected lines from the current file",
@@ -86,7 +85,7 @@ export const dict = {
"command.session.unshare": "Unshare session", "command.session.unshare": "Unshare session",
"command.session.unshare.description": "Stop sharing this session", "command.session.unshare.description": "Stop sharing this session",
"palette.search.placeholder": "Search files and commands", "palette.search.placeholder": "Search files, commands, and sessions",
"palette.empty": "No results found", "palette.empty": "No results found",
"palette.group.commands": "Commands", "palette.group.commands": "Commands",
"palette.group.files": "Files", "palette.group.files": "Files",
+1 -2
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.session.new": "Nueva sesión", "command.session.new": "Nueva sesión",
"command.file.open": "Abrir archivo", "command.file.open": "Abrir archivo",
"command.file.open.description": "Buscar archivos y comandos",
"command.context.addSelection": "Añadir selección al contexto", "command.context.addSelection": "Añadir selección al contexto",
"command.context.addSelection.description": "Añadir las líneas seleccionadas del archivo actual", "command.context.addSelection.description": "Añadir las líneas seleccionadas del archivo actual",
"command.terminal.toggle": "Alternar terminal", "command.terminal.toggle": "Alternar terminal",
@@ -84,7 +83,7 @@ export const dict = {
"command.session.unshare": "Dejar de compartir sesión", "command.session.unshare": "Dejar de compartir sesión",
"command.session.unshare.description": "Dejar de compartir esta sesión", "command.session.unshare.description": "Dejar de compartir esta sesión",
"palette.search.placeholder": "Buscar archivos y comandos", "palette.search.placeholder": "Buscar archivos, comandos y sesiones",
"palette.empty": "No se encontraron resultados", "palette.empty": "No se encontraron resultados",
"palette.group.commands": "Comandos", "palette.group.commands": "Comandos",
"palette.group.files": "Archivos", "palette.group.files": "Archivos",
+1 -2
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.session.new": "Nouvelle session", "command.session.new": "Nouvelle session",
"command.file.open": "Ouvrir un fichier", "command.file.open": "Ouvrir un fichier",
"command.file.open.description": "Rechercher des fichiers et des commandes",
"command.context.addSelection": "Ajouter la sélection au contexte", "command.context.addSelection": "Ajouter la sélection au contexte",
"command.context.addSelection.description": "Ajouter les lignes sélectionnées du fichier actuel", "command.context.addSelection.description": "Ajouter les lignes sélectionnées du fichier actuel",
"command.terminal.toggle": "Basculer le terminal", "command.terminal.toggle": "Basculer le terminal",
@@ -84,7 +83,7 @@ export const dict = {
"command.session.unshare": "Ne plus partager la session", "command.session.unshare": "Ne plus partager la session",
"command.session.unshare.description": "Arrêter de partager cette session", "command.session.unshare.description": "Arrêter de partager cette session",
"palette.search.placeholder": "Rechercher des fichiers et des commandes", "palette.search.placeholder": "Rechercher des fichiers, des commandes et des sessions",
"palette.empty": "Aucun résultat trouvé", "palette.empty": "Aucun résultat trouvé",
"palette.group.commands": "Commandes", "palette.group.commands": "Commandes",
"palette.group.files": "Fichiers", "palette.group.files": "Fichiers",
+1 -2
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.session.new": "新しいセッション", "command.session.new": "新しいセッション",
"command.file.open": "ファイルを開く", "command.file.open": "ファイルを開く",
"command.file.open.description": "ファイルとコマンドを検索",
"command.context.addSelection": "選択範囲をコンテキストに追加", "command.context.addSelection": "選択範囲をコンテキストに追加",
"command.context.addSelection.description": "現在のファイルから選択した行を追加", "command.context.addSelection.description": "現在のファイルから選択した行を追加",
"command.terminal.toggle": "ターミナルの切り替え", "command.terminal.toggle": "ターミナルの切り替え",
@@ -84,7 +83,7 @@ export const dict = {
"command.session.unshare": "セッションの共有を停止", "command.session.unshare": "セッションの共有を停止",
"command.session.unshare.description": "このセッションの共有を停止", "command.session.unshare.description": "このセッションの共有を停止",
"palette.search.placeholder": "ファイルコマンドを検索", "palette.search.placeholder": "ファイルコマンド、セッションを検索",
"palette.empty": "結果が見つかりません", "palette.empty": "結果が見つかりません",
"palette.group.commands": "コマンド", "palette.group.commands": "コマンド",
"palette.group.files": "ファイル", "palette.group.files": "ファイル",
+1 -2
View File
@@ -48,7 +48,6 @@ export const dict = {
"command.session.new": "새 세션", "command.session.new": "새 세션",
"command.file.open": "파일 열기", "command.file.open": "파일 열기",
"command.file.open.description": "파일 및 명령어 검색",
"command.context.addSelection": "선택 영역을 컨텍스트에 추가", "command.context.addSelection": "선택 영역을 컨텍스트에 추가",
"command.context.addSelection.description": "현재 파일에서 선택한 줄을 추가", "command.context.addSelection.description": "현재 파일에서 선택한 줄을 추가",
"command.terminal.toggle": "터미널 토글", "command.terminal.toggle": "터미널 토글",
@@ -88,7 +87,7 @@ export const dict = {
"command.session.unshare": "세션 공유 중지", "command.session.unshare": "세션 공유 중지",
"command.session.unshare.description": "이 세션 공유 중지", "command.session.unshare.description": "이 세션 공유 중지",
"palette.search.placeholder": "파일 명령어 검색", "palette.search.placeholder": "파일, 명령어 및 세션 검색",
"palette.empty": "결과 없음", "palette.empty": "결과 없음",
"palette.group.commands": "명령어", "palette.group.commands": "명령어",
"palette.group.files": "파일", "palette.group.files": "파일",
+1 -2
View File
@@ -47,7 +47,6 @@ export const dict = {
"command.session.new": "Ny sesjon", "command.session.new": "Ny sesjon",
"command.file.open": "Åpne fil", "command.file.open": "Åpne fil",
"command.file.open.description": "Søk i filer og kommandoer",
"command.context.addSelection": "Legg til markering i kontekst", "command.context.addSelection": "Legg til markering i kontekst",
"command.context.addSelection.description": "Legg til valgte linjer fra gjeldende fil", "command.context.addSelection.description": "Legg til valgte linjer fra gjeldende fil",
"command.terminal.toggle": "Veksle terminal", "command.terminal.toggle": "Veksle terminal",
@@ -87,7 +86,7 @@ export const dict = {
"command.session.unshare": "Slutt å dele sesjon", "command.session.unshare": "Slutt å dele sesjon",
"command.session.unshare.description": "Slutt å dele denne sesjonen", "command.session.unshare.description": "Slutt å dele denne sesjonen",
"palette.search.placeholder": "Søk i filer og kommandoer", "palette.search.placeholder": "Søk i filer, kommandoer og sesjoner",
"palette.empty": "Ingen resultater funnet", "palette.empty": "Ingen resultater funnet",
"palette.group.commands": "Kommandoer", "palette.group.commands": "Kommandoer",
"palette.group.files": "Filer", "palette.group.files": "Filer",
+1 -2
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.session.new": "Nowa sesja", "command.session.new": "Nowa sesja",
"command.file.open": "Otwórz plik", "command.file.open": "Otwórz plik",
"command.file.open.description": "Szukaj plików i poleceń",
"command.context.addSelection": "Dodaj zaznaczenie do kontekstu", "command.context.addSelection": "Dodaj zaznaczenie do kontekstu",
"command.context.addSelection.description": "Dodaj zaznaczone linie z bieżącego pliku", "command.context.addSelection.description": "Dodaj zaznaczone linie z bieżącego pliku",
"command.terminal.toggle": "Przełącz terminal", "command.terminal.toggle": "Przełącz terminal",
@@ -84,7 +83,7 @@ export const dict = {
"command.session.unshare": "Przestań udostępniać sesję", "command.session.unshare": "Przestań udostępniać sesję",
"command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji", "command.session.unshare.description": "Zatrzymaj udostępnianie tej sesji",
"palette.search.placeholder": "Szukaj plików i poleceń", "palette.search.placeholder": "Szukaj plików, poleceń i sesji",
"palette.empty": "Brak wyników", "palette.empty": "Brak wyników",
"palette.group.commands": "Polecenia", "palette.group.commands": "Polecenia",
"palette.group.files": "Pliki", "palette.group.files": "Pliki",
+1 -2
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.session.new": "Новая сессия", "command.session.new": "Новая сессия",
"command.file.open": "Открыть файл", "command.file.open": "Открыть файл",
"command.file.open.description": "Поиск файлов и команд",
"command.context.addSelection": "Добавить выделение в контекст", "command.context.addSelection": "Добавить выделение в контекст",
"command.context.addSelection.description": "Добавить выбранные строки из текущего файла", "command.context.addSelection.description": "Добавить выбранные строки из текущего файла",
"command.terminal.toggle": "Переключить терминал", "command.terminal.toggle": "Переключить терминал",
@@ -84,7 +83,7 @@ export const dict = {
"command.session.unshare": "Отменить публикацию", "command.session.unshare": "Отменить публикацию",
"command.session.unshare.description": "Прекратить публикацию сессии", "command.session.unshare.description": "Прекратить публикацию сессии",
"palette.search.placeholder": "Поиск файлов и команд", "palette.search.placeholder": "Поиск файлов, команд и сессий",
"palette.empty": "Ничего не найдено", "palette.empty": "Ничего не найдено",
"palette.group.commands": "Команды", "palette.group.commands": "Команды",
"palette.group.files": "Файлы", "palette.group.files": "Файлы",
+1 -2
View File
@@ -44,7 +44,6 @@ export const dict = {
"command.session.new": "เซสชันใหม่", "command.session.new": "เซสชันใหม่",
"command.file.open": "เปิดไฟล์", "command.file.open": "เปิดไฟล์",
"command.file.open.description": "ค้นหาไฟล์และคำสั่ง",
"command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท", "command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท",
"command.context.addSelection.description": "เพิ่มบรรทัดที่เลือกจากไฟล์ปัจจุบัน", "command.context.addSelection.description": "เพิ่มบรรทัดที่เลือกจากไฟล์ปัจจุบัน",
"command.terminal.toggle": "สลับเทอร์มินัล", "command.terminal.toggle": "สลับเทอร์มินัล",
@@ -84,7 +83,7 @@ export const dict = {
"command.session.unshare": "ยกเลิกการแชร์เซสชัน", "command.session.unshare": "ยกเลิกการแชร์เซสชัน",
"command.session.unshare.description": "หยุดการแชร์เซสชันนี้", "command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
"palette.search.placeholder": "ค้นหาไฟล์และคำสั่ง", "palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน",
"palette.empty": "ไม่พบผลลัพธ์", "palette.empty": "ไม่พบผลลัพธ์",
"palette.group.commands": "คำสั่ง", "palette.group.commands": "คำสั่ง",
"palette.group.files": "ไฟล์", "palette.group.files": "ไฟล์",
+1 -2
View File
@@ -48,7 +48,6 @@ export const dict = {
"command.session.new": "新建会话", "command.session.new": "新建会话",
"command.file.open": "打开文件", "command.file.open": "打开文件",
"command.file.open.description": "搜索文件和命令",
"command.context.addSelection": "将所选内容添加到上下文", "command.context.addSelection": "将所选内容添加到上下文",
"command.context.addSelection.description": "添加当前文件中选中的行", "command.context.addSelection.description": "添加当前文件中选中的行",
"command.terminal.toggle": "切换终端", "command.terminal.toggle": "切换终端",
@@ -88,7 +87,7 @@ export const dict = {
"command.session.unshare": "取消分享会话", "command.session.unshare": "取消分享会话",
"command.session.unshare.description": "停止分享此会话", "command.session.unshare.description": "停止分享此会话",
"palette.search.placeholder": "搜索文件命令", "palette.search.placeholder": "搜索文件命令和会话",
"palette.empty": "未找到结果", "palette.empty": "未找到结果",
"palette.group.commands": "命令", "palette.group.commands": "命令",
"palette.group.files": "文件", "palette.group.files": "文件",
+1 -2
View File
@@ -48,7 +48,6 @@ export const dict = {
"command.session.new": "新增工作階段", "command.session.new": "新增工作階段",
"command.file.open": "開啟檔案", "command.file.open": "開啟檔案",
"command.file.open.description": "搜尋檔案和命令",
"command.context.addSelection": "將選取內容加入上下文", "command.context.addSelection": "將選取內容加入上下文",
"command.context.addSelection.description": "加入目前檔案中選取的行", "command.context.addSelection.description": "加入目前檔案中選取的行",
"command.terminal.toggle": "切換終端機", "command.terminal.toggle": "切換終端機",
@@ -88,7 +87,7 @@ export const dict = {
"command.session.unshare": "取消分享工作階段", "command.session.unshare": "取消分享工作階段",
"command.session.unshare.description": "停止分享此工作階段", "command.session.unshare.description": "停止分享此工作階段",
"palette.search.placeholder": "搜尋檔案命令", "palette.search.placeholder": "搜尋檔案命令和工作階段",
"palette.empty": "找不到結果", "palette.empty": "找不到結果",
"palette.group.commands": "命令", "palette.group.commands": "命令",
"palette.group.files": "檔案", "palette.group.files": "檔案",
+25 -362
View File
@@ -27,7 +27,6 @@ import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { InlineInput } from "@opencode-ai/ui/inline-input" import { InlineInput } from "@opencode-ai/ui/inline-input"
import { List, type ListRef } from "@opencode-ai/ui/list"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { HoverCard } from "@opencode-ai/ui/hover-card" import { HoverCard } from "@opencode-ai/ui/hover-card"
import { MessageNav } from "@opencode-ai/ui/message-nav" import { MessageNav } from "@opencode-ai/ui/message-nav"
@@ -1001,69 +1000,6 @@ export default function Layout(props: ParentProps) {
} }
} }
async function deleteSession(session: Session) {
const [store, setStore] = globalSync.child(session.directory)
const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
const result = await globalSDK.client.session
.delete({ directory: session.directory, sessionID: session.id })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return
setStore(
produce((draft) => {
const removed = new Set<string>([session.id])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [session.id]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
if (session.id === params.id) {
if (nextSession) {
navigate(`/${params.dir}/session/${nextSession.id}`)
} else {
navigate(`/${params.dir}/session`)
}
}
}
command.register(() => { command.register(() => {
const commands: CommandOption[] = [ const commands: CommandOption[] = [
{ {
@@ -1317,15 +1253,6 @@ export default function Layout(props: ParentProps) {
globalSync.project.meta(project.worktree, { name }) globalSync.project.meta(project.worktree, { name })
} }
async function renameSession(session: Session, next: string) {
if (next === session.title) return
await globalSDK.client.session.update({
directory: session.directory,
sessionID: session.id,
title: next,
})
}
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => { const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory) const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
if (current === next) return if (current === next) return
@@ -1476,33 +1403,6 @@ export default function Layout(props: ParentProps) {
}) })
} }
function DialogDeleteSession(props: { session: Session }) {
const handleDelete = async () => {
await deleteSession(props.session)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: props.session.title })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
function DialogDeleteWorkspace(props: { root: string; directory: string }) { function DialogDeleteWorkspace(props: { root: string; directory: string }) {
const name = createMemo(() => getFilename(props.directory)) const name = createMemo(() => getFilename(props.directory))
const [data, setData] = createStore({ const [data, setData] = createStore({
@@ -1856,10 +1756,6 @@ export default function Layout(props: ParentProps) {
const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded()) const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id) const isActive = createMemo(() => props.session.id === params.id)
const [menu, setMenu] = createStore({
open: false,
pendingRename: false,
})
const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined } const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
const cancelHoverPrefetch = () => { const cancelHoverPrefetch = () => {
@@ -1886,7 +1782,7 @@ export default function Layout(props: ParentProps) {
const item = ( const item = (
<A <A
href={`${props.slug}/session/${props.session.id}`} href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menu.open ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerEnter={scheduleHoverPrefetch} onPointerEnter={scheduleHoverPrefetch}
onPointerLeave={cancelHoverPrefetch} onPointerLeave={cancelHoverPrefetch}
onMouseEnter={scheduleHoverPrefetch} onMouseEnter={scheduleHoverPrefetch}
@@ -1918,14 +1814,9 @@ export default function Layout(props: ParentProps) {
</Match> </Match>
</Switch> </Switch>
</div> </div>
<InlineEditor <span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
id={`session:${props.session.id}`} {props.session.title}
value={() => props.session.title} </span>
onSave={(next) => renameSession(props.session, next)}
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
stopPropagation
/>
<Show when={props.session.summary}> <Show when={props.session.summary}>
{(summary) => ( {(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden"> <div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
@@ -1973,7 +1864,10 @@ export default function Layout(props: ParentProps) {
getLabel={messageLabel} getLabel={messageLabel}
onMessageSelect={(message) => { onMessageSelect={(message) => {
if (!isActive()) { if (!isActive()) {
sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`) layout.pendingMessage.set(
`${base64Encode(props.session.directory)}/${props.session.id}`,
message.id,
)
navigate(`${props.slug}/session/${props.session.id}`) navigate(`${props.slug}/session/${props.session.id}`)
return return
} }
@@ -1990,49 +1884,25 @@ export default function Layout(props: ParentProps) {
<div <div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`} class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
classList={{ classList={{
"opacity-100 pointer-events-auto": menu.open, "opacity-100 pointer-events-auto": !!props.mobile,
"opacity-0 pointer-events-none": !menu.open, "opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true, "group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true, "group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}} }}
> >
<DropdownMenu modal={!sidebarHovering()} open={menu.open} onOpenChange={(open) => setMenu("open", open)}> <Tooltip value={language.t("common.archive")} placement="top">
<Tooltip value={language.t("common.moreOptions")} placement="top"> <IconButton
<DropdownMenu.Trigger icon="archive"
as={IconButton} variant="ghost"
icon="dot-grid" class="size-6 rounded-md"
variant="ghost" aria-label={language.t("common.archive")}
class="size-6 rounded-md data-[expanded]:bg-surface-base-active" onClick={(event) => {
aria-label={language.t("common.moreOptions")} event.preventDefault()
/> event.stopPropagation()
</Tooltip> void archiveSession(props.session)
<DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}> }}
<DropdownMenu.Content />
onCloseAutoFocus={(event) => { </Tooltip>
if (!menu.pendingRename) return
event.preventDefault()
setMenu("pendingRename", false)
openEditor(`session:${props.session.id}`, props.session.title)
}}
>
<DropdownMenu.Item
onSelect={() => {
setMenu("pendingRename", true)
setMenu("open", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => archiveSession(props.session)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession session={props.session} />)}>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div> </div>
</div> </div>
) )
@@ -2706,14 +2576,6 @@ export default function Layout(props: ParentProps) {
} }
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => { const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
type SearchItem = {
id: string
title: string
directory: string
label: string
archived?: number
}
const projectName = createMemo(() => { const projectName = createMemo(() => {
const project = panelProps.project const project = panelProps.project
if (!project) return "" if (!project) return ""
@@ -2729,107 +2591,6 @@ export default function Layout(props: ParentProps) {
}) })
const homedir = createMemo(() => globalSync.data.path.home) const homedir = createMemo(() => globalSync.data.path.home)
const [search, setSearch] = createStore({
value: "",
})
const searching = createMemo(() => search.value.trim().length > 0)
let searchRef: HTMLInputElement | undefined
let listRef: ListRef | undefined
const token = { value: 0 }
let inflight: Promise<SearchItem[]> | undefined
let all: SearchItem[] | undefined
const reset = () => {
token.value += 1
inflight = undefined
all = undefined
setSearch({ value: "" })
listRef = undefined
}
const open = (item: SearchItem | undefined) => {
if (!item) return
const href = `/${base64Encode(item.directory)}/session/${item.id}`
if (!layout.sidebar.opened()) {
setState("hoverSession", undefined)
setState("hoverProject", undefined)
}
reset()
navigate(href)
layout.mobileSidebar.hide()
}
const items = (filter: string) => {
const query = filter.trim()
if (!query) {
token.value += 1
inflight = undefined
all = undefined
return [] as SearchItem[]
}
const project = panelProps.project
if (!project) return [] as SearchItem[]
if (all) return all
if (inflight) return inflight
const current = token.value
const dirs = workspaceIds(project)
inflight = Promise.all(
dirs.map((input) => {
const directory = workspaceKey(input)
const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
const kind =
directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
const label = `${kind} : ${name}`
return globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? language.t("command.session.new"),
directory,
label,
archived: s.time?.archived,
})),
)
.catch(() => [] as SearchItem[])
}),
)
.then((results) => {
if (token.value !== current) return [] as SearchItem[]
const seen = new Set<string>()
const next = results.flat().filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
all = next
return next
})
.catch(() => [] as SearchItem[])
.finally(() => {
inflight = undefined
})
return inflight
}
createEffect(
on(
() => panelProps.project?.worktree,
() => reset(),
{ defer: true },
),
)
return ( return (
<div <div
classList={{ classList={{
@@ -2918,105 +2679,7 @@ export default function Layout(props: ParentProps) {
</div> </div>
</div> </div>
<div class="shrink-0 px-2 pt-2"> <div class="flex-1 min-h-0 flex flex-col">
<div
class="flex items-center gap-2 p-2 rounded-md bg-surface-base shadow-xs-border-base focus-within:shadow-xs-border-select"
onPointerDown={(event) => {
const target = event.target
if (!(target instanceof Element)) return
if (target.closest("input, textarea, [contenteditable='true']")) return
searchRef?.focus()
}}
>
<Icon name="magnifying-glass" />
<InlineInput
ref={(el) => {
searchRef = el
}}
class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak"
style={{ "box-shadow": "none" }}
value={search.value}
onInput={(event) => setSearch("value", event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault()
setSearch("value", "")
queueMicrotask(() => searchRef?.focus())
return
}
if (!searching()) return
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") {
const ref = listRef
if (!ref) return
event.stopPropagation()
ref.onKeyDown(event)
return
}
if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
if (event.key === "n" || event.key === "p") {
const ref = listRef
if (!ref) return
event.stopPropagation()
ref.onKeyDown(event)
}
}
}}
placeholder={language.t("session.header.search.placeholder", { project: projectName() })}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
/>
<Show when={search.value}>
<IconButton
icon="circle-x"
variant="ghost"
class="size-5"
aria-label={language.t("common.close")}
onClick={() => {
setSearch("value", "")
queueMicrotask(() => searchRef?.focus())
}}
/>
</Show>
</div>
</div>
<Show when={searching()}>
<List
class="flex-1 min-h-0 pb-2 pt-2 !px-2 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
items={items}
filter={search.value}
filterKeys={["title", "label", "id"]}
key={(item) => `${item.directory}:${item.id}`}
onSelect={open}
ref={(ref) => {
listRef = ref
}}
>
{(item) => (
<div class="flex flex-col gap-0.5 min-w-0 pr-2 text-left">
<span
class="text-14-medium text-text-strong truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.title}
</span>
<span
class="text-12-regular text-text-weak truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.label}
</span>
</div>
)}
</List>
</Show>
<div class="flex-1 min-h-0 flex flex-col" classList={{ hidden: searching() }}>
<Show <Show
when={workspacesEnabled()} when={workspacesEnabled()}
fallback={ fallback={
@@ -3100,7 +2763,7 @@ export default function Layout(props: ParentProps) {
<div <div
class="shrink-0 px-2 py-3 border-t border-border-weak-base" class="shrink-0 px-2 py-3 border-t border-border-weak-base"
classList={{ classList={{
hidden: searching() || !(providers.all().length > 0 && providers.paid().length === 0), hidden: !(providers.all().length > 0 && providers.paid().length === 0),
}} }}
> >
<div class="rounded-md bg-background-base shadow-xs-border-base"> <div class="rounded-md bg-background-base shadow-xs-border-base">
+460 -75
View File
@@ -16,13 +16,16 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file" import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input" import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage" import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { useCodeComponent } from "@opencode-ai/ui/context/code" import { useCodeComponent } from "@opencode-ai/ui/context/code"
@@ -73,10 +76,31 @@ import { same } from "@/utils/same"
type DiffStyle = "unified" | "split" type DiffStyle = "unified" | "split"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const HANDOFF_MAX = 40
const handoff = { const handoff = {
prompt: "", session: new Map<string, HandoffSession>(),
terminals: [] as string[], terminal: new Map<string, string[]>(),
files: {} as Record<string, SelectedLineRange | null>, }
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
map.set(key, value)
while (map.size > HANDOFF_MAX) {
const first = map.keys().next().value
if (first === undefined) return
map.delete(first)
}
}
const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
touch(handoff.session, key, { ...prev, ...patch })
} }
interface SessionReviewTabProps { interface SessionReviewTabProps {
@@ -280,9 +304,47 @@ export default function Page() {
.finally(() => setUi("responding", false)) .finally(() => setUi("responding", false))
} }
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const workspaceKey = createMemo(() => params.dir ?? "")
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
const tabs = createMemo(() => layout.tabs(sessionKey)) const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey)) const view = createMemo(() => layout.view(sessionKey))
createEffect(
on(
() => params.id,
(id, prev) => {
if (!id) return
if (prev) return
const pending = layout.handoff.tabs()
if (!pending) return
if (Date.now() - pending.at > 60_000) {
layout.handoff.clearTabs()
return
}
if (pending.id !== id) return
layout.handoff.clearTabs()
if (pending.dir !== (params.dir ?? "")) return
const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return
const current = tabs().tabs()
if (current.all.length > 0 || current.active) return
const all = normalizeTabs(from.all)
const active = from.active ? normalizeTab(from.active) : undefined
tabs().setAll(all)
tabs().setActive(active && all.includes(active) ? active : all[0])
workspaceTabs().setAll([])
workspaceTabs().setActive(undefined)
},
{ defer: true },
),
)
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
createEffect( createEffect(
on( on(
@@ -398,6 +460,213 @@ export default function Page() {
if (!id) return false if (!id) return false
return sync.session.history.loading(id) return sync.session.history.loading(id)
}) })
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
() => params.id,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!params.id) return
setTitle({ editing: true, draft: info()?.title ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const sessionID = params.id
if (!sessionID) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (info()?.title ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
async function archiveSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
if (params.id !== sessionID) return
if (session.parentID) {
navigate(`/${params.dir}/session/${session.parentID}`)
return
}
if (nextSession) {
navigate(`/${params.dir}/session/${nextSession.id}`)
return
}
navigate(`/${params.dir}/session`)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
async function deleteSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
if (params.id !== sessionID) return true
if (session.parentID) {
navigate(`/${params.dir}/session/${session.parentID}`)
return true
}
if (nextSession) {
navigate(`/${params.dir}/session/${nextSession.id}`)
return true
}
navigate(`/${params.dir}/session`)
return true
}
function DialogDeleteSession(props: { sessionID: string }) {
const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: title() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
const emptyUserMessages: UserMessage[] = [] const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo( const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[], () => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -545,8 +814,10 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
createEffect(() => { createEffect(() => {
if (!params.id) return sdk.directory
sync.session.sync(params.id) const id = params.id
if (!id) return
sync.session.sync(id)
}) })
createEffect(() => { createEffect(() => {
@@ -614,10 +885,22 @@ export default function Page() {
createEffect( createEffect(
on( on(
() => params.id, sessionKey,
() => { () => {
setStore("messageId", undefined) setStore("messageId", undefined)
setStore("expanded", {}) setStore("expanded", {})
setUi("autoCreated", false)
},
{ defer: true },
),
)
createEffect(
on(
() => params.dir,
(dir) => {
if (!dir) return
setStore("newSessionWorktree", "main")
}, },
{ defer: true }, { defer: true },
), ),
@@ -681,7 +964,7 @@ export default function Page() {
{ {
id: "file.open", id: "file.open",
title: language.t("command.file.open"), title: language.t("command.file.open"),
description: language.t("command.file.open.description"), description: language.t("palette.search.placeholder"),
category: language.t("command.category.file"), category: language.t("command.category.file"),
keybind: "mod+p", keybind: "mod+p",
slash: "open", slash: "open",
@@ -1125,12 +1408,15 @@ export default function Page() {
activeDiff: undefined as string | undefined, activeDiff: undefined as string | undefined,
}) })
const reviewScroll = () => tree.reviewScroll createEffect(
const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value) on(
const pendingDiff = () => tree.pendingDiff sessionKey,
const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value) () => {
const activeDiff = () => tree.activeDiff setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value) },
{ defer: true },
),
)
const showAllFiles = () => { const showAllFiles = () => {
if (fileTreeTab() !== "changes") return if (fileTreeTab() !== "changes") return
@@ -1151,8 +1437,8 @@ export default function Page() {
view={view} view={view}
diffStyle={layout.review.diffStyle()} diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle} onDiffStyleChange={layout.review.setDiffStyle}
onScrollRef={setReviewScroll} onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={activeDiff()} focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()} comments={comments.all()}
focusedComment={comments.focus()} focusedComment={comments.focus()}
@@ -1202,7 +1488,7 @@ export default function Page() {
} }
const reviewDiffTop = (path: string) => { const reviewDiffTop = (path: string) => {
const root = reviewScroll() const root = tree.reviewScroll
if (!root) return if (!root) return
const id = reviewDiffId(path) const id = reviewDiffId(path)
@@ -1218,7 +1504,7 @@ export default function Page() {
} }
const scrollToReviewDiff = (path: string) => { const scrollToReviewDiff = (path: string) => {
const root = reviewScroll() const root = tree.reviewScroll
if (!root) return false if (!root) return false
const top = reviewDiffTop(path) const top = reviewDiffTop(path)
@@ -1232,24 +1518,23 @@ export default function Page() {
const focusReviewDiff = (path: string) => { const focusReviewDiff = (path: string) => {
const current = view().review.open() ?? [] const current = view().review.open() ?? []
if (!current.includes(path)) view().review.setOpen([...current, path]) if (!current.includes(path)) view().review.setOpen([...current, path])
setActiveDiff(path) setTree({ activeDiff: path, pendingDiff: path })
setPendingDiff(path)
} }
createEffect(() => { createEffect(() => {
const pending = pendingDiff() const pending = tree.pendingDiff
if (!pending) return if (!pending) return
if (!reviewScroll()) return if (!tree.reviewScroll) return
if (!diffsReady()) return if (!diffsReady()) return
const attempt = (count: number) => { const attempt = (count: number) => {
if (pendingDiff() !== pending) return if (tree.pendingDiff !== pending) return
if (count > 60) { if (count > 60) {
setPendingDiff(undefined) setTree("pendingDiff", undefined)
return return
} }
const root = reviewScroll() const root = tree.reviewScroll
if (!root) { if (!root) {
requestAnimationFrame(() => attempt(count + 1)) requestAnimationFrame(() => attempt(count + 1))
return return
@@ -1267,7 +1552,7 @@ export default function Page() {
} }
if (Math.abs(root.scrollTop - top) <= 1) { if (Math.abs(root.scrollTop - top) <= 1) {
setPendingDiff(undefined) setTree("pendingDiff", undefined)
return return
} }
@@ -1310,13 +1595,17 @@ export default function Page() {
void sync.session.diff(id) void sync.session.diff(id)
}) })
let treeDir: string | undefined
createEffect(() => { createEffect(() => {
const dir = sdk.directory
if (!isDesktop()) return if (!isDesktop()) return
if (!layout.fileTree.opened()) return if (!layout.fileTree.opened()) return
if (sync.status === "loading") return if (sync.status === "loading") return
fileTreeTab() fileTreeTab()
void file.tree.list("") const refresh = treeDir !== dir
treeDir = dir
void (refresh ? file.tree.refresh("") : file.tree.list(""))
}) })
const autoScroll = createAutoScroll({ const autoScroll = createAutoScroll({
@@ -1351,6 +1640,18 @@ export default function Page() {
let scrollSpyFrame: number | undefined let scrollSpyFrame: number | undefined
let scrollSpyTarget: HTMLDivElement | undefined let scrollSpyTarget: HTMLDivElement | undefined
createEffect(
on(
sessionKey,
() => {
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
scrollSpyFrame = undefined
scrollSpyTarget = undefined
},
{ defer: true },
),
)
const anchor = (id: string) => `message-${id}` const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => { const setScrollRef = (el: HTMLDivElement | undefined) => {
@@ -1465,20 +1766,14 @@ export default function Page() {
window.history.replaceState(null, "", `#${anchor(id)}`) window.history.replaceState(null, "", `#${anchor(id)}`)
} }
createEffect(() => { createEffect(
const sessionID = params.id on(sessionKey, (key) => {
if (!sessionID) return if (!params.id) return
const raw = sessionStorage.getItem("opencode.pendingMessage") const messageID = layout.pendingMessage.consume(key)
if (!raw) return if (!messageID) return
const parts = raw.split("|") setUi("pendingMessage", messageID)
const pendingSessionID = parts[0] }),
const messageID = parts[1] )
if (!pendingSessionID || !messageID) return
if (pendingSessionID !== sessionID) return
sessionStorage.removeItem("opencode.pendingMessage")
setUi("pendingMessage", messageID)
})
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller const root = scroller
@@ -1692,7 +1987,7 @@ export default function Page() {
createEffect(() => { createEffect(() => {
if (!prompt.ready()) return if (!prompt.ready()) return
handoff.prompt = previewPrompt() setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
}) })
createEffect(() => { createEffect(() => {
@@ -1712,20 +2007,22 @@ export default function Page() {
return language.t("terminal.title") return language.t("terminal.title")
} }
handoff.terminals = terminal.all().map(label) touch(handoff.terminal, params.dir!, terminal.all().map(label))
}) })
createEffect(() => { createEffect(() => {
if (!file.ready()) return if (!file.ready()) return
handoff.files = Object.fromEntries( setSessionHandoff(sessionKey(), {
tabs() files: Object.fromEntries(
.all() tabs()
.flatMap((tab) => { .all()
const path = file.pathFromTab(tab) .flatMap((tab) => {
if (!path) return [] const path = file.pathFromTab(tab)
return [[path, file.selectedLines(path) ?? null] as const] if (!path) return []
}), return [[path, file.selectedLines(path) ?? null] as const]
) }),
),
})
}) })
onCleanup(() => { onCleanup(() => {
@@ -1771,7 +2068,7 @@ export default function Page() {
<div <div
classList={{ classList={{
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true, "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 pt-6 md:pt-3": true, "flex-1 pt-2 md:pt-3": true,
"md:flex-none": layout.fileTree.opened(), "md:flex-none": layout.fileTree.opened(),
}} }}
style={{ style={{
@@ -1801,7 +2098,7 @@ export default function Page() {
diffs={diffs} diffs={diffs}
view={view} view={view}
diffStyle="unified" diffStyle="unified"
focusedFile={activeDiff()} focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })} onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()} comments={comments.all()}
focusedComment={comments.focus()} focusedComment={comments.focus()}
@@ -1950,24 +2247,112 @@ export default function Page() {
"sticky top-0 z-30 bg-background-stronger": true, "sticky top-0 z-30 bg-background-stronger": true,
"w-full": true, "w-full": true,
"px-4 md:px-6": true, "px-4 md:px-6": true,
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]":
centered(), centered(),
}} }}
> >
<div class="h-10 flex items-center gap-1"> <div class="h-10 w-full flex items-center justify-between gap-2">
<Show when={info()?.parentID}> <div class="flex items-center gap-1 min-w-0 flex-1">
<IconButton <Show when={info()?.parentID}>
tabIndex={-1} <IconButton
icon="arrow-left" tabIndex={-1}
variant="ghost" icon="arrow-left"
onClick={() => { variant="ghost"
navigate(`/${params.dir}/session/${info()?.parentID}`) onClick={() => {
}} navigate(`/${params.dir}/session/${info()?.parentID}`)
aria-label={language.t("common.goBack")} }}
/> aria-label={language.t("common.goBack")}
</Show> />
<Show when={info()?.title}> </Show>
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1> <Show when={info()?.title || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-16-medium text-text-strong truncate min-w-0"
onDblClick={openTitleEditor}
>
{info()?.title}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-16-medium text-text-strong grow-1 min-w-0"
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={() => closeTitleEditor()}
/>
</Show>
</Show>
</div>
<Show when={params.id}>
{(id) => (
<div class="shrink-0 flex items-center">
<DropdownMenu
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<Tooltip value={language.t("common.moreOptions")} placement="top">
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingRename: true, menuOpen: false })
}}
>
<DropdownMenu.ItemLabel>
{language.t("common.rename")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>
{language.t("common.archive")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>
{language.t("common.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show> </Show>
</div> </div>
</div> </div>
@@ -1976,10 +2361,10 @@ export default function Page() {
<div <div
ref={autoScroll.contentRef} ref={autoScroll.contentRef}
role="log" role="log"
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
classList={{ classList={{
"w-full": true, "w-full": true,
"md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto": "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]":
centered(), centered(),
"mt-0.5": centered(), "mt-0.5": centered(),
"mt-0": !centered(), "mt-0": !centered(),
@@ -2147,7 +2532,7 @@ export default function Page() {
when={prompt.ready()} when={prompt.ready()}
fallback={ fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none"> <div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoff.prompt || language.t("prompt.loading")} {handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
</div> </div>
} }
> >
@@ -2398,7 +2783,7 @@ export default function Page() {
const p = path() const p = path()
if (!p) return null if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null if (file.ready()) return file.selectedLines(p) ?? null
return handoff.files[p] ?? null return handoff.session.get(sessionKey())?.files[p] ?? null
}) })
let wrap: HTMLDivElement | undefined let wrap: HTMLDivElement | undefined
@@ -2892,7 +3277,7 @@ export default function Page() {
allowed={diffFiles()} allowed={diffFiles()}
kinds={kinds()} kinds={kinds()}
draggable={false} draggable={false}
active={activeDiff()} active={tree.activeDiff}
onFileClick={(node) => focusReviewDiff(node.path)} onFileClick={(node) => focusReviewDiff(node.path)}
/> />
</Show> </Show>
@@ -2952,7 +3337,7 @@ export default function Page() {
fallback={ fallback={
<div class="flex flex-col h-full pointer-events-none"> <div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden"> <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
<For each={handoff.terminals}> <For each={handoff.terminal.get(params.dir!) ?? []}>
{(title) => ( {(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40"> <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title} {title}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.1.49", "version": "1.1.51",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -79,7 +79,7 @@ export async function handler(
const dataDumper = createDataDumper(sessionId, requestId, projectId) const dataDumper = createDataDumper(sessionId, requestId, projectId)
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient) const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
const isTrial = await trialLimiter?.isTrial() const isTrial = await trialLimiter?.isTrial()
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip) const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip, input.request.headers)
await rateLimiter?.check() await rateLimiter?.check()
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId) const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
const stickyProvider = await stickyTracker?.get() const stickyProvider = await stickyTracker?.get()
@@ -4,9 +4,11 @@ import { RateLimitError } from "./error"
import { logger } from "./logger" import { logger } from "./logger"
import { ZenData } from "@opencode-ai/console-core/model.js" import { ZenData } from "@opencode-ai/console-core/model.js"
export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string) { export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string, headers: Headers) {
if (!limit) return if (!limit) return
const limitValue = limit.checkHeader && !headers.get(limit.checkHeader) ? limit.fallbackValue! : limit.value
const ip = !rawIp.length ? "unknown" : rawIp const ip = !rawIp.length ? "unknown" : rawIp
const now = Date.now() const now = Date.now()
const intervals = const intervals =
@@ -32,7 +34,7 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
) )
const total = rows.reduce((sum, r) => sum + r.count, 0) const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`) logger.debug(`rate limit total: ${total}`)
if (total >= limit.value) throw new RateLimitError(`Rate limit exceeded. Please try again later.`) if (total >= limitValue) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
}, },
} }
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.1.49", "version": "1.1.51",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+2
View File
@@ -21,6 +21,8 @@ export namespace ZenData {
const RateLimitSchema = z.object({ const RateLimitSchema = z.object({
period: z.enum(["day", "rolling"]), period: z.enum(["day", "rolling"]),
value: z.number().int(), value: z.number().int(),
checkHeader: z.string().optional(),
fallbackValue: z.number().int().optional(),
}) })
export type Format = z.infer<typeof FormatSchema> export type Format = z.infer<typeof FormatSchema>
export type Trial = z.infer<typeof TrialSchema> export type Trial = z.infer<typeof TrialSchema>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.1.49", "version": "1.1.51",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.1.49", "version": "1.1.51",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.1.49", "version": "1.1.51",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
@@ -42,6 +42,13 @@
"active": true, "active": true,
"targets": ["deb", "rpm", "dmg", "nsis", "app"], "targets": ["deb", "rpm", "dmg", "nsis", "app"],
"externalBin": ["sidecars/opencode-cli"], "externalBin": ["sidecars/opencode-cli"],
"linux": {
"rpm": {
"compression": {
"type": "none"
}
}
},
"macOS": { "macOS": {
"entitlements": "./entitlements.plist" "entitlements": "./entitlements.plist"
}, },
@@ -21,6 +21,11 @@
"files": { "files": {
"/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml" "/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml"
} }
},
"rpm": {
"compression": {
"type": "none"
}
} }
} }
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.1.49", "version": "1.1.51",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.1.49" version = "1.1.51"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-darwin-arm64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-darwin-arm64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-darwin-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-darwin-x64.zip"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-linux-arm64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-linux-arm64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-linux-x64.tar.gz" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-linux-x64.tar.gz"
cmd = "./opencode" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-windows-x64.zip" archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-windows-x64.zip"
cmd = "./opencode.exe" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.1.49", "version": "1.1.51",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+4 -2
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.1.49", "version": "1.1.51",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -20,6 +20,7 @@
"bin": { "bin": {
"opencode": "./bin/opencode" "opencode": "./bin/opencode"
}, },
"randomField": "this-is-a-random-value-12345",
"exports": { "exports": {
"./*": "./src/*.ts" "./*": "./src/*.ts"
}, },
@@ -91,8 +92,9 @@
"@standard-schema/spec": "1.0.0", "@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62", "@zip.js/zip.js": "2.7.62",
"ai": "catalog:", "ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0", "bonjour-service": "1.3.0",
"bun-pty": "0.4.4", "bun-pty": "0.4.8",
"chokidar": "4.0.3", "chokidar": "4.0.3",
"clipboardy": "4.0.0", "clipboardy": "4.0.0",
"decimal.js": "10.5.0", "decimal.js": "10.5.0",
+2
View File
@@ -18,6 +18,7 @@ const version = Object.values(binaries)[0]
await $`mkdir -p ./dist/${pkg.name}` await $`mkdir -p ./dist/${pkg.name}`
await $`cp -r ./bin ./dist/${pkg.name}/bin` await $`cp -r ./bin ./dist/${pkg.name}/bin`
await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs` await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
await Bun.file(`./dist/${pkg.name}/package.json`).write( await Bun.file(`./dist/${pkg.name}/package.json`).write(
JSON.stringify( JSON.stringify(
@@ -30,6 +31,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs", postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
}, },
version: version, version: version,
license: pkg.license,
optionalDependencies: binaries, optionalDependencies: binaries,
}, },
null, null,
+3
View File
@@ -18,6 +18,7 @@ import { mergeDeep, pipe, sortBy, values } from "remeda"
import { Global } from "@/global" import { Global } from "@/global"
import path from "path" import path from "path"
import { Plugin } from "@/plugin" import { Plugin } from "@/plugin"
import { Skill } from "../skill"
export namespace Agent { export namespace Agent {
export const Info = z export const Info = z
@@ -50,12 +51,14 @@ export namespace Agent {
const state = Instance.state(async () => { const state = Instance.state(async () => {
const cfg = await Config.get() const cfg = await Config.get()
const skillDirs = await Skill.dirs()
const defaults = PermissionNext.fromConfig({ const defaults = PermissionNext.fromConfig({
"*": "allow", "*": "allow",
doom_loop: "ask", doom_loop: "ask",
external_directory: { external_directory: {
"*": "ask", "*": "ask",
[Truncate.GLOB]: "allow", [Truncate.GLOB]: "allow",
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
}, },
question: "deny", question: "deny",
plan_enter: "deny", plan_enter: "deny",
+12 -1
View File
@@ -6,6 +6,7 @@ import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { readableStreamToText } from "bun" import { readableStreamToText } from "bun"
import { Lock } from "../util/lock" import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
export namespace BunProc { export namespace BunProc {
const log = Log.create({ service: "bun" }) const log = Log.create({ service: "bun" })
@@ -73,7 +74,17 @@ export namespace BunProc {
const dependencies = parsed.dependencies ?? {} const dependencies = parsed.dependencies ?? {}
if (!parsed.dependencies) parsed.dependencies = dependencies if (!parsed.dependencies) parsed.dependencies = dependencies
const modExists = await Filesystem.exists(mod) const modExists = await Filesystem.exists(mod)
if (dependencies[pkg] === version && modExists) return mod const cachedVersion = dependencies[pkg]
if (!modExists || !cachedVersion) {
// continue to install
} else if (version !== "latest" && cachedVersion === version) {
return mod
} else if (version === "latest") {
const isOutdated = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!isOutdated) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
}
const proxied = !!( const proxied = !!(
process.env.HTTP_PROXY || process.env.HTTP_PROXY ||
+48
View File
@@ -0,0 +1,48 @@
import { readableStreamToText, semver } from "bun"
import { Log } from "../util/log"
export namespace PackageRegistry {
const log = Log.create({ service: "bun" })
function which() {
return process.execPath
}
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
const result = Bun.spawn([which(), "info", pkg, field], {
cwd,
stdout: "pipe",
stderr: "pipe",
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
const code = await result.exited
const stdout = result.stdout ? await readableStreamToText(result.stdout) : ""
const stderr = result.stderr ? await readableStreamToText(result.stderr) : ""
if (code !== 0) {
log.warn("bun info failed", { pkg, field, code, stderr })
return null
}
const value = stdout.trim()
if (!value) return null
return value
}
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.order(cachedVersion, latestVersion) === -1
}
}
+370 -181
View File
@@ -4,25 +4,211 @@ import { UI } from "../ui"
import { cmd } from "./cmd" import { cmd } from "./cmd"
import { Flag } from "../../flag/flag" import { Flag } from "../../flag/flag"
import { bootstrap } from "../bootstrap" import { bootstrap } from "../bootstrap"
import { Command } from "../../command"
import { EOL } from "os" import { EOL } from "os"
import { select } from "@clack/prompts" import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
import { Server } from "../../server/server" import { Server } from "../../server/server"
import { Provider } from "../../provider/provider" import { Provider } from "../../provider/provider"
import { Agent } from "../../agent/agent" import { Agent } from "../../agent/agent"
import { PermissionNext } from "../../permission/next"
import { Tool } from "../../tool/tool"
import { GlobTool } from "../../tool/glob"
import { GrepTool } from "../../tool/grep"
import { ListTool } from "../../tool/ls"
import { ReadTool } from "../../tool/read"
import { WebFetchTool } from "../../tool/webfetch"
import { EditTool } from "../../tool/edit"
import { WriteTool } from "../../tool/write"
import { CodeSearchTool } from "../../tool/codesearch"
import { WebSearchTool } from "../../tool/websearch"
import { TaskTool } from "../../tool/task"
import { SkillTool } from "../../tool/skill"
import { BashTool } from "../../tool/bash"
import { TodoWriteTool } from "../../tool/todo"
import { Locale } from "../../util/locale"
const TOOL: Record<string, [string, string]> = { type ToolProps<T extends Tool.Info> = {
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD], input: Tool.InferParameters<T>
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD], metadata: Tool.InferMetadata<T>
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD], part: ToolPart
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD], }
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
grep: ["Grep", UI.Style.TEXT_INFO_BOLD], function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
list: ["List", UI.Style.TEXT_INFO_BOLD], const state = part.state
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD], return {
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD], input: state.input as Tool.InferParameters<T>,
websearch: ["Search", UI.Style.TEXT_DIM_BOLD], metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata<T>,
part,
}
}
type Inline = {
icon: string
title: string
description?: string
}
function inline(info: Inline) {
const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
}
function block(info: Inline, output?: string) {
UI.empty()
inline(info)
if (!output?.trim()) return
UI.println(output)
UI.empty()
}
function fallback(part: ToolPart) {
const state = part.state
const input = "input" in state ? state.input : undefined
const title =
("title" in state && state.title ? state.title : undefined) ||
(input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown")
inline({
icon: "⚙",
title: `${part.tool} ${title}`,
})
}
function glob(info: ToolProps<typeof GlobTool>) {
const root = info.input.path ?? ""
const title = `Glob "${info.input.pattern}"`
const suffix = root ? `in ${normalizePath(root)}` : ""
const num = info.metadata.count
const description =
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
inline({
icon: "✱",
title,
...(description && { description }),
})
}
function grep(info: ToolProps<typeof GrepTool>) {
const root = info.input.path ?? ""
const title = `Grep "${info.input.pattern}"`
const suffix = root ? `in ${normalizePath(root)}` : ""
const num = info.metadata.matches
const description =
num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
inline({
icon: "✱",
title,
...(description && { description }),
})
}
function list(info: ToolProps<typeof ListTool>) {
const dir = info.input.path ? normalizePath(info.input.path) : ""
inline({
icon: "→",
title: dir ? `List ${dir}` : "List",
})
}
function read(info: ToolProps<typeof ReadTool>) {
const file = normalizePath(info.input.filePath)
const pairs = Object.entries(info.input).filter(([key, value]) => {
if (key === "filePath") return false
return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
})
const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined
inline({
icon: "→",
title: `Read ${file}`,
...(description && { description }),
})
}
function write(info: ToolProps<typeof WriteTool>) {
block(
{
icon: "←",
title: `Write ${normalizePath(info.input.filePath)}`,
},
info.part.state.status === "completed" ? info.part.state.output : undefined,
)
}
function webfetch(info: ToolProps<typeof WebFetchTool>) {
inline({
icon: "%",
title: `WebFetch ${info.input.url}`,
})
}
function edit(info: ToolProps<typeof EditTool>) {
const title = normalizePath(info.input.filePath)
const diff = info.metadata.diff
block(
{
icon: "←",
title: `Edit ${title}`,
},
diff,
)
}
function codesearch(info: ToolProps<typeof CodeSearchTool>) {
inline({
icon: "◇",
title: `Exa Code Search "${info.input.query}"`,
})
}
function websearch(info: ToolProps<typeof WebSearchTool>) {
inline({
icon: "◈",
title: `Exa Web Search "${info.input.query}"`,
})
}
function task(info: ToolProps<typeof TaskTool>) {
const agent = Locale.titlecase(info.input.subagent_type)
const desc = info.input.description
const started = info.part.state.status === "running"
const name = desc ?? `${agent} Task`
inline({
icon: started ? "•" : "✓",
title: name,
description: desc ? `${agent} Agent` : undefined,
})
}
function skill(info: ToolProps<typeof SkillTool>) {
inline({
icon: "→",
title: `Skill "${info.input.name}"`,
})
}
function bash(info: ToolProps<typeof BashTool>) {
const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
block(
{
icon: "$",
title: `${info.input.command}`,
},
output,
)
}
function todo(info: ToolProps<typeof TodoWriteTool>) {
block(
{
icon: "#",
title: "Todos",
},
info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"),
)
}
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
return input
} }
export const RunCommand = cmd({ export const RunCommand = cmd({
@@ -91,17 +277,22 @@ export const RunCommand = cmd({
type: "string", type: "string",
describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)", describe: "model variant (provider-specific reasoning effort, e.g., high, max, minimal)",
}) })
.option("thinking", {
type: "boolean",
describe: "show thinking blocks",
default: false,
})
}, },
handler: async (args) => { handler: async (args) => {
let message = [...args.message, ...(args["--"] || [])] let message = [...args.message, ...(args["--"] || [])]
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
.join(" ") .join(" ")
const fileParts: any[] = [] const files: { type: "file"; url: string; filename: string; mime: string }[] = []
if (args.file) { if (args.file) {
const files = Array.isArray(args.file) ? args.file : [args.file] const list = Array.isArray(args.file) ? args.file : [args.file]
for (const filePath of files) { for (const filePath of list) {
const resolvedPath = path.resolve(process.cwd(), filePath) const resolvedPath = path.resolve(process.cwd(), filePath)
const file = Bun.file(resolvedPath) const file = Bun.file(resolvedPath)
const stats = await file.stat().catch(() => {}) const stats = await file.stat().catch(() => {})
@@ -117,7 +308,7 @@ export const RunCommand = cmd({
const stat = await file.stat() const stat = await file.stat()
const mime = stat.isDirectory() ? "application/x-directory" : "text/plain" const mime = stat.isDirectory() ? "application/x-directory" : "text/plain"
fileParts.push({ files.push({
type: "file", type: "file",
url: `file://${resolvedPath}`, url: `file://${resolvedPath}`,
filename: path.basename(resolvedPath), filename: path.basename(resolvedPath),
@@ -133,17 +324,75 @@ export const RunCommand = cmd({
process.exit(1) process.exit(1)
} }
const execute = async (sdk: OpencodeClient, sessionID: string) => { const rules: PermissionNext.Ruleset = [
const printEvent = (color: string, type: string, title: string) => { {
UI.println( permission: "question",
color + `|`, action: "deny",
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`, pattern: "*",
"", },
UI.Style.TEXT_NORMAL + title, {
) permission: "plan_enter",
action: "deny",
pattern: "*",
},
{
permission: "plan_exit",
action: "deny",
pattern: "*",
},
]
function title() {
if (args.title === undefined) return
if (args.title !== "") return args.title
return message.slice(0, 50) + (message.length > 50 ? "..." : "")
}
async function session(sdk: OpencodeClient) {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
}
if (args.session) return args.session
const name = title()
const result = await sdk.session.create({ title: name, permission: rules })
return result.data?.id
}
async function share(sdk: OpencodeClient, sessionID: string) {
const cfg = await sdk.config.get()
if (!cfg.data) return
if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
const res = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
})
if (!res.error && "data" in res && res.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + res.data.share.url)
}
}
async function execute(sdk: OpencodeClient) {
function tool(part: ToolPart) {
if (part.tool === "bash") return bash(props<typeof BashTool>(part))
if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
if (part.tool === "list") return list(props<typeof ListTool>(part))
if (part.tool === "read") return read(props<typeof ReadTool>(part))
if (part.tool === "write") return write(props<typeof WriteTool>(part))
if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
if (part.tool === "edit") return edit(props<typeof EditTool>(part))
if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
if (part.tool === "task") return task(props<typeof TaskTool>(part))
if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
return fallback(part)
} }
const outputJsonEvent = (type: string, data: any) => { function emit(type: string, data: Record<string, unknown>) {
if (args.format === "json") { if (args.format === "json") {
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL) process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
return true return true
@@ -152,41 +401,77 @@ export const RunCommand = cmd({
} }
const events = await sdk.event.subscribe() const events = await sdk.event.subscribe()
let errorMsg: string | undefined let error: string | undefined
async function loop() {
const toggles = new Map<string, boolean>()
const eventProcessor = (async () => {
for await (const event of events.stream) { for await (const event of events.stream) {
if (
event.type === "message.updated" &&
event.properties.info.role === "assistant" &&
args.format !== "json" &&
toggles.get("start") !== true
) {
UI.empty()
UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
UI.empty()
toggles.set("start", true)
}
if (event.type === "message.part.updated") { if (event.type === "message.part.updated") {
const part = event.properties.part const part = event.properties.part
if (part.sessionID !== sessionID) continue if (part.sessionID !== sessionID) continue
if (part.type === "tool" && part.state.status === "completed") { if (part.type === "tool" && part.state.status === "completed") {
if (outputJsonEvent("tool_use", { part })) continue if (emit("tool_use", { part })) continue
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD] tool(part)
const title = }
part.state.title ||
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown") if (
printEvent(color, tool, title) part.type === "tool" &&
if (part.tool === "bash" && part.state.output?.trim()) { part.tool === "task" &&
UI.println() part.state.status === "running" &&
UI.println(part.state.output) args.format !== "json"
} ) {
if (toggles.get(part.id) === true) continue
task(props<typeof TaskTool>(part))
toggles.set(part.id, true)
} }
if (part.type === "step-start") { if (part.type === "step-start") {
if (outputJsonEvent("step_start", { part })) continue if (emit("step_start", { part })) continue
} }
if (part.type === "step-finish") { if (part.type === "step-finish") {
if (outputJsonEvent("step_finish", { part })) continue if (emit("step_finish", { part })) continue
} }
if (part.type === "text" && part.time?.end) { if (part.type === "text" && part.time?.end) {
if (outputJsonEvent("text", { part })) continue if (emit("text", { part })) continue
const isPiped = !process.stdout.isTTY const text = part.text.trim()
if (!isPiped) UI.println() if (!text) continue
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL) if (!process.stdout.isTTY) {
if (!isPiped) UI.println() process.stdout.write(text + EOL)
continue
}
UI.empty()
UI.println(text)
UI.empty()
}
if (part.type === "reasoning" && part.time?.end && args.thinking) {
if (emit("reasoning", { part })) continue
const text = part.text.trim()
if (!text) continue
const line = `Thinking: ${text}`
if (process.stdout.isTTY) {
UI.empty()
UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
UI.empty()
continue
}
process.stdout.write(line + EOL)
} }
} }
@@ -197,42 +482,40 @@ export const RunCommand = cmd({
if ("data" in props.error && props.error.data && "message" in props.error.data) { if ("data" in props.error && props.error.data && "message" in props.error.data) {
err = String(props.error.data.message) err = String(props.error.data.message)
} }
errorMsg = errorMsg ? errorMsg + EOL + err : err error = error ? error + EOL + err : err
if (outputJsonEvent("error", { error: props.error })) continue if (emit("error", { error: props.error })) continue
UI.error(err) UI.error(err)
} }
if (event.type === "session.idle" && event.properties.sessionID === sessionID) { if (
event.type === "session.status" &&
event.properties.sessionID === sessionID &&
event.properties.status.type === "idle"
) {
break break
} }
if (event.type === "permission.asked") { if (event.type === "permission.asked") {
const permission = event.properties const permission = event.properties
if (permission.sessionID !== sessionID) continue if (permission.sessionID !== sessionID) continue
const result = await select({ UI.println(
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`, UI.Style.TEXT_WARNING_BOLD + "!",
options: [ UI.Style.TEXT_NORMAL +
{ value: "once", label: "Allow once" }, `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
{ value: "always", label: "Always allow: " + permission.always.join(", ") }, )
{ value: "reject", label: "Reject" }, await sdk.permission.reply({
], requestID: permission.id,
initialValue: "once", reply: "reject",
}).catch(() => "reject")
const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject"
await sdk.permission.respond({
sessionID,
permissionID: permission.id,
response,
}) })
} }
} }
})() }
// Validate agent if specified // Validate agent if specified
const resolvedAgent = await (async () => { const agent = await (async () => {
if (!args.agent) return undefined if (!args.agent) return undefined
const agent = await Agent.get(args.agent) const entry = await Agent.get(args.agent)
if (!agent) { if (!entry) {
UI.println( UI.println(
UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL, UI.Style.TEXT_NORMAL,
@@ -240,7 +523,7 @@ export const RunCommand = cmd({
) )
return undefined return undefined
} }
if (agent.mode === "subagent") { if (entry.mode === "subagent") {
UI.println( UI.println(
UI.Style.TEXT_WARNING_BOLD + "!", UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL, UI.Style.TEXT_NORMAL,
@@ -251,91 +534,42 @@ export const RunCommand = cmd({
return args.agent return args.agent
})() })()
const sessionID = await session(sdk)
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
await share(sdk, sessionID)
loop().catch((e) => {
console.error(e)
process.exit(1)
})
if (args.command) { if (args.command) {
await sdk.session.command({ await sdk.session.command({
sessionID, sessionID,
agent: resolvedAgent, agent,
model: args.model, model: args.model,
command: args.command, command: args.command,
arguments: message, arguments: message,
variant: args.variant, variant: args.variant,
}) })
} else { } else {
const modelParam = args.model ? Provider.parseModel(args.model) : undefined const model = args.model ? Provider.parseModel(args.model) : undefined
await sdk.session.prompt({ await sdk.session.prompt({
sessionID, sessionID,
agent: resolvedAgent, agent,
model: modelParam, model,
variant: args.variant, variant: args.variant,
parts: [...fileParts, { type: "text", text: message }], parts: [...files, { type: "text", text: message }],
}) })
} }
await eventProcessor
if (errorMsg) process.exit(1)
} }
if (args.attach) { if (args.attach) {
const sdk = createOpencodeClient({ baseUrl: args.attach }) const sdk = createOpencodeClient({ baseUrl: args.attach })
return await execute(sdk)
const sessionID = await (async () => {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
}
if (args.session) return args.session
const title =
args.title !== undefined
? args.title === ""
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
: args.title
: undefined
const result = await sdk.session.create(
title
? {
title,
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
}
: {
permission: [
{
permission: "question",
action: "deny",
pattern: "*",
},
],
},
)
return result.data?.id
})()
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
const cfgResult = await sdk.config.get()
if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
const shareResult = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
})
if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url)
}
}
return await execute(sdk, sessionID)
} }
await bootstrap(process.cwd(), async () => { await bootstrap(process.cwd(), async () => {
@@ -344,52 +578,7 @@ export const RunCommand = cmd({
return Server.App().fetch(request) return Server.App().fetch(request)
}) as typeof globalThis.fetch }) as typeof globalThis.fetch
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
await execute(sdk)
if (args.command) {
const exists = await Command.get(args.command)
if (!exists) {
UI.error(`Command "${args.command}" not found`)
process.exit(1)
}
}
const sessionID = await (async () => {
if (args.continue) {
const result = await sdk.session.list()
return result.data?.find((s) => !s.parentID)?.id
}
if (args.session) return args.session
const title =
args.title !== undefined
? args.title === ""
? message.slice(0, 50) + (message.length > 50 ? "..." : "")
: args.title
: undefined
const result = await sdk.session.create(title ? { title } : {})
return result.data?.id
})()
if (!sessionID) {
UI.error("Session not found")
process.exit(1)
}
const cfgResult = await sdk.config.get()
if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
const shareResult = await sdk.session.share({ sessionID }).catch((error) => {
if (error instanceof Error && error.message.includes("disabled")) {
UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message)
}
return { error }
})
if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) {
UI.println(UI.Style.TEXT_INFO_BOLD + "~ " + shareResult.data.share.url)
}
}
await execute(sdk, sessionID)
}) })
}, },
}) })
@@ -187,7 +187,6 @@ function App() {
const route = useRoute() const route = useRoute()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const renderer = useRenderer() const renderer = useRenderer()
Clipboard.setRenderer(renderer)
renderer.disableStdoutInterception() renderer.disableStdoutInterception()
const dialog = useDialog() const dialog = useDialog()
const local = useLocal() const local = useLocal()
@@ -35,6 +35,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agent = iife(() => { const agent = iife(() => {
const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden)) const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
const visibleAgents = createMemo(() => sync.data.agent.filter((x) => !x.hidden))
const [agentStore, setAgentStore] = createStore<{ const [agentStore, setAgentStore] = createStore<{
current: string current: string
}>({ }>({
@@ -48,6 +49,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
theme.warning, theme.warning,
theme.primary, theme.primary,
theme.error, theme.error,
theme.info,
]) ])
return { return {
list() { list() {
@@ -75,11 +77,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}) })
}, },
color(name: string) { color(name: string) {
const all = sync.data.agent const index = visibleAgents().findIndex((x) => x.name === name)
const agent = all.find((x) => x.name === name)
if (agent?.color) return RGBA.fromHex(agent.color)
const index = all.findIndex((x) => x.name === name)
if (index === -1) return colors()[0] if (index === -1) return colors()[0]
const agent = visibleAgents()[index]
if (agent?.color) {
const color = agent.color
if (color.startsWith("#")) return RGBA.fromHex(color)
// already validated by config, just satisfying TS here
return theme[color as keyof typeof theme] as RGBA
}
return colors()[index % colors().length] return colors()[index % colors().length]
}, },
} }
@@ -1,12 +1,23 @@
import { $ } from "bun" import { $ } from "bun"
import type { CliRenderer } from "@opentui/core"
import { platform, release } from "os" import { platform, release } from "os"
import clipboardy from "clipboardy" import clipboardy from "clipboardy"
import { lazy } from "../../../../util/lazy.js" import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os" import { tmpdir } from "os"
import path from "path" import path from "path"
const rendererRef = { current: undefined as CliRenderer | undefined } /**
* Writes text to clipboard via OSC 52 escape sequence.
* This allows clipboard operations to work over SSH by having
* the terminal emulator handle the clipboard locally.
*/
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
export namespace Clipboard { export namespace Clipboard {
export interface Content { export interface Content {
@@ -14,10 +25,6 @@ export namespace Clipboard {
mime: string mime: string
} }
export function setRenderer(renderer: CliRenderer | undefined): void {
rendererRef.current = renderer
}
export async function read(): Promise<Content | undefined> { export async function read(): Promise<Content | undefined> {
const os = platform() const os = platform()
@@ -146,11 +153,7 @@ export namespace Clipboard {
}) })
export async function copy(text: string): Promise<void> { export async function copy(text: string): Promise<void> {
const renderer = rendererRef.current writeOsc52(text)
if (renderer) {
const copied = renderer.copyToClipboardOSC52(text)
if (copied) return
}
await getCopyMethod()(text) await getCopyMethod()(text)
} }
} }
+42 -12
View File
@@ -28,6 +28,7 @@ import { existsSync } from "fs"
import { Bus } from "@/bus" import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event" import { Event } from "../server/event"
import { PackageRegistry } from "@/bun/registry"
export namespace Config { export namespace Config {
const log = Log.create({ service: "config" }) const log = Log.create({ service: "config" })
@@ -154,9 +155,10 @@ export namespace Config {
} }
} }
const exists = existsSync(path.join(dir, "node_modules")) const shouldInstall = await needsInstall(dir)
const installing = installDependencies(dir) if (shouldInstall) {
if (!exists) await installing await installDependencies(dir)
}
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir)) result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
result.agent = mergeDeep(result.agent, await loadAgent(dir)) result.agent = mergeDeep(result.agent, await loadAgent(dir))
@@ -235,6 +237,7 @@ export namespace Config {
export async function installDependencies(dir: string) { export async function installDependencies(dir: string) {
const pkg = path.join(dir, "package.json") const pkg = path.join(dir, "package.json")
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (!(await Bun.file(pkg).exists())) { if (!(await Bun.file(pkg).exists())) {
await Bun.write(pkg, "{}") await Bun.write(pkg, "{}")
@@ -244,18 +247,43 @@ export namespace Config {
const hasGitIgnore = await Bun.file(gitignore).exists() const hasGitIgnore = await Bun.file(gitignore).exists()
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n")) if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
await BunProc.run( await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
["add", "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"], cwd: dir,
{ }).catch(() => {})
cwd: dir,
},
).catch(() => {})
// Install any additional dependencies defined in the package.json // Install any additional dependencies defined in the package.json
// This allows local plugins and custom tools to use external packages // This allows local plugins and custom tools to use external packages
await BunProc.run(["install"], { cwd: dir }).catch(() => {}) await BunProc.run(["install"], { cwd: dir }).catch(() => {})
} }
async function needsInstall(dir: string) {
const nodeModules = path.join(dir, "node_modules")
if (!existsSync(nodeModules)) return true
const pkg = path.join(dir, "package.json")
const pkgFile = Bun.file(pkg)
const pkgExists = await pkgFile.exists()
if (!pkgExists) return true
const parsed = await pkgFile.json().catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
const isOutdated = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!isOutdated) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
})
return true
}
if (depVersion === targetVersion) return false
return true
}
function rel(item: string, patterns: string[]) { function rel(item: string, patterns: string[]) {
for (const pattern of patterns) { for (const pattern of patterns) {
const index = item.indexOf(pattern) const index = item.indexOf(pattern)
@@ -617,10 +645,12 @@ export namespace Config {
.describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"), .describe("Hide this subagent from the @ autocomplete menu (default: false, only applies to mode: subagent)"),
options: z.record(z.string(), z.any()).optional(), options: z.record(z.string(), z.any()).optional(),
color: z color: z
.string() .union([
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format") z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
])
.optional() .optional()
.describe("Hex color code for the agent (e.g., #FF5733)"), .describe("Hex color code (e.g., #FF5733) or theme color (e.g., primary)"),
steps: z steps: z
.number() .number()
.int() .int()
+2
View File
@@ -23,6 +23,8 @@ export namespace Flag {
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT") OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = export const OPENCODE_DISABLE_CLAUDE_CODE_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS") OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS")
export const OPENCODE_DISABLE_EXTERNAL_SKILLS =
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS || truthy("OPENCODE_DISABLE_EXTERNAL_SKILLS")
export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean export declare const OPENCODE_DISABLE_PROJECT_CONFIG: boolean
export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"] export const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
export declare const OPENCODE_CLIENT: string export declare const OPENCODE_CLIENT: string
+2 -2
View File
@@ -732,7 +732,7 @@ export namespace LSPServer {
export const CSharp: Info = { export const CSharp: Info = {
id: "csharp", id: "csharp",
root: NearestRoot([".sln", ".csproj", "global.json"]), root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"], extensions: [".cs"],
async spawn(root) { async spawn(root) {
let bin = Bun.which("csharp-ls", { let bin = Bun.which("csharp-ls", {
@@ -772,7 +772,7 @@ export namespace LSPServer {
export const FSharp: Info = { export const FSharp: Info = {
id: "fsharp", id: "fsharp",
root: NearestRoot([".sln", ".fsproj", "global.json"]), root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) { async spawn(root) {
let bin = Bun.which("fsautocomplete", { let bin = Bun.which("fsautocomplete", {
+23 -37
View File
@@ -239,7 +239,9 @@ export namespace Provider {
options: providerOptions, options: providerOptions,
async getModel(sdk: any, modelID: string, options?: Record<string, any>) { async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
// Skip region prefixing if model already has a cross-region inference profile prefix // Skip region prefixing if model already has a cross-region inference profile prefix
if (modelID.startsWith("global.") || modelID.startsWith("jp.")) { // Models from models.dev may already include prefixes like us., eu., global., etc.
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
if (crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))) {
return sdk.languageModel(modelID) return sdk.languageModel(modelID)
} }
@@ -461,52 +463,36 @@ export namespace Provider {
if (!accountId || !gateway) return { autoload: false } if (!accountId || !gateway) return { autoload: false }
// Get API token from env or auth prompt // Get API token from env or auth - required for authenticated gateways
const apiToken = await (async () => { const apiToken = await (async () => {
const envToken = Env.get("CLOUDFLARE_API_TOKEN") const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
if (envToken) return envToken if (envToken) return envToken
const auth = await Auth.get(input.id) const auth = await Auth.get(input.id)
if (auth?.type === "api") return auth.key if (auth?.type === "api") return auth.key
return undefined return undefined
})() })()
if (!apiToken) {
throw new Error(
"CLOUDFLARE_API_TOKEN (or CF_AIG_TOKEN) is required for Cloudflare AI Gateway. " +
"Set it via environment variable or run `opencode auth cloudflare-ai-gateway`.",
)
}
// Use official ai-gateway-provider package (v2.x for AI SDK v5 compatibility)
const { createAiGateway } = await import("ai-gateway-provider")
const { createUnified } = await import("ai-gateway-provider/providers/unified")
const aigateway = createAiGateway({ accountId, gateway, apiKey: apiToken })
const unified = createUnified()
return { return {
autoload: true, autoload: true,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) { async getModel(_sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.languageModel(modelID) // Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5")
}, return aigateway(unified(modelID))
options: {
baseURL: `https://gateway.ai.cloudflare.com/v1/${accountId}/${gateway}/compat`,
headers: {
// Cloudflare AI Gateway uses cf-aig-authorization for authenticated gateways
// This enables Unified Billing where Cloudflare handles upstream provider auth
...(apiToken ? { "cf-aig-authorization": `Bearer ${apiToken}` } : {}),
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
// Custom fetch to handle parameter transformation and auth
fetch: async (input: RequestInfo | URL, init?: RequestInit) => {
const headers = new Headers(init?.headers)
// Strip Authorization header - AI Gateway uses cf-aig-authorization instead
headers.delete("Authorization")
// Transform max_tokens to max_completion_tokens for newer models
if (init?.body && init.method === "POST") {
try {
const body = JSON.parse(init.body as string)
if (body.max_tokens !== undefined && !body.max_completion_tokens) {
body.max_completion_tokens = body.max_tokens
delete body.max_tokens
init = { ...init, body: JSON.stringify(body) }
}
} catch (e) {
// If body parsing fails, continue with original request
}
}
return fetch(input, { ...init, headers })
},
}, },
options: {},
} }
}, },
cerebras: async () => { cerebras: async () => {
+21 -5
View File
@@ -1,5 +1,6 @@
import type { APICallError, ModelMessage } from "ai" import type { APICallError, ModelMessage } from "ai"
import { mergeDeep, unique } from "remeda" import { mergeDeep, unique } from "remeda"
import type { JSONSchema7 } from "@ai-sdk/provider"
import type { JSONSchema } from "zod/v4/core" import type { JSONSchema } from "zod/v4/core"
import type { Provider } from "./provider" import type { Provider } from "./provider"
import type { ModelsDev } from "./models" import type { ModelsDev } from "./models"
@@ -333,7 +334,9 @@ export namespace ProviderTransform {
id.includes("minimax") || id.includes("minimax") ||
id.includes("glm") || id.includes("glm") ||
id.includes("mistral") || id.includes("mistral") ||
id.includes("kimi") id.includes("kimi") ||
// TODO: Remove this after models.dev data is fixed to use "kimi-k2.5" instead of "k2p5"
id.includes("k2p5")
) )
return {} return {}
@@ -717,7 +720,7 @@ export namespace ProviderTransform {
return standardLimit return standardLimit
} }
export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema) { export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 {
/* /*
if (["openai", "azure"].includes(providerID)) { if (["openai", "azure"].includes(providerID)) {
if (schema.type === "object" && schema.properties) { if (schema.type === "object" && schema.properties) {
@@ -768,8 +771,21 @@ export namespace ProviderTransform {
result.required = result.required.filter((field: any) => field in result.properties) result.required = result.required.filter((field: any) => field in result.properties)
} }
if (result.type === "array" && result.items == null) { if (result.type === "array") {
result.items = {} if (result.items == null) {
result.items = {}
}
// Ensure items has at least a type if it's an empty object
// This handles nested arrays like { type: "array", items: { type: "array", items: {} } }
if (typeof result.items === "object" && !Array.isArray(result.items) && !result.items.type) {
result.items.type = "string"
}
}
// Remove properties/required from non-object types (Gemini rejects these)
if (result.type && result.type !== "object") {
delete result.properties
delete result.required
} }
return result return result
@@ -778,7 +794,7 @@ export namespace ProviderTransform {
schema = sanitizeGemini(schema) schema = sanitizeGemini(schema)
} }
return schema return schema as JSONSchema7
} }
export function error(providerID: string, error: APICallError) { export function error(providerID: string, error: APICallError) {
+3
View File
@@ -8,6 +8,7 @@ import type { WSContext } from "hono/ws"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { lazy } from "@opencode-ai/util/lazy" import { lazy } from "@opencode-ai/util/lazy"
import { Shell } from "@/shell/shell" import { Shell } from "@/shell/shell"
import { Plugin } from "@/plugin"
export namespace Pty { export namespace Pty {
const log = Log.create({ service: "pty" }) const log = Log.create({ service: "pty" })
@@ -102,9 +103,11 @@ export namespace Pty {
} }
const cwd = input.cwd || Instance.directory const cwd = input.cwd || Instance.directory
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const env = { const env = {
...process.env, ...process.env,
...input.env, ...input.env,
...shellEnv.env,
TERM: "xterm-256color", TERM: "xterm-256color",
OPENCODE_TERMINAL: "1", OPENCODE_TERMINAL: "1",
} as Record<string, string> } as Record<string, string>
+5 -4
View File
@@ -17,13 +17,14 @@ const FILES = [
] ]
function globalFiles() { function globalFiles() {
const files = [path.join(Global.Path.config, "AGENTS.md")] const files = []
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
if (Flag.OPENCODE_CONFIG_DIR) { if (Flag.OPENCODE_CONFIG_DIR) {
files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md")) files.push(path.join(Flag.OPENCODE_CONFIG_DIR, "AGENTS.md"))
} }
files.push(path.join(Global.Path.config, "AGENTS.md"))
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
}
return files return files
} }
+7 -2
View File
@@ -9,7 +9,7 @@ import { SessionRevert } from "./revert"
import { Session } from "." import { Session } from "."
import { Agent } from "../agent/agent" import { Agent } from "../agent/agent"
import { Provider } from "../provider/provider" import { Provider } from "../provider/provider"
import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions } from "ai" import { type Tool as AITool, tool, jsonSchema, type ToolCallOptions, asSchema } from "ai"
import { SessionCompaction } from "./compaction" import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Bus } from "../bus" import { Bus } from "../bus"
@@ -738,6 +738,8 @@ export namespace SessionPrompt {
const execute = item.execute const execute = item.execute
if (!execute) continue if (!execute) continue
const transformed = ProviderTransform.schema(input.model, asSchema(item.inputSchema).jsonSchema)
item.inputSchema = jsonSchema(transformed)
// Wrap execute to add plugin hooks and format output // Wrap execute to add plugin hooks and format output
item.execute = async (args, opts) => { item.execute = async (args, opts) => {
const ctx = context(args, opts) const ctx = context(args, opts)
@@ -1498,12 +1500,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const matchingInvocation = invocations[shellName] ?? invocations[""] const matchingInvocation = invocations[shellName] ?? invocations[""]
const args = matchingInvocation?.args const args = matchingInvocation?.args
const cwd = Instance.directory
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const proc = spawn(shell, args, { const proc = spawn(shell, args, {
cwd: Instance.directory, cwd,
detached: process.platform !== "win32", detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
env: { env: {
...process.env, ...process.env,
...shellEnv.env,
TERM: "dumb", TERM: "dumb",
}, },
}) })
+46 -33
View File
@@ -40,12 +40,17 @@ export namespace Skill {
}), }),
) )
// External skill directories to search for (project-level and global)
// These follow the directory layout used by Claude Code and other agents.
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md") const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
const CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
const SKILL_GLOB = new Bun.Glob("**/SKILL.md") const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
export const state = Instance.state(async () => { export const state = Instance.state(async () => {
const skills: Record<string, Info> = {} const skills: Record<string, Info> = {}
const dirs = new Set<string>()
const addSkill = async (match: string) => { const addSkill = async (match: string) => {
const md = await ConfigMarkdown.parse(match).catch((err) => { const md = await ConfigMarkdown.parse(match).catch((err) => {
@@ -71,6 +76,8 @@ export namespace Skill {
}) })
} }
dirs.add(path.dirname(match))
skills[parsed.data.name] = { skills[parsed.data.name] = {
name: parsed.data.name, name: parsed.data.name,
description: parsed.data.description, description: parsed.data.description,
@@ -79,38 +86,37 @@ export namespace Skill {
} }
} }
// Scan .claude/skills/ directories (project-level) const scanExternal = async (root: string, scope: "global" | "project") => {
const claudeDirs = await Array.fromAsync( return Array.fromAsync(
Filesystem.up({ EXTERNAL_SKILL_GLOB.scan({
targets: [".claude"], cwd: root,
start: Instance.directory, absolute: true,
stop: Instance.worktree, onlyFiles: true,
}), followSymlinks: true,
) dot: true,
// Also include global ~/.claude/skills/ }),
const globalClaude = `${Global.Path.home}/.claude` )
if (await Filesystem.isDir(globalClaude)) { .then((matches) => Promise.all(matches.map(addSkill)))
claudeDirs.push(globalClaude) .catch((error) => {
log.error(`failed to scan ${scope} skills`, { dir: root, error })
})
} }
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) { // Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
for (const dir of claudeDirs) { // Load global (home) first, then project-level (so project-level overwrites)
const matches = await Array.fromAsync( if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
CLAUDE_SKILL_GLOB.scan({ for (const dir of EXTERNAL_DIRS) {
cwd: dir, const root = path.join(Global.Path.home, dir)
absolute: true, if (!(await Filesystem.isDir(root))) continue
onlyFiles: true, await scanExternal(root, "global")
followSymlinks: true, }
dot: true,
}),
).catch((error) => {
log.error("failed .claude directory scan for skills", { dir, error })
return []
})
for (const match of matches) { for await (const root of Filesystem.up({
await addSkill(match) targets: EXTERNAL_DIRS,
} start: Instance.directory,
stop: Instance.worktree,
})) {
await scanExternal(root, "project")
} }
} }
@@ -145,14 +151,21 @@ export namespace Skill {
} }
} }
return skills return {
skills,
dirs: Array.from(dirs),
}
}) })
export async function get(name: string) { export async function get(name: string) {
return state().then((x) => x[name]) return state().then((x) => x.skills[name])
} }
export async function all() { export async function all() {
return state().then((x) => Object.values(x)) return state().then((x) => Object.values(x.skills))
}
export async function dirs() {
return state().then((x) => x.dirs)
} }
} }
+3
View File
@@ -16,6 +16,7 @@ import { Shell } from "@/shell/shell"
import { BashArity } from "@/permission/arity" import { BashArity } from "@/permission/arity"
import { Truncate } from "./truncation" import { Truncate } from "./truncation"
import { Plugin } from "@/plugin"
const MAX_METADATA_LENGTH = 30_000 const MAX_METADATA_LENGTH = 30_000
const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
@@ -162,11 +163,13 @@ export const BashTool = Tool.define("bash", async () => {
}) })
} }
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
const proc = spawn(params.command, { const proc = spawn(params.command, {
shell, shell,
cwd, cwd,
env: { env: {
...process.env, ...process.env,
...shellEnv.env,
}, },
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32", detached: process.platform !== "win32",
+5 -5
View File
@@ -1,6 +1,7 @@
import z from "zod" import z from "zod"
import { Tool } from "./tool" import { Tool } from "./tool"
import DESCRIPTION from "./codesearch.txt" import DESCRIPTION from "./codesearch.txt"
import { abortAfterAny } from "../util/abort"
const API_CONFIG = { const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai", BASE_URL: "https://mcp.exa.ai",
@@ -73,8 +74,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
}, },
} }
const controller = new AbortController() const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
const timeoutId = setTimeout(() => controller.abort(), 30000)
try { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -86,10 +86,10 @@ export const CodeSearchTool = Tool.define("codesearch", {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify(codeRequest), body: JSON.stringify(codeRequest),
signal: AbortSignal.any([controller.signal, ctx.abort]), signal,
}) })
clearTimeout(timeoutId) clearTimeout()
if (!response.ok) { if (!response.ok) {
const errorText = await response.text() const errorText = await response.text()
@@ -120,7 +120,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
metadata: {}, metadata: {},
} }
} catch (error) { } catch (error) {
clearTimeout(timeoutId) clearTimeout()
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
throw new Error("Code search request timed out") throw new Error("Code search request timed out")
+1 -1
View File
@@ -110,7 +110,7 @@ export namespace ToolRegistry {
TaskTool, TaskTool,
WebFetchTool, WebFetchTool,
TodoWriteTool, TodoWriteTool,
TodoReadTool, // TodoReadTool,
WebSearchTool, WebSearchTool,
CodeSearchTool, CodeSearchTool,
SkillTool, SkillTool,
+55 -12
View File
@@ -1,8 +1,11 @@
import path from "path" import path from "path"
import { pathToFileURL } from "url"
import z from "zod" import z from "zod"
import { Tool } from "./tool" import { Tool } from "./tool"
import { Skill } from "../skill" import { Skill } from "../skill"
import { PermissionNext } from "../permission/next" import { PermissionNext } from "../permission/next"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
export const SkillTool = Tool.define("skill", async (ctx) => { export const SkillTool = Tool.define("skill", async (ctx) => {
const skills = await Skill.all() const skills = await Skill.all()
@@ -18,21 +21,29 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
const description = const description =
accessibleSkills.length === 0 accessibleSkills.length === 0
? "Load a skill to get detailed instructions for a specific task. No skills are currently available." ? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [ : [
"Load a skill to get detailed instructions for a specific task.", "Load a specialized skill that provides domain-specific instructions and workflows.",
"Skills provide specialized knowledge and step-by-step guidance.", "",
"Use this when a task matches an available skill's description.", "When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"Only the skills listed here are available:", "",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
"<available_skills>", "<available_skills>",
...accessibleSkills.flatMap((skill) => [ ...accessibleSkills.flatMap((skill) => [
` <skill>`, ` <skill>`,
` <name>${skill.name}</name>`, ` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`, ` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
` </skill>`, ` </skill>`,
]), ]),
"</available_skills>", "</available_skills>",
].join(" ") ].join("\n")
const examples = accessibleSkills const examples = accessibleSkills
.map((skill) => `'${skill.name}'`) .map((skill) => `'${skill.name}'`)
@@ -41,7 +52,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : "" const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
const parameters = z.object({ const parameters = z.object({
name: z.string().describe(`The skill identifier from available_skills${hint}`), name: z.string().describe(`The name of the skill from available_skills${hint}`),
}) })
return { return {
@@ -61,15 +72,47 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
always: [params.name], always: [params.name],
metadata: {}, metadata: {},
}) })
const content = skill.content
const dir = path.dirname(skill.location)
// Format output similar to plugin pattern const dir = path.dirname(skill.location)
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", content.trim()].join("\n") const base = pathToFileURL(dir).href
const limit = 10
const files = await iife(async () => {
const arr = []
for await (const file of Ripgrep.files({
cwd: dir,
follow: false,
hidden: true,
signal: ctx.abort,
})) {
if (file.includes("SKILL.md")) {
continue
}
arr.push(path.resolve(dir, file))
if (arr.length >= limit) {
break
}
}
return arr
}).then((f) => f.map((file) => `<file>${file}</file>`).join("\n"))
return { return {
title: `Loaded skill: ${skill.name}`, title: `Loaded skill: ${skill.name}`,
output, output: [
`<skill_content name="${skill.name}">`,
`# Skill: ${skill.name}`,
"",
skill.content.trim(),
"",
`Base directory for this skill: ${base}`,
"Relative paths in this skill (e.g., scripts/, reference/) are relative to this base directory.",
"Note: file list is sampled.",
"",
"<skill_files>",
files,
"</skill_files>",
"</skill_content>",
].join("\n"),
metadata: { metadata: {
name: skill.name, name: skill.name,
dir, dir,
+3 -5
View File
@@ -2,6 +2,7 @@ import z from "zod"
import { Tool } from "./tool" import { Tool } from "./tool"
import TurndownService from "turndown" import TurndownService from "turndown"
import DESCRIPTION from "./webfetch.txt" import DESCRIPTION from "./webfetch.txt"
import { abortAfterAny } from "../util/abort"
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds const DEFAULT_TIMEOUT = 30 * 1000 // 30 seconds
@@ -36,8 +37,7 @@ export const WebFetchTool = Tool.define("webfetch", {
const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT) const timeout = Math.min((params.timeout ?? DEFAULT_TIMEOUT / 1000) * 1000, MAX_TIMEOUT)
const controller = new AbortController() const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
const timeoutId = setTimeout(() => controller.abort(), timeout)
// Build Accept header based on requested format with q parameters for fallbacks // Build Accept header based on requested format with q parameters for fallbacks
let acceptHeader = "*/*" let acceptHeader = "*/*"
@@ -55,8 +55,6 @@ export const WebFetchTool = Tool.define("webfetch", {
acceptHeader = acceptHeader =
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8"
} }
const signal = AbortSignal.any([controller.signal, ctx.abort])
const headers = { const headers = {
"User-Agent": "User-Agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",
@@ -72,7 +70,7 @@ export const WebFetchTool = Tool.define("webfetch", {
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } }) ? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
: initial : initial
clearTimeout(timeoutId) clearTimeout()
if (!response.ok) { if (!response.ok) {
throw new Error(`Request failed with status code: ${response.status}`) throw new Error(`Request failed with status code: ${response.status}`)
+5 -5
View File
@@ -1,6 +1,7 @@
import z from "zod" import z from "zod"
import { Tool } from "./tool" import { Tool } from "./tool"
import DESCRIPTION from "./websearch.txt" import DESCRIPTION from "./websearch.txt"
import { abortAfterAny } from "../util/abort"
const API_CONFIG = { const API_CONFIG = {
BASE_URL: "https://mcp.exa.ai", BASE_URL: "https://mcp.exa.ai",
@@ -91,8 +92,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
}, },
} }
const controller = new AbortController() const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort)
const timeoutId = setTimeout(() => controller.abort(), 25000)
try { try {
const headers: Record<string, string> = { const headers: Record<string, string> = {
@@ -104,10 +104,10 @@ export const WebSearchTool = Tool.define("websearch", async () => {
method: "POST", method: "POST",
headers, headers,
body: JSON.stringify(searchRequest), body: JSON.stringify(searchRequest),
signal: AbortSignal.any([controller.signal, ctx.abort]), signal,
}) })
clearTimeout(timeoutId) clearTimeout()
if (!response.ok) { if (!response.ok) {
const errorText = await response.text() const errorText = await response.text()
@@ -137,7 +137,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
metadata: {}, metadata: {},
} }
} catch (error) { } catch (error) {
clearTimeout(timeoutId) clearTimeout()
if (error instanceof Error && error.name === "AbortError") { if (error instanceof Error && error.name === "AbortError") {
throw new Error("Search request timed out") throw new Error("Search request timed out")
+35
View File
@@ -0,0 +1,35 @@
/**
* Creates an AbortController that automatically aborts after a timeout.
*
* Uses bind() instead of arrow functions to avoid capturing the surrounding
* scope in closures. Arrow functions like `() => controller.abort()` capture
* request bodies and other large objects, preventing GC for the timer lifetime.
*
* @param ms Timeout in milliseconds
* @returns Object with controller, signal, and clearTimeout function
*/
export function abortAfter(ms: number) {
const controller = new AbortController()
const id = setTimeout(controller.abort.bind(controller), ms)
return {
controller,
signal: controller.signal,
clearTimeout: () => globalThis.clearTimeout(id),
}
}
/**
* Combines multiple AbortSignals with a timeout.
*
* @param ms Timeout in milliseconds
* @param signals Additional signals to combine
* @returns Combined signal that aborts on timeout or when any input signal aborts
*/
export function abortAfterAny(ms: number, ...signals: AbortSignal[]) {
const timeout = abortAfter(ms)
const signal = AbortSignal.any([timeout.signal, ...signals])
return {
signal,
clearTimeout: timeout.clearTimeout,
}
}
@@ -1,4 +1,5 @@
import { test, expect } from "bun:test" import { test, expect } from "bun:test"
import path from "path"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Agent } from "../../src/agent/agent" import { Agent } from "../../src/agent/agent"
@@ -513,6 +514,42 @@ test("explicit Truncate.GLOB deny is respected", async () => {
}) })
}) })
test("skill directories are allowed for external_directory", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "perm-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: perm-skill
description: Permission skill.
---
# Permission Skill
`,
)
},
})
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const skillDir = path.join(tmp.path, ".opencode", "skill", "perm-skill")
const target = path.join(skillDir, "reference", "notes.md")
expect(PermissionNext.evaluate("external_directory", target, build!.permission).action).toBe("allow")
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
test("defaultAgent returns build when no default_agent config", async () => { test("defaultAgent returns build when no default_agent config", async () => {
await using tmp = await tmpdir() await using tmp = await tmpdir()
await Instance.provide({ await Instance.provide({
@@ -15,6 +15,7 @@ test("agent color parsed from project config", async () => {
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
agent: { agent: {
build: { color: "#FFA500" }, build: { color: "#FFA500" },
plan: { color: "primary" },
}, },
}), }),
) )
@@ -25,6 +26,7 @@ test("agent color parsed from project config", async () => {
fn: async () => { fn: async () => {
const cfg = await Config.get() const cfg = await Config.get()
expect(cfg.agent?.["build"]?.color).toBe("#FFA500") expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
expect(cfg.agent?.["plan"]?.color).toBe("primary")
}, },
}) })
}) })
@@ -38,6 +40,7 @@ test("Agent.get includes color from config", async () => {
$schema: "https://opencode.ai/config.json", $schema: "https://opencode.ai/config.json",
agent: { agent: {
plan: { color: "#A855F7" }, plan: { color: "#A855F7" },
build: { color: "accent" },
}, },
}), }),
) )
@@ -48,6 +51,8 @@ test("Agent.get includes color from config", async () => {
fn: async () => { fn: async () => {
const plan = await AgentSvc.get("plan") const plan = await AgentSvc.get("plan")
expect(plan?.color).toBe("#A855F7") expect(plan?.color).toBe("#A855F7")
const build = await AgentSvc.get("build")
expect(build?.color).toBe("accent")
}, },
}) })
}) })
@@ -8,6 +8,7 @@ let openCalledWith: string | undefined
mock.module("open", () => ({ mock.module("open", () => ({
default: async (url: string) => { default: async (url: string) => {
openCalledWith = url openCalledWith = url
// Return a mock subprocess that emits an error if openShouldFail is true // Return a mock subprocess that emits an error if openShouldFail is true
const subprocess = new EventEmitter() const subprocess = new EventEmitter()
if (openShouldFail) { if (openShouldFail) {
@@ -133,20 +134,17 @@ test("BrowserOpenFailed event is published when open() throws", async () => {
}) })
// Run authenticate with a timeout to avoid waiting forever for the callback // Run authenticate with a timeout to avoid waiting forever for the callback
const authPromise = MCP.authenticate("test-oauth-server") // Attach a handler immediately so callback shutdown rejections
// don't show up as unhandled between tests.
const authPromise = MCP.authenticate("test-oauth-server").catch(() => undefined)
// Wait for the browser open attempt (error fires at 10ms, but we wait for event to be published) // Config.get() can be slow in tests, so give it plenty of time.
await new Promise((resolve) => setTimeout(resolve, 200)) await new Promise((resolve) => setTimeout(resolve, 2_000))
// Stop the callback server and cancel any pending auth // Stop the callback server and cancel any pending auth
await McpOAuthCallback.stop() await McpOAuthCallback.stop()
// Wait for authenticate to reject (due to server stopping) await authPromise
try {
await authPromise
} catch {
// Expected to fail
}
unsubscribe() unsubscribe()
@@ -187,20 +185,15 @@ test("BrowserOpenFailed event is NOT published when open() succeeds", async () =
}) })
// Run authenticate with a timeout to avoid waiting forever for the callback // Run authenticate with a timeout to avoid waiting forever for the callback
const authPromise = MCP.authenticate("test-oauth-server-2") const authPromise = MCP.authenticate("test-oauth-server-2").catch(() => undefined)
// Wait for the browser open attempt and the 500ms error detection timeout // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
await new Promise((resolve) => setTimeout(resolve, 700)) await new Promise((resolve) => setTimeout(resolve, 2_000))
// Stop the callback server and cancel any pending auth // Stop the callback server and cancel any pending auth
await McpOAuthCallback.stop() await McpOAuthCallback.stop()
// Wait for authenticate to reject (due to server stopping) await authPromise
try {
await authPromise
} catch {
// Expected to fail
}
unsubscribe() unsubscribe()
@@ -237,20 +230,15 @@ test("open() is called with the authorization URL", async () => {
openCalledWith = undefined openCalledWith = undefined
// Run authenticate with a timeout to avoid waiting forever for the callback // Run authenticate with a timeout to avoid waiting forever for the callback
const authPromise = MCP.authenticate("test-oauth-server-3") const authPromise = MCP.authenticate("test-oauth-server-3").catch(() => undefined)
// Wait for the browser open attempt and the 500ms error detection timeout // Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
await new Promise((resolve) => setTimeout(resolve, 700)) await new Promise((resolve) => setTimeout(resolve, 2_000))
// Stop the callback server and cancel any pending auth // Stop the callback server and cancel any pending auth
await McpOAuthCallback.stop() await McpOAuthCallback.stop()
// Wait for authenticate to reject (due to server stopping) await authPromise
try {
await authPromise
} catch {
// Expected to fail
}
// Verify open was called with a URL // Verify open was called with a URL
expect(openCalledWith).toBeDefined() expect(openCalledWith).toBeDefined()
@@ -0,0 +1,136 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import { Instance } from "../../src/project/instance"
import { WebFetchTool } from "../../src/tool/webfetch"
const projectRoot = path.join(__dirname, "../..")
const ctx = {
sessionID: "test",
messageID: "",
callID: "",
agent: "build",
abort: new AbortController().signal,
messages: [],
metadata: () => {},
ask: async () => {},
}
const MB = 1024 * 1024
const ITERATIONS = 50
const getHeapMB = () => {
Bun.gc(true)
return process.memoryUsage().heapUsed / MB
}
describe("memory: abort controller leak", () => {
test("webfetch does not leak memory over many invocations", async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const tool = await WebFetchTool.init()
// Warm up
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
Bun.gc(true)
const baseline = getHeapMB()
// Run many fetches
for (let i = 0; i < ITERATIONS; i++) {
await tool.execute({ url: "https://example.com", format: "text" }, ctx).catch(() => {})
}
Bun.gc(true)
const after = getHeapMB()
const growth = after - baseline
console.log(`Baseline: ${baseline.toFixed(2)} MB`)
console.log(`After ${ITERATIONS} fetches: ${after.toFixed(2)} MB`)
console.log(`Growth: ${growth.toFixed(2)} MB`)
// Memory growth should be minimal - less than 1MB per 10 requests
// With the old closure pattern, this would grow ~0.5MB per request
expect(growth).toBeLessThan(ITERATIONS / 10)
},
})
}, 60000)
test("compare closure vs bind pattern directly", async () => {
const ITERATIONS = 500
// Test OLD pattern: arrow function closure
// Store closures in a map keyed by content to force retention
const closureMap = new Map<string, () => void>()
const timers: Timer[] = []
const controllers: AbortController[] = []
Bun.gc(true)
Bun.sleepSync(100)
const baseline = getHeapMB()
for (let i = 0; i < ITERATIONS; i++) {
// Simulate large response body like webfetch would have
const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration
const controller = new AbortController()
controllers.push(controller)
// OLD pattern - closure captures `content`
const handler = () => {
// Actually use content so it can't be optimized away
if (content.length > 1000000000) controller.abort()
}
closureMap.set(content, handler)
const timeoutId = setTimeout(handler, 30000)
timers.push(timeoutId)
}
Bun.gc(true)
Bun.sleepSync(100)
const after = getHeapMB()
const oldGrowth = after - baseline
console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`)
// Cleanup after measuring
timers.forEach(clearTimeout)
controllers.forEach((c) => c.abort())
closureMap.clear()
// Test NEW pattern: bind
Bun.gc(true)
Bun.sleepSync(100)
const baseline2 = getHeapMB()
const handlers2: (() => void)[] = []
const timers2: Timer[] = []
const controllers2: AbortController[] = []
for (let i = 0; i < ITERATIONS; i++) {
const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured
const controller = new AbortController()
controllers2.push(controller)
// NEW pattern - bind doesn't capture surrounding scope
const handler = controller.abort.bind(controller)
handlers2.push(handler)
const timeoutId = setTimeout(handler, 30000)
timers2.push(timeoutId)
}
Bun.gc(true)
Bun.sleepSync(100)
const after2 = getHeapMB()
const newGrowth = after2 - baseline2
// Cleanup after measuring
timers2.forEach(clearTimeout)
controllers2.forEach((c) => c.abort())
handlers2.length = 0
console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`)
console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`)
expect(newGrowth).toBeLessThanOrEqual(oldGrowth)
})
})
@@ -1,4 +1,4 @@
import { test, expect, mock } from "bun:test" import { test, expect, mock, describe } from "bun:test"
import path from "path" import path from "path"
import { unlink } from "fs/promises" import { unlink } from "fs/promises"
@@ -266,3 +266,214 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
}, },
}) })
}) })
// Tests for cross-region inference profile prefix handling
// Models from models.dev may come with prefixes already (e.g., us., eu., global.)
// These should NOT be double-prefixed when passed to the SDK
test("Bedrock: model with us. prefix should not be double-prefixed", 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: {
"amazon-bedrock": {
options: {
region: "us-east-1",
},
models: {
"us.anthropic.claude-opus-4-5-20251101-v1:0": {
name: "Claude Opus 4.5 (US)",
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["amazon-bedrock"]).toBeDefined()
// The model should exist with the us. prefix
expect(providers["amazon-bedrock"].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
test("Bedrock: model with global. prefix should not be prefixed", 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: {
"amazon-bedrock": {
options: {
region: "us-east-1",
},
models: {
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
name: "Claude Opus 4.5 (Global)",
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
test("Bedrock: model with eu. prefix should not be double-prefixed", 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: {
"amazon-bedrock": {
options: {
region: "eu-west-1",
},
models: {
"eu.anthropic.claude-opus-4-5-20251101-v1:0": {
name: "Claude Opus 4.5 (EU)",
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["amazon-bedrock"]).toBeDefined()
expect(providers["amazon-bedrock"].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
test("Bedrock: model without prefix in US region should get us. prefix added", 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: {
"amazon-bedrock": {
options: {
region: "us-east-1",
},
models: {
"anthropic.claude-opus-4-5-20251101-v1:0": {
name: "Claude Opus 4.5",
},
},
},
},
}),
)
},
})
await Instance.provide({
directory: tmp.path,
init: async () => {
Env.set("AWS_PROFILE", "default")
},
fn: async () => {
const providers = await Provider.list()
expect(providers["amazon-bedrock"]).toBeDefined()
// Non-prefixed model should still be registered
expect(providers["amazon-bedrock"].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
// Direct unit tests for cross-region inference profile prefix handling
// These test the prefix detection logic used in getModel
describe("Bedrock cross-region prefix detection", () => {
const crossRegionPrefixes = ["global.", "us.", "eu.", "jp.", "apac.", "au."]
test("should detect global. prefix", () => {
const modelID = "global.anthropic.claude-opus-4-5-20251101-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should detect us. prefix", () => {
const modelID = "us.anthropic.claude-opus-4-5-20251101-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should detect eu. prefix", () => {
const modelID = "eu.anthropic.claude-opus-4-5-20251101-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should detect jp. prefix", () => {
const modelID = "jp.anthropic.claude-sonnet-4-20250514-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should detect apac. prefix", () => {
const modelID = "apac.anthropic.claude-sonnet-4-20250514-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should detect au. prefix", () => {
const modelID = "au.anthropic.claude-sonnet-4-5-20250929-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(true)
})
test("should NOT detect prefix for non-prefixed model", () => {
const modelID = "anthropic.claude-opus-4-5-20251101-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(false)
})
test("should NOT detect prefix for amazon nova models", () => {
const modelID = "amazon.nova-pro-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(false)
})
test("should NOT detect prefix for cohere models", () => {
const modelID = "cohere.command-r-plus-v1:0"
const hasPrefix = crossRegionPrefixes.some((prefix) => modelID.startsWith(prefix))
expect(hasPrefix).toBe(false)
})
})
@@ -293,6 +293,229 @@ describe("ProviderTransform.schema - gemini array items", () => {
}) })
}) })
describe("ProviderTransform.schema - gemini nested array items", () => {
const geminiModel = {
providerID: "google",
api: {
id: "gemini-3-pro",
},
} as any
test("adds type to 2D array with empty inner items", () => {
const schema = {
type: "object",
properties: {
values: {
type: "array",
items: {
type: "array",
items: {}, // Empty items object
},
},
},
} as any
const result = ProviderTransform.schema(geminiModel, schema) as any
// Inner items should have a default type
expect(result.properties.values.items.items.type).toBe("string")
})
test("adds items and type to 2D array with missing inner items", () => {
const schema = {
type: "object",
properties: {
data: {
type: "array",
items: { type: "array" }, // No items at all
},
},
} as any
const result = ProviderTransform.schema(geminiModel, schema) as any
expect(result.properties.data.items.items).toBeDefined()
expect(result.properties.data.items.items.type).toBe("string")
})
test("handles deeply nested arrays (3D)", () => {
const schema = {
type: "object",
properties: {
matrix: {
type: "array",
items: {
type: "array",
items: {
type: "array",
// No items
},
},
},
},
} as any
const result = ProviderTransform.schema(geminiModel, schema) as any
expect(result.properties.matrix.items.items.items).toBeDefined()
expect(result.properties.matrix.items.items.items.type).toBe("string")
})
test("preserves existing item types in nested arrays", () => {
const schema = {
type: "object",
properties: {
numbers: {
type: "array",
items: {
type: "array",
items: { type: "number" }, // Has explicit type
},
},
},
} as any
const result = ProviderTransform.schema(geminiModel, schema) as any
// Should preserve the explicit type
expect(result.properties.numbers.items.items.type).toBe("number")
})
test("handles mixed nested structures with objects and arrays", () => {
const schema = {
type: "object",
properties: {
spreadsheetData: {
type: "object",
properties: {
rows: {
type: "array",
items: {
type: "array",
items: {}, // Empty items
},
},
},
},
},
} as any
const result = ProviderTransform.schema(geminiModel, schema) as any
expect(result.properties.spreadsheetData.properties.rows.items.items.type).toBe("string")
})
})
describe("ProviderTransform.schema - gemini non-object properties removal", () => {
const geminiModel = {
providerID: "google",
api: {
id: "gemini-3-pro",
},
} as any
test("removes properties from non-object types", () => {
const schema = {
type: "object",
properties: {
data: {
type: "string",
properties: { invalid: { type: "string" } },
},
},
} as any
const result = ProviderTransform.schema(geminiModel, schema) as any
expect(result.properties.data.type).toBe("string")
expect(result.properties.data.properties).toBeUndefined()
})
test("removes required from non-object types", () => {
const schema = {
type: "object",
properties: {
data: {
type: "array",
items: { type: "string" },
required: ["invalid"],
},
},
} as any
const result = ProviderTransform.schema(geminiModel, schema) as any
expect(result.properties.data.type).toBe("array")
expect(result.properties.data.required).toBeUndefined()
})
test("removes properties and required from nested non-object types", () => {
const schema = {
type: "object",
properties: {
outer: {
type: "object",
properties: {
inner: {
type: "number",
properties: { bad: { type: "string" } },
required: ["bad"],
},
},
},
},
} as any
const result = ProviderTransform.schema(geminiModel, schema) as any
expect(result.properties.outer.properties.inner.type).toBe("number")
expect(result.properties.outer.properties.inner.properties).toBeUndefined()
expect(result.properties.outer.properties.inner.required).toBeUndefined()
})
test("keeps properties and required on object types", () => {
const schema = {
type: "object",
properties: {
data: {
type: "object",
properties: { name: { type: "string" } },
required: ["name"],
},
},
} as any
const result = ProviderTransform.schema(geminiModel, schema) as any
expect(result.properties.data.type).toBe("object")
expect(result.properties.data.properties).toBeDefined()
expect(result.properties.data.required).toEqual(["name"])
})
test("does not affect non-gemini providers", () => {
const openaiModel = {
providerID: "openai",
api: {
id: "gpt-4",
},
} as any
const schema = {
type: "object",
properties: {
data: {
type: "string",
properties: { invalid: { type: "string" } },
},
},
} as any
const result = ProviderTransform.schema(openaiModel, schema) as any
expect(result.properties.data.properties).toBeDefined()
})
})
describe("ProviderTransform.message - DeepSeek reasoning content", () => { describe("ProviderTransform.message - DeepSeek reasoning content", () => {
test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => {
const msgs = [ const msgs = [
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test" import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import path from "path" import path from "path"
import { InstructionPrompt } from "../../src/session/instruction" import { InstructionPrompt } from "../../src/session/instruction"
import { Instance } from "../../src/project/instance" import { Instance } from "../../src/project/instance"
import { Global } from "../../src/global"
import { tmpdir } from "../fixture/fixture" import { tmpdir } from "../fixture/fixture"
describe("InstructionPrompt.resolve", () => { describe("InstructionPrompt.resolve", () => {
@@ -68,3 +69,102 @@ describe("InstructionPrompt.resolve", () => {
}) })
}) })
}) })
describe("InstructionPrompt.systemPaths OPENCODE_CONFIG_DIR", () => {
let originalConfigDir: string | undefined
beforeEach(() => {
originalConfigDir = process.env["OPENCODE_CONFIG_DIR"]
})
afterEach(() => {
if (originalConfigDir === undefined) {
delete process.env["OPENCODE_CONFIG_DIR"]
} else {
process.env["OPENCODE_CONFIG_DIR"] = originalConfigDir
}
})
test("prefers OPENCODE_CONFIG_DIR AGENTS.md over global when both exist", async () => {
await using profileTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Profile Instructions")
},
})
await using globalTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
},
})
await using projectTmp = await tmpdir()
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
const originalGlobalConfig = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await InstructionPrompt.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
},
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
}
})
test("falls back to global AGENTS.md when OPENCODE_CONFIG_DIR has no AGENTS.md", async () => {
await using profileTmp = await tmpdir()
await using globalTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
},
})
await using projectTmp = await tmpdir()
process.env["OPENCODE_CONFIG_DIR"] = profileTmp.path
const originalGlobalConfig = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await InstructionPrompt.systemPaths()
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
}
})
test("uses global AGENTS.md when OPENCODE_CONFIG_DIR is not set", async () => {
await using globalTmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "AGENTS.md"), "# Global Instructions")
},
})
await using projectTmp = await tmpdir()
delete process.env["OPENCODE_CONFIG_DIR"]
const originalGlobalConfig = Global.Path.config
;(Global.Path as { config: string }).config = globalTmp.path
try {
await Instance.provide({
directory: projectTmp.path,
fn: async () => {
const paths = await InstructionPrompt.systemPaths()
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
},
})
} finally {
;(Global.Path as { config: string }).config = originalGlobalConfig
}
})
})
+203
View File
@@ -55,6 +55,42 @@ Instructions here.
}) })
}) })
test("returns skill directories from Skill.dirs", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "dir-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: dir-skill
description: Skill for dirs test.
---
# Dir Skill
`,
)
},
})
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const dirs = await Skill.dirs()
const skillDir = path.join(tmp.path, ".opencode", "skill", "dir-skill")
expect(dirs).toContain(skillDir)
expect(dirs.length).toBe(1)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
test("discovers multiple skills from .opencode/skill/ directory", async () => { test("discovers multiple skills from .opencode/skill/ directory", async () => {
await using tmp = await tmpdir({ await using tmp = await tmpdir({
git: true, git: true,
@@ -183,3 +219,170 @@ test("returns empty array when no skills exist", async () => {
}, },
}) })
}) })
test("discovers skills from .agents/skills/ directory", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".agents", "skills", "agent-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(1)
const agentSkill = skills.find((s) => s.name === "agent-skill")
expect(agentSkill).toBeDefined()
expect(agentSkill!.location).toContain(".agents/skills/agent-skill/SKILL.md")
},
})
})
test("discovers global skills from ~/.agents/skills/ directory", async () => {
await using tmp = await tmpdir({ git: true })
const originalHome = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
await fs.mkdir(skillDir, { recursive: true })
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: global-agent-skill
description: A global skill from ~/.agents/skills for testing.
---
# Global Agent Skill
This skill is loaded from the global home directory.
`,
)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(1)
expect(skills[0].name).toBe("global-agent-skill")
expect(skills[0].description).toBe("A global skill from ~/.agents/skills for testing.")
expect(skills[0].location).toContain(".agents/skills/global-agent-skill/SKILL.md")
},
})
} finally {
process.env.OPENCODE_TEST_HOME = originalHome
}
})
test("discovers skills from both .claude/skills/ and .agents/skills/", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
await Bun.write(
path.join(claudeDir, "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
)
await Bun.write(
path.join(agentDir, "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const skills = await Skill.all()
expect(skills.length).toBe(2)
expect(skills.find((s) => s.name === "claude-skill")).toBeDefined()
expect(skills.find((s) => s.name === "agent-skill")).toBeDefined()
},
})
})
test("properly resolves directories that skills live in", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const opencodeSkillDir = path.join(dir, ".opencode", "skill", "agent-skill")
const opencodeSkillsDir = path.join(dir, ".opencode", "skills", "agent-skill")
const claudeDir = path.join(dir, ".claude", "skills", "claude-skill")
const agentDir = path.join(dir, ".agents", "skills", "agent-skill")
await Bun.write(
path.join(claudeDir, "SKILL.md"),
`---
name: claude-skill
description: A skill in the .claude/skills directory.
---
# Claude Skill
`,
)
await Bun.write(
path.join(agentDir, "SKILL.md"),
`---
name: agent-skill
description: A skill in the .agents/skills directory.
---
# Agent Skill
`,
)
await Bun.write(
path.join(opencodeSkillDir, "SKILL.md"),
`---
name: opencode-skill
description: A skill in the .opencode/skill directory.
---
# OpenCode Skill
`,
)
await Bun.write(
path.join(opencodeSkillsDir, "SKILL.md"),
`---
name: opencode-skill
description: A skill in the .opencode/skills directory.
---
# OpenCode Skill
`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const dirs = await Skill.dirs()
expect(dirs.length).toBe(4)
},
})
})
+112
View File
@@ -0,0 +1,112 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import type { PermissionNext } from "../../src/permission/next"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"
import { tmpdir } from "../fixture/fixture"
const baseCtx: Omit<Tool.Context, "ask"> = {
sessionID: "test",
messageID: "",
callID: "",
agent: "build",
abort: AbortSignal.any([]),
messages: [],
metadata: () => {},
}
describe("tool.skill", () => {
test("description lists skill location URL", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "tool-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: tool-skill
description: Skill for tool tests.
---
# Tool Skill
`,
)
},
})
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tool = await SkillTool.init()
const skillPath = path.join(tmp.path, ".opencode", "skill", "tool-skill", "SKILL.md")
expect(tool.description).toContain(`<location>${pathToFileURL(skillPath).href}</location>`)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
test("execute returns skill content block with files", async () => {
await using tmp = await tmpdir({
git: true,
init: async (dir) => {
const skillDir = path.join(dir, ".opencode", "skill", "tool-skill")
await Bun.write(
path.join(skillDir, "SKILL.md"),
`---
name: tool-skill
description: Skill for tool tests.
---
# Tool Skill
Use this skill.
`,
)
await Bun.write(path.join(skillDir, "scripts", "demo.txt"), "demo")
},
})
const home = process.env.OPENCODE_TEST_HOME
process.env.OPENCODE_TEST_HOME = tmp.path
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tool = await SkillTool.init()
const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
const ctx: Tool.Context = {
...baseCtx,
ask: async (req) => {
requests.push(req)
},
}
const result = await tool.execute({ name: "tool-skill" }, ctx)
const dir = path.join(tmp.path, ".opencode", "skill", "tool-skill")
const file = path.resolve(dir, "scripts", "demo.txt")
expect(requests.length).toBe(1)
expect(requests[0].permission).toBe("skill")
expect(requests[0].patterns).toContain("tool-skill")
expect(requests[0].always).toContain("tool-skill")
expect(result.metadata.dir).toBe(dir)
expect(result.output).toContain(`<skill_content name="tool-skill">`)
expect(result.output).toContain(`Base directory for this skill: ${pathToFileURL(dir).href}`)
expect(result.output).toContain(`<file>${file}</file>`)
},
})
} finally {
process.env.OPENCODE_TEST_HOME = home
}
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.1.49", "version": "1.1.51",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1
View File
@@ -185,6 +185,7 @@ export interface Hooks {
input: { tool: string; sessionID: string; callID: string }, input: { tool: string; sessionID: string; callID: string },
output: { args: any }, output: { args: any },
) => Promise<void> ) => Promise<void>
"shell.env"?: (input: { cwd: string }, output: { env: Record<string, string> }) => Promise<void>
"tool.execute.after"?: ( "tool.execute.after"?: (
input: { tool: string; sessionID: string; callID: string }, input: { tool: string; sessionID: string; callID: string },
output: { output: {
+2 -2
View File
@@ -70,8 +70,8 @@ export const Script = {
get preview() { get preview() {
return IS_PREVIEW return IS_PREVIEW
}, },
get release() { get release(): boolean {
return env.OPENCODE_RELEASE return !!env.OPENCODE_RELEASE
}, },
get team() { get team() {
return team return team
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.1.49", "version": "1.1.51",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+9 -2
View File
@@ -1403,9 +1403,9 @@ export type AgentConfig = {
[key: string]: unknown [key: string]: unknown
} }
/** /**
* Hex color code for the agent (e.g., #FF5733) * Hex color code (e.g., #FF5733) or theme color (e.g., primary)
*/ */
color?: string color?: string | "primary" | "secondary" | "accent" | "success" | "warning" | "error" | "info"
/** /**
* Maximum number of agentic iterations before forcing text-only response * Maximum number of agentic iterations before forcing text-only response
*/ */
@@ -1430,6 +1430,13 @@ export type AgentConfig = {
[key: string]: unknown [key: string]: unknown
} }
| string | string
| "primary"
| "secondary"
| "accent"
| "success"
| "warning"
| "error"
| "info"
| number | number
| PermissionConfig | PermissionConfig
| undefined | undefined
+11 -3
View File
@@ -9071,9 +9071,17 @@
"additionalProperties": {} "additionalProperties": {}
}, },
"color": { "color": {
"description": "Hex color code for the agent (e.g., #FF5733)", "description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
"type": "string", "anyOf": [
"pattern": "^#[0-9a-fA-F]{6}$" {
"type": "string",
"pattern": "^#[0-9a-fA-F]{6}$"
},
{
"type": "string",
"enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"]
}
]
}, },
"steps": { "steps": {
"description": "Maximum number of agentic iterations before forcing text-only response", "description": "Maximum number of agentic iterations before forcing text-only response",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.1.49", "version": "1.1.51",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.1.49", "version": "1.1.51",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
+1 -1
View File
@@ -63,7 +63,7 @@ export function Tooltip(props: TooltipProps) {
<Switch> <Switch>
<Match when={local.inactive}>{local.children}</Match> <Match when={local.inactive}>{local.children}</Match>
<Match when={true}> <Match when={true}>
<KobalteTooltip forceMount gutter={4} {...others} open={local.forceOpen || open()} onOpenChange={setOpen}> <KobalteTooltip gutter={4} {...others} open={local.forceOpen || open()} onOpenChange={setOpen}>
<KobalteTooltip.Trigger as={"div"} data-component="tooltip-trigger" class={local.class}> <KobalteTooltip.Trigger as={"div"} data-component="tooltip-trigger" class={local.class}>
{c()} {c()}
</KobalteTooltip.Trigger> </KobalteTooltip.Trigger>
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.1.49", "version": "1.1.51",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.1.49", "version": "1.1.51",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+33 -6
View File
@@ -46,7 +46,7 @@ OpenCode comes with two built-in primary agents and two built-in subagents.
--- ---
### Build ### Use build
_Mode_: `primary` _Mode_: `primary`
@@ -54,7 +54,7 @@ Build is the **default** primary agent with all tools enabled. This is the stand
--- ---
### Plan ### Use plan
_Mode_: `primary` _Mode_: `primary`
@@ -68,7 +68,7 @@ This agent is useful when you want the LLM to analyze code, suggest changes, or
--- ---
### General ### Use general
_Mode_: `subagent` _Mode_: `subagent`
@@ -76,7 +76,7 @@ A general-purpose agent for researching complex questions and executing multi-st
--- ---
### Explore ### Use explore
_Mode_: `subagent` _Mode_: `subagent`
@@ -84,6 +84,30 @@ A fast, read-only agent for exploring codebases. Cannot modify files. Use this w
--- ---
### Use compaction
_Mode_: `primary`
Hidden system agent that compacts long context into a smaller summary. It runs automatically when needed and is not selectable in the UI.
---
### Use title
_Mode_: `primary`
Hidden system agent that generates short session titles. It runs automatically and is not selectable in the UI.
---
### Use summary
_Mode_: `primary`
Hidden system agent that creates session summaries. It runs automatically and is not selectable in the UI.
---
## Usage ## Usage
1. For primary agents, use the **Tab** key to cycle through them during a session. You can also use your configured `switch_agent` keybind. 1. For primary agents, use the **Tab** key to cycle through them during a session. You can also use your configured `switch_agent` keybind.
@@ -576,18 +600,21 @@ Users can always invoke any subagent directly via the `@` autocomplete menu, eve
Customize the agent's visual appearance in the UI with the `color` option. This affects how the agent appears in the interface. Customize the agent's visual appearance in the UI with the `color` option. This affects how the agent appears in the interface.
Use a valid hex color (e.g., `#FF5733`) or theme color: `primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info`.
```json title="opencode.json" ```json title="opencode.json"
{ {
"agent": { "agent": {
"creative": { "creative": {
"color": "#ff6b6b" "color": "#ff6b6b"
},
"code-reviewer": {
"color": "accent"
} }
} }
} }
``` ```
Must be a valid hex color code like `#FF5733`.
--- ---
### Top P ### Top P
+10
View File
@@ -427,6 +427,12 @@ opencode export [sessionID]
If you don't provide a session ID, you'll be prompted to select from available sessions. If you don't provide a session ID, you'll be prompted to select from available sessions.
You can redirect the output to save to a file for further investigation:
```bash
opencode export > export.json
```
--- ---
### import ### import
@@ -569,10 +575,14 @@ OpenCode can be configured using environment variables.
| `OPENCODE_DISABLE_CLAUDE_CODE` | boolean | Disable reading from `.claude` (prompt + skills) | | `OPENCODE_DISABLE_CLAUDE_CODE` | boolean | Disable reading from `.claude` (prompt + skills) |
| `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` | | `OPENCODE_DISABLE_CLAUDE_CODE_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` |
| `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` | | `OPENCODE_DISABLE_CLAUDE_CODE_SKILLS` | boolean | Disable loading `.claude/skills` |
| `OPENCODE_DISABLE_MODELS_FETCH` | boolean | Disable fetching models from remote sources |
| `OPENCODE_FAKE_VCS` | string | Fake VCS provider for testing purposes |
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Disable file time checking for optimization |
| `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) | | `OPENCODE_CLIENT` | string | Client identifier (defaults to `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Enable Exa web search tools | | `OPENCODE_ENABLE_EXA` | boolean | Enable Exa web search tools |
| `OPENCODE_SERVER_PASSWORD` | string | Enable basic auth for `serve`/`web` | | `OPENCODE_SERVER_PASSWORD` | string | Enable basic auth for `serve`/`web` |
| `OPENCODE_SERVER_USERNAME` | string | Override basic auth username (default `opencode`) | | `OPENCODE_SERVER_USERNAME` | string | Override basic auth username (default `opencode`) |
| `OPENCODE_MODELS_URL` | string | Custom URL for fetching models configuration |
--- ---
+2
View File
@@ -190,6 +190,7 @@ You can configure server settings for the `opencode serve` and `opencode web` co
"port": 4096, "port": 4096,
"hostname": "0.0.0.0", "hostname": "0.0.0.0",
"mdns": true, "mdns": true,
"mdnsDomain": "myproject.local",
"cors": ["http://localhost:5173"] "cors": ["http://localhost:5173"]
} }
} }
@@ -200,6 +201,7 @@ Available options:
- `port` - Port to listen on. - `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`. - `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. - `mdns` - Enable mDNS service discovery. This allows other devices on the network to discover your OpenCode server.
- `mdnsDomain` - Custom domain name for mDNS service. Defaults to `opencode.local`. Useful for running multiple instances on the same network.
- `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`. - `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). [Learn more about the server here](/docs/server).

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