Compare commits

...
Author SHA1 Message Date
Dax Raad 8056842baf sync 2026-02-12 18:19:14 -05:00
Dax Raad 8ea1df1560 fix(file): honor dirs=false in route 2026-02-10 23:18:32 -05:00
Dax Raad 52ae62d4cc feat(file): simplify search type param 2026-02-10 23:16:26 -05:00
Dax Raad 3c11eda350 feat(file): use FFF for file searches 2026-02-10 23:03:26 -05:00
opencode-agent[bot] c6ec2f47ef chore: generate 2026-02-11 01:56:08 +00:00
Aiden ClineandJames Lal 0fd6f365be fix(core): ensure compaction is more reliable, add reserve token buffer to ensure that input window has enough room to compact (#12924)
Co-authored-by: James Lal <james@littlebearlabs.io>
2026-02-10 19:55:22 -06:00
Aiden Cline 60bdb6e9ba tweak: /review prompt to look for behavior changes more explicitly (#13049) 2026-02-10 19:44:42 -06:00
Dax 6e9cd576ea fix(tui): default session sidebar to auto (#13046) 2026-02-11 01:26:01 +00:00
OpeOginni 53ec15a56a fix(tui): improve amazon-bedrock check to include container credentials (#13037) 2026-02-10 18:51:49 -06:00
github-actions[bot] a90b62267f Update VOUCHED list
https://github.com/anomalyco/opencode/issues/12841#issuecomment-3881500535
2026-02-11 00:37:36 +00:00
Frank 24556331c8 wip: zen 2026-02-10 17:56:10 -05:00
Frank 39145b99e8 wip: zen 2026-02-10 17:44:00 -05:00
Frank 0afa6e03a8 wip: zen 2026-02-10 17:36:32 -05:00
Frank 7a3c775dc1 wip: zen 2026-02-10 17:24:03 -05:00
Frank 3ea58bb790 wip: zen 2026-02-10 17:15:01 -05:00
Adam 50c705cd2d fix(docs): locale translations 2026-02-10 22:12:32 +00:00
Frank 3894c217cc wip: zen 2026-02-10 22:12:32 +00:00
Adam 66c2bb8f37 chore: update website stats 2026-02-10 22:12:32 +00:00
opencode 1bbbd51d48 release: v1.1.56 2026-02-10 22:12:25 +00:00
Adam 50f3e74d05 fix(app): task tool rendering 2026-02-10 15:28:46 -06:00
Adam 21475a1dfd fix(docs): invalid markdown 2026-02-10 15:18:57 -06:00
Filip dce4c05fa9 fix(desktop): open apps with executables on Windows (#13022) 2026-02-10 15:10:58 -06:00
Frank 8c56571ef9 zen: log error 2026-02-10 15:55:33 -05:00
Adam 92a77b72fb fix(app): don't close sidebar on session change (#13013) 2026-02-10 14:45:52 -06:00
opencode-agent[bot] 4f6b929784 chore: generate 2026-02-10 20:22:31 +00:00
Adam 55119559b3 fix(app): don't scroll code search input 2026-02-10 20:22:31 +00:00
Adam fd5531316f fix(docs): locale translations 2026-02-10 20:22:30 +00:00
opencode fbc41475b4 release: v1.1.55 2026-02-10 20:21:34 +00:00
Dax Raad a0673256db core: increase test timeout to 30s to prevent failures during package installation 2026-02-10 14:25:25 -05:00
Adam fc37337a3e fix(app): memory leak with platform fetch for events 2026-02-10 13:18:33 -06:00
Adam 80220cebe4 fix(app): disable terminal transparency 2026-02-10 13:11:31 -06:00
Dax 8bdf6fa359 fix: show helpful message when free usage limit is exceeded (#13005) 2026-02-10 19:04:10 +00:00
opencode 1d11a0adfd release: v1.1.54 2026-02-10 19:04:02 +00:00
Adam 1e2f664410 fix(app): back to platform fetch for now 2026-02-10 12:40:26 -06:00
Adam a3aad9c9bf fix(app): include basic auth 2026-02-10 12:37:28 -06:00
Frank eb2587844b zen: retry on 429 2026-02-10 13:35:16 -05:00
Adam d863a9cf4e fix(app): global event default fetch 2026-02-10 12:29:01 -06:00
Frank 7d5be1556a wip: zen 2026-02-10 13:07:08 -05:00
Adam 659f15aa9b fix(app): no changes in review pane 2026-02-10 11:53:33 -06:00
Adam d1f5b9e911 fix(app): memory leak with event fetch 2026-02-10 11:30:58 -06:00
Adam 284b00ff23 fix(app): don't dispose instance after reset workspace 2026-02-10 10:57:50 -06:00
Adam 2c5760742b chore: translator agent 2026-02-10 10:36:28 -06:00
Adam 70c794e913 fix(app): regressions 2026-02-10 10:15:37 -06:00
Adam 3929f0b5bd fix(app): terminal replay (#12991) 2026-02-10 10:15:19 -06:00
Adam 6f5dfe125a fix(app): use agent configured variant (#12993) 2026-02-10 10:15:09 -06:00
Dax 27fa9dc843 refactor: clean up dialog-model.tsx per code review (#12983) 2026-02-10 15:13:37 +00:00
Adam 1e03a55acd fix(app): persist defensiveness (#12973) 2026-02-10 07:47:05 -06:00
Filip 65c9669283 test(e2e): redo & undo test (#12974) 2026-02-10 07:46:48 -06:00
opencode-agent[bot] 18b6257119 chore: generate 2026-02-10 13:39:21 +00:00
Adam c607c01fb9 chore: fix e2e tests 2026-02-10 07:38:13 -06:00
Adam 4c4e30cd71 fix(docs): locale translations 2026-02-10 07:11:19 -06:00
Adam 19ad7ad809 chore: fix test 2026-02-10 07:06:20 -06:00
562 changed files with 10919 additions and 9484 deletions
+1
View File
@@ -15,4 +15,5 @@ kitlangton
kommander
r44vc0rp
rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
+883
View File
@@ -0,0 +1,883 @@
---
description: Translate content for a specified locale while preserving technical terms
mode: subagent
model: opencode/gemini-3-pro
---
You are a professional translator and localization specialist.
Translate the user's content into the requested target locale (language + region, e.g. fr-FR, de-DE).
Requirements:
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
- Also preserve every term listed in the Do-Not-Translate glossary below.
- Do not modify fenced code blocks.
- Output ONLY the translation (no commentary).
If the target locale is missing, ask the user to provide it.
---
# Do-Not-Translate Terms (OpenCode Docs)
Generated from: `packages/web/src/content/docs/*.mdx` (default English docs)
Generated on: 2026-02-10
Use this as a translation QA checklist / glossary. Preserve listed terms exactly (spelling, casing, punctuation).
General rules (verbatim, even if not listed below):
- Anything inside inline code (single backticks) or fenced code blocks (triple backticks)
- MDX/JS code in docs: `import ... from "..."`, component tags, identifiers
- CLI commands, flags, config keys/values, file paths, URLs/domains, and env vars
## Proper nouns and product names
Additional (not reliably captured via link text):
```text
Astro
Bun
Chocolatey
Cursor
Docker
Git
GitHub Actions
GitLab CI
GNOME Terminal
Homebrew
Mise
Neovim
Node.js
npm
Obsidian
opencode
opencode-ai
Paru
pnpm
ripgrep
Scoop
SST
Starlight
Visual Studio Code
VS Code
VSCodium
Windsurf
Windows Terminal
Yarn
Zellij
Zed
anomalyco
```
Extracted from link labels in the English docs (review and prune as desired):
```text
@openspoon/subtask2
302.AI console
ACP progress report
Agent Client Protocol
Agent Skills
Agentic
AGENTS.md
AI SDK
Alacritty
Anthropic
Anthropic's Data Policies
Atom One
Avante.nvim
Ayu
Azure AI Foundry
Azure portal
Baseten
built-in GITHUB_TOKEN
Bun.$
Catppuccin
Cerebras console
ChatGPT Plus or Pro
Cloudflare dashboard
CodeCompanion.nvim
CodeNomad
Configuring Adapters: Environment Variables
Context7 MCP server
Cortecs console
Deep Infra dashboard
DeepSeek console
Duo Agent Platform
Everforest
Fireworks AI console
Firmware dashboard
Ghostty
GitLab CLI agents docs
GitLab docs
GitLab User Settings > Access Tokens
Granular Rules (Object Syntax)
Grep by Vercel
Groq console
Gruvbox
Helicone
Helicone documentation
Helicone Header Directory
Helicone's Model Directory
Hugging Face Inference Providers
Hugging Face settings
install WSL
IO.NET console
JetBrains IDE
Kanagawa
Kitty
MiniMax API Console
Models.dev
Moonshot AI console
Nebius Token Factory console
Nord
OAuth
Ollama integration docs
OpenAI's Data Policies
OpenChamber
OpenCode
OpenCode config
OpenCode Config
OpenCode TUI with the opencode theme
OpenCode Web - Active Session
OpenCode Web - New Session
OpenCode Web - See Servers
OpenCode Zen
OpenCode-Obsidian
OpenRouter dashboard
OpenWork
OVHcloud panel
Pro+ subscription
SAP BTP Cockpit
Scaleway Console IAM settings
Scaleway Generative APIs
SDK documentation
Sentry MCP server
shell API
Together AI console
Tokyonight
Unified Billing
Venice AI console
Vercel dashboard
WezTerm
Windows Subsystem for Linux (WSL)
WSL
WSL (Windows Subsystem for Linux)
WSL extension
xAI console
Z.AI API console
Zed
ZenMux dashboard
Zod
```
## Acronyms and initialisms
```text
ACP
AGENTS
AI
AI21
ANSI
API
AST
AWS
BTP
CD
CDN
CI
CLI
CMD
CORS
DEBUG
EKS
ERROR
FAQ
GLM
GNOME
GPT
HTML
HTTP
HTTPS
IAM
ID
IDE
INFO
IO
IP
IRSA
JS
JSON
JSONC
K2
LLM
LM
LSP
M2
MCP
MR
NET
NPM
NTLM
OIDC
OS
PAT
PATH
PHP
PR
PTY
README
RFC
RPC
SAP
SDK
SKILL
SSE
SSO
TS
TTY
TUI
UI
URL
US
UX
VCS
VPC
VPN
VS
WARN
WSL
X11
YAML
```
## Code identifiers used in prose (CamelCase, mixedCase)
```text
apiKey
AppleScript
AssistantMessage
baseURL
BurntSushi
ChatGPT
ClangFormat
CodeCompanion
CodeNomad
DeepSeek
DefaultV2
FileContent
FileDiff
FileNode
fineGrained
FormatterStatus
GitHub
GitLab
iTerm2
JavaScript
JetBrains
macOS
mDNS
MiniMax
NeuralNomadsAI
NickvanDyke
NoeFabris
OpenAI
OpenAPI
OpenChamber
OpenCode
OpenRouter
OpenTUI
OpenWork
ownUserPermissions
PowerShell
ProviderAuthAuthorization
ProviderAuthMethod
ProviderInitError
SessionStatus
TabItem
tokenType
ToolIDs
ToolList
TypeScript
typesUrl
UserMessage
VcsInfo
WebView2
WezTerm
xAI
ZenMux
```
## OpenCode CLI commands (as shown in docs)
```text
opencode
opencode [project]
opencode /path/to/project
opencode acp
opencode agent [command]
opencode agent create
opencode agent list
opencode attach [url]
opencode attach http://10.20.30.40:4096
opencode attach http://localhost:4096
opencode auth [command]
opencode auth list
opencode auth login
opencode auth logout
opencode auth ls
opencode export [sessionID]
opencode github [command]
opencode github install
opencode github run
opencode import <file>
opencode import https://opncd.ai/s/abc123
opencode import session.json
opencode mcp [command]
opencode mcp add
opencode mcp auth [name]
opencode mcp auth list
opencode mcp auth ls
opencode mcp auth my-oauth-server
opencode mcp auth sentry
opencode mcp debug <name>
opencode mcp debug my-oauth-server
opencode mcp list
opencode mcp logout [name]
opencode mcp logout my-oauth-server
opencode mcp ls
opencode models --refresh
opencode models [provider]
opencode models anthropic
opencode run [message..]
opencode run Explain the use of context in Go
opencode serve
opencode serve --cors http://localhost:5173 --cors https://app.example.com
opencode serve --hostname 0.0.0.0 --port 4096
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
opencode session [command]
opencode session list
opencode stats
opencode uninstall
opencode upgrade
opencode upgrade [target]
opencode upgrade v0.1.48
opencode web
opencode web --cors https://example.com
opencode web --hostname 0.0.0.0
opencode web --mdns
opencode web --mdns --mdns-domain myproject.local
opencode web --port 4096
opencode web --port 4096 --hostname 0.0.0.0
opencode.server.close()
```
## Slash commands and routes
```text
/agent
/auth/:id
/clear
/command
/config
/config/providers
/connect
/continue
/doc
/editor
/event
/experimental/tool?provider=<p>&model=<m>
/experimental/tool/ids
/export
/file?path=<path>
/file/content?path=<p>
/file/status
/find?pattern=<pat>
/find/file
/find/file?query=<q>
/find/symbol?query=<q>
/formatter
/global/event
/global/health
/help
/init
/instance/dispose
/log
/lsp
/mcp
/mnt/
/mnt/c/
/mnt/d/
/models
/oc
/opencode
/path
/project
/project/current
/provider
/provider/{id}/oauth/authorize
/provider/{id}/oauth/callback
/provider/auth
/q
/quit
/redo
/resume
/session
/session/:id
/session/:id/abort
/session/:id/children
/session/:id/command
/session/:id/diff
/session/:id/fork
/session/:id/init
/session/:id/message
/session/:id/message/:messageID
/session/:id/permissions/:permissionID
/session/:id/prompt_async
/session/:id/revert
/session/:id/share
/session/:id/shell
/session/:id/summarize
/session/:id/todo
/session/:id/unrevert
/session/status
/share
/summarize
/theme
/tui
/tui/append-prompt
/tui/clear-prompt
/tui/control/next
/tui/control/response
/tui/execute-command
/tui/open-help
/tui/open-models
/tui/open-sessions
/tui/open-themes
/tui/show-toast
/tui/submit-prompt
/undo
/Users/username
/Users/username/projects/*
/vcs
```
## CLI flags and short options
```text
--agent
--attach
--command
--continue
--cors
--cwd
--days
--dir
--dry-run
--event
--file
--force
--fork
--format
--help
--hostname
--hostname 0.0.0.0
--keep-config
--keep-data
--log-level
--max-count
--mdns
--mdns-domain
--method
--model
--models
--port
--print-logs
--project
--prompt
--refresh
--session
--share
--title
--token
--tools
--verbose
--version
--wait
-c
-d
-f
-h
-m
-n
-s
-v
```
## Environment variables
```text
AI_API_URL
AI_FLOW_CONTEXT
AI_FLOW_EVENT
AI_FLOW_INPUT
AICORE_DEPLOYMENT_ID
AICORE_RESOURCE_GROUP
AICORE_SERVICE_KEY
ANTHROPIC_API_KEY
AWS_ACCESS_KEY_ID
AWS_BEARER_TOKEN_BEDROCK
AWS_PROFILE
AWS_REGION
AWS_ROLE_ARN
AWS_SECRET_ACCESS_KEY
AWS_WEB_IDENTITY_TOKEN_FILE
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
AZURE_RESOURCE_NAME
CI_PROJECT_DIR
CI_SERVER_FQDN
CI_WORKLOAD_REF
CLOUDFLARE_ACCOUNT_ID
CLOUDFLARE_API_TOKEN
CLOUDFLARE_GATEWAY_ID
CONTEXT7_API_KEY
GITHUB_TOKEN
GITLAB_AI_GATEWAY_URL
GITLAB_HOST
GITLAB_INSTANCE_URL
GITLAB_OAUTH_CLIENT_ID
GITLAB_TOKEN
GITLAB_TOKEN_OPENCODE
GOOGLE_APPLICATION_CREDENTIALS
GOOGLE_CLOUD_PROJECT
HTTP_PROXY
HTTPS_PROXY
K2_
MY_API_KEY
MY_ENV_VAR
MY_MCP_CLIENT_ID
MY_MCP_CLIENT_SECRET
NO_PROXY
NODE_ENV
NODE_EXTRA_CA_CERTS
NPM_AUTH_TOKEN
OC_ALLOW_WAYLAND
OPENCODE_API_KEY
OPENCODE_AUTH_JSON
OPENCODE_AUTO_SHARE
OPENCODE_CLIENT
OPENCODE_CONFIG
OPENCODE_CONFIG_CONTENT
OPENCODE_CONFIG_DIR
OPENCODE_DISABLE_AUTOCOMPACT
OPENCODE_DISABLE_AUTOUPDATE
OPENCODE_DISABLE_CLAUDE_CODE
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
OPENCODE_DISABLE_DEFAULT_PLUGINS
OPENCODE_DISABLE_FILETIME_CHECK
OPENCODE_DISABLE_LSP_DOWNLOAD
OPENCODE_DISABLE_MODELS_FETCH
OPENCODE_DISABLE_PRUNE
OPENCODE_DISABLE_TERMINAL_TITLE
OPENCODE_ENABLE_EXA
OPENCODE_ENABLE_EXPERIMENTAL_MODELS
OPENCODE_EXPERIMENTAL
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
OPENCODE_EXPERIMENTAL_EXA
OPENCODE_EXPERIMENTAL_FILEWATCHER
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY
OPENCODE_EXPERIMENTAL_LSP_TOOL
OPENCODE_EXPERIMENTAL_LSP_TY
OPENCODE_EXPERIMENTAL_MARKDOWN
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
OPENCODE_EXPERIMENTAL_OXFMT
OPENCODE_EXPERIMENTAL_PLAN_MODE
OPENCODE_FAKE_VCS
OPENCODE_GIT_BASH_PATH
OPENCODE_MODEL
OPENCODE_MODELS_URL
OPENCODE_PERMISSION
OPENCODE_PORT
OPENCODE_SERVER_PASSWORD
OPENCODE_SERVER_USERNAME
PROJECT_ROOT
RESOURCE_NAME
RUST_LOG
VARIABLE_NAME
VERTEX_LOCATION
XDG_CONFIG_HOME
```
## Package/module identifiers
```text
../../../config.mjs
@astrojs/starlight/components
@opencode-ai/plugin
@opencode-ai/sdk
path
shescape
zod
@
@ai-sdk/anthropic
@ai-sdk/cerebras
@ai-sdk/google
@ai-sdk/openai
@ai-sdk/openai-compatible
@File#L37-42
@modelcontextprotocol/server-everything
@opencode
```
## GitHub owner/repo slugs referenced in docs
```text
24601/opencode-zellij-namer
angristan/opencode-wakatime
anomalyco/opencode
apps/opencode-agent
athal7/opencode-devcontainers
awesome-opencode/awesome-opencode
backnotprop/plannotator
ben-vargas/ai-sdk-provider-opencode-sdk
btriapitsyn/openchamber
BurntSushi/ripgrep
Cluster444/agentic
code-yeongyu/oh-my-opencode
darrenhinde/opencode-agents
different-ai/opencode-scheduler
different-ai/openwork
features/copilot
folke/tokyonight.nvim
franlol/opencode-md-table-formatter
ggml-org/llama.cpp
ghoulr/opencode-websearch-cited.git
H2Shami/opencode-helicone-session
hosenur/portal
jamesmurdza/daytona
jenslys/opencode-gemini-auth
JRedeker/opencode-morph-fast-apply
JRedeker/opencode-shell-strategy
kdcokenny/ocx
kdcokenny/opencode-background-agents
kdcokenny/opencode-notify
kdcokenny/opencode-workspace
kdcokenny/opencode-worktree
login/device
mohak34/opencode-notifier
morhetz/gruvbox
mtymek/opencode-obsidian
NeuralNomadsAI/CodeNomad
nick-vi/opencode-type-inject
NickvanDyke/opencode.nvim
NoeFabris/opencode-antigravity-auth
nordtheme/nord
numman-ali/opencode-openai-codex-auth
olimorris/codecompanion.nvim
panta82/opencode-notificator
rebelot/kanagawa.nvim
remorses/kimaki
sainnhe/everforest
shekohex/opencode-google-antigravity-auth
shekohex/opencode-pty.git
spoons-and-mirrors/subtask2
sudo-tee/opencode.nvim
supermemoryai/opencode-supermemory
Tarquinen/opencode-dynamic-context-pruning
Th3Whit3Wolf/one-nvim
upstash/context7
vtemian/micode
vtemian/octto
yetone/avante.nvim
zenobi-us/opencode-plugin-template
zenobi-us/opencode-skillful
```
## Paths, filenames, globs, and URLs
```text
./.opencode/themes/*.json
./<project-slug>/storage/
./config/#custom-directory
./global/storage/
.agents/skills/*/SKILL.md
.agents/skills/<name>/SKILL.md
.clang-format
.claude
.claude/skills
.claude/skills/*/SKILL.md
.claude/skills/<name>/SKILL.md
.env
.github/workflows/opencode.yml
.gitignore
.gitlab-ci.yml
.ignore
.NET SDK
.npmrc
.ocamlformat
.opencode
.opencode/
.opencode/agents/
.opencode/commands/
.opencode/commands/test.md
.opencode/modes/
.opencode/plans/*.md
.opencode/plugins/
.opencode/skills/<name>/SKILL.md
.opencode/skills/git-release/SKILL.md
.opencode/tools/
.well-known/opencode
{ type: "raw" \| "patch", content: string }
{file:path/to/file}
**/*.js
%USERPROFILE%/intelephense/license.txt
%USERPROFILE%\.cache\opencode
%USERPROFILE%\.config\opencode\opencode.jsonc
%USERPROFILE%\.config\opencode\plugins
%USERPROFILE%\.local\share\opencode
%USERPROFILE%\.local\share\opencode\log
<project-root>/.opencode/themes/*.json
<providerId>/<modelId>
<your-project>/.opencode/plugins/
~
~/...
~/.agents/skills/*/SKILL.md
~/.agents/skills/<name>/SKILL.md
~/.aws/credentials
~/.bashrc
~/.cache/opencode
~/.cache/opencode/node_modules/
~/.claude/CLAUDE.md
~/.claude/skills/
~/.claude/skills/*/SKILL.md
~/.claude/skills/<name>/SKILL.md
~/.config/opencode
~/.config/opencode/AGENTS.md
~/.config/opencode/agents/
~/.config/opencode/commands/
~/.config/opencode/modes/
~/.config/opencode/opencode.json
~/.config/opencode/opencode.jsonc
~/.config/opencode/plugins/
~/.config/opencode/skills/*/SKILL.md
~/.config/opencode/skills/<name>/SKILL.md
~/.config/opencode/themes/*.json
~/.config/opencode/tools/
~/.config/zed/settings.json
~/.local/share
~/.local/share/opencode/
~/.local/share/opencode/auth.json
~/.local/share/opencode/log/
~/.local/share/opencode/mcp-auth.json
~/.local/share/opencode/opencode.jsonc
~/.npmrc
~/.zshrc
~/code/
~/Library/Application Support
~/projects/*
~/projects/personal/
${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts
$HOME/intelephense/license.txt
$HOME/projects/*
$XDG_CONFIG_HOME/opencode/themes/*.json
agent/
agents/
build/
commands/
dist/
http://<wsl-ip>:4096
http://127.0.0.1:8080/callback
http://localhost:<port>
http://localhost:4096
http://localhost:4096/doc
https://app.example.com
https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/
https://opencode.ai/zen/v1/chat/completions
https://opencode.ai/zen/v1/messages
https://opencode.ai/zen/v1/models/gemini-3-flash
https://opencode.ai/zen/v1/models/gemini-3-pro
https://opencode.ai/zen/v1/responses
https://RESOURCE_NAME.openai.azure.com/
laravel/pint
log/
model: "anthropic/claude-sonnet-4-5"
modes/
node_modules/
openai/gpt-4.1
opencode.ai/config.json
opencode/<model-id>
opencode/gpt-5.1-codex
opencode/gpt-5.2-codex
opencode/kimi-k2
openrouter/google/gemini-2.5-flash
opncd.ai/s/<share-id>
packages/*/AGENTS.md
plugins/
project/
provider_id/model_id
provider/model
provider/model-id
rm -rf ~/.cache/opencode
skills/
skills/*/SKILL.md
src/**/*.ts
themes/
tools/
```
## Keybind strings
```text
alt+b
Alt+Ctrl+K
alt+d
alt+f
Cmd+Esc
Cmd+Option+K
Cmd+Shift+Esc
Cmd+Shift+G
Cmd+Shift+P
ctrl+a
ctrl+b
ctrl+d
ctrl+e
Ctrl+Esc
ctrl+f
ctrl+g
ctrl+k
Ctrl+Shift+Esc
Ctrl+Shift+P
ctrl+t
ctrl+u
ctrl+w
ctrl+x
DELETE
Shift+Enter
WIN+R
```
## Model ID strings referenced
```text
{env:OPENCODE_MODEL}
anthropic/claude-3-5-sonnet-20241022
anthropic/claude-haiku-4-20250514
anthropic/claude-haiku-4-5
anthropic/claude-sonnet-4-20250514
anthropic/claude-sonnet-4-5
gitlab/duo-chat-haiku-4-5
lmstudio/google/gemma-3n-e4b
openai/gpt-4.1
openai/gpt-5
opencode/gpt-5.1-codex
opencode/gpt-5.2-codex
opencode/kimi-k2
openrouter/google/gemini-2.5-flash
```
+42 -15
View File
@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.53",
"version": "1.1.56",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.53",
"version": "1.1.56",
"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.53",
"version": "1.1.56",
"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.53",
"version": "1.1.56",
"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.53",
"version": "1.1.56",
"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.53",
"version": "1.1.56",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -215,7 +215,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.53",
"version": "1.1.56",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -244,7 +244,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.53",
"version": "1.1.56",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -260,7 +260,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.53",
"version": "1.1.56",
"bin": {
"opencode": "./bin/opencode",
},
@@ -288,6 +288,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@ff-labs/bun": "0.1.37",
"@gitlab/gitlab-ai-provider": "3.5.0",
"@gitlab/opencode-gitlab-auth": "1.3.2",
"@hono/standard-validator": "0.1.5",
@@ -366,7 +367,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.53",
"version": "1.1.56",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -386,7 +387,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.53",
"version": "1.1.56",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -397,7 +398,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.53",
"version": "1.1.56",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -410,7 +411,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.53",
"version": "1.1.56",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -452,7 +453,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.53",
"version": "1.1.56",
"dependencies": {
"zod": "catalog:",
},
@@ -463,7 +464,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.53",
"version": "1.1.56",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -961,6 +962,8 @@
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
"@ff-labs/bun": ["@ff-labs/bun@0.1.37", "", { "peerDependencies": { "bun": ">=1.0.0" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "fff": "scripts/cli.ts", "fff-demo": "examples/search.ts" } }, "sha512-kDdRSLXCKgZjqVQoVAy2SaXAtAKUsg1GXgYGYTQYGbLcqS1j8oBRA5abnDikamgFbH4QHMC/ml1fQTXUN94TUQ=="],
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
@@ -1305,6 +1308,28 @@
"@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-df7smckMWSUfaT5mzwN9Lfpd3ZGkOqo+vmQ8VV2a32gl14v6uZ/qeeo+1RlANXn8M0uzXPWWCkrKZIWSZUR0qw=="],
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-YiLxfsPzQqaVvT2a+nxH9do0YfUjrlxF3tKP0b1DDgvfgCcVKGsrQH3Wa82qHgL4dnT8h2bqi94JxXESEuPmcA=="],
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-XbhsA2XAFzvFr0vPSV6SNqGxab4xHKdPmVTLqoSHAx9tffrSq/012BDptOskulwnD+YNsrJUx2D2Ve1xvfgGcg=="],
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-VaNQTu0Up4gnwZLQ6/Hmho6jAlLxTQ1PwxEth8EsXHf82FOXXPV5OCQ6KC9mmmocjKlmWFaIGebThrOy8DUo4g=="],
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-t8uimCVBTw5f9K2QTZE5wN6UOrFETNrh/Xr7qtXT9nAOzaOnIFvYA+HcHbGfi31fRlCVfTxqm/EiCwJ1gEw9YQ=="],
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-oQyAW3+ugulvXTZ+XYeUMmNPR94sJeMokfHQoKwPvVwhVkgRuMhcLGV2ZesHCADVu30Oz2MFXbgdC8x4/o9dRg=="],
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-nZ12g22cy7pEOBwAxz2tp0wVqekaCn9QRKuGTHqOdLlyAqR4SCdErDvDhUWd51bIyHTQoCmj72TegGTgG0WNPw=="],
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-4ZjIUgCxEyKwcKXideB5sX0KJpnHTZtu778w73VNq2uNH2fNpMZv98+DBgJyQ9OfFoRhmKn1bmLmSefvnHzI9w=="],
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-3FXQgtYFsT0YOmAdMcJn56pLM5kzSl6y942rJJIl5l2KummB9Ea3J/vMJMzQk7NCAGhleZGWU/pJSS/uXKGa7w=="],
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-/d6vAmgKvkoYlsGPsRPlPmOK1slPis/F40UG02pYwypTH0wmY0smgzdFqR4YmryxFh17XrW1kITv+U99Oajk9Q=="],
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-a/+hSrrDpMD7THyXvE2KJy1skxzAD0cnW4K1WjuI/91VqsphjNzvf5t/ZgxEVL4wb6f+hKrSJ5J3aH47zPr61g=="],
"@oxc-minify/binding-android-arm64": ["@oxc-minify/binding-android-arm64@0.96.0", "", { "os": "android", "cpu": "arm64" }, "sha512-lzeIEMu/v6Y+La5JSesq4hvyKtKBq84cgQpKYTYM/yGuNk2tfd5Ha31hnC+mTh48lp/5vZH+WBfjVUjjINCfug=="],
"@oxc-minify/binding-darwin-arm64": ["@oxc-minify/binding-darwin-arm64@0.96.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-i0LkJAUXb4BeBFrJQbMKQPoxf8+cFEffDyLSb7NEzzKuPcH8qrVsnEItoOzeAdYam8Sr6qCHVwmBNEQzl7PWpw=="],
@@ -2177,6 +2202,8 @@
"buffers": ["buffers@0.1.1", "", {}, "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="],
"bun": ["bun@1.3.9", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.9", "@oven/bun-darwin-x64": "1.3.9", "@oven/bun-darwin-x64-baseline": "1.3.9", "@oven/bun-linux-aarch64": "1.3.9", "@oven/bun-linux-aarch64-musl": "1.3.9", "@oven/bun-linux-x64": "1.3.9", "@oven/bun-linux-x64-baseline": "1.3.9", "@oven/bun-linux-x64-musl": "1.3.9", "@oven/bun-linux-x64-musl-baseline": "1.3.9", "@oven/bun-windows-x64": "1.3.9", "@oven/bun-windows-x64-baseline": "1.3.9" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-v5hkh1us7sMNjfimWE70flYbD5I1/qWQaqmJ45q2qk5H/7muQVa478LSVRSFyGTBUBog2LsPQnfIRdjyWJRY+A=="],
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
+5 -9
View File
@@ -166,14 +166,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
let logProcessor
if ($app.stage === "production" || $app.stage === "frank") {
const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [HONEYCOMB_API_KEY],
})
}
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")],
})
new sst.cloudflare.x.SolidStart("Console", {
domain,
@@ -211,7 +207,7 @@ new sst.cloudflare.x.SolidStart("Console", {
transform: {
worker: {
placement: { mode: "smart" },
tailConsumers: logProcessor ? [{ service: logProcessor.nodes.worker.scriptName }] : [],
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
},
},
},
+1
View File
@@ -1,2 +1,3 @@
[test]
root = "./src"
preload = ["./happydom.ts"]
@@ -0,0 +1,126 @@
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { withSession } from "../actions"
import { createSdk, modKey } from "../utils"
import { promptSelector } from "../selectors"
async function seedConversation(input: {
page: Page
sdk: ReturnType<typeof createSdk>
sessionID: string
token: string
}) {
const prompt = input.page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.click()
await input.page.keyboard.type(`Reply with exactly: ${input.token}`)
await input.page.keyboard.press("Enter")
let userMessageID: string | undefined
await expect
.poll(
async () => {
const messages = await input.sdk.session
.messages({ sessionID: input.sessionID, limit: 50 })
.then((r) => r.data ?? [])
const users = messages.filter((m) => m.info.role === "user")
if (users.length === 0) return false
const user = users.reduce((acc, item) => (item.info.id > acc.info.id ? item : acc))
userMessageID = user.info.id
const assistantText = messages
.filter((m) => m.info.role === "assistant")
.flatMap((m) => m.parts)
.filter((p) => p.type === "text")
.map((p) => p.text)
.join("\n")
return assistantText.includes(input.token)
},
{ timeout: 90_000 },
)
.toBe(true)
if (!userMessageID) throw new Error("Expected a user message id")
return { prompt, userMessageID }
}
test("slash undo sets revert and restores prior prompt", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `undo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await expect(seeded.prompt).toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`)).toHaveCount(0)
})
})
})
test("slash redo clears revert and restores latest state", async ({ page, withProject }) => {
test.setTimeout(120_000)
const token = `redo_${Date.now()}`
await withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
await seeded.prompt.click()
await page.keyboard.type("/undo")
const undo = page.locator('[data-slash-id="session.undo"]').first()
await expect(undo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBe(seeded.userMessageID)
await seeded.prompt.click()
await page.keyboard.press(`${modKey}+A`)
await page.keyboard.press("Backspace")
await page.keyboard.type("/redo")
const redo = page.locator('[data-slash-id="session.redo"]').first()
await expect(redo).toBeVisible()
await page.keyboard.press("Enter")
await expect
.poll(async () => await sdk.session.get({ sessionID: session.id }).then((r) => r.data?.revert?.messageID), {
timeout: 30_000,
})
.toBeUndefined()
await expect(seeded.prompt).not.toContainText(token)
await expect(page.locator(`[data-message-id="${seeded.userMessageID}"]`).first()).toBeVisible()
})
})
})
@@ -0,0 +1,36 @@
import { test, expect } from "../fixtures"
import { closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e sidebar popover archive 1 ${stamp}` }).then((r) => r.data)
const two = await sdk.session.create({ title: `e2e sidebar popover archive 2 ${stamp}` }).then((r) => r.data)
if (!one?.id) throw new Error("Session create did not return an id")
if (!two?.id) throw new Error("Session create did not return an id")
try {
await gotoSession(one.id)
await closeSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
await project.hover()
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
const item = await hoverSessionItem(page, one.id)
await item
.getByRole("button", { name: /archive/i })
.first()
.click()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
} finally {
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
}
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.53",
"version": "1.1.56",
"description": "",
"type": "module",
"exports": {
+2 -6
View File
@@ -89,7 +89,6 @@ let runner: ReturnType<typeof Bun.spawn> | undefined
let server: { stop: () => Promise<void> | void } | undefined
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
let cleaned = false
let internalError = false
const cleanup = async () => {
if (cleaned) return
@@ -115,9 +114,8 @@ const shutdown = (code: number, reason: string) => {
}
const reportInternalError = (reason: string, error: unknown) => {
internalError = true
console.error(`e2e-local internal error: ${reason}`)
console.error(error)
console.warn(`e2e-local ignored server error: ${reason}`)
console.warn(error)
}
process.once("SIGINT", () => shutdown(130, "SIGINT"))
@@ -177,6 +175,4 @@ try {
await cleanup()
}
if (code === 0 && internalError) code = 1
process.exit(code)
@@ -6,6 +6,7 @@ let dirsToExpand: typeof import("./file-tree").dirsToExpand
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
}))
mock.module("@/context/file", () => ({
+5 -6
View File
@@ -1,4 +1,5 @@
import { useFile } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
@@ -20,11 +21,7 @@ import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
function pathToFileUrl(filepath: string): string {
const encodedPath = filepath
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
return `file://${encodedPath}`
return `file://${encodeFilePath(filepath)}`
}
type Kind = "add" | "del" | "mix"
@@ -223,12 +220,14 @@ export default function FileTree(props: {
seen.add(item)
}
return out.toSorted((a, b) => {
out.sort((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
return out
})
const Node = (
@@ -112,7 +112,7 @@ describe("buildRequestParts", () => {
// Special chars should be encoded
expect(filePart.url).toContain("file%23name.txt")
// Should have Windows drive letter properly encoded
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]%3A/)
expect(filePart.url).toMatch(/file:\/\/\/[A-Z]:/)
}
})
@@ -210,7 +210,7 @@ describe("buildRequestParts", () => {
if (filePart?.type === "file") {
// Should handle absolute path that differs from sessionDirectory
expect(() => new URL(filePart.url)).not.toThrow()
expect(filePart.url).toContain("/D%3A/other/project/file.ts")
expect(filePart.url).toContain("/D:/other/project/file.ts")
}
})
@@ -1,6 +1,7 @@
import { getFilename } from "@opencode-ai/util/path"
import { type AgentPartInput, type FilePartInput, type Part, type TextPartInput } from "@opencode-ai/sdk/v2/client"
import type { FileSelection } from "@/context/file"
import { encodeFilePath } from "@/context/file/path"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
import { Identifier } from "@/utils/id"
@@ -27,23 +28,11 @@ type BuildRequestPartsInput = {
sessionDirectory: string
}
const absolute = (directory: string, path: string) =>
path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/")
const encodeFilePath = (filepath: string): string => {
// Normalize Windows paths: convert backslashes to forward slashes
let normalized = filepath.replace(/\\/g, "/")
// Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
if (/^[A-Za-z]:/.test(normalized)) {
normalized = "/" + normalized
}
// Encode each path segment (preserving forward slashes as path separators)
return normalized
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/")
const absolute = (directory: string, path: string) => {
if (path.startsWith("/")) return path
if (/^[A-Za-z]:[\\/]/.test(path) || /^[A-Za-z]:$/.test(path)) return path
if (path.startsWith("\\\\") || path.startsWith("//")) return path
return `${directory.replace(/[\\/]+$/, "")}/${path}`
}
const fileQuery = (selection: FileSelection | undefined) =>
@@ -166,6 +166,7 @@ export function SessionHeader() {
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
@@ -355,7 +356,12 @@ export function SessionHeader() {
<span class="text-12-regular text-text-strong">Open</span>
</Button>
<div class="self-stretch w-px bg-border-base/70" />
<DropdownMenu gutter={6} placement="bottom-end">
<DropdownMenu
gutter={6}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
@@ -375,7 +381,13 @@ export function SessionHeader() {
}}
>
{options().map((o) => (
<DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
<DropdownMenu.RadioItem
value={o.id}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<AppIcon id={o.icon} class={size(o.icon)} />
</div>
@@ -388,7 +400,12 @@ export function SessionHeader() {
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={copyPath}>
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
+30 -47
View File
@@ -74,7 +74,9 @@ export const Terminal = (props: TerminalProps) => {
let handleTextareaBlur: () => void
let disposed = false
const cleanups: VoidFunction[] = []
let tail = local.pty.tail ?? ""
const start =
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
let cursor = start ?? 0
const cleanup = () => {
if (!cleanups.length) return
@@ -164,13 +166,16 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
url.searchParams.set("directory", sdk.directory)
url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
cleanups.push(() => {
if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close()
})
@@ -185,7 +190,7 @@ export const Terminal = (props: TerminalProps) => {
cursorStyle: "bar",
fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: true,
allowTransparency: false,
convertEol: true,
theme: terminalColors(),
scrollback: 10_000,
@@ -289,26 +294,6 @@ export const Terminal = (props: TerminalProps) => {
handleResize = () => fit.fit()
window.addEventListener("resize", handleResize)
cleanups.push(() => window.removeEventListener("resize", handleResize))
const limit = 16_384
const min = 32
const windowMs = 750
const seed = tail.length > limit ? tail.slice(-limit) : tail
let sync = seed.length >= min
let syncUntil = 0
const stopSync = () => {
sync = false
syncUntil = 0
}
const overlap = (data: string) => {
if (!seed) return 0
const max = Math.min(seed.length, data.length)
if (max < min) return 0
for (let i = max; i >= min; i--) {
if (seed.slice(-i) === data.slice(0, i)) return i
}
return 0
}
const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
@@ -325,7 +310,6 @@ export const Terminal = (props: TerminalProps) => {
})
cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => {
if (data) stopSync()
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
@@ -343,7 +327,6 @@ export const Terminal = (props: TerminalProps) => {
const handleOpen = () => {
local.onConnect?.()
if (sync) syncUntil = Date.now() + windowMs
sdk.client.pty
.update({
ptyID: local.pty.id,
@@ -357,31 +340,31 @@ export const Terminal = (props: TerminalProps) => {
socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen))
const decoder = new TextDecoder()
const handleMessage = (event: MessageEvent) => {
if (disposed) return
if (event.data instanceof ArrayBuffer) {
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const bytes = new Uint8Array(event.data)
if (bytes[0] !== 0) return
const json = decoder.decode(bytes.subarray(1))
try {
const meta = JSON.parse(json) as { cursor?: unknown }
const next = meta?.cursor
if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) {
cursor = next
}
} catch {
// ignore
}
return
}
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
const next = (() => {
if (!sync) return data
if (syncUntil && Date.now() > syncUntil) {
stopSync()
return data
}
const n = overlap(data)
if (!n) {
stopSync()
return data
}
const trimmed = data.slice(n)
if (trimmed) stopSync()
return trimmed
})()
if (!next) return
t.write(next)
tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit)
t.write(data)
cursor += data.length
}
socket.addEventListener("message", handleMessage)
cleanups.push(() => socket.removeEventListener("message", handleMessage))
@@ -435,7 +418,7 @@ export const Terminal = (props: TerminalProps) => {
props.onCleanup({
...local.pty,
buffer,
tail,
cursor,
rows: t.rows,
cols: t.cols,
scrollY: t.getViewportY(),
@@ -6,6 +6,7 @@ let createCommentSessionForTest: typeof import("./comments").createCommentSessio
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
}))
mock.module("@opencode-ai/ui/context", () => ({
+8 -8
View File
@@ -108,7 +108,7 @@ describe("encodeFilePath", () => {
const url = new URL(fileUrl)
expect(url.protocol).toBe("file:")
expect(url.pathname).toContain("README.bs.md")
expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md")
expect(result).toBe("/D:/dev/projects/opencode/README.bs.md")
})
test("should handle mixed separator path (Windows + Unix)", () => {
@@ -118,7 +118,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/D%3A/dev/projects/opencode/README.bs.md")
expect(result).toBe("/D:/dev/projects/opencode/README.bs.md")
})
test("should handle Windows path with spaces", () => {
@@ -146,7 +146,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/C%3A/")
expect(result).toBe("/C:/")
})
test("should handle Windows relative path with backslashes", () => {
@@ -177,7 +177,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/c%3A/users/test/file.txt")
expect(result).toBe("/c:/users/test/file.txt")
})
})
@@ -193,7 +193,7 @@ describe("encodeFilePath", () => {
const result = encodeFilePath(windowsPath)
// Should convert to forward slashes and add leading /
expect(result).not.toContain("\\")
expect(result).toMatch(/^\/[A-Za-z]%3A\//)
expect(result).toMatch(/^\/[A-Za-z]:\//)
})
test("should handle relative paths the same on all platforms", () => {
@@ -237,7 +237,7 @@ describe("encodeFilePath", () => {
const result = encodeFilePath(alreadyNormalized)
// Should not add another leading slash
expect(result).toBe("/D%3A/path/file.txt")
expect(result).toBe("/D:/path/file.txt")
expect(result).not.toContain("//D")
})
@@ -246,7 +246,7 @@ describe("encodeFilePath", () => {
const result = encodeFilePath(justDrive)
const fileUrl = `file://${result}`
expect(result).toBe("/D%3A")
expect(result).toBe("/D:")
expect(() => new URL(fileUrl)).not.toThrow()
})
@@ -256,7 +256,7 @@ describe("encodeFilePath", () => {
const fileUrl = `file://${result}`
expect(() => new URL(fileUrl)).not.toThrow()
expect(result).toBe("/C%3A/Users/test/")
expect(result).toBe("/C:/Users/test/")
})
test("should handle very long paths", () => {
+6 -1
View File
@@ -90,9 +90,14 @@ export function encodeFilePath(filepath: string): string {
}
// Encode each path segment (preserving forward slashes as path separators)
// Keep the colon in Windows drive letters (`/C:/...`) so downstream file URL parsers
// can reliably detect drives.
return normalized
.split("/")
.map((segment) => encodeURIComponent(segment))
.map((segment, index) => {
if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
return encodeURIComponent(segment)
})
.join("/")
}
+10 -1
View File
@@ -12,10 +12,19 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform()
const abort = new AbortController()
const auth = (() => {
if (typeof window === "undefined") return
const password = window.__OPENCODE__?.serverPassword
if (!password) return
return {
Authorization: `Basic ${btoa(`opencode:${password}`)}`,
}
})()
const eventSdk = createOpencodeClient({
baseUrl: server.url,
signal: abort.signal,
fetch: platform.fetch,
headers: auth,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
+25 -12
View File
@@ -6,6 +6,7 @@ import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { useModels } from "@/context/models"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
export type ModelKey = { providerID: string; modelID: string }
@@ -184,11 +185,27 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
models.setVisibility(model, visible)
},
variant: {
current() {
configured() {
const a = agent.current()
const m = current()
if (!a || !m) return undefined
return getConfiguredAgentVariant({
agent: { model: a.model, variant: a.variant },
model: { providerID: m.provider.id, modelID: m.id, variants: m.variants },
})
},
selected() {
const m = current()
if (!m) return undefined
return models.variant.get({ providerID: m.provider.id, modelID: m.id })
},
current() {
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
},
list() {
const m = current()
if (!m) return []
@@ -203,17 +220,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
cycle() {
const variants = this.list()
if (variants.length === 0) return
const currentVariant = this.current()
if (!currentVariant) {
this.set(variants[0])
return
}
const index = variants.indexOf(currentVariant)
if (index === -1 || index === variants.length - 1) {
this.set(undefined)
return
}
this.set(variants[index + 1])
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
}),
)
},
},
}
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
describe("model variant", () => {
test("resolves configured agent variant when model matches", () => {
const value = getConfiguredAgentVariant({
agent: {
model: { providerID: "openai", modelID: "gpt-5.2" },
variant: "xhigh",
},
model: {
providerID: "openai",
modelID: "gpt-5.2",
variants: { low: {}, high: {}, xhigh: {} },
},
})
expect(value).toBe("xhigh")
})
test("ignores configured variant when model does not match", () => {
const value = getConfiguredAgentVariant({
agent: {
model: { providerID: "openai", modelID: "gpt-5.2" },
variant: "xhigh",
},
model: {
providerID: "anthropic",
modelID: "claude-sonnet-4",
variants: { low: {}, high: {}, xhigh: {} },
},
})
expect(value).toBeUndefined()
})
test("prefers selected variant over configured variant", () => {
const value = resolveModelVariant({
variants: ["low", "high", "xhigh"],
selected: "high",
configured: "xhigh",
})
expect(value).toBe("high")
})
test("cycles from configured variant to next", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "high",
})
expect(value).toBe("xhigh")
})
test("wraps from configured last variant to first", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "xhigh",
})
expect(value).toBe("low")
})
})
+50
View File
@@ -0,0 +1,50 @@
type AgentModel = {
providerID: string
modelID: string
}
type Agent = {
model?: AgentModel
variant?: string
}
type Model = AgentModel & {
variants?: Record<string, unknown>
}
type VariantInput = {
variants: string[]
selected: string | undefined
configured: string | undefined
}
export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
if (!input.agent?.variant) return undefined
if (!input.agent.model) return undefined
if (!input.model?.variants) return undefined
if (input.agent.model.providerID !== input.model.providerID) return undefined
if (input.agent.model.modelID !== input.model.modelID) return undefined
if (!(input.agent.variant in input.model.variants)) return undefined
return input.agent.variant
}
export function resolveModelVariant(input: VariantInput) {
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
}
export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
if (input.configured && input.variants.includes(input.configured)) {
const index = input.variants.indexOf(input.configured)
if (index === input.variants.length - 1) return input.variants[0]
return input.variants[index + 1]
}
return input.variants[0]
}
@@ -5,6 +5,7 @@ let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => str
beforeAll(async () => {
mock.module("@solidjs/router", () => ({
useNavigate: () => () => undefined,
useParams: () => ({}),
}))
mock.module("@opencode-ai/ui/context", () => ({
+1 -1
View File
@@ -13,7 +13,7 @@ export type LocalPTY = {
cols?: number
buffer?: string
scrollY?: number
tail?: string
cursor?: number
}
const WORKSPACE_KEY = "__workspace__"
@@ -54,6 +54,13 @@ export default function Layout(props: ParentProps) {
navigate(`/${params.dir}/session/${sessionID}`)
}
const sessionHref = (sessionID: string) => {
if (params.dir) return `/${params.dir}/session/${sessionID}`
return `/session/${sessionID}`
}
const syncSession = (sessionID: string) => sync.session.sync(sessionID)
return (
<DataProvider
data={sync.data}
@@ -62,6 +69,8 @@ export default function Layout(props: ParentProps) {
onQuestionReply={replyToQuestion}
onQuestionReject={rejectQuestion}
onNavigateToSession={navigateToSession}
onSessionHref={sessionHref}
onSyncSession={syncSession}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
+2 -1
View File
@@ -25,7 +25,8 @@ export default function Home() {
const homedir = createMemo(() => sync.data.path.home)
const recent = createMemo(() => {
return sync.data.project
.toSorted((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice()
.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
.slice(0, 5)
})
+1 -17
View File
@@ -181,20 +181,6 @@ export default function Layout(props: ParentProps) {
aim.reset()
})
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
() => {
if (layout.sidebar.opened()) return
if (!state.hoverProject) return
aim.reset()
setState("hoverSession", undefined)
setState("hoverProject", undefined)
},
{ defer: true },
),
)
const autoselecting = createMemo(() => {
if (params.dir) return false
if (!state.autoselect) return false
@@ -1272,8 +1258,6 @@ export default function Layout(props: ParentProps) {
),
)
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
setBusy(directory, false)
dismiss()
@@ -1938,7 +1922,7 @@ export default function Layout(props: ParentProps) {
direction="horizontal"
size={layout.sidebar.width()}
min={244}
max={window.innerWidth * 0.3 + 64}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
collapseThreshold={244}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}
+1 -1
View File
@@ -26,7 +26,7 @@ export const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now))
store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
export const childMapByParent = (sessions: Session[]) => {
const map = new Map<string, string[]>()
@@ -118,7 +118,7 @@ export const SortableWorkspace = (props: {
const touch = createMediaQuery("(hover: none)")
const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id)))
const loadMore = async () => {
setWorkspaceStore("limit", (limit) => limit + 5)
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.directory)
}
@@ -368,7 +368,7 @@ export const LocalWorkspace = (props: {
const loading = createMemo(() => !booted() && sessions().length === 0)
const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
const loadMore = async () => {
workspace().setStore("limit", (limit) => limit + 5)
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree)
}
+28 -7
View File
@@ -1026,10 +1026,31 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<div class={input.emptyClass}>
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
</div>
<SessionReviewTab
title={changesTitle()}
empty={
store.changes === "turn" ? (
emptyTurn()
) : (
<div class={input.emptyClass}>
<Mark class="w-14 opacity-10" />
<div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
</div>
)
}
diffs={reviewDiffs}
view={view}
diffStyle={input.diffStyle}
onDiffStyleChange={input.onDiffStyleChange}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
onFocusedCommentChange={comments.setFocus}
onViewFile={openReviewFile}
classes={input.classes}
/>
</Match>
</Switch>
)
@@ -1041,7 +1062,7 @@ export default function Page() {
diffStyle: layout.review.diffStyle(),
onDiffStyleChange: layout.review.setDiffStyle,
loadingClass: "px-6 py-4 text-text-weak",
emptyClass: "h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
</div>
</div>
@@ -1569,7 +1590,7 @@ export default function Page() {
container: "px-4",
},
loadingClass: "px-4 py-4 text-text-weak",
emptyClass: "h-full px-4 pb-30 flex flex-col items-center justify-center text-center gap-6",
emptyClass: "h-full pb-30 flex flex-col items-center justify-center text-center gap-6",
})}
scroll={ui.scroll}
onResumeScroll={resumeScroll}
@@ -1683,7 +1704,7 @@ export default function Page() {
direction="horizontal"
size={layout.session.width()}
min={450}
max={window.innerWidth * 0.45}
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
onResize={layout.session.resize}
/>
</Show>
@@ -228,6 +228,7 @@ export const createScrollSpy = (input: Input) => {
node.delete(key)
visible.delete(key)
dirty = true
schedule()
}
const markDirty = () => {
@@ -41,7 +41,7 @@ export function TerminalPanel(props: {
direction="vertical"
size={props.height}
min={100}
max={window.innerHeight * 0.6}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
collapseThreshold={50}
onResize={props.resize}
onCollapse={props.close}
@@ -365,48 +365,81 @@ export const useSessionCommands = (input: {
return [
{
id: "session.share",
title: input.info()?.share?.url ? "Copy share link" : input.language.t("command.session.share"),
title: input.info()?.share?.url
? input.language.t("session.share.copy.copyLink")
: input.language.t("command.session.share"),
description: input.info()?.share?.url
? "Copy share URL to clipboard"
? input.language.t("toast.session.share.success.description")
: input.language.t("command.session.share.description"),
category: input.language.t("command.category.session"),
slash: "share",
disabled: !input.params.id,
onSelect: async () => {
if (!input.params.id) return
const copy = (url: string, existing: boolean) =>
navigator.clipboard
.writeText(url)
.then(() =>
showToast({
title: existing
? input.language.t("session.share.copy.copied")
: input.language.t("toast.session.share.success.title"),
description: input.language.t("toast.session.share.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: input.language.t("toast.session.share.copyFailed.title"),
variant: "error",
}),
)
const url = input.info()?.share?.url
if (url) {
await copy(url, true)
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = value
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return Promise.resolve(true)
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return Promise.resolve(false)
return clipboard.writeText(value).then(
() => true,
() => false,
)
}
const copy = async (url: string, existing: boolean) => {
const ok = await write(url)
if (!ok) {
showToast({
title: input.language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
showToast({
title: existing
? input.language.t("session.share.copy.copied")
: input.language.t("toast.session.share.success.title"),
description: input.language.t("toast.session.share.success.description"),
variant: "success",
})
}
const existing = input.info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
await input.sdk.client.session
const url = await input.sdk.client.session
.share({ sessionID: input.params.id })
.then((res) => copy(res.data!.share!.url, false))
.catch(() =>
showToast({
title: input.language.t("toast.session.share.failed.title"),
description: input.language.t("toast.session.share.failed.description"),
variant: "error",
}),
)
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: input.language.t("toast.session.share.failed.title"),
description: input.language.t("toast.session.share.failed.description"),
variant: "error",
})
return
}
await copy(url, false)
},
},
{
+5
View File
@@ -99,4 +99,9 @@ describe("persist localStorage resilience", () => {
expect(storage.getItem("direct-value")).toBe('{"value":5}')
})
test("normalizer rejects malformed JSON payloads", () => {
const result = persistTesting.normalize({ value: "ok" }, '{"value":"\\x"}')
expect(result).toBeUndefined()
})
})
+31 -30
View File
@@ -195,6 +195,14 @@ function parse(value: string) {
}
}
function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) {
const parsed = parse(raw)
if (parsed === undefined) return
const migrated = migrate ? migrate(parsed) : parsed
const merged = merge(defaults, migrated)
return JSON.stringify(merged)
}
function workspaceStorage(dir: string) {
const head = dir.slice(0, 12) || "workspace"
const sum = checksum(dir) ?? "0"
@@ -291,6 +299,7 @@ function localStorageDirect(): SyncStorage {
export const PersistTesting = {
localStorageDirect,
localStorageWithPrefix,
normalize,
}
export const Persist = {
@@ -358,12 +367,11 @@ export function persisted<T>(
getItem: (key) => {
const raw = current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
const next = normalize(defaults, raw, config.migrate)
if (next === undefined) {
current.removeItem(key)
return null
}
if (raw !== next) current.setItem(key, next)
return next
}
@@ -372,16 +380,13 @@ export function persisted<T>(
const legacyRaw = legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
current.setItem(key, legacyRaw)
const next = normalize(defaults, legacyRaw, config.migrate)
if (next === undefined) {
legacyStore.removeItem(legacyKey)
continue
}
current.setItem(key, next)
legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) current.setItem(key, next)
return next
}
@@ -405,12 +410,11 @@ export function persisted<T>(
getItem: async (key) => {
const raw = await current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
const next = normalize(defaults, raw, config.migrate)
if (next === undefined) {
await current.removeItem(key).catch(() => undefined)
return null
}
if (raw !== next) await current.setItem(key, next)
return next
}
@@ -421,16 +425,13 @@ export function persisted<T>(
const legacyRaw = await legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
await current.setItem(key, legacyRaw)
const next = normalize(defaults, legacyRaw, config.migrate)
if (next === undefined) {
await legacyStore.removeItem(legacyKey).catch(() => undefined)
continue
}
await current.setItem(key, next)
await legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) await current.setItem(key, next)
return next
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.53",
"version": "1.1.56",
"type": "module",
"license": "MIT",
"scripts": {
+4 -4
View File
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "95K",
full: "95,000",
compact: "100K",
full: "100,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "650",
commits: "8,500",
contributors: "700",
commits: "9,000",
monthlyUsers: "2.5M",
},
} as const
@@ -1,13 +1,15 @@
export class AuthError extends Error {}
export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class SubscriptionError extends Error {
export class UserLimitError extends Error {}
export class ModelError extends Error {}
class LimitError extends Error {
retryAfter?: number
constructor(message: string, retryAfter?: number) {
super(message)
this.retryAfter = retryAfter
}
}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class RateLimitError extends Error {}
export class FreeUsageLimitError extends LimitError {}
export class SubscriptionUsageLimitError extends LimitError {}
@@ -18,10 +18,10 @@ import {
AuthError,
CreditsError,
MonthlyLimitError,
SubscriptionError,
UserLimitError,
ModelError,
RateLimitError,
FreeUsageLimitError,
SubscriptionUsageLimitError,
} from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
@@ -52,7 +52,8 @@ export async function handler(
type ModelInfo = Awaited<ReturnType<typeof validateModel>>
type ProviderInfo = Awaited<ReturnType<typeof selectProvider>>
const MAX_RETRIES = 3
const MAX_FAILOVER_RETRIES = 3
const MAX_429_RETRIES = 3
const FREE_WORKSPACES = [
"wrk_01K46JDFR0E75SG2Q8K172KF3Y", // frank
"wrk_01K6W1A3VE0KMNVSCQT43BG2SX", // opencode bench
@@ -111,7 +112,7 @@ export async function handler(
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
const res = await fetch(reqUrl, {
const res = await fetchWith429Retry(reqUrl, {
method: "POST",
headers: (() => {
const headers = new Headers(input.request.headers)
@@ -133,20 +134,26 @@ export async function handler(
body: reqBody,
})
// Try another provider => stop retrying if using fallback provider
if (
res.status !== 200 &&
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 &&
// ie. cannot change codex model providers mid-session
modelInfo.stickyProvider !== "strict" &&
modelInfo.fallbackProvider &&
providerInfo.id !== modelInfo.fallbackProvider
) {
return retriableRequest({
excludeProviders: [...retry.excludeProviders, providerInfo.id],
retryCount: retry.retryCount + 1,
if (res.status !== 200) {
logger.metric({
"llm.error.code": res.status,
"llm.error.message": res.statusText,
})
// Try another provider => stop retrying if using fallback provider
if (
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 &&
// ie. cannot change codex model providers mid-session
modelInfo.stickyProvider !== "strict" &&
modelInfo.fallbackProvider &&
providerInfo.id !== modelInfo.fallbackProvider
) {
return retriableRequest({
excludeProviders: [...retry.excludeProviders, providerInfo.id],
retryCount: retry.retryCount + 1,
})
}
}
return { providerInfo, reqBody, res, startTimestamp }
@@ -304,9 +311,9 @@ export async function handler(
{ status: 401 },
)
if (error instanceof RateLimitError || error instanceof SubscriptionError) {
if (error instanceof FreeUsageLimitError || error instanceof SubscriptionUsageLimitError) {
const headers = new Headers()
if (error instanceof SubscriptionError && error.retryAfter) {
if (error.retryAfter) {
headers.set("retry-after", String(error.retryAfter))
}
return new Response(
@@ -369,7 +376,7 @@ export async function handler(
if (provider) return provider
}
if (retry.retryCount === MAX_RETRIES) {
if (retry.retryCount === MAX_FAILOVER_RETRIES) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
}
@@ -520,7 +527,7 @@ export async function handler(
timeUpdated: sub.timeFixedUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionError(
throw new SubscriptionUsageLimitError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
@@ -534,7 +541,7 @@ export async function handler(
timeUpdated: sub.timeRollingUpdated,
})
if (result.status === "rate-limited")
throw new SubscriptionError(
throw new SubscriptionUsageLimitError(
`Subscription quota exceeded. Retry in ${formatRetryTime(result.resetInSec)}.`,
result.resetInSec,
)
@@ -597,6 +604,15 @@ export async function handler(
providerInfo.apiKey = authInfo.provider.credentials
}
async function fetchWith429Retry(url: string, options: RequestInit, retry = { count: 0 }) {
const res = await fetch(url, options)
if (res.status === 429 && retry.count < MAX_429_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, retry.count) * 500))
return fetchWith429Retry(url, options, { count: retry.count + 1 })
}
return res
}
async function trackUsage(
authInfo: AuthInfo,
modelInfo: ModelInfo,
@@ -1,6 +1,6 @@
import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizzle/index.js"
import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
import { RateLimitError } from "./error"
import { FreeUsageLimitError } from "./error"
import { logger } from "./logger"
import { ZenData } from "@opencode-ai/console-core/model.js"
@@ -28,17 +28,46 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
check: async () => {
const rows = await Database.use((tx) =>
tx
.select({ count: IpRateLimitTable.count })
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, intervals))),
)
const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`)
if (total >= limitValue) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
if (total >= limitValue)
throw new FreeUsageLimitError(
`Rate limit exceeded. Please try again later.`,
limit.period === "day" ? getRetryAfterDay(now) : getRetryAfterHour(rows, intervals, limitValue, now),
)
},
}
}
export function getRetryAfterDay(now: number) {
return Math.ceil((86_400_000 - (now % 86_400_000)) / 1000)
}
export function getRetryAfterHour(
rows: { interval: string; count: number }[],
intervals: string[],
limit: number,
now: number,
) {
const counts = new Map(rows.map((r) => [r.interval, r.count]))
// intervals are ordered newest to oldest: [current, -1h, -2h]
// simulate dropping oldest intervals one at a time
let running = intervals.reduce((sum, i) => sum + (counts.get(i) ?? 0), 0)
for (let i = intervals.length - 1; i >= 0; i--) {
running -= counts.get(intervals[i]) ?? 0
if (running < limit) {
// interval at index i rolls out of the window (intervals.length - i) hours from the current hour start
const hours = intervals.length - i
return Math.ceil((hours * 3_600_000 - (now % 3_600_000)) / 1000)
}
}
return Math.ceil((3_600_000 - (now % 3_600_000)) / 1000)
}
function buildYYYYMMDD(timestamp: number) {
return new Date(timestamp)
.toISOString()
@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test"
import { getRetryAfterDay, getRetryAfterHour } from "../src/routes/zen/util/rateLimiter"
describe("getRetryAfterDay", () => {
test("returns full day at midnight UTC", () => {
const midnight = Date.UTC(2026, 0, 15, 0, 0, 0, 0)
expect(getRetryAfterDay(midnight)).toBe(86_400)
})
test("returns remaining seconds until next UTC day", () => {
const noon = Date.UTC(2026, 0, 15, 12, 0, 0, 0)
expect(getRetryAfterDay(noon)).toBe(43_200)
})
test("rounds up to nearest second", () => {
const almost = Date.UTC(2026, 0, 15, 23, 59, 59, 500)
expect(getRetryAfterDay(almost)).toBe(1)
})
})
describe("getRetryAfterHour", () => {
// 14:30:00 UTC — 30 minutes into the current hour
const now = Date.UTC(2026, 0, 15, 14, 30, 0, 0)
const intervals = ["2026011514", "2026011513", "2026011512"]
test("waits 3 hours when all usage is in current hour", () => {
const rows = [{ interval: "2026011514", count: 10 }]
// only current hour has usage — it won't leave the window for 3 hours from hour start
// 3 * 3600 - 1800 = 9000s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(9000)
})
test("waits 1 hour when dropping oldest interval is sufficient", () => {
const rows = [
{ interval: "2026011514", count: 2 },
{ interval: "2026011512", count: 10 },
]
// total=12, drop oldest (-2h, count=10) -> 2 < 10
// hours = 3 - 2 = 1 -> 1 * 3600 - 1800 = 1800s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
})
test("waits 2 hours when usage spans oldest two intervals", () => {
const rows = [
{ interval: "2026011513", count: 8 },
{ interval: "2026011512", count: 5 },
]
// total=13, drop -2h (5) -> 8, 8 >= 8, drop -1h (8) -> 0 < 8
// hours = 3 - 1 = 2 -> 2 * 3600 - 1800 = 5400s
expect(getRetryAfterHour(rows, intervals, 8, now)).toBe(5400)
})
test("waits 1 hour when oldest interval alone pushes over limit", () => {
const rows = [
{ interval: "2026011514", count: 1 },
{ interval: "2026011513", count: 1 },
{ interval: "2026011512", count: 10 },
]
// total=12, drop -2h (10) -> 2 < 10
// hours = 3 - 2 = 1 -> 1800s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
})
test("waits 2 hours when middle interval keeps total over limit", () => {
const rows = [
{ interval: "2026011514", count: 4 },
{ interval: "2026011513", count: 4 },
{ interval: "2026011512", count: 4 },
]
// total=12, drop -2h (4) -> 8, 8 >= 5, drop -1h (4) -> 4 < 5
// hours = 3 - 1 = 2 -> 5400s
expect(getRetryAfterHour(rows, intervals, 5, now)).toBe(5400)
})
test("rounds up to nearest second", () => {
const offset = Date.UTC(2026, 0, 15, 14, 30, 0, 500)
const rows = [
{ interval: "2026011514", count: 2 },
{ interval: "2026011512", count: 10 },
]
// hours=1 -> 3_600_000 - 1_800_500 = 1_799_500ms -> ceil(1799.5) = 1800
expect(getRetryAfterHour(rows, intervals, 10, offset)).toBe(1800)
})
test("fallback returns time until next hour when rows are empty", () => {
// edge case: rows empty but function called (shouldn't happen in practice)
// loop drops all zeros, running stays 0 which is < any positive limit on first iteration
const rows: { interval: string; count: number }[] = []
// drop -2h (0) -> 0 < 1 -> hours = 3 - 2 = 1 -> 1800s
expect(getRetryAfterHour(rows, intervals, 1, now)).toBe(1800)
})
})
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.53",
"version": "1.1.56",
"private": true,
"type": "module",
"license": "MIT",
@@ -19,6 +19,7 @@
"zod": "catalog:"
},
"exports": {
"./*.js": "./src/*.ts",
"./*": "./src/*"
},
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.53",
"version": "1.1.56",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+12 -7
View File
@@ -17,8 +17,7 @@ export default {
)
return
let metrics = {
event_type: "completions",
let data = {
"cf.continent": event.event.request.cf?.continent,
"cf.country": event.event.request.cf?.country,
"cf.city": event.event.request.cf?.city,
@@ -31,22 +30,28 @@ export default {
status: event.event.response?.status ?? 0,
ip: event.event.request.headers["x-real-ip"],
}
const time = new Date(event.eventTimestamp ?? Date.now()).toISOString()
const events = []
for (const log of event.logs) {
for (const message of log.message) {
if (!message.startsWith("_metric:")) continue
metrics = { ...metrics, ...JSON.parse(message.slice(8)) }
const json = JSON.parse(message.slice(8))
data = { ...data, ...json }
if ("llm.error.code" in json) {
events.push({ time, data: { ...data, event_type: "llm.error" } })
}
}
}
console.log(JSON.stringify(metrics, null, 2))
events.push({ time, data: { ...data, event_type: "completions" } })
console.log(JSON.stringify(data, null, 2))
const ret = await fetch("https://api.honeycomb.io/1/events/zen", {
const ret = await fetch("https://api.honeycomb.io/1/batch/zen", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Honeycomb-Event-Time": (event.eventTimestamp ?? Date.now()).toString(),
"X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
},
body: JSON.stringify(metrics),
body: JSON.stringify(events),
})
console.log(ret.status)
console.log(await ret.text())
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.53",
"version": "1.1.56",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.53",
"version": "1.1.56",
"type": "module",
"license": "MIT",
"scripts": {
+162 -7
View File
@@ -20,9 +20,9 @@ use std::{
env,
net::TcpListener,
path::PathBuf,
process::Command,
sync::{Arc, Mutex},
time::Duration,
process::Command,
};
use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel};
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
@@ -152,12 +152,12 @@ fn check_app_exists(app_name: &str) -> bool {
{
check_windows_app(app_name)
}
#[cfg(target_os = "macos")]
{
check_macos_app(app_name)
}
#[cfg(target_os = "linux")]
{
check_linux_app(app_name)
@@ -165,11 +165,165 @@ fn check_app_exists(app_name: &str) -> bool {
}
#[cfg(target_os = "windows")]
fn check_windows_app(app_name: &str) -> bool {
fn check_windows_app(_app_name: &str) -> bool {
// Check if command exists in PATH, including .exe
return true;
}
#[cfg(target_os = "windows")]
fn resolve_windows_app_path(app_name: &str) -> Option<String> {
use std::path::{Path, PathBuf};
// Try to find the command using 'where'
let output = Command::new("where").arg(app_name).output().ok()?;
if !output.status.success() {
return None;
}
let paths = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect::<Vec<_>>();
let has_ext = |path: &Path, ext: &str| {
path.extension()
.and_then(|v| v.to_str())
.map(|v| v.eq_ignore_ascii_case(ext))
.unwrap_or(false)
};
if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
return Some(path.to_string_lossy().to_string());
}
let resolve_cmd = |path: &Path| -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
for token in content.split('"') {
let lower = token.to_ascii_lowercase();
if !lower.contains(".exe") {
continue;
}
if let Some(index) = lower.find("%~dp0") {
let base = path.parent()?;
let suffix = &token[index + 5..];
let mut resolved = PathBuf::from(base);
for part in suffix.replace('/', "\\").split('\\') {
if part.is_empty() || part == "." {
continue;
}
if part == ".." {
let _ = resolved.pop();
continue;
}
resolved.push(part);
}
if resolved.exists() {
return Some(resolved.to_string_lossy().to_string());
}
}
let resolved = PathBuf::from(token);
if resolved.exists() {
return Some(resolved.to_string_lossy().to_string());
}
}
None
};
for path in &paths {
if has_ext(path, "cmd") || has_ext(path, "bat") {
if let Some(resolved) = resolve_cmd(path) {
return Some(resolved);
}
}
if path.extension().is_none() {
let cmd = path.with_extension("cmd");
if cmd.exists() {
if let Some(resolved) = resolve_cmd(&cmd) {
return Some(resolved);
}
}
let bat = path.with_extension("bat");
if bat.exists() {
if let Some(resolved) = resolve_cmd(&bat) {
return Some(resolved);
}
}
}
}
let key = app_name
.chars()
.filter(|v| v.is_ascii_alphanumeric())
.flat_map(|v| v.to_lowercase())
.collect::<String>();
if !key.is_empty() {
for path in &paths {
let dirs = [
path.parent(),
path.parent().and_then(|dir| dir.parent()),
path.parent()
.and_then(|dir| dir.parent())
.and_then(|dir| dir.parent()),
];
for dir in dirs.into_iter().flatten() {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let candidate = entry.path();
if !has_ext(&candidate, "exe") {
continue;
}
let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
continue;
};
let name = stem
.chars()
.filter(|v| v.is_ascii_alphanumeric())
.flat_map(|v| v.to_lowercase())
.collect::<String>();
if name.contains(&key) || key.contains(&name) {
return Some(candidate.to_string_lossy().to_string());
}
}
}
}
}
}
paths.first().map(|path| path.to_string_lossy().to_string())
}
#[tauri::command]
#[specta::specta]
fn resolve_app_path(app_name: &str) -> Option<String> {
#[cfg(target_os = "windows")]
{
resolve_windows_app_path(app_name)
}
#[cfg(not(target_os = "windows"))]
{
// On macOS/Linux, just return the app_name as-is since
// the opener plugin handles them correctly
Some(app_name.to_string())
}
}
#[cfg(target_os = "macos")]
fn check_macos_app(app_name: &str) -> bool {
// Check common installation locations
@@ -181,13 +335,13 @@ fn check_macos_app(app_name: &str) -> bool {
if let Ok(home) = std::env::var("HOME") {
app_locations.push(format!("{}/Applications/{}.app", home, app_name));
}
for location in app_locations {
if std::path::Path::new(&location).exists() {
return true;
}
}
// Also check if command exists in PATH
Command::new("which")
.arg(app_name)
@@ -251,7 +405,8 @@ pub fn run() {
get_display_backend,
set_display_backend,
markdown::parse_markdown_command,
check_app_exists
check_app_exists,
resolve_app_path
])
.events(tauri_specta::collect_events![LoadingWindowComplete])
.error_handling(tauri_specta::ErrorHandlingMode::Throw);
+1
View File
@@ -14,6 +14,7 @@ export const commands = {
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }),
};
/** Events */
+10 -1
View File
@@ -116,6 +116,15 @@ function parseRecord(value: unknown) {
return value as Record<string, unknown>
}
function parseStored(value: unknown) {
if (typeof value !== "string") return value
try {
return JSON.parse(value) as unknown
} catch {
return value
}
}
function pickLocale(value: unknown): Locale | null {
const direct = parseLocale(value)
if (direct) return direct
@@ -169,7 +178,7 @@ export function initI18n(): Promise<Locale> {
if (!store) return state.locale
const raw = await store.get("language").catch(() => null)
const value = typeof raw === "string" ? JSON.parse(raw) : raw
const value = parseStored(raw)
const next = pickLocale(value) ?? state.locale
state.locale = next
+6 -1
View File
@@ -98,7 +98,12 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
void shellOpen(url).catch(() => undefined)
},
openPath(path: string, app?: string) {
async openPath(path: string, app?: string) {
const os = ostype()
if (os === "windows" && app) {
const resolvedApp = await commands.resolveAppPath(app)
return openerOpenPath(path, resolvedApp || app)
}
return openerOpenPath(path, app)
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.53",
"version": "1.1.56",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.53"
version = "1.1.56"
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.53/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/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.53/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/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.53/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/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.53/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.53",
"version": "1.1.56",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -2,4 +2,4 @@ preload = ["@opentui/solid/preload"]
[test]
preload = ["./test/preload.ts"]
timeout = 10000 # 10 seconds (default is 5000ms)
timeout = 30000 # 30 seconds - allow time for package installation
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.53",
"version": "1.1.56",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -71,6 +71,7 @@
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@ff-labs/bun": "0.1.37",
"@gitlab/gitlab-ai-provider": "3.5.0",
"@gitlab/opencode-gitlab-auth": "1.3.2",
"@hono/standard-validator": "0.1.5",
@@ -1,6 +1,6 @@
You are a helpful AI assistant tasked with summarizing conversations.
When asked to summarize, provide a detailed but concise summary of the conversation.
When asked to summarize, provide a detailed but concise summary of the conversation.
Focus on information that would be helpful for continuing the conversation, including:
- What was done
- What is currently being worked on
@@ -10,3 +10,5 @@ Focus on information that would be helpful for continuing the conversation, incl
- Important technical decisions and why they were made
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
Do not respond to any questions in the conversation, only output the summary.
@@ -2,7 +2,7 @@ import { createMemo, createSignal } from "solid-js"
import { useLocal } from "@tui/context/local"
import { useSync } from "@tui/context/sync"
import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
import { DialogSelect, type DialogSelectRef } from "@tui/ui/dialog-select"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
@@ -20,96 +20,51 @@ export function DialogModel(props: { providerID?: string }) {
const sync = useSync()
const dialog = useDialog()
const keybind = useKeybind()
const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
const [query, setQuery] = createSignal("")
const connected = useConnected()
const providers = createDialogProviderOptions()
const showExtra = createMemo(() => {
if (!connected()) return false
if (props.providerID) return false
return true
})
const showExtra = createMemo(() => connected() && !props.providerID)
const options = createMemo(() => {
const q = query()
const needle = q.trim()
const needle = query().trim()
const showSections = showExtra() && needle.length === 0
const favorites = connected() ? local.model.favorite() : []
const recents = local.model.recent()
const recentList = showSections
? recents.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
)
: []
const favoriteOptions = showSections
? favorites.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Favorites",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
function toOptions(items: typeof favorites, category: string) {
if (!showSections) return []
return items.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: provider.name,
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set({ providerID: provider.id, modelID: model.id }, { recent: true })
},
]
})
: []
},
]
})
}
const recentOptions = showSections
? recentList.flatMap((item) => {
const provider = sync.data.provider.find((x) => x.id === item.providerID)
if (!provider) return []
const model = provider.models[item.modelID]
if (!model) return []
return [
{
key: item,
value: {
providerID: provider.id,
modelID: model.id,
},
title: model.name ?? item.modelID,
description: provider.name,
category: "Recent",
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model.id,
},
{ recent: true },
)
},
},
]
})
: []
const favoriteOptions = toOptions(favorites, "Favorites")
const recentOptions = toOptions(
recents.filter(
(item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID),
),
"Recent",
)
const providerOptions = pipe(
sync.data.provider,
@@ -123,45 +78,26 @@ export function DialogModel(props: { providerID?: string }) {
entries(),
filter(([_, info]) => info.status !== "deprecated"),
filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)),
map(([model, info]) => {
const value = {
providerID: provider.id,
modelID: model,
}
return {
value,
title: info.name ?? model,
description: favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
dialog.clear()
local.model.set(
{
providerID: provider.id,
modelID: model,
},
{ recent: true },
)
},
}
}),
map(([model, info]) => ({
value: { providerID: provider.id, modelID: model },
title: info.name ?? model,
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
dialog.clear()
local.model.set({ providerID: provider.id, modelID: model }, { recent: true })
},
})),
filter((x) => {
if (!showSections) return true
const value = x.value
const inFavorites = favorites.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
if (inFavorites) return false
const inRecents = recents.some(
(item) => item.providerID === value.providerID && item.modelID === value.modelID,
)
if (inRecents) return false
if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
return false
if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID))
return false
return true
}),
sortBy(
@@ -175,21 +111,19 @@ export function DialogModel(props: { providerID?: string }) {
const popularProviders = !connected()
? pipe(
providers(),
map((option) => {
return {
...option,
category: "Popular providers",
}
}),
map((option) => ({
...option,
category: "Popular providers",
})),
take(6),
)
: []
// Search shows a single merged list (favorites inline)
if (needle) {
const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj)
const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj)
return [...filteredProviders, ...filteredPopular]
return [
...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj),
...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj),
]
}
return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders]
@@ -199,13 +133,11 @@ export function DialogModel(props: { providerID?: string }) {
props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null,
)
const title = createMemo(() => {
if (provider()) return provider()!.name
return "Select model"
})
const title = createMemo(() => provider()?.name ?? "Select model")
return (
<DialogSelect
<DialogSelect<ReturnType<typeof options>[number]["value"]>
options={options()}
keybind={[
{
keybind: keybind.all.model_provider_list?.[0],
@@ -223,12 +155,11 @@ export function DialogModel(props: { providerID?: string }) {
},
},
]}
ref={setRef}
onFilter={setQuery}
flat={true}
skipFilter={true}
title={title()}
current={local.model.current()}
options={options()}
/>
)
}
@@ -141,7 +141,7 @@ export function Session() {
})
const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "hide")
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [conceal, setConceal] = createSignal(true)
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
@@ -15,6 +15,7 @@ export interface DialogSelectProps<T> {
title: string
placeholder?: string
options: DialogSelectOption<T>[]
flat?: boolean
ref?: (ref: DialogSelectRef<T>) => void
onMove?: (option: DialogSelectOption<T>) => void
onFilter?: (query: string) => void
@@ -100,7 +101,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
setStore("input", "keyboard")
})
const grouped = createMemo(() => {
const flatten = createMemo(() => props.flat && store.filter.length > 0)
const grouped = createMemo<[string, DialogSelectOption<T>[]][]>(() => {
if (flatten()) return [["", filtered()]]
const result = pipe(
filtered(),
groupBy((x) => x.category ?? ""),
@@ -117,10 +121,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
)
})
const rows = createMemo(() => {
const headers = grouped().reduce((acc, [category], i) => {
if (!category) return acc
return acc + (i > 0 ? 2 : 1)
}, 0)
return flat().length + headers
})
const dimensions = useTerminalDimensions()
const height = createMemo(() =>
Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6),
)
const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6))
const selected = createMemo(() => flat()[store.selected])
@@ -311,7 +321,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
>
<Option
title={option.title}
footer={option.footer}
footer={flatten() ? (option.category ?? option.footer) : option.footer}
description={option.description !== category ? option.description : undefined}
active={active()}
current={current()}
@@ -57,6 +57,8 @@ Use best judgement when processing input.
**Performance** - Only flag if obviously problematic.
- O(n²) on unbounded data, N+1 queries, blocking I/O on hot paths
**Behavior Changes** - If a behavioral change is introduced, raise it (especially if it's possibly unintentional).
---
## Before You Flag Something
+6
View File
@@ -1161,6 +1161,12 @@ export namespace Config {
.object({
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
reserved: z
.number()
.int()
.min(0)
.optional()
.describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."),
})
.optional(),
experimental: z
+29
View File
@@ -0,0 +1,29 @@
import { FileFinder } from "@ff-labs/bun"
import { Log } from "@/util/log"
export namespace FFF {
const log = Log.create({ service: "file.fff" })
const init = (cwd: string) => {
const result = FileFinder.init({ basePath: cwd })
if (result.ok) return true
log.error("init failed", { error: result.error, cwd })
return false
}
export async function search(input: { cwd: string; query: string; limit: number }) {
if (!input.query) return []
if (!init(input.cwd)) return []
const result = FileFinder.search(input.query, {
pageIndex: 0,
pageSize: input.limit,
})
if (!result.ok) {
log.error("search failed", { error: result.error, query: input.query, cwd: input.cwd })
return []
}
return result.value.items.map((item: { relativePath: string }) => item.relativePath)
}
}
+45 -25
View File
@@ -9,6 +9,7 @@ import ignore from "ignore"
import { Log } from "../util/log"
import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { FFF } from "./fff"
import { Ripgrep } from "./ripgrep"
import fuzzysort from "fuzzysort"
import { Global } from "../global"
@@ -541,42 +542,61 @@ export namespace File {
})
}
export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
export async function search(input: { query: string; limit?: number; type?: "file" | "directory" | "all" }) {
const query = input.query.trim()
const limit = input.limit ?? 100
const kind = input.type ?? (input.dirs === false ? "file" : "all")
const kind = input.type ?? "all"
log.info("search", { query, kind })
const result = await state().then((x) => x.files())
const hidden = (item: string) => {
const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
}
const preferHidden = query.startsWith(".") || query.includes("/.")
const sortHiddenLast = (items: string[]) => {
if (preferHidden) return items
const visible: string[] = []
const hiddenItems: string[] = []
for (const item of items) {
const isHidden = hidden(item)
if (isHidden) hiddenItems.push(item)
if (!isHidden) visible.push(item)
const files = await FFF.search({
cwd: Instance.directory,
query,
limit: kind === "all" || kind === "directory" ? limit * 20 : limit,
})
const set = new Set<string>()
for (const file of files) {
let dir = path.dirname(file)
while (true) {
if (dir === ".") break
const next = path.dirname(dir)
set.add(dir + "/")
if (next === dir) break
dir = next
}
return [...visible, ...hiddenItems]
}
const allDirs = Array.from(set)
if (!query) {
if (kind === "file") return result.files.slice(0, limit)
return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
const output = kind === "file" ? files.slice(0, limit) : allDirs.toSorted().slice(0, limit)
log.info("search", { query, kind, results: output.length })
return output
}
const items =
kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
if (kind === "directory") {
const ranked: string[] = []
for (const item of fuzzysort.go(query, allDirs, { limit: limit * 20 })) {
ranked.push(item.target)
}
const output = ranked.slice(0, limit)
log.info("search", { query, kind, results: output.length })
return output
}
const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
if (kind === "file") {
const output = files.slice(0, limit)
log.info("search", { query, kind, results: output.length })
return output
}
const rankedDirs: string[] = []
for (const item of fuzzysort.go(query, allDirs, { limit })) {
rankedDirs.push(item.target)
}
const merged = files.slice(0, limit).concat(rankedDirs)
const output: string[] = []
for (const item of fuzzysort.go(query, merged, { limit })) {
output.push(item.target)
}
log.info("search", { query, kind, results: output.length })
return output
}
+6 -1
View File
@@ -211,7 +211,12 @@ export namespace Provider {
const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile) return { autoload: false }
const containerCreds = Boolean(
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
)
if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds)
return { autoload: false }
const providerOptions: AmazonBedrockProviderSettings = {
region: defaultRegion,
+5 -23
View File
@@ -5,6 +5,7 @@ import type { JSONSchema } from "zod/v4/core"
import type { Provider } from "./provider"
import type { ModelsDev } from "./models"
import { iife } from "@/util/iife"
import { Flag } from "@/flag/flag"
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
@@ -17,6 +18,8 @@ function mimeToModality(mime: string): Modality | undefined {
}
export namespace ProviderTransform {
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
// Maps npm package to the key the AI SDK expects for providerOptions
function sdkKey(npm: string): string | undefined {
switch (npm) {
@@ -723,29 +726,8 @@ export namespace ProviderTransform {
return { [key]: options }
}
export function maxOutputTokens(
npm: string,
options: Record<string, any>,
modelLimit: number,
globalLimit: number,
): number {
const modelCap = modelLimit || globalLimit
const standardLimit = Math.min(modelCap, globalLimit)
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
const thinking = options?.["thinking"]
const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0
const enabled = thinking?.["type"] === "enabled"
if (enabled && budgetTokens > 0) {
// Return text tokens so that text + thinking <= model cap, preferring 32k text when possible.
if (budgetTokens + standardLimit <= modelCap) {
return standardLimit
}
return modelCap - budgetTokens
}
}
return standardLimit
export function maxOutputTokens(model: Provider.Model): number {
return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
}
export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 {
+49 -13
View File
@@ -15,6 +15,17 @@ export namespace Pty {
const BUFFER_LIMIT = 1024 * 1024 * 2
const BUFFER_CHUNK = 64 * 1024
const encoder = new TextEncoder()
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
const meta = (cursor: number) => {
const json = JSON.stringify({ cursor })
const bytes = encoder.encode(json)
const out = new Uint8Array(bytes.length + 1)
out[0] = 0
out.set(bytes, 1)
return out
}
const pty = lazy(async () => {
const { spawn } = await import("bun-pty")
@@ -68,6 +79,8 @@ export namespace Pty {
info: Info
process: IPty
buffer: string
bufferCursor: number
cursor: number
subscribers: Set<WSContext>
}
@@ -139,23 +152,27 @@ export namespace Pty {
info,
process: ptyProcess,
buffer: "",
bufferCursor: 0,
cursor: 0,
subscribers: new Set(),
}
state().set(id, session)
ptyProcess.onData((data) => {
let open = false
session.cursor += data.length
for (const ws of session.subscribers) {
if (ws.readyState !== 1) {
session.subscribers.delete(ws)
continue
}
open = true
ws.send(data)
}
if (open) return
session.buffer += data
if (session.buffer.length <= BUFFER_LIMIT) return
session.buffer = session.buffer.slice(-BUFFER_LIMIT)
const excess = session.buffer.length - BUFFER_LIMIT
session.buffer = session.buffer.slice(excess)
session.bufferCursor += excess
})
ptyProcess.onExit(({ exitCode }) => {
log.info("session exited", { id, exitCode })
@@ -215,28 +232,47 @@ export namespace Pty {
}
}
export function connect(id: string, ws: WSContext) {
export function connect(id: string, ws: WSContext, cursor?: number) {
const session = state().get(id)
if (!session) {
ws.close()
return
}
log.info("client connected to session", { id })
session.subscribers.add(ws)
if (session.buffer) {
const buffer = session.buffer.length <= BUFFER_LIMIT ? session.buffer : session.buffer.slice(-BUFFER_LIMIT)
session.buffer = ""
const start = session.bufferCursor
const end = session.cursor
const from =
cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0
const data = (() => {
if (!session.buffer) return ""
if (from >= end) return ""
const offset = Math.max(0, from - start)
if (offset >= session.buffer.length) return ""
return session.buffer.slice(offset)
})()
if (data) {
try {
for (let i = 0; i < buffer.length; i += BUFFER_CHUNK) {
ws.send(buffer.slice(i, i + BUFFER_CHUNK))
for (let i = 0; i < data.length; i += BUFFER_CHUNK) {
ws.send(data.slice(i, i + BUFFER_CHUNK))
}
} catch {
session.subscribers.delete(ws)
session.buffer = buffer
ws.close()
return
}
}
try {
ws.send(meta(end))
} catch {
ws.close()
return
}
session.subscribers.add(ws)
return {
onMessage: (message: string | ArrayBuffer) => {
session.process.write(String(message))
+2 -3
View File
@@ -64,7 +64,7 @@ export const FileRoutes = lazy(() =>
z.object({
query: z.string(),
dirs: z.enum(["true", "false"]).optional(),
type: z.enum(["file", "directory"]).optional(),
type: z.enum(["file", "directory", "all"]).optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
),
@@ -76,8 +76,7 @@ export const FileRoutes = lazy(() =>
const results = await File.search({
query,
limit: limit ?? 10,
dirs: dirs !== "false",
type,
type: dirs === "false" ? "file" : type,
})
return c.json(results)
},
+8 -1
View File
@@ -151,11 +151,18 @@ export const PtyRoutes = lazy(() =>
validator("param", z.object({ ptyID: z.string() })),
upgradeWebSocket((c) => {
const id = c.req.param("ptyID")
const cursor = (() => {
const value = c.req.query("cursor")
if (!value) return
const parsed = Number(value)
if (!Number.isSafeInteger(parsed) || parsed < -1) return
return parsed
})()
let handler: ReturnType<typeof Pty.connect>
if (!Pty.get(id)) throw new Error("Session not found")
return {
onOpen(_event, ws) {
handler = Pty.connect(id, ws)
handler = Pty.connect(id, ws, cursor)
},
onMessage(event) {
handler?.onMessage(String(event.data))
+9 -1
View File
@@ -78,6 +78,9 @@ export namespace Server {
})
})
.use((c, next) => {
// Allow CORS preflight requests to succeed without auth.
// Browser clients sending Authorization headers will preflight with OPTIONS.
if (c.req.method === "OPTIONS") return next()
const password = Flag.OPENCODE_SERVER_PASSWORD
if (!password) return next()
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
@@ -107,7 +110,12 @@ export namespace Server {
if (input.startsWith("http://localhost:")) return input
if (input.startsWith("http://127.0.0.1:")) return input
if (input === "tauri://localhost" || input === "http://tauri.localhost") return input
if (
input === "tauri://localhost" ||
input === "http://tauri.localhost" ||
input === "https://tauri.localhost"
)
return input
// *.opencode.ai (https only, adjust if needed)
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
+41 -8
View File
@@ -6,7 +6,6 @@ import { Instance } from "../project/instance"
import { Provider } from "../provider/provider"
import { MessageV2 } from "./message-v2"
import z from "zod"
import { SessionPrompt } from "./prompt"
import { Token } from "../util/token"
import { Log } from "../util/log"
import { SessionProcessor } from "./processor"
@@ -14,6 +13,7 @@ import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { ProviderTransform } from "@/provider/transform"
export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
@@ -27,15 +27,22 @@ export namespace SessionCompaction {
),
}
const COMPACTION_BUFFER = 20_000
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
const config = await Config.get()
if (config.compaction?.auto === false) return false
const context = input.model.limit.context
if (context === 0) return false
const count = input.tokens.input + input.tokens.cache.read + input.tokens.output
const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX
const usable = input.model.limit.input || context - output
return count > usable
const count =
input.tokens.total ||
input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
const reserved =
config.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
const usable = input.model.limit.input ? input.model.limit.input - reserved : context - reserved
return count >= usable
}
export const PRUNE_MINIMUM = 20_000
@@ -139,8 +146,34 @@ export namespace SessionCompaction {
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const defaultPrompt =
"Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation."
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
The summary that you construct will be used so that another agent can read it and continue the work.
When constructing the summary, try to stick to this template:
---
## Goal
[What goal(s) is the user trying to accomplish?]
## Instructions
- [What important instructions did the user give you that are relevant]
- [If there is a plan or spec, include information about it so next agent can continue using it]
## Discoveries
[What notable things were learned during this conversation that would be useful for the next agent to know when continuing the work]
## Accomplished
[What work has been completed, what work is still in progress, and what work is left?]
## Relevant files / directories
[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
---`
const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
const result = await processor.process({
user: userMessage,
@@ -181,7 +214,7 @@ export namespace SessionCompaction {
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: "Continue if you have next steps",
text: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.",
time: {
start: Date.now(),
end: Date.now(),
+45 -19
View File
@@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import z from "zod"
import { type LanguageModelUsage, type ProviderMetadata } from "ai"
import { type ProviderMetadata } from "ai"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
@@ -22,6 +22,8 @@ import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -439,34 +441,58 @@ export namespace Session {
export const getUsage = fn(
z.object({
model: z.custom<Provider.Model>(),
usage: z.custom<LanguageModelUsage>(),
usage: z.custom<LanguageModelV2Usage>(),
metadata: z.custom<ProviderMetadata>().optional(),
}),
(input) => {
const cacheReadInputTokens = input.usage.cachedInputTokens ?? 0
const cacheWriteInputTokens = (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0) as number
const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
const adjustedInputTokens = excludesCachedTokens
? (input.usage.inputTokens ?? 0)
: (input.usage.inputTokens ?? 0) - cacheReadInputTokens - cacheWriteInputTokens
const safe = (value: number) => {
if (!Number.isFinite(value)) return 0
return value
}
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
const cacheWriteInputTokens = safe(
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0) as number,
)
// OpenRouter provides inputTokens as the total count of input tokens (including cached).
// AFAIK other providers (OpenRouter/OpenAI/Gemini etc.) do it the same way e.g. vercel/ai#8794 (comment)
// Anthropic does it differently though - inputTokens doesn't include cached tokens.
// It looks like OpenCode's cost calculation assumes all providers return inputTokens the same way Anthropic does (I'm guessing getUsage logic was originally implemented with anthropic), so it's causing incorrect cost calculation for OpenRouter and others.
const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
const adjustedInputTokens = safe(
excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens,
)
const total = iife(() => {
// Anthropic doesn't provide total_tokens, also ai sdk will vastly undercount if we
// don't compute from components
if (
input.model.api.npm === "@ai-sdk/anthropic" ||
input.model.api.npm === "@ai-sdk/amazon-bedrock" ||
input.model.api.npm === "@ai-sdk/google-vertex/anthropic"
) {
return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens
}
return input.usage.totalTokens
})
const tokens = {
input: safe(adjustedInputTokens),
output: safe(input.usage.outputTokens ?? 0),
reasoning: safe(input.usage?.reasoningTokens ?? 0),
total,
input: adjustedInputTokens,
output: outputTokens,
reasoning: reasoningTokens,
cache: {
write: safe(cacheWriteInputTokens),
read: safe(cacheReadInputTokens),
write: cacheWriteInputTokens,
read: cacheReadInputTokens,
},
}
+2 -10
View File
@@ -25,8 +25,7 @@ import { Auth } from "@/auth"
export namespace LLM {
const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
export type StreamInput = {
user: MessageV2.User
@@ -149,14 +148,7 @@ export namespace LLM {
)
const maxOutputTokens =
isCodex || provider.id.includes("github-copilot")
? undefined
: ProviderTransform.maxOutputTokens(
input.model.api.npm,
params.options,
input.model.limit.output,
OUTPUT_TOKEN_MAX,
)
isCodex || provider.id.includes("github-copilot") ? undefined : ProviderTransform.maxOutputTokens(input.model)
const tools = await resolveTools(input)
@@ -210,6 +210,7 @@ export namespace MessageV2 {
snapshot: z.string().optional(),
cost: z.number(),
tokens: z.object({
total: z.number().optional(),
input: z.number(),
output: z.number(),
reasoning: z.number(),
@@ -383,6 +384,7 @@ export namespace MessageV2 {
summary: z.boolean().optional(),
cost: z.number(),
tokens: z.object({
total: z.number().optional(),
input: z.number(),
output: z.number(),
reasoning: z.number(),
@@ -342,6 +342,9 @@ export namespace SessionProcessor {
stack: JSON.stringify(e.stack),
})
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
if (MessageV2.ContextOverflowError.isInstance(error)) {
// TODO: Handle context overflow error
}
const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
attempt++
-1
View File
@@ -52,7 +52,6 @@ globalThis.AI_SDK_LOG_WARNINGS = false
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
const state = Instance.state(
() => {
+3 -2
View File
@@ -59,11 +59,12 @@ export namespace SessionRetry {
}
export function retryable(error: ReturnType<NamedError["toObject"]>) {
// DO NOT retry context overflow errors
// context overflow errors should not be retried
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) {
if (!error.data.isRetryable) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError"))
return `Free usage exceeded, add credits https://opencode.ai/zen`
return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message
}
+1 -15
View File
@@ -9,8 +9,6 @@ import { Identifier } from "@/id/id"
import { Snapshot } from "@/snapshot"
import { Log } from "@/util/log"
import path from "path"
import { Instance } from "@/project/instance"
import { Storage } from "@/storage/storage"
import { Bus } from "@/bus"
@@ -91,18 +89,7 @@ export namespace SessionSummary {
)
async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) {
const files = new Set(
input.messages
.flatMap((x) => x.parts)
.filter((x) => x.type === "patch")
.flatMap((x) => x.files)
.map((x) => path.relative(Instance.worktree, x).replaceAll("\\", "/")),
)
const diffs = await computeDiff({ messages: input.messages }).then((x) =>
x.filter((x) => {
return files.has(x.file)
}),
)
const diffs = await computeDiff({ messages: input.messages })
await Session.update(input.sessionID, (draft) => {
draft.summary = {
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
@@ -206,7 +193,6 @@ export namespace SessionSummary {
for (const part of item.parts) {
if (part.type === "step-finish" && part.snapshot) {
to = part.snapshot
break
}
}
}
+52
View File
@@ -0,0 +1,52 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { tmpdir } from "../fixture/fixture"
import { FFF } from "../../src/file/fff"
import { File } from "../../src/file"
import { Instance } from "../../src/project/instance"
describe("file.fff", () => {
test("returns files and supports directory search via File.search", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "src", "app", "index.ts"), "export const app = true")
await Bun.write(path.join(dir, "src", "app", "util.ts"), "export const util = true")
await Bun.write(path.join(dir, "docs", "guide.md"), "# guide")
},
})
const files = await FFF.search({
cwd: tmp.path,
query: "index",
limit: 20,
})
expect(files.includes(path.join("src", "app", "index.ts"))).toBe(true)
await Instance.provide({
directory: tmp.path,
fn: async () => {
const found = await File.search({
query: "index",
type: "file",
limit: 20,
})
expect(found.includes(path.join("src", "app", "index.ts"))).toBe(true)
const dirs = await File.search({
query: "app",
type: "directory",
limit: 20,
})
expect(dirs.includes("src/app/")).toBe(true)
const all = await File.search({
query: "app",
type: "all",
limit: 20,
})
expect(all.includes(path.join("src", "app", "index.ts"))).toBe(true)
expect(all.includes("src/app/")).toBe(true)
},
})
})
})
@@ -175,100 +175,6 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
})
})
describe("ProviderTransform.maxOutputTokens", () => {
test("returns 32k when modelLimit > 32k", () => {
const modelLimit = 100000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns modelLimit when modelLimit < 32k", () => {
const modelLimit = 16000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(16000)
})
describe("azure", () => {
test("returns 32k when modelLimit > 32k", () => {
const modelLimit = 100000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/azure", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns modelLimit when modelLimit < 32k", () => {
const modelLimit = 16000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/azure", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(16000)
})
})
describe("bedrock", () => {
test("returns 32k when modelLimit > 32k", () => {
const modelLimit = 100000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/amazon-bedrock", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns modelLimit when modelLimit < 32k", () => {
const modelLimit = 16000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/amazon-bedrock", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(16000)
})
})
describe("anthropic without thinking options", () => {
test("returns 32k when modelLimit > 32k", () => {
const modelLimit = 100000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns modelLimit when modelLimit < 32k", () => {
const modelLimit = 16000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(16000)
})
})
describe("anthropic with thinking options", () => {
test("returns 32k when budgetTokens + 32k <= modelLimit", () => {
const modelLimit = 100000
const options = {
thinking: {
type: "enabled",
budgetTokens: 10000,
},
}
const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", options, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns modelLimit - budgetTokens when budgetTokens + 32k > modelLimit", () => {
const modelLimit = 50000
const options = {
thinking: {
type: "enabled",
budgetTokens: 30000,
},
}
const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", options, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(20000)
})
test("returns 32k when thinking type is not enabled", () => {
const modelLimit = 100000
const options = {
thinking: {
type: "disabled",
budgetTokens: 10000,
},
}
const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", options, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
})
})
describe("ProviderTransform.schema - gemini array items", () => {
test("adds missing items for array properties", () => {
const geminiModel = {
@@ -15,6 +15,7 @@ function createModel(opts: {
output: number
input?: number
cost?: Provider.Model["cost"]
npm?: string
}): Provider.Model {
return {
id: "test-model",
@@ -34,7 +35,7 @@ function createModel(opts: {
input: { text: true, image: false, audio: false, video: false },
output: { text: true, image: false, audio: false, video: false },
},
api: { npm: "@ai-sdk/anthropic" },
api: { npm: opts.npm ?? "@ai-sdk/anthropic" },
options: {},
} as Provider.Model
}
@@ -70,7 +71,7 @@ describe("session.compaction.isOverflow", () => {
directory: tmp.path,
fn: async () => {
const model = createModel({ context: 100_000, output: 32_000 })
const tokens = { input: 50_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
const tokens = { input: 60_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
@@ -112,6 +113,86 @@ describe("session.compaction.isOverflow", () => {
})
})
// ─── Bug reproduction tests ───────────────────────────────────────────
// These tests demonstrate that when limit.input is set, isOverflow()
// does not subtract any headroom for the next model response. This means
// compaction only triggers AFTER we've already consumed the full input
// budget, leaving zero room for the next API call's output tokens.
//
// Compare: without limit.input, usable = context - output (reserves space).
// With limit.input, usable = limit.input (reserves nothing).
//
// Related issues: #10634, #8089, #11086, #12621
// Open PRs: #6875, #12924
test("BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Simulate Claude with prompt caching: input limit = 200K, output limit = 32K
const model = createModel({ context: 200_000, input: 200_000, output: 32_000 })
// We've used 198K tokens total. Only 2K under the input limit.
// On the next turn, the full conversation (198K) becomes input,
// plus the model needs room to generate output — this WILL overflow.
const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } }
// count = 180K + 3K + 15K = 198K
// usable = limit.input = 200K (no output subtracted!)
// 198K > 200K = false → no compaction triggered
// WITHOUT limit.input: usable = 200K - 32K = 168K, and 198K > 168K = true ✓
// WITH limit.input: usable = 200K, and 198K > 200K = false ✗
// With 198K used and only 2K headroom, the next turn will overflow.
// Compaction MUST trigger here.
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
})
test("BUG: without limit.input, same token count correctly triggers compaction", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Same model but without limit.input — uses context - output instead
const model = createModel({ context: 200_000, output: 32_000 })
// Same token usage as above
const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } }
// count = 198K
// usable = context - output = 200K - 32K = 168K
// 198K > 168K = true → compaction correctly triggered
const result = await SessionCompaction.isOverflow({ tokens, model })
expect(result).toBe(true) // ← Correct: headroom is reserved
},
})
})
test("BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Two models with identical context/output limits, differing only in limit.input
const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 })
const withoutInputLimit = createModel({ context: 200_000, output: 32_000 })
// 170K total tokens — well above context-output (168K) but below input limit (200K)
const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } }
const withLimit = await SessionCompaction.isOverflow({ tokens, model: withInputLimit })
const withoutLimit = await SessionCompaction.isOverflow({ tokens, model: withoutInputLimit })
// Both models have identical real capacity — they should agree:
expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output)
expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K)
},
})
})
test("returns false when model context limit is 0", async () => {
await using tmp = await tmpdir()
await Instance.provide({
@@ -290,4 +371,53 @@ describe("session.getUsage", () => {
expect(result.cost).toBe(3 + 1.5)
})
test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])(
"computes total from components for %s models",
(npm) => {
const model = createModel({ context: 100_000, output: 32_000, npm })
const usage = {
inputTokens: 1000,
outputTokens: 500,
// These providers typically report total as input + output only,
// excluding cache read/write.
totalTokens: 1500,
cachedInputTokens: 200,
}
if (npm === "@ai-sdk/amazon-bedrock") {
const result = Session.getUsage({
model,
usage,
metadata: {
bedrock: {
usage: {
cacheWriteInputTokens: 300,
},
},
},
})
expect(result.tokens.input).toBe(1000)
expect(result.tokens.cache.read).toBe(200)
expect(result.tokens.cache.write).toBe(300)
expect(result.tokens.total).toBe(2000)
return
}
const result = Session.getUsage({
model,
usage,
metadata: {
anthropic: {
cacheCreationInputTokens: 300,
},
},
})
expect(result.tokens.input).toBe(1000)
expect(result.tokens.cache.read).toBe(200)
expect(result.tokens.cache.write).toBe(300)
expect(result.tokens.total).toBe(2000)
},
)
})
+4 -28
View File
@@ -314,12 +314,7 @@ describe("session.llm.stream", () => {
expect(body.stream).toBe(true)
const maxTokens = (body.max_tokens as number | undefined) ?? (body.max_output_tokens as number | undefined)
const expectedMaxTokens = ProviderTransform.maxOutputTokens(
resolved.api.npm,
ProviderTransform.options({ model: resolved, sessionID }),
resolved.limit.output,
LLM.OUTPUT_TOKEN_MAX,
)
const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved)
expect(maxTokens).toBe(expectedMaxTokens)
const reasoning = (body.reasoningEffort as string | undefined) ?? (body.reasoning_effort as string | undefined)
@@ -442,12 +437,7 @@ describe("session.llm.stream", () => {
expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
const maxTokens = body.max_output_tokens as number | undefined
const expectedMaxTokens = ProviderTransform.maxOutputTokens(
resolved.api.npm,
ProviderTransform.options({ model: resolved, sessionID }),
resolved.limit.output,
LLM.OUTPUT_TOKEN_MAX,
)
const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved)
expect(maxTokens).toBe(expectedMaxTokens)
},
})
@@ -565,14 +555,7 @@ describe("session.llm.stream", () => {
expect(capture.url.pathname.endsWith("/messages")).toBe(true)
expect(body.model).toBe(resolved.api.id)
expect(body.max_tokens).toBe(
ProviderTransform.maxOutputTokens(
resolved.api.npm,
ProviderTransform.options({ model: resolved, sessionID }),
resolved.limit.output,
LLM.OUTPUT_TOKEN_MAX,
),
)
expect(body.max_tokens).toBe(ProviderTransform.maxOutputTokens(resolved))
expect(body.temperature).toBe(0.4)
expect(body.top_p).toBe(0.9)
},
@@ -677,14 +660,7 @@ describe("session.llm.stream", () => {
expect(capture.url.pathname).toBe(pathSuffix)
expect(config?.temperature).toBe(0.3)
expect(config?.topP).toBe(0.8)
expect(config?.maxOutputTokens).toBe(
ProviderTransform.maxOutputTokens(
resolved.api.npm,
ProviderTransform.options({ model: resolved, sessionID }),
resolved.limit.output,
LLM.OUTPUT_TOKEN_MAX,
),
)
expect(config?.maxOutputTokens).toBe(ProviderTransform.maxOutputTokens(resolved))
},
})
})
@@ -112,6 +112,15 @@ describe("session.retry.retryable", () => {
const error = wrap("not-json")
expect(SessionRetry.retryable(error)).toBeUndefined()
})
test("does not retry context overflow errors", () => {
const error = new MessageV2.ContextOverflowError({
message: "Input exceeds context window of this model",
responseBody: '{"error":{"code":"context_length_exceeded"}}',
}).toObject() as ReturnType<NamedError["toObject"]>
expect(SessionRetry.retryable(error)).toBeUndefined()
})
})
describe("session.message-v2.fromError", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.53",
"version": "1.1.56",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.53",
"version": "1.1.56",
"type": "module",
"license": "MIT",
"scripts": {
+6
View File
@@ -203,6 +203,7 @@ export type AssistantMessage = {
summary?: boolean
cost: number
tokens: {
total?: number
input: number
output: number
reasoning: number
@@ -418,6 +419,7 @@ export type StepFinishPart = {
snapshot?: string
cost: number
tokens: {
total?: number
input: number
output: number
reasoning: number
@@ -1822,6 +1824,10 @@ export type Config = {
* Enable pruning of old tool outputs (default: true)
*/
prune?: boolean
/**
* Token buffer for compaction. Leaves enough window to avoid overflow during compaction.
*/
reserved?: number
}
experimental?: {
disable_paste_summary?: boolean
+12
View File
@@ -6393,6 +6393,9 @@
"tokens": {
"type": "object",
"properties": {
"total": {
"type": "number"
},
"input": {
"type": "number"
},
@@ -7023,6 +7026,9 @@
"tokens": {
"type": "object",
"properties": {
"total": {
"type": "number"
},
"input": {
"type": "number"
},
@@ -9894,6 +9900,12 @@
"prune": {
"description": "Enable pruning of old tool outputs (default: true)",
"type": "boolean"
},
"reserved": {
"description": "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
}
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.53",
"version": "1.1.56",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.53",
"version": "1.1.56",
"type": "module",
"license": "MIT",
"exports": {
+88 -82
View File
@@ -1,5 +1,6 @@
import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Portal } from "solid-js/web"
import { createDefaultOptions, styleVariables } from "../pierre"
import { getWorkerPool } from "../pierre/worker"
import { Icon } from "./icon"
@@ -125,11 +126,9 @@ export function Code<T>(props: CodeProps<T>) {
let wrapper!: HTMLDivElement
let container!: HTMLDivElement
let findInput: HTMLInputElement | undefined
let findBar: HTMLDivElement | undefined
let findOverlay!: HTMLDivElement
let findOverlayFrame: number | undefined
let findOverlayScroll: HTMLElement[] = []
let findScroll: HTMLElement | undefined
let observer: MutationObserver | undefined
let renderToken = 0
let selectionFrame: number | undefined
@@ -159,6 +158,8 @@ export function Code<T>(props: CodeProps<T>) {
let findMode: "highlights" | "overlay" = "overlay"
let findHits: Range[] = []
const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 })
const file = createMemo(
() =>
new File<T>(
@@ -291,23 +292,26 @@ export function Code<T>(props: CodeProps<T>) {
setFindIndex(0)
}
const getScrollParent = (el: HTMLElement): HTMLElement | null => {
const getScrollParent = (el: HTMLElement): HTMLElement | undefined => {
let parent = el.parentElement
while (parent) {
const style = getComputedStyle(parent)
if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
parent = parent.parentElement
}
return null
}
const positionFindBar = () => {
if (!findBar || !wrapper) return
const scrollTop = findScroll ? findScroll.scrollTop : window.scrollY
findBar.style.position = "absolute"
findBar.style.top = `${scrollTop + 8}px`
findBar.style.right = "8px"
findBar.style.left = ""
if (typeof window === "undefined") return
const root = getScrollParent(wrapper) ?? wrapper
const rect = root.getBoundingClientRect()
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
const header = Number.isNaN(title) ? 0 : title
setFindPos({
top: Math.round(rect.top) + header - 4,
right: Math.round(window.innerWidth - rect.right) + 8,
})
}
const scanFind = (root: ShadowRoot, query: string) => {
@@ -426,7 +430,6 @@ export function Code<T>(props: CodeProps<T>) {
}
if (opts?.scroll && active) {
scrollToRange(active)
positionFindBar()
}
return
}
@@ -435,7 +438,6 @@ export function Code<T>(props: CodeProps<T>) {
syncOverlayScroll()
if (opts?.scroll && active) {
scrollToRange(active)
positionFindBar()
}
scheduleOverlay()
}
@@ -464,14 +466,12 @@ export function Code<T>(props: CodeProps<T>) {
return
}
scrollToRange(active)
positionFindBar()
return
}
clearHighlightFind()
syncOverlayScroll()
scrollToRange(active)
positionFindBar()
scheduleOverlay()
}
@@ -484,11 +484,9 @@ export function Code<T>(props: CodeProps<T>) {
findCurrent = host
findTarget = host
findScroll = getScrollParent(wrapper) ?? undefined
if (!findOpen()) setFindOpen(true)
requestAnimationFrame(() => {
applyFind({ scroll: true })
positionFindBar()
findInput?.focus()
findInput?.select()
})
@@ -514,18 +512,18 @@ export function Code<T>(props: CodeProps<T>) {
createEffect(() => {
if (!findOpen()) return
findScroll = getScrollParent(wrapper) ?? undefined
const target = findScroll ?? window
const handler = () => positionFindBar()
target.addEventListener("scroll", handler, { passive: true })
window.addEventListener("resize", handler, { passive: true })
handler()
const update = () => positionFindBar()
requestAnimationFrame(update)
window.addEventListener("resize", update, { passive: true })
const root = getScrollParent(wrapper) ?? wrapper
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
observer?.observe(root)
onCleanup(() => {
target.removeEventListener("scroll", handler)
window.removeEventListener("resize", handler)
findScroll = undefined
window.removeEventListener("resize", update)
observer?.disconnect()
})
})
@@ -916,6 +914,64 @@ export function Code<T>(props: CodeProps<T>) {
pendingSelectionEnd = false
})
const FindBar = (barProps: { class: string; style?: ComponentProps<"div">["style"] }) => (
<div class={barProps.class} style={barProps.style} onPointerDown={(e) => e.stopPropagation()}>
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
<input
ref={findInput}
placeholder="Find"
value={findQuery()}
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
onInput={(e) => {
setFindQuery(e.currentTarget.value)
setFindIndex(0)
applyFind({ reset: true, scroll: true })
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
closeFind()
return
}
if (e.key !== "Enter") return
e.preventDefault()
stepFind(e.shiftKey ? -1 : 1)
}}
/>
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
</div>
<div class="flex items-center">
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Previous match"
onClick={() => stepFind(-1)}
>
<Icon name="chevron-down" size="small" class="rotate-180" />
</button>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Next match"
onClick={() => stepFind(1)}
>
<Icon name="chevron-down" size="small" />
</button>
</div>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
aria-label="Close search"
onClick={closeFind}
>
<Icon name="close-small" size="small" />
</button>
</div>
)
return (
<div
data-component="code"
@@ -936,65 +992,15 @@ export function Code<T>(props: CodeProps<T>) {
}}
>
<Show when={findOpen()}>
<div
ref={findBar}
class="z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
onPointerDown={(e) => e.stopPropagation()}
>
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
<input
ref={findInput}
placeholder="Find"
value={findQuery()}
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
onInput={(e) => {
setFindQuery(e.currentTarget.value)
setFindIndex(0)
applyFind({ reset: true, scroll: true })
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
closeFind()
return
}
if (e.key !== "Enter") return
e.preventDefault()
stepFind(e.shiftKey ? -1 : 1)
<Portal>
<FindBar
class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
style={{
top: `${findPos().top}px`,
right: `${findPos().right}px`,
}}
/>
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
</div>
<div class="flex items-center">
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Previous match"
onClick={() => stepFind(-1)}
>
<Icon name="chevron-down" size="small" class="rotate-180" />
</button>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Next match"
onClick={() => stepFind(1)}
>
<Icon name="chevron-down" size="small" />
</button>
</div>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
aria-label="Close search"
onClick={closeFind}
>
<Icon name="close-small" size="small" />
</button>
</div>
</Portal>
</Show>
<div ref={container} />
<div ref={findOverlay} class="pointer-events-none absolute inset-0 z-0" />
+70 -32
View File
@@ -877,6 +877,74 @@ ToolRegistry.register({
const data = useData()
const i18n = useI18n()
const childSessionId = () => props.metadata.sessionId as string | undefined
const href = createMemo(() => {
const sessionId = childSessionId()
if (!sessionId) return
const direct = data.sessionHref?.(sessionId)
if (direct) return direct
if (typeof window === "undefined") return
const path = window.location.pathname
const idx = path.indexOf("/session")
if (idx === -1) return
return `${path.slice(0, idx)}/session/${sessionId}`
})
createEffect(() => {
const sessionId = childSessionId()
if (!sessionId) return
const sync = data.syncSession
if (!sync) return
Promise.resolve(sync(sessionId)).catch(() => undefined)
})
const handleLinkClick = (e: MouseEvent) => {
const sessionId = childSessionId()
const url = href()
if (!sessionId || !url) return
e.stopPropagation()
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const nav = data.navigateToSession
if (!nav || typeof window === "undefined") return
e.preventDefault()
const before = window.location.pathname + window.location.search + window.location.hash
nav(sessionId)
setTimeout(() => {
const after = window.location.pathname + window.location.search + window.location.hash
if (after === before) window.location.assign(url)
}, 50)
}
const trigger = () => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title" class="capitalize">
{i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })}
</span>
<Show when={props.input.description}>
<Switch>
<Match when={href()}>
{(url) => (
<a data-slot="basic-tool-tool-subtitle" class="clickable" href={url()} onClick={handleLinkClick}>
{props.input.description}
</a>
)}
</Match>
<Match when={true}>
<span data-slot="basic-tool-tool-subtitle">{props.input.description}</span>
</Match>
</Switch>
</Show>
</div>
</div>
)
const childToolParts = createMemo(() => {
const sessionId = childSessionId()
if (!sessionId) return []
@@ -924,13 +992,6 @@ ToolRegistry.register({
})
}
const handleSubtitleClick = () => {
const sessionId = childSessionId()
if (sessionId && data.navigateToSession) {
data.navigateToSession(sessionId)
}
}
const renderChildToolPart = () => {
const toolData = childToolPart()
if (!toolData) return null
@@ -958,21 +1019,7 @@ ToolRegistry.register({
<Switch>
<Match when={childPermission()}>
<>
<Show
when={childToolPart()}
fallback={
<BasicTool
icon="task"
defaultOpen={true}
trigger={{
title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }),
titleClass: "capitalize",
subtitle: props.input.description,
}}
onSubtitleClick={handleSubtitleClick}
/>
}
>
<Show when={childToolPart()} fallback={<BasicTool icon="task" defaultOpen={true} trigger={trigger()} />}>
{renderChildToolPart()}
</Show>
<div data-component="permission-prompt">
@@ -991,16 +1038,7 @@ ToolRegistry.register({
</>
</Match>
<Match when={true}>
<BasicTool
icon="task"
defaultOpen={true}
trigger={{
title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }),
titleClass: "capitalize",
subtitle: props.input.description,
}}
onSubtitleClick={handleSubtitleClick}
>
<BasicTool icon="task" defaultOpen={true} trigger={trigger()}>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
+8
View File
@@ -48,6 +48,10 @@ export type QuestionRejectFn = (input: { requestID: string }) => void
export type NavigateToSessionFn = (sessionID: string) => void
export type SessionHrefFn = (sessionID: string) => string
export type SyncSessionFn = (sessionID: string) => void | Promise<void>
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: {
@@ -57,6 +61,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
onQuestionReply?: QuestionReplyFn
onQuestionReject?: QuestionRejectFn
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
onSyncSession?: SyncSessionFn
}) => {
return {
get store() {
@@ -69,6 +75,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
replyToQuestion: props.onQuestionReply,
rejectQuestion: props.onQuestionReject,
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
syncSession: props.onSyncSession,
}
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.1.53",
"version": "1.1.56",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.1.53",
"version": "1.1.56",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+2 -2
View File
@@ -142,13 +142,13 @@ require("codecompanion").setup({
## الدعم
يعمل OpenCode عبر ACP بالطريقة نفسها التي يعمل بها في الطرفية. جميع الميزات مدعومة:
يعمل OpenCode عبر ACP بالطريقة نفسها التي يعمل بها في terminal. جميع الميزات مدعومة:
:::note
بعض أوامر الشرطة المائلة المضمنة مثل `/undo` و`/redo` غير مدعومة حاليا.
:::
- الأدوات المضمنة (عمليات الملفات، أوامر الطرفية، إلخ.)
- الأدوات المضمنة (عمليات الملفات، أوامر terminal، إلخ.)
- الأدوات المخصصة وأوامر الشرطة المائلة
- خوادم MCP المضبوطة في إعدادات OpenCode
- قواعد خاصة بالمشروع من `AGENTS.md`
+1 -1
View File
@@ -460,7 +460,7 @@ permission:
webfetch: deny
---
حلل الشفرة فقط واقترح التغييرات.
Only analyze code and suggest changes.
```
يمكنك ضبط الأذونات لأوامر bash محددة.
+7 -7
View File
@@ -1,11 +1,11 @@
---
title: سطر الأوامر
description: خيارات وأوامر سطر أوامر OpenCode.
title: CLI
description: أوامر وخيارات CLI في OpenCode.
---
import { Tabs, TabItem } from "@astrojs/starlight/components"
يبدأ سطر أوامر OpenCode افتراضيا واجهة [TUI](/docs/tui) عند تشغيله بدون أي معاملات.
يبدأ CLI الخاص بـ OpenCode افتراضيا واجهة [TUI](/docs/tui) عند تشغيله بدون أي معاملات.
```bash
opencode
@@ -21,7 +21,7 @@ opencode run "Explain how closures work in JavaScript"
### tui
بدء واجهة المستخدم الطرفية (TUI) الخاصة بـ OpenCode.
بدء واجهة المستخدم terminal (TUI) الخاصة بـ OpenCode.
```bash
opencode [project]
@@ -44,7 +44,7 @@ opencode [project]
## الأوامر
يتضمن سطر أوامر OpenCode أيضا الأوامر التالية.
يتضمن CLI الخاص بـ OpenCode أيضا الأوامر التالية.
---
@@ -60,7 +60,7 @@ opencode agent [command]
### attach
إرفاق طرفية بخادم الواجهة الخلفية لـ OpenCode قيد التشغيل بالفعل، والذي تم تشغيله عبر الأمرين `serve` أو `web`.
إرفاق terminal بخادم الواجهة الخلفية لـ OpenCode قيد التشغيل بالفعل، والذي تم تشغيله عبر الأمرين `serve` أو `web`.
```bash
opencode attach [url]
@@ -562,7 +562,7 @@ opencode upgrade v0.1.48
| `OPENCODE_CONFIG_CONTENT` | string | محتوى تهيئة JSON مُضمّن |
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | تعطيل التحقق التلقائي من التحديثات |
| `OPENCODE_DISABLE_PRUNE` | boolean | تعطيل تنقية البيانات القديمة |
| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | تعطيل تحديث عنوان الطرفية تلقائيا |
| `OPENCODE_DISABLE_TERMINAL_TITLE` | boolean | تعطيل تحديث عنوان terminal تلقائيا |
| `OPENCODE_PERMISSION` | string | تهيئة أذونات JSON مُضمّنة |
| `OPENCODE_DISABLE_DEFAULT_PLUGINS` | boolean | تعطيل الإضافات الافتراضية |
| `OPENCODE_DISABLE_LSP_DOWNLOAD` | boolean | تعطيل تنزيل خوادم LSP تلقائيا |
@@ -160,7 +160,7 @@ with the following content: $3
---
### خرج الصدفة (Shell)
### خرج shell
استخدم _!`command`_ لحقن خرج [أمر bash](/docs/tui#bash-commands) داخل مُطالبتك.
+1 -1
View File
@@ -172,7 +172,7 @@ opencode run "Hello world"
- `scroll_acceleration.enabled` - تفعيل تسارع التمرير بأسلوب macOS. **له أولوية على `scroll_speed`.**
- `scroll_speed` - مُضاعِف سرعة تمرير مخصص (الافتراضي: `3`، الحد الأدنى: `1`). يتم تجاهله إذا كان `scroll_acceleration.enabled` مساويًا لـ `true`.
- `diff_style` - التحكم في عرض `diff`. القيمة `"auto"` تتكيف مع عرض الطرفية، و`"stacked"` تعرض عمودًا واحدًا دائمًا.
- `diff_style` - التحكم في عرض `diff`. القيمة `"auto"` تتكيف مع عرض terminal، و`"stacked"` تعرض عمودًا واحدًا دائمًا.
[تعرف على المزيد حول استخدام TUI هنا](/docs/tui).
+13 -13
View File
@@ -52,19 +52,19 @@ description: مشاريع وتكاملات مبنية باستخدام OpenCode.
## المشاريع
| الاسم | الوصف |
| ------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------- |
| [kimaki](https://github.com/remorses/kimaki) | بوت Discord للتحكم بجلسات OpenCode، مبني على SDK |
| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | ملحق Neovim لموجهات تراعي المحرر، مبني على API |
| [portal](https://github.com/hosenur/portal) | واجهة ويب تركز على الجوال لـ OpenCode عبر Tailscale/VPN |
| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | قالب لبناء ملحقات OpenCode |
| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | واجهة Neovim لـ opencode - وكيل برمجة بالذكاء الاصطناعي يعمل في الطرفية |
| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | موفر Vercel AI SDK لاستخدام OpenCode عبر @opencode-ai/sdk |
| [OpenChamber](https://github.com/btriapitsyn/openchamber) | تطبيق ويب/سطح مكتب وامتداد VS Code لـ OpenCode |
| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | ملحق Obsidian يدمج OpenCode داخل واجهة Obsidian |
| [OpenWork](https://github.com/different-ai/openwork) | بديل مفتوح المصدر لـ Claude Cowork، مدعوم بـ OpenCode |
| [ocx](https://github.com/kdcokenny/ocx) | مدير امتدادات OpenCode مع ملفات تعريف محمولة ومعزولة. |
| [CodeNomad](https://github.com/NeuralNomadsAI/CodeNomad) | تطبيق عميل لسطح المكتب والويب والجوال وعن بُعد لـ OpenCode |
| الاسم | الوصف |
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------ |
| [kimaki](https://github.com/remorses/kimaki) | بوت Discord للتحكم بجلسات OpenCode، مبني على SDK |
| [opencode.nvim](https://github.com/NickvanDyke/opencode.nvim) | ملحق Neovim لموجهات تراعي المحرر، مبني على API |
| [portal](https://github.com/hosenur/portal) | واجهة ويب تركز على الجوال لـ OpenCode عبر Tailscale/VPN |
| [opencode plugin template](https://github.com/zenobi-us/opencode-plugin-template/) | قالب لبناء ملحقات OpenCode |
| [opencode.nvim](https://github.com/sudo-tee/opencode.nvim) | واجهة Neovim لـ opencode - وكيل برمجة بالذكاء الاصطناعي يعمل في terminal |
| [ai-sdk-provider-opencode-sdk](https://github.com/ben-vargas/ai-sdk-provider-opencode-sdk) | موفر Vercel AI SDK لاستخدام OpenCode عبر @opencode-ai/sdk |
| [OpenChamber](https://github.com/btriapitsyn/openchamber) | تطبيق ويب/سطح مكتب وامتداد VS Code لـ OpenCode |
| [OpenCode-Obsidian](https://github.com/mtymek/opencode-obsidian) | ملحق Obsidian يدمج OpenCode داخل واجهة Obsidian |
| [OpenWork](https://github.com/different-ai/openwork) | بديل مفتوح المصدر لـ Claude Cowork، مدعوم بـ OpenCode |
| [ocx](https://github.com/kdcokenny/ocx) | مدير امتدادات OpenCode مع ملفات تعريف محمولة ومعزولة. |
| [CodeNomad](https://github.com/NeuralNomadsAI/CodeNomad) | تطبيق عميل لسطح المكتب والويب والجوال وعن بُعد لـ OpenCode |
---
+6 -6
View File
@@ -3,14 +3,14 @@ title: IDE
description: امتداد OpenCode لـ VS Code و Cursor وغيرها من بيئات التطوير
---
يتكامل OpenCode مع VS Code و Cursor أو أي IDE يدعم الطرفية. ما عليك سوى تشغيل `opencode` في الطرفية للبدء.
يتكامل OpenCode مع VS Code و Cursor أو أي IDE يدعم terminal. ما عليك سوى تشغيل `opencode` في terminal للبدء.
---
## الاستخدام
- **تشغيل سريع**: استخدم `Cmd+Esc` (Mac) أو `Ctrl+Esc` (Windows/Linux) لفتح OpenCode في عرض طرفية مقسّم، أو للتركيز على جلسة طرفية موجودة إذا كانت قيد التشغيل بالفعل.
- **جلسة جديدة**: استخدم `Cmd+Shift+Esc` (Mac) أو `Ctrl+Shift+Esc` (Windows/Linux) لبدء جلسة طرفية جديدة لـ OpenCode حتى لو كانت هناك جلسة مفتوحة. يمكنك أيضا النقر على زر OpenCode في واجهة المستخدم.
- **تشغيل سريع**: استخدم `Cmd+Esc` (Mac) أو `Ctrl+Esc` (Windows/Linux) لفتح OpenCode في عرض terminal مقسّم، أو للتركيز على جلسة terminal موجودة إذا كانت قيد التشغيل بالفعل.
- **جلسة جديدة**: استخدم `Cmd+Shift+Esc` (Mac) أو `Ctrl+Shift+Esc` (Windows/Linux) لبدء جلسة terminal جديدة لـ OpenCode حتى لو كانت هناك جلسة مفتوحة. يمكنك أيضا النقر على زر OpenCode في واجهة المستخدم.
- **وعي بالسياق**: شارك تلقائيا تحديدك الحالي أو تبويبك مع OpenCode.
- **اختصارات الإشارة إلى الملفات**: استخدم `Cmd+Option+K` (Mac) أو `Alt+Ctrl+K` (Linux/Windows) لإدراج مراجع الملفات. مثلا: `@File#L37-42`.
@@ -21,7 +21,7 @@ description: امتداد OpenCode لـ VS Code و Cursor وغيرها من بي
لتثبيت OpenCode على VS Code والتفرعات الشائعة مثل Cursor و Windsurf و VSCodium:
1. افتح VS Code
2. افتح الطرفية المدمجة
2. افتح terminal المدمجة
3. شغّل `opencode` - سيتم تثبيت الامتداد تلقائيا
أما إذا كنت تريد استخدام IDE الخاص بك عند تشغيل `/editor` أو `/export` من واجهة TUI، فستحتاج إلى ضبط `export EDITOR="code --wait"`. [اعرف المزيد](/docs/tui/#editor-setup).
@@ -38,8 +38,8 @@ description: امتداد OpenCode لـ VS Code و Cursor وغيرها من بي
إذا فشل تثبيت الامتداد تلقائيا:
- تأكد من أنك تشغّل `opencode` داخل الطرفية المدمجة.
- تأكد من تثبيت واجهة سطر الأوامر (CLI) الخاصة بـ IDE لديك:
- تأكد من أنك تشغّل `opencode` داخل terminal المدمجة.
- تأكد من تثبيت CLI الخاصة بـ IDE لديك:
- لـ VS Code: أمر `code`
- لـ Cursor: أمر `cursor`
- لـ Windsurf: أمر `windsurf`

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