mirror of
https://github.com/anomalyco/opencode.git
synced 2026-02-09 18:34:21 +00:00
Compare commits
66 Commits
v1.1.49
...
docs-expor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f49834f301 | ||
|
|
93356021f4 | ||
|
|
c8622df762 | ||
|
|
c277ee8cbf | ||
|
|
a2face30f4 | ||
|
|
a219615fe5 | ||
|
|
af06175b1f | ||
|
|
2e8d8de58b | ||
|
|
310de8b1ea | ||
|
|
891875402c | ||
|
|
154cbf6996 | ||
|
|
64bafce665 | ||
|
|
5588453cbe | ||
|
|
5aaf8f8247 | ||
|
|
8c1f1f13dc | ||
|
|
b942e0b4dc | ||
|
|
f282613746 | ||
|
|
7c440ae82c | ||
|
|
b7bd561eaa | ||
|
|
6daa962aaa | ||
|
|
93a07e5a2a | ||
|
|
93e060272a | ||
|
|
acac05f22e | ||
|
|
b5a4671c64 | ||
|
|
a68fedd4a6 | ||
|
|
015cd404e4 | ||
|
|
9921809565 | ||
|
|
137336f373 | ||
|
|
d940d17918 | ||
|
|
0a5d5bc524 | ||
|
|
a30696f9bf | ||
|
|
25bdd77b1d | ||
|
|
2f12e8ee92 | ||
|
|
95211a8854 | ||
|
|
6b5cf936a2 | ||
|
|
17e62b050f | ||
|
|
ee84eb44ee | ||
|
|
82dd4b6908 | ||
|
|
185858749b | ||
|
|
39a504773c | ||
|
|
b7b734f51f | ||
|
|
dcff5b6596 | ||
|
|
4f7da2b757 | ||
|
|
60e616ec81 | ||
|
|
416964acd0 | ||
|
|
017ad2c7f7 | ||
|
|
e33eb1b058 | ||
|
|
857b8a4b56 | ||
|
|
3975329629 | ||
|
|
54e14c1a17 | ||
|
|
3741516fe3 | ||
|
|
76381f33d5 | ||
|
|
95d0d476e3 | ||
|
|
7508839b70 | ||
|
|
e88cbefabe | ||
|
|
a38bae684f | ||
|
|
08671e3155 | ||
|
|
0d557721cf | ||
|
|
e709808b32 | ||
|
|
0d22068c90 | ||
|
|
d116c227e0 | ||
|
|
3f07dffbb0 | ||
|
|
801e4a8a9d | ||
|
|
3adeed8f97 | ||
|
|
1275c71a63 | ||
|
|
ca8c23dd71 |
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
@@ -1,4 +1,5 @@
|
||||
# web + desktop packages
|
||||
packages/app/ @adamdotdevin
|
||||
packages/tauri/ @adamdotdevin
|
||||
packages/desktop/src-tauri/ @brendonovich
|
||||
packages/desktop/ @adamdotdevin
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,6 +5,7 @@ node_modules
|
||||
.env
|
||||
.idea
|
||||
.vscode
|
||||
.codex
|
||||
*~
|
||||
playground
|
||||
tmp
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
// "plugin": ["opencode-openai-codex-auth"],
|
||||
// "enterprise": {
|
||||
// "url": "https://enterprise.dev.opencode.ai",
|
||||
// },
|
||||
@@ -9,12 +8,7 @@
|
||||
"options": {},
|
||||
},
|
||||
},
|
||||
"mcp": {
|
||||
"context7": {
|
||||
"type": "remote",
|
||||
"url": "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
"mcp": {},
|
||||
"tools": {
|
||||
"github-triage": false,
|
||||
"github-pr-search": false,
|
||||
|
||||
16
README.md
16
README.md
@@ -82,7 +82,7 @@ The install script respects the following priority order for the installation pa
|
||||
|
||||
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
|
||||
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
|
||||
|
||||
```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.
|
||||
|
||||
- **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
|
||||
- Denies file edits by default
|
||||
- Asks permission before running bash commands
|
||||
- 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.
|
||||
|
||||
Learn more about [agents](https://opencode.ai/docs/agents).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -116,7 +116,7 @@ If you're interested in contributing to OpenCode, please read our [contributing
|
||||
|
||||
### 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
|
||||
|
||||
@@ -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:
|
||||
|
||||
- 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.
|
||||
- Out of the box LSP support
|
||||
- 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
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
63
bun.lock
63
bun.lock
@@ -23,7 +23,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -73,7 +73,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -107,7 +107,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -134,7 +134,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -158,7 +158,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -182,7 +182,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -213,7 +213,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -242,7 +242,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -258,7 +258,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -307,8 +307,9 @@
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "2.3.1",
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.4",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
@@ -362,7 +363,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -382,7 +383,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -393,7 +394,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -406,7 +407,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -448,7 +449,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -459,7 +460,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@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/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/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/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-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-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-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=="],
|
||||
|
||||
@@ -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/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/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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-yIrljJgOR1GZCAXi5bx+YvrIAjSkTAMTSzlhLFY/ufE=",
|
||||
"aarch64-linux": "sha256-Xa3BgqbuD5Cx5OpyVSN1v7Klge449hPqR1GY9E9cAX0=",
|
||||
"aarch64-darwin": "sha256-Q3FKm7+4Jr3PL+TnQngrTtv/xdek2st5HmgeoEOHUis=",
|
||||
"x86_64-darwin": "sha256-asJ8DBvIgkqh8HhrN48M/L4xj1kwv+uyQMy9bN2HxuM="
|
||||
"x86_64-linux": "sha256-9XlAYCNdBhw8NmfJoYNjvQYhSn02rFhWvbJtlOnnCjc=",
|
||||
"aarch64-linux": "sha256-Mdz3gAy8auN7mhMHRaWyH/exHGO9eYDyUMQKqscg6Xc=",
|
||||
"aarch64-darwin": "sha256-NDB6+NVZ4+9+Yds/cjEGQAn9Tl/LRuEjEH6wV5dTdVg=",
|
||||
"x86_64-darwin": "sha256-LGJ5TJYgyK8Vn0BliEeJdoblcubj5ZIjvJoUtdVXfvU="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test as base, expect } from "@playwright/test"
|
||||
import { seedProjects } from "./actions"
|
||||
import { test as base, expect, type Page } from "@playwright/test"
|
||||
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
|
||||
import { promptSelector } from "./selectors"
|
||||
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
||||
|
||||
@@ -8,6 +8,14 @@ export const settingsKey = "settings.v3"
|
||||
type TestFixtures = {
|
||||
sdk: ReturnType<typeof createSdk>
|
||||
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 = {
|
||||
@@ -33,17 +41,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
await use(createSdk(directory))
|
||||
},
|
||||
gotoSession: async ({ page, directory }, use) => {
|
||||
await seedProjects(page, { directory })
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:model",
|
||||
JSON.stringify({
|
||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
||||
user: [],
|
||||
variant: {},
|
||||
}),
|
||||
)
|
||||
})
|
||||
await seedStorage(page, { directory })
|
||||
|
||||
const gotoSession = async (sessionID?: string) => {
|
||||
await page.goto(sessionPath(directory, sessionID))
|
||||
@@ -51,6 +49,39 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||
}
|
||||
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 }
|
||||
|
||||
@@ -1,52 +1,53 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { openSidebar } from "../actions"
|
||||
|
||||
test("dialog edit project updates name and startup script", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
|
||||
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()
|
||||
await header.hover()
|
||||
const trigger = header.getByRole("button", { name: "More options" }).first()
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click({ force: true })
|
||||
await expect(header).toContainText(name)
|
||||
|
||||
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 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)
|
||||
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 { createTestProject, seedProjects, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
|
||||
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
|
||||
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 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
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()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.hover()
|
||||
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||
await expect(close).toBeVisible()
|
||||
await close.click()
|
||||
|
||||
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
|
||||
await expect(close).toBeVisible()
|
||||
await close.click()
|
||||
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
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 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherName = other.split("/").pop() ?? other
|
||||
const otherSlug = dirSlug(other)
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
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(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
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
|
||||
.locator(".group\\/project")
|
||||
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
|
||||
.first()
|
||||
await expect(header).toContainText(otherName)
|
||||
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
|
||||
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
|
||||
await expect(trigger).toHaveCount(1)
|
||||
await trigger.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||
await expect(menu).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||
await expect(otherButton).toHaveCount(0)
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
|
||||
@@ -1,33 +1,34 @@
|
||||
import { test, expect } from "../fixtures"
|
||||
import { defocus, createTestProject, seedProjects, cleanupTestProject } from "../actions"
|
||||
import { defocus, createTestProject, cleanupTestProject } from "../actions"
|
||||
import { projectSwitchSelector } from "../selectors"
|
||||
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 })
|
||||
|
||||
const other = await createTestProject()
|
||||
const otherSlug = dirSlug(other)
|
||||
|
||||
await seedProjects(page, { directory, extra: [other] })
|
||||
|
||||
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)
|
||||
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||
await expect(otherButton).toBeVisible()
|
||||
await otherButton.click()
|
||||
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||
|
||||
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(currentButton).toBeVisible()
|
||||
await currentButton.click()
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||
},
|
||||
{ extra: [other] },
|
||||
)
|
||||
} finally {
|
||||
await cleanupTestProject(other)
|
||||
}
|
||||
|
||||
@@ -10,33 +10,20 @@ import {
|
||||
cleanupTestProject,
|
||||
clickMenuItem,
|
||||
confirmDialog,
|
||||
createTestProject,
|
||||
openSidebar,
|
||||
openWorkspaceMenu,
|
||||
seedProjects,
|
||||
setWorkspacesEnabled,
|
||||
} from "../actions"
|
||||
import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors"
|
||||
import { dirSlug } from "../utils"
|
||||
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||
|
||||
function slugFromUrl(url: string) {
|
||||
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
|
||||
}
|
||||
|
||||
async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise<void>) {
|
||||
const project = await createTestProject()
|
||||
const rootSlug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
|
||||
await gotoSession()
|
||||
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
|
||||
const rootSlug = project.slug
|
||||
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 page.getByRole("button", { name: "New workspace" }).first().click()
|
||||
@@ -70,25 +57,13 @@ async function setupWorkspaceTest(page: Page, directory: string, gotoSession: ()
|
||||
)
|
||||
.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 })
|
||||
|
||||
const project = await createTestProject()
|
||||
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 withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
|
||||
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 expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||
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 })
|
||||
|
||||
const project = await createTestProject()
|
||||
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 withProject(async ({ slug }) => {
|
||||
await openSidebar(page)
|
||||
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 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 })
|
||||
|
||||
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 menu = await openWorkspaceMenu(page, slug)
|
||||
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.press("Enter")
|
||||
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 })
|
||||
|
||||
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 extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
||||
const original = await fs.readFile(readme, "utf8")
|
||||
@@ -250,17 +207,15 @@ test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
|
||||
.catch(() => 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 })
|
||||
|
||||
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)
|
||||
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||
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.locator(workspaceItemSelector(slug))).toHaveCount(0)
|
||||
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 withProject(async ({ slug: rootSlug }) => {
|
||||
const workspaces = [] as { directory: string; slug: string }[]
|
||||
|
||||
const project = await createTestProject()
|
||||
const rootSlug = dirSlug(project)
|
||||
await seedProjects(page, { directory, extra: [project] })
|
||||
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 workspaces = [] as { directory: string; 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()
|
||||
const waitReady = async (slug: string) => {
|
||||
await expect
|
||||
.poll(
|
||||
() => {
|
||||
const slug = slugFromUrl(page.url())
|
||||
return slug.length > 0 && slug !== rootSlug && slug !== prev
|
||||
async () => {
|
||||
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||
try {
|
||||
await item.hover({ timeout: 500 })
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
{ timeout: 45_000 },
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
const slug = slugFromUrl(page.url())
|
||||
const dir = base64Decode(slug)
|
||||
workspaces.push({ slug, directory: dir })
|
||||
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 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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -6,7 +6,6 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
|
||||
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
|
||||
const reuse = !process.env.CI
|
||||
const win = process.platform === "win32"
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./e2e",
|
||||
@@ -15,8 +14,7 @@ export default defineConfig({
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
fullyParallel: !win,
|
||||
workers: win ? 1 : undefined,
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
|
||||
|
||||
@@ -241,19 +241,19 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
protected _rowEnd(row: number, isLastRow: boolean): void {
|
||||
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._nullCellCount = 0
|
||||
}
|
||||
|
||||
if (!isLastRow) {
|
||||
const nextLine = this._buffer.getLine(row + 1)
|
||||
this._nullCellCount = 0
|
||||
|
||||
if (!nextLine?.isWrapped) {
|
||||
rowSeparator = "\r\n"
|
||||
this._lastCursorRow = row + 1
|
||||
this._lastCursorCol = 0
|
||||
}
|
||||
if (!isLastRow && !wrapped) {
|
||||
rowSeparator = "\r\n"
|
||||
this._lastCursorRow = row + 1
|
||||
this._lastCursorCol = 0
|
||||
}
|
||||
|
||||
this._allRows[this._rowIndex] = this._currentRow
|
||||
@@ -389,7 +389,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
|
||||
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 (this._nullCellCount > 0) {
|
||||
@@ -442,12 +442,24 @@ class StringSerializeHandler extends BaseSerializeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
if (!excludeFinalCursorPosition) {
|
||||
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
|
||||
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
|
||||
const cursorCol = this._buffer.cursorX + 1
|
||||
content += `\u001b[${cursorRow};${cursorCol}H`
|
||||
}
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -158,22 +158,22 @@ export function DialogEditProject(props: { project: LocalProject }) {
|
||||
</Show>
|
||||
</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={{
|
||||
"opacity-100": 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
|
||||
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={{
|
||||
"opacity-100": 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>
|
||||
<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 { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Keybind } from "@opencode-ai/ui/keybind"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { base64Encode } from "@opencode-ai/util/encode"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/util/path"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, onCleanup, Show } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
|
||||
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 { useFile } from "@/context/file"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
|
||||
type EntryType = "command" | "file"
|
||||
type EntryType = "command" | "file" | "session"
|
||||
|
||||
type Entry = {
|
||||
id: string
|
||||
@@ -22,6 +27,9 @@ type Entry = {
|
||||
category: string
|
||||
option?: CommandOption
|
||||
path?: string
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
archived?: number
|
||||
}
|
||||
|
||||
type DialogSelectFileMode = "all" | "files"
|
||||
@@ -33,6 +41,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const file = useFile()
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const globalSync = useGlobalSync()
|
||||
const filesOnly = () => props.mode === "files"
|
||||
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
|
||||
const tabs = createMemo(() => layout.tabs(sessionKey))
|
||||
@@ -73,6 +84,52 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
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 picks = createMemo(() => {
|
||||
@@ -122,6 +179,68 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
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 query = text.trim()
|
||||
setGrouped(query.length > 0)
|
||||
@@ -146,9 +265,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
const files = await file.searchFiles(query)
|
||||
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)
|
||||
return [...list(), ...entries]
|
||||
return [...list(), ...nextSessions, ...entries]
|
||||
}
|
||||
|
||||
const handleMove = (item: Entry | undefined) => {
|
||||
@@ -178,6 +298,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
return
|
||||
}
|
||||
|
||||
if (item.type === "session") {
|
||||
if (!item.directory || !item.sessionID) return
|
||||
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!item.path) return
|
||||
open(item.path)
|
||||
}
|
||||
@@ -202,13 +328,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
items={items}
|
||||
key={(item) => item.id}
|
||||
filterKeys={["title", "description", "category"]}
|
||||
groupBy={(item) => item.category}
|
||||
groupBy={grouped() ? (item) => item.category : () => ""}
|
||||
onMove={handleMove}
|
||||
onSelect={handleSelect}
|
||||
>
|
||||
{(item) => (
|
||||
<Show
|
||||
when={item.type === "command"}
|
||||
<Switch
|
||||
fallback={
|
||||
<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">
|
||||
@@ -223,18 +348,43 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="w-full flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
|
||||
<Show when={item.description}>
|
||||
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
|
||||
<Match when={item.type === "command"}>
|
||||
<div class="w-full flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</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>
|
||||
</div>
|
||||
<Show when={item.keybind}>
|
||||
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={item.type === "session"}>
|
||||
<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">
|
||||
<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>
|
||||
</Dialog>
|
||||
|
||||
@@ -54,7 +54,6 @@ const ModelList: Component<{
|
||||
class="w-full"
|
||||
placement="right-start"
|
||||
gutter={12}
|
||||
forceMount={false}
|
||||
value={
|
||||
<ModelTooltip
|
||||
model={item}
|
||||
@@ -214,7 +213,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
class="p-1"
|
||||
action={
|
||||
<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
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
@@ -224,7 +223,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
|
||||
onClick={handleConnectProvider}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip placement="top" forceMount={false} value={language.t("dialog.model.manage")}>
|
||||
<Tooltip placement="top" value={language.t("dialog.model.manage")}>
|
||||
<IconButton
|
||||
icon="sliders"
|
||||
variant="ghost"
|
||||
|
||||
@@ -321,7 +321,6 @@ export default function FileTree(props: {
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
forceMount={false}
|
||||
openDelay={2000}
|
||||
placement="bottom-start"
|
||||
class="w-full"
|
||||
|
||||
@@ -1132,7 +1132,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const images = imageAttachments().slice()
|
||||
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()
|
||||
return
|
||||
}
|
||||
@@ -1220,7 +1220,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
})
|
||||
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
|
||||
|
||||
@@ -1934,13 +1937,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
<Button
|
||||
as="div"
|
||||
variant="ghost"
|
||||
class="px-2 min-w-0 max-w-[140px]"
|
||||
class="px-2 min-w-0 max-w-[240px]"
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
<span class="truncate max-w-[100px]">
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
@@ -1956,12 +1959,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<ModelSelectorPopover
|
||||
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}>
|
||||
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
|
||||
</Show>
|
||||
<span class="truncate max-w-[100px]">
|
||||
<span class="truncate">
|
||||
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0" />
|
||||
@@ -2068,7 +2071,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
>
|
||||
<IconButton
|
||||
type="submit"
|
||||
disabled={!prompt.dirty() && !working()}
|
||||
disabled={!prompt.dirty() && !working() && commentCount() === 0}
|
||||
icon={working() ? "stop" : "arrow-up"}
|
||||
variant="primary"
|
||||
class="h-6 w-4.5"
|
||||
|
||||
@@ -226,11 +226,11 @@ export const SettingsProviders: Component = () => {
|
||||
</For>
|
||||
|
||||
<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"
|
||||
>
|
||||
<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" />
|
||||
<span class="text-14-medium text-text-strong">Custom provider</span>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
|
||||
@@ -146,6 +146,7 @@ export const Terminal = (props: TerminalProps) => {
|
||||
const once = { value: false }
|
||||
|
||||
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) {
|
||||
url.username = "opencode"
|
||||
url.password = window.__OPENCODE__?.serverPassword
|
||||
|
||||
@@ -33,6 +33,14 @@ type SessionTabs = {
|
||||
type SessionView = {
|
||||
scroll: Record<string, SessionScroll>
|
||||
reviewOpen?: string[]
|
||||
pendingMessage?: string
|
||||
pendingMessageAt?: number
|
||||
}
|
||||
|
||||
type TabHandoff = {
|
||||
dir: string
|
||||
id: string
|
||||
at: number
|
||||
}
|
||||
|
||||
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>,
|
||||
sessionView: {} as Record<string, SessionView>,
|
||||
handoff: {
|
||||
tabs: undefined as TabHandoff | undefined,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const MAX_SESSION_KEYS = 50
|
||||
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
|
||||
const meta = { active: undefined as string | undefined, pruned: false }
|
||||
const used = new Map<string, number>()
|
||||
|
||||
@@ -411,6 +423,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
return {
|
||||
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: {
|
||||
list,
|
||||
open(directory: string) {
|
||||
@@ -536,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
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>) {
|
||||
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
|
||||
|
||||
@@ -682,12 +747,15 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
if (!current) return
|
||||
|
||||
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(() => {
|
||||
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)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -44,7 +44,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "جلسة جديدة",
|
||||
"command.file.open": "فتح ملف",
|
||||
"command.file.open.description": "البحث في الملفات والأوامر",
|
||||
"command.context.addSelection": "إضافة التحديد إلى السياق",
|
||||
"command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي",
|
||||
"command.terminal.toggle": "تبديل المحطة الطرفية",
|
||||
@@ -84,7 +83,7 @@ export const dict = {
|
||||
"command.session.unshare": "إلغاء مشاركة الجلسة",
|
||||
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
|
||||
|
||||
"palette.search.placeholder": "البحث في الملفات والأوامر",
|
||||
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
|
||||
"palette.empty": "لا توجد نتائج",
|
||||
"palette.group.commands": "الأوامر",
|
||||
"palette.group.files": "الملفات",
|
||||
|
||||
@@ -44,7 +44,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Nova sessão",
|
||||
"command.file.open": "Abrir arquivo",
|
||||
"command.file.open.description": "Buscar arquivos e comandos",
|
||||
"command.context.addSelection": "Adicionar seleção ao contexto",
|
||||
"command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual",
|
||||
"command.terminal.toggle": "Alternar terminal",
|
||||
@@ -84,7 +83,7 @@ export const dict = {
|
||||
"command.session.unshare": "Parar de compartilhar 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.group.commands": "Comandos",
|
||||
"palette.group.files": "Arquivos",
|
||||
|
||||
@@ -44,7 +44,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Ny session",
|
||||
"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.description": "Tilføj markerede linjer fra den aktuelle fil",
|
||||
"command.terminal.toggle": "Skift terminal",
|
||||
@@ -84,7 +83,7 @@ export const dict = {
|
||||
"command.session.unshare": "Stop deling af 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.group.commands": "Kommandoer",
|
||||
"palette.group.files": "Filer",
|
||||
|
||||
@@ -48,7 +48,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Neue Sitzung",
|
||||
"command.file.open": "Datei öffnen",
|
||||
"command.file.open.description": "Dateien und Befehle durchsuchen",
|
||||
"command.context.addSelection": "Auswahl zum Kontext hinzufügen",
|
||||
"command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen",
|
||||
"command.terminal.toggle": "Terminal umschalten",
|
||||
@@ -88,7 +87,7 @@ export const dict = {
|
||||
"command.session.unshare": "Teilen der Sitzung aufheben",
|
||||
"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.group.commands": "Befehle",
|
||||
"palette.group.files": "Dateien",
|
||||
|
||||
@@ -44,7 +44,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "New session",
|
||||
"command.file.open": "Open file",
|
||||
"command.file.open.description": "Search files and commands",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.context.addSelection": "Add selection to context",
|
||||
"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.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.group.commands": "Commands",
|
||||
"palette.group.files": "Files",
|
||||
|
||||
@@ -44,7 +44,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Nueva sesión",
|
||||
"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.description": "Añadir las líneas seleccionadas del archivo actual",
|
||||
"command.terminal.toggle": "Alternar terminal",
|
||||
@@ -84,7 +83,7 @@ export const dict = {
|
||||
"command.session.unshare": "Dejar de compartir 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.group.commands": "Comandos",
|
||||
"palette.group.files": "Archivos",
|
||||
|
||||
@@ -44,7 +44,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Nouvelle session",
|
||||
"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.description": "Ajouter les lignes sélectionnées du fichier actuel",
|
||||
"command.terminal.toggle": "Basculer le terminal",
|
||||
@@ -84,7 +83,7 @@ export const dict = {
|
||||
"command.session.unshare": "Ne plus partager la 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.group.commands": "Commandes",
|
||||
"palette.group.files": "Fichiers",
|
||||
|
||||
@@ -44,7 +44,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "新しいセッション",
|
||||
"command.file.open": "ファイルを開く",
|
||||
"command.file.open.description": "ファイルとコマンドを検索",
|
||||
"command.context.addSelection": "選択範囲をコンテキストに追加",
|
||||
"command.context.addSelection.description": "現在のファイルから選択した行を追加",
|
||||
"command.terminal.toggle": "ターミナルの切り替え",
|
||||
@@ -84,7 +83,7 @@ export const dict = {
|
||||
"command.session.unshare": "セッションの共有を停止",
|
||||
"command.session.unshare.description": "このセッションの共有を停止",
|
||||
|
||||
"palette.search.placeholder": "ファイルとコマンドを検索",
|
||||
"palette.search.placeholder": "ファイル、コマンド、セッションを検索",
|
||||
"palette.empty": "結果が見つかりません",
|
||||
"palette.group.commands": "コマンド",
|
||||
"palette.group.files": "ファイル",
|
||||
|
||||
@@ -48,7 +48,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "새 세션",
|
||||
"command.file.open": "파일 열기",
|
||||
"command.file.open.description": "파일 및 명령어 검색",
|
||||
"command.context.addSelection": "선택 영역을 컨텍스트에 추가",
|
||||
"command.context.addSelection.description": "현재 파일에서 선택한 줄을 추가",
|
||||
"command.terminal.toggle": "터미널 토글",
|
||||
@@ -88,7 +87,7 @@ export const dict = {
|
||||
"command.session.unshare": "세션 공유 중지",
|
||||
"command.session.unshare.description": "이 세션 공유 중지",
|
||||
|
||||
"palette.search.placeholder": "파일 및 명령어 검색",
|
||||
"palette.search.placeholder": "파일, 명령어 및 세션 검색",
|
||||
"palette.empty": "결과 없음",
|
||||
"palette.group.commands": "명령어",
|
||||
"palette.group.files": "파일",
|
||||
|
||||
@@ -47,7 +47,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Ny sesjon",
|
||||
"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.description": "Legg til valgte linjer fra gjeldende fil",
|
||||
"command.terminal.toggle": "Veksle terminal",
|
||||
@@ -87,7 +86,7 @@ export const dict = {
|
||||
"command.session.unshare": "Slutt å dele sesjon",
|
||||
"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.group.commands": "Kommandoer",
|
||||
"palette.group.files": "Filer",
|
||||
|
||||
@@ -44,7 +44,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Nowa sesja",
|
||||
"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.description": "Dodaj zaznaczone linie z bieżącego pliku",
|
||||
"command.terminal.toggle": "Przełącz terminal",
|
||||
@@ -84,7 +83,7 @@ export const dict = {
|
||||
"command.session.unshare": "Przestań udostępniać sesję",
|
||||
"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.group.commands": "Polecenia",
|
||||
"palette.group.files": "Pliki",
|
||||
|
||||
@@ -44,7 +44,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "Новая сессия",
|
||||
"command.file.open": "Открыть файл",
|
||||
"command.file.open.description": "Поиск файлов и команд",
|
||||
"command.context.addSelection": "Добавить выделение в контекст",
|
||||
"command.context.addSelection.description": "Добавить выбранные строки из текущего файла",
|
||||
"command.terminal.toggle": "Переключить терминал",
|
||||
@@ -84,7 +83,7 @@ export const dict = {
|
||||
"command.session.unshare": "Отменить публикацию",
|
||||
"command.session.unshare.description": "Прекратить публикацию сессии",
|
||||
|
||||
"palette.search.placeholder": "Поиск файлов и команд",
|
||||
"palette.search.placeholder": "Поиск файлов, команд и сессий",
|
||||
"palette.empty": "Ничего не найдено",
|
||||
"palette.group.commands": "Команды",
|
||||
"palette.group.files": "Файлы",
|
||||
|
||||
@@ -44,7 +44,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "เซสชันใหม่",
|
||||
"command.file.open": "เปิดไฟล์",
|
||||
"command.file.open.description": "ค้นหาไฟล์และคำสั่ง",
|
||||
"command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท",
|
||||
"command.context.addSelection.description": "เพิ่มบรรทัดที่เลือกจากไฟล์ปัจจุบัน",
|
||||
"command.terminal.toggle": "สลับเทอร์มินัล",
|
||||
@@ -84,7 +83,7 @@ export const dict = {
|
||||
"command.session.unshare": "ยกเลิกการแชร์เซสชัน",
|
||||
"command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
|
||||
|
||||
"palette.search.placeholder": "ค้นหาไฟล์และคำสั่ง",
|
||||
"palette.search.placeholder": "ค้นหาไฟล์ คำสั่ง และเซสชัน",
|
||||
"palette.empty": "ไม่พบผลลัพธ์",
|
||||
"palette.group.commands": "คำสั่ง",
|
||||
"palette.group.files": "ไฟล์",
|
||||
|
||||
@@ -48,7 +48,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "新建会话",
|
||||
"command.file.open": "打开文件",
|
||||
"command.file.open.description": "搜索文件和命令",
|
||||
"command.context.addSelection": "将所选内容添加到上下文",
|
||||
"command.context.addSelection.description": "添加当前文件中选中的行",
|
||||
"command.terminal.toggle": "切换终端",
|
||||
@@ -88,7 +87,7 @@ export const dict = {
|
||||
"command.session.unshare": "取消分享会话",
|
||||
"command.session.unshare.description": "停止分享此会话",
|
||||
|
||||
"palette.search.placeholder": "搜索文件和命令",
|
||||
"palette.search.placeholder": "搜索文件、命令和会话",
|
||||
"palette.empty": "未找到结果",
|
||||
"palette.group.commands": "命令",
|
||||
"palette.group.files": "文件",
|
||||
|
||||
@@ -48,7 +48,6 @@ export const dict = {
|
||||
|
||||
"command.session.new": "新增工作階段",
|
||||
"command.file.open": "開啟檔案",
|
||||
"command.file.open.description": "搜尋檔案和命令",
|
||||
"command.context.addSelection": "將選取內容加入上下文",
|
||||
"command.context.addSelection.description": "加入目前檔案中選取的行",
|
||||
"command.terminal.toggle": "切換終端機",
|
||||
@@ -88,7 +87,7 @@ export const dict = {
|
||||
"command.session.unshare": "取消分享工作階段",
|
||||
"command.session.unshare.description": "停止分享此工作階段",
|
||||
|
||||
"palette.search.placeholder": "搜尋檔案和命令",
|
||||
"palette.search.placeholder": "搜尋檔案、命令和工作階段",
|
||||
"palette.empty": "找不到結果",
|
||||
"palette.group.commands": "命令",
|
||||
"palette.group.files": "檔案",
|
||||
|
||||
@@ -27,7 +27,6 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
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 { HoverCard } from "@opencode-ai/ui/hover-card"
|
||||
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(() => {
|
||||
const commands: CommandOption[] = [
|
||||
{
|
||||
@@ -1317,15 +1253,6 @@ export default function Layout(props: ParentProps) {
|
||||
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 current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
|
||||
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 }) {
|
||||
const name = createMemo(() => getFilename(props.directory))
|
||||
const [data, setData] = createStore({
|
||||
@@ -1856,10 +1756,6 @@ export default function Layout(props: ParentProps) {
|
||||
const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
|
||||
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
|
||||
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 cancelHoverPrefetch = () => {
|
||||
@@ -1886,7 +1782,7 @@ export default function Layout(props: ParentProps) {
|
||||
const item = (
|
||||
<A
|
||||
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}
|
||||
onPointerLeave={cancelHoverPrefetch}
|
||||
onMouseEnter={scheduleHoverPrefetch}
|
||||
@@ -1918,14 +1814,9 @@ export default function Layout(props: ParentProps) {
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<InlineEditor
|
||||
id={`session:${props.session.id}`}
|
||||
value={() => props.session.title}
|
||||
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
|
||||
/>
|
||||
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
|
||||
{props.session.title}
|
||||
</span>
|
||||
<Show when={props.session.summary}>
|
||||
{(summary) => (
|
||||
<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}
|
||||
onMessageSelect={(message) => {
|
||||
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}`)
|
||||
return
|
||||
}
|
||||
@@ -1990,49 +1884,25 @@ export default function Layout(props: ParentProps) {
|
||||
<div
|
||||
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
|
||||
classList={{
|
||||
"opacity-100 pointer-events-auto": menu.open,
|
||||
"opacity-0 pointer-events-none": !menu.open,
|
||||
"opacity-100 pointer-events-auto": !!props.mobile,
|
||||
"opacity-0 pointer-events-none": !props.mobile,
|
||||
"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,
|
||||
}}
|
||||
>
|
||||
<DropdownMenu modal={!sidebarHovering()} open={menu.open} onOpenChange={(open) => setMenu("open", 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 mount={!props.mobile ? state.nav : undefined}>
|
||||
<DropdownMenu.Content
|
||||
onCloseAutoFocus={(event) => {
|
||||
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>
|
||||
<Tooltip value={language.t("common.archive")} placement="top">
|
||||
<IconButton
|
||||
icon="archive"
|
||||
variant="ghost"
|
||||
class="size-6 rounded-md"
|
||||
aria-label={language.t("common.archive")}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
void archiveSession(props.session)
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -2706,14 +2576,6 @@ export default function Layout(props: ParentProps) {
|
||||
}
|
||||
|
||||
const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
|
||||
type SearchItem = {
|
||||
id: string
|
||||
title: string
|
||||
directory: string
|
||||
label: string
|
||||
archived?: number
|
||||
}
|
||||
|
||||
const projectName = createMemo(() => {
|
||||
const project = panelProps.project
|
||||
if (!project) return ""
|
||||
@@ -2729,107 +2591,6 @@ export default function Layout(props: ParentProps) {
|
||||
})
|
||||
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 (
|
||||
<div
|
||||
classList={{
|
||||
@@ -2918,105 +2679,7 @@ export default function Layout(props: ParentProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 px-2 pt-2">
|
||||
<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() }}>
|
||||
<div class="flex-1 min-h-0 flex flex-col">
|
||||
<Show
|
||||
when={workspacesEnabled()}
|
||||
fallback={
|
||||
@@ -3100,7 +2763,7 @@ export default function Layout(props: ParentProps) {
|
||||
<div
|
||||
class="shrink-0 px-2 py-3 border-t border-border-weak-base"
|
||||
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">
|
||||
|
||||
@@ -16,13 +16,16 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { useLocal } from "@/context/local"
|
||||
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 { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
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 { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useCodeComponent } from "@opencode-ai/ui/context/code"
|
||||
@@ -73,10 +76,31 @@ import { same } from "@/utils/same"
|
||||
|
||||
type DiffStyle = "unified" | "split"
|
||||
|
||||
type HandoffSession = {
|
||||
prompt: string
|
||||
files: Record<string, SelectedLineRange | null>
|
||||
}
|
||||
|
||||
const HANDOFF_MAX = 40
|
||||
|
||||
const handoff = {
|
||||
prompt: "",
|
||||
terminals: [] as string[],
|
||||
files: {} as Record<string, SelectedLineRange | null>,
|
||||
session: new Map<string, HandoffSession>(),
|
||||
terminal: new Map<string, string[]>(),
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -280,9 +304,47 @@ export default function Page() {
|
||||
.finally(() => setUi("responding", false))
|
||||
}
|
||||
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 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) {
|
||||
createEffect(
|
||||
on(
|
||||
@@ -398,6 +460,213 @@ export default function Page() {
|
||||
if (!id) return false
|
||||
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 userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
@@ -545,8 +814,10 @@ export default function Page() {
|
||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
createEffect(() => {
|
||||
if (!params.id) return
|
||||
sync.session.sync(params.id)
|
||||
sdk.directory
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
sync.session.sync(id)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -614,10 +885,22 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.id,
|
||||
sessionKey,
|
||||
() => {
|
||||
setStore("messageId", undefined)
|
||||
setStore("expanded", {})
|
||||
setUi("autoCreated", false)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => params.dir,
|
||||
(dir) => {
|
||||
if (!dir) return
|
||||
setStore("newSessionWorktree", "main")
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
@@ -681,7 +964,7 @@ export default function Page() {
|
||||
{
|
||||
id: "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"),
|
||||
keybind: "mod+p",
|
||||
slash: "open",
|
||||
@@ -1125,12 +1408,15 @@ export default function Page() {
|
||||
activeDiff: undefined as string | undefined,
|
||||
})
|
||||
|
||||
const reviewScroll = () => tree.reviewScroll
|
||||
const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value)
|
||||
const pendingDiff = () => tree.pendingDiff
|
||||
const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value)
|
||||
const activeDiff = () => tree.activeDiff
|
||||
const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value)
|
||||
createEffect(
|
||||
on(
|
||||
sessionKey,
|
||||
() => {
|
||||
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const showAllFiles = () => {
|
||||
if (fileTreeTab() !== "changes") return
|
||||
@@ -1151,8 +1437,8 @@ export default function Page() {
|
||||
view={view}
|
||||
diffStyle={layout.review.diffStyle()}
|
||||
onDiffStyleChange={layout.review.setDiffStyle}
|
||||
onScrollRef={setReviewScroll}
|
||||
focusedFile={activeDiff()}
|
||||
onScrollRef={(el) => setTree("reviewScroll", el)}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
@@ -1202,7 +1488,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const reviewDiffTop = (path: string) => {
|
||||
const root = reviewScroll()
|
||||
const root = tree.reviewScroll
|
||||
if (!root) return
|
||||
|
||||
const id = reviewDiffId(path)
|
||||
@@ -1218,7 +1504,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const scrollToReviewDiff = (path: string) => {
|
||||
const root = reviewScroll()
|
||||
const root = tree.reviewScroll
|
||||
if (!root) return false
|
||||
|
||||
const top = reviewDiffTop(path)
|
||||
@@ -1232,24 +1518,23 @@ export default function Page() {
|
||||
const focusReviewDiff = (path: string) => {
|
||||
const current = view().review.open() ?? []
|
||||
if (!current.includes(path)) view().review.setOpen([...current, path])
|
||||
setActiveDiff(path)
|
||||
setPendingDiff(path)
|
||||
setTree({ activeDiff: path, pendingDiff: path })
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const pending = pendingDiff()
|
||||
const pending = tree.pendingDiff
|
||||
if (!pending) return
|
||||
if (!reviewScroll()) return
|
||||
if (!tree.reviewScroll) return
|
||||
if (!diffsReady()) return
|
||||
|
||||
const attempt = (count: number) => {
|
||||
if (pendingDiff() !== pending) return
|
||||
if (tree.pendingDiff !== pending) return
|
||||
if (count > 60) {
|
||||
setPendingDiff(undefined)
|
||||
setTree("pendingDiff", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
const root = reviewScroll()
|
||||
const root = tree.reviewScroll
|
||||
if (!root) {
|
||||
requestAnimationFrame(() => attempt(count + 1))
|
||||
return
|
||||
@@ -1267,7 +1552,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
if (Math.abs(root.scrollTop - top) <= 1) {
|
||||
setPendingDiff(undefined)
|
||||
setTree("pendingDiff", undefined)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1310,13 +1595,17 @@ export default function Page() {
|
||||
void sync.session.diff(id)
|
||||
})
|
||||
|
||||
let treeDir: string | undefined
|
||||
createEffect(() => {
|
||||
const dir = sdk.directory
|
||||
if (!isDesktop()) return
|
||||
if (!layout.fileTree.opened()) return
|
||||
if (sync.status === "loading") return
|
||||
|
||||
fileTreeTab()
|
||||
void file.tree.list("")
|
||||
const refresh = treeDir !== dir
|
||||
treeDir = dir
|
||||
void (refresh ? file.tree.refresh("") : file.tree.list(""))
|
||||
})
|
||||
|
||||
const autoScroll = createAutoScroll({
|
||||
@@ -1351,6 +1640,18 @@ export default function Page() {
|
||||
let scrollSpyFrame: number | 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 setScrollRef = (el: HTMLDivElement | undefined) => {
|
||||
@@ -1465,20 +1766,14 @@ export default function Page() {
|
||||
window.history.replaceState(null, "", `#${anchor(id)}`)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
const raw = sessionStorage.getItem("opencode.pendingMessage")
|
||||
if (!raw) return
|
||||
const parts = raw.split("|")
|
||||
const pendingSessionID = parts[0]
|
||||
const messageID = parts[1]
|
||||
if (!pendingSessionID || !messageID) return
|
||||
if (pendingSessionID !== sessionID) return
|
||||
|
||||
sessionStorage.removeItem("opencode.pendingMessage")
|
||||
setUi("pendingMessage", messageID)
|
||||
})
|
||||
createEffect(
|
||||
on(sessionKey, (key) => {
|
||||
if (!params.id) return
|
||||
const messageID = layout.pendingMessage.consume(key)
|
||||
if (!messageID) return
|
||||
setUi("pendingMessage", messageID)
|
||||
}),
|
||||
)
|
||||
|
||||
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
|
||||
const root = scroller
|
||||
@@ -1692,7 +1987,7 @@ export default function Page() {
|
||||
|
||||
createEffect(() => {
|
||||
if (!prompt.ready()) return
|
||||
handoff.prompt = previewPrompt()
|
||||
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
@@ -1712,20 +2007,22 @@ export default function Page() {
|
||||
return language.t("terminal.title")
|
||||
}
|
||||
|
||||
handoff.terminals = terminal.all().map(label)
|
||||
touch(handoff.terminal, params.dir!, terminal.all().map(label))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!file.ready()) return
|
||||
handoff.files = Object.fromEntries(
|
||||
tabs()
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return []
|
||||
return [[path, file.selectedLines(path) ?? null] as const]
|
||||
}),
|
||||
)
|
||||
setSessionHandoff(sessionKey(), {
|
||||
files: Object.fromEntries(
|
||||
tabs()
|
||||
.all()
|
||||
.flatMap((tab) => {
|
||||
const path = file.pathFromTab(tab)
|
||||
if (!path) return []
|
||||
return [[path, file.selectedLines(path) ?? null] as const]
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
@@ -1771,7 +2068,7 @@ export default function Page() {
|
||||
<div
|
||||
classList={{
|
||||
"@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(),
|
||||
}}
|
||||
style={{
|
||||
@@ -1801,7 +2098,7 @@ export default function Page() {
|
||||
diffs={diffs}
|
||||
view={view}
|
||||
diffStyle="unified"
|
||||
focusedFile={activeDiff()}
|
||||
focusedFile={tree.activeDiff}
|
||||
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
|
||||
comments={comments.all()}
|
||||
focusedComment={comments.focus()}
|
||||
@@ -1950,24 +2247,112 @@ export default function Page() {
|
||||
"sticky top-0 z-30 bg-background-stronger": true,
|
||||
"w-full": 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(),
|
||||
}}
|
||||
>
|
||||
<div class="h-10 flex items-center gap-1">
|
||||
<Show when={info()?.parentID}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
navigate(`/${params.dir}/session/${info()?.parentID}`)
|
||||
}}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={info()?.title}>
|
||||
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
|
||||
<div class="h-10 w-full flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<Show when={info()?.parentID}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
navigate(`/${params.dir}/session/${info()?.parentID}`)
|
||||
}}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1976,10 +2361,10 @@ export default function Page() {
|
||||
<div
|
||||
ref={autoScroll.contentRef}
|
||||
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={{
|
||||
"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(),
|
||||
"mt-0.5": centered(),
|
||||
"mt-0": !centered(),
|
||||
@@ -2147,7 +2532,7 @@ export default function Page() {
|
||||
when={prompt.ready()}
|
||||
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">
|
||||
{handoff.prompt || language.t("prompt.loading")}
|
||||
{handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -2398,7 +2783,7 @@ export default function Page() {
|
||||
const p = path()
|
||||
if (!p) return 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
|
||||
@@ -2892,7 +3277,7 @@ export default function Page() {
|
||||
allowed={diffFiles()}
|
||||
kinds={kinds()}
|
||||
draggable={false}
|
||||
active={activeDiff()}
|
||||
active={tree.activeDiff}
|
||||
onFileClick={(node) => focusReviewDiff(node.path)}
|
||||
/>
|
||||
</Show>
|
||||
@@ -2952,7 +3337,7 @@ export default function Page() {
|
||||
fallback={
|
||||
<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">
|
||||
<For each={handoff.terminals}>
|
||||
<For each={handoff.terminal.get(params.dir!) ?? []}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
{title}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -79,7 +79,7 @@ export async function handler(
|
||||
const dataDumper = createDataDumper(sessionId, requestId, projectId)
|
||||
const trialLimiter = createTrialLimiter(modelInfo.trial, ip, ocClient)
|
||||
const isTrial = await trialLimiter?.isTrial()
|
||||
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip)
|
||||
const rateLimiter = createRateLimiter(modelInfo.rateLimit, ip, input.request.headers)
|
||||
await rateLimiter?.check()
|
||||
const stickyTracker = createStickyTracker(modelInfo.stickyProvider, sessionId)
|
||||
const stickyProvider = await stickyTracker?.get()
|
||||
|
||||
@@ -4,9 +4,11 @@ import { RateLimitError } from "./error"
|
||||
import { logger } from "./logger"
|
||||
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
|
||||
|
||||
const limitValue = limit.checkHeader && !headers.get(limit.checkHeader) ? limit.fallbackValue! : limit.value
|
||||
|
||||
const ip = !rawIp.length ? "unknown" : rawIp
|
||||
const now = Date.now()
|
||||
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)
|
||||
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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -21,6 +21,8 @@ export namespace ZenData {
|
||||
const RateLimitSchema = z.object({
|
||||
period: z.enum(["day", "rolling"]),
|
||||
value: z.number().int(),
|
||||
checkHeader: z.string().optional(),
|
||||
fallbackValue: z.number().int().optional(),
|
||||
})
|
||||
export type Format = z.infer<typeof FormatSchema>
|
||||
export type Trial = z.infer<typeof TrialSchema>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -42,6 +42,13 @@
|
||||
"active": true,
|
||||
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
|
||||
"externalBin": ["sidecars/opencode-cli"],
|
||||
"linux": {
|
||||
"rpm": {
|
||||
"compression": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"macOS": {
|
||||
"entitlements": "./entitlements.plist"
|
||||
},
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
"files": {
|
||||
"/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml"
|
||||
}
|
||||
},
|
||||
"rpm": {
|
||||
"compression": {
|
||||
"type": "none"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.49"
|
||||
version = "1.1.51"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.51/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[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"
|
||||
args = ["acp"]
|
||||
|
||||
[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"
|
||||
args = ["acp"]
|
||||
|
||||
[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"
|
||||
args = ["acp"]
|
||||
|
||||
[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"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -20,6 +20,7 @@
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode"
|
||||
},
|
||||
"randomField": "this-is-a-random-value-12345",
|
||||
"exports": {
|
||||
"./*": "./src/*.ts"
|
||||
},
|
||||
@@ -91,8 +92,9 @@
|
||||
"@standard-schema/spec": "1.0.0",
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"ai": "catalog:",
|
||||
"ai-gateway-provider": "2.3.1",
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.4",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"decimal.js": "10.5.0",
|
||||
|
||||
@@ -18,6 +18,7 @@ const version = Object.values(binaries)[0]
|
||||
await $`mkdir -p ./dist/${pkg.name}`
|
||||
await $`cp -r ./bin ./dist/${pkg.name}/bin`
|
||||
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(
|
||||
JSON.stringify(
|
||||
@@ -30,6 +31,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
|
||||
postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
|
||||
},
|
||||
version: version,
|
||||
license: pkg.license,
|
||||
optionalDependencies: binaries,
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -18,6 +18,7 @@ import { mergeDeep, pipe, sortBy, values } from "remeda"
|
||||
import { Global } from "@/global"
|
||||
import path from "path"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { Skill } from "../skill"
|
||||
|
||||
export namespace Agent {
|
||||
export const Info = z
|
||||
@@ -50,12 +51,14 @@ export namespace Agent {
|
||||
const state = Instance.state(async () => {
|
||||
const cfg = await Config.get()
|
||||
|
||||
const skillDirs = await Skill.dirs()
|
||||
const defaults = PermissionNext.fromConfig({
|
||||
"*": "allow",
|
||||
doom_loop: "ask",
|
||||
external_directory: {
|
||||
"*": "ask",
|
||||
[Truncate.GLOB]: "allow",
|
||||
...Object.fromEntries(skillDirs.map((dir) => [path.join(dir, "*"), "allow"])),
|
||||
},
|
||||
question: "deny",
|
||||
plan_enter: "deny",
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Filesystem } from "../util/filesystem"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { readableStreamToText } from "bun"
|
||||
import { Lock } from "../util/lock"
|
||||
import { PackageRegistry } from "./registry"
|
||||
|
||||
export namespace BunProc {
|
||||
const log = Log.create({ service: "bun" })
|
||||
@@ -73,7 +74,17 @@ export namespace BunProc {
|
||||
const dependencies = parsed.dependencies ?? {}
|
||||
if (!parsed.dependencies) parsed.dependencies = dependencies
|
||||
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 = !!(
|
||||
process.env.HTTP_PROXY ||
|
||||
|
||||
48
packages/opencode/src/bun/registry.ts
Normal file
48
packages/opencode/src/bun/registry.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -4,25 +4,211 @@ import { UI } from "../ui"
|
||||
import { cmd } from "./cmd"
|
||||
import { Flag } from "../../flag/flag"
|
||||
import { bootstrap } from "../bootstrap"
|
||||
import { Command } from "../../command"
|
||||
import { EOL } from "os"
|
||||
import { select } from "@clack/prompts"
|
||||
import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
|
||||
import { Server } from "../../server/server"
|
||||
import { Provider } from "../../provider/provider"
|
||||
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]> = {
|
||||
todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
|
||||
bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
|
||||
edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
|
||||
grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
|
||||
list: ["List", UI.Style.TEXT_INFO_BOLD],
|
||||
read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
|
||||
write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
|
||||
websearch: ["Search", UI.Style.TEXT_DIM_BOLD],
|
||||
type ToolProps<T extends Tool.Info> = {
|
||||
input: Tool.InferParameters<T>
|
||||
metadata: Tool.InferMetadata<T>
|
||||
part: ToolPart
|
||||
}
|
||||
|
||||
function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
|
||||
const state = part.state
|
||||
return {
|
||||
input: state.input as Tool.InferParameters<T>,
|
||||
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({
|
||||
@@ -91,17 +277,22 @@ export const RunCommand = cmd({
|
||||
type: "string",
|
||||
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) => {
|
||||
let message = [...args.message, ...(args["--"] || [])]
|
||||
.map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
|
||||
.join(" ")
|
||||
|
||||
const fileParts: any[] = []
|
||||
const files: { type: "file"; url: string; filename: string; mime: string }[] = []
|
||||
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 file = Bun.file(resolvedPath)
|
||||
const stats = await file.stat().catch(() => {})
|
||||
@@ -117,7 +308,7 @@ export const RunCommand = cmd({
|
||||
const stat = await file.stat()
|
||||
const mime = stat.isDirectory() ? "application/x-directory" : "text/plain"
|
||||
|
||||
fileParts.push({
|
||||
files.push({
|
||||
type: "file",
|
||||
url: `file://${resolvedPath}`,
|
||||
filename: path.basename(resolvedPath),
|
||||
@@ -133,17 +324,75 @@ export const RunCommand = cmd({
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const execute = async (sdk: OpencodeClient, sessionID: string) => {
|
||||
const printEvent = (color: string, type: string, title: string) => {
|
||||
UI.println(
|
||||
color + `|`,
|
||||
UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
|
||||
"",
|
||||
UI.Style.TEXT_NORMAL + title,
|
||||
)
|
||||
const rules: PermissionNext.Ruleset = [
|
||||
{
|
||||
permission: "question",
|
||||
action: "deny",
|
||||
pattern: "*",
|
||||
},
|
||||
{
|
||||
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") {
|
||||
process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
|
||||
return true
|
||||
@@ -152,41 +401,77 @@ export const RunCommand = cmd({
|
||||
}
|
||||
|
||||
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) {
|
||||
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") {
|
||||
const part = event.properties.part
|
||||
if (part.sessionID !== sessionID) continue
|
||||
|
||||
if (part.type === "tool" && part.state.status === "completed") {
|
||||
if (outputJsonEvent("tool_use", { part })) continue
|
||||
const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
|
||||
const title =
|
||||
part.state.title ||
|
||||
(Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
|
||||
printEvent(color, tool, title)
|
||||
if (part.tool === "bash" && part.state.output?.trim()) {
|
||||
UI.println()
|
||||
UI.println(part.state.output)
|
||||
}
|
||||
if (emit("tool_use", { part })) continue
|
||||
tool(part)
|
||||
}
|
||||
|
||||
if (
|
||||
part.type === "tool" &&
|
||||
part.tool === "task" &&
|
||||
part.state.status === "running" &&
|
||||
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 (outputJsonEvent("step_start", { part })) continue
|
||||
if (emit("step_start", { part })) continue
|
||||
}
|
||||
|
||||
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 (outputJsonEvent("text", { part })) continue
|
||||
const isPiped = !process.stdout.isTTY
|
||||
if (!isPiped) UI.println()
|
||||
process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
|
||||
if (!isPiped) UI.println()
|
||||
if (emit("text", { part })) continue
|
||||
const text = part.text.trim()
|
||||
if (!text) continue
|
||||
if (!process.stdout.isTTY) {
|
||||
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) {
|
||||
err = String(props.error.data.message)
|
||||
}
|
||||
errorMsg = errorMsg ? errorMsg + EOL + err : err
|
||||
if (outputJsonEvent("error", { error: props.error })) continue
|
||||
error = error ? error + EOL + err : err
|
||||
if (emit("error", { error: props.error })) continue
|
||||
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
|
||||
}
|
||||
|
||||
if (event.type === "permission.asked") {
|
||||
const permission = event.properties
|
||||
if (permission.sessionID !== sessionID) continue
|
||||
const result = await select({
|
||||
message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
|
||||
options: [
|
||||
{ value: "once", label: "Allow once" },
|
||||
{ value: "always", label: "Always allow: " + permission.always.join(", ") },
|
||||
{ value: "reject", label: "Reject" },
|
||||
],
|
||||
initialValue: "once",
|
||||
}).catch(() => "reject")
|
||||
const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject"
|
||||
await sdk.permission.respond({
|
||||
sessionID,
|
||||
permissionID: permission.id,
|
||||
response,
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL +
|
||||
`permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
|
||||
)
|
||||
await sdk.permission.reply({
|
||||
requestID: permission.id,
|
||||
reply: "reject",
|
||||
})
|
||||
}
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// Validate agent if specified
|
||||
const resolvedAgent = await (async () => {
|
||||
const agent = await (async () => {
|
||||
if (!args.agent) return undefined
|
||||
const agent = await Agent.get(args.agent)
|
||||
if (!agent) {
|
||||
const entry = await Agent.get(args.agent)
|
||||
if (!entry) {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
@@ -240,7 +523,7 @@ export const RunCommand = cmd({
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
if (agent.mode === "subagent") {
|
||||
if (entry.mode === "subagent") {
|
||||
UI.println(
|
||||
UI.Style.TEXT_WARNING_BOLD + "!",
|
||||
UI.Style.TEXT_NORMAL,
|
||||
@@ -251,91 +534,42 @@ export const RunCommand = cmd({
|
||||
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) {
|
||||
await sdk.session.command({
|
||||
sessionID,
|
||||
agent: resolvedAgent,
|
||||
agent,
|
||||
model: args.model,
|
||||
command: args.command,
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
})
|
||||
} else {
|
||||
const modelParam = args.model ? Provider.parseModel(args.model) : undefined
|
||||
const model = args.model ? Provider.parseModel(args.model) : undefined
|
||||
await sdk.session.prompt({
|
||||
sessionID,
|
||||
agent: resolvedAgent,
|
||||
model: modelParam,
|
||||
agent,
|
||||
model,
|
||||
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) {
|
||||
const sdk = createOpencodeClient({ baseUrl: args.attach })
|
||||
|
||||
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)
|
||||
return await execute(sdk)
|
||||
}
|
||||
|
||||
await bootstrap(process.cwd(), async () => {
|
||||
@@ -344,52 +578,7 @@ export const RunCommand = cmd({
|
||||
return Server.App().fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
|
||||
|
||||
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)
|
||||
await execute(sdk)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -187,7 +187,6 @@ function App() {
|
||||
const route = useRoute()
|
||||
const dimensions = useTerminalDimensions()
|
||||
const renderer = useRenderer()
|
||||
Clipboard.setRenderer(renderer)
|
||||
renderer.disableStdoutInterception()
|
||||
const dialog = useDialog()
|
||||
const local = useLocal()
|
||||
|
||||
@@ -35,6 +35,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const agent = iife(() => {
|
||||
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<{
|
||||
current: string
|
||||
}>({
|
||||
@@ -48,6 +49,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
theme.warning,
|
||||
theme.primary,
|
||||
theme.error,
|
||||
theme.info,
|
||||
])
|
||||
return {
|
||||
list() {
|
||||
@@ -75,11 +77,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
},
|
||||
color(name: string) {
|
||||
const all = sync.data.agent
|
||||
const agent = all.find((x) => x.name === name)
|
||||
if (agent?.color) return RGBA.fromHex(agent.color)
|
||||
const index = all.findIndex((x) => x.name === name)
|
||||
const index = visibleAgents().findIndex((x) => x.name === name)
|
||||
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]
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
import { $ } from "bun"
|
||||
import type { CliRenderer } from "@opentui/core"
|
||||
import { platform, release } from "os"
|
||||
import clipboardy from "clipboardy"
|
||||
import { lazy } from "../../../../util/lazy.js"
|
||||
import { tmpdir } from "os"
|
||||
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 interface Content {
|
||||
@@ -14,10 +25,6 @@ export namespace Clipboard {
|
||||
mime: string
|
||||
}
|
||||
|
||||
export function setRenderer(renderer: CliRenderer | undefined): void {
|
||||
rendererRef.current = renderer
|
||||
}
|
||||
|
||||
export async function read(): Promise<Content | undefined> {
|
||||
const os = platform()
|
||||
|
||||
@@ -146,11 +153,7 @@ export namespace Clipboard {
|
||||
})
|
||||
|
||||
export async function copy(text: string): Promise<void> {
|
||||
const renderer = rendererRef.current
|
||||
if (renderer) {
|
||||
const copied = renderer.copyToClipboardOSC52(text)
|
||||
if (copied) return
|
||||
}
|
||||
writeOsc52(text)
|
||||
await getCopyMethod()(text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { existsSync } from "fs"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Event } from "../server/event"
|
||||
import { PackageRegistry } from "@/bun/registry"
|
||||
|
||||
export namespace Config {
|
||||
const log = Log.create({ service: "config" })
|
||||
@@ -154,9 +155,10 @@ export namespace Config {
|
||||
}
|
||||
}
|
||||
|
||||
const exists = existsSync(path.join(dir, "node_modules"))
|
||||
const installing = installDependencies(dir)
|
||||
if (!exists) await installing
|
||||
const shouldInstall = await needsInstall(dir)
|
||||
if (shouldInstall) {
|
||||
await installDependencies(dir)
|
||||
}
|
||||
|
||||
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
|
||||
result.agent = mergeDeep(result.agent, await loadAgent(dir))
|
||||
@@ -235,6 +237,7 @@ export namespace Config {
|
||||
|
||||
export async function installDependencies(dir: string) {
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
|
||||
|
||||
if (!(await Bun.file(pkg).exists())) {
|
||||
await Bun.write(pkg, "{}")
|
||||
@@ -244,18 +247,43 @@ export namespace Config {
|
||||
const hasGitIgnore = await Bun.file(gitignore).exists()
|
||||
if (!hasGitIgnore) await Bun.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
||||
|
||||
await BunProc.run(
|
||||
["add", "@opencode-ai/plugin@" + (Installation.isLocal() ? "latest" : Installation.VERSION), "--exact"],
|
||||
{
|
||||
cwd: dir,
|
||||
},
|
||||
).catch(() => {})
|
||||
await BunProc.run(["add", `@opencode-ai/plugin@${targetVersion}`, "--exact"], {
|
||||
cwd: dir,
|
||||
}).catch(() => {})
|
||||
|
||||
// Install any additional dependencies defined in the package.json
|
||||
// This allows local plugins and custom tools to use external packages
|
||||
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[]) {
|
||||
for (const pattern of patterns) {
|
||||
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)"),
|
||||
options: z.record(z.string(), z.any()).optional(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format")
|
||||
.union([
|
||||
z.string().regex(/^#[0-9a-fA-F]{6}$/, "Invalid hex color format"),
|
||||
z.enum(["primary", "secondary", "accent", "success", "warning", "error", "info"]),
|
||||
])
|
||||
.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
|
||||
.number()
|
||||
.int()
|
||||
|
||||
@@ -23,6 +23,8 @@ export namespace Flag {
|
||||
OPENCODE_DISABLE_CLAUDE_CODE || truthy("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT")
|
||||
export const 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 const OPENCODE_FAKE_VCS = process.env["OPENCODE_FAKE_VCS"]
|
||||
export declare const OPENCODE_CLIENT: string
|
||||
|
||||
@@ -732,7 +732,7 @@ export namespace LSPServer {
|
||||
|
||||
export const CSharp: Info = {
|
||||
id: "csharp",
|
||||
root: NearestRoot([".sln", ".csproj", "global.json"]),
|
||||
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
|
||||
extensions: [".cs"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("csharp-ls", {
|
||||
@@ -772,7 +772,7 @@ export namespace LSPServer {
|
||||
|
||||
export const FSharp: Info = {
|
||||
id: "fsharp",
|
||||
root: NearestRoot([".sln", ".fsproj", "global.json"]),
|
||||
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
|
||||
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
|
||||
async spawn(root) {
|
||||
let bin = Bun.which("fsautocomplete", {
|
||||
|
||||
@@ -239,7 +239,9 @@ export namespace Provider {
|
||||
options: providerOptions,
|
||||
async getModel(sdk: any, modelID: string, options?: Record<string, any>) {
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -461,52 +463,36 @@ export namespace Provider {
|
||||
|
||||
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 envToken = Env.get("CLOUDFLARE_API_TOKEN")
|
||||
const envToken = Env.get("CLOUDFLARE_API_TOKEN") || Env.get("CF_AIG_TOKEN")
|
||||
if (envToken) return envToken
|
||||
const auth = await Auth.get(input.id)
|
||||
if (auth?.type === "api") return auth.key
|
||||
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 {
|
||||
autoload: true,
|
||||
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
return sdk.languageModel(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 })
|
||||
},
|
||||
async getModel(_sdk: any, modelID: string, _options?: Record<string, any>) {
|
||||
// Model IDs use Unified API format: provider/model (e.g., "anthropic/claude-sonnet-4-5")
|
||||
return aigateway(unified(modelID))
|
||||
},
|
||||
options: {},
|
||||
}
|
||||
},
|
||||
cerebras: async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { APICallError, ModelMessage } from "ai"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import type { JSONSchema } from "zod/v4/core"
|
||||
import type { Provider } from "./provider"
|
||||
import type { ModelsDev } from "./models"
|
||||
@@ -333,7 +334,9 @@ export namespace ProviderTransform {
|
||||
id.includes("minimax") ||
|
||||
id.includes("glm") ||
|
||||
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 {}
|
||||
|
||||
@@ -717,7 +720,7 @@ export namespace ProviderTransform {
|
||||
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 (schema.type === "object" && schema.properties) {
|
||||
@@ -768,8 +771,21 @@ export namespace ProviderTransform {
|
||||
result.required = result.required.filter((field: any) => field in result.properties)
|
||||
}
|
||||
|
||||
if (result.type === "array" && result.items == null) {
|
||||
result.items = {}
|
||||
if (result.type === "array") {
|
||||
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
|
||||
@@ -778,7 +794,7 @@ export namespace ProviderTransform {
|
||||
schema = sanitizeGemini(schema)
|
||||
}
|
||||
|
||||
return schema
|
||||
return schema as JSONSchema7
|
||||
}
|
||||
|
||||
export function error(providerID: string, error: APICallError) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { WSContext } from "hono/ws"
|
||||
import { Instance } from "../project/instance"
|
||||
import { lazy } from "@opencode-ai/util/lazy"
|
||||
import { Shell } from "@/shell/shell"
|
||||
import { Plugin } from "@/plugin"
|
||||
|
||||
export namespace Pty {
|
||||
const log = Log.create({ service: "pty" })
|
||||
@@ -102,9 +103,11 @@ export namespace Pty {
|
||||
}
|
||||
|
||||
const cwd = input.cwd || Instance.directory
|
||||
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
|
||||
const env = {
|
||||
...process.env,
|
||||
...input.env,
|
||||
...shellEnv.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
} as Record<string, string>
|
||||
|
||||
@@ -17,13 +17,14 @@ const FILES = [
|
||||
]
|
||||
|
||||
function globalFiles() {
|
||||
const files = [path.join(Global.Path.config, "AGENTS.md")]
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_PROMPT) {
|
||||
files.push(path.join(os.homedir(), ".claude", "CLAUDE.md"))
|
||||
}
|
||||
const files = []
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { SessionRevert } from "./revert"
|
||||
import { Session } from "."
|
||||
import { Agent } from "../agent/agent"
|
||||
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 { Instance } from "../project/instance"
|
||||
import { Bus } from "../bus"
|
||||
@@ -738,6 +738,8 @@ export namespace SessionPrompt {
|
||||
const execute = item.execute
|
||||
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
|
||||
item.execute = async (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 args = matchingInvocation?.args
|
||||
|
||||
const cwd = Instance.directory
|
||||
const shellEnv = await Plugin.trigger("shell.env", { cwd }, { env: {} })
|
||||
const proc = spawn(shell, args, {
|
||||
cwd: Instance.directory,
|
||||
cwd,
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: {
|
||||
...process.env,
|
||||
...shellEnv.env,
|
||||
TERM: "dumb",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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 CLAUDE_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
|
||||
const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
|
||||
|
||||
export const state = Instance.state(async () => {
|
||||
const skills: Record<string, Info> = {}
|
||||
const dirs = new Set<string>()
|
||||
|
||||
const addSkill = async (match: string) => {
|
||||
const md = await ConfigMarkdown.parse(match).catch((err) => {
|
||||
@@ -71,6 +76,8 @@ export namespace Skill {
|
||||
})
|
||||
}
|
||||
|
||||
dirs.add(path.dirname(match))
|
||||
|
||||
skills[parsed.data.name] = {
|
||||
name: parsed.data.name,
|
||||
description: parsed.data.description,
|
||||
@@ -79,38 +86,37 @@ export namespace Skill {
|
||||
}
|
||||
}
|
||||
|
||||
// Scan .claude/skills/ directories (project-level)
|
||||
const claudeDirs = await Array.fromAsync(
|
||||
Filesystem.up({
|
||||
targets: [".claude"],
|
||||
start: Instance.directory,
|
||||
stop: Instance.worktree,
|
||||
}),
|
||||
)
|
||||
// Also include global ~/.claude/skills/
|
||||
const globalClaude = `${Global.Path.home}/.claude`
|
||||
if (await Filesystem.isDir(globalClaude)) {
|
||||
claudeDirs.push(globalClaude)
|
||||
const scanExternal = async (root: string, scope: "global" | "project") => {
|
||||
return Array.fromAsync(
|
||||
EXTERNAL_SKILL_GLOB.scan({
|
||||
cwd: root,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
}),
|
||||
)
|
||||
.then((matches) => Promise.all(matches.map(addSkill)))
|
||||
.catch((error) => {
|
||||
log.error(`failed to scan ${scope} skills`, { dir: root, error })
|
||||
})
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS) {
|
||||
for (const dir of claudeDirs) {
|
||||
const matches = await Array.fromAsync(
|
||||
CLAUDE_SKILL_GLOB.scan({
|
||||
cwd: dir,
|
||||
absolute: true,
|
||||
onlyFiles: true,
|
||||
followSymlinks: true,
|
||||
dot: true,
|
||||
}),
|
||||
).catch((error) => {
|
||||
log.error("failed .claude directory scan for skills", { dir, error })
|
||||
return []
|
||||
})
|
||||
// Scan external skill directories (.claude/skills/, .agents/skills/, etc.)
|
||||
// Load global (home) first, then project-level (so project-level overwrites)
|
||||
if (!Flag.OPENCODE_DISABLE_EXTERNAL_SKILLS) {
|
||||
for (const dir of EXTERNAL_DIRS) {
|
||||
const root = path.join(Global.Path.home, dir)
|
||||
if (!(await Filesystem.isDir(root))) continue
|
||||
await scanExternal(root, "global")
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
await addSkill(match)
|
||||
}
|
||||
for await (const root of Filesystem.up({
|
||||
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) {
|
||||
return state().then((x) => x[name])
|
||||
return state().then((x) => x.skills[name])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import { Shell } from "@/shell/shell"
|
||||
|
||||
import { BashArity } from "@/permission/arity"
|
||||
import { Truncate } from "./truncation"
|
||||
import { Plugin } from "@/plugin"
|
||||
|
||||
const MAX_METADATA_LENGTH = 30_000
|
||||
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, {
|
||||
shell,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...shellEnv.env,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: process.platform !== "win32",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./codesearch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
|
||||
const API_CONFIG = {
|
||||
BASE_URL: "https://mcp.exa.ai",
|
||||
@@ -73,8 +74,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
|
||||
},
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 30000)
|
||||
const { signal, clearTimeout } = abortAfterAny(30000, ctx.abort)
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -86,10 +86,10 @@ export const CodeSearchTool = Tool.define("codesearch", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(codeRequest),
|
||||
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
||||
signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
@@ -120,7 +120,7 @@ export const CodeSearchTool = Tool.define("codesearch", {
|
||||
metadata: {},
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout()
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error("Code search request timed out")
|
||||
|
||||
@@ -110,7 +110,7 @@ export namespace ToolRegistry {
|
||||
TaskTool,
|
||||
WebFetchTool,
|
||||
TodoWriteTool,
|
||||
TodoReadTool,
|
||||
// TodoReadTool,
|
||||
WebSearchTool,
|
||||
CodeSearchTool,
|
||||
SkillTool,
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import { Skill } from "../skill"
|
||||
import { PermissionNext } from "../permission/next"
|
||||
import { Ripgrep } from "../file/ripgrep"
|
||||
import { iife } from "@/util/iife"
|
||||
|
||||
export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
const skills = await Skill.all()
|
||||
@@ -18,21 +21,29 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
|
||||
const description =
|
||||
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.",
|
||||
"Skills provide specialized knowledge and step-by-step guidance.",
|
||||
"Use this when a task matches an available skill's description.",
|
||||
"Only the skills listed here are available:",
|
||||
"Load a specialized skill that provides domain-specific instructions and workflows.",
|
||||
"",
|
||||
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
|
||||
"",
|
||||
"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>",
|
||||
...accessibleSkills.flatMap((skill) => [
|
||||
` <skill>`,
|
||||
` <name>${skill.name}</name>`,
|
||||
` <description>${skill.description}</description>`,
|
||||
` <location>${pathToFileURL(skill.location).href}</location>`,
|
||||
` </skill>`,
|
||||
]),
|
||||
"</available_skills>",
|
||||
].join(" ")
|
||||
].join("\n")
|
||||
|
||||
const examples = accessibleSkills
|
||||
.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 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 {
|
||||
@@ -61,15 +72,47 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
|
||||
always: [params.name],
|
||||
metadata: {},
|
||||
})
|
||||
const content = skill.content
|
||||
const dir = path.dirname(skill.location)
|
||||
|
||||
// Format output similar to plugin pattern
|
||||
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", content.trim()].join("\n")
|
||||
const dir = path.dirname(skill.location)
|
||||
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 {
|
||||
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: {
|
||||
name: skill.name,
|
||||
dir,
|
||||
|
||||
@@ -2,6 +2,7 @@ import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import TurndownService from "turndown"
|
||||
import DESCRIPTION from "./webfetch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
|
||||
const MAX_RESPONSE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
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 controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout)
|
||||
const { signal, clearTimeout } = abortAfterAny(timeout, ctx.abort)
|
||||
|
||||
// Build Accept header based on requested format with q parameters for fallbacks
|
||||
let acceptHeader = "*/*"
|
||||
@@ -55,8 +55,6 @@ export const WebFetchTool = Tool.define("webfetch", {
|
||||
acceptHeader =
|
||||
"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 = {
|
||||
"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",
|
||||
@@ -72,7 +70,7 @@ export const WebFetchTool = Tool.define("webfetch", {
|
||||
? await fetch(params.url, { signal, headers: { ...headers, "User-Agent": "opencode" } })
|
||||
: initial
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with status code: ${response.status}`)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import z from "zod"
|
||||
import { Tool } from "./tool"
|
||||
import DESCRIPTION from "./websearch.txt"
|
||||
import { abortAfterAny } from "../util/abort"
|
||||
|
||||
const API_CONFIG = {
|
||||
BASE_URL: "https://mcp.exa.ai",
|
||||
@@ -91,8 +92,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
|
||||
},
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeoutId = setTimeout(() => controller.abort(), 25000)
|
||||
const { signal, clearTimeout } = abortAfterAny(25000, ctx.abort)
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
@@ -104,10 +104,10 @@ export const WebSearchTool = Tool.define("websearch", async () => {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(searchRequest),
|
||||
signal: AbortSignal.any([controller.signal, ctx.abort]),
|
||||
signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout()
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
@@ -137,7 +137,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
|
||||
metadata: {},
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId)
|
||||
clearTimeout()
|
||||
|
||||
if (error instanceof Error && error.name === "AbortError") {
|
||||
throw new Error("Search request timed out")
|
||||
|
||||
35
packages/opencode/src/util/abort.ts
Normal file
35
packages/opencode/src/util/abort.ts
Normal 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 path from "path"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
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 () => {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
|
||||
@@ -15,6 +15,7 @@ test("agent color parsed from project config", async () => {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent: {
|
||||
build: { color: "#FFA500" },
|
||||
plan: { color: "primary" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -25,6 +26,7 @@ test("agent color parsed from project config", async () => {
|
||||
fn: async () => {
|
||||
const cfg = await Config.get()
|
||||
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",
|
||||
agent: {
|
||||
plan: { color: "#A855F7" },
|
||||
build: { color: "accent" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -48,6 +51,8 @@ test("Agent.get includes color from config", async () => {
|
||||
fn: async () => {
|
||||
const plan = await AgentSvc.get("plan")
|
||||
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", () => ({
|
||||
default: async (url: string) => {
|
||||
openCalledWith = url
|
||||
|
||||
// Return a mock subprocess that emits an error if openShouldFail is true
|
||||
const subprocess = new EventEmitter()
|
||||
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
|
||||
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)
|
||||
await new Promise((resolve) => setTimeout(resolve, 200))
|
||||
// Config.get() can be slow in tests, so give it plenty of time.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||
|
||||
// Stop the callback server and cancel any pending auth
|
||||
await McpOAuthCallback.stop()
|
||||
|
||||
// Wait for authenticate to reject (due to server stopping)
|
||||
try {
|
||||
await authPromise
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
await authPromise
|
||||
|
||||
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
|
||||
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
|
||||
await new Promise((resolve) => setTimeout(resolve, 700))
|
||||
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||
|
||||
// Stop the callback server and cancel any pending auth
|
||||
await McpOAuthCallback.stop()
|
||||
|
||||
// Wait for authenticate to reject (due to server stopping)
|
||||
try {
|
||||
await authPromise
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
await authPromise
|
||||
|
||||
unsubscribe()
|
||||
|
||||
@@ -237,20 +230,15 @@ test("open() is called with the authorization URL", async () => {
|
||||
openCalledWith = undefined
|
||||
|
||||
// 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
|
||||
await new Promise((resolve) => setTimeout(resolve, 700))
|
||||
// Config.get() can be slow in tests; also covers the ~500ms open() error-detection window.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000))
|
||||
|
||||
// Stop the callback server and cancel any pending auth
|
||||
await McpOAuthCallback.stop()
|
||||
|
||||
// Wait for authenticate to reject (due to server stopping)
|
||||
try {
|
||||
await authPromise
|
||||
} catch {
|
||||
// Expected to fail
|
||||
}
|
||||
await authPromise
|
||||
|
||||
// Verify open was called with a URL
|
||||
expect(openCalledWith).toBeDefined()
|
||||
|
||||
136
packages/opencode/test/memory/abort-leak.test.ts
Normal file
136
packages/opencode/test/memory/abort-leak.test.ts
Normal file
@@ -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 { 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", () => {
|
||||
test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => {
|
||||
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 { InstructionPrompt } from "../../src/session/instruction"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Global } from "../../src/global"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
|
||||
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
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
await using tmp = await tmpdir({
|
||||
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
packages/opencode/test/tool/skill.test.ts
Normal file
112
packages/opencode/test/tool/skill.test.ts
Normal 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,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -185,6 +185,7 @@ export interface Hooks {
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { args: any },
|
||||
) => Promise<void>
|
||||
"shell.env"?: (input: { cwd: string }, output: { env: Record<string, string> }) => Promise<void>
|
||||
"tool.execute.after"?: (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: {
|
||||
|
||||
@@ -70,8 +70,8 @@ export const Script = {
|
||||
get preview() {
|
||||
return IS_PREVIEW
|
||||
},
|
||||
get release() {
|
||||
return env.OPENCODE_RELEASE
|
||||
get release(): boolean {
|
||||
return !!env.OPENCODE_RELEASE
|
||||
},
|
||||
get team() {
|
||||
return team
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1403,9 +1403,9 @@ export type AgentConfig = {
|
||||
[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
|
||||
*/
|
||||
@@ -1430,6 +1430,13 @@ export type AgentConfig = {
|
||||
[key: string]: unknown
|
||||
}
|
||||
| string
|
||||
| "primary"
|
||||
| "secondary"
|
||||
| "accent"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "error"
|
||||
| "info"
|
||||
| number
|
||||
| PermissionConfig
|
||||
| undefined
|
||||
|
||||
@@ -9071,9 +9071,17 @@
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"color": {
|
||||
"description": "Hex color code for the agent (e.g., #FF5733)",
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{6}$"
|
||||
"description": "Hex color code (e.g., #FF5733) or theme color (e.g., primary)",
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"pattern": "^#[0-9a-fA-F]{6}$"
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": ["primary", "secondary", "accent", "success", "warning", "error", "info"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"steps": {
|
||||
"description": "Maximum number of agentic iterations before forcing text-only response",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -63,7 +63,7 @@ export function Tooltip(props: TooltipProps) {
|
||||
<Switch>
|
||||
<Match when={local.inactive}>{local.children}</Match>
|
||||
<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}>
|
||||
{c()}
|
||||
</KobalteTooltip.Trigger>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.1.49",
|
||||
"version": "1.1.51",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
||||
@@ -46,7 +46,7 @@ OpenCode comes with two built-in primary agents and two built-in subagents.
|
||||
|
||||
---
|
||||
|
||||
### Build
|
||||
### Use build
|
||||
|
||||
_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`
|
||||
|
||||
@@ -68,7 +68,7 @@ This agent is useful when you want the LLM to analyze code, suggest changes, or
|
||||
|
||||
---
|
||||
|
||||
### General
|
||||
### Use general
|
||||
|
||||
_Mode_: `subagent`
|
||||
|
||||
@@ -76,7 +76,7 @@ A general-purpose agent for researching complex questions and executing multi-st
|
||||
|
||||
---
|
||||
|
||||
### Explore
|
||||
### Use explore
|
||||
|
||||
_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
|
||||
|
||||
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.
|
||||
|
||||
Use a valid hex color (e.g., `#FF5733`) or theme color: `primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info`.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"agent": {
|
||||
"creative": {
|
||||
"color": "#ff6b6b"
|
||||
},
|
||||
"code-reviewer": {
|
||||
"color": "accent"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Must be a valid hex color code like `#FF5733`.
|
||||
|
||||
---
|
||||
|
||||
### Top P
|
||||
|
||||
@@ -427,6 +427,12 @@ opencode export [sessionID]
|
||||
|
||||
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
|
||||
@@ -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_PROMPT` | boolean | Disable reading `~/.claude/CLAUDE.md` |
|
||||
| `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_ENABLE_EXA` | boolean | Enable Exa web search tools |
|
||||
| `OPENCODE_SERVER_PASSWORD` | string | Enable basic auth for `serve`/`web` |
|
||||
| `OPENCODE_SERVER_USERNAME` | string | Override basic auth username (default `opencode`) |
|
||||
| `OPENCODE_MODELS_URL` | string | Custom URL for fetching models configuration |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -190,6 +190,7 @@ You can configure server settings for the `opencode serve` and `opencode web` co
|
||||
"port": 4096,
|
||||
"hostname": "0.0.0.0",
|
||||
"mdns": true,
|
||||
"mdnsDomain": "myproject.local",
|
||||
"cors": ["http://localhost:5173"]
|
||||
}
|
||||
}
|
||||
@@ -200,6 +201,7 @@ Available options:
|
||||
- `port` - Port to listen on.
|
||||
- `hostname` - Hostname to listen on. When `mdns` is enabled and no hostname is set, defaults to `0.0.0.0`.
|
||||
- `mdns` - Enable mDNS service discovery. This allows other devices on the network to discover your OpenCode server.
|
||||
- `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`.
|
||||
|
||||
[Learn more about the server here](/docs/server).
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user