diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index ffa6ed7cd35..df53d1ac2ae 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -15,4 +15,5 @@ kitlangton kommander r44vc0rp rekram1-node +-spider-yamet clawdbot/llm psychosis, spam pinging the team thdxr diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d8a5c8a9025..8cf030eceb0 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,6 +1,6 @@ ### What does this PR do? -Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the pr. +Please provide a description of the issue (if there is one), the changes you made to fix it, and why they work. It is expected that you understand why your changes work and if you do not understand why at least say as much so a maintainer knows how much to value the PR. **If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!** diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md new file mode 100644 index 00000000000..dec6fa6c4fc --- /dev/null +++ b/.opencode/agent/translator.md @@ -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 +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 +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 ] [--hostname ] [--cors ] +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=

&model= +/experimental/tool/ids +/export +/file?path= +/file/content?path=

+/file/status +/find?pattern= +/find/file +/find/file?query= +/find/symbol?query= +/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 +.//storage/ +./config/#custom-directory +./global/storage/ +.agents/skills/*/SKILL.md +.agents/skills//SKILL.md +.clang-format +.claude +.claude/skills +.claude/skills/*/SKILL.md +.claude/skills//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//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 +/.opencode/themes/*.json +/ +/.opencode/plugins/ +~ +~/... +~/.agents/skills/*/SKILL.md +~/.agents/skills//SKILL.md +~/.aws/credentials +~/.bashrc +~/.cache/opencode +~/.cache/opencode/node_modules/ +~/.claude/CLAUDE.md +~/.claude/skills/ +~/.claude/skills/*/SKILL.md +~/.claude/skills//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//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://:4096 +http://127.0.0.1:8080/callback +http://localhost: +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/ +opencode/gpt-5.1-codex +opencode/gpt-5.2-codex +opencode/kimi-k2 +openrouter/google/gemini-2.5-flash +opncd.ai/s/ +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 +``` diff --git a/.opencode/tool/github-triage.txt b/.opencode/tool/github-triage.txt index 4c46a72c162..ae47cf4cb0a 100644 --- a/.opencode/tool/github-triage.txt +++ b/.opencode/tool/github-triage.txt @@ -1,4 +1,4 @@ -Use this tool to assign and/or label a Github issue. +Use this tool to assign and/or label a GitHub issue. You can assign the following users: - thdxr diff --git a/bun.lock b/bun.lock index 1bfb04df22c..1063b4ec948 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.53", + "version": "1.1.57", "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.57", "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.57", "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.57", "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.57", "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.57", "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.57", "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.57", "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.57", "bin": { "opencode": "./bin/opencode", }, @@ -366,7 +366,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.53", + "version": "1.1.57", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -386,7 +386,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.53", + "version": "1.1.57", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -397,7 +397,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.53", + "version": "1.1.57", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -410,7 +410,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.53", + "version": "1.1.57", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -452,7 +452,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.53", + "version": "1.1.57", "dependencies": { "zod": "catalog:", }, @@ -463,7 +463,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.53", + "version": "1.1.57", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/flake.nix b/flake.nix index 03849ed53ec..40e9d337f58 100644 --- a/flake.nix +++ b/flake.nix @@ -1,5 +1,5 @@ { - description = "Coli development flake"; + description = "OpenCode development flake"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; @@ -37,16 +37,16 @@ node_modules = final.callPackage ./nix/node_modules.nix { inherit rev; }; - coli = final.callPackage ./nix/opencode.nix { + opencode = final.callPackage ./nix/opencode.nix { inherit node_modules; }; desktop = final.callPackage ./nix/desktop.nix { - inherit coli; + inherit opencode; }; in { - inherit coli; - coli-desktop = desktop; + inherit opencode; + opencode-desktop = desktop; }; }; @@ -56,16 +56,16 @@ node_modules = pkgs.callPackage ./nix/node_modules.nix { inherit rev; }; - coli = pkgs.callPackage ./nix/opencode.nix { + opencode = pkgs.callPackage ./nix/opencode.nix { inherit node_modules; }; desktop = pkgs.callPackage ./nix/desktop.nix { - inherit coli; + inherit opencode; }; in { - default = coli; - inherit coli desktop; + default = opencode; + inherit opencode desktop; # Updater derivation with fakeHash - build fails and reveals correct hash node_modules_updater = node_modules.override { hash = pkgs.lib.fakeHash; diff --git a/github/index.ts b/github/index.ts index 7901162191a..da310178a7d 100644 --- a/github/index.ts +++ b/github/index.ts @@ -141,7 +141,7 @@ try { const comment = await createComment() commentId = comment.data.id - // Setup coli session + // Setup opencode session const repoData = await fetchRepo() session = await client.session.create().then((r) => r.data) await subscribeSessionEvents() @@ -151,7 +151,7 @@ try { await client.session.share({ path: session }) return session.id.slice(-8) })() - console.log("coli session", session.id) + console.log("opencode session", session.id) if (shareId) { console.log("Share link:", `${useShareUrl()}/s/${shareId}`) } @@ -231,7 +231,7 @@ function createOpencode() { const host = "127.0.0.1" const port = 4096 const url = `http://${host}:${port}` - const proc = spawn(`coli`, [`serve`, `--hostname=${host}`, `--port=${port}`]) + const proc = spawn(`opencode`, [`serve`, `--hostname=${host}`, `--port=${port}`]) const client = createOpencodeClient({ baseUrl: url }) return { @@ -243,8 +243,8 @@ function createOpencode() { function assertPayloadKeyword() { const payload = useContext().payload as IssueCommentEvent | PullRequestReviewCommentEvent const body = payload.comment.body.trim() - if (!body.match(/(?:^|\s)(?:\/coli|\/oc)(?=$|\s)/)) { - throw new Error("Comments must mention `/coli` or `/oc`") + if (!body.match(/(?:^|\s)(?:\/opencode|\/oc)(?=$|\s)/)) { + throw new Error("Comments must mention `/opencode` or `/oc`") } } @@ -275,7 +275,7 @@ async function assertOpencodeConnected() { body: { service: "github-workflow", level: "info", - message: "Prepare to react to Github Workflow event", + message: "Prepare to react to GitHub Workflow event", }, }) connected = true @@ -285,7 +285,7 @@ async function assertOpencodeConnected() { } while (retry++ < 30) if (!connected) { - throw new Error("Failed to connect to coli server") + throw new Error("Failed to connect to opencode server") } } @@ -362,7 +362,7 @@ function useIssueId() { } function useShareUrl() { - return isMock() ? "https://dev.opencode.ai" : "https://coli.ai" + return isMock() ? "https://dev.opencode.ai" : "https://opencode.ai" } async function getAccessToken() { @@ -381,7 +381,7 @@ async function getAccessToken() { body: JSON.stringify({ owner: repo.owner, repo: repo.repo }), }) } else { - const oidcToken = await core.getIDToken("coli-github-action") + const oidcToken = await core.getIDToken("opencode-github-action") response = await fetch("https://api.opencode.ai/exchange_github_app_token", { method: "POST", headers: { @@ -417,19 +417,19 @@ async function getUserPrompt() { let prompt = (() => { const body = payload.comment.body.trim() - if (body === "/coli" || body === "/oc") { + if (body === "/opencode" || body === "/oc") { if (reviewContext) { return `Review this code change and suggest improvements for the commented lines:\n\nFile: ${reviewContext.file}\nLines: ${reviewContext.line}\n\n${reviewContext.diffHunk}` } return "Summarize this thread" } - if (body.includes("/coli") || body.includes("/oc")) { + if (body.includes("/opencode") || body.includes("/oc")) { if (reviewContext) { return `${body}\n\nContext: You are reviewing a comment on file "${reviewContext.file}" at line ${reviewContext.line}.\n\nDiff context:\n${reviewContext.diffHunk}` } return body } - throw new Error("Comments must mention `/coli` or `/oc`") + throw new Error("Comments must mention `/opencode` or `/oc`") })() // Handle images @@ -607,7 +607,7 @@ async function resolveAgent(): Promise { } async function chat(text: string, files: PromptFiles = []) { - console.log("Sending message to coli...") + console.log("Sending message to opencode...") const { providerID, modelID } = useEnvModel() const agent = await resolveAgent() @@ -663,8 +663,8 @@ async function configureGit(appToken: string) { await $`git config --local --unset-all ${config}` await $`git config --local ${config} "AUTHORIZATION: basic ${newCredentials}"` - await $`git config --global user.name "coli-agent[bot]"` - await $`git config --global user.email "coli-agent[bot]@users.noreply.github.com"` + await $`git config --global user.name "opencode-agent[bot]"` + await $`git config --global user.email "opencode-agent[bot]@users.noreply.github.com"` } async function restoreGitConfig() { @@ -710,7 +710,7 @@ function generateBranchName(type: "issue" | "pr") { .replace(/\.\d{3}Z/, "") .split("T") .join("") - return `coli/${type}${useIssueId()}-${timestamp}` + return `opencode/${type}${useIssueId()}-${timestamp}` } async function pushToNewBranch(summary: string, branch: string) { @@ -821,9 +821,9 @@ function footer(opts?: { image?: boolean }) { const titleAlt = encodeURIComponent(session.title.substring(0, 50)) const title64 = Buffer.from(session.title.substring(0, 700), "utf8").toString("base64") - return `${titleAlt}\n` + return `${titleAlt}\n` })() - const shareUrl = shareId ? `[coli session](${useShareUrl()}/s/${shareId})  |  ` : "" + const shareUrl = shareId ? `[opencode session](${useShareUrl()}/s/${shareId})  |  ` : "" return `\n\n${image}${shareUrl}[github run](${useEnvRunUrl()})` } diff --git a/infra/console.ts b/infra/console.ts index 9d13c76ee3a..4e5a14b0457 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -6,7 +6,7 @@ import { EMAILOCTOPUS_API_KEY } from "./app" //////////////// const cluster = planetscale.getDatabaseOutput({ - name: "coli", + name: "opencode", organization: "anomalyco", }) @@ -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 }], }, }, }, diff --git a/infra/enterprise.ts b/infra/enterprise.ts index cd9af3d024f..22b4c6f44ee 100644 --- a/infra/enterprise.ts +++ b/infra/enterprise.ts @@ -8,10 +8,10 @@ const teams = new sst.cloudflare.x.SolidStart("Teams", { path: "packages/enterprise", buildCommand: "bun run build:cloudflare", environment: { - COLI_STORAGE_ADAPTER: "r2", - COLI_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID, - COLI_STORAGE_ACCESS_KEY_ID: SECRET.R2AccessKey.value, - COLI_STORAGE_SECRET_ACCESS_KEY: SECRET.R2SecretKey.value, - COLI_STORAGE_BUCKET: storage.name, + OPENCODE_STORAGE_ADAPTER: "r2", + OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID, + OPENCODE_STORAGE_ACCESS_KEY_ID: SECRET.R2AccessKey.value, + OPENCODE_STORAGE_SECRET_ACCESS_KEY: SECRET.R2SecretKey.value, + OPENCODE_STORAGE_BUCKET: storage.name, }, }) diff --git a/install b/install index b5468761de2..22b7ca39ed7 100755 --- a/install +++ b/install @@ -1,6 +1,6 @@ #!/usr/bin/env bash set -euo pipefail -APP=coli +APP=opencode MUTED='\033[0;2m' RED='\033[0;31m' @@ -22,7 +22,7 @@ Options: Examples: curl -fsSL https://opencode.ai/install | bash curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180 - ./install --binary /path/to/coli + ./install --binary /path/to/opencode EOF } @@ -65,7 +65,7 @@ while [[ $# -gt 0 ]]; do esac done -INSTALL_DIR=$HOME/.coli/bin +INSTALL_DIR=$HOME/.opencode/bin mkdir -p "$INSTALL_DIR" # If --binary is provided, skip all download/detection logic @@ -205,11 +205,11 @@ print_message() { } check_version() { - if command -v coli >/dev/null 2>&1; then - coli_path=$(which coli) + if command -v opencode >/dev/null 2>&1; then + opencode_path=$(which opencode) ## Check the installed version - installed_version=$(coli --version 2>/dev/null || echo "") + installed_version=$(opencode --version 2>/dev/null || echo "") if [[ "$installed_version" != "$specific_version" ]]; then print_message info "${MUTED}Installed version: ${NC}$installed_version." @@ -261,7 +261,7 @@ download_with_progress() { fi local tmp_dir=${TMPDIR:-/tmp} - local basename="${tmp_dir}/coli_install_$$" + local basename="${tmp_dir}/opencode_install_$$" local tracefile="${basename}.trace" rm -f "$tracefile" @@ -311,8 +311,8 @@ download_with_progress() { } download_and_install() { - print_message info "\n${MUTED}Installing ${NC}coli ${MUTED}version: ${NC}$specific_version" - local tmp_dir="${TMPDIR:-/tmp}/coli_install_$$" + print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version" + local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$" mkdir -p "$tmp_dir" if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then @@ -326,15 +326,15 @@ download_and_install() { unzip -q "$tmp_dir/$filename" -d "$tmp_dir" fi - mv "$tmp_dir/coli" "$INSTALL_DIR" - chmod 755 "${INSTALL_DIR}/coli" + mv "$tmp_dir/opencode" "$INSTALL_DIR" + chmod 755 "${INSTALL_DIR}/opencode" rm -rf "$tmp_dir" } install_from_binary() { - print_message info "\n${MUTED}Installing ${NC}coli ${MUTED}from: ${NC}$binary_path" - cp "$binary_path" "${INSTALL_DIR}/coli" - chmod 755 "${INSTALL_DIR}/coli" + print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}from: ${NC}$binary_path" + cp "$binary_path" "${INSTALL_DIR}/opencode" + chmod 755 "${INSTALL_DIR}/opencode" } if [ -n "$binary_path" ]; then @@ -352,9 +352,9 @@ add_to_path() { if grep -Fxq "$command" "$config_file"; then print_message info "Command already exists in $config_file, skipping write." elif [[ -w $config_file ]]; then - echo -e "\n# coli" >> "$config_file" + echo -e "\n# opencode" >> "$config_file" echo "$command" >> "$config_file" - print_message info "${MUTED}Successfully added ${NC}coli ${MUTED}to \$PATH in ${NC}$config_file" + print_message info "${MUTED}Successfully added ${NC}opencode ${MUTED}to \$PATH in ${NC}$config_file" else print_message warning "Manually add the directory to $config_file (or similar):" print_message info " $command" @@ -439,7 +439,7 @@ echo -e "" echo -e "${MUTED}OpenCode includes free models, to start:${NC}" echo -e "" echo -e "cd ${MUTED}# Open directory${NC}" -echo -e "coli ${MUTED}# Run command${NC}" +echo -e "opencode ${MUTED}# Run command${NC}" echo -e "" echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs" echo -e "" diff --git a/nix/desktop.nix b/nix/desktop.nix index 6af2ceb0a0a..efdc2bd72e2 100644 --- a/nix/desktop.nix +++ b/nix/desktop.nix @@ -21,11 +21,11 @@ openssl, webkitgtk_4_1, gst_all_1, - coli, + opencode, }: rustPlatform.buildRustPackage (finalAttrs: { - pname = "coli-desktop"; - inherit (coli) + pname = "opencode-desktop"; + inherit (opencode) version src node_modules @@ -72,7 +72,7 @@ rustPlatform.buildRustPackage (finalAttrs: { patchShebangs packages/desktop/node_modules mkdir -p packages/desktop/src-tauri/sidecars - cp ${coli}/bin/coli packages/desktop/src-tauri/sidecars/coli-cli-${stdenv.hostPlatform.rust.rustcTarget} + cp ${opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-${stdenv.hostPlatform.rust.rustcTarget} ''; # see publish-tauri job in .github/workflows/publish.yml @@ -86,15 +86,15 @@ rustPlatform.buildRustPackage (finalAttrs: { # should be removed once binary is renamed or decided otherwise # darwin output is a .app bundle so no conflict postFixup = lib.optionalString stdenv.hostPlatform.isLinux '' - mv $out/bin/OpenCode $out/bin/coli-desktop - sed -i 's|^Exec=OpenCode$|Exec=coli-desktop|' $out/share/applications/OpenCode.desktop + mv $out/bin/OpenCode $out/bin/opencode-desktop + sed -i 's|^Exec=OpenCode$|Exec=opencode-desktop|' $out/share/applications/OpenCode.desktop ''; meta = { description = "OpenCode Desktop App"; homepage = "https://opencode.ai"; license = lib.licenses.mit; - mainProgram = "coli-desktop"; - inherit (coli.meta) platforms; + mainProgram = "opencode-desktop"; + inherit (opencode.meta) platforms; }; }) diff --git a/nix/node_modules.nix b/nix/node_modules.nix index 6123d882494..e918846c244 100644 --- a/nix/node_modules.nix +++ b/nix/node_modules.nix @@ -19,7 +19,7 @@ let bunOs = if platform.isLinux then "linux" else "darwin"; in stdenvNoCC.mkDerivation { - pname = "coli-node_modules"; + pname = "opencode-node_modules"; version = "${packageJson.version}-${rev}"; src = lib.fileset.toSource { diff --git a/nix/opencode.nix b/nix/opencode.nix index 6b3a22a6697..b7d6f95947c 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -13,7 +13,7 @@ node_modules ? callPackage ./node-modules.nix { }, }: stdenvNoCC.mkDerivation (finalAttrs: { - pname = "coli"; + pname = "opencode"; inherit (node_modules) version src; inherit node_modules; @@ -34,9 +34,9 @@ stdenvNoCC.mkDerivation (finalAttrs: { ''; env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json"; - env.COLI_DISABLE_MODELS_FETCH = true; - env.COLI_VERSION = finalAttrs.version; - env.COLI_CHANNEL = "local"; + env.OPENCODE_DISABLE_MODELS_FETCH = true; + env.OPENCODE_VERSION = finalAttrs.version; + env.OPENCODE_CHANNEL = "local"; buildPhase = '' runHook preBuild @@ -51,10 +51,10 @@ stdenvNoCC.mkDerivation (finalAttrs: { installPhase = '' runHook preInstall - install -Dm755 dist/coli-*/bin/coli $out/bin/coli - install -Dm644 schema.json $out/share/coli/schema.json + install -Dm755 dist/opencode-*/bin/opencode $out/bin/opencode + install -Dm644 schema.json $out/share/opencode/schema.json - wrapProgram $out/bin/coli \ + wrapProgram $out/bin/opencode \ --prefix PATH : ${ lib.makeBinPath ( [ @@ -70,9 +70,9 @@ stdenvNoCC.mkDerivation (finalAttrs: { postInstall = lib.optionalString (stdenvNoCC.buildPlatform.canExecute stdenvNoCC.hostPlatform) '' # trick yargs into also generating zsh completions - installShellCompletion --cmd coli \ - --bash <($out/bin/coli completion) \ - --zsh <(SHELL=/bin/zsh $out/bin/coli completion) + installShellCompletion --cmd opencode \ + --bash <($out/bin/opencode completion) \ + --zsh <(SHELL=/bin/zsh $out/bin/opencode completion) ''; nativeInstallCheckInputs = [ @@ -80,18 +80,18 @@ stdenvNoCC.mkDerivation (finalAttrs: { writableTmpDirAsHomeHook ]; doInstallCheck = true; - versionCheckKeepEnvironment = [ "HOME" "COLI_DISABLE_MODELS_FETCH" ]; + versionCheckKeepEnvironment = [ "HOME" "OPENCODE_DISABLE_MODELS_FETCH" ]; versionCheckProgramArg = "--version"; passthru = { - jsonschema = "${placeholder "out"}/share/coli/schema.json"; + jsonschema = "${placeholder "out"}/share/opencode/schema.json"; }; meta = { description = "The open source coding agent"; homepage = "https://opencode.ai/"; license = lib.licenses.mit; - mainProgram = "coli"; + mainProgram = "opencode"; inherit (node_modules.meta) platforms; }; }) diff --git a/package.json b/package.json index 380370a74b5..2c69f46d299 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "name": "coli", + "name": "opencode", "description": "AI-powered development tool", "private": true, "type": "module", diff --git a/packages/app/bunfig.toml b/packages/app/bunfig.toml index 36399045119..f1caabbcce9 100644 --- a/packages/app/bunfig.toml +++ b/packages/app/bunfig.toml @@ -1,2 +1,3 @@ [test] +root = "./src" preload = ["./happydom.ts"] diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index ad625886ba1..3467effa6b3 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -140,7 +140,7 @@ export async function openSettings(page: Page) { export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) { await page.addInitScript( (args: { directory: string; serverUrl: string; extra: string[] }) => { - const key = "coli.global.dat:server" + const key = "opencode.global.dat:server" const raw = localStorage.getItem(key) const parsed = (() => { if (!raw) return undefined @@ -192,7 +192,7 @@ export async function seedProjects(page: Page, input: { directory: string; extra } export async function createTestProject() { - const root = await fs.mkdtemp(path.join(os.tmpdir(), "coli-e2e-project-")) + const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-")) await fs.writeFile(path.join(root, "README.md"), "# e2e\n") diff --git a/packages/app/e2e/app/server-default.spec.ts b/packages/app/e2e/app/server-default.spec.ts index c2a82dca1f8..adbc83473be 100644 --- a/packages/app/e2e/app/server-default.spec.ts +++ b/packages/app/e2e/app/server-default.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "../fixtures" import { serverName, serverUrl } from "../utils" import { clickListItem, closeDialog, clickMenuItem } from "../actions" -const DEFAULT_SERVER_URL_KEY = "coli.settings.dat:defaultServerUrl" +const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" test("can set a default server on web", async ({ page, gotoSession }) => { await page.addInitScript((key: string) => { diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 6e5d78621cd..ea41ed8516e 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -74,9 +74,9 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin await seedProjects(page, input) await page.addInitScript(() => { localStorage.setItem( - "coli.global.dat:model", + "opencode.global.dat:model", JSON.stringify({ - recent: [{ providerID: "coli", modelID: "big-pickle" }], + recent: [{ providerID: "opencode", modelID: "big-pickle" }], user: [], variant: {}, }), diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts new file mode 100644 index 00000000000..5af314cafae --- /dev/null +++ b/packages/app/e2e/projects/workspace-new-session.spec.ts @@ -0,0 +1,140 @@ +import { base64Decode } from "@opencode-ai/util/encode" +import type { Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { cleanupTestProject, openSidebar, sessionIDFromUrl, setWorkspacesEnabled } from "../actions" +import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" +import { createSdk } from "../utils" + +function slugFromUrl(url: string) { + return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" +} + +async function waitWorkspaceReady(page: Page, slug: string) { + await openSidebar(page) + await expect + .poll( + async () => { + const item = page.locator(workspaceItemSelector(slug)).first() + try { + await item.hover({ timeout: 500 }) + return true + } catch { + return false + } + }, + { timeout: 60_000 }, + ) + .toBe(true) +} + +async function createWorkspace(page: Page, root: string, seen: string[]) { + await openSidebar(page) + await page.getByRole("button", { name: "New workspace" }).first().click() + + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + if (!slug) return "" + if (slug === root) return "" + if (seen.includes(slug)) return "" + return slug + }, + { timeout: 45_000 }, + ) + .not.toBe("") + + const slug = slugFromUrl(page.url()) + const directory = base64Decode(slug) + if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`) + return { slug, directory } +} + +async function openWorkspaceNewSession(page: Page, slug: string) { + await waitWorkspaceReady(page, slug) + + const item = page.locator(workspaceItemSelector(slug)).first() + await item.hover() + + const button = page.locator(workspaceNewSessionSelector(slug)).first() + await expect(button).toBeVisible() + await button.click({ force: true }) + + await expect.poll(() => slugFromUrl(page.url())).toBe(slug) + await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:[/?#]|$)`)) +} + +async function createSessionFromWorkspace(page: Page, slug: string, text: string) { + await openWorkspaceNewSession(page, slug) + + const prompt = page.locator(promptSelector) + await expect(prompt).toBeVisible() + await prompt.click() + await page.keyboard.type(text) + await page.keyboard.press("Enter") + + await expect.poll(() => slugFromUrl(page.url())).toBe(slug) + await expect(page).toHaveURL(new RegExp(`/${slug}/session/[^/?#]+`), { timeout: 30_000 }) + + const sessionID = sessionIDFromUrl(page.url()) + if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`) + return sessionID +} + +async function sessionDirectory(directory: string, sessionID: string) { + const info = await createSdk(directory) + .session.get({ sessionID }) + .then((x) => x.data) + .catch(() => undefined) + if (!info) return "" + return info.directory +} + +test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + await withProject(async ({ directory, slug: root }) => { + const workspaces = [] as { slug: string; directory: string }[] + const sessions = [] as string[] + + try { + await openSidebar(page) + await setWorkspacesEnabled(page, root, true) + + const first = await createWorkspace(page, root, []) + workspaces.push(first) + await waitWorkspaceReady(page, first.slug) + + const second = await createWorkspace(page, root, [first.slug]) + workspaces.push(second) + await waitWorkspaceReady(page, second.slug) + + const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`) + sessions.push(firstSession) + + const secondSession = await createSessionFromWorkspace(page, second.slug, `workspace two ${Date.now()}`) + sessions.push(secondSession) + + const thirdSession = await createSessionFromWorkspace(page, first.slug, `workspace one again ${Date.now()}`) + sessions.push(thirdSession) + + await expect.poll(() => sessionDirectory(first.directory, firstSession)).toBe(first.directory) + await expect.poll(() => sessionDirectory(second.directory, secondSession)).toBe(second.directory) + await expect.poll(() => sessionDirectory(first.directory, thirdSession)).toBe(first.directory) + } finally { + const dirs = [directory, ...workspaces.map((workspace) => workspace.directory)] + await Promise.all( + sessions.map((sessionID) => + Promise.all( + dirs.map((dir) => + createSdk(dir) + .session.delete({ sessionID }) + .catch(() => undefined), + ), + ), + ), + ) + await Promise.all(workspaces.map((workspace) => cleanupTestProject(workspace.directory))) + } + }) +}) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 246d136d2c1..842433891e2 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -30,7 +30,7 @@ export const projectCloseMenuSelector = (slug: string) => `[data-action="project export const projectWorkspacesToggleSelector = (slug: string) => `[data-action="project-workspaces-toggle"][data-project="${slug}"]` -export const titlebarRightSelector = "#coli-titlebar-right" +export const titlebarRightSelector = "#opencode-titlebar-right" export const popoverBodySelector = '[data-slot="popover-body"]' @@ -48,6 +48,9 @@ export const workspaceItemSelector = (slug: string) => export const workspaceMenuTriggerSelector = (slug: string) => `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]` +export const workspaceNewSessionSelector = (slug: string) => + `${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]` + export const listItemSelector = '[data-slot="list-item"]' export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]` diff --git a/packages/app/e2e/session/session-undo-redo.spec.ts b/packages/app/e2e/session/session-undo-redo.spec.ts new file mode 100644 index 00000000000..c7af038c27a --- /dev/null +++ b/packages/app/e2e/session/session-undo-redo.spec.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 + 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() + }) + }) +}) diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts index 1975d5ada83..4610fb33152 100644 --- a/packages/app/e2e/session/session.spec.ts +++ b/packages/app/e2e/session/session.spec.ts @@ -9,7 +9,7 @@ import { } from "../actions" import { sessionItemSelector, inlineInputSelector } from "../selectors" -const shareDisabled = process.env.COLI_DISABLE_SHARE === "true" || process.env.COLI_DISABLE_SHARE === "1" +const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1" type Sdk = Parameters[0] @@ -107,7 +107,7 @@ test("session can be deleted via header menu", async ({ page, sdk, gotoSession } }) test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => { - test.skip(shareDisabled, "Share is disabled in this environment (COLI_DISABLE_SHARE).") + test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).") const stamp = Date.now() const title = `e2e share test ${stamp}` diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index d881835a323..2865419f0de 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -27,7 +27,7 @@ test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSe test("changing language updates settings labels", async ({ page, gotoSession }) => { await page.addInitScript(() => { - localStorage.setItem("coli.global.dat:language", JSON.stringify({ locale: "en" })) + localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" })) }) await gotoSession() @@ -95,7 +95,7 @@ test("changing theme persists in localStorage", async ({ page, gotoSession }) => await page.keyboard.press("Escape") const storedThemeId = await page.evaluate(() => { - return localStorage.getItem("coli-theme-id") + return localStorage.getItem("opencode-theme-id") }) expect(storedThemeId).not.toBeNull() diff --git a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts new file mode 100644 index 00000000000..e37f94f3a7a --- /dev/null +++ b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts @@ -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) + } +}) diff --git a/packages/app/package.json b/packages/app/package.json index a995880e01c..3d9d94941c2 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.53", + "version": "1.1.57", "description": "", "type": "module", "exports": { diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 15da6331d0a..ea85829e0bc 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -24,8 +24,8 @@ export default defineConfig({ reuseExistingServer: reuse, timeout: 120_000, env: { - VITE_COLI_SERVER_HOST: serverHost, - VITE_COLI_SERVER_PORT: serverPort, + VITE_OPENCODE_SERVER_HOST: serverHost, + VITE_OPENCODE_SERVER_PORT: serverPort, }, }, use: { diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts index 11804e54620..112e2bc60a1 100644 --- a/packages/app/script/e2e-local.ts +++ b/packages/app/script/e2e-local.ts @@ -44,7 +44,7 @@ async function waitForHealth(url: string) { const appDir = process.cwd() const repoDir = path.resolve(appDir, "../..") -const coliDir = path.join(repoDir, "packages", "opencode") +const opencodeDir = path.join(repoDir, "packages", "opencode") const extraArgs = (() => { const args = process.argv.slice(2) @@ -55,32 +55,32 @@ const extraArgs = (() => { const [serverPort, webPort] = await Promise.all([freePort(), freePort()]) const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-")) -const keepSandbox = process.env.COLI_E2E_KEEP_SANDBOX === "1" +const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1" const serverEnv = { ...process.env, - COLI_DISABLE_SHARE: process.env.COLI_DISABLE_SHARE ?? "true", - COLI_DISABLE_LSP_DOWNLOAD: "true", - COLI_DISABLE_DEFAULT_PLUGINS: "true", - COLI_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", - COLI_TEST_HOME: path.join(sandbox, "home"), + OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true", + OPENCODE_DISABLE_LSP_DOWNLOAD: "true", + OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", + OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + OPENCODE_TEST_HOME: path.join(sandbox, "home"), XDG_DATA_HOME: path.join(sandbox, "share"), XDG_CACHE_HOME: path.join(sandbox, "cache"), XDG_CONFIG_HOME: path.join(sandbox, "config"), XDG_STATE_HOME: path.join(sandbox, "state"), - COLI_E2E_PROJECT_DIR: repoDir, - COLI_E2E_SESSION_TITLE: "E2E Session", - COLI_E2E_MESSAGE: "Seeded for UI e2e", - COLI_E2E_MODEL: "coli/gpt-5-nano", - COLI_CLIENT: "app", + OPENCODE_E2E_PROJECT_DIR: repoDir, + OPENCODE_E2E_SESSION_TITLE: "E2E Session", + OPENCODE_E2E_MESSAGE: "Seeded for UI e2e", + OPENCODE_E2E_MODEL: "opencode/gpt-5-nano", + OPENCODE_CLIENT: "app", } satisfies Record const runnerEnv = { ...serverEnv, PLAYWRIGHT_SERVER_HOST: "127.0.0.1", PLAYWRIGHT_SERVER_PORT: String(serverPort), - VITE_COLI_SERVER_HOST: "127.0.0.1", - VITE_COLI_SERVER_PORT: String(serverPort), + VITE_OPENCODE_SERVER_HOST: "127.0.0.1", + VITE_OPENCODE_SERVER_PORT: String(serverPort), PLAYWRIGHT_PORT: String(webPort), } satisfies Record @@ -89,7 +89,6 @@ let runner: ReturnType | undefined let server: { stop: () => Promise | void } | undefined let inst: { Instance: { disposeAll: () => Promise | 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) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 28a015c7d1e..e49b725a197 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -43,7 +43,7 @@ function UiI18nBridge(props: ParentProps) { declare global { interface Window { - __COLI__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] } + __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean } } } @@ -100,7 +100,7 @@ export function AppInterface(props: { defaultUrl?: string; children?: JSX.Elemen if (stored) return stored if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_COLI_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_COLI_SERVER_PORT ?? "4096"}` + return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` return window.location.origin } diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index d2ea4d38d22..65e322b4345 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -265,20 +265,20 @@ export function DialogConnectProvider(props: { provider: string }) { return (

- +
- {language.t("provider.connect.coliZen.line1")} + {language.t("provider.connect.opencodeZen.line1")}
- {language.t("provider.connect.coliZen.line2")} + {language.t("provider.connect.opencodeZen.line2")}
- {language.t("provider.connect.coliZen.visit.prefix")} + {language.t("provider.connect.opencodeZen.visit.prefix")} - {language.t("provider.connect.coliZen.visit.link")} + {language.t("provider.connect.opencodeZen.visit.link")} - {language.t("provider.connect.coliZen.visit.suffix")} + {language.t("provider.connect.opencodeZen.visit.suffix")}
diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx index 009d64c534d..78c169777e0 100644 --- a/packages/app/src/components/dialog-select-model-unpaid.tsx +++ b/packages/app/src/components/dialog-select-model-unpaid.tsx @@ -55,7 +55,7 @@ export const DialogSelectModelUnpaid: Component = () => { } > @@ -104,7 +104,7 @@ export const DialogSelectModelUnpaid: Component = () => {
{i.name} - + {language.t("dialog.provider.tag.recommended")} diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index d980cf5aaad..26021f06aab 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -58,7 +58,7 @@ const ModelList: Component<{ } > @@ -75,7 +75,7 @@ const ModelList: Component<{ {(i) => (
{i.name} - + {language.t("model.tag.free")} diff --git a/packages/app/src/components/dialog-select-provider.tsx b/packages/app/src/components/dialog-select-provider.tsx index 30dc4891708..f878e50e81a 100644 --- a/packages/app/src/components/dialog-select-provider.tsx +++ b/packages/app/src/components/dialog-select-provider.tsx @@ -67,7 +67,7 @@ export const DialogSelectProvider: Component = () => { {language.t("settings.providers.tag.custom")} - + {language.t("dialog.provider.tag.recommended")} diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index cdbb0eb6fbc..f8892ebbdc8 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -8,7 +8,6 @@ import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" -import { SettingsOhMyOpenCode } from "./settings-ohmyopencode" export const DialogSettings: Component = () => { const language = useLanguage() @@ -46,10 +45,6 @@ export const DialogSettings: Component = () => { {language.t("settings.models.title")} - - - {language.t("settings.ohmyopencode.title")} -
@@ -72,9 +67,6 @@ export const DialogSettings: Component = () => { - - - {/* */} {/* */} {/* */} diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts index eb048e29ed5..29e20b4807c 100644 --- a/packages/app/src/components/file-tree.test.ts +++ b/packages/app/src/components/file-tree.test.ts @@ -6,6 +6,7 @@ let dirsToExpand: typeof import("./file-tree").dirsToExpand beforeAll(async () => { mock.module("@solidjs/router", () => ({ + useNavigate: () => () => undefined, useParams: () => ({}), })) mock.module("@/context/file", () => ({ diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index 4a3e276724d..d7b7299731c 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -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 = ( diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index da45c351ec7..4f495d27d13 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -787,7 +787,7 @@ export const PromptInput: Component = (props) => { }, setMode: (mode) => setStore("mode", mode), setPopover: (popover) => setStore("popover", popover), - newSessionWorktree: props.newSessionWorktree, + newSessionWorktree: () => props.newSessionWorktree, onNewSessionWorktreeReset: props.onNewSessionWorktreeReset, onSubmit: props.onSubmit, }) diff --git a/packages/app/src/components/prompt-input/build-request-parts.test.ts b/packages/app/src/components/prompt-input/build-request-parts.test.ts index b0fd3a050cd..72bdecc01f3 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.test.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts @@ -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") } }) diff --git a/packages/app/src/components/prompt-input/build-request-parts.ts b/packages/app/src/components/prompt-input/build-request-parts.ts index 11aec9631d7..0cc54dc2b78 100644 --- a/packages/app/src/components/prompt-input/build-request-parts.ts +++ b/packages/app/src/components/prompt-input/build-request-parts.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) => diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts new file mode 100644 index 00000000000..475a0e20f29 --- /dev/null +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -0,0 +1,175 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" +import type { Prompt } from "@/context/prompt" + +let createPromptSubmit: typeof import("./submit").createPromptSubmit + +const createdClients: string[] = [] +const createdSessions: string[] = [] +const sentShell: string[] = [] +const syncedDirectories: string[] = [] + +let selected = "/repo/worktree-a" + +const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] + +const clientFor = (directory: string) => ({ + session: { + create: async () => { + createdSessions.push(directory) + return { data: { id: `session-${createdSessions.length}` } } + }, + shell: async () => { + sentShell.push(directory) + return { data: undefined } + }, + prompt: async () => ({ data: undefined }), + command: async () => ({ data: undefined }), + abort: async () => ({ data: undefined }), + }, + worktree: { + create: async () => ({ data: { directory: `${directory}/new` } }), + }, +}) + +beforeAll(async () => { + const rootClient = clientFor("/repo/main") + + mock.module("@solidjs/router", () => ({ + useNavigate: () => () => undefined, + useParams: () => ({}), + })) + + mock.module("@opencode-ai/sdk/v2/client", () => ({ + createOpencodeClient: (input: { directory: string }) => { + createdClients.push(input.directory) + return clientFor(input.directory) + }, + })) + + mock.module("@opencode-ai/ui/toast", () => ({ + showToast: () => 0, + })) + + mock.module("@opencode-ai/util/encode", () => ({ + base64Encode: (value: string) => value, + })) + + mock.module("@/context/local", () => ({ + useLocal: () => ({ + model: { + current: () => ({ id: "model", provider: { id: "provider" } }), + variant: { current: () => undefined }, + }, + agent: { + current: () => ({ name: "agent" }), + }, + }), + })) + + mock.module("@/context/prompt", () => ({ + usePrompt: () => ({ + current: () => promptValue, + reset: () => undefined, + set: () => undefined, + context: { + add: () => undefined, + remove: () => undefined, + items: () => [], + }, + }), + })) + + mock.module("@/context/layout", () => ({ + useLayout: () => ({ + handoff: { + setTabs: () => undefined, + }, + }), + })) + + mock.module("@/context/sdk", () => ({ + useSDK: () => ({ + directory: "/repo/main", + client: rootClient, + url: "http://localhost:4096", + }), + })) + + mock.module("@/context/sync", () => ({ + useSync: () => ({ + data: { command: [] }, + session: { + optimistic: { + add: () => undefined, + remove: () => undefined, + }, + }, + set: () => undefined, + }), + })) + + mock.module("@/context/global-sync", () => ({ + useGlobalSync: () => ({ + child: (directory: string) => { + syncedDirectories.push(directory) + return [{}, () => undefined] + }, + }), + })) + + mock.module("@/context/platform", () => ({ + usePlatform: () => ({ + fetch: fetch, + }), + })) + + mock.module("@/context/language", () => ({ + useLanguage: () => ({ + t: (key: string) => key, + }), + })) + + const mod = await import("./submit") + createPromptSubmit = mod.createPromptSubmit +}) + +beforeEach(() => { + createdClients.length = 0 + createdSessions.length = 0 + sentShell.length = 0 + syncedDirectories.length = 0 + selected = "/repo/worktree-a" +}) + +describe("prompt submit worktree selection", () => { + test("reads the latest worktree accessor value per submit", async () => { + const submit = createPromptSubmit({ + info: () => undefined, + imageAttachments: () => [], + commentCount: () => 0, + mode: () => "shell", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + newSessionWorktree: () => selected, + onNewSessionWorktreeReset: () => undefined, + onSubmit: () => undefined, + }) + + const event = { preventDefault: () => undefined } as unknown as Event + + await submit.handleSubmit(event) + selected = "/repo/worktree-b" + await submit.handleSubmit(event) + + expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + }) +}) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index c118f8cb210..49d75a95ecc 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -37,7 +37,7 @@ type PromptSubmitInput = { resetHistoryNavigation: () => void setMode: (mode: "normal" | "shell") => void setPopover: (popover: "at" | "slash" | null) => void - newSessionWorktree?: string + newSessionWorktree?: Accessor onNewSessionWorktreeReset?: () => void onSubmit?: () => void } @@ -137,7 +137,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const projectDirectory = sdk.directory const isNewSession = !params.id - const worktreeSelection = input.newSessionWorktree || "main" + const worktreeSelection = input.newSessionWorktree?.() || "main" let sessionDirectory = projectDirectory let client = sdk.client diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index a834347a71b..383490f99da 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useParams } from "@solidjs/router" @@ -18,6 +18,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" import { AppIcon } from "@opencode-ai/ui/app-icon" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Spinner } from "@opencode-ai/ui/spinner" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Popover } from "@opencode-ai/ui/popover" import { TextField } from "@opencode-ai/ui/text-field" @@ -166,6 +167,8 @@ export function SessionHeader() { }) const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) + const [menu, setMenu] = createStore({ open: false }) + const [openRequest, setOpenRequest] = createStore({ app: undefined as OpenApp | undefined, version: 0 }) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) @@ -178,20 +181,32 @@ export function SessionHeader() { setPrefs("app", options()[0]?.id ?? "finder") }) - const openDir = (app: OpenApp) => { - const directory = projectDirectory() - if (!directory) return - if (!canOpen()) return - - const item = options().find((o) => o.id === app) - const openWith = item && "openWith" in item ? item.openWith : undefined - Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => { - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: err instanceof Error ? err.message : String(err), - }) + const [openTask] = createResource( + () => openRequest.app && openRequest.version, + async () => { + const app = openRequest.app + const directory = projectDirectory() + if (!app || !directory || !canOpen()) return + + const item = options().find((o) => o.id === app) + const openWith = item && "openWith" in item ? item.openWith : undefined + await platform.openPath?.(directory, openWith) + }, + ) + + createEffect(() => { + const err = openTask.error + if (!err) return + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: err instanceof Error ? err.message : String(err), }) + }) + + const openDir = (app: OpenApp) => { + if (openTask.loading) return + setOpenRequest({ app, version: openRequest.version + 1 }) } const copyPath = () => { @@ -288,8 +303,8 @@ export function SessionHeader() { platform.openLink(url) } - const centerMount = createMemo(() => document.getElementById("coli-titlebar-center")) - const rightMount = createMemo(() => document.getElementById("coli-titlebar-right")) + const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) + const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) return ( <> @@ -328,39 +343,56 @@ export function SessionHeader() { - - - {language.t("session.header.open.copyPath")} - - +
+ +
} >
- + setMenu("open", open)} + > @@ -375,7 +407,14 @@ export function SessionHeader() { }} > {options().map((o) => ( - openDir(o.id)}> + { + setMenu("open", false) + openDir(o.id) + }} + >
@@ -388,7 +427,12 @@ export function SessionHeader() { - + { + setMenu("open", false) + copyPath() + }} + >
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index db057a4c41f..72135c342e5 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -367,6 +367,34 @@ export const SettingsGeneral: Component = () => {
+ + {(_) => { + const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.()) + const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest) + + return ( +
+

{language.t("settings.desktop.section.wsl")}

+ +
+ +
+ platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())} + /> +
+
+
+
+ ) + }} +
+ {/* Updates Section */}

{language.t("settings.general.section.updates")}

diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 9c9961d99ff..d2444e2d2a9 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -31,7 +31,7 @@ export const SettingsProviders: Component = () => { const connected = createMemo(() => { return providers .connected() - .filter((p) => p.id !== "coli" || Object.values(p.models).find((m) => m.cost?.input)) + .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)) }) const popular = createMemo(() => { @@ -171,13 +171,13 @@ export const SettingsProviders: Component = () => {
{item.name} - + {language.t("dialog.provider.tag.recommended")}
- + - {language.t("dialog.provider.coli.note")} + {language.t("dialog.provider.opencode.note")} diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 06f627503a2..6e89990178b 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -338,7 +338,7 @@ export function StatusPopover() {
{(() => { const value = language.t("dialog.plugins.empty") - const file = "coli.json" + const file = "opencode.json" const parts = value.split(file) if (parts.length === 1) return value return ( diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 4df47d3d620..2527c74ec3f 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -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 @@ -128,11 +130,12 @@ export const Terminal = (props: TerminalProps) => { const t = term if (!t) return t.focus() + t.textarea?.focus() setTimeout(() => t.textarea?.focus(), 0) } const handlePointerDown = () => { const activeElement = document.activeElement - if (activeElement instanceof HTMLElement && activeElement !== container) { + if (activeElement instanceof HTMLElement && activeElement !== container && !container.contains(activeElement)) { activeElement.blur() } focusTerminal() @@ -164,13 +167,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.__COLI__?.serverPassword) { - url.username = "coli" - url.password = window.__COLI__?.serverPassword + 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 +191,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, @@ -199,44 +205,32 @@ export const Terminal = (props: TerminalProps) => { ghostty = g term = t - const copy = () => { + const handleCopy = (event: ClipboardEvent) => { const selection = t.getSelection() - if (!selection) return false - - const body = document.body - if (body) { - const textarea = document.createElement("textarea") - textarea.value = selection - textarea.setAttribute("readonly", "") - textarea.style.position = "fixed" - textarea.style.opacity = "0" - body.appendChild(textarea) - textarea.select() - const copied = document.execCommand("copy") - body.removeChild(textarea) - if (copied) return true - } + if (!selection) return - const clipboard = navigator.clipboard - if (clipboard?.writeText) { - clipboard.writeText(selection).catch(() => {}) - return true - } + const clipboard = event.clipboardData + if (!clipboard) return + + event.preventDefault() + clipboard.setData("text/plain", selection) + } + + const handlePaste = (event: ClipboardEvent) => { + const clipboard = event.clipboardData + const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? "" + if (!text) return - return false + event.preventDefault() + event.stopPropagation() + t.paste(text) } t.attachCustomKeyEventHandler((event) => { const key = event.key.toLowerCase() if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") { - copy() - return true - } - - if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") { - if (!t.hasSelection()) return true - copy() + document.execCommand("copy") return true } @@ -247,6 +241,12 @@ export const Terminal = (props: TerminalProps) => { return matchKeybind(keybinds, event) }) + container.addEventListener("copy", handleCopy, true) + cleanups.push(() => container.removeEventListener("copy", handleCopy, true)) + + container.addEventListener("paste", handlePaste, true) + cleanups.push(() => container.removeEventListener("paste", handlePaste, true)) + const fit = new mod.FitAddon() const serializer = new SerializeAddon() cleanups.push(() => disposeIfDisposable(fit)) @@ -289,26 +289,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 +305,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 +322,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 +335,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 - 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 + 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 } - const trimmed = data.slice(n) - if (trimmed) stopSync() - return trimmed - })() - - if (!next) return + return + } - t.write(next) - tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit) + const data = typeof event.data === "string" ? event.data : "" + if (!data) return + t.write(data) + cursor += data.length } socket.addEventListener("message", handleMessage) cleanups.push(() => socket.removeEventListener("message", handleMessage)) @@ -435,7 +413,7 @@ export const Terminal = (props: TerminalProps) => { props.onCleanup({ ...local.pty, buffer, - tail, + cursor, rows: t.rows, cols: t.cols, scrollY: t.getViewportY(), diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 17ce2a10b9d..e7b8066ae8b 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -238,11 +238,11 @@ export function Titlebar() {
-
+
-
+
-
+
diff --git a/packages/app/src/context/comments.test.ts b/packages/app/src/context/comments.test.ts index 13cb132c4dd..4f223e5f869 100644 --- a/packages/app/src/context/comments.test.ts +++ b/packages/app/src/context/comments.test.ts @@ -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", () => ({ diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 95247c08bb7..f2a3c44b6c4 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -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", () => { diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index e1d47c64421..859fdc04062 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -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("/") } diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 0cd4f6c997e..cb610bf6ed6 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -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 diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index dcf7364c07b..e2bf4498074 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -83,13 +83,13 @@ function createGlobalSync() { if (!import.meta.env.DEV) return ;( globalThis as { - __COLI_GLOBAL_SYNC_STATS?: { + __OPENCODE_GLOBAL_SYNC_STATS?: { activeDirectoryStores: number evictions: number loadSessionsFullFetchFallback: number } } - ).__COLI_GLOBAL_SYNC_STATS = { + ).__OPENCODE_GLOBAL_SYNC_STATS = { activeDirectoryStores, evictions: stats.evictions, loadSessionsFullFetchFallback: stats.loadSessionsFallback, diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index f51bb693092..85f93f36895 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -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(), + }), + ) }, }, } diff --git a/packages/app/src/context/model-variant.test.ts b/packages/app/src/context/model-variant.test.ts new file mode 100644 index 00000000000..01b149fd267 --- /dev/null +++ b/packages/app/src/context/model-variant.test.ts @@ -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") + }) +}) diff --git a/packages/app/src/context/model-variant.ts b/packages/app/src/context/model-variant.ts new file mode 100644 index 00000000000..6b7ae725640 --- /dev/null +++ b/packages/app/src/context/model-variant.ts @@ -0,0 +1,50 @@ +type AgentModel = { + providerID: string + modelID: string +} + +type Agent = { + model?: AgentModel + variant?: string +} + +type Model = AgentModel & { + variants?: Record +} + +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] +} diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 7aa6c655400..e260c1977ed 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -57,6 +57,12 @@ export type Platform = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServerUrl?(url: string | null): Promise | void + /** Get the configured WSL integration (desktop only) */ + getWslEnabled?(): Promise + + /** Set the configured WSL integration (desktop only) */ + setWslEnabled?(config: boolean): Promise | void + /** Get the preferred display backend (desktop only) */ getDisplayBackend?(): Promise | DisplayBackend | null diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index d8c8cfcd4fd..a250de57c0d 100644 --- a/packages/app/src/context/terminal.test.ts +++ b/packages/app/src/context/terminal.test.ts @@ -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", () => ({ diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 76e8cf0f738..f0f184f8be9 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -13,7 +13,7 @@ export type LocalPTY = { cols?: number buffer?: string scrollY?: number - tail?: string + cursor?: number } const WORKSPACE_KEY = "__workspace__" diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index f1d2dca635c..aa52fa1e7cb 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -6,7 +6,7 @@ import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" import pkg from "../package.json" -const DEFAULT_SERVER_URL_KEY = "coli.settings.dat:defaultServerUrl" +const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { diff --git a/packages/app/src/env.d.ts b/packages/app/src/env.d.ts index 58ef5a32180..ad575e93b4a 100644 --- a/packages/app/src/env.d.ts +++ b/packages/app/src/env.d.ts @@ -1,6 +1,6 @@ interface ImportMetaEnv { - readonly VITE_COLI_SERVER_HOST: string - readonly VITE_COLI_SERVER_PORT: string + readonly VITE_OPENCODE_SERVER_HOST: string + readonly VITE_OPENCODE_SERVER_PORT: string } interface ImportMeta { diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index efda9a38429..55184aa1b42 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -3,7 +3,7 @@ import { decode64 } from "@/utils/base64" import { useParams } from "@solidjs/router" import { createMemo } from "solid-js" -export const popularProviders = ["coli", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] +export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"] export function useProviders() { const globalSync = useGlobalSync() @@ -18,7 +18,7 @@ export function useProviders() { }) const connected = createMemo(() => providers().all.filter((p) => providers().connected.includes(p.id))) const paid = createMemo(() => - connected().filter((p) => p.id !== "coli" || Object.values(p.models).find((m) => m.cost?.input)), + connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)), ) const popular = createMemo(() => providers().all.filter((p) => popularProviders.includes(p.id))) return { diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 58a2038e801..7a09edc5184 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -122,13 +122,13 @@ export const dict = { "provider.connect.apiKey.label": "مفتاح واجهة برمجة تطبيقات {{provider}}", "provider.connect.apiKey.placeholder": "مفتاح API", "provider.connect.apiKey.required": "مفتاح API مطلوب", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "يمنحك OpenCode Zen الوصول إلى مجموعة مختارة من النماذج الموثوقة والمحسنة لوكلاء البرمجة.", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "باستخدام مفتاح API واحد، ستحصل على إمكانية الوصول إلى نماذج مثل Claude و GPT و Gemini و GLM والمزيد.", - "provider.connect.coliZen.visit.prefix": "قم بزيارة ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " للحصول على مفتاح API الخاص بك.", + "provider.connect.opencodeZen.visit.prefix": "قم بزيارة ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " للحصول على مفتاح API الخاص بك.", "provider.connect.oauth.code.visit.prefix": "قم بزيارة ", "provider.connect.oauth.code.visit.link": "هذا الرابط", "provider.connect.oauth.code.visit.suffix": @@ -508,6 +508,9 @@ export const dict = { "settings.section.server": "الخادم", "settings.tab.general": "عام", "settings.tab.shortcuts": "اختصارات", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "المظهر", "settings.general.section.notifications": "إشعارات النظام", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index cd8f11859e5..ba09fbe03db 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -122,13 +122,13 @@ export const dict = { "provider.connect.apiKey.label": "Chave de API do {{provider}}", "provider.connect.apiKey.placeholder": "Chave de API", "provider.connect.apiKey.required": "A chave de API é obrigatória", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen oferece acesso a um conjunto selecionado de modelos confiáveis otimizados para agentes de código.", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "Com uma única chave de API você terá acesso a modelos como Claude, GPT, Gemini, GLM e mais.", - "provider.connect.coliZen.visit.prefix": "Visite ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " para obter sua chave de API.", + "provider.connect.opencodeZen.visit.prefix": "Visite ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " para obter sua chave de API.", "provider.connect.oauth.code.visit.prefix": "Visite ", "provider.connect.oauth.code.visit.link": "este link", "provider.connect.oauth.code.visit.suffix": @@ -512,6 +512,9 @@ export const dict = { "settings.section.server": "Servidor", "settings.tab.general": "Geral", "settings.tab.shortcuts": "Atalhos", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "Aparência", "settings.general.section.notifications": "Notificações do sistema", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index da47513a20d..38d6b79c94d 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -96,7 +96,7 @@ export const dict = { "dialog.provider.group.popular": "Popularno", "dialog.provider.group.other": "Ostalo", "dialog.provider.tag.recommended": "Preporučeno", - "dialog.provider.coli.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge", + "dialog.provider.opencode.note": "Kurirani modeli uključujući Claude, GPT, Gemini i druge", "dialog.provider.anthropic.note": "Direktan pristup Claude modelima, uključujući Pro i Max", "dialog.provider.copilot.note": "Claude modeli za pomoć pri kodiranju", "dialog.provider.openai.note": "GPT modeli za brze, sposobne opšte AI zadatke", @@ -127,13 +127,13 @@ export const dict = { "provider.connect.apiKey.label": "{{provider}} API ključ", "provider.connect.apiKey.placeholder": "API ključ", "provider.connect.apiKey.required": "API ključ je obavezan", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen ti daje pristup kuriranom skupu pouzdanih, optimizovanih modela za coding agente.", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "Sa jednim API ključem dobijaš pristup modelima kao što su Claude, GPT, Gemini, GLM i drugi.", - "provider.connect.coliZen.visit.prefix": "Posjeti ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " da preuzmeš svoj API ključ.", + "provider.connect.opencodeZen.visit.prefix": "Posjeti ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " da preuzmeš svoj API ključ.", "provider.connect.oauth.code.visit.prefix": "Posjeti ", "provider.connect.oauth.code.visit.link": "ovaj link", "provider.connect.oauth.code.visit.suffix": @@ -539,6 +539,9 @@ export const dict = { "settings.section.server": "Server", "settings.tab.general": "Opšte", "settings.tab.shortcuts": "Prečice", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "Izgled", "settings.general.section.notifications": "Sistemske obavijesti", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 2392e6e1a90..e36fb16d5b7 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -122,13 +122,13 @@ export const dict = { "provider.connect.apiKey.label": "{{provider}} API-nøgle", "provider.connect.apiKey.placeholder": "API-nøgle", "provider.connect.apiKey.required": "API-nøgle er påkrævet", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen giver dig adgang til et udvalg af pålidelige optimerede modeller til kodningsagenter.", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "Med en enkelt API-nøgle får du adgang til modeller som Claude, GPT, Gemini, GLM og flere.", - "provider.connect.coliZen.visit.prefix": "Besøg ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " for at hente din API-nøgle.", + "provider.connect.opencodeZen.visit.prefix": "Besøg ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " for at hente din API-nøgle.", "provider.connect.oauth.code.visit.prefix": "Besøg ", "provider.connect.oauth.code.visit.link": "dette link", "provider.connect.oauth.code.visit.suffix": @@ -512,6 +512,9 @@ export const dict = { "settings.section.server": "Server", "settings.tab.general": "Generelt", "settings.tab.shortcuts": "Genveje", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "Udseende", "settings.general.section.notifications": "Systemmeddelelser", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index b6ee4a5e4d5..633d51d0528 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -126,13 +126,13 @@ export const dict = { "provider.connect.apiKey.label": "{{provider}} API-Schlüssel", "provider.connect.apiKey.placeholder": "API-Schlüssel", "provider.connect.apiKey.required": "API-Schlüssel ist erforderlich", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen bietet Ihnen Zugriff auf eine kuratierte Auswahl zuverlässiger, optimierter Modelle für Coding-Agenten.", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "Mit einem einzigen API-Schlüssel erhalten Sie Zugriff auf Modelle wie Claude, GPT, Gemini, GLM und mehr.", - "provider.connect.coliZen.visit.prefix": "Besuchen Sie ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": ", um Ihren API-Schlüssel zu erhalten.", + "provider.connect.opencodeZen.visit.prefix": "Besuchen Sie ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": ", um Ihren API-Schlüssel zu erhalten.", "provider.connect.oauth.code.visit.prefix": "Besuchen Sie ", "provider.connect.oauth.code.visit.link": "diesen Link", "provider.connect.oauth.code.visit.suffix": @@ -556,6 +556,9 @@ export const dict = { "settings.section.server": "Server", "settings.tab.general": "Allgemein", "settings.tab.shortcuts": "Tastenkombinationen", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "Erscheinungsbild", "settings.general.section.notifications": "Systembenachrichtigungen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 1a2941cc82e..c138c7b6145 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -96,7 +96,7 @@ export const dict = { "dialog.provider.group.popular": "Popular", "dialog.provider.group.other": "Other", "dialog.provider.tag.recommended": "Recommended", - "dialog.provider.coli.note": "Curated models including Claude, GPT, Gemini and more", + "dialog.provider.opencode.note": "Curated models including Claude, GPT, Gemini and more", "dialog.provider.anthropic.note": "Direct access to Claude models, including Pro and Max", "dialog.provider.copilot.note": "Claude models for coding assistance", "dialog.provider.openai.note": "GPT models for fast, capable general AI tasks", @@ -127,13 +127,13 @@ export const dict = { "provider.connect.apiKey.label": "{{provider}} API key", "provider.connect.apiKey.placeholder": "API key", "provider.connect.apiKey.required": "API key is required", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen gives you access to a curated set of reliable optimized models for coding agents.", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "With a single API key you'll get access to models such as Claude, GPT, Gemini, GLM and more.", - "provider.connect.coliZen.visit.prefix": "Visit ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " to collect your API key.", + "provider.connect.opencodeZen.visit.prefix": "Visit ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " to collect your API key.", "provider.connect.oauth.code.visit.prefix": "Visit ", "provider.connect.oauth.code.visit.link": "this link", "provider.connect.oauth.code.visit.suffix": @@ -286,7 +286,7 @@ export const dict = { "dialog.mcp.empty": "No MCPs configured", "dialog.lsp.empty": "LSPs auto-detected from file types", - "dialog.plugins.empty": "Plugins configured in coli.json", + "dialog.plugins.empty": "Plugins configured in opencode.json", "mcp.status.connected": "connected", "mcp.status.failed": "failed", @@ -454,7 +454,7 @@ export const dict = { "error.chain.responseBody": "Response body:\n{{body}}", "error.chain.didYouMean": "Did you mean: {{suggestions}}", "error.chain.modelNotFound": "Model not found: {{provider}}/{{model}}", - "error.chain.checkConfig": "Check your config (coli.json) provider/model names", + "error.chain.checkConfig": "Check your config (opencode.json) provider/model names", "error.chain.mcpFailed": 'MCP server "{{name}}" failed. Note, OpenCode does not support MCP authentication yet.', "error.chain.providerAuthFailed": "Provider authentication failed ({{provider}}): {{message}}", "error.chain.providerInitFailed": @@ -583,6 +583,9 @@ export const dict = { "settings.section.server": "Server", "settings.tab.general": "General", "settings.tab.shortcuts": "Shortcuts", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "Appearance", "settings.general.section.notifications": "System notifications", @@ -717,27 +720,6 @@ export const dict = { "settings.providers.tag.other": "Other", "settings.models.title": "Models", "settings.models.description": "Model settings will be configurable here.", - "settings.ohmyopencode.title": "Oh My OpenCode", - "settings.ohmyopencode.description": "Configure Oh My OpenCode plugin settings", - "settings.ohmyopencode.section.agents": "Agent overrides", - "settings.ohmyopencode.section.features": "Features", - "settings.ohmyopencode.section.tmux": "Tmux integration", - "settings.ohmyopencode.row.enabled.title": "Enabled", - "settings.ohmyopencode.row.enabled.description": "Oh My OpenCode is integrated as a built-in plugin", - "settings.ohmyopencode.row.tmux.title": "Enable tmux", - "settings.ohmyopencode.row.tmux.description": "Enable tmux integration for visual multi-agent execution", - "settings.ohmyopencode.row.tmuxLayout.title": "Tmux layout", - "settings.ohmyopencode.row.tmuxLayout.description": "Layout for tmux agent panes", - "settings.ohmyopencode.row.ralphLoop.title": "Ralph Loop", - "settings.ohmyopencode.row.ralphLoop.description": "Enable the Ralph Loop for continuous agent execution", - "settings.ohmyopencode.row.autoUpdate.title": "Auto update", - "settings.ohmyopencode.row.autoUpdate.description": "Enable auto-update checking for Oh My OpenCode", - "settings.ohmyopencode.row.browserEngine.title": "Browser engine", - "settings.ohmyopencode.row.browserEngine.description": "Browser automation provider to use", - "settings.ohmyopencode.row.disabledAgents.title": "Disabled agents", - "settings.ohmyopencode.row.disabledAgents.description": "Agents to disable (e.g., oracle, multimodal-looker)", - "settings.ohmyopencode.row.disabledHooks.title": "Disabled hooks", - "settings.ohmyopencode.row.disabledHooks.description": "Hooks to disable (e.g., comment-checker, auto-update-checker)", "settings.agents.title": "Agents", "settings.agents.description": "Agent settings will be configurable here.", "settings.commands.title": "Commands", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index e52a319a129..ff4198228a5 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -122,13 +122,13 @@ export const dict = { "provider.connect.apiKey.label": "Clave API de {{provider}}", "provider.connect.apiKey.placeholder": "Clave API", "provider.connect.apiKey.required": "La clave API es obligatoria", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen te da acceso a un conjunto curado de modelos fiables optimizados para agentes de programación.", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "Con una sola clave API obtendrás acceso a modelos como Claude, GPT, Gemini, GLM y más.", - "provider.connect.coliZen.visit.prefix": "Visita ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " para obtener tu clave API.", + "provider.connect.opencodeZen.visit.prefix": "Visita ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " para obtener tu clave API.", "provider.connect.oauth.code.visit.prefix": "Visita ", "provider.connect.oauth.code.visit.link": "este enlace", "provider.connect.oauth.code.visit.suffix": @@ -515,6 +515,9 @@ export const dict = { "settings.section.server": "Servidor", "settings.tab.general": "General", "settings.tab.shortcuts": "Atajos", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "Apariencia", "settings.general.section.notifications": "Notificaciones del sistema", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 90aa688b683..402c095ba59 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -122,13 +122,13 @@ export const dict = { "provider.connect.apiKey.label": "Clé API {{provider}}", "provider.connect.apiKey.placeholder": "Clé API", "provider.connect.apiKey.required": "La clé API est requise", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen vous donne accès à un ensemble sélectionné de modèles fiables et optimisés pour les agents de codage.", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "Avec une seule clé API, vous aurez accès à des modèles tels que Claude, GPT, Gemini, GLM et plus encore.", - "provider.connect.coliZen.visit.prefix": "Visitez ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " pour récupérer votre clé API.", + "provider.connect.opencodeZen.visit.prefix": "Visitez ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " pour récupérer votre clé API.", "provider.connect.oauth.code.visit.prefix": "Visitez ", "provider.connect.oauth.code.visit.link": "ce lien", "provider.connect.oauth.code.visit.suffix": @@ -522,6 +522,9 @@ export const dict = { "settings.section.server": "Serveur", "settings.tab.general": "Général", "settings.tab.shortcuts": "Raccourcis", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "Apparence", "settings.general.section.notifications": "Notifications système", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index a534942df9d..312ac3262c7 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -122,12 +122,12 @@ export const dict = { "provider.connect.apiKey.label": "{{provider}} APIキー", "provider.connect.apiKey.placeholder": "APIキー", "provider.connect.apiKey.required": "APIキーが必要です", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zenは、コーディングエージェント向けに最適化された信頼性の高いモデルへのアクセスを提供します。", - "provider.connect.coliZen.line2": "1つのAPIキーで、Claude、GPT、Gemini、GLMなどのモデルにアクセスできます。", - "provider.connect.coliZen.visit.prefix": " ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " にアクセスしてAPIキーを取得してください。", + "provider.connect.opencodeZen.line2": "1つのAPIキーで、Claude、GPT、Gemini、GLMなどのモデルにアクセスできます。", + "provider.connect.opencodeZen.visit.prefix": " ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " にアクセスしてAPIキーを取得してください。", "provider.connect.oauth.code.visit.prefix": " ", "provider.connect.oauth.code.visit.link": "このリンク", "provider.connect.oauth.code.visit.suffix": @@ -507,6 +507,9 @@ export const dict = { "settings.section.server": "サーバー", "settings.tab.general": "一般", "settings.tab.shortcuts": "ショートカット", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "外観", "settings.general.section.notifications": "システム通知", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 81389915130..b162ab3916e 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -126,12 +126,12 @@ export const dict = { "provider.connect.apiKey.label": "{{provider}} API 키", "provider.connect.apiKey.placeholder": "API 키", "provider.connect.apiKey.required": "API 키가 필요합니다", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen은 코딩 에이전트를 위해 최적화된 신뢰할 수 있는 엄선된 모델에 대한 액세스를 제공합니다.", - "provider.connect.coliZen.line2": "단일 API 키로 Claude, GPT, Gemini, GLM 등 다양한 모델에 액세스할 수 있습니다.", - "provider.connect.coliZen.visit.prefix": "", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": "를 방문하여 API 키를 받으세요.", + "provider.connect.opencodeZen.line2": "단일 API 키로 Claude, GPT, Gemini, GLM 등 다양한 모델에 액세스할 수 있습니다.", + "provider.connect.opencodeZen.visit.prefix": "", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": "를 방문하여 API 키를 받으세요.", "provider.connect.oauth.code.visit.prefix": "", "provider.connect.oauth.code.visit.link": "이 링크", "provider.connect.oauth.code.visit.suffix": @@ -513,6 +513,9 @@ export const dict = { "settings.section.server": "서버", "settings.tab.general": "일반", "settings.tab.shortcuts": "단축키", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "모양", "settings.general.section.notifications": "시스템 알림", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 0fe5c586f69..001b9eda656 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -125,13 +125,13 @@ export const dict = { "provider.connect.apiKey.label": "{{provider}} API-nøkkel", "provider.connect.apiKey.placeholder": "API-nøkkel", "provider.connect.apiKey.required": "API-nøkkel er påkrevd", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen gir deg tilgang til et utvalg av pålitelige optimaliserte modeller for kodeagenter.", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "Med én enkelt API-nøkkel får du tilgang til modeller som Claude, GPT, Gemini, GLM og flere.", - "provider.connect.coliZen.visit.prefix": "Besøk ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " for å hente API-nøkkelen din.", + "provider.connect.opencodeZen.visit.prefix": "Besøk ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " for å hente API-nøkkelen din.", "provider.connect.oauth.code.visit.prefix": "Besøk ", "provider.connect.oauth.code.visit.link": "denne lenken", "provider.connect.oauth.code.visit.suffix": @@ -515,6 +515,9 @@ export const dict = { "settings.section.server": "Server", "settings.tab.general": "Generelt", "settings.tab.shortcuts": "Snarveier", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "Utseende", "settings.general.section.notifications": "Systemvarsler", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 971899e5bb3..2a20cd57e39 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -122,13 +122,13 @@ export const dict = { "provider.connect.apiKey.label": "Klucz API {{provider}}", "provider.connect.apiKey.placeholder": "Klucz API", "provider.connect.apiKey.required": "Klucz API jest wymagany", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen daje dostęp do wybranego zestawu niezawodnych, zoptymalizowanych modeli dla agentów kodujących.", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "Z jednym kluczem API uzyskasz dostęp do modeli takich jak Claude, GPT, Gemini, GLM i więcej.", - "provider.connect.coliZen.visit.prefix": "Odwiedź ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": ", aby odebrać swój klucz API.", + "provider.connect.opencodeZen.visit.prefix": "Odwiedź ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": ", aby odebrać swój klucz API.", "provider.connect.oauth.code.visit.prefix": "Odwiedź ", "provider.connect.oauth.code.visit.link": "ten link", "provider.connect.oauth.code.visit.suffix": @@ -514,6 +514,9 @@ export const dict = { "settings.section.server": "Serwer", "settings.tab.general": "Ogólne", "settings.tab.shortcuts": "Skróty", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "Wygląd", "settings.general.section.notifications": "Powiadomienia systemowe", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 63050a91526..698c8db5819 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -122,13 +122,13 @@ export const dict = { "provider.connect.apiKey.label": "{{provider}} API ключ", "provider.connect.apiKey.placeholder": "API ключ", "provider.connect.apiKey.required": "API ключ обязателен", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen даёт вам доступ к отобранным надёжным оптимизированным моделям для агентов программирования.", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "С одним API ключом вы получите доступ к таким моделям как Claude, GPT, Gemini, GLM и другим.", - "provider.connect.coliZen.visit.prefix": "Посетите ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " чтобы получить ваш API ключ.", + "provider.connect.opencodeZen.visit.prefix": "Посетите ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " чтобы получить ваш API ключ.", "provider.connect.oauth.code.visit.prefix": "Посетите ", "provider.connect.oauth.code.visit.link": "эту ссылку", "provider.connect.oauth.code.visit.suffix": @@ -517,6 +517,9 @@ export const dict = { "settings.section.server": "Сервер", "settings.tab.general": "Основные", "settings.tab.shortcuts": "Горячие клавиши", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "Внешний вид", "settings.general.section.notifications": "Системные уведомления", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index ae94e851c85..161f37f3ba2 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -95,7 +95,7 @@ export const dict = { "dialog.provider.group.popular": "ยอดนิยม", "dialog.provider.group.other": "อื่น ๆ", "dialog.provider.tag.recommended": "แนะนำ", - "dialog.provider.coli.note": "โมเดลที่คัดสรร รวมถึง Claude, GPT, Gemini และอื่น ๆ", + "dialog.provider.opencode.note": "โมเดลที่คัดสรร รวมถึง Claude, GPT, Gemini และอื่น ๆ", "dialog.provider.anthropic.note": "เข้าถึงโมเดล Claude โดยตรง รวมถึง Pro และ Max", "dialog.provider.copilot.note": "โมเดล Claude สำหรับการช่วยเหลือในการเขียนโค้ด", "dialog.provider.openai.note": "โมเดล GPT สำหรับงาน AI ทั่วไปที่รวดเร็วและมีความสามารถ", @@ -126,13 +126,13 @@ export const dict = { "provider.connect.apiKey.label": "คีย์ API ของ {{provider}}", "provider.connect.apiKey.placeholder": "คีย์ API", "provider.connect.apiKey.required": "ต้องใช้คีย์ API", - "provider.connect.coliZen.line1": + "provider.connect.opencodeZen.line1": "OpenCode Zen ให้คุณเข้าถึงชุดโมเดลที่เชื่อถือได้และปรับแต่งแล้วสำหรับเอเจนต์การเขียนโค้ด", - "provider.connect.coliZen.line2": + "provider.connect.opencodeZen.line2": "ด้วยคีย์ API เดียวคุณจะได้รับการเข้าถึงโมเดล เช่น Claude, GPT, Gemini, GLM และอื่น ๆ", - "provider.connect.coliZen.visit.prefix": "เยี่ยมชม ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " เพื่อรวบรวมคีย์ API ของคุณ", + "provider.connect.opencodeZen.visit.prefix": "เยี่ยมชม ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " เพื่อรวบรวมคีย์ API ของคุณ", "provider.connect.oauth.code.visit.prefix": "เยี่ยมชม ", "provider.connect.oauth.code.visit.link": "ลิงก์นี้", "provider.connect.oauth.code.visit.suffix": @@ -516,6 +516,9 @@ export const dict = { "settings.section.server": "เซิร์ฟเวอร์", "settings.tab.general": "ทั่วไป", "settings.tab.shortcuts": "ทางลัด", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "รูปลักษณ์", "settings.general.section.notifications": "การแจ้งเตือนระบบ", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index d9188582329..a2931cf98c8 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -102,7 +102,7 @@ export const dict = { "dialog.provider.anthropic.note": "使用 Claude Pro/Max 或 API 密钥连接", "dialog.provider.openai.note": "使用 ChatGPT Pro/Plus 或 API 密钥连接", "dialog.provider.copilot.note": "使用 Copilot 或 API 密钥连接", - "dialog.provider.coli.note": "使用 OpenCode Zen 或 API 密钥连接", + "dialog.provider.opencode.note": "使用 OpenCode Zen 或 API 密钥连接", "dialog.provider.google.note": "使用 Google 账号或 API 密钥连接", "dialog.provider.openrouter.note": "使用 OpenRouter 账号或 API 密钥连接", "dialog.provider.vercel.note": "使用 Vercel 账号或 API 密钥连接", @@ -130,11 +130,11 @@ export const dict = { "provider.connect.apiKey.label": "{{provider}} API 密钥", "provider.connect.apiKey.placeholder": "API 密钥", "provider.connect.apiKey.required": "API 密钥为必填项", - "provider.connect.coliZen.line1": "OpenCode Zen 为你提供一组精选的可靠优化模型,用于代码智能体。", - "provider.connect.coliZen.line2": "只需一个 API 密钥,你就能使用 Claude、GPT、Gemini、GLM 等模型。", - "provider.connect.coliZen.visit.prefix": "访问 ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " 获取你的 API 密钥。", + "provider.connect.opencodeZen.line1": "OpenCode Zen 为你提供一组精选的可靠优化模型,用于代码智能体。", + "provider.connect.opencodeZen.line2": "只需一个 API 密钥,你就能使用 Claude、GPT、Gemini、GLM 等模型。", + "provider.connect.opencodeZen.visit.prefix": "访问 ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " 获取你的 API 密钥。", "provider.connect.oauth.code.visit.prefix": "访问 ", "provider.connect.oauth.code.visit.link": "此链接", "provider.connect.oauth.code.visit.suffix": " 获取授权码,以连接你的帐户并在 OpenCode 中使用 {{provider}} 模型。", @@ -548,6 +548,9 @@ export const dict = { "settings.section.server": "服务器", "settings.tab.general": "通用", "settings.tab.shortcuts": "快捷键", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "外观", "settings.general.section.notifications": "系统通知", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 3bdfc9ac0d3..cae0c75b46c 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -126,11 +126,11 @@ export const dict = { "provider.connect.apiKey.label": "{{provider}} API 金鑰", "provider.connect.apiKey.placeholder": "API 金鑰", "provider.connect.apiKey.required": "API 金鑰為必填", - "provider.connect.coliZen.line1": "OpenCode Zen 為你提供一組精選的可靠最佳化模型,用於程式碼代理程式。", - "provider.connect.coliZen.line2": "只需一個 API 金鑰,你就能使用 Claude、GPT、Gemini、GLM 等模型。", - "provider.connect.coliZen.visit.prefix": "造訪 ", - "provider.connect.coliZen.visit.link": "opencode.ai/zen", - "provider.connect.coliZen.visit.suffix": " 取得你的 API 金鑰。", + "provider.connect.opencodeZen.line1": "OpenCode Zen 為你提供一組精選的可靠最佳化模型,用於程式碼代理程式。", + "provider.connect.opencodeZen.line2": "只需一個 API 金鑰,你就能使用 Claude、GPT、Gemini、GLM 等模型。", + "provider.connect.opencodeZen.visit.prefix": "造訪 ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", + "provider.connect.opencodeZen.visit.suffix": " 取得你的 API 金鑰。", "provider.connect.oauth.code.visit.prefix": "造訪 ", "provider.connect.oauth.code.visit.link": "此連結", "provider.connect.oauth.code.visit.suffix": " 取得授權碼,以連線你的帳戶並在 OpenCode 中使用 {{provider}} 模型。", @@ -545,6 +545,9 @@ export const dict = { "settings.section.server": "伺服器", "settings.tab.general": "一般", "settings.tab.shortcuts": "快速鍵", + "settings.desktop.section.wsl": "WSL", + "settings.desktop.wsl.title": "WSL integration", + "settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.", "settings.general.section.appearance": "外觀", "settings.general.section.notifications": "系統通知", diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index b2a17b96b90..f36bb7ab4e8 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -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 ( {props.children} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 10f7dac530b..6b61ed30041 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -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) }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 59adef4694a..a18b7ef237a 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -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} diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts index d7b379e4a39..7bdb002a366 100644 --- a/packages/app/src/pages/layout/deep-links.ts +++ b/packages/app/src/pages/layout/deep-links.ts @@ -1,7 +1,7 @@ -export const deepLinkEvent = "coli:deep-link" +export const deepLinkEvent = "opencode:deep-link" export const parseDeepLink = (input: string) => { - if (!input.startsWith("coli://")) return + if (!input.startsWith("opencode://")) return if (typeof URL.canParse === "function" && !URL.canParse(input)) return const url = (() => { try { @@ -21,14 +21,14 @@ export const collectOpenProjectDeepLinks = (urls: string[]) => urls.map(parseDeepLink).filter((directory): directory is string => !!directory) type OpenCodeWindow = Window & { - __COLI__?: { + __OPENCODE__?: { deepLinks?: string[] } } export const drainPendingDeepLinks = (target: OpenCodeWindow) => { - const pending = target.__COLI__?.deepLinks ?? [] + const pending = target.__OPENCODE__?.deepLinks ?? [] if (pending.length === 0) return [] - if (target.__COLI__) target.__COLI__.deepLinks = [] + if (target.__OPENCODE__) target.__OPENCODE__.deepLinks = [] return pending } diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 61b01f2ecd0..83d8f4748ab 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -4,24 +4,24 @@ import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspac describe("layout deep links", () => { test("parses open-project deep links", () => { - expect(parseDeepLink("coli://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") }) test("ignores non-project deep links", () => { - expect(parseDeepLink("coli://other?directory=/tmp/demo")).toBeUndefined() + expect(parseDeepLink("opencode://other?directory=/tmp/demo")).toBeUndefined() expect(parseDeepLink("https://example.com")).toBeUndefined() }) test("ignores malformed deep links safely", () => { - expect(() => parseDeepLink("coli://open-project/%E0%A4%A%")).not.toThrow() - expect(parseDeepLink("coli://open-project/%E0%A4%A%")).toBeUndefined() + expect(() => parseDeepLink("opencode://open-project/%E0%A4%A%")).not.toThrow() + expect(parseDeepLink("opencode://open-project/%E0%A4%A%")).toBeUndefined() }) test("parses links when URL.canParse is unavailable", () => { const original = Object.getOwnPropertyDescriptor(URL, "canParse") Object.defineProperty(URL, "canParse", { configurable: true, value: undefined }) try { - expect(parseDeepLink("coli://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") } finally { if (original) Object.defineProperty(URL, "canParse", original) if (!original) Reflect.deleteProperty(URL, "canParse") @@ -29,27 +29,27 @@ describe("layout deep links", () => { }) test("ignores open-project deep links without directory", () => { - expect(parseDeepLink("coli://open-project")).toBeUndefined() - expect(parseDeepLink("coli://open-project?directory=")).toBeUndefined() + expect(parseDeepLink("opencode://open-project")).toBeUndefined() + expect(parseDeepLink("opencode://open-project?directory=")).toBeUndefined() }) test("collects only valid open-project directories", () => { const result = collectOpenProjectDeepLinks([ - "coli://open-project?directory=/a", - "coli://other?directory=/b", - "coli://open-project?directory=/c", + "opencode://open-project?directory=/a", + "opencode://other?directory=/b", + "opencode://open-project?directory=/c", ]) expect(result).toEqual(["/a", "/c"]) }) test("drains global deep links once", () => { const target = { - __COLI__: { - deepLinks: ["coli://open-project?directory=/a"], + __OPENCODE__: { + deepLinks: ["opencode://open-project?directory=/a"], }, - } as unknown as Window & { __COLI__?: { deepLinks?: string[] } } + } as unknown as Window & { __OPENCODE__?: { deepLinks?: string[] } } - expect(drainPendingDeepLinks(target)).toEqual(["coli://open-project?directory=/a"]) + expect(drainPendingDeepLinks(target)).toEqual(["opencode://open-project?directory=/a"]) expect(drainPendingDeepLinks(target)).toEqual([]) }) }) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 6ecccb95cf8..6a1e7c0123d 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -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() diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 1a58dbc57a0..678bfa0d86d 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -17,7 +17,7 @@ import { type Message, type Session, type TextPart } from "@opencode-ai/sdk/v2/c import { For, Match, Show, Switch, createMemo, onCleanup, type Accessor, type JSX } from "solid-js" import { agentColor } from "@/utils/agent" -const COLI_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" +const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const notification = useNotification() @@ -33,7 +33,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti { const item = ( props.mobile || !props.sidebarExpanded() const item = ( { diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index a7a33f25e12..13c1e55ef8b 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -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) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 24d46f82893..9453dd703c7 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1026,10 +1026,31 @@ export default function Page() { -
- -
{language.t("session.review.empty")}
-
+ + +
{language.t("session.review.empty")}
+
+ ) + } + 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} + /> ) @@ -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", })}
@@ -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} /> diff --git a/packages/app/src/pages/session/scroll-spy.ts b/packages/app/src/pages/session/scroll-spy.ts index 8c52d77dcee..6ef4c844c41 100644 --- a/packages/app/src/pages/session/scroll-spy.ts +++ b/packages/app/src/pages/session/scroll-spy.ts @@ -228,6 +228,7 @@ export const createScrollSpy = (input: Input) => { node.delete(key) visible.delete(key) dirty = true + schedule() } const markDirty = () => { diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 09095d689cd..2e65fde0e32 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -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} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 09c0fd17cc8..d52022d73a6 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -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) - return + + 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, + ) } - await input.sdk.client.session - .share({ sessionID: input.params.id }) - .then((res) => copy(res.data!.share!.url, false)) - .catch(() => + + const copy = async (url: string, existing: boolean) => { + const ok = await write(url) + if (!ok) { showToast({ - title: input.language.t("toast.session.share.failed.title"), - description: input.language.t("toast.session.share.failed.description"), + 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 + } + + const url = await input.sdk.client.session + .share({ sessionID: input.params.id }) + .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) }, }, { diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index 4b824863e6d..2a2c349b755 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -22,22 +22,22 @@ class MemoryStorage implements Storage { getItem(key: string) { this.calls.get += 1 this.events.push(`get:${key}`) - if (key.startsWith("coli.throw")) throw new Error("storage get failed") + if (key.startsWith("opencode.throw")) throw new Error("storage get failed") return this.values.get(key) ?? null } setItem(key: string, value: string) { this.calls.set += 1 this.events.push(`set:${key}`) - if (key.startsWith("coli.quota")) throw new DOMException("quota", "QuotaExceededError") - if (key.startsWith("coli.throw")) throw new Error("storage set failed") + if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError") + if (key.startsWith("opencode.throw")) throw new Error("storage set failed") this.values.set(key, value) } removeItem(key: string) { this.calls.remove += 1 this.events.push(`remove:${key}`) - if (key.startsWith("coli.throw")) throw new Error("storage remove failed") + if (key.startsWith("opencode.throw")) throw new Error("storage remove failed") this.values.delete(key) } } @@ -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() + }) }) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index fcfd3b79b50..57e01d86a9d 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -15,8 +15,8 @@ type PersistTarget = { } const LEGACY_STORAGE = "default.dat" -const GLOBAL_STORAGE = "coli.global.dat" -const LOCAL_PREFIX = "coli." +const GLOBAL_STORAGE = "opencode.global.dat" +const LOCAL_PREFIX = "opencode." const fallback = new Map() const CACHE_MAX_ENTRIES = 500 @@ -195,10 +195,18 @@ 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" - return `coli.workspace.${head}.${sum}.dat` + return `opencode.workspace.${head}.${sum}.dat` } function localStorageWithPrefix(prefix: string): SyncStorage { @@ -291,6 +299,7 @@ function localStorageDirect(): SyncStorage { export const PersistTesting = { localStorageDirect, localStorageWithPrefix, + normalize, } export const Persist = { @@ -358,12 +367,11 @@ export function persisted( 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( 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( 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( 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 } diff --git a/packages/app/src/utils/worktree.test.ts b/packages/app/src/utils/worktree.test.ts index 1eb342b07c7..8161e7ad836 100644 --- a/packages/app/src/utils/worktree.test.ts +++ b/packages/app/src/utils/worktree.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test" import { Worktree } from "./worktree" -const dir = (name: string) => `/tmp/coli-worktree-${name}-${crypto.randomUUID()}` +const dir = (name: string) => `/tmp/opencode-worktree-${name}-${crypto.randomUUID()}` describe("Worktree", () => { test("normalizes trailing slashes", () => { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 2c289f78b98..433b65220e0 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.53", + "version": "1.1.57", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index be53ad909b5..e64d3646202 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -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 diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 1aecdb846fa..08c716aba3b 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -394,7 +394,7 @@ export const dict = { "workspace.settings.edit": "Edit", "workspace.billing.title": "Billing", - "workspace.billing.subtitle.beforeLink": "Manage payments methods.", + "workspace.billing.subtitle.beforeLink": "Manage payment methods.", "workspace.billing.contactUs": "Contact us", "workspace.billing.subtitle.afterLink": "if you have any questions.", "workspace.billing.currentBalance": "Current Balance", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index 6f3151676fa..a1c04bc0af0 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -203,7 +203,7 @@ export const dict = { "zen.how.step2.link": "betale per forespørsel", "zen.how.step2.afterLink": "med null markeringer", "zen.how.step3.title": "Automatisk påfylling", - "zen.how.step3.body": "når saldoen din når $5, legger vi automatisk til $20", + "zen.how.step3.body": "når saldoen din når $5, fyller vi automatisk på $20", "zen.privacy.title": "Personvernet ditt er viktig for oss", "zen.privacy.beforeExceptions": "Alle Zen-modeller er vert i USA. Leverandører følger en nulloppbevaringspolicy og bruker ikke dataene dine til modelltrening, med", @@ -283,7 +283,7 @@ export const dict = { "changelog.empty": "Ingen endringsloggoppforinger funnet.", "changelog.viewJson": "Vis JSON", "workspace.nav.zen": "Zen", - "workspace.nav.apiKeys": "API Taster", + "workspace.nav.apiKeys": "API Nøkler", "workspace.nav.members": "Medlemmer", "workspace.nav.billing": "Fakturering", "workspace.nav.settings": "Innstillinger", @@ -320,7 +320,7 @@ export const dict = { "workspace.providers.edit": "Redigere", "workspace.providers.delete": "Slett", "workspace.providers.saving": "Lagrer...", - "workspace.providers.save": "Spare", + "workspace.providers.save": "Lagre", "workspace.providers.table.provider": "Leverandør", "workspace.providers.table.apiKey": "API nøkkel", "workspace.usage.title": "Brukshistorikk", @@ -330,21 +330,21 @@ export const dict = { "workspace.usage.table.model": "Modell", "workspace.usage.table.input": "Inndata", "workspace.usage.table.output": "Produksjon", - "workspace.usage.table.cost": "Koste", + "workspace.usage.table.cost": "Kostnad", "workspace.usage.breakdown.input": "Inndata", "workspace.usage.breakdown.cacheRead": "Cache lest", "workspace.usage.breakdown.cacheWrite": "Cache-skriving", "workspace.usage.breakdown.output": "Produksjon", "workspace.usage.breakdown.reasoning": "Argumentasjon", "workspace.usage.subscription": "abonnement (${{amount}})", - "workspace.cost.title": "Koste", + "workspace.cost.title": "Kostnad", "workspace.cost.subtitle": "Brukskostnader fordelt på modell.", "workspace.cost.allModels": "Alle modeller", "workspace.cost.allKeys": "Alle nøkler", "workspace.cost.deletedSuffix": "(slettet)", "workspace.cost.empty": "Ingen bruksdata tilgjengelig for den valgte perioden.", "workspace.cost.subscriptionShort": "sub", - "workspace.keys.title": "API Taster", + "workspace.keys.title": "API Nøkler", "workspace.keys.subtitle": "Administrer API-nøklene dine for å få tilgang til opencode-tjenester.", "workspace.keys.create": "Opprett API-nøkkel", "workspace.keys.placeholder": "Skriv inn nøkkelnavn", @@ -370,7 +370,7 @@ export const dict = { "workspace.members.edit": "Redigere", "workspace.members.delete": "Slett", "workspace.members.saving": "Lagrer...", - "workspace.members.save": "Spare", + "workspace.members.save": "Lagre", "workspace.members.table.email": "E-post", "workspace.members.table.role": "Rolle", "workspace.members.table.monthLimit": "Månedsgrense", @@ -383,7 +383,7 @@ export const dict = { "workspace.settings.workspaceName": "Navn på arbeidsområde", "workspace.settings.defaultName": "Misligholde", "workspace.settings.updating": "Oppdaterer...", - "workspace.settings.save": "Spare", + "workspace.settings.save": "Lagre", "workspace.settings.edit": "Redigere", "workspace.billing.title": "Fakturering", "workspace.billing.subtitle.beforeLink": "Administrer betalingsmåter.", @@ -407,22 +407,22 @@ export const dict = { "workspace.monthlyLimit.noLimit": "Ingen bruksgrense satt.", "workspace.monthlyLimit.currentUsage.beforeMonth": "Gjeldende bruk for", "workspace.monthlyLimit.currentUsage.beforeAmount": "er $", - "workspace.reload.title": "Last inn automatisk", - "workspace.reload.disabled.before": "Automatisk reload er", - "workspace.reload.disabled.state": "funksjonshemmet", - "workspace.reload.disabled.after": "Aktiver for å laste automatisk på nytt når balansen er lav.", - "workspace.reload.enabled.before": "Automatisk reload er", + "workspace.reload.title": "Automatisk påfylling", + "workspace.reload.disabled.before": "Automatisk påfylling er", + "workspace.reload.disabled.state": "deaktivert", + "workspace.reload.disabled.after": "Aktiver for å automatisk påfylle på nytt når saldoen er lav.", + "workspace.reload.enabled.before": "Automatisk påfylling er", "workspace.reload.enabled.state": "aktivert", - "workspace.reload.enabled.middle": "Vi laster på nytt", + "workspace.reload.enabled.middle": "Vi fyller på", "workspace.reload.processingFee": "behandlingsgebyr", - "workspace.reload.enabled.after": "når balansen når", + "workspace.reload.enabled.after": "når saldoen når", "workspace.reload.edit": "Redigere", "workspace.reload.enable": "Aktiver", - "workspace.reload.enableAutoReload": "Aktiver automatisk reload", + "workspace.reload.enableAutoReload": "Aktiver automatisk påfylling", "workspace.reload.reloadAmount": "Last inn $", "workspace.reload.whenBalanceReaches": "Når saldoen når $", "workspace.reload.saving": "Lagrer...", - "workspace.reload.save": "Spare", + "workspace.reload.save": "Lagre", "workspace.reload.failedAt": "Omlasting mislyktes kl", "workspace.reload.reason": "Grunn:", "workspace.reload.updatePaymentMethod": "Oppdater betalingsmåten og prøv på nytt.", @@ -436,7 +436,7 @@ export const dict = { "workspace.payments.table.receipt": "Kvittering", "workspace.payments.type.credit": "kreditt", "workspace.payments.type.subscription": "abonnement", - "workspace.payments.view": "Utsikt", + "workspace.payments.view": "Vis", "workspace.black.loading": "Laster inn...", "workspace.black.time.day": "dag", "workspace.black.time.days": "dager", diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index fbeb4b59dad..e47134d2b94 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -46,7 +46,7 @@ export default function Home() { } return ( -
+
{/**/} {i18n.t("home.title")} diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index b97b7343074..a3a93d2ef86 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -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 {} diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index af2a8c3e60b..9646cacd057 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -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> type ProviderInfo = Awaited> - 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, diff --git a/packages/console/app/src/routes/zen/util/rateLimiter.ts b/packages/console/app/src/routes/zen/util/rateLimiter.ts index 90e10479c44..5e4f31e6769 100644 --- a/packages/console/app/src/routes/zen/util/rateLimiter.ts +++ b/packages/console/app/src/routes/zen/util/rateLimiter.ts @@ -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() diff --git a/packages/console/app/src/routes/zen/v1/models.ts b/packages/console/app/src/routes/zen/v1/models.ts index 08d9956e2fc..ee2b3ab5416 100644 --- a/packages/console/app/src/routes/zen/v1/models.ts +++ b/packages/console/app/src/routes/zen/v1/models.ts @@ -29,7 +29,7 @@ export async function GET(input: APIEvent) { id, object: "model", created: Math.floor(Date.now() / 1000), - owned_by: "coli", + owned_by: "opencode", })), }), { diff --git a/packages/console/app/test/rateLimiter.test.ts b/packages/console/app/test/rateLimiter.test.ts new file mode 100644 index 00000000000..864f907d669 --- /dev/null +++ b/packages/console/app/test/rateLimiter.test.ts @@ -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) + }) +}) diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 4304b17790d..7268af6e37a 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.53", + "version": "1.1.57", "private": true, "type": "module", "license": "MIT", @@ -19,6 +19,7 @@ "zod": "catalog:" }, "exports": { + "./*.js": "./src/*.ts", "./*": "./src/*" }, "scripts": { diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index e1e540fb7a4..9a2908e32e3 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -53,8 +53,6 @@ export namespace ZenData { weight: z.number().optional(), disabled: z.boolean().optional(), storeModel: z.string().optional(), - headers: z.record(z.string(), z.string()).optional(), - bodyModifier: z.record(z.string(), z.string()).optional(), }), ), }) @@ -62,13 +60,20 @@ export namespace ZenData { const ProviderSchema = z.object({ api: z.string(), apiKey: z.string(), - format: FormatSchema, + format: FormatSchema.optional(), headerMappings: z.record(z.string(), z.string()).optional(), + family: z.string().optional(), + }) + + const ProviderFamilySchema = z.object({ + headers: z.record(z.string(), z.string()).optional(), + bodyModifier: z.record(z.string(), z.string()).optional(), }) const ModelsSchema = z.object({ models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])), providers: z.record(z.string(), ProviderSchema), + providerFamilies: z.record(z.string(), ProviderFamilySchema), }) export const validate = fn(ModelsSchema, (input) => { @@ -98,7 +103,16 @@ export namespace ZenData { Resource.ZEN_MODELS19.value + Resource.ZEN_MODELS20.value, ) - return ModelsSchema.parse(json) + const { models, providers, providerFamilies } = ModelsSchema.parse(json) + return { + models, + providers: Object.fromEntries( + Object.entries(providers).map(([id, provider]) => [ + id, + { ...provider, ...(provider.family ? providerFamilies[provider.family] : {}) }, + ]), + ), + } }) } diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 7ad6c9934cc..f22b9382462 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.53", + "version": "1.1.57", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/function/src/auth.ts b/packages/console/function/src/auth.ts index 50d62fde0ac..c26ab215b32 100644 --- a/packages/console/function/src/auth.ts +++ b/packages/console/function/src/auth.ts @@ -111,14 +111,14 @@ export default { const emails = (await fetch("https://api.github.com/user/emails", { headers: { Authorization: `Bearer ${response.tokenset.access}`, - "User-Agent": "coli", + "User-Agent": "opencode", Accept: "application/vnd.github+json", }, }).then((x) => x.json())) as any const user = (await fetch("https://api.github.com/user", { headers: { Authorization: `Bearer ${response.tokenset.access}`, - "User-Agent": "coli", + "User-Agent": "opencode", Accept: "application/vnd.github+json", }, }).then((x) => x.json())) as any diff --git a/packages/console/function/src/log-processor.ts b/packages/console/function/src/log-processor.ts index 9e76e2ceb08..327fc930b72 100644 --- a/packages/console/function/src/log-processor.ts +++ b/packages/console/function/src/log-processor.ts @@ -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()) diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index c314f3392df..f7e6e59e1c6 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.53", + "version": "1.1.57", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/containers/script/build.ts b/packages/containers/script/build.ts index 6b14754d7a0..6b880e7a5b9 100644 --- a/packages/containers/script/build.ts +++ b/packages/containers/script/build.ts @@ -22,11 +22,11 @@ const images = ["base", "bun-node", "rust", "tauri-linux", "publish"] const setup = async () => { if (!push) return const list = await $`docker buildx ls`.text() - if (list.includes("coli")) { - await $`docker buildx use coli` + if (list.includes("opencode")) { + await $`docker buildx use opencode` return } - await $`docker buildx create --name coli --use` + await $`docker buildx create --name opencode --use` } await setup() diff --git a/packages/desktop/AGENTS.md b/packages/desktop/AGENTS.md new file mode 100644 index 00000000000..3839db1a904 --- /dev/null +++ b/packages/desktop/AGENTS.md @@ -0,0 +1,4 @@ +# Desktop package notes + +- Never call `invoke` manually in this package. +- Use the generated bindings in `packages/desktop/src/bindings.ts` for core commands/events. diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 8f09ed169ff..33954555c56 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.53", + "version": "1.1.57", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts index e95482b8654..3e14250b1aa 100644 --- a/packages/desktop/scripts/predev.ts +++ b/packages/desktop/scripts/predev.ts @@ -6,7 +6,7 @@ const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE const sidecarConfig = getCurrentSidecar(RUST_TARGET) -const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/coli`) +const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`) await $`cd ../opencode && bun run build --single` diff --git a/packages/desktop/scripts/prepare.ts b/packages/desktop/scripts/prepare.ts index 069f4d3d4bd..d802f2d89ee 100755 --- a/packages/desktop/scripts/prepare.ts +++ b/packages/desktop/scripts/prepare.ts @@ -11,9 +11,9 @@ console.log(`Updated package.json version to ${Script.version}`) const sidecarConfig = getCurrentSidecar() -const dir = "src-tauri/target/coli-binaries" +const dir = "src-tauri/target/opencode-binaries" await $`mkdir -p ${dir}` -await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n coli-cli`.cwd(dir) +await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir) -await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/coli`)) +await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`)) diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index 43b345b512a..c3019f0b970 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -3,27 +3,27 @@ import { $ } from "bun" export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [ { rustTarget: "aarch64-apple-darwin", - ocBinary: "coli-darwin-arm64", + ocBinary: "opencode-darwin-arm64", assetExt: "zip", }, { rustTarget: "x86_64-apple-darwin", - ocBinary: "coli-darwin-x64", + ocBinary: "opencode-darwin-x64", assetExt: "zip", }, { rustTarget: "x86_64-pc-windows-msvc", - ocBinary: "coli-windows-x64", + ocBinary: "opencode-windows-x64", assetExt: "zip", }, { rustTarget: "x86_64-unknown-linux-gnu", - ocBinary: "coli-linux-x64", + ocBinary: "opencode-linux-x64", assetExt: "tar.gz", }, { rustTarget: "aarch64-unknown-linux-gnu", - ocBinary: "coli-linux-arm64", + ocBinary: "opencode-linux-arm64", assetExt: "tar.gz", }, ] @@ -41,7 +41,7 @@ export function getCurrentSidecar(target = RUST_TARGET) { export async function copyBinaryToSidecarFolder(source: string, target = RUST_TARGET) { await $`mkdir -p src-tauri/sidecars` - const dest = windowsify(`src-tauri/sidecars/coli-cli-${target}`) + const dest = windowsify(`src-tauri/sidecars/opencode-cli-${target}`) await $`cp ${source} ${dest}` console.log(`Copied ${source} to ${dest}`) diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 537a7c9c566..8ce97b2b724 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -535,8 +535,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -2491,6 +2493,15 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matches" version = "0.1.10" @@ -2691,6 +2702,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-conv" version = "0.1.0" @@ -3065,6 +3085,7 @@ dependencies = [ name = "opencode-desktop" version = "0.0.0" dependencies = [ + "chrono", "comrak", "dirs", "futures", @@ -3096,6 +3117,9 @@ dependencies = [ "tauri-plugin-window-state", "tauri-specta", "tokio", + "tracing", + "tracing-appender", + "tracing-subscriber", "uuid", "webkit2gtk", "windows 0.61.3", @@ -4412,6 +4436,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shared_child" version = "1.1.1" @@ -5472,6 +5505,15 @@ dependencies = [ "syn 2.0.110", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + [[package]] name = "tiff" version = "0.10.3" @@ -5745,20 +5787,32 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.17", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", @@ -5767,11 +5821,41 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] @@ -5964,6 +6048,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "version-compare" version = "0.2.1" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index a8e06226bd2..e9ba55b039a 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "coli-desktop" +name = "opencode-desktop" version = "0.0.0" description = "The open source AI coding agent" authors = ["Anomaly Innovations"] @@ -11,7 +11,7 @@ edition = "2024" # The `_lib` suffix may seem redundant but it is necessary # to make the lib name unique and wouldn't conflict with the bin name. # This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519 -name = "coli_lib" +name = "opencode_lib" crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] @@ -47,6 +47,10 @@ specta = "=2.0.0-rc.22" specta-typescript = "0.0.9" tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] } dirs = "6.0.0" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" +chrono = "0.4" [target.'cfg(target_os = "linux")'.dependencies] gtk = "0.18.2" diff --git a/packages/desktop/src-tauri/build.rs b/packages/desktop/src-tauri/build.rs index 9ea1d589697..85c91f55a69 100644 --- a/packages/desktop/src-tauri/build.rs +++ b/packages/desktop/src-tauri/build.rs @@ -2,7 +2,7 @@ fn main() { if let Ok(git_ref) = std::env::var("GITHUB_REF") { let branch = git_ref.strip_prefix("refs/heads/").unwrap_or(&git_ref); if branch == "beta" { - println!("cargo:rustc-env=COLI_SQLITE=1"); + println!("cargo:rustc-env=OPENCODE_SQLITE=1"); } } diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index 6ad5a3adf9b..b9e1ed4bd50 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -1,13 +1,15 @@ use tauri::{AppHandle, Manager, path::BaseDirectory}; use tauri_plugin_shell::{ ShellExt, - process::{Command, CommandChild, CommandEvent}, + process::{Command, CommandChild, CommandEvent, TerminatedPayload}, }; +use tauri_plugin_store::StoreExt; +use tokio::sync::oneshot; -use crate::{LogState, constants::MAX_LOG_ENTRIES}; +use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY}; -const CLI_INSTALL_DIR: &str = ".coli/bin"; -const CLI_BINARY_NAME: &str = "coli"; +const CLI_INSTALL_DIR: &str = ".opencode/bin"; +const CLI_BINARY_NAME: &str = "opencode"; #[derive(serde::Deserialize)] pub struct ServerConfig { @@ -21,10 +23,10 @@ pub struct Config { } pub async fn get_config(app: &AppHandle) -> Option { - create_command(app, "debug config") + create_command(app, "debug config", &[]) .output() .await - .inspect_err(|e| eprintln!("Failed to read OC config: {e}")) + .inspect_err(|e| tracing::warn!("Failed to read OC config: {e}")) .ok() .and_then(|out| String::from_utf8(out.stdout.to_vec()).ok()) .and_then(|s| serde_json::from_str::(&s).ok()) @@ -44,7 +46,7 @@ pub fn get_sidecar_path(app: &tauri::AppHandle) -> std::path::PathBuf { .expect("Failed to get current binary") .parent() .expect("Failed to get parent dir") - .join("coli-cli") + .join("opencode-cli") } fn is_cli_installed() -> bool { @@ -67,7 +69,7 @@ pub fn install_cli(app: tauri::AppHandle) -> Result { return Err("Sidecar binary not found".to_string()); } - let temp_script = std::env::temp_dir().join("coli-install.sh"); + let temp_script = std::env::temp_dir().join("opencode-install.sh"); std::fs::write(&temp_script, INSTALL_SCRIPT) .map_err(|e| format!("Failed to write install script: {}", e))?; @@ -99,12 +101,12 @@ pub fn install_cli(app: tauri::AppHandle) -> Result { pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> { if cfg!(debug_assertions) { - println!("Skipping CLI sync for debug build"); + tracing::debug!("Skipping CLI sync for debug build"); return Ok(()); } if !is_cli_installed() { - println!("No CLI installation found, skipping sync"); + tracing::info!("No CLI installation found, skipping sync"); return Ok(()); } @@ -127,21 +129,21 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> { let app_version = app.package_info().version.clone(); if cli_version >= app_version { - println!( - "CLI version {} is up to date (app version: {}), skipping sync", - cli_version, app_version + tracing::info!( + %cli_version, %app_version, + "CLI is up to date, skipping sync" ); return Ok(()); } - println!( - "CLI version {} is older than app version {}, syncing", - cli_version, app_version + tracing::info!( + %cli_version, %app_version, + "CLI is older than app version, syncing" ); install_cli(app)?; - println!("Synced installed CLI"); + tracing::info!("Synced installed CLI"); Ok(()) } @@ -150,25 +152,106 @@ fn get_user_shell() -> String { std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) } -pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command { +fn is_wsl_enabled(app: &tauri::AppHandle) -> bool { + let Ok(store) = app.store(SETTINGS_STORE) else { + return false; + }; + + store + .get(WSL_ENABLED_KEY) + .as_ref() + .and_then(|value| value.as_bool()) + .unwrap_or(false) +} + +fn shell_escape(input: &str) -> String { + if input.is_empty() { + return "''".to_string(); + } + + let mut escaped = String::from("'"); + escaped.push_str(&input.replace("'", "'\"'\"'")); + escaped.push('\''); + escaped +} + +pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, String)]) -> Command { let state_dir = app .path() .resolve("", BaseDirectory::AppLocalData) .expect("Failed to resolve app local data dir"); - #[cfg(target_os = "windows")] - return app - .shell() - .sidecar("coli-cli") - .unwrap() - .args(args.split_whitespace()) - .env("COLI_EXPERIMENTAL_ICON_DISCOVERY", "true") - .env("COLI_EXPERIMENTAL_FILEWATCHER", "true") - .env("COLI_CLIENT", "desktop") - .env("XDG_STATE_HOME", &state_dir); - - #[cfg(not(target_os = "windows"))] - return { + let mut envs = vec![ + ( + "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY".to_string(), + "true".to_string(), + ), + ( + "OPENCODE_EXPERIMENTAL_FILEWATCHER".to_string(), + "true".to_string(), + ), + ("OPENCODE_CLIENT".to_string(), "desktop".to_string()), + ( + "XDG_STATE_HOME".to_string(), + state_dir.to_string_lossy().to_string(), + ), + ]; + envs.extend( + extra_env + .iter() + .map(|(key, value)| (key.to_string(), value.clone())), + ); + + if cfg!(windows) { + if is_wsl_enabled(app) { + tracing::info!("WSL is enabled, spawning CLI server in WSL"); + let version = app.package_info().version.to_string(); + let mut script = vec![ + "set -e".to_string(), + "BIN=\"$HOME/.opencode/bin/opencode\"".to_string(), + "if [ ! -x \"$BIN\" ]; then".to_string(), + format!( + " curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path", + shell_escape(&version) + ), + "fi".to_string(), + ]; + + let mut env_prefix = vec![ + "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true".to_string(), + "OPENCODE_EXPERIMENTAL_FILEWATCHER=true".to_string(), + "OPENCODE_CLIENT=desktop".to_string(), + "XDG_STATE_HOME=\"$HOME/.local/state\"".to_string(), + ]; + env_prefix.extend( + envs.iter() + .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY") + .filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_FILEWATCHER") + .filter(|(key, _)| key != "OPENCODE_CLIENT") + .filter(|(key, _)| key != "XDG_STATE_HOME") + .map(|(key, value)| format!("{}={}", key, shell_escape(value))), + ); + + script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args)); + + return app + .shell() + .command("wsl") + .args(["-e", "bash", "-lc", &script.join("\n")]); + } else { + let mut cmd = app + .shell() + .sidecar("opencode-cli") + .unwrap() + .args(args.split_whitespace()); + + for (key, value) in envs { + cmd = cmd.env(key, value); + } + + return cmd; + } + } else { let sidecar = get_sidecar_path(app); let shell = get_user_shell(); @@ -178,58 +261,64 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command { format!("\"{}\" {}", sidecar.display(), args) }; - app.shell() - .command(&shell) - .env("COLI_EXPERIMENTAL_ICON_DISCOVERY", "true") - .env("COLI_EXPERIMENTAL_FILEWATCHER", "true") - .env("COLI_CLIENT", "desktop") - .env("XDG_STATE_HOME", &state_dir) - .args(["-il", "-c", &cmd]) - }; + let mut cmd = app.shell().command(&shell).args(["-il", "-c", &cmd]); + + for (key, value) in envs { + cmd = cmd.env(key, value); + } + + cmd + } } -pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> CommandChild { - let log_state = app.state::(); - let log_state_clone = log_state.inner().clone(); +pub fn serve( + app: &AppHandle, + hostname: &str, + port: u32, + password: &str, +) -> (CommandChild, oneshot::Receiver) { + let (exit_tx, exit_rx) = oneshot::channel::(); + + tracing::info!(port, "Spawning sidecar"); - println!("spawning sidecar on port {port}"); + let envs = [ + ("OPENCODE_SERVER_USERNAME", "opencode".to_string()), + ("OPENCODE_SERVER_PASSWORD", password.to_string()), + ]; let (mut rx, child) = create_command( app, - format!("serve --hostname {hostname} --port {port}").as_str(), + format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(), + &envs, ) - .env("COLI_SERVER_USERNAME", "coli") - .env("COLI_SERVER_PASSWORD", password) .spawn() - .expect("Failed to spawn coli"); + .expect("Failed to spawn opencode"); tokio::spawn(async move { + let mut exit_tx = Some(exit_tx); while let Some(event) = rx.recv().await { match event { CommandEvent::Stdout(line_bytes) => { let line = String::from_utf8_lossy(&line_bytes); - print!("{line}"); - - // Store log in shared state - if let Ok(mut logs) = log_state_clone.0.lock() { - logs.push_back(format!("[STDOUT] {}", line)); - // Keep only the last MAX_LOG_ENTRIES - while logs.len() > MAX_LOG_ENTRIES { - logs.pop_front(); - } - } + tracing::info!(target: "sidecar", "{line}"); } CommandEvent::Stderr(line_bytes) => { let line = String::from_utf8_lossy(&line_bytes); - eprint!("{line}"); - - // Store log in shared state - if let Ok(mut logs) = log_state_clone.0.lock() { - logs.push_back(format!("[STDERR] {}", line)); - // Keep only the last MAX_LOG_ENTRIES - while logs.len() > MAX_LOG_ENTRIES { - logs.pop_front(); - } + tracing::info!(target: "sidecar", "{line}"); + } + CommandEvent::Error(err) => { + tracing::error!(target: "sidecar", "{err}"); + } + CommandEvent::Terminated(payload) => { + tracing::info!( + target: "sidecar", + code = ?payload.code, + signal = ?payload.signal, + "Sidecar terminated" + ); + + if let Some(tx) = exit_tx.take() { + let _ = tx.send(payload); } } _ => {} @@ -237,5 +326,5 @@ pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> Comm } }); - child + (child, exit_rx) } diff --git a/packages/desktop/src-tauri/src/constants.rs b/packages/desktop/src-tauri/src/constants.rs index 2303463152c..9d50d00e202 100644 --- a/packages/desktop/src-tauri/src/constants.rs +++ b/packages/desktop/src-tauri/src/constants.rs @@ -1,9 +1,9 @@ use tauri_plugin_window_state::StateFlags; -pub const SETTINGS_STORE: &str = "coli.settings.dat"; +pub const SETTINGS_STORE: &str = "opencode.settings.dat"; pub const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; +pub const WSL_ENABLED_KEY: &str = "wslEnabled"; pub const UPDATER_ENABLED: bool = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some(); -pub const MAX_LOG_ENTRIES: usize = 200; pub fn window_state_flags() -> StateFlags { StateFlags::all() - StateFlags::DECORATIONS - StateFlags::VISIBLE diff --git a/packages/desktop/src-tauri/src/job_object.rs b/packages/desktop/src-tauri/src/job_object.rs index 220aa5db66d..8d774b14cd9 100644 --- a/packages/desktop/src-tauri/src/job_object.rs +++ b/packages/desktop/src-tauri/src/job_object.rs @@ -15,9 +15,9 @@ use std::io::{Error, Result}; use std::sync::Mutex; use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, - JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, - SetInformationJobObject, + AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation, + SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, }; use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE}; @@ -111,7 +111,7 @@ impl JobObjectState { error: Mutex::new(None), }, Err(e) => { - eprintln!("Failed to create job object: {e}"); + tracing::error!("Failed to create job object: {e}"); Self { job: Mutex::new(None), error: Mutex::new(Some(format!("Failed to create job object: {e}"))), @@ -123,11 +123,11 @@ impl JobObjectState { pub fn assign_pid(&self, pid: u32) { if let Some(job) = self.job.lock().unwrap().as_ref() { if let Err(e) = job.assign_pid(pid) { - eprintln!("Failed to assign process {pid} to job object: {e}"); + tracing::error!(pid, "Failed to assign process to job object: {e}"); *self.error.lock().unwrap() = Some(format!("Failed to assign process to job object: {e}")); } else { - println!("Assigned process {pid} to job object for automatic cleanup"); + tracing::info!(pid, "Assigned process to job object for automatic cleanup"); } } } diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 0358d98736d..2c570b7a77d 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod constants; mod job_object; #[cfg(target_os = "linux")] pub mod linux_display; +mod logging; mod markdown; mod server; mod window_customizer; @@ -16,13 +17,12 @@ use futures::{ #[cfg(windows)] use job_object::*; use std::{ - collections::VecDeque, 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)))] @@ -52,6 +52,13 @@ enum InitStep { Done, } +#[derive(serde::Deserialize, specta::Type)] +#[serde(rename_all = "snake_case")] +enum WslPathMode { + Windows, + Linux, +} + struct InitState { current: watch::Receiver, } @@ -78,14 +85,11 @@ impl ServerState { } } -#[derive(Clone)] -struct LogState(Arc>>); - #[tauri::command] #[specta::specta] fn kill_sidecar(app: AppHandle) { let Some(server_state) = app.try_state::() else { - println!("Server not running"); + tracing::info!("Server not running"); return; }; @@ -95,24 +99,17 @@ fn kill_sidecar(app: AppHandle) { .expect("Failed to acquire mutex lock") .take() else { - println!("Server state missing"); + tracing::info!("Server state missing"); return; }; let _ = server_state.kill(); - println!("Killed server"); + tracing::info!("Killed server"); } -async fn get_logs(app: AppHandle) -> Result { - let log_state = app.try_state::().ok_or("Log state not found")?; - - let logs = log_state - .0 - .lock() - .map_err(|_| "Failed to acquire log lock")?; - - Ok(logs.iter().cloned().collect::>().join("")) +fn get_logs() -> String { + logging::tail() } #[tauri::command] @@ -152,12 +149,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) @@ -166,8 +163,400 @@ fn check_app_exists(app_name: &str) -> bool { #[cfg(target_os = "windows")] fn check_windows_app(app_name: &str) -> bool { - // Check if command exists in PATH, including .exe - return true; + resolve_windows_app_path(app_name).is_some() +} + +#[cfg(target_os = "windows")] +fn resolve_windows_app_path(app_name: &str) -> Option { + use std::path::{Path, PathBuf}; + + fn expand_env(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + let mut index = 0; + + while let Some(start) = value[index..].find('%') { + let start = index + start; + out.push_str(&value[index..start]); + + let Some(end_rel) = value[start + 1..].find('%') else { + out.push_str(&value[start..]); + return out; + }; + + let end = start + 1 + end_rel; + let key = &value[start + 1..end]; + if key.is_empty() { + out.push('%'); + index = end + 1; + continue; + } + + if let Ok(v) = std::env::var(key) { + out.push_str(&v); + index = end + 1; + continue; + } + + out.push_str(&value[start..=end]); + index = end + 1; + } + + out.push_str(&value[index..]); + out + } + + fn extract_exe(value: &str) -> Option { + let value = value.trim(); + if value.is_empty() { + return None; + } + + if let Some(rest) = value.strip_prefix('"') { + if let Some(end) = rest.find('"') { + let inner = rest[..end].trim(); + if inner.to_ascii_lowercase().contains(".exe") { + return Some(inner.to_string()); + } + } + } + + let lower = value.to_ascii_lowercase(); + let end = lower.find(".exe")?; + Some(value[..end + 4].trim().trim_matches('"').to_string()) + } + + fn candidates(app_name: &str) -> Vec { + let app_name = app_name.trim().trim_matches('"'); + if app_name.is_empty() { + return vec![]; + } + + let mut out = Vec::::new(); + let mut push = |value: String| { + let value = value.trim().trim_matches('"').to_string(); + if value.is_empty() { + return; + } + if out.iter().any(|v| v.eq_ignore_ascii_case(&value)) { + return; + } + out.push(value); + }; + + push(app_name.to_string()); + + let lower = app_name.to_ascii_lowercase(); + if !lower.ends_with(".exe") { + push(format!("{app_name}.exe")); + } + + let snake = { + let mut s = String::new(); + let mut underscore = false; + for c in lower.chars() { + if c.is_ascii_alphanumeric() { + s.push(c); + underscore = false; + continue; + } + if underscore { + continue; + } + s.push('_'); + underscore = true; + } + s.trim_matches('_').to_string() + }; + + if !snake.is_empty() { + push(snake.clone()); + if !snake.ends_with(".exe") { + push(format!("{snake}.exe")); + } + } + + let alnum = lower + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .collect::(); + + if !alnum.is_empty() { + push(alnum.clone()); + push(format!("{alnum}.exe")); + } + + match lower.as_str() { + "sublime text" | "sublime-text" | "sublime_text" | "sublime text.exe" => { + push("subl".to_string()); + push("subl.exe".to_string()); + push("sublime_text".to_string()); + push("sublime_text.exe".to_string()); + } + _ => {} + } + + out + } + + fn reg_app_path(exe: &str) -> Option { + let exe = exe.trim().trim_matches('"'); + if exe.is_empty() { + return None; + } + + let keys = [ + format!( + r"HKCU\Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}" + ), + format!( + r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}" + ), + format!( + r"HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\{exe}" + ), + ]; + + for key in keys { + let Some(output) = Command::new("reg") + .args(["query", &key, "/ve"]) + .output() + .ok() + else { + continue; + }; + + if !output.status.success() { + continue; + } + + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let tokens = line.split_whitespace().collect::>(); + let Some(index) = tokens.iter().position(|v| v.starts_with("REG_")) else { + continue; + }; + + let value = tokens[index + 1..].join(" "); + let Some(exe) = extract_exe(&value) else { + continue; + }; + + let exe = expand_env(&exe); + let path = Path::new(exe.trim().trim_matches('"')); + if path.exists() { + return Some(path.to_string_lossy().to_string()); + } + } + } + + None + } + + let app_name = app_name.trim().trim_matches('"'); + if app_name.is_empty() { + return None; + } + + let direct = Path::new(app_name); + if direct.is_absolute() && direct.exists() { + return Some(direct.to_string_lossy().to_string()); + } + + let key = app_name + .chars() + .filter(|v| v.is_ascii_alphanumeric()) + .flat_map(|v| v.to_lowercase()) + .collect::(); + + 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) + }; + + let resolve_cmd = |path: &Path| -> Option { + let bytes = std::fs::read(path).ok()?; + let content = String::from_utf8_lossy(&bytes); + + for token in content.split('"') { + let Some(exe) = extract_exe(token) else { + continue; + }; + + let lower = exe.to_ascii_lowercase(); + if let Some(index) = lower.find("%~dp0") { + let base = path.parent()?; + let suffix = &exe[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()); + } + + continue; + } + + let resolved = PathBuf::from(expand_env(&exe)); + if resolved.exists() { + return Some(resolved.to_string_lossy().to_string()); + } + } + + None + }; + + let resolve_where = |query: &str| -> Option { + let output = Command::new("where").arg(query).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::>(); + + if paths.is_empty() { + return None; + } + + if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) { + return Some(path.to_string_lossy().to_string()); + } + + 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); + } + } + } + } + + 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::(); + + 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()) + }; + + let list = candidates(app_name); + for query in &list { + if let Some(path) = resolve_where(query) { + return Some(path); + } + } + + let mut exes = Vec::::new(); + for query in &list { + let query = query.trim().trim_matches('"'); + if query.is_empty() { + continue; + } + + let name = Path::new(query) + .file_name() + .and_then(|v| v.to_str()) + .unwrap_or(query); + + let exe = if name.to_ascii_lowercase().ends_with(".exe") { + name.to_string() + } else { + format!("{name}.exe") + }; + + if exes.iter().any(|v| v.eq_ignore_ascii_case(&exe)) { + continue; + } + + exes.push(exe); + } + + for exe in exes { + if let Some(path) = reg_app_path(&exe) { + return Some(path); + } + } + + None +} + +#[tauri::command] +#[specta::specta] +fn resolve_app_path(app_name: &str) -> Option { + #[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")] @@ -181,13 +570,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) @@ -238,35 +627,54 @@ fn check_linux_app(app_name: &str) -> bool { return true; } +#[tauri::command] +#[specta::specta] +fn wsl_path(path: String, mode: Option) -> Result { + if !cfg!(windows) { + return Ok(path); + } + + let flag = match mode.unwrap_or(WslPathMode::Linux) { + WslPathMode::Windows => "-w", + WslPathMode::Linux => "-u", + }; + + let output = if path.starts_with('~') { + let suffix = path.strip_prefix('~').unwrap_or(""); + let escaped = suffix.replace('"', "\\\""); + let cmd = format!("wslpath {flag} \"$HOME{escaped}\""); + Command::new("wsl") + .args(["-e", "sh", "-lc", &cmd]) + .output() + .map_err(|e| format!("Failed to run wslpath: {e}"))? + } else { + Command::new("wsl") + .args(["-e", "wslpath", flag, &path]) + .output() + .map_err(|e| format!("Failed to run wslpath: {e}"))? + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + if stderr.is_empty() { + return Err("wslpath failed".to_string()); + } + return Err(stderr); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { - let builder = tauri_specta::Builder::::new() - // Then register them (separated by a comma) - .commands(tauri_specta::collect_commands![ - kill_sidecar, - cli::install_cli, - await_initialization, - server::get_default_server_url, - server::set_default_server_url, - get_display_backend, - set_display_backend, - markdown::parse_markdown_command, - check_app_exists - ]) - .events(tauri_specta::collect_events![LoadingWindowComplete]) - .error_handling(tauri_specta::ErrorHandlingMode::Throw); + let builder = make_specta_builder(); #[cfg(debug_assertions)] // <- Only export on non-release builds - builder - .export( - specta_typescript::Typescript::default(), - "../src/bindings.ts", - ) - .expect("Failed to export typescript bindings"); + export_types(&builder); #[cfg(all(target_os = "macos", not(debug_assertions)))] let _ = std::process::Command::new("killall") - .arg("coli-cli") + .arg("opencode-cli") .output(); let mut builder = tauri::Builder::default() @@ -297,10 +705,18 @@ pub fn run() { .plugin(tauri_plugin_decorum::init()) .invoke_handler(builder.invoke_handler()) .setup(move |app| { - let app = app.handle().clone(); + let handle = app.handle().clone(); + + let log_dir = app + .path() + .app_log_dir() + .expect("failed to resolve app log dir"); + // Hold the guard in managed state so it lives for the app's lifetime, + // ensuring all buffered logs are flushed on shutdown. + handle.manage(logging::init(&log_dir)); - builder.mount_events(&app); - tauri::async_runtime::spawn(initialize(app)); + builder.mount_events(&handle); + tauri::async_runtime::spawn(initialize(handle)); Ok(()) }); @@ -314,19 +730,56 @@ pub fn run() { .expect("error while running tauri application") .run(|app, event| { if let RunEvent::Exit = event { - println!("Received Exit"); + tracing::info!("Received Exit"); kill_sidecar(app.clone()); } }); } +fn make_specta_builder() -> tauri_specta::Builder { + tauri_specta::Builder::::new() + // Then register them (separated by a comma) + .commands(tauri_specta::collect_commands![ + kill_sidecar, + cli::install_cli, + await_initialization, + server::get_default_server_url, + server::set_default_server_url, + server::get_wsl_config, + server::set_wsl_config, + get_display_backend, + set_display_backend, + markdown::parse_markdown_command, + check_app_exists, + wsl_path, + resolve_app_path + ]) + .events(tauri_specta::collect_events![LoadingWindowComplete]) + .error_handling(tauri_specta::ErrorHandlingMode::Throw) +} + +fn export_types(builder: &tauri_specta::Builder) { + builder + .export( + specta_typescript::Typescript::default(), + "../src/bindings.ts", + ) + .expect("Failed to export typescript bindings"); +} + +#[cfg(test)] +#[test] +fn test_export_types() { + let builder = make_specta_builder(); + export_types(&builder); +} + #[derive(tauri_specta::Event, serde::Deserialize, specta::Type)] struct LoadingWindowComplete; -// #[tracing::instrument(skip_all)] async fn initialize(app: AppHandle) { - println!("Initializing app"); + tracing::info!("Initializing app"); let (init_tx, init_rx) = watch::channel(InitStep::ServerWaiting); @@ -339,9 +792,9 @@ async fn initialize(app: AppHandle) { let loading_window_complete = event_once_fut::(&app); - println!("Main and loading windows created"); + tracing::info!("Main and loading windows created"); - let sqlite_enabled = option_env!("COLI_SQLITE").is_some(); + let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some(); let loading_task = tokio::spawn({ let init_tx = init_tx.clone(); @@ -350,7 +803,7 @@ async fn initialize(app: AppHandle) { async move { let mut sqlite_exists = sqlite_file_exists(); - println!("Setting up server connection"); + tracing::info!("Setting up server connection"); let server_connection = setup_server_connection(app.clone()).await; // we delay spawning this future so that the timeout is created lazily @@ -364,16 +817,24 @@ async fn initialize(app: AppHandle) { let app = app.clone(); Some( async move { - let Ok(Ok(_)) = timeout(Duration::from_secs(30), health_check.0).await - else { + let res = timeout(Duration::from_secs(30), health_check.0).await; + let err = match res { + Ok(Ok(Ok(()))) => None, + Ok(Ok(Err(e))) => Some(e), + Ok(Err(e)) => Some(format!("Health check task failed: {e}")), + Err(_) => Some("Health check timed out".to_string()), + }; + + if let Some(err) = err { let _ = child.kill(); + return Err(format!( - "Failed to spawn OpenCode Server. Logs:\n{}", - get_logs(app.clone()).await.unwrap() + "Failed to spawn OpenCode Server ({err}). Logs:\n{}", + get_logs() )); - }; + } - println!("CLI health check OK"); + tracing::info!("CLI health check OK"); #[cfg(windows)] { @@ -401,11 +862,11 @@ async fn initialize(app: AppHandle) { if let Some(cli_health_check) = cli_health_check { if sqlite_enabled { - println!("Does sqlite file exist: {sqlite_exists}"); + tracing::debug!(sqlite_exists, "Checking sqlite file existence"); if !sqlite_exists { - println!( - "Sqlite file not found at {}, waiting for it to be generated", - coli_db_path().expect("failed to get db path").display() + tracing::info!( + path = %opencode_db_path().expect("failed to get db path").display(), + "Sqlite file not found, waiting for it to be generated" ); let _ = init_tx.send(InitStep::SqliteWaiting); @@ -430,7 +891,7 @@ async fn initialize(app: AppHandle) { .await .is_err() { - println!("Loading task timed out, showing loading window"); + tracing::debug!("Loading task timed out, showing loading window"); let app = app.clone(); let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window"); sleep(Duration::from_secs(1)).await; @@ -443,14 +904,14 @@ async fn initialize(app: AppHandle) { let _ = loading_task.await; - println!("Loading done, completing initialisation"); + tracing::info!("Loading done, completing initialisation"); let _ = init_tx.send(InitStep::Done); if loading_window.is_some() { loading_window_complete.await; - println!("Loading window completed"); + tracing::info!("Loading window completed"); } MainWindow::create(&app).expect("Failed to create main window"); @@ -464,9 +925,6 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver) { #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] app.deep_link().register_all().ok(); - // Initialize log state - app.manage(LogState(Arc::new(Mutex::new(VecDeque::new())))); - #[cfg(windows)] app.manage(JobObjectState::new()); @@ -476,7 +934,7 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver) { fn spawn_cli_sync_task(app: AppHandle) { tokio::spawn(async move { if let Err(e) = sync_cli(app) { - eprintln!("Failed to sync CLI: {e}"); + tracing::error!("Failed to sync CLI: {e}"); } }); } @@ -496,12 +954,12 @@ enum ServerConnection { async fn setup_server_connection(app: AppHandle) -> ServerConnection { let custom_url = get_saved_server_url(&app).await; - println!("Attempting server connection to custom url: {custom_url:?}"); + tracing::info!(?custom_url, "Attempting server connection"); if let Some(url) = custom_url && server::check_health_or_ask_retry(&app, &url).await { - println!("Connected to custom server: {}", url); + tracing::info!(%url, "Connected to custom server"); return ServerConnection::Existing { url: url.clone() }; } @@ -509,15 +967,15 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection { let hostname = "127.0.0.1"; let local_url = format!("http://{hostname}:{local_port}"); - println!("Checking health of server '{}'", local_url); + tracing::debug!(url = %local_url, "Checking health of local server"); if server::check_health(&local_url, None).await { - println!("Health check OK, using existing server"); + tracing::info!(url = %local_url, "Health check OK, using existing server"); return ServerConnection::Existing { url: local_url }; } let password = uuid::Uuid::new_v4().to_string(); - println!("Spawning new local server"); + tracing::info!("Spawning new local server"); let (child, health_check) = server::spawn_local_server(app, hostname.to_string(), local_port, password.clone()); @@ -530,9 +988,9 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection { } fn get_sidecar_port() -> u32 { - option_env!("COLI_PORT") + option_env!("OPENCODE_PORT") .map(|s| s.to_string()) - .or_else(|| std::env::var("COLI_PORT").ok()) + .or_else(|| std::env::var("OPENCODE_PORT").ok()) .and_then(|port_str| port_str.parse().ok()) .unwrap_or_else(|| { TcpListener::bind("127.0.0.1:0") @@ -544,14 +1002,14 @@ fn get_sidecar_port() -> u32 { } fn sqlite_file_exists() -> bool { - let Ok(path) = coli_db_path() else { + let Ok(path) = opencode_db_path() else { return true; }; path.exists() } -fn coli_db_path() -> Result { +fn opencode_db_path() -> Result { let xdg_data_home = env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()); let data_home = match xdg_data_home { @@ -562,7 +1020,7 @@ fn coli_db_path() -> Result { } }; - Ok(data_home.join("coli").join("coli.db")) + Ok(data_home.join("opencode").join("opencode.db")) } // Creates a `once` listener for the specified event and returns a future that resolves diff --git a/packages/desktop/src-tauri/src/linux_display.rs b/packages/desktop/src-tauri/src/linux_display.rs index 9ce64a88dac..0179cf8bbb3 100644 --- a/packages/desktop/src-tauri/src/linux_display.rs +++ b/packages/desktop/src-tauri/src/linux_display.rs @@ -14,7 +14,11 @@ struct DisplayConfig { } fn dir() -> Option { - Some(dirs::data_dir()?.join("ai.coli.desktop")) + Some(dirs::data_dir()?.join(if cfg!(debug_assertions) { + "ai.opencode.desktop.dev" + } else { + "ai.opencode.desktop" + })) } fn path() -> Option { @@ -22,10 +26,12 @@ fn path() -> Option { } pub fn read_wayland() -> Option { - let path = path()?; - let raw = std::fs::read_to_string(path).ok()?; - let config = serde_json::from_str::(&raw).ok()?; - config.wayland + let raw = std::fs::read_to_string(path()?).ok()?; + let root = serde_json::from_str::(&raw) + .ok()? + .get(LINUX_DISPLAY_CONFIG_KEY) + .cloned()?; + serde_json::from_value::(root).ok()?.wayland } pub fn write_wayland(app: &AppHandle, value: bool) -> Result<(), String> { diff --git a/packages/desktop/src-tauri/src/logging.rs b/packages/desktop/src-tauri/src/logging.rs new file mode 100644 index 00000000000..f794f9c1bc4 --- /dev/null +++ b/packages/desktop/src-tauri/src/logging.rs @@ -0,0 +1,83 @@ +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt}; + +const MAX_LOG_AGE_DAYS: u64 = 7; +const TAIL_LINES: usize = 1000; + +static LOG_PATH: std::sync::OnceLock = std::sync::OnceLock::new(); + +pub fn init(log_dir: &Path) -> WorkerGuard { + std::fs::create_dir_all(log_dir).expect("failed to create log directory"); + + cleanup(log_dir); + + let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); + let filename = format!("opencode-desktop_{timestamp}.log"); + let log_path = log_dir.join(&filename); + + LOG_PATH + .set(log_path.clone()) + .expect("logging already initialized"); + + let file = File::create(&log_path).expect("failed to create log file"); + let (non_blocking, guard) = tracing_appender::non_blocking(file); + + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| { + if cfg!(debug_assertions) { + EnvFilter::new("opencode_lib=debug,opencode_desktop=debug,sidecar=debug") + } else { + EnvFilter::new("opencode_lib=info,opencode_desktop=info,sidecar=info") + } + }); + + tracing_subscriber::registry() + .with(filter) + .with(fmt::layer().with_writer(std::io::stderr)) + .with( + fmt::layer() + .with_writer(non_blocking) + .with_ansi(false), + ) + .init(); + + guard +} + +pub fn tail() -> String { + let Some(path) = LOG_PATH.get() else { + return String::new(); + }; + + let Ok(file) = File::open(path) else { + return String::new(); + }; + + let lines: Vec = BufReader::new(file) + .lines() + .map_while(Result::ok) + .collect(); + + let start = lines.len().saturating_sub(TAIL_LINES); + lines[start..].join("\n") +} + +fn cleanup(log_dir: &Path) { + let cutoff = std::time::SystemTime::now() + - std::time::Duration::from_secs(MAX_LOG_AGE_DAYS * 24 * 60 * 60); + + let Ok(entries) = std::fs::read_dir(log_dir) else { + return; + }; + + for entry in entries.flatten() { + if let Ok(meta) = entry.metadata() + && let Ok(modified) = meta.modified() + && modified < cutoff + { + let _ = std::fs::remove_file(entry.path()); + } + } +} diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 05c33d6ccbf..9eb86cdacc8 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -23,7 +23,7 @@ fn configure_display_backend() -> Option { return None; } - let prefer_wayland = coli_lib::linux_display::read_wayland().unwrap_or(false); + let prefer_wayland = opencode_lib::linux_display::read_wayland().unwrap_or(false); let allow_wayland = prefer_wayland || matches!( env::var("OC_ALLOW_WAYLAND"), @@ -43,7 +43,7 @@ fn configure_display_backend() -> Option { set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1"); return Some( "Wayland session detected; forcing X11 backend to avoid compositor protocol errors. \ - Set OC_ALLOW_WAYLAND=1 to keep native Wayland." + Set OC_ALLOW_WAYLAND=1 to keep native Wayland." .into(), ); } @@ -86,9 +86,9 @@ fn main() { #[cfg(target_os = "linux")] { if let Some(backend_note) = configure_display_backend() { - eprintln!("{backend_note:?}"); + eprintln!("{backend_note}"); } } - coli_lib::run() + opencode_lib::run() } diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 8ba9d694e25..81e0595af71 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -8,9 +8,20 @@ use tokio::task::JoinHandle; use crate::{ cli, - constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE}, + constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY}, }; +#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug)] +pub struct WslConfig { + pub enabled: bool, +} + +impl Default for WslConfig { + fn default() -> Self { + Self { enabled: false } + } +} + #[tauri::command] #[specta::specta] pub fn get_default_server_url(app: AppHandle) -> Result, String> { @@ -48,16 +59,48 @@ pub async fn set_default_server_url(app: AppHandle, url: Option) -> Resu Ok(()) } +#[tauri::command] +#[specta::specta] +pub fn get_wsl_config(app: AppHandle) -> Result { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + let enabled = store + .get(WSL_ENABLED_KEY) + .as_ref() + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + Ok(WslConfig { enabled }) +} + +#[tauri::command] +#[specta::specta] +pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + store.set(WSL_ENABLED_KEY, serde_json::Value::Bool(config.enabled)); + + store + .save() + .map_err(|e| format!("Failed to save settings: {}", e))?; + + Ok(()) +} + pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option { if let Some(url) = get_default_server_url(app.clone()).ok().flatten() { - println!("Using desktop-specific custom URL: {url}"); + tracing::info!(%url, "Using desktop-specific custom URL"); return Some(url); } if let Some(cli_config) = cli::get_config(app).await && let Some(url) = get_server_url_from_config(&cli_config) { - println!("Using custom server URL from config: {url}"); + tracing::info!(%url, "Using custom server URL from config"); return Some(url); } @@ -70,26 +113,43 @@ pub fn spawn_local_server( port: u32, password: String, ) -> (CommandChild, HealthCheck) { - let child = cli::serve(&app, &hostname, port, &password); + let (child, exit) = cli::serve(&app, &hostname, port, &password); let health_check = HealthCheck(tokio::spawn(async move { let url = format!("http://{hostname}:{port}"); - let timestamp = Instant::now(); - loop { - tokio::time::sleep(Duration::from_millis(100)).await; - if check_health(&url, Some(&password)).await { - println!("Server ready after {:?}", timestamp.elapsed()); - break; + let ready = async { + loop { + tokio::time::sleep(Duration::from_millis(100)).await; + + if check_health(&url, Some(&password)).await { + tracing::info!(elapsed = ?timestamp.elapsed(), "Server ready"); + return Ok(()); + } } + }; + + let terminated = async { + match exit.await { + Ok(payload) => Err(format!( + "Sidecar terminated before becoming healthy (code={:?} signal={:?})", + payload.code, payload.signal + )), + Err(_) => Err("Sidecar terminated before becoming healthy".to_string()), + } + }; + + tokio::select! { + res = ready => res, + res = terminated => res, } })); (child, health_check) } -pub struct HealthCheck(pub JoinHandle<()>); +pub struct HealthCheck(pub JoinHandle>); pub async fn check_health(url: &str, password: Option<&str>) -> bool { let Ok(url) = reqwest::Url::parse(url) else { @@ -115,7 +175,7 @@ pub async fn check_health(url: &str, password: Option<&str>) -> bool { let mut req = client.get(health_url); if let Some(password) = password { - req = req.basic_auth("coli", Some(password)); + req = req.basic_auth("opencode", Some(password)); } req.send() @@ -156,7 +216,7 @@ fn normalize_hostname_for_url(hostname: &str) -> String { fn get_server_url_from_config(config: &cli::Config) -> Option { let server = config.server.as_ref()?; let port = server.port?; - println!("server.port found in OC config: {port}"); + tracing::debug!(port, "server.port found in OC config"); let hostname = server .hostname .as_ref() @@ -167,7 +227,7 @@ fn get_server_url_from_config(config: &cli::Config) -> Option { } pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool { - println!("Checking health for {url}"); + tracing::debug!(%url, "Checking health"); loop { if check_health(url, None).await { return true; diff --git a/packages/desktop/src-tauri/src/windows.rs b/packages/desktop/src-tauri/src/windows.rs index f75f914a525..2ddcb0506d8 100644 --- a/packages/desktop/src-tauri/src/windows.rs +++ b/packages/desktop/src-tauri/src/windows.rs @@ -1,4 +1,7 @@ -use crate::constants::{UPDATER_ENABLED, window_state_flags}; +use crate::{ + constants::{UPDATER_ENABLED, window_state_flags}, + server::get_wsl_config, +}; use std::{ops::Deref, time::Duration}; use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; use tauri_plugin_window_state::AppHandleExt; @@ -22,6 +25,11 @@ impl MainWindow { return Ok(Self(window)); } + let wsl_enabled = get_wsl_config(app.clone()) + .ok() + .map(|v| v.enabled) + .unwrap_or(false); + let window_builder = base_window_config( WebviewWindowBuilder::new(app, Self::LABEL, WebviewUrl::App("/".into())), app, @@ -34,8 +42,9 @@ impl MainWindow { .maximized(true) .initialization_script(format!( r#" - window.__COLI__ ??= {{}}; - window.__COLI__.updaterEnabled = {UPDATER_ENABLED}; + window.__OPENCODE__ ??= {{}}; + window.__OPENCODE__.updaterEnabled = {UPDATER_ENABLED}; + window.__OPENCODE__.wsl = {wsl_enabled}; "# )); diff --git a/packages/desktop/src-tauri/tauri.conf.json b/packages/desktop/src-tauri/tauri.conf.json index 390383c4440..d5ca15b8a71 100644 --- a/packages/desktop/src-tauri/tauri.conf.json +++ b/packages/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCode Dev", - "identifier": "ai.coli.desktop.dev", + "identifier": "ai.opencode.desktop.dev", "mainBinaryName": "OpenCode", "version": "../package.json", "build": { @@ -33,7 +33,7 @@ ], "active": true, "targets": ["deb", "rpm", "dmg", "nsis", "app"], - "externalBin": ["sidecars/coli-cli"], + "externalBin": ["sidecars/opencode-cli"], "linux": { "rpm": { "compression": { @@ -55,7 +55,7 @@ "plugins": { "deep-link": { "desktop": { - "schemes": ["coli"] + "schemes": ["opencode"] } } } diff --git a/packages/desktop/src-tauri/tauri.prod.conf.json b/packages/desktop/src-tauri/tauri.prod.conf.json index d4314926fb2..0416c59cbb9 100644 --- a/packages/desktop/src-tauri/tauri.prod.conf.json +++ b/packages/desktop/src-tauri/tauri.prod.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCode", - "identifier": "ai.coli.desktop", + "identifier": "ai.opencode.desktop", "bundle": { "createUpdaterArtifacts": true, "icon": [ @@ -19,7 +19,7 @@ "linux": { "deb": { "files": { - "/usr/share/metainfo/ai.coli.coli.metainfo.xml": "release/appstream.metainfo.xml" + "/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml" } }, "rpm": { diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 2db1a624cc1..3d588a17155 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -10,10 +10,14 @@ export const commands = { awaitInitialization: (events: Channel) => __TAURI_INVOKE("await_initialization", { events }), getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + getWslConfig: () => __TAURI_INVOKE("get_wsl_config"), + setWslConfig: (config: WslConfig) => __TAURI_INVOKE("set_wsl_config", { config }), getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"), setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE("set_display_backend", { backend }), parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), checkAppExists: (appName: string) => __TAURI_INVOKE("check_app_exists", { appName }), + wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE("wsl_path", { path, mode }), + resolveAppPath: (appName: string) => __TAURI_INVOKE("resolve_app_path", { appName }), }; /** Events */ @@ -33,6 +37,12 @@ export type ServerReadyData = { password: string | null, }; +export type WslConfig = { + enabled: boolean, + }; + +export type WslPathMode = "windows" | "linux"; + /* Tauri Specta runtime */ function makeEvent(name: string) { const base = { diff --git a/packages/desktop/src/i18n/index.ts b/packages/desktop/src/i18n/index.ts index 376769e282b..7b1ebfe696a 100644 --- a/packages/desktop/src/i18n/index.ts +++ b/packages/desktop/src/i18n/index.ts @@ -116,6 +116,15 @@ function parseRecord(value: unknown) { return value as Record } +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 { 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 diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 13d6d149327..ca603da5f97 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -16,7 +16,6 @@ import { open as shellOpen } from "@tauri-apps/plugin-shell" import { type as ostype } from "@tauri-apps/plugin-os" import { check, Update } from "@tauri-apps/plugin-updater" import { getCurrentWindow } from "@tauri-apps/api/window" -import { invoke } from "@tauri-apps/api/core" import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification" import { relaunch } from "@tauri-apps/plugin-process" import { AsyncStorage } from "@solid-primitives/storage" @@ -30,7 +29,7 @@ import { UPDATER_ENABLED } from "./updater" import { initI18n, t } from "./i18n" import pkg from "../package.json" import "./styles.css" -import { commands, InitStep } from "./bindings" +import { commands, InitStep, type WslConfig } from "./bindings" import { Channel } from "@tauri-apps/api/core" import { createMenu } from "./menu" @@ -47,9 +46,9 @@ const deepLinkEvent = "opencode:deep-link" const emitDeepLinks = (urls: string[]) => { if (urls.length === 0) return - window.__COLI__ ??= {} - const pending = window.__COLI__.deepLinks ?? [] - window.__COLI__.deepLinks = [...pending, ...urls] + window.__OPENCODE__ ??= {} + const pending = window.__OPENCODE__.deepLinks ?? [] + window.__OPENCODE__.deepLinks = [...pending, ...urls] window.dispatchEvent(new CustomEvent(deepLinkEvent, { detail: { urls } })) } @@ -59,333 +58,374 @@ const listenForDeepLinks = async () => { await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined) } -const createPlatform = (password: Accessor): Platform => ({ - platform: "desktop", - os: (() => { +const createPlatform = (password: Accessor): Platform => { + const os = (() => { const type = ostype() if (type === "macos" || type === "windows" || type === "linux") return type return undefined - })(), - version: pkg.version, - - async openDirectoryPickerDialog(opts) { - const result = await open({ - directory: true, - multiple: opts?.multiple ?? false, - title: opts?.title ?? t("desktop.dialog.chooseFolder"), - }) - return result - }, - - async openFilePickerDialog(opts) { - const result = await open({ - directory: false, - multiple: opts?.multiple ?? false, - title: opts?.title ?? t("desktop.dialog.chooseFile"), - }) - return result - }, + })() - async saveFilePickerDialog(opts) { - const result = await save({ - title: opts?.title ?? t("desktop.dialog.saveFile"), - defaultPath: opts?.defaultPath, - }) - return result - }, - - openLink(url: string) { - void shellOpen(url).catch(() => undefined) - }, - - openPath(path: string, app?: string) { - return openerOpenPath(path, app) - }, - - back() { - window.history.back() - }, - - forward() { - window.history.forward() - }, - - storage: (() => { - type StoreLike = { - get(key: string): Promise - set(key: string, value: string): Promise - delete(key: string): Promise - clear(): Promise - keys(): Promise - length(): Promise + const wslHome = async () => { + if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined + return commands.wslPath("~", "windows").catch(() => undefined) + } + + const handleWslPicker = async (result: T | null): Promise => { + if (!result || !window.__OPENCODE__?.wsl) return result + if (Array.isArray(result)) { + return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any } + return commands.wslPath(result, "linux").catch(() => result) as any + } + + return { + platform: "desktop", + os, + version: pkg.version, + + async openDirectoryPickerDialog(opts) { + const defaultPath = await wslHome() + const result = await open({ + directory: true, + multiple: opts?.multiple ?? false, + title: opts?.title ?? t("desktop.dialog.chooseFolder"), + defaultPath, + }) + return await handleWslPicker(result) + }, + + async openFilePickerDialog(opts) { + const result = await open({ + directory: false, + multiple: opts?.multiple ?? false, + title: opts?.title ?? t("desktop.dialog.chooseFile"), + }) + return handleWslPicker(result) + }, - const WRITE_DEBOUNCE_MS = 250 + async saveFilePickerDialog(opts) { + const result = await save({ + title: opts?.title ?? t("desktop.dialog.saveFile"), + defaultPath: opts?.defaultPath, + }) + return handleWslPicker(result) + }, + + openLink(url: string) { + void shellOpen(url).catch(() => undefined) + }, + async openPath(path: string, app?: string) { + const os = ostype() + if (os === "windows") { + const resolvedApp = (app && (await commands.resolveAppPath(app))) || app + const resolvedPath = await (async () => { + if (window.__OPENCODE__?.wsl) { + const converted = await commands.wslPath(path, "windows").catch(() => null) + if (converted) return converted + } - const storeCache = new Map>() - const apiCache = new Map Promise }>() - const memoryCache = new Map() + return path + })() + return openerOpenPath(resolvedPath, resolvedApp) + } + return openerOpenPath(path, app) + }, + + back() { + window.history.back() + }, + + forward() { + window.history.forward() + }, + + storage: (() => { + type StoreLike = { + get(key: string): Promise + set(key: string, value: string): Promise + delete(key: string): Promise + clear(): Promise + keys(): Promise + length(): Promise + } - const flushAll = async () => { - const apis = Array.from(apiCache.values()) - await Promise.all(apis.map((api) => api.flush().catch(() => undefined))) - } + const WRITE_DEBOUNCE_MS = 250 - if ("addEventListener" in globalThis) { - const handleVisibility = () => { - if (document.visibilityState !== "hidden") return - void flushAll() + const storeCache = new Map>() + const apiCache = new Map Promise }>() + const memoryCache = new Map() + + const flushAll = async () => { + const apis = Array.from(apiCache.values()) + await Promise.all(apis.map((api) => api.flush().catch(() => undefined))) } - window.addEventListener("pagehide", () => void flushAll()) - document.addEventListener("visibilitychange", handleVisibility) - } + if ("addEventListener" in globalThis) { + const handleVisibility = () => { + if (document.visibilityState !== "hidden") return + void flushAll() + } - const createMemoryStore = () => { - const data = new Map() - const store: StoreLike = { - get: async (key) => data.get(key), - set: async (key, value) => { - data.set(key, value) - }, - delete: async (key) => { - data.delete(key) - }, - clear: async () => { - data.clear() - }, - keys: async () => Array.from(data.keys()), - length: async () => data.size, + window.addEventListener("pagehide", () => void flushAll()) + document.addEventListener("visibilitychange", handleVisibility) } - return store - } - const getStore = (name: string) => { - const cached = storeCache.get(name) - if (cached) return cached + const createMemoryStore = () => { + const data = new Map() + const store: StoreLike = { + get: async (key) => data.get(key), + set: async (key, value) => { + data.set(key, value) + }, + delete: async (key) => { + data.delete(key) + }, + clear: async () => { + data.clear() + }, + keys: async () => Array.from(data.keys()), + length: async () => data.size, + } + return store + } - const store = Store.load(name).catch(() => { - const cached = memoryCache.get(name) + const getStore = (name: string) => { + const cached = storeCache.get(name) if (cached) return cached - const memory = createMemoryStore() - memoryCache.set(name, memory) - return memory - }) - - storeCache.set(name, store) - return store - } + const store = Store.load(name).catch(() => { + const cached = memoryCache.get(name) + if (cached) return cached - const createStorage = (name: string) => { - const pending = new Map() - let timer: ReturnType | undefined - let flushing: Promise | undefined + const memory = createMemoryStore() + memoryCache.set(name, memory) + return memory + }) - const flush = async () => { - if (flushing) return flushing + storeCache.set(name, store) + return store + } - flushing = (async () => { - const store = await getStore(name) - while (pending.size > 0) { - const batch = Array.from(pending.entries()) - pending.clear() - for (const [key, value] of batch) { - if (value === null) { - await store.delete(key).catch(() => undefined) - } else { - await store.set(key, value).catch(() => undefined) + const createStorage = (name: string) => { + const pending = new Map() + let timer: ReturnType | undefined + let flushing: Promise | undefined + + const flush = async () => { + if (flushing) return flushing + + flushing = (async () => { + const store = await getStore(name) + while (pending.size > 0) { + const batch = Array.from(pending.entries()) + pending.clear() + for (const [key, value] of batch) { + if (value === null) { + await store.delete(key).catch(() => undefined) + } else { + await store.set(key, value).catch(() => undefined) + } } } - } - })().finally(() => { - flushing = undefined - }) + })().finally(() => { + flushing = undefined + }) - return flushing - } + return flushing + } - const schedule = () => { - if (timer) return - timer = setTimeout(() => { - timer = undefined - void flush() - }, WRITE_DEBOUNCE_MS) - } + const schedule = () => { + if (timer) return + timer = setTimeout(() => { + timer = undefined + void flush() + }, WRITE_DEBOUNCE_MS) + } - const api: AsyncStorage & { flush: () => Promise } = { - flush, - getItem: async (key: string) => { - const next = pending.get(key) - if (next !== undefined) return next - - const store = await getStore(name) - const value = await store.get(key).catch(() => null) - if (value === undefined) return null - return value - }, - setItem: async (key: string, value: string) => { - pending.set(key, value) - schedule() - }, - removeItem: async (key: string) => { - pending.set(key, null) - schedule() - }, - clear: async () => { - pending.clear() - const store = await getStore(name) - await store.clear().catch(() => undefined) - }, - key: async (index: number) => { - const store = await getStore(name) - return (await store.keys().catch(() => []))[index] - }, - getLength: async () => { - const store = await getStore(name) - return await store.length().catch(() => 0) - }, - get length() { - return api.getLength() - }, - } + const api: AsyncStorage & { flush: () => Promise } = { + flush, + getItem: async (key: string) => { + const next = pending.get(key) + if (next !== undefined) return next + + const store = await getStore(name) + const value = await store.get(key).catch(() => null) + if (value === undefined) return null + return value + }, + setItem: async (key: string, value: string) => { + pending.set(key, value) + schedule() + }, + removeItem: async (key: string) => { + pending.set(key, null) + schedule() + }, + clear: async () => { + pending.clear() + const store = await getStore(name) + await store.clear().catch(() => undefined) + }, + key: async (index: number) => { + const store = await getStore(name) + return (await store.keys().catch(() => []))[index] + }, + getLength: async () => { + const store = await getStore(name) + return await store.length().catch(() => 0) + }, + get length() { + return api.getLength() + }, + } - return api - } + return api + } - return (name = "default.dat") => { - const cached = apiCache.get(name) - if (cached) return cached + return (name = "default.dat") => { + const cached = apiCache.get(name) + if (cached) return cached - const api = createStorage(name) - apiCache.set(name, api) - return api - } - })(), - - checkUpdate: async () => { - if (!UPDATER_ENABLED) return { updateAvailable: false } - const next = await check().catch(() => null) - if (!next) return { updateAvailable: false } - const ok = await next - .download() - .then(() => true) - .catch(() => false) - if (!ok) return { updateAvailable: false } - update = next - return { updateAvailable: true, version: next.version } - }, - - update: async () => { - if (!UPDATER_ENABLED || !update) return - if (ostype() === "windows") await commands.killSidecar().catch(() => undefined) - await update.install().catch(() => undefined) - }, - - restart: async () => { - await commands.killSidecar().catch(() => undefined) - await relaunch() - }, - - notify: async (title, description, href) => { - const granted = await isPermissionGranted().catch(() => false) - const permission = granted ? "granted" : await requestPermission().catch(() => "denied") - if (permission !== "granted") return - - const win = getCurrentWindow() - const focused = await win.isFocused().catch(() => document.hasFocus()) - if (focused) return - - await Promise.resolve() - .then(() => { - const notification = new Notification(title, { - body: description ?? "", - icon: "https://opencode.ai/favicon-96x96-v3.png", - }) - notification.onclick = () => { - const win = getCurrentWindow() - void win.show().catch(() => undefined) - void win.unminimize().catch(() => undefined) - void win.setFocus().catch(() => undefined) - if (href) { - window.history.pushState(null, "", href) - window.dispatchEvent(new PopStateEvent("popstate")) + const api = createStorage(name) + apiCache.set(name, api) + return api + } + })(), + + checkUpdate: async () => { + if (!UPDATER_ENABLED) return { updateAvailable: false } + const next = await check().catch(() => null) + if (!next) return { updateAvailable: false } + const ok = await next + .download() + .then(() => true) + .catch(() => false) + if (!ok) return { updateAvailable: false } + update = next + return { updateAvailable: true, version: next.version } + }, + + update: async () => { + if (!UPDATER_ENABLED || !update) return + if (ostype() === "windows") await commands.killSidecar().catch(() => undefined) + await update.install().catch(() => undefined) + }, + + restart: async () => { + await commands.killSidecar().catch(() => undefined) + await relaunch() + }, + + notify: async (title, description, href) => { + const granted = await isPermissionGranted().catch(() => false) + const permission = granted ? "granted" : await requestPermission().catch(() => "denied") + if (permission !== "granted") return + + const win = getCurrentWindow() + const focused = await win.isFocused().catch(() => document.hasFocus()) + if (focused) return + + await Promise.resolve() + .then(() => { + const notification = new Notification(title, { + body: description ?? "", + icon: "https://opencode.ai/favicon-96x96-v3.png", + }) + notification.onclick = () => { + const win = getCurrentWindow() + void win.show().catch(() => undefined) + void win.unminimize().catch(() => undefined) + void win.setFocus().catch(() => undefined) + if (href) { + window.history.pushState(null, "", href) + window.dispatchEvent(new PopStateEvent("popstate")) + } + notification.close() } - notification.close() - } - }) - .catch(() => undefined) - }, + }) + .catch(() => undefined) + }, - fetch: (input, init) => { - const pw = password() + fetch: (input, init) => { + const pw = password() - const addHeader = (headers: Headers, password: string) => { - headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`) - } + const addHeader = (headers: Headers, password: string) => { + headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`) + } - if (input instanceof Request) { - if (pw) addHeader(input.headers, pw) - return tauriFetch(input) - } else { - const headers = new Headers(init?.headers) - if (pw) addHeader(headers, pw) - return tauriFetch(input, { - ...(init as any), - headers: headers, + if (input instanceof Request) { + if (pw) addHeader(input.headers, pw) + return tauriFetch(input) + } else { + const headers = new Headers(init?.headers) + if (pw) addHeader(headers, pw) + return tauriFetch(input, { + ...(init as any), + headers: headers, + }) + } + }, + + getWslEnabled: async () => { + const next = await commands.getWslConfig().catch(() => null) + if (next) return next.enabled + return window.__OPENCODE__!.wsl ?? false + }, + + setWslEnabled: async (enabled) => { + await commands.setWslConfig({ enabled }) + }, + + getDefaultServerUrl: async () => { + const result = await commands.getDefaultServerUrl().catch(() => null) + return result + }, + + setDefaultServerUrl: async (url: string | null) => { + await commands.setDefaultServerUrl(url) + }, + + getDisplayBackend: async () => { + const result = await commands.getDisplayBackend().catch(() => null) + return result + }, + + setDisplayBackend: async (backend) => { + await commands.setDisplayBackend(backend) + }, + + parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), + + webviewZoom, + + checkAppExists: async (appName: string) => { + return commands.checkAppExists(appName) + }, + + async readClipboardImage() { + const image = await readImage().catch(() => null) + if (!image) return null + const bytes = await image.rgba().catch(() => null) + if (!bytes || bytes.length === 0) return null + const size = await image.size().catch(() => null) + if (!size) return null + const canvas = document.createElement("canvas") + canvas.width = size.width + canvas.height = size.height + const ctx = canvas.getContext("2d") + if (!ctx) return null + const imageData = ctx.createImageData(size.width, size.height) + imageData.data.set(bytes) + ctx.putImageData(imageData, 0, 0) + return new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) return resolve(null) + resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })) + }, "image/png") }) - } - }, - - getDefaultServerUrl: async () => { - const result = await commands.getDefaultServerUrl().catch(() => null) - return result - }, - - setDefaultServerUrl: async (url: string | null) => { - await commands.setDefaultServerUrl(url) - }, - - getDisplayBackend: async () => { - const result = await invoke("get_display_backend").catch(() => null) - return result - }, - - setDisplayBackend: async (backend) => { - await invoke("set_display_backend", { backend }).catch(() => undefined) - }, - - parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), - - webviewZoom, - - checkAppExists: async (appName: string) => { - return commands.checkAppExists(appName) - }, - - async readClipboardImage() { - const image = await readImage().catch(() => null) - if (!image) return null - const bytes = await image.rgba().catch(() => null) - if (!bytes || bytes.length === 0) return null - const size = await image.size().catch(() => null) - if (!size) return null - const canvas = document.createElement("canvas") - canvas.width = size.width - canvas.height = size.height - const ctx = canvas.getContext("2d") - if (!ctx) return null - const imageData = ctx.createImageData(size.width, size.height) - imageData.data.set(bytes) - ctx.putImageData(imageData, 0, 0) - return new Promise((resolve) => { - canvas.toBlob((blob) => { - if (!blob) return resolve(null) - resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })) - }, "image/png") - }) - }, -}) + }, + } +} let menuTrigger = null as null | ((id: string) => void) createMenu((id) => { @@ -395,6 +435,7 @@ void listenForDeepLinks() render(() => { const [serverPassword, setServerPassword] = createSignal(null) + const platform = createPlatform(() => serverPassword()) function handleClick(e: MouseEvent) { @@ -418,8 +459,8 @@ render(() => { {(data) => { setServerPassword(data().password) - window.__COLI__ ??= {} - window.__COLI__.serverPassword = data().password ?? undefined + window.__OPENCODE__ ??= {} + window.__OPENCODE__.serverPassword = data().password ?? undefined function Inner() { const cmd = useCommand() diff --git a/packages/desktop/src/updater.ts b/packages/desktop/src/updater.ts index 59482b2dba7..73266963388 100644 --- a/packages/desktop/src/updater.ts +++ b/packages/desktop/src/updater.ts @@ -6,7 +6,7 @@ import { type as ostype } from "@tauri-apps/plugin-os" import { initI18n, t } from "./i18n" import { commands } from "./bindings" -export const UPDATER_ENABLED = window.__COLI__?.updaterEnabled ?? false +export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) { await initI18n() diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 289d8fcb8d6..3a20733b655 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.53", + "version": "1.1.57", "private": true, "type": "module", "license": "MIT", diff --git a/packages/enterprise/src/core/storage.ts b/packages/enterprise/src/core/storage.ts index bf42ae60a84..b8030b4f901 100644 --- a/packages/enterprise/src/core/storage.ts +++ b/packages/enterprise/src/core/storage.ts @@ -64,27 +64,27 @@ export namespace Storage { } function s3(): Adapter { - const bucket = process.env.COLI_STORAGE_BUCKET! - const region = process.env.COLI_STORAGE_REGION || "us-east-1" + const bucket = process.env.OPENCODE_STORAGE_BUCKET! + const region = process.env.OPENCODE_STORAGE_REGION || "us-east-1" const client = new AwsClient({ region, - accessKeyId: process.env.COLI_STORAGE_ACCESS_KEY_ID!, - secretAccessKey: process.env.COLI_STORAGE_SECRET_ACCESS_KEY!, + accessKeyId: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID!, + secretAccessKey: process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY!, }) return createAdapter(client, `https://s3.${region}.amazonaws.com`, bucket) } function r2() { - const accountId = process.env.COLI_STORAGE_ACCOUNT_ID! + const accountId = process.env.OPENCODE_STORAGE_ACCOUNT_ID! const client = new AwsClient({ - accessKeyId: process.env.COLI_STORAGE_ACCESS_KEY_ID!, - secretAccessKey: process.env.COLI_STORAGE_SECRET_ACCESS_KEY!, + accessKeyId: process.env.OPENCODE_STORAGE_ACCESS_KEY_ID!, + secretAccessKey: process.env.OPENCODE_STORAGE_SECRET_ACCESS_KEY!, }) - return createAdapter(client, `https://${accountId}.r2.cloudflarestorage.com`, process.env.COLI_STORAGE_BUCKET!) + return createAdapter(client, `https://${accountId}.r2.cloudflarestorage.com`, process.env.OPENCODE_STORAGE_BUCKET!) } const adapter = lazy(() => { - const type = process.env.COLI_STORAGE_ADAPTER + const type = process.env.OPENCODE_STORAGE_ADAPTER if (type === "r2") return r2() if (type === "s3") return s3() throw new Error("No storage adapter configured") diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index b9dc3668e8c..a2607891c8a 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -206,7 +206,7 @@ export default function () { modelParam = "unknown" } const version = `v${info().version}` - return `https://social-cards.sst.dev/coli-share/${encodedTitle}.png?model=${modelParam}&version=${version}&id=${data().shareID}` + return `https://social-cards.sst.dev/opencode-share/${encodedTitle}.png?model=${modelParam}&version=${version}&id=${data().shareID}` }) return ( @@ -214,7 +214,7 @@ export default function () { {info().title} | OpenCode - + diff --git a/packages/enterprise/vite.config.ts b/packages/enterprise/vite.config.ts index 2edbcedfc08..11ca1729dfe 100644 --- a/packages/enterprise/vite.config.ts +++ b/packages/enterprise/vite.config.ts @@ -4,7 +4,7 @@ import { nitro } from "nitro/vite" import tailwindcss from "@tailwindcss/vite" const nitroConfig: any = (() => { - const target = process.env.COLI_DEPLOYMENT_TARGET + const target = process.env.OPENCODE_DEPLOYMENT_TARGET if (target === "cloudflare") { return { compatibilityDate: "2024-09-19", @@ -23,7 +23,7 @@ export default defineConfig({ solidStart() as PluginOption, nitro({ ...nitroConfig, - baseURL: process.env.COLI_BASE_URL, + baseURL: process.env.OPENCODE_BASE_URL, }), ], server: { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index f924474bd93..1df1f9a5065 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,36 +1,36 @@ -id = "coli" -name = "Coli" +id = "opencode" +name = "OpenCode" description = "The open source coding agent." -version = "1.1.53" +version = "1.1.57" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" -[agent_servers.coli] -name = "Coli" -icon = "./icons/coli.svg" +[agent_servers.opencode] +name = "OpenCode" +icon = "./icons/opencode.svg" -[agent_servers.coli.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/coli-darwin-arm64.zip" -cmd = "./coli" +[agent_servers.opencode.targets.darwin-aarch64] +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.57/opencode-darwin-arm64.zip" +cmd = "./opencode" args = ["acp"] -[agent_servers.coli.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/coli-darwin-x64.zip" -cmd = "./coli" +[agent_servers.opencode.targets.darwin-x86_64] +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.57/opencode-darwin-x64.zip" +cmd = "./opencode" args = ["acp"] -[agent_servers.coli.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/coli-linux-arm64.tar.gz" -cmd = "./coli" +[agent_servers.opencode.targets.linux-aarch64] +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.57/opencode-linux-arm64.tar.gz" +cmd = "./opencode" args = ["acp"] -[agent_servers.coli.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/coli-linux-x64.tar.gz" -cmd = "./coli" +[agent_servers.opencode.targets.linux-x86_64] +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.57/opencode-linux-x64.tar.gz" +cmd = "./opencode" args = ["acp"] -[agent_servers.coli.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/coli-windows-x64.zip" -cmd = "./coli.exe" +[agent_servers.opencode.targets.windows-x86_64] +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.57/opencode-windows-x64.zip" +cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index b3086a180db..2a2b7862302 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.53", + "version": "1.1.57", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml index db64a09a988..c4617527d03 100644 --- a/packages/opencode/bunfig.toml +++ b/packages/opencode/bunfig.toml @@ -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 diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 48e898eea4d..d4568eb0a03 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.53", - "name": "coli", + "version": "1.1.57", + "name": "opencode", "type": "module", "license": "MIT", "private": true, @@ -18,7 +18,7 @@ "deploy": "echo 'Deploying application...' && bun run build && echo 'Deployment completed successfully'" }, "bin": { - "coli": "./bin/coli" + "opencode": "./bin/opencode" }, "randomField": "this-is-a-random-value-12345", "exports": { diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index a775f687589..f0b3fa828a7 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -14,7 +14,7 @@ process.chdir(dir) import pkg from "../package.json" import { Script } from "@opencode-ai/script" -const modelsUrl = process.env.COLI_MODELS_URL || "https://models.dev" +const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" // Fetch and generate models.dev snapshot const modelsData = process.env.MODELS_DEV_API_JSON ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() @@ -149,17 +149,17 @@ for (const item of targets) { autoloadTsconfig: true, autoloadPackageJson: true, target: name.replace(pkg.name, "bun") as any, - outfile: `dist/${name}/bin/coli`, - execArgv: [`--user-agent=coli/${Script.version}`, "--use-system-ca", "--"], + outfile: `dist/${name}/bin/opencode`, + execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"], windows: {}, }, entrypoints: ["./src/index.ts", parserWorker, workerPath], define: { - COLI_VERSION: `'${Script.version}'`, + OPENCODE_VERSION: `'${Script.version}'`, OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath, - COLI_WORKER_PATH: workerPath, - COLI_CHANNEL: `'${Script.channel}'`, - COLI_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", + OPENCODE_WORKER_PATH: workerPath, + OPENCODE_CHANNEL: `'${Script.channel}'`, + OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "", }, }) diff --git a/packages/opencode/script/publish.ts b/packages/opencode/script/publish.ts index 0279b72b3ba..fbc1c83ba6d 100755 --- a/packages/opencode/script/publish.ts +++ b/packages/opencode/script/publish.ts @@ -49,7 +49,7 @@ const tasks = Object.entries(binaries).map(async ([name]) => { await Promise.all(tasks) await $`cd ./dist/${pkg.name} && bun pm pack && npm publish *.tgz --access public --tag ${Script.channel}` -const image = "ghcr.io/anomalyco/coli" +const image = "ghcr.io/anomalyco/opencode" const platforms = "linux/amd64,linux/arm64" const tags = [`${image}:${version}`, `${image}:${Script.channel}`] const tagFlags = tags.flatMap((t) => ["-t", t]) @@ -58,10 +58,10 @@ await $`docker buildx build --platform ${platforms} ${tagFlags} --push .` // registries if (!Script.preview) { // Calculate SHA values - const arm64Sha = await $`sha256sum ./dist/coli-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) - const x64Sha = await $`sha256sum ./dist/coli-linux-x64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) - const macX64Sha = await $`sha256sum ./dist/coli-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) - const macArm64Sha = await $`sha256sum ./dist/coli-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) + const arm64Sha = await $`sha256sum ./dist/opencode-linux-arm64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) + const x64Sha = await $`sha256sum ./dist/opencode-linux-x64.tar.gz | cut -d' ' -f1`.text().then((x) => x.trim()) + const macX64Sha = await $`sha256sum ./dist/opencode-darwin-x64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) + const macArm64Sha = await $`sha256sum ./dist/opencode-darwin-arm64.zip | cut -d' ' -f1`.text().then((x) => x.trim()) const [pkgver, _subver = ""] = Script.version.split(/(-.*)/, 2) @@ -70,7 +70,7 @@ if (!Script.preview) { "# Maintainer: dax", "# Maintainer: adam", "", - "pkgname='coli-bin'", + "pkgname='opencode-bin'", `pkgver=${pkgver}`, `_subver=${_subver}`, "options=('!debug' '!strip')", @@ -79,23 +79,23 @@ if (!Script.preview) { "url='https://github.com/anomalyco/opencode'", "arch=('aarch64' 'x86_64')", "license=('MIT')", - "provides=('coli')", - "conflicts=('coli')", + "provides=('opencode')", + "conflicts=('opencode')", "depends=('ripgrep')", "", - `source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/coli-linux-arm64.tar.gz")`, + `source_aarch64=("\${pkgname}_\${pkgver}_aarch64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-arm64.tar.gz")`, `sha256sums_aarch64=('${arm64Sha}')`, - `source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/coli-linux-x64.tar.gz")`, + `source_x86_64=("\${pkgname}_\${pkgver}_x86_64.tar.gz::https://github.com/anomalyco/opencode/releases/download/v\${pkgver}\${_subver}/opencode-linux-x64.tar.gz")`, `sha256sums_x86_64=('${x64Sha}')`, "", "package() {", - ' install -Dm755 ./coli "${pkgdir}/usr/bin/coli"', + ' install -Dm755 ./opencode "${pkgdir}/usr/bin/opencode"', "}", "", ].join("\n") - for (const [pkg, pkgbuild] of [["coli-bin", binaryPkgbuild]]) { + for (const [pkg, pkgbuild] of [["opencode-bin", binaryPkgbuild]]) { for (let i = 0; i < 30; i++) { try { await $`rm -rf ./dist/aur-${pkg}` @@ -128,36 +128,36 @@ if (!Script.preview) { "", " on_macos do", " if Hardware::CPU.intel?", - ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/coli-darwin-x64.zip"`, + ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-x64.zip"`, ` sha256 "${macX64Sha}"`, "", " def install", - ' bin.install "coli"', + ' bin.install "opencode"', " end", " end", " if Hardware::CPU.arm?", - ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/coli-darwin-arm64.zip"`, + ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-darwin-arm64.zip"`, ` sha256 "${macArm64Sha}"`, "", " def install", - ' bin.install "coli"', + ' bin.install "opencode"', " end", " end", " end", "", " on_linux do", " if Hardware::CPU.intel? and Hardware::CPU.is_64_bit?", - ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/coli-linux-x64.tar.gz"`, + ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-x64.tar.gz"`, ` sha256 "${x64Sha}"`, " def install", - ' bin.install "coli"', + ' bin.install "opencode"', " end", " end", " if Hardware::CPU.arm? and Hardware::CPU.is_64_bit?", - ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/coli-linux-arm64.tar.gz"`, + ` url "https://github.com/anomalyco/opencode/releases/download/v${Script.version}/opencode-linux-arm64.tar.gz"`, ` sha256 "${arm64Sha}"`, " def install", - ' bin.install "coli"', + ' bin.install "opencode"', " end", " end", " end", @@ -174,8 +174,8 @@ if (!Script.preview) { const tap = `https://x-access-token:${token}@github.com/anomalyco/homebrew-tap.git` await $`rm -rf ./dist/homebrew-tap` await $`git clone ${tap} ./dist/homebrew-tap` - await Bun.file("./dist/homebrew-tap/coli.rb").write(homebrewFormula) - await $`cd ./dist/homebrew-tap && git add coli.rb` + await Bun.file("./dist/homebrew-tap/opencode.rb").write(homebrewFormula) + await $`cd ./dist/homebrew-tap && git add opencode.rb` await $`cd ./dist/homebrew-tap && git commit -m "Update to v${Script.version}"` await $`cd ./dist/homebrew-tap && git push` } diff --git a/packages/opencode/script/seed-e2e.ts b/packages/opencode/script/seed-e2e.ts index b8fc7663e79..ba2155cb692 100644 --- a/packages/opencode/script/seed-e2e.ts +++ b/packages/opencode/script/seed-e2e.ts @@ -1,9 +1,9 @@ -const dir = process.env.COLI_E2E_PROJECT_DIR ?? process.cwd() -const title = process.env.COLI_E2E_SESSION_TITLE ?? "E2E Session" -const text = process.env.COLI_E2E_MESSAGE ?? "Seeded for UI e2e" -const model = process.env.COLI_E2E_MODEL ?? "coli/gpt-5-nano" +const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd() +const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session" +const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e" +const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano" const parts = model.split("/") -const providerID = parts[0] ?? "coli" +const providerID = parts[0] ?? "opencode" const modelID = parts[1] ?? "gpt-5-nano" const now = Date.now() diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index fb637da7217..ae6f6fcc296 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -485,16 +485,16 @@ export namespace ACP { log.info("initialize", { protocolVersion: params.protocolVersion }) const authMethod: AuthMethod = { - description: "Run `coli auth login` in the terminal", - name: "Login with coli", - id: "coli-login", + description: "Run `opencode auth login` in the terminal", + name: "Login with opencode", + id: "opencode-login", } // If client supports terminal-auth capability, use that instead. if (params.clientCapabilities?._meta?.["terminal-auth"] === true) { authMethod._meta = { "terminal-auth": { - command: "coli", + command: "opencode", args: ["auth", "login"], label: "OpenCode Login", }, @@ -1497,12 +1497,12 @@ export namespace ACP { if (specified && !providers.length) return specified - const coliProvider = providers.find((p) => p.id === "coli") - if (coliProvider) { - if (coliProvider.models["big-pickle"]) { - return { providerID: "coli", modelID: "big-pickle" } + const opencodeProvider = providers.find((p) => p.id === "opencode") + if (opencodeProvider) { + if (opencodeProvider.models["big-pickle"]) { + return { providerID: "opencode", modelID: "big-pickle" } } - const [best] = Provider.sort(Object.values(coliProvider.models)) + const [best] = Provider.sort(Object.values(opencodeProvider.models)) if (best) { return { providerID: best.providerID, @@ -1522,7 +1522,7 @@ export namespace ACP { if (specified) return specified - return { providerID: "coli", modelID: "big-pickle" } + return { providerID: "opencode", modelID: "big-pickle" } } function parseUri( @@ -1634,7 +1634,7 @@ export namespace ACP { availableVariants: string[] }) { return { - coli: { + opencode: { modelId: `${input.model.providerID}/${input.model.modelID}`, variant: input.variant ?? null, availableVariants: input.availableVariants, diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 3513ab1c30a..e338559be7e 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -103,7 +103,7 @@ export namespace Agent { }, edit: { "*": "deny", - [path.join(".coli", "plans", "*.md")]: "allow", + [path.join(".opencode", "plans", "*.md")]: "allow", [path.relative(Instance.worktree, path.join(Global.Path.data, path.join("plans", "*.md")))]: "allow", }, }), diff --git a/packages/opencode/src/agent/prompt/compaction.txt b/packages/opencode/src/agent/prompt/compaction.txt index b919671a0ac..3308627e153 100644 --- a/packages/opencode/src/agent/prompt/compaction.txt +++ b/packages/opencode/src/agent/prompt/compaction.txt @@ -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. diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index 737ab30e233..ce948b92ac8 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -2,7 +2,7 @@ import path from "path" import { Global } from "../global" import z from "zod" -export const OAUTH_DUMMY_KEY = "coli-oauth-dummy-key" +export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key" export namespace Auth { export const Oauth = z diff --git a/packages/opencode/src/cli/cmd/acp.ts b/packages/opencode/src/cli/cmd/acp.ts index b6e39a47ccc..99a9a81ab9c 100644 --- a/packages/opencode/src/cli/cmd/acp.ts +++ b/packages/opencode/src/cli/cmd/acp.ts @@ -20,7 +20,7 @@ export const AcpCommand = cmd({ }) }, handler: async (args) => { - process.env.COLI_CLIENT = "acp" + process.env.OPENCODE_CLIENT = "acp" await bootstrap(process.cwd(), async () => { const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) diff --git a/packages/opencode/src/cli/cmd/agent.ts b/packages/opencode/src/cli/cmd/agent.ts index 351397280b0..e5da9fdb386 100644 --- a/packages/opencode/src/cli/cmd/agent.ts +++ b/packages/opencode/src/cli/cmd/agent.ts @@ -98,7 +98,7 @@ const AgentCreateCommand = cmd({ scope = scopeResult } targetPath = path.join( - scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".coli"), + scope === "global" ? Global.Path.config : path.join(Instance.worktree, ".opencode"), "agent", ) } diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts index 020fff04dfa..34e2269d0c1 100644 --- a/packages/opencode/src/cli/cmd/auth.ts +++ b/packages/opencode/src/cli/cmd/auth.ts @@ -219,7 +219,7 @@ export const AuthLoginCommand = cmd({ describe: "log in to a provider", builder: (yargs) => yargs.positional("url", { - describe: "coli auth provider", + describe: "opencode auth provider", type: "string", }), async handler(args) { @@ -229,7 +229,7 @@ export const AuthLoginCommand = cmd({ UI.empty() prompts.intro("Add credential") if (args.url) { - const wellknown = await fetch(`${args.url}/.well-known/coli`).then((x) => x.json() as any) + const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any) prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``) const proc = Bun.spawn({ cmd: wellknown.auth.command, @@ -269,7 +269,7 @@ export const AuthLoginCommand = cmd({ }) const priority: Record = { - coli: 0, + opencode: 0, anthropic: 1, "github-copilot": 2, openai: 3, @@ -292,7 +292,7 @@ export const AuthLoginCommand = cmd({ label: x.name, value: x.id, hint: { - coli: "recommended", + opencode: "recommended", anthropic: "Claude Max or API key", openai: "ChatGPT Plus/Pro or API key", }[x.id], @@ -330,7 +330,7 @@ export const AuthLoginCommand = cmd({ } prompts.log.warn( - `This only stores a credential for ${provider} - you will need configure it in coli.json, check the docs for examples.`, + `This only stores a credential for ${provider} - you will need configure it in opencode.json, check the docs for examples.`, ) } @@ -339,12 +339,12 @@ export const AuthLoginCommand = cmd({ "Amazon Bedrock authentication priority:\n" + " 1. Bearer token (AWS_BEARER_TOKEN_BEDROCK or /connect)\n" + " 2. AWS credential chain (profile, access keys, IAM roles, EKS IRSA)\n\n" + - "Configure via coli.json options (profile, region, endpoint) or\n" + + "Configure via opencode.json options (profile, region, endpoint) or\n" + "AWS environment variables (AWS_PROFILE, AWS_REGION, AWS_ACCESS_KEY_ID, AWS_WEB_IDENTITY_TOKEN_FILE).", ) } - if (provider === "coli") { + if (provider === "opencode") { prompts.log.info("Create an api key at https://opencode.ai/auth") } diff --git a/packages/opencode/src/cli/cmd/github.ts b/packages/opencode/src/cli/cmd/github.ts index 58885da1416..7f9a03d948a 100644 --- a/packages/opencode/src/cli/cmd/github.ts +++ b/packages/opencode/src/cli/cmd/github.ts @@ -131,9 +131,9 @@ type IssueQueryResponse = { } } -const AGENT_USERNAME = "coli-agent[bot]" +const AGENT_USERNAME = "opencode-agent[bot]" const AGENT_REACTION = "eyes" -const WORKFLOW_FILE = ".github/workflows/coli.yml" +const WORKFLOW_FILE = ".github/workflows/opencode.yml" // Event categories for routing // USER_EVENTS: triggered by user actions, have actor/issueId, support reactions/comments @@ -228,7 +228,7 @@ export const GithubInstallCommand = cmd({ "", " 3. Go to a GitHub issue and comment `/oc summarize` to see the agent in action", "", - " Learn more about the GitHub agent - https://coli.ai/docs/github/#usage-examples", + " Learn more about the GitHub agent - https://opencode.ai/docs/github/#usage-examples", ].join("\n"), ) } @@ -252,7 +252,7 @@ export const GithubInstallCommand = cmd({ async function promptProvider() { const priority: Record = { - coli: 0, + opencode: 0, anthropic: 1, openai: 2, google: 3, @@ -310,7 +310,7 @@ export const GithubInstallCommand = cmd({ if (installation) return s.stop("GitHub app already installed") // Open browser - const url = "https://github.com/apps/coli-agent" + const url = "https://github.com/apps/opencode-agent" const command = process.platform === "darwin" ? `open "${url}"` @@ -362,7 +362,7 @@ export const GithubInstallCommand = cmd({ await Bun.write( path.join(app.root, WORKFLOW_FILE), - `name: coli + `name: opencode on: issue_comment: @@ -371,12 +371,12 @@ on: types: [created] jobs: - coli: + opencode: if: | contains(github.event.comment.body, ' /oc') || startsWith(github.event.comment.body, '/oc') || - contains(github.event.comment.body, ' /coli') || - startsWith(github.event.comment.body, '/coli') + contains(github.event.comment.body, ' /opencode') || + startsWith(github.event.comment.body, '/opencode') runs-on: ubuntu-latest permissions: id-token: write @@ -389,8 +389,8 @@ jobs: with: persist-credentials: false - - name: Run coli - uses: anomalyco/coli/github@latest${envStr} + - name: Run opencode + uses: anomalyco/opencode/github@latest${envStr} with: model: ${provider}/${model}`, ) @@ -459,7 +459,7 @@ export const GithubRunCommand = cmd({ ? (payload as IssueCommentEvent | IssuesEvent).issue.number : (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number const runUrl = `/${owner}/${repo}/actions/runs/${runId}` - const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://coli.ai" + const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai" let appToken: string let octoRest: Octokit @@ -507,7 +507,7 @@ export const GithubRunCommand = cmd({ await addReaction(commentType) } - // Setup coli session + // Setup opencode session const repoData = await fetchRepo() session = await Session.create({ permission: [ @@ -525,7 +525,7 @@ export const GithubRunCommand = cmd({ await Session.share(session.id) return session.id.slice(-8) })() - console.log("coli session", session.id) + console.log("opencode session", session.id) // Handle event types: // REPO_EVENTS (schedule, workflow_dispatch): no issue/PR context, output to logs/PR only @@ -724,7 +724,7 @@ export const GithubRunCommand = cmd({ } const reviewContext = getReviewCommentContext() - const mentions = (process.env["MENTIONS"] || "/coli,/oc") + const mentions = (process.env["MENTIONS"] || "/opencode,/oc") .split(",") .map((m) => m.trim().toLowerCase()) .filter(Boolean) @@ -870,7 +870,7 @@ export const GithubRunCommand = cmd({ } async function chat(message: string, files: PromptFiles = []) { - console.log("Sending message to coli...") + console.log("Sending message to opencode...") const result = await SessionPrompt.prompt({ sessionID: session.id, @@ -954,7 +954,7 @@ export const GithubRunCommand = cmd({ async function getOidcToken() { try { - return await core.getIDToken("coli-github-action") + return await core.getIDToken("opencode-github-action") } catch (error) { console.error("Failed to get OIDC token:", error instanceof Error ? error.message : error) throw new Error( @@ -1055,9 +1055,9 @@ export const GithubRunCommand = cmd({ .join("") if (type === "schedule" || type === "dispatch") { const hex = crypto.randomUUID().slice(0, 6) - return `coli/${type}-${hex}-${timestamp}` + return `opencode/${type}-${hex}-${timestamp}` } - return `coli/${type}${issueId}-${timestamp}` + return `opencode/${type}${issueId}-${timestamp}` } async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) { @@ -1296,9 +1296,9 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"` const titleAlt = encodeURIComponent(session.title.substring(0, 50)) const title64 = Buffer.from(session.title.substring(0, 700), "utf8").toString("base64") - return `${titleAlt}\n` + return `${titleAlt}\n` })() - const shareUrl = shareId ? `[coli session](${shareBaseUrl}/s/${shareId})  |  ` : "" + const shareUrl = shareId ? `[opencode session](${shareBaseUrl}/s/${shareId})  |  ` : "" return `\n\n${image}${shareUrl}[github run](${runUrl})` } @@ -1359,7 +1359,7 @@ query($owner: String!, $repo: String!, $number: Int!) { return [ "", "You are running as a GitHub Action. Important:", - "- Git push and PR creation are handled AUTOMATICALLY by the coli infrastructure after your response", + "- Git push and PR creation are handled AUTOMATICALLY by the opencode infrastructure after your response", "- Do NOT include warnings or disclaimers about GitHub tokens, workflow permissions, or PR creation capabilities", "- Do NOT suggest manual steps for creating PRs or pushing code - this happens automatically", "- Focus only on the code changes and your analysis/response", @@ -1497,7 +1497,7 @@ query($owner: String!, $repo: String!, $number: Int!) { return [ "", "You are running as a GitHub Action. Important:", - "- Git push and PR creation are handled AUTOMATICALLY by the coli infrastructure after your response", + "- Git push and PR creation are handled AUTOMATICALLY by the opencode infrastructure after your response", "- Do NOT include warnings or disclaimers about GitHub tokens, workflow permissions, or PR creation capabilities", "- Do NOT suggest manual steps for creating PRs or pushing code - this happens automatically", "- Focus only on the code changes and your analysis/response", diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 654d658a7a5..95719215e32 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -84,7 +84,7 @@ export const McpListCommand = cmd({ if (servers.length === 0) { prompts.log.warn("No MCP servers configured") - prompts.outro("Add servers with: coli mcp add") + prompts.outro("Add servers with: opencode mcp add") return } @@ -161,7 +161,7 @@ export const McpAuthCommand = cmd({ if (oauthServers.length === 0) { prompts.log.warn("No OAuth-capable MCP servers configured") - prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in coli.json:") + prompts.log.info("Remote MCP servers support OAuth by default. Add a remote server in opencode.json:") prompts.log.info(` "mcp": { "my-server": { @@ -380,11 +380,11 @@ export const McpLogoutCommand = cmd({ }) async function resolveConfigPath(baseDir: string, global = false) { - // Check for existing config files (prefer .jsonc over .json, check .coli/ subdirectory too) - const candidates = [path.join(baseDir, "coli.json"), path.join(baseDir, "coli.jsonc")] + // Check for existing config files (prefer .jsonc over .json, check .opencode/ subdirectory too) + const candidates = [path.join(baseDir, "opencode.json"), path.join(baseDir, "opencode.jsonc")] if (!global) { - candidates.push(path.join(baseDir, ".coli", "coli.json"), path.join(baseDir, ".coli", "coli.jsonc")) + candidates.push(path.join(baseDir, ".opencode", "opencode.json"), path.join(baseDir, ".opencode", "opencode.jsonc")) } for (const candidate of candidates) { @@ -393,7 +393,7 @@ async function resolveConfigPath(baseDir: string, global = false) { } } - // Default to coli.json if none exist + // Default to opencode.json if none exist return candidates[0] } @@ -482,7 +482,7 @@ export const McpAddCommand = cmd({ if (type === "local") { const command = await prompts.text({ message: "Enter command to run", - placeholder: "e.g., coli x @modelcontextprotocol/server-filesystem", + placeholder: "e.g., opencode x @modelcontextprotocol/server-filesystem", validate: (x) => (x && x.length > 0 ? undefined : "Required"), }) if (prompts.isCancel(command)) throw new UI.CancelledError() @@ -663,7 +663,7 @@ export const McpDebugCommand = cmd({ params: { protocolVersion: "2024-11-05", capabilities: {}, - clientInfo: { name: "coli-debug", version: Installation.VERSION }, + clientInfo: { name: "opencode-debug", version: Installation.VERSION }, }, id: 1, }), @@ -704,7 +704,7 @@ export const McpDebugCommand = cmd({ try { const client = new Client({ - name: "coli-debug", + name: "opencode-debug", version: Installation.VERSION, }) await client.connect(transport) diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index b4e8062ea4d..156dae91c67 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -61,10 +61,10 @@ export const ModelsCommand = cmd({ } const providerIDs = Object.keys(providers).sort((a, b) => { - const aIsColi = a.startsWith("coli") - const bIsColi = b.startsWith("coli") - if (aIsColi && !bIsColi) return -1 - if (!aIsColi && bIsColi) return 1 + const aIsOpencode = a.startsWith("opencode") + const bIsOpencode = b.startsWith("opencode") + if (aIsOpencode && !bIsOpencode) return -1 + if (!aIsOpencode && bIsOpencode) return 1 return a.localeCompare(b) }) diff --git a/packages/opencode/src/cli/cmd/pr.ts b/packages/opencode/src/cli/cmd/pr.ts index 9302055c149..d6176572002 100644 --- a/packages/opencode/src/cli/cmd/pr.ts +++ b/packages/opencode/src/cli/cmd/pr.ts @@ -5,7 +5,7 @@ import { $ } from "bun" export const PrCommand = cmd({ command: "pr ", - describe: "fetch and checkout a GitHub PR branch, then run coli", + describe: "fetch and checkout a GitHub PR branch, then run opencode", builder: (yargs) => yargs.positional("number", { type: "number", @@ -63,15 +63,15 @@ export const PrCommand = cmd({ await $`git branch --set-upstream-to=${remoteName}/${headRefName} ${localBranchName}`.nothrow() } - // Check for coli session link in PR body + // Check for opencode session link in PR body if (prInfo && prInfo.body) { const sessionMatch = prInfo.body.match(/https:\/\/opncd\.ai\/s\/([a-zA-Z0-9_-]+)/) if (sessionMatch) { const sessionUrl = sessionMatch[0] - UI.println(`Found coli session: ${sessionUrl}`) + UI.println(`Found opencode session: ${sessionUrl}`) UI.println(`Importing session...`) - const importResult = await $`coli import ${sessionUrl}`.nothrow() + const importResult = await $`opencode import ${sessionUrl}`.nothrow() if (importResult.exitCode === 0) { const importOutput = importResult.text().trim() // Extract session ID from the output (format: "Imported session: ") @@ -88,23 +88,23 @@ export const PrCommand = cmd({ UI.println(`Successfully checked out PR #${prNumber} as branch '${localBranchName}'`) UI.println() - UI.println("Starting coli...") + UI.println("Starting opencode...") UI.println() - // Launch coli TUI with session ID if available + // Launch opencode TUI with session ID if available const { spawn } = await import("child_process") - const coliArgs = sessionId ? ["-s", sessionId] : [] - const coliProcess = spawn("coli", coliArgs, { + const opencodeArgs = sessionId ? ["-s", sessionId] : [] + const opencodeProcess = spawn("opencode", opencodeArgs, { stdio: "inherit", cwd: process.cwd(), }) await new Promise((resolve, reject) => { - coliProcess.on("exit", (code) => { + opencodeProcess.on("exit", (code) => { if (code === 0) resolve() - else reject(new Error(`coli exited with code ${code}`)) + else reject(new Error(`opencode exited with code ${code}`)) }) - coliProcess.on("error", reject) + opencodeProcess.on("error", reject) }) }, }) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 943b376fdb0..163a5820d99 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -214,7 +214,7 @@ function normalizePath(input?: string) { export const RunCommand = cmd({ command: "run [message..]", - describe: "run coli with a message", + describe: "run opencode with a message", builder: (yargs: Argv) => { return yargs .positional("message", { @@ -272,7 +272,7 @@ export const RunCommand = cmd({ }) .option("attach", { type: "string", - describe: "attach to a running coli server (e.g., http://localhost:4096)", + describe: "attach to a running opencode server (e.g., http://localhost:4096)", }) .option("port", { type: "number", @@ -376,7 +376,7 @@ export const RunCommand = cmd({ async function share(sdk: OpencodeClient, sessionID: string) { const cfg = await sdk.config.get() if (!cfg.data) return - if (cfg.data.share !== "auto" && !Flag.COLI_AUTO_SHARE && !args.share) return + if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return const res = await sdk.session.share({ sessionID }).catch((error) => { if (error instanceof Error && error.message.includes("disabled")) { UI.println(UI.Style.TEXT_DANGER_BOLD + "! " + error.message) @@ -591,7 +591,7 @@ export const RunCommand = cmd({ const request = new Request(input, init) return Server.App().fetch(request) }) as typeof globalThis.fetch - const sdk = createOpencodeClient({ baseUrl: "http://coli.internal", fetch: fetchFn }) + const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn }) await execute(sdk) }) }, diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 23510f658b5..bee2c8f711f 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -6,14 +6,14 @@ import { Flag } from "../../flag/flag" export const ServeCommand = cmd({ command: "serve", builder: (yargs) => withNetworkOptions(yargs), - describe: "starts a headless coli server", + describe: "starts a headless opencode server", handler: async (args) => { - if (!Flag.COLI_SERVER_PASSWORD) { - console.log("Warning: COLI_SERVER_PASSWORD is not set; server is unsecured.") + if (!Flag.OPENCODE_SERVER_PASSWORD) { + console.log("Warning: OPENCODE_SERVER_PASSWORD is not set; server is unsecured.") } const opts = await resolveNetworkOptions(args) const server = Server.listen(opts) - console.log(`coli server listening on http://${server.hostname}:${server.port}`) + console.log(`opencode server listening on http://${server.hostname}:${server.port}`) await new Promise(() => {}) await server.stop() }, diff --git a/packages/opencode/src/cli/cmd/session.ts b/packages/opencode/src/cli/cmd/session.ts index aea2eb1ec73..c6a1fd4138f 100644 --- a/packages/opencode/src/cli/cmd/session.ts +++ b/packages/opencode/src/cli/cmd/session.ts @@ -20,8 +20,8 @@ function pagerCmd(): string[] { if (Bun.file(lessOnPath).size) return [lessOnPath, ...lessOptions] } - if (Flag.COLI_GIT_BASH_PATH) { - const less = path.join(Flag.COLI_GIT_BASH_PATH, "..", "..", "usr", "bin", "less.exe") + if (Flag.OPENCODE_GIT_BASH_PATH) { + const less = path.join(Flag.OPENCODE_GIT_BASH_PATH, "..", "..", "usr", "bin", "less.exe") if (Bun.file(less).size) return [less, ...lessOptions] } diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2db93117330..0d5aefe7bc3 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -216,7 +216,7 @@ function App() { // Update terminal window title based on current route and session createEffect(() => { - if (!terminalTitleEnabled() || Flag.COLI_DISABLE_TERMINAL_TITLE) return + if (!terminalTitleEnabled() || Flag.OPENCODE_DISABLE_TERMINAL_TITLE) return if (route.data.type === "home") { renderer.setTerminalTitle("OpenCode") @@ -468,7 +468,7 @@ function App() { { title: "View status", keybind: "status_view", - value: "coli.status", + value: "opencode.status", slash: { name: "status", }, @@ -682,7 +682,7 @@ function App() { toast.show({ variant: "info", title: "Update Available", - message: `Coli v${evt.properties.version} is available. Run 'coli upgrade' to update manually.`, + message: `OpenCode v${evt.properties.version} is available. Run 'opencode upgrade' to update manually.`, duration: 10000, }) }) @@ -693,7 +693,7 @@ function App() { height={dimensions().height} backgroundColor={theme.background} onMouseUp={async () => { - if (Flag.COLI_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) { + if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) { renderer.clearSelection() return } @@ -762,7 +762,7 @@ function ErrorComponent(props: { ) } - issueURL.searchParams.set("coli-version", Installation.VERSION) + issueURL.searchParams.set("opencode-version", Installation.VERSION) const copyIssueURL = () => { Clipboard.copy(issueURL.toString()).then(() => { diff --git a/packages/opencode/src/cli/cmd/tui/attach.ts b/packages/opencode/src/cli/cmd/tui/attach.ts index 5cee4450a4d..e852cb73d4c 100644 --- a/packages/opencode/src/cli/cmd/tui/attach.ts +++ b/packages/opencode/src/cli/cmd/tui/attach.ts @@ -3,7 +3,7 @@ import { tui } from "./app" export const AttachCommand = cmd({ command: "attach ", - describe: "attach to a running coli server", + describe: "attach to a running opencode server", builder: (yargs) => yargs .positional("url", { @@ -23,7 +23,7 @@ export const AttachCommand = cmd({ .option("password", { alias: ["p"], type: "string", - describe: "basic auth password (defaults to COLI_SERVER_PASSWORD)", + describe: "basic auth password (defaults to OPENCODE_SERVER_PASSWORD)", }), handler: async (args) => { const directory = (() => { @@ -37,9 +37,9 @@ export const AttachCommand = cmd({ } })() const headers = (() => { - const password = args.password ?? process.env.COLI_SERVER_PASSWORD + const password = args.password ?? process.env.OPENCODE_SERVER_PASSWORD if (!password) return undefined - const auth = `Basic ${Buffer.from(`coli:${password}`).toString("base64")}` + const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}` return { Authorization: auth } })() await tui({ diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index fb6d7bba7e6..c30b8d12a93 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -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" @@ -11,7 +11,7 @@ import * as fuzzysort from "fuzzysort" export function useConnected() { const sync = useSync() return createMemo(() => - sync.data.provider.some((x) => x.id !== "coli" || Object.values(x.models).some((y) => y.cost?.input !== 0)), + sync.data.provider.some((x) => x.id !== "opencode" || Object.values(x.models).some((y) => y.cost?.input !== 0)), ) } @@ -20,101 +20,56 @@ export function DialogModel(props: { providerID?: string }) { const sync = useSync() const dialog = useDialog() const keybind = useKeybind() - const [ref, setRef] = createSignal>() 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 === "coli" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "coli" ? "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 === "coli" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "coli" ? "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, sortBy( - (provider) => provider.id !== "coli", + (provider) => provider.id !== "opencode", (provider) => provider.name, ), flatMap((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 === "coli" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "coli" ? "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 ( - [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()} /> ) } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 02c31c5e9dc..9682bee4ead 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -15,7 +15,7 @@ import { Clipboard } from "@tui/util/clipboard" import { useToast } from "../ui/toast" const PROVIDER_PRIORITY: Record = { - coli: 0, + opencode: 0, anthropic: 1, "github-copilot": 2, openai: 3, @@ -34,7 +34,7 @@ export function createDialogProviderOptions() { title: provider.name, value: provider.id, description: { - coli: "(Recommended)", + opencode: "(Recommended)", anthropic: "(Claude Max or API key)", openai: "(ChatGPT Plus/Pro or API key)", }[provider.id], @@ -214,7 +214,7 @@ function ApiMethod(props: ApiMethodProps) { title={props.title} placeholder="API key" description={ - props.providerID === "coli" ? ( + props.providerID === "opencode" ? ( OpenCode Zen gives you access to all the best coding models at the cheapest prices with a single API key. diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index fd563cad23d..3b6b5ef2182 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -79,7 +79,7 @@ export function DialogStatus() { {(val) => val().error} Disabled in configuration - Needs authentication (run: coli mcp auth {key}) + Needs authentication (run: opencode mcp auth {key}) {(val) => (val() as { error: string }).error} diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 8576dd5763a..cefef208de4 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -1,5 +1,5 @@ import { BoxRenderable, TextareaRenderable, MouseEvent, PasteEvent, t, dim, fg } from "@opentui/core" -import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, Show, Switch, Match } from "solid-js" +import { createEffect, createMemo, type JSX, onMount, createSignal, onCleanup, on, Show, Switch, Match } from "solid-js" import "opentui-spinner/solid" import { useLocal } from "@tui/context/local" import { useTheme } from "@tui/context/theme" @@ -54,6 +54,7 @@ export type PromptRef = { } const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"] +const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"] export function Prompt(props: PromptProps) { let input: TextareaRenderable @@ -134,6 +135,16 @@ export function Prompt(props: PromptProps) { interrupt: 0, }) + createEffect( + on( + () => props.sessionID, + () => { + setStore("placeholder", Math.floor(Math.random() * PLACEHOLDERS.length)) + }, + { defer: true }, + ), + ) + // Initialize agent/model/variant from last user message when session changes let syncedSessionID: string | undefined createEffect(() => { @@ -736,6 +747,15 @@ export function Prompt(props: PromptProps) { return !!current }) + const placeholderText = createMemo(() => { + if (props.sessionID) return undefined + if (store.mode === "shell") { + const example = SHELL_PLACEHOLDERS[store.placeholder % SHELL_PLACEHOLDERS.length] + return `Run a command... "${example}"` + } + return `Ask anything... "${PLACEHOLDERS[store.placeholder % PLACEHOLDERS.length]}"` + }) + const spinnerDef = createMemo(() => { const color = local.agent.color(local.agent.current().name) return { @@ -797,7 +817,7 @@ export function Prompt(props: PromptProps) { flexGrow={1} >