Merge branch 'dev' into feat/canceled-prompts-in-history
This commit is contained in:
commit
f762125775
86 changed files with 1656 additions and 703 deletions
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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
59
bun.lock
59
bun.lock
|
|
@ -23,7 +23,7 @@
|
|||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"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.48",
|
||||
"version": "1.1.49",
|
||||
"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.48",
|
||||
"version": "1.1.49",
|
||||
"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.48",
|
||||
"version": "1.1.49",
|
||||
"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.48",
|
||||
"version": "1.1.49",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
|
|
@ -213,7 +213,7 @@
|
|||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
|
|
@ -242,7 +242,7 @@
|
|||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
|
|
@ -258,7 +258,7 @@
|
|||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
|
|
@ -307,6 +307,7 @@
|
|||
"@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",
|
||||
"chokidar": "4.0.3",
|
||||
|
|
@ -362,7 +363,7 @@
|
|||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
|
|
@ -382,7 +383,7 @@
|
|||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
|
|
@ -393,7 +394,7 @@
|
|||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
|
|
@ -406,7 +407,7 @@
|
|||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
|
|
@ -448,7 +449,7 @@
|
|||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
|
|
@ -459,7 +460,7 @@
|
|||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"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=="],
|
||||
|
|
@ -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-Uc9UFWrG9bVROt+DmXduXoY409wBBLtBe0G7R41NF8Q=",
|
||||
"aarch64-linux": "sha256-KTUsuPfWaw2qb26GmEa5tcSeF3+Kx2X5ZP5DE8jJuvQ=",
|
||||
"aarch64-darwin": "sha256-C650/LVIoeymKnRw9lVO3f5ve9xYZPrO0vOM5pqY2nE=",
|
||||
"x86_64-darwin": "sha256-xLLI2mNn222ktx6s8rwej3rMzQGl1S1jV/NXmLFg2DU="
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1934,13 +1934,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 +1956,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 +2068,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>
|
||||
|
|
|
|||
|
|
@ -137,7 +137,6 @@ export function Titlebar() {
|
|||
<header
|
||||
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
|
||||
style={{ "min-height": minHeight() }}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
|
|
@ -145,10 +144,9 @@ export function Titlebar() {
|
|||
"pl-2": !mac(),
|
||||
}}
|
||||
onMouseDown={drag}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<Show when={mac()}>
|
||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} data-tauri-drag-region />
|
||||
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
|
||||
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
|
||||
<IconButton
|
||||
icon="menu"
|
||||
|
|
@ -222,13 +220,10 @@ export function Titlebar() {
|
|||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" data-tauri-drag-region />
|
||||
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center"
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center">
|
||||
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
|
||||
</div>
|
||||
|
||||
|
|
@ -238,9 +233,8 @@ export function Titlebar() {
|
|||
"pr-6": !windows(),
|
||||
}}
|
||||
onMouseDown={drag}
|
||||
data-tauri-drag-region
|
||||
>
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" data-tauri-drag-region />
|
||||
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" />
|
||||
<Show when={windows()}>
|
||||
<div class="w-6 shrink-0" />
|
||||
<div data-tauri-decorum-tb class="flex flex-row" />
|
||||
|
|
|
|||
|
|
@ -682,12 +682,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"
|
||||
|
|
@ -2706,14 +2705,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 +2720,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 +2808,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 +2892,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">
|
||||
|
|
|
|||
|
|
@ -681,7 +681,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",
|
||||
|
|
@ -1950,7 +1950,7 @@ 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(),
|
||||
}}
|
||||
>
|
||||
|
|
@ -1976,10 +1976,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(),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"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.48",
|
||||
"version": "1.1.49",
|
||||
"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.48",
|
||||
"version": "1.1.49",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
</head>
|
||||
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root" class="flex flex-col h-dvh p-px"></div>
|
||||
<div id="root" class="flex flex-col h-dvh"></div>
|
||||
<div data-tauri-decorum-tb class="w-0 h-0 hidden" />
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.1.48"
|
||||
version = "1.1.49"
|
||||
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.48/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/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.48/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.48/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/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.48/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/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.48/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.49/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"$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.48",
|
||||
"version": "1.1.49",
|
||||
"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,6 +92,7 @@
|
|||
"@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",
|
||||
"chokidar": "4.0.3",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,8 +148,9 @@ 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
|
||||
// Try OSC52 but don't early return - always fall back to native method
|
||||
// OSC52 may report success but not actually work in all terminals
|
||||
renderer.copyToClipboardOSC52(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", {
|
||||
|
|
|
|||
|
|
@ -461,52 +461,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 () => {
|
||||
|
|
@ -1001,6 +985,12 @@ export namespace Provider {
|
|||
const fetchFn = customFetch ?? fetch
|
||||
const opts = init ?? {}
|
||||
|
||||
// Merge configured headers into request headers
|
||||
opts.headers = {
|
||||
...(typeof opts.headers === "object" ? opts.headers : {}),
|
||||
...options["headers"],
|
||||
}
|
||||
|
||||
if (options["timeout"] !== undefined && options["timeout"] !== null) {
|
||||
const signals: AbortSignal[] = []
|
||||
if (opts.signal) signals.push(opts.signal)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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.48",
|
||||
"version": "1.1.49",
|
||||
"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: {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"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.48",
|
||||
"version": "1.1.49",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"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.48",
|
||||
"version": "1.1.49",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"name": "@opencode-ai/web",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||
|
|
|
|||
|
|
@ -576,18 +576,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
|
||||
|
|
|
|||
|
|
@ -569,10 +569,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).
|
||||
|
|
|
|||
|
|
@ -192,6 +192,10 @@ Plugins can subscribe to events as seen below in the Examples section. Here is a
|
|||
|
||||
- `todo.updated`
|
||||
|
||||
#### Shell Events
|
||||
|
||||
- `shell.env`
|
||||
|
||||
#### Tool Events
|
||||
|
||||
- `tool.execute.after`
|
||||
|
|
@ -254,6 +258,23 @@ export const EnvProtection = async ({ project, client, $, directory, worktree })
|
|||
|
||||
---
|
||||
|
||||
### Inject environment variables
|
||||
|
||||
Inject environment variables into all shell execution (AI tools and user terminals):
|
||||
|
||||
```javascript title=".opencode/plugins/inject-env.js"
|
||||
export const InjectEnvPlugin = async () => {
|
||||
return {
|
||||
"shell.env": async (input, output) => {
|
||||
output.env.MY_API_KEY = "secret"
|
||||
output.env.PROJECT_ROOT = input.cwd
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Custom tools
|
||||
|
||||
Plugins can also add custom tools to opencode:
|
||||
|
|
@ -296,10 +317,12 @@ Use `client.app.log()` instead of `console.log` for structured logging:
|
|||
```ts title=".opencode/plugins/my-plugin.ts"
|
||||
export const MyPlugin = async ({ client }) => {
|
||||
await client.app.log({
|
||||
service: "my-plugin",
|
||||
level: "info",
|
||||
message: "Plugin initialized",
|
||||
extra: { foo: "bar" },
|
||||
body: {
|
||||
service: "my-plugin",
|
||||
level: "info",
|
||||
message: "Plugin initialized",
|
||||
extra: { foo: "bar" },
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -18,12 +18,13 @@ opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
|||
|
||||
#### Options
|
||||
|
||||
| Flag | Description | Default |
|
||||
| ------------ | ----------------------------------- | ----------- |
|
||||
| `--port` | Port to listen on | `4096` |
|
||||
| `--hostname` | Hostname to listen on | `127.0.0.1` |
|
||||
| `--mdns` | Enable mDNS discovery | `false` |
|
||||
| `--cors` | Additional browser origins to allow | `[]` |
|
||||
| Flag | Description | Default |
|
||||
| --------------- | ----------------------------------- | ---------------- |
|
||||
| `--port` | Port to listen on | `4096` |
|
||||
| `--hostname` | Hostname to listen on | `127.0.0.1` |
|
||||
| `--mdns` | Enable mDNS discovery | `false` |
|
||||
| `--mdns-domain` | Custom domain name for mDNS service | `opencode.local` |
|
||||
| `--cors` | Additional browser origins to allow | `[]` |
|
||||
|
||||
`--cors` can be passed multiple times:
|
||||
|
||||
|
|
|
|||
|
|
@ -64,6 +64,12 @@ opencode web --mdns
|
|||
|
||||
This automatically sets the hostname to `0.0.0.0` and advertises the server as `opencode.local`.
|
||||
|
||||
You can customize the mDNS domain name to run multiple instances on the same network:
|
||||
|
||||
```bash
|
||||
opencode web --mdns --mdns-domain myproject.local
|
||||
```
|
||||
|
||||
### CORS
|
||||
|
||||
To allow additional domains for CORS (useful for custom frontends):
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
"name": "opencode",
|
||||
"displayName": "opencode",
|
||||
"description": "opencode for VS Code",
|
||||
"version": "1.1.48",
|
||||
"version": "1.1.49",
|
||||
"publisher": "sst-dev",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue