diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 459ce25d05..52eec90991 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,4 +1,4 @@ -blank_issues_enabled: true +blank_issues_enabled: false contact_links: - name: 💬 Discord Community url: https://discord.gg/opencode diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td new file mode 100644 index 0000000000..df53d1ac2a --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,19 @@ +# Vouched contributors for this project. +# +# See https://github.com/mitchellh/vouch for details. +# +# Syntax: +# - One handle per line (without @), sorted alphabetically. +# - Optional platform prefix: platform:username (e.g., github:user). +# - Denounce with minus prefix: -username or -platform:username. +# - Optional details after a space following the handle. +adamdotdevin +fwang +iamdavidhill +jayair +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 d8a5c8a902..8cf030eceb 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/.github/workflows/compliance-close.yml b/.github/workflows/compliance-close.yml new file mode 100644 index 0000000000..5b424d0adf --- /dev/null +++ b/.github/workflows/compliance-close.yml @@ -0,0 +1,86 @@ +name: compliance-close + +on: + schedule: + # Run every 30 minutes to check for expired compliance windows + - cron: "*/30 * * * *" + workflow_dispatch: + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + close-non-compliant: + runs-on: ubuntu-latest + steps: + - name: Close non-compliant issues and PRs after 2 hours + uses: actions/github-script@v7 + with: + script: | + const { data: items } = await github.rest.issues.listForRepo({ + owner: context.repo.owner, + repo: context.repo.repo, + labels: 'needs:compliance', + state: 'open', + per_page: 100, + }); + + if (items.length === 0) { + core.info('No open issues/PRs with needs:compliance label'); + return; + } + + const now = Date.now(); + const twoHours = 2 * 60 * 60 * 1000; + + for (const item of items) { + const isPR = !!item.pull_request; + const kind = isPR ? 'PR' : 'issue'; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + }); + + const complianceComment = comments.find(c => c.body.includes('')); + if (!complianceComment) continue; + + const commentAge = now - new Date(complianceComment.created_at).getTime(); + if (commentAge < twoHours) { + core.info(`${kind} #${item.number} still within 2-hour window (${Math.round(commentAge / 60000)}m elapsed)`); + continue; + } + + const closeMessage = isPR + ? 'This pull request has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new pull request that follows our guidelines.' + : 'This issue has been automatically closed because it was not updated to meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md) within the 2-hour window.\n\nFeel free to open a new issue that follows our issue templates.'; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + body: closeMessage, + }); + + if (isPR) { + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: item.number, + state: 'closed', + }); + } else { + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: item.number, + state: 'closed', + state_reason: 'not_planned', + }); + } + + core.info(`Closed non-compliant ${kind} #${item.number} after 2-hour window`); + } diff --git a/.github/workflows/daily-issues-recap.yml b/.github/workflows/daily-issues-recap.yml index 79543fcb19..31cf08233b 100644 --- a/.github/workflows/daily-issues-recap.yml +++ b/.github/workflows/daily-issues-recap.yml @@ -48,8 +48,12 @@ jobs: TODAY'S DATE: ${TODAY} STEP 1: Gather today's issues - Search for all issues created today (${TODAY}) using: - gh issue list --repo ${{ github.repository }} --state all --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500 + Search for all OPEN issues created today (${TODAY}) using: + gh issue list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,body,labels,state,comments,createdAt,author --limit 500 + + IMPORTANT: EXCLUDE all issues authored by Anomaly team members. Filter out issues where the author login matches ANY of these: + adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr + This recap is specifically for COMMUNITY (external) issues only. STEP 2: Analyze and categorize For each issue created today, categorize it: diff --git a/.github/workflows/daily-pr-recap.yml b/.github/workflows/daily-pr-recap.yml index 7ca94bd237..2f0f023cfd 100644 --- a/.github/workflows/daily-pr-recap.yml +++ b/.github/workflows/daily-pr-recap.yml @@ -47,14 +47,18 @@ jobs: TODAY'S DATE: ${TODAY} STEP 1: Gather PR data - Run these commands to gather PR information. ONLY include PRs created or updated TODAY (${TODAY}): + Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}): - # PRs created today - gh pr list --repo ${{ github.repository }} --state all --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 + # Open PRs created today + gh pr list --repo ${{ github.repository }} --state open --search \"created:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 - # PRs with activity today (updated today) + # Open PRs with activity today (updated today) gh pr list --repo ${{ github.repository }} --state open --search \"updated:${TODAY}\" --json number,title,author,labels,createdAt,updatedAt,reviewDecision,isDraft,additions,deletions --limit 100 + IMPORTANT: EXCLUDE all PRs authored by Anomaly team members. Filter out PRs where the author login matches ANY of these: + adamdotdevin, Brendonovich, fwang, Hona, iamdavidhill, jayair, kitlangton, kommander, MrMushrooooom, R44VC0RP, rekram1-node, thdxr + This recap is specifically for COMMUNITY (external) contributions only. + STEP 2: For high-activity PRs, check comment counts diff --git a/.github/workflows/docs-locale-sync.yml b/.github/workflows/docs-locale-sync.yml new file mode 100644 index 0000000000..c97f3e1917 --- /dev/null +++ b/.github/workflows/docs-locale-sync.yml @@ -0,0 +1,82 @@ +name: docs-locale-sync + +on: + push: + branches: + - dev + paths: + - packages/web/src/content/docs/*.mdx + +jobs: + sync-locales: + if: github.actor != 'opencode-agent[bot]' + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + id-token: write + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Setup git committer + id: committer + uses: ./.github/actions/setup-git-committer + with: + opencode-app-id: ${{ vars.OPENCODE_APP_ID }} + opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} + + - name: Compute changed English docs + id: changes + run: | + FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true) + if [ -z "$FILES" ]; then + echo "has_changes=false" >> "$GITHUB_OUTPUT" + echo "No English docs changed in push range" + exit 0 + fi + echo "has_changes=true" >> "$GITHUB_OUTPUT" + { + echo "files<> "$GITHUB_OUTPUT" + + - name: Sync locale docs with OpenCode + if: steps.changes.outputs.has_changes == 'true' + uses: sst/opencode/github@latest + env: + OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} + with: + model: opencode/gpt-5.2 + agent: docs + prompt: | + Update localized docs to match the latest English docs changes. + + Changed English doc files: + + ${{ steps.changes.outputs.files }} + + + Requirements: + 1. Update all relevant locale docs under packages/web/src/content/docs// so they reflect these English page changes. + 2. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update. + 3. Keep locale docs structure aligned with their corresponding English pages. + 4. Do not modify English source docs in packages/web/src/content/docs/*.mdx. + 5. If no locale updates are needed, make no changes. + + - name: Commit and push locale docs updates + if: steps.changes.outputs.has_changes == 'true' + run: | + if [ -z "$(git status --porcelain)" ]; then + echo "No locale docs changes to commit" + exit 0 + fi + git add -A + git commit -m "docs(i18n): sync locale docs from english changes" + git pull --rebase --autostash origin "$GITHUB_REF_NAME" + git push origin HEAD:"$GITHUB_REF_NAME" diff --git a/.github/workflows/duplicate-issues.yml b/.github/workflows/duplicate-issues.yml index cbe8df5175..87e655fe4b 100644 --- a/.github/workflows/duplicate-issues.yml +++ b/.github/workflows/duplicate-issues.yml @@ -21,7 +21,7 @@ jobs: - name: Install opencode run: curl -fsSL https://opencode.ai/install | bash - - name: Check for duplicate issues + - name: Check duplicates and compliance env: OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -34,30 +34,84 @@ jobs: "webfetch": "deny" } run: | - opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:' + opencode run -m opencode/claude-haiku-4-5 "A new issue has been created: - Issue number: - ${{ github.event.issue.number }} + Issue number: ${{ github.event.issue.number }} - Lookup this issue and search through existing issues (excluding #${{ github.event.issue.number }}) in this repository to find any potential duplicates of this new issue. + Lookup this issue with gh issue view ${{ github.event.issue.number }}. + + You have TWO tasks. Perform both, then post a SINGLE comment (if needed). + + --- + + TASK 1: CONTRIBUTING GUIDELINES COMPLIANCE CHECK + + Check whether the issue follows our contributing guidelines and issue templates. + + This project has three issue templates that every issue MUST use one of: + + 1. Bug Report - requires a Description field with real content + 2. Feature Request - requires a verification checkbox and description, title should start with [FEATURE]: + 3. Question - requires the Question field with real content + + Additionally check: + - No AI-generated walls of text (long, AI-generated descriptions are not acceptable) + - The issue has real content, not just template placeholder text left unchanged + - Bug reports should include some context about how to reproduce + - Feature requests should explain the problem or need + - We want to push for having the user provide system description & information + + Do NOT be nitpicky about optional fields. Only flag real problems like: no template used, required fields empty or placeholder text only, obviously AI-generated walls of text, or completely empty/nonsensical content. + + --- + + TASK 2: DUPLICATE CHECK + + Search through existing issues (excluding #${{ github.event.issue.number }}) to find potential duplicates. Consider: 1. Similar titles or descriptions 2. Same error messages or symptoms 3. Related functionality or components 4. Similar feature requests - If you find any potential duplicates, please comment on the new issue with: - - A brief explanation of why it might be a duplicate - - Links to the potentially duplicate issues - - A suggestion to check those issues first + Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, note the pinned keybinds issue #4997. + + --- + + POSTING YOUR COMMENT: + + Based on your findings, post a SINGLE comment on issue #${{ github.event.issue.number }}. Build the comment as follows: + + If the issue is NOT compliant, start the comment with: + + Then explain what needs to be fixed and that they have 2 hours to edit the issue before it is automatically closed. Also add the label needs:compliance to the issue using: gh issue edit ${{ github.event.issue.number }} --add-label needs:compliance + + If duplicates were found, include a section about potential duplicates with links. + + If the issue mentions keybinds/keyboard shortcuts, include a note about #4997. + + If the issue IS compliant AND no duplicates were found AND no keybind reference, do NOT comment at all. Use this format for the comment: - 'This issue might be a duplicate of existing issues. Please check: + + [If not compliant:] + + This issue doesn't fully meet our [contributing guidelines](../blob/dev/CONTRIBUTING.md). + + **What needs to be fixed:** + - [specific reasons] + + Please edit this issue to address the above within **2 hours**, or it will be automatically closed. + + [If duplicates found, add:] + --- + This issue might be a duplicate of existing issues. Please check: - #[issue_number]: [brief description of similarity] - Feel free to ignore if none of these address your specific case.' + [If keybind-related, add:] + For keybind-related issues, please also check our pinned keybinds documentation: #4997 - Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, please add a comment mentioning the pinned keybinds issue #4997: - 'For keybind-related issues, please also check our pinned keybinds documentation: #4997' + [End with if not compliant:] + If you believe this was flagged incorrectly, please let a maintainer know. - If no clear duplicates are found, do not comment." + Remember: post at most ONE comment combining all findings. If everything is fine, post nothing." diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml index 5446f9212f..894dbf47b1 100644 --- a/.github/workflows/nix-hashes.yml +++ b/.github/workflows/nix-hashes.yml @@ -12,6 +12,9 @@ on: - "package.json" - "packages/*/package.json" - "flake.lock" + - "nix/node_modules.nix" + - "nix/scripts/**" + - "patches/**" - ".github/workflows/nix-hashes.yml" jobs: diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml new file mode 100644 index 0000000000..94569f4731 --- /dev/null +++ b/.github/workflows/vouch-check-issue.yml @@ -0,0 +1,96 @@ +name: vouch-check-issue + +on: + issues: + types: [opened] + +permissions: + contents: read + issues: write + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Check if issue author is denounced + uses: actions/github-script@v7 + with: + script: | + const author = context.payload.issue.user.login; + const issueNumber = context.payload.issue.number; + + // Skip bots + if (author.endsWith('[bot]')) { + core.info(`Skipping bot: ${author}`); + return; + } + + // Read the VOUCHED.td file via API (no checkout needed) + let content; + try { + const response = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/VOUCHED.td', + }); + content = Buffer.from(response.data.content, 'base64').toString('utf-8'); + } catch (error) { + if (error.status === 404) { + core.info('No .github/VOUCHED.td file found, skipping check.'); + return; + } + throw error; + } + + // Parse the .td file for denounced users + const denounced = new Map(); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + if (!trimmed.startsWith('-')) continue; + + const rest = trimmed.slice(1).trim(); + if (!rest) continue; + const spaceIdx = rest.indexOf(' '); + const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); + const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); + + // Handle platform:username or bare username + // Only match bare usernames or github: prefix (skip other platforms) + const colonIdx = handle.indexOf(':'); + if (colonIdx !== -1) { + const platform = handle.slice(0, colonIdx).toLowerCase(); + if (platform !== 'github') continue; + } + const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); + if (!username) continue; + + denounced.set(username.toLowerCase(), reason); + } + + // Check if the author is denounced + const reason = denounced.get(author.toLowerCase()); + if (reason === undefined) { + core.info(`User ${author} is not denounced. Allowing issue.`); + return; + } + + // Author is denounced — close the issue + const body = 'This issue has been automatically closed.'; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + body, + }); + + await github.rest.issues.update({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issueNumber, + state: 'closed', + state_reason: 'not_planned', + }); + + core.info(`Closed issue #${issueNumber} from denounced user ${author}`); diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml new file mode 100644 index 0000000000..470b8e0a5a --- /dev/null +++ b/.github/workflows/vouch-check-pr.yml @@ -0,0 +1,93 @@ +name: vouch-check-pr + +on: + pull_request_target: + types: [opened] + +permissions: + contents: read + pull-requests: write + +jobs: + check: + runs-on: ubuntu-latest + steps: + - name: Check if PR author is denounced + uses: actions/github-script@v7 + with: + script: | + const author = context.payload.pull_request.user.login; + const prNumber = context.payload.pull_request.number; + + // Skip bots + if (author.endsWith('[bot]')) { + core.info(`Skipping bot: ${author}`); + return; + } + + // Read the VOUCHED.td file via API (no checkout needed) + let content; + try { + const response = await github.rest.repos.getContent({ + owner: context.repo.owner, + repo: context.repo.repo, + path: '.github/VOUCHED.td', + }); + content = Buffer.from(response.data.content, 'base64').toString('utf-8'); + } catch (error) { + if (error.status === 404) { + core.info('No .github/VOUCHED.td file found, skipping check.'); + return; + } + throw error; + } + + // Parse the .td file for denounced users + const denounced = new Map(); + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + if (!trimmed.startsWith('-')) continue; + + const rest = trimmed.slice(1).trim(); + if (!rest) continue; + const spaceIdx = rest.indexOf(' '); + const handle = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx); + const reason = spaceIdx === -1 ? null : rest.slice(spaceIdx + 1).trim(); + + // Handle platform:username or bare username + // Only match bare usernames or github: prefix (skip other platforms) + const colonIdx = handle.indexOf(':'); + if (colonIdx !== -1) { + const platform = handle.slice(0, colonIdx).toLowerCase(); + if (platform !== 'github') continue; + } + const username = colonIdx === -1 ? handle : handle.slice(colonIdx + 1); + if (!username) continue; + + denounced.set(username.toLowerCase(), reason); + } + + // Check if the author is denounced + const reason = denounced.get(author.toLowerCase()); + if (reason === undefined) { + core.info(`User ${author} is not denounced. Allowing PR.`); + return; + } + + // Author is denounced — close the PR + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: prNumber, + body: 'This pull request has been automatically closed.', + }); + + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: prNumber, + state: 'closed', + }); + + core.info(`Closed PR #${prNumber} from denounced user ${author}`); diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml new file mode 100644 index 0000000000..cf0524c21a --- /dev/null +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -0,0 +1,37 @@ +name: vouch-manage-by-issue + +on: + issue_comment: + types: [created] + +concurrency: + group: vouch-manage + cancel-in-progress: false + +permissions: + contents: write + issues: write + pull-requests: read + +jobs: + manage: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Setup git committer + id: committer + uses: ./.github/actions/setup-git-committer + with: + opencode-app-id: ${{ vars.OPENCODE_APP_ID }} + opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }} + + - uses: mitchellh/vouch/action/manage-by-issue@main + with: + issue-id: ${{ github.event.issue.number }} + comment-id: ${{ github.event.comment.id }} + env: + GITHUB_TOKEN: ${{ steps.committer.outputs.token }} diff --git a/.opencode/agent/translator.md b/.opencode/agent/translator.md new file mode 100644 index 0000000000..dec6fa6c4f --- /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 4c46a72c16..ae47cf4cb0 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/CONTRIBUTING.md b/CONTRIBUTING.md index 60b76a95e9..4bec009ef4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -258,3 +258,49 @@ These are not strictly enforced, they are just general guidelines: ## Feature Requests For net-new functionality, start with a design conversation. Open an issue describing the problem, your proposed approach (optional), and why it belongs in OpenCode. The core team will help decide whether it should move forward; please wait for that approval instead of opening a feature PR directly. + +## Trust & Vouch System + +This project uses [vouch](https://github.com/mitchellh/vouch) to manage contributor trust. The vouch list is maintained in [`.github/VOUCHED.td`](.github/VOUCHED.td). + +### How it works + +- **Vouched users** are explicitly trusted contributors. +- **Denounced users** are explicitly blocked. Issues and pull requests from denounced users are automatically closed. If you have been denounced, you can request to be unvouched by reaching out to a maintainer on [Discord](https://opencode.ai/discord) +- **Everyone else** can participate normally — you don't need to be vouched to open issues or PRs. + +### For maintainers + +Collaborators with write access can manage the vouch list by commenting on any issue: + +- `vouch` — vouch for the issue author +- `vouch @username` — vouch for a specific user +- `denounce` — denounce the issue author +- `denounce @username` — denounce a specific user +- `denounce @username ` — denounce with a reason +- `unvouch` / `unvouch @username` — remove someone from the list + +Changes are committed automatically to `.github/VOUCHED.td`. + +### Denouncement policy + +Denouncement is reserved for users who repeatedly submit low-quality AI-generated contributions, spam, or otherwise act in bad faith. It is not used for disagreements or honest mistakes. + +## Issue Requirements + +All issues **must** use one of our issue templates: + +- **Bug report** — for reporting bugs (requires a description) +- **Feature request** — for suggesting enhancements (requires verification checkbox and description) +- **Question** — for asking questions (requires the question) + +Blank issues are not allowed. When a new issue is opened, an automated check verifies that it follows a template and meets our contributing guidelines. If an issue doesn't meet the requirements, you'll receive a comment explaining what needs to be fixed and have **2 hours** to edit the issue. After that, it will be automatically closed. + +Issues may be flagged for: + +- Not using a template +- Required fields left empty or filled with placeholder text +- AI-generated walls of text +- Missing meaningful content + +If you believe your issue was incorrectly flagged, let a maintainer know. diff --git a/bun.lock b/bun.lock index 8d2fe82d78..b98a6cc7a8 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.53", + "version": "1.1.56", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.53", + "version": "1.1.56", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.53", + "version": "1.1.56", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.53", + "version": "1.1.56", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.53", + "version": "1.1.56", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.53", + "version": "1.1.56", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -215,7 +215,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.53", + "version": "1.1.56", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -244,7 +244,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.53", + "version": "1.1.56", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -260,7 +260,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.53", + "version": "1.1.56", "bin": { "opencode": "./bin/opencode", }, @@ -366,7 +366,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.53", + "version": "1.1.56", "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.56", "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.56", "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.56", "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.56", "dependencies": { "zod": "catalog:", }, @@ -463,14 +463,14 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.53", + "version": "1.1.56", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", "@astrojs/solid-js": "5.1.0", "@astrojs/starlight": "0.34.3", "@fontsource/ibm-plex-mono": "5.2.5", - "@shikijs/transformers": "3.4.2", + "@shikijs/transformers": "3.20.0", "@types/luxon": "catalog:", "ai": "catalog:", "astro": "5.7.13", @@ -485,8 +485,10 @@ "shiki": "catalog:", "solid-js": "catalog:", "toolbeam-docs-theme": "0.4.8", + "vscode-languageserver-types": "3.17.5", }, "devDependencies": { + "@astrojs/check": "0.9.6", "@types/node": "catalog:", "opencode": "workspace:*", "typescript": "catalog:", @@ -617,12 +619,16 @@ "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], + "@astrojs/check": ["@astrojs/check@0.9.6", "", { "dependencies": { "@astrojs/language-server": "^2.16.1", "chokidar": "^4.0.1", "kleur": "^4.1.5", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": "^5.0.0" }, "bin": { "astro-check": "bin/astro-check.js" } }, "sha512-jlaEu5SxvSgmfGIFfNgcn5/f+29H61NJzEMfAZ82Xopr4XBchXB1GVlcJsE+elUlsYSbXlptZLX+JMG3b/wZEA=="], + "@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="], "@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="], "@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.1", "", {}, "sha512-7dwEVigz9vUWDw3nRwLQ/yH/xYovlUA0ZD86xoeKEBmkz9O6iELG1yri67PgAPW6VLL/xInA4t7H0CK6VmtkKQ=="], + "@astrojs/language-server": ["@astrojs/language-server@2.16.3", "", { "dependencies": { "@astrojs/compiler": "^2.13.0", "@astrojs/yaml2ts": "^0.2.2", "@jridgewell/sourcemap-codec": "^1.5.5", "@volar/kit": "~2.4.27", "@volar/language-core": "~2.4.27", "@volar/language-server": "~2.4.27", "@volar/language-service": "~2.4.27", "muggle-string": "^0.4.1", "tinyglobby": "^0.2.15", "volar-service-css": "0.0.68", "volar-service-emmet": "0.0.68", "volar-service-html": "0.0.68", "volar-service-prettier": "0.0.68", "volar-service-typescript": "0.0.68", "volar-service-typescript-twoslash-queries": "0.0.68", "volar-service-yaml": "0.0.68", "vscode-html-languageservice": "^5.6.1", "vscode-uri": "^3.1.0" }, "peerDependencies": { "prettier": "^3.0.0", "prettier-plugin-astro": ">=0.11.0" }, "optionalPeers": ["prettier", "prettier-plugin-astro"], "bin": { "astro-ls": "bin/nodeServer.js" } }, "sha512-yO5K7RYCMXUfeDlnU6UnmtnoXzpuQc0yhlaCNZ67k1C/MiwwwvMZz+LGa+H35c49w5QBfvtr4w4Zcf5PcH8uYA=="], + "@astrojs/markdown-remark": ["@astrojs/markdown-remark@6.3.1", "", { "dependencies": { "@astrojs/internal-helpers": "0.6.1", "@astrojs/prism": "3.2.0", "github-slugger": "^2.0.0", "hast-util-from-html": "^2.0.3", "hast-util-to-text": "^4.0.2", "import-meta-resolve": "^4.1.0", "js-yaml": "^4.1.0", "mdast-util-definitions": "^6.0.0", "rehype-raw": "^7.0.0", "rehype-stringify": "^10.0.1", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.1", "remark-smartypants": "^3.0.2", "shiki": "^3.0.0", "smol-toml": "^1.3.1", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1", "vfile": "^6.0.3" } }, "sha512-c5F5gGrkczUaTVgmMW9g1YMJGzOtRvjjhw6IfGuxarM6ct09MpwysP10US729dy07gg8y+ofVifezvP3BNsWZg=="], "@astrojs/mdx": ["@astrojs/mdx@4.3.13", "", { "dependencies": { "@astrojs/markdown-remark": "6.3.10", "@mdx-js/mdx": "^3.1.1", "acorn": "^8.15.0", "es-module-lexer": "^1.7.0", "estree-util-visit": "^2.0.0", "hast-util-to-html": "^9.0.5", "piccolore": "^0.1.3", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "remark-smartypants": "^3.0.2", "source-map": "^0.7.6", "unist-util-visit": "^5.0.0", "vfile": "^6.0.3" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-IHDHVKz0JfKBy3//52JSiyWv089b7GVSChIXLrlUOoTLWowG3wr2/8hkaEgEyd/vysvNQvGk+QhysXpJW5ve6Q=="], @@ -639,6 +645,8 @@ "@astrojs/underscore-redirects": ["@astrojs/underscore-redirects@1.0.0", "", {}, "sha512-qZxHwVnmb5FXuvRsaIGaqWgnftjCuMY+GSbaVZdBmE4j8AfgPqKPxYp8SUERyJcjpKCEmO4wD6ybuGH8A2kVRQ=="], + "@astrojs/yaml2ts": ["@astrojs/yaml2ts@0.2.2", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-GOfvSr5Nqy2z5XiwqTouBBpy5FyI6DEe+/g/Mk5am9SjILN1S5fOEvYK0GuWHg98yS/dobP4m8qyqw/URW35fQ=="], + "@aws-crypto/crc32": ["@aws-crypto/crc32@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg=="], "@aws-crypto/crc32c": ["@aws-crypto/crc32c@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag=="], @@ -849,6 +857,20 @@ "@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="], + "@emmetio/abbreviation": ["@emmetio/abbreviation@2.3.3", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA=="], + + "@emmetio/css-abbreviation": ["@emmetio/css-abbreviation@2.1.8", "", { "dependencies": { "@emmetio/scanner": "^1.0.4" } }, "sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw=="], + + "@emmetio/css-parser": ["@emmetio/css-parser@0.4.1", "", { "dependencies": { "@emmetio/stream-reader": "^2.2.0", "@emmetio/stream-reader-utils": "^0.1.0" } }, "sha512-2bC6m0MV/voF4CTZiAbG5MWKbq5EBmDPKu9Sb7s7nVcEzNQlrZP6mFFFlIaISM8X6514H9shWMme1fCm8cWAfQ=="], + + "@emmetio/html-matcher": ["@emmetio/html-matcher@1.3.0", "", { "dependencies": { "@emmetio/scanner": "^1.0.0" } }, "sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ=="], + + "@emmetio/scanner": ["@emmetio/scanner@1.0.4", "", {}, "sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA=="], + + "@emmetio/stream-reader": ["@emmetio/stream-reader@2.2.0", "", {}, "sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw=="], + + "@emmetio/stream-reader-utils": ["@emmetio/stream-reader-utils@0.1.0", "", {}, "sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A=="], + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], @@ -1961,6 +1983,22 @@ "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], + "@volar/kit": ["@volar/kit@2.4.28", "", { "dependencies": { "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "typesafe-path": "^0.2.2", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "typescript": "*" } }, "sha512-cKX4vK9dtZvDRaAzeoUdaAJEew6IdxHNCRrdp5Kvcl6zZOqb6jTOfk3kXkIkG3T7oTFXguEMt5+9ptyqYR84Pg=="], + + "@volar/language-core": ["@volar/language-core@2.4.28", "", { "dependencies": { "@volar/source-map": "2.4.28" } }, "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ=="], + + "@volar/language-server": ["@volar/language-server@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "@volar/language-service": "2.4.28", "@volar/typescript": "2.4.28", "path-browserify": "^1.0.1", "request-light": "^0.7.0", "vscode-languageserver": "^9.0.1", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-NqcLnE5gERKuS4PUFwlhMxf6vqYo7hXtbMFbViXcbVkbZ905AIVWhnSo0ZNBC2V127H1/2zP7RvVOVnyITFfBw=="], + + "@volar/language-service": ["@volar/language-service@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "vscode-languageserver-protocol": "^3.17.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" } }, "sha512-Rh/wYCZJrI5vCwMk9xyw/Z+MsWxlJY1rmMZPsxUoJKfzIRjS/NF1NmnuEcrMbEVGja00aVpCsInJfixQTMdvLw=="], + + "@volar/source-map": ["@volar/source-map@2.4.28", "", {}, "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ=="], + + "@volar/typescript": ["@volar/typescript@2.4.28", "", { "dependencies": { "@volar/language-core": "2.4.28", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw=="], + + "@vscode/emmet-helper": ["@vscode/emmet-helper@2.11.0", "", { "dependencies": { "emmet": "^2.4.3", "jsonc-parser": "^2.3.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.15.1", "vscode-uri": "^3.0.8" } }, "sha512-QLxjQR3imPZPQltfbWRnHU6JecWTF1QSWhx3GAKQpslx7y3Dp6sIIXhKjiUJ/BR9FX8PVthjr9PD6pNwOJfAzw=="], + + "@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="], + "@webgpu/types": ["@webgpu/types@0.1.54", "", {}, "sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg=="], "@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="], @@ -1989,6 +2027,8 @@ "ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], + "ajv-draft-04": ["ajv-draft-04@1.0.0", "", { "peerDependencies": { "ajv": "^8.5.0" }, "optionalPeers": ["ajv"] }, "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw=="], + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], "ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="], @@ -2391,6 +2431,8 @@ "electron-to-chromium": ["electron-to-chromium@1.5.282", "", {}, "sha512-FCPkJtpst28UmFzd903iU7PdeVTfY0KAeJy+Lk0GLZRwgwYHn/irRcaCbQQOmr5Vytc/7rcavsYLvTM8RiHYhQ=="], + "emmet": ["emmet@2.4.11", "", { "dependencies": { "@emmetio/abbreviation": "^2.3.3", "@emmetio/css-abbreviation": "^2.1.8" } }, "sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], @@ -3169,6 +3211,8 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + "multicast-dns": ["multicast-dns@7.2.5", "", { "dependencies": { "dns-packet": "^5.2.2", "thunky": "^1.0.2" }, "bin": { "multicast-dns": "cli.js" } }, "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg=="], "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], @@ -3321,6 +3365,8 @@ "pascal-case": ["pascal-case@3.1.2", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g=="], + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], @@ -3521,6 +3567,10 @@ "remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="], + "request-light": ["request-light@0.7.0", "", {}, "sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="], @@ -3863,8 +3913,12 @@ "typed-array-length": ["typed-array-length@1.0.7", "", { "dependencies": { "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", "is-typed-array": "^1.1.13", "possible-typed-array-names": "^1.0.0", "reflect.getprototypeof": "^1.0.6" } }, "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg=="], + "typesafe-path": ["typesafe-path@0.2.2", "", {}, "sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA=="], + "typescript": ["typescript@5.8.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="], + "typescript-auto-import-cache": ["typescript-auto-import-cache@0.3.6", "", { "dependencies": { "semver": "^7.3.8" } }, "sha512-RpuHXrknHdVdK7wv/8ug3Fr0WNsNi5l5aB8MYYuXhq2UH5lnEB1htJ1smhtD5VeCsGr2p8mUDtd83LCQDFVgjQ=="], + "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], "ulid": ["ulid@3.0.1", "", { "bin": { "ulid": "dist/cli.js" } }, "sha512-dPJyqPzx8preQhqq24bBG1YNkvigm87K8kVEHCD+ruZg24t6IFEFv00xMWfxcC4djmFtiTLdFuADn4+DOz6R7Q=="], @@ -3961,10 +4015,40 @@ "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + "volar-service-css": ["volar-service-css@0.0.68", "", { "dependencies": { "vscode-css-languageservice": "^6.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-lJSMh6f3QzZ1tdLOZOzovLX0xzAadPhx8EKwraDLPxBndLCYfoTvnNuiFFV8FARrpAlW5C0WkH+TstPaCxr00Q=="], + + "volar-service-emmet": ["volar-service-emmet@0.0.68", "", { "dependencies": { "@emmetio/css-parser": "^0.4.1", "@emmetio/html-matcher": "^1.3.0", "@vscode/emmet-helper": "^2.9.3", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-nHvixrRQ83EzkQ4G/jFxu9Y4eSsXS/X2cltEPDM+K9qZmIv+Ey1w0tg1+6caSe8TU5Hgw4oSTwNMf/6cQb3LzQ=="], + + "volar-service-html": ["volar-service-html@0.0.68", "", { "dependencies": { "vscode-html-languageservice": "^5.3.0", "vscode-languageserver-textdocument": "^1.0.11", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-fru9gsLJxy33xAltXOh4TEdi312HP80hpuKhpYQD4O5hDnkNPEBdcQkpB+gcX0oK0VxRv1UOzcGQEUzWCVHLfA=="], + + "volar-service-prettier": ["volar-service-prettier@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0", "prettier": "^2.2 || ^3.0" }, "optionalPeers": ["@volar/language-service", "prettier"] }, "sha512-grUmWHkHlebMOd6V8vXs2eNQUw/bJGJMjekh/EPf/p2ZNTK0Uyz7hoBRngcvGfJHMsSXZH8w/dZTForIW/4ihw=="], + + "volar-service-typescript": ["volar-service-typescript@0.0.68", "", { "dependencies": { "path-browserify": "^1.0.1", "semver": "^7.6.2", "typescript-auto-import-cache": "^0.3.5", "vscode-languageserver-textdocument": "^1.0.11", "vscode-nls": "^5.2.0", "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-z7B/7CnJ0+TWWFp/gh2r5/QwMObHNDiQiv4C9pTBNI2Wxuwymd4bjEORzrJ/hJ5Yd5+OzeYK+nFCKevoGEEeKw=="], + + "volar-service-typescript-twoslash-queries": ["volar-service-typescript-twoslash-queries@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-NugzXcM0iwuZFLCJg47vI93su5YhTIweQuLmZxvz5ZPTaman16JCvmDZexx2rd5T/75SNuvvZmrTOTNYUsfe5w=="], + + "volar-service-yaml": ["volar-service-yaml@0.0.68", "", { "dependencies": { "vscode-uri": "^3.0.8", "yaml-language-server": "~1.19.2" }, "peerDependencies": { "@volar/language-service": "~2.4.0" }, "optionalPeers": ["@volar/language-service"] }, "sha512-84XgE02LV0OvTcwfqhcSwVg4of3MLNUWPMArO6Aj8YXqyEVnPu8xTEMY2btKSq37mVAPuaEVASI4e3ptObmqcA=="], + + "vscode-css-languageservice": ["vscode-css-languageservice@6.3.9", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-1tLWfp+TDM5ZuVWht3jmaY5y7O6aZmpeXLoHl5bv1QtRsRKt4xYGRMmdJa5Pqx/FTkgRbsna9R+Gn2xE+evVuA=="], + + "vscode-html-languageservice": ["vscode-html-languageservice@5.6.1", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-5Mrqy5CLfFZUgkyhNZLA1Ye5g12Cb/v6VM7SxUzZUaRKWMDz4md+y26PrfRTSU0/eQAl3XpO9m2og+GGtDMuaA=="], + + "vscode-json-languageservice": ["vscode-json-languageservice@4.1.8", "", { "dependencies": { "jsonc-parser": "^3.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-nls": "^5.0.0", "vscode-uri": "^3.0.2" } }, "sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="], + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + "vscode-nls": ["vscode-nls@5.2.0", "", {}, "sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng=="], + + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + "web-namespaces": ["web-namespaces@2.0.1", "", {}, "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ=="], "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], @@ -4025,6 +4109,8 @@ "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + "yaml-language-server": ["yaml-language-server@1.19.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "ajv": "^8.17.1", "ajv-draft-04": "^1.0.0", "lodash": "4.17.21", "prettier": "^3.5.0", "request-light": "^0.5.7", "vscode-json-languageservice": "4.1.8", "vscode-languageserver": "^9.0.0", "vscode-languageserver-textdocument": "^1.0.1", "vscode-languageserver-types": "^3.16.0", "vscode-uri": "^3.0.2", "yaml": "2.7.1" }, "bin": { "yaml-language-server": "bin/yaml-language-server" } }, "sha512-9F3myNmJzUN/679jycdMxqtydPSDRAarSj3wPiF7pchEPnO9Dg07Oc+gIYLqXR4L+g+FSEVXXv2+mr54StLFOg=="], + "yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="], "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], @@ -4097,6 +4183,8 @@ "@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="], + "@astrojs/check/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + "@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], "@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.6.1", "", {}, "sha512-l5Pqf6uZu31aG+3Lv8nl/3s4DbUzdlxTWDof4pEpto6GUJNhhCbelVi9dEyurOVyqaelwmS9oSyOWOENSfgo9A=="], @@ -4291,7 +4379,7 @@ "@opencode-ai/desktop/typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], - "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.4.2", "", { "dependencies": { "@shikijs/core": "3.4.2", "@shikijs/types": "3.4.2" } }, "sha512-I5baLVi/ynLEOZoWSAMlACHNnG+yw5HDmse0oe+GW6U1u+ULdEB3UHiVWaHoJSSONV7tlcVxuaMy74sREDkSvg=="], + "@opencode-ai/web/@shikijs/transformers": ["@shikijs/transformers@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/types": "3.20.0" } }, "sha512-PrHHMRr3Q5W1qB/42kJW6laqFyWdhrPF2hNR9qjOm1xcSiAO3hAHo7HaVyHE6pMyevmy3i51O8kuGGXC78uK3g=="], "@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="], @@ -4365,6 +4453,8 @@ "@tanstack/server-functions-plugin/@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + "@vscode/emmet-helper/jsonc-parser": ["jsonc-parser@2.3.1", "", {}, "sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg=="], + "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="], @@ -4591,6 +4681,8 @@ "vitest/why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + "vscode-languageserver-protocol/vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + "which-builtin-type/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "wrangler/esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], @@ -4601,6 +4693,12 @@ "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "yaml-language-server/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="], + + "yaml-language-server/yaml": ["yaml@2.7.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-10ULxpnOCQXxJvBgxsn9ptjq6uviG/htZKk9veJGhlqn3w/DxQ631zFF+nlQXLwmImeS5amR2dl2U8sg6U9jsQ=="], + "yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="], "zod-to-json-schema/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -4621,6 +4719,10 @@ "@ai-sdk/openai/@ai-sdk/provider-utils/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], + "@astrojs/check/yargs/cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "@astrojs/check/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/internal-helpers": ["@astrojs/internal-helpers@0.7.5", "", {}, "sha512-vreGnYSSKhAjFJCWAwe/CNhONvoc5lokxtRoZims+0wa3KbHBdPHSSthJsKxPd8d/aic6lWKpRTYGY/hsgK6EA=="], "@astrojs/mdx/@astrojs/markdown-remark/@astrojs/prism": ["@astrojs/prism@3.3.0", "", { "dependencies": { "prismjs": "^1.30.0" } }, "sha512-q8VwfU/fDZNoDOf+r7jUnMC2//H2l0TuQ6FkGJL8vD8nw/q5KiL3DS1KKBI3QhI9UQhpJ5dc7AtqfbXWuOgLCQ=="], @@ -4865,9 +4967,9 @@ "@opencode-ai/desktop/@actions/artifact/@actions/http-client": ["@actions/http-client@2.2.3", "", { "dependencies": { "tunnel": "^0.0.6", "undici": "^5.25.4" } }, "sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA=="], - "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.4.2", "", { "dependencies": { "@shikijs/types": "3.4.2", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-AG8vnSi1W2pbgR2B911EfGqtLE9c4hQBYkv/x7Z+Kt0VxhgQKcW7UNDVYsu9YxwV6u+OJrvdJrMq6DNWoBjihQ=="], + "@opencode-ai/web/@shikijs/transformers/@shikijs/core": ["@shikijs/core@3.20.0", "", { "dependencies": { "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-f2ED7HYV4JEk827mtMDwe/yQ25pRiXZmtHjWF8uzZKuKiEsJR7Ce1nuQ+HhV9FzDcbIo4ObBCD9GPTzNuy9S1g=="], - "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.4.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-zHC1l7L+eQlDXLnxvM9R91Efh2V4+rN3oMVS2swCBssbj2U/FBwybD1eeLaq8yl/iwT+zih8iUbTBCgGZOYlVg=="], + "@opencode-ai/web/@shikijs/transformers/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="], "@opentui/solid/@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -5111,6 +5213,14 @@ "@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@20.0.0", "", {}, "sha512-EtqRBEjp1dL/15V7WiX5LJMIxxkdiGJnabzYx5Apx4FkQIFgAfKumXeYAqqJCj1s+BMX4cPFIFC4OLCR6stlnA=="], + "@astrojs/check/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@astrojs/check/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "@astrojs/check/yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "@astrojs/check/yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "@aws-crypto/sha1-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], @@ -5251,6 +5361,10 @@ "tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + "@astrojs/check/yargs/cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@astrojs/check/yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + "@aws-sdk/client-sts/@aws-sdk/credential-provider-node/@aws-sdk/credential-provider-sso/@aws-sdk/token-providers/@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.782.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.775.0", "@aws-sdk/middleware-host-header": "3.775.0", "@aws-sdk/middleware-logger": "3.775.0", "@aws-sdk/middleware-recursion-detection": "3.775.0", "@aws-sdk/middleware-user-agent": "3.782.0", "@aws-sdk/region-config-resolver": "3.775.0", "@aws-sdk/types": "3.775.0", "@aws-sdk/util-endpoints": "3.782.0", "@aws-sdk/util-user-agent-browser": "3.775.0", "@aws-sdk/util-user-agent-node": "3.782.0", "@smithy/config-resolver": "^4.1.0", "@smithy/core": "^3.2.0", "@smithy/fetch-http-handler": "^5.0.2", "@smithy/hash-node": "^4.0.2", "@smithy/invalid-dependency": "^4.0.2", "@smithy/middleware-content-length": "^4.0.2", "@smithy/middleware-endpoint": "^4.1.0", "@smithy/middleware-retry": "^4.1.0", "@smithy/middleware-serde": "^4.0.3", "@smithy/middleware-stack": "^4.0.2", "@smithy/node-config-provider": "^4.0.2", "@smithy/node-http-handler": "^4.0.4", "@smithy/protocol-http": "^5.1.0", "@smithy/smithy-client": "^4.2.0", "@smithy/types": "^4.2.0", "@smithy/url-parser": "^4.0.2", "@smithy/util-base64": "^4.0.0", "@smithy/util-body-length-browser": "^4.0.0", "@smithy/util-body-length-node": "^4.0.0", "@smithy/util-defaults-mode-browser": "^4.0.8", "@smithy/util-defaults-mode-node": "^4.0.8", "@smithy/util-endpoints": "^3.0.2", "@smithy/util-middleware": "^4.0.2", "@smithy/util-retry": "^4.0.2", "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" } }, "sha512-QOYC8q7luzHFXrP0xYAqBctoPkynjfV0r9dqntFu4/IWMTyC1vlo1UTxFAjIPyclYw92XJyEkVCVg9v/nQnsUA=="], "@jsx-email/cli/tailwindcss/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/github/index.ts b/github/index.ts index 73378894cd..da310178a7 100644 --- a/github/index.ts +++ b/github/index.ts @@ -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 diff --git a/infra/console.ts b/infra/console.ts index 5abffb555a..4e5a14b045 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -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/nix/hashes.json b/nix/hashes.json index ddd1253ed1..d334ce6668 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-1IpZnnN6+acCcV0AgO4OVdvgf4TFBFId5dms5W5ecA0=", - "aarch64-linux": "sha256-TKmPhXokOav46ucP9AFwHGgKmB9CdGCcUtwqUtLlzG4=", - "aarch64-darwin": "sha256-xJQuw3+QHYnlClDrafQKPQyR+aqyAEofvYYjCowHDps=", - "x86_64-darwin": "sha256-ywU3Oka2QNGKu/HI+//3bdYJ9qo1N7K5Wr2vpTgSM/g=" + "x86_64-linux": "sha256-cvRBvHRuunNjF07c4GVHl5rRgoTn1qfI/HdJWtOV63M=", + "aarch64-linux": "sha256-DJUI4pMZ7wQTnyOiuDHALmZz7FZtrTbzRzCuNOShmWE=", + "aarch64-darwin": "sha256-JnkqDwuC7lNsjafV+jOGfvs8K1xC8rk5CTOW+spjiCA=", + "x86_64-darwin": "sha256-GBeTqq2vDn/mXplYNglrAT2xajjFVzB4ATHnMS0j7z4=" } } diff --git a/nix/node_modules.nix b/nix/node_modules.nix index 836ef02a56..e918846c24 100644 --- a/nix/node_modules.nix +++ b/nix/node_modules.nix @@ -30,7 +30,7 @@ stdenvNoCC.mkDerivation { ../bun.lock ../package.json ../patches - ../install + ../install # required by desktop build (cli.rs include_str!) ] ); }; diff --git a/packages/app/bunfig.toml b/packages/app/bunfig.toml index 3639904511..f1caabbcce 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/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts new file mode 100644 index 0000000000..5af314cafa --- /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 317c70969d..842433891e 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -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 0000000000..c7af038c27 --- /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/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts new file mode 100644 index 0000000000..e37f94f3a7 --- /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 a995880e01..42426c3435 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.56", "description": "", "type": "module", "exports": { diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts index 96b35cfb7f..112e2bc60a 100644 --- a/packages/app/script/e2e-local.ts +++ b/packages/app/script/e2e-local.ts @@ -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 5bbe86e209..e49b725a19 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 { - __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] } + __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean } } } diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts index eb048e29ed..29e20b4807 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 4a3e276724..d7b7299731 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 da45c351ec..4f495d27d1 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/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index b384bf7d84..0f778b5181 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -31,7 +31,7 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { const dataUrl = reader.result as string const attachment: ImageAttachmentPart = { type: "image", - id: crypto.randomUUID(), + id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2), filename: file.name, mime: file.type, dataUrl, 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 b0fd3a050c..72bdecc01f 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 11aec9631d..0cc54dc2b7 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 0000000000..475a0e20f2 --- /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 f193a9ffe3..05799fa0c2 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 } @@ -146,7 +146,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } 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 fcba47004b..94b8436667 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -112,21 +112,35 @@ export function SessionHeader() { const [exists, setExists] = createStore>>({ finder: true }) + const apps = createMemo(() => { + if (os() === "macos") return MAC_APPS + if (os() === "windows") return WINDOWS_APPS + return LINUX_APPS + }) + + const fileManager = createMemo(() => { + if (os() === "macos") return { label: "Finder", icon: "finder" as const } + if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const } + return { label: "File Manager", icon: "finder" as const } + }) + createEffect(() => { if (platform.platform !== "desktop") return if (!platform.checkAppExists) return - const list = os() - const apps = list === "macos" ? MAC_APPS : list === "windows" ? WINDOWS_APPS : list === "linux" ? LINUX_APPS : [] - if (apps.length === 0) return + const list = apps() + + setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>) void Promise.all( - apps.map((app) => - Promise.resolve(platform.checkAppExists?.(app.openWith)).then((value) => { - const ok = Boolean(value) - console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`) - return [app.id, ok] as const - }), + list.map((app) => + Promise.resolve(platform.checkAppExists?.(app.openWith)) + .then((value) => Boolean(value)) + .catch(() => false) + .then((ok) => { + console.debug(`[session-header] App "${app.label}" (${app.openWith}): ${ok ? "exists" : "does not exist"}`) + return [app.id, ok] as const + }), ), ).then((entries) => { setExists(Object.fromEntries(entries) as Partial>) @@ -134,30 +148,32 @@ export function SessionHeader() { }) const options = createMemo(() => { - if (os() === "macos") { - return [{ id: "finder", label: "Finder", icon: "finder" }, ...MAC_APPS.filter((app) => exists[app.id])] as const - } - - if (os() === "windows") { - return [ - { id: "finder", label: "File Explorer", icon: "file-explorer" }, - ...WINDOWS_APPS.filter((app) => exists[app.id]), - ] as const - } - return [ - { id: "finder", label: "File Manager", icon: "finder" }, - ...LINUX_APPS.filter((app) => exists[app.id]), + { id: "finder", label: fileManager().label, icon: fileManager().icon }, + ...apps().filter((app) => exists[app.id]), ] as const }) + type OpenIcon = OpenApp | "file-explorer" + const base = new Set(["finder", "vscode", "cursor", "zed"]) + const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]") + + const checksReady = createMemo(() => { + if (platform.platform !== "desktop") return true + if (!platform.checkAppExists) return true + const list = apps() + return list.every((app) => exists[app.id] !== undefined) + }) + const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp })) + const [menu, setMenu] = createStore({ open: false }) const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0]) createEffect(() => { if (platform.platform !== "desktop") return + if (!checksReady()) return const value = prefs.app if (options().some((o) => o.id === value)) return setPrefs("app", options()[0]?.id ?? "finder") @@ -283,7 +299,7 @@ export function SessionHeader() { )} @@ -303,6 +323,7 @@ export function SessionHeader() { {(mount) => (

+ -
sync.project?.sandboxes ?? []) @@ -24,11 +26,11 @@ export function NewSessionView(props: NewSessionViewProps) { if (options().includes(selection)) return selection return MAIN_WORKTREE }) - const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory) + const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory) const isWorktree = createMemo(() => { const project = sync.project if (!project) return false - return sync.data.path.directory !== project.worktree + return sdk.directory !== project.worktree }) const label = (value: string) => { @@ -45,7 +47,7 @@ export function NewSessionView(props: NewSessionViewProps) { } return ( -
+
{language.t("command.session.new")}
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b31cfb6cc7..72135c342e 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,8 +1,10 @@ -import { Component, createMemo, type JSX } from "solid-js" +import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" +import { Tooltip } from "@opencode-ai/ui/tooltip" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" @@ -40,6 +42,8 @@ export const SettingsGeneral: Component = () => { checking: false, }) + const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux") + const check = () => { if (!platform.checkUpdate) return setStore("checking", true) @@ -363,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")}

@@ -410,13 +442,49 @@ export const SettingsGeneral: Component = () => {
+ + + {(_) => { + const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.()) + const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest) + + const onChange = (checked: boolean) => + platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch()) + + return ( +
+

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

+ +
+ + {language.t("settings.general.row.wayland.title")} + + + + + +
+ } + description={language.t("settings.general.row.wayland.description")} + > +
+ +
+ +
+
+ ) + }} +
) } interface SettingsRowProps { - title: string + title: string | JSX.Element description: string | JSX.Element children: JSX.Element } diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 3354c3d362..6e89990178 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -141,7 +141,7 @@ export function StatusPopover() { triggerProps={{ variant: "ghost", class: - "rounded-sm w-[75px] h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none data-[expanded]:bg-surface-raised-base-active", + "rounded-md h-[24px] px-3 gap-2 border border-border-base bg-surface-panel shadow-none data-[expanded]:bg-surface-raised-base-active", style: { scale: 1 }, }} trigger={ diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 6a69897a74..ebe4c646f4 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 @@ -164,7 +166,9 @@ 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:" const auth = platform.wsAuth?.(sdk.url) if (auth) { @@ -175,6 +179,7 @@ export const Terminal = (props: TerminalProps) => { 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() }) @@ -189,7 +194,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, @@ -293,26 +298,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) { @@ -329,7 +314,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) } @@ -347,7 +331,6 @@ export const Terminal = (props: TerminalProps) => { const handleOpen = () => { local.onConnect?.() - if (sync) syncUntil = Date.now() + windowMs sdk.client.pty .update({ ptyID: local.pty.id, @@ -361,31 +344,31 @@ export const Terminal = (props: TerminalProps) => { socket.addEventListener("open", handleOpen) cleanups.push(() => socket.removeEventListener("open", handleOpen)) + const decoder = new TextDecoder() + const handleMessage = (event: MessageEvent) => { if (disposed) return + if (event.data instanceof ArrayBuffer) { + // WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }). + const bytes = new Uint8Array(event.data) + if (bytes[0] !== 0) return + const json = decoder.decode(bytes.subarray(1)) + try { + const meta = JSON.parse(json) as { cursor?: unknown } + const next = meta?.cursor + if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) { + cursor = next + } + } catch { + // ignore + } + return + } + const data = typeof event.data === "string" ? event.data : "" if (!data) return - - const next = (() => { - if (!sync) return data - if (syncUntil && Date.now() > syncUntil) { - stopSync() - return data - } - const n = overlap(data) - if (!n) { - stopSync() - return data - } - const trimmed = data.slice(n) - if (trimmed) stopSync() - return trimmed - })() - - if (!next) return - - t.write(next) - tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit) + t.write(data) + cursor += data.length } socket.addEventListener("message", handleMessage) cleanups.push(() => socket.removeEventListener("message", handleMessage)) @@ -439,7 +422,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 4a43a855ce..e7b8066ae8 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -68,12 +68,14 @@ export function Titlebar() { id: "common.goBack", title: language.t("common.goBack"), category: language.t("command.category.view"), + keybind: "mod+[", onSelect: back, }, { id: "common.goForward", title: language.t("common.goForward"), category: language.t("command.category.view"), + keybind: "mod+]", onSelect: forward, }, ]) diff --git a/packages/app/src/context/comments.test.ts b/packages/app/src/context/comments.test.ts index 13cb132c4d..4f223e5f86 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/comments.tsx b/packages/app/src/context/comments.tsx index d43f3705be..b91f029bc8 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -53,7 +53,7 @@ function createCommentSessionState(store: Store, setStore: SetStor const add = (input: Omit) => { const next: LineComment = { - id: crypto.randomUUID(), + id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2), time: Date.now(), ...input, } diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 996ea2aafe..88b70cd41d 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -7,6 +7,7 @@ import { getFilename } from "@opencode-ai/util/path" import { useSDK } from "./sdk" import { useSync } from "./sync" import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" import { createPathHelpers } from "./file/path" import { approxBytes, @@ -50,9 +51,11 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ useSync() const params = useParams() const language = useLanguage() + const layout = useLayout() const scope = createMemo(() => sdk.directory) const path = createPathHelpers(scope) + const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const inflight = new Map>() const [store, setStore] = createStore<{ @@ -183,6 +186,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ invalidateFromWatcher(e.details, { normalize: path.normalize, hasFile: (file) => Boolean(store.file[file]), + isOpen: (file) => tabs.all().some((tab) => path.pathFromTab(tab) === file), loadFile: (file) => { void load(file, { force: true }) }, diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index 95247c08bb..f2a3c44b6c 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 e1d47c6442..859fdc0406 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/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts index 653e0aa752..9536b52536 100644 --- a/packages/app/src/context/file/watcher.test.ts +++ b/packages/app/src/context/file/watcher.test.ts @@ -27,6 +27,37 @@ describe("file watcher invalidation", () => { expect(refresh).toEqual(["src"]) }) + test("reloads files that are open in tabs", () => { + const loads: string[] = [] + + invalidateFromWatcher( + { + type: "file.watcher.updated", + properties: { + file: "src/open.ts", + event: "change", + }, + }, + { + normalize: (input) => input, + hasFile: () => false, + isOpen: (path) => path === "src/open.ts", + loadFile: (path) => loads.push(path), + node: () => ({ + path: "src/open.ts", + type: "file", + name: "open.ts", + absolute: "/repo/src/open.ts", + ignored: false, + }), + isDirLoaded: () => false, + refreshDir: () => {}, + }, + ) + + expect(loads).toEqual(["src/open.ts"]) + }) + test("refreshes only changed loaded directory nodes", () => { const refresh: string[] = [] diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index a3a98eae4b..fbf7199279 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -8,6 +8,7 @@ type WatcherEvent = { type WatcherOps = { normalize: (input: string) => string hasFile: (path: string) => boolean + isOpen?: (path: string) => boolean loadFile: (path: string) => void node: (path: string) => FileNode | undefined isDirLoaded: (path: string) => boolean @@ -27,7 +28,7 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { if (!path) return if (path.startsWith(".git/")) return - if (ops.hasFile(path)) { + if (ops.hasFile(path) || ops.isOpen?.(path)) { ops.loadFile(path) } diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 0cd4f6c997..cb610bf6ed 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/local.tsx b/packages/app/src/context/local.tsx index f51bb69309..85f93f3689 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 0000000000..01b149fd26 --- /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 0000000000..6b7ae72564 --- /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 5b4a26bd0b..5a0b607f1e 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -61,7 +61,7 @@ export type Platform = { serverKey?(url: string): string /** Override whether a server URL should be treated as local */ - isServerLocal?(url: string): boolean + isServerLocal?(url: string): boolean | undefined /** Connect to a remote server over SSH (desktop only) */ sshConnect?(command: string): Promise<{ url: string; key: string; password: string | null }> @@ -72,6 +72,18 @@ export type Platform = { /** Credentials to embed in WebSocket URLs (desktop only) */ wsAuth?(url: string): { username: string; password: string } | null + /** 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 + + /** Set the preferred display backend (desktop only) */ + setDisplayBackend?(backend: DisplayBackend): Promise + /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ parseMarkdown?(markdown: string): Promise @@ -85,6 +97,8 @@ export type Platform = { readClipboardImage?(): Promise } +export type DisplayBackend = "auto" | "wayland" + export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({ name: "Platform", init: (props: { value: Platform }) => { diff --git a/packages/app/src/context/terminal.test.ts b/packages/app/src/context/terminal.test.ts index d8c8cfcd4f..a250de57c0 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 76e8cf0f73..f0f184f8be 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/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 201d63660a..7a09edc518 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -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 b7f2d74857..ba09fbe03d 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -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 05eca1628e..38d6b79c94 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -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 8ea4907c1b..e36fb16d5b 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -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 a4884a1033..633d51d052 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -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 8fba6861b0..c138c7b614 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -208,8 +208,8 @@ export const dict = { "model.tooltip.context": "Context limit {{limit}}", "common.search.placeholder": "Search", - "common.goBack": "Back", - "common.goForward": "Forward", + "common.goBack": "Navigate back", + "common.goForward": "Navigate forward", "common.loading": "Loading", "common.loading.ellipsis": "...", "common.cancel": "Cancel", @@ -583,11 +583,15 @@ 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", "settings.general.section.updates": "Updates", "settings.general.section.sounds": "Sound effects", + "settings.general.section.display": "Display", "settings.general.row.language.title": "Language", "settings.general.row.language.description": "Change the display language for OpenCode", @@ -598,6 +602,11 @@ export const dict = { "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Customise the mono font used in code blocks", + "settings.general.row.wayland.title": "Use native Wayland", + "settings.general.row.wayland.description": "Disable X11 fallback on Wayland. Requires restart.", + "settings.general.row.wayland.tooltip": + "On Linux with mixed refresh-rate monitors, native Wayland can be more stable.", + "settings.general.row.releaseNotes.title": "Release notes", "settings.general.row.releaseNotes.description": "Show What's New popups after updates", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 50d9060703..ff4198228a 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -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 7ad39f3406..402c095ba5 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -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 a39bfbaf33..312ac3262c 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -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 b5927b2107..b162ab3916 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -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 7d8cdd27f3..001b9eda65 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -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 76a47ea26f..2a20cd57e3 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -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 e83ce37618..698c8db581 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -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 2be19d15b1..161f37f3ba 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -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 a48f9e5494..a2931cf98c 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -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 60363fc99e..cae0c75b46 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -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/index.ts b/packages/app/src/index.ts index fb66820092..59e1431fa8 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,3 +1,3 @@ -export { PlatformProvider, type Platform } from "./context/platform" +export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform" export { AppBaseProviders, AppInterface } from "./app" export { useCommand } from "./context/command" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index f331dabc4e..b2c1fe4ecd 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 10f7dac530..6b61ed3004 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 59adef4694..a18b7ef237 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/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 6ecccb95cf..6a1e7c0123 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 facfbddc7f..678bfa0d86 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -21,8 +21,11 @@ const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const notification = useNotification() - const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree)) - const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree)) + const dirs = createMemo(() => [props.project.worktree, ...(props.project.sandboxes ?? [])]) + const unseenCount = createMemo(() => + dirs().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), + ) + const hasError = createMemo(() => dirs().some((directory) => notification.project.unseenHasError(directory))) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) return (
@@ -141,7 +144,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { 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 d4ada4f850..13c1e55ef8 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -2,6 +2,7 @@ import { useNavigate, useParams } from "@solidjs/router" import { createEffect, createMemo, For, Show, type Accessor, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createSortable } from "@thisbeyond/solid-dnd" +import { createMediaQuery } from "@solid-primitives/media" import { base64Encode } from "@opencode-ai/util/encode" import { getFilename } from "@opencode-ai/util/path" import { Button } from "@opencode-ai/ui/button" @@ -114,9 +115,10 @@ export const SortableWorkspace = (props: { const busy = createMemo(() => props.ctx.isBusy(props.directory)) const wasBusy = createMemo((prev) => prev || busy(), false) const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) - const showNew = createMemo(() => !loading() && (sessions().length === 0 || (active() && !params.id))) + 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) } @@ -270,23 +272,25 @@ export const SortableWorkspace = (props: { - - { - event.preventDefault() - event.stopPropagation() - props.ctx.setHoverSession(undefined) - props.ctx.clearHoverProjectSoon() - navigate(`/${slug()}/session`) - }} - /> - + + + { + event.preventDefault() + event.stopPropagation() + props.ctx.setHoverSession(undefined) + props.ctx.clearHoverProjectSoon() + navigate(`/${slug()}/session`) + }} + /> + +
@@ -364,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 a552ebb313..9453dd703c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -591,8 +591,7 @@ export default function Page() { const newSessionWorktree = createMemo(() => { if (store.newSessionWorktree === "create") return "create" const project = sync.project - const directory = sync.data.path.directory - if (project && directory && directory !== project.worktree) return directory + if (project && sdk.directory !== project.worktree) return sdk.directory return "main" }) @@ -1027,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} + />
) @@ -1042,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", })} @@ -1570,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} @@ -1648,7 +1668,7 @@ export default function Page() { const target = value === "main" ? sync.project?.worktree : value if (!target) return - if (target === sync.data.path.directory) return + if (target === sdk.directory) return layout.projects.open(target) navigate(`/${base64Encode(target)}/session`) }} @@ -1684,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/message-timeline.tsx b/packages/app/src/pages/session/message-timeline.tsx index f536c7061f..a4ca06dd5b 100644 --- a/packages/app/src/pages/session/message-timeline.tsx +++ b/packages/app/src/pages/session/message-timeline.tsx @@ -179,7 +179,7 @@ export function MessageTimeline(props: { "sticky top-0 z-30 bg-background-stronger": true, "w-full": true, "px-4 md:px-6": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered, + "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, }} >
@@ -278,7 +278,7 @@ export function MessageTimeline(props: { class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" classList={{ "w-full": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": props.centered, + "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered, "mt-0.5": props.centered, "mt-0": !props.centered, }} @@ -321,7 +321,7 @@ export function MessageTimeline(props: { }} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200 3xl:max-w-[1200px]": props.centered, + "md:max-w-200 2xl:max-w-[1000px]": props.centered, }} > { node.delete(key) visible.delete(key) dirty = true + schedule() } const markDirty = () => { diff --git a/packages/app/src/pages/session/session-prompt-dock.tsx b/packages/app/src/pages/session/session-prompt-dock.tsx index 6979570272..eaf0564b23 100644 --- a/packages/app/src/pages/session/session-prompt-dock.tsx +++ b/packages/app/src/pages/session/session-prompt-dock.tsx @@ -31,7 +31,7 @@ export function SessionPromptDock(props: {
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 09095d689c..2e65fde0e3 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 09c0fd17cc..d52022d73a 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) + + const write = (value: string) => { + const body = typeof document === "undefined" ? undefined : document.body + if (body) { + const textarea = document.createElement("textarea") + textarea.value = value + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + textarea.style.pointerEvents = "none" + body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + body.removeChild(textarea) + if (copied) return Promise.resolve(true) + } + + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard + if (!clipboard?.writeText) return Promise.resolve(false) + return clipboard.writeText(value).then( + () => true, + () => false, + ) + } + + const copy = async (url: string, existing: boolean) => { + const ok = await write(url) + if (!ok) { + showToast({ + title: input.language.t("toast.session.share.copyFailed.title"), + variant: "error", + }) + return + } + + showToast({ + title: existing + ? input.language.t("session.share.copy.copied") + : input.language.t("toast.session.share.success.title"), + description: input.language.t("toast.session.share.success.description"), + variant: "success", + }) + } + + const existing = input.info()?.share?.url + if (existing) { + await copy(existing, true) return } - await input.sdk.client.session + + const url = await input.sdk.client.session .share({ sessionID: input.params.id }) - .then((res) => copy(res.data!.share!.url, false)) - .catch(() => - showToast({ - title: input.language.t("toast.session.share.failed.title"), - description: input.language.t("toast.session.share.failed.description"), - variant: "error", - }), - ) + .then((res) => res.data?.share?.url) + .catch(() => undefined) + if (!url) { + showToast({ + title: input.language.t("toast.session.share.failed.title"), + description: input.language.t("toast.session.share.failed.description"), + variant: "error", + }) + return + } + + await copy(url, false) }, }, { diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index 5be68f3b0e..2a2c349b75 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -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 329a406dd8..57e01d86a9 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -195,6 +195,14 @@ function parse(value: string) { } } +function normalize(defaults: unknown, raw: string, migrate?: (value: unknown) => unknown) { + const parsed = parse(raw) + if (parsed === undefined) return + const migrated = migrate ? migrate(parsed) : parsed + const merged = merge(defaults, migrated) + return JSON.stringify(merged) +} + function workspaceStorage(dir: string) { const head = dir.slice(0, 12) || "workspace" const sum = checksum(dir) ?? "0" @@ -291,6 +299,7 @@ function localStorageDirect(): SyncStorage { export const PersistTesting = { localStorageDirect, localStorageWithPrefix, + normalize, } export const Persist = { @@ -358,12 +367,11 @@ export function persisted( 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/console/app/package.json b/packages/console/app/package.json index 2c289f78b9..eebbbeb9ce 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.56", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index be53ad909b..e64d364620 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 1aecdb846f..08c716aba3 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 6f3151676f..a1c04bc0af 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/lib/language.ts b/packages/console/app/src/lib/language.ts index b7165ed6ca..54321d2343 100644 --- a/packages/console/app/src/lib/language.ts +++ b/packages/console/app/src/lib/language.ts @@ -68,6 +68,82 @@ const TAG = { tr: "tr", } satisfies Record +const DOCS = { + en: "root", + zh: "zh-cn", + zht: "zh-tw", + ko: "ko", + de: "de", + es: "es", + fr: "fr", + it: "it", + da: "da", + ja: "ja", + pl: "pl", + ru: "ru", + ar: "ar", + no: "nb", + br: "pt-br", + th: "th", + tr: "tr", +} satisfies Record + +const DOCS_SEGMENT = new Set([ + "ar", + "bs", + "da", + "de", + "es", + "fr", + "it", + "ja", + "ko", + "nb", + "pl", + "pt-br", + "ru", + "th", + "tr", + "zh-cn", + "zh-tw", +]) + +function suffix(pathname: string) { + const index = pathname.search(/[?#]/) + if (index === -1) { + return { + path: fix(pathname), + suffix: "", + } + } + + return { + path: fix(pathname.slice(0, index)), + suffix: pathname.slice(index), + } +} + +export function docs(locale: Locale, pathname: string) { + const value = DOCS[locale] + const next = suffix(pathname) + if (next.path !== "/docs" && next.path !== "/docs/" && !next.path.startsWith("/docs/")) { + return `${next.path}${next.suffix}` + } + + if (value === "root") return `${next.path}${next.suffix}` + + if (next.path === "/docs") return `/docs/${value}${next.suffix}` + if (next.path === "/docs/") return `/docs/${value}/${next.suffix}` + + const head = next.path.slice("/docs/".length).split("/")[0] ?? "" + if (!head) return `/docs/${value}/${next.suffix}` + if (DOCS_SEGMENT.has(head)) return `${next.path}${next.suffix}` + if (head.startsWith("_")) return `${next.path}${next.suffix}` + if (head.includes(".")) return `${next.path}${next.suffix}` + + return `/docs/${value}${next.path.slice("/docs".length)}${next.suffix}` +} + export function parseLocale(value: unknown): Locale | null { if (typeof value !== "string") return null if ((LOCALES as readonly string[]).includes(value)) return value as Locale @@ -90,7 +166,7 @@ export function strip(pathname: string) { export function route(locale: Locale, pathname: string) { const next = strip(pathname) - if (next.startsWith("/docs")) return next + if (next.startsWith("/docs")) return docs(locale, next) if (next.startsWith("/auth")) return next if (next.startsWith("/workspace")) return next if (locale === "en") return next diff --git a/packages/console/app/src/routes/docs/[...path].ts b/packages/console/app/src/routes/docs/[...path].ts index 0711b5ce0f..bbe07f1f07 100644 --- a/packages/console/app/src/routes/docs/[...path].ts +++ b/packages/console/app/src/routes/docs/[...path].ts @@ -1,14 +1,16 @@ import type { APIEvent } from "@solidjs/start/server" -import { LOCALE_HEADER, localeFromCookieHeader, parseLocale, tag } from "~/lib/language" +import { Resource } from "@opencode-ai/console-resource" +import { docs, localeFromRequest, tag } from "~/lib/language" async function handler(evt: APIEvent) { const req = evt.request.clone() const url = new URL(req.url) - const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}` + const locale = localeFromRequest(req) + const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai" + const targetUrl = `https://${host}${docs(locale, url.pathname)}${url.search}` const headers = new Headers(req.headers) - const locale = parseLocale(req.headers.get(LOCALE_HEADER)) ?? localeFromCookieHeader(req.headers.get("cookie")) - if (locale) headers.set("accept-language", tag(locale)) + headers.set("accept-language", tag(locale)) const response = await fetch(targetUrl, { method: req.method, diff --git a/packages/console/app/src/routes/docs/index.ts b/packages/console/app/src/routes/docs/index.ts index 0711b5ce0f..bbe07f1f07 100644 --- a/packages/console/app/src/routes/docs/index.ts +++ b/packages/console/app/src/routes/docs/index.ts @@ -1,14 +1,16 @@ import type { APIEvent } from "@solidjs/start/server" -import { LOCALE_HEADER, localeFromCookieHeader, parseLocale, tag } from "~/lib/language" +import { Resource } from "@opencode-ai/console-resource" +import { docs, localeFromRequest, tag } from "~/lib/language" async function handler(evt: APIEvent) { const req = evt.request.clone() const url = new URL(req.url) - const targetUrl = `https://docs.opencode.ai${url.pathname}${url.search}` + const locale = localeFromRequest(req) + const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai" + const targetUrl = `https://${host}${docs(locale, url.pathname)}${url.search}` const headers = new Headers(req.headers) - const locale = parseLocale(req.headers.get(LOCALE_HEADER)) ?? localeFromCookieHeader(req.headers.get("cookie")) - if (locale) headers.set("accept-language", tag(locale)) + headers.set("accept-language", tag(locale)) const response = await fetch(targetUrl, { method: req.method, diff --git a/packages/console/app/src/routes/download/index.tsx b/packages/console/app/src/routes/download/index.tsx index b5dbbd39a9..e5e4e97502 100644 --- a/packages/console/app/src/routes/download/index.tsx +++ b/packages/console/app/src/routes/download/index.tsx @@ -294,7 +294,7 @@ export default function Download() { VS Code
-
+ {i18n.t("download.action.install")}
@@ -318,7 +318,7 @@ export default function Download() { Cursor - + {i18n.t("download.action.install")} @@ -335,7 +335,7 @@ export default function Download() { Zed - + {i18n.t("download.action.install")} @@ -352,7 +352,7 @@ export default function Download() { Windsurf - + {i18n.t("download.action.install")} @@ -369,7 +369,7 @@ export default function Download() { VSCodium - + {i18n.t("download.action.install")} @@ -393,7 +393,7 @@ export default function Download() { GitHub - + {i18n.t("download.action.install")} @@ -410,7 +410,7 @@ export default function Download() { GitLab - + {i18n.t("download.action.install")} diff --git a/packages/console/app/src/routes/s/[id].ts b/packages/console/app/src/routes/s/[id].ts index 628a75b2e3..60f8d8ba87 100644 --- a/packages/console/app/src/routes/s/[id].ts +++ b/packages/console/app/src/routes/s/[id].ts @@ -1,14 +1,16 @@ import type { APIEvent } from "@solidjs/start/server" -import { LOCALE_HEADER, localeFromCookieHeader, parseLocale, tag } from "~/lib/language" +import { Resource } from "@opencode-ai/console-resource" +import { docs, localeFromRequest, tag } from "~/lib/language" async function handler(evt: APIEvent) { const req = evt.request.clone() const url = new URL(req.url) - const targetUrl = `https://docs.opencode.ai/docs${url.pathname}${url.search}` + const locale = localeFromRequest(req) + const host = Resource.App.stage === "production" ? "docs.opencode.ai" : "docs.dev.opencode.ai" + const targetUrl = `https://${host}${docs(locale, `/docs${url.pathname}`)}${url.search}` const headers = new Headers(req.headers) - const locale = parseLocale(req.headers.get(LOCALE_HEADER)) ?? localeFromCookieHeader(req.headers.get("cookie")) - if (locale) headers.set("accept-language", tag(locale)) + headers.set("accept-language", tag(locale)) const response = await fetch(targetUrl, { method: req.method, diff --git a/packages/console/app/src/routes/workspace/[id]/index.tsx b/packages/console/app/src/routes/workspace/[id]/index.tsx index 408f6a1133..c91cfd2bcc 100644 --- a/packages/console/app/src/routes/workspace/[id]/index.tsx +++ b/packages/console/app/src/routes/workspace/[id]/index.tsx @@ -9,10 +9,12 @@ import { GraphSection } from "./graph-section" import { IconLogo } from "~/component/icon" import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common" import { useI18n } from "~/context/i18n" +import { useLanguage } from "~/context/language" export default function () { const params = useParams() const i18n = useI18n() + const language = useLanguage() const userInfo = createAsync(() => querySessionInfo(params.id!)) const billingInfo = createAsync(() => queryBillingInfo(params.id!)) const checkoutAction = useAction(createCheckoutUrl) @@ -38,7 +40,7 @@ export default function () {

{i18n.t("workspace.home.banner.beforeLink")}{" "} - + {i18n.t("common.learnMore")} . diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index b97b734307..a3a93d2ef8 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 af2a8c3e60..9646cacd05 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 90e10479c4..5e4f31e676 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/test/rateLimiter.test.ts b/packages/console/app/test/rateLimiter.test.ts new file mode 100644 index 0000000000..864f907d66 --- /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 4304b17790..06013e5376 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.56", "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 e1e540fb7a..9a2908e32e 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 7ad6c9934c..b7d58641ee 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.56", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/function/src/log-processor.ts b/packages/console/function/src/log-processor.ts index 9e76e2ceb0..327fc930b7 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 c314f3392d..7bcfe3efb8 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.56", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/AGENTS.md b/packages/desktop/AGENTS.md new file mode 100644 index 0000000000..3839db1a90 --- /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 8f09ed169f..a3a92b9559 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.56", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index 98c38677b4..6882d369e9 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -3,8 +3,12 @@ use tauri_plugin_shell::{ ShellExt, process::{Command, CommandChild, CommandEvent}, }; +use tauri_plugin_store::StoreExt; -use crate::{LogState, constants::MAX_LOG_ENTRIES}; +use crate::{ + LogState, + constants::{MAX_LOG_ENTRIES, SETTINGS_STORE, WSL_ENABLED_KEY}, +}; const CLI_INSTALL_DIR: &str = ".opencode/bin"; const CLI_BINARY_NAME: &str = "opencode"; @@ -21,7 +25,7 @@ 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}")) @@ -150,25 +154,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("opencode-cli") - .unwrap() - .args(args.split_whitespace()) - .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") - .env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true") - .env("OPENCODE_CLIENT", "desktop") - .env("XDG_STATE_HOME", &state_dir); + 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())), + ); - #[cfg(not(target_os = "windows"))] - return { + if cfg!(windows) { + if is_wsl_enabled(app) { + println!("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,14 +263,14 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command { format!("\"{}\" {}", sidecar.display(), args) }; - app.shell() - .command(&shell) - .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") - .env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true") - .env("OPENCODE_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 { @@ -194,12 +279,16 @@ pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> Comm 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(), + &envs, ) - .env("OPENCODE_SERVER_USERNAME", "opencode") - .env("OPENCODE_SERVER_PASSWORD", password) .spawn() .expect("Failed to spawn opencode"); diff --git a/packages/desktop/src-tauri/src/constants.rs b/packages/desktop/src-tauri/src/constants.rs index ac3e1d02ad..cdf05fb458 100644 --- a/packages/desktop/src-tauri/src/constants.rs +++ b/packages/desktop/src-tauri/src/constants.rs @@ -2,6 +2,7 @@ use tauri_plugin_window_state::StateFlags; 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; diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 472d9e233a..0b2463688f 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -2,6 +2,8 @@ mod cli; mod constants; #[cfg(windows)] mod job_object; +#[cfg(target_os = "linux")] +pub mod linux_display; mod markdown; mod server; mod ssh; @@ -19,9 +21,9 @@ use std::{ env, net::TcpListener, path::PathBuf, + process::Command, sync::{Arc, Mutex}, time::Duration, - process::Command, }; use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel}; #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] @@ -51,6 +53,13 @@ enum InitStep { Done, } +#[derive(serde::Deserialize, specta::Type)] +#[serde(rename_all = "snake_case")] +enum WslPathMode { + Windows, + Linux, +} + struct InitState { current: watch::Receiver, } @@ -151,12 +160,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) @@ -164,11 +173,165 @@ fn check_app_exists(app_name: &str) -> bool { } #[cfg(target_os = "windows")] -fn check_windows_app(app_name: &str) -> bool { +fn check_windows_app(_app_name: &str) -> bool { // Check if command exists in PATH, including .exe return true; } +#[cfg(target_os = "windows")] +fn resolve_windows_app_path(app_name: &str) -> Option { + use std::path::{Path, PathBuf}; + + // Try to find the command using 'where' + let output = Command::new("where").arg(app_name).output().ok()?; + + if !output.status.success() { + return None; + } + + let paths = String::from_utf8_lossy(&output.stdout) + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(PathBuf::from) + .collect::>(); + + let has_ext = |path: &Path, ext: &str| { + path.extension() + .and_then(|v| v.to_str()) + .map(|v| v.eq_ignore_ascii_case(ext)) + .unwrap_or(false) + }; + + if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) { + return Some(path.to_string_lossy().to_string()); + } + + let resolve_cmd = |path: &Path| -> Option { + let content = std::fs::read_to_string(path).ok()?; + + for token in content.split('"') { + let lower = token.to_ascii_lowercase(); + if !lower.contains(".exe") { + continue; + } + + if let Some(index) = lower.find("%~dp0") { + let base = path.parent()?; + let suffix = &token[index + 5..]; + let mut resolved = PathBuf::from(base); + + for part in suffix.replace('/', "\\").split('\\') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." { + let _ = resolved.pop(); + continue; + } + resolved.push(part); + } + + if resolved.exists() { + return Some(resolved.to_string_lossy().to_string()); + } + } + + let resolved = PathBuf::from(token); + if resolved.exists() { + return Some(resolved.to_string_lossy().to_string()); + } + } + + None + }; + + for path in &paths { + if has_ext(path, "cmd") || has_ext(path, "bat") { + if let Some(resolved) = resolve_cmd(path) { + return Some(resolved); + } + } + + if path.extension().is_none() { + let cmd = path.with_extension("cmd"); + if cmd.exists() { + if let Some(resolved) = resolve_cmd(&cmd) { + return Some(resolved); + } + } + + let bat = path.with_extension("bat"); + if bat.exists() { + if let Some(resolved) = resolve_cmd(&bat) { + return Some(resolved); + } + } + } + } + + let key = app_name + .chars() + .filter(|v| v.is_ascii_alphanumeric()) + .flat_map(|v| v.to_lowercase()) + .collect::(); + + 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()) +} + +#[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")] fn check_macos_app(app_name: &str) -> bool { // Check common installation locations @@ -180,13 +343,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) @@ -195,37 +358,92 @@ fn check_macos_app(app_name: &str) -> bool { .unwrap_or(false) } +#[derive(serde::Serialize, serde::Deserialize, specta::Type)] +#[serde(rename_all = "camelCase")] +pub enum LinuxDisplayBackend { + Wayland, + Auto, +} + +#[tauri::command] +#[specta::specta] +fn get_display_backend() -> Option { + #[cfg(target_os = "linux")] + { + let prefer = linux_display::read_wayland().unwrap_or(false); + return Some(if prefer { + LinuxDisplayBackend::Wayland + } else { + LinuxDisplayBackend::Auto + }); + } + + #[cfg(not(target_os = "linux"))] + None +} + +#[tauri::command] +#[specta::specta] +fn set_display_backend(_app: AppHandle, _backend: LinuxDisplayBackend) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + let prefer = matches!(_backend, LinuxDisplayBackend::Wayland); + return linux_display::write_wayland(&_app, prefer); + } + + #[cfg(not(target_os = "linux"))] + Ok(()) +} + #[cfg(target_os = "linux")] 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, - markdown::parse_markdown_command, - check_app_exists, - ssh::ssh_connect, - ssh::ssh_disconnect, - ssh::ssh_prompt_reply - ]) - .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") @@ -285,6 +503,47 @@ pub fn run() { }); } +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, + ssh::ssh_connect, + ssh::ssh_disconnect, + ssh::ssh_prompt_reply + ]) + .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; diff --git a/packages/desktop/src-tauri/src/linux_display.rs b/packages/desktop/src-tauri/src/linux_display.rs new file mode 100644 index 0000000000..9e1cf90918 --- /dev/null +++ b/packages/desktop/src-tauri/src/linux_display.rs @@ -0,0 +1,47 @@ +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::path::PathBuf; +use tauri::AppHandle; +use tauri_plugin_store::StoreExt; + +use crate::constants::SETTINGS_STORE; + +pub const LINUX_DISPLAY_CONFIG_KEY: &str = "linuxDisplayConfig"; + +#[derive(Default, Serialize, Deserialize)] +struct DisplayConfig { + wayland: Option, +} + +fn dir() -> Option { + Some(dirs::data_dir()?.join("ai.opencode.desktop")) +} + +fn path() -> Option { + dir().map(|dir| dir.join(SETTINGS_STORE)) +} + +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 +} + +pub fn write_wayland(app: &AppHandle, value: bool) -> Result<(), String> { + let store = app + .store(SETTINGS_STORE) + .map_err(|e| format!("Failed to open settings store: {}", e))?; + + store.set( + LINUX_DISPLAY_CONFIG_KEY, + json!(DisplayConfig { + wayland: Some(value), + }), + ); + store + .save() + .map_err(|e| format!("Failed to save settings store: {}", e))?; + + Ok(()) +} diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 1e61045add..77c6b428fb 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -23,12 +23,16 @@ fn configure_display_backend() -> Option { return None; } - // Allow users to explicitly keep Wayland if they know their setup is stable. - let allow_wayland = matches!( - env::var("OC_ALLOW_WAYLAND"), - Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes") - ); + let prefer_wayland = opencode_lib::linux_display::read_wayland().unwrap_or(false); + let allow_wayland = prefer_wayland + || matches!( + env::var("OC_ALLOW_WAYLAND"), + Ok(v) if matches!(v.to_ascii_lowercase().as_str(), "1" | "true" | "yes") + ); if allow_wayland { + if prefer_wayland { + return Some("Wayland session detected; using native Wayland from settings".into()); + } return Some("Wayland session detected; respecting OC_ALLOW_WAYLAND=1".into()); } @@ -51,29 +55,33 @@ fn configure_display_backend() -> Option { ) } +trait AskpassStream: std::io::Read + std::io::Write {} + +impl AskpassStream for T where T: std::io::Read + std::io::Write {} + #[cfg(unix)] -fn askpass_stream(socket: &str) -> Result, String> { +fn askpass_stream(socket: &str) -> Result, String> { if let Some(addr) = socket.strip_prefix("tcp:") { let stream = std::net::TcpStream::connect(addr) .map_err(|e| format!("askpass connect failed: {e}"))?; - let boxed: Box = Box::new(stream); + let boxed: Box = Box::new(stream); return Ok(boxed); } use std::os::unix::net::UnixStream; let stream = UnixStream::connect(socket).map_err(|e| format!("askpass connect failed: {e}"))?; - let boxed: Box = Box::new(stream); + let boxed: Box = Box::new(stream); Ok(boxed) } #[cfg(not(unix))] -fn askpass_stream(socket: &str) -> Result, String> { +fn askpass_stream(socket: &str) -> Result, String> { let addr = socket .strip_prefix("tcp:") .ok_or_else(|| "askpass socket is not tcp on this platform".to_string())?; let stream = std::net::TcpStream::connect(addr).map_err(|e| format!("askpass connect failed: {e}"))?; - let boxed: Box = Box::new(stream); + let boxed: Box = Box::new(stream); Ok(boxed) } diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 2a78411a43..8113fc7e31 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,6 +59,38 @@ 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}"); diff --git a/packages/desktop/src-tauri/src/ssh.rs b/packages/desktop/src-tauri/src/ssh.rs index 25ac6f4cfd..53d9fc38f5 100644 --- a/packages/desktop/src-tauri/src/ssh.rs +++ b/packages/desktop/src-tauri/src/ssh.rs @@ -7,7 +7,7 @@ use std::{ use tauri::{AppHandle, Emitter as _, Manager}; use tokio::{ - io::{AsyncBufReadExt as _, AsyncReadExt as _, AsyncWriteExt as _, BufReader}, + io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, process::{Child, Command}, sync::{Mutex, oneshot}, }; @@ -283,7 +283,11 @@ fn control_args(socket_path: Option<&Path>, mode: ControlMode) -> Vec { args } -async fn wait_master_ready(askpass: &Askpass, spec: &Spec, socket_path: &Path) -> Result<(), String> { +async fn wait_master_ready( + askpass: &Askpass, + spec: &Spec, + socket_path: &Path, +) -> Result<(), String> { let start = Instant::now(); loop { if start.elapsed() > Duration::from_secs(30) { @@ -369,11 +373,7 @@ async fn ensure_remote_opencode( Ok(()) } -async fn spawn_master( - askpass: &Askpass, - spec: &Spec, - socket_path: &Path, -) -> Result { +async fn spawn_master(askpass: &Askpass, spec: &Spec, socket_path: &Path) -> Result { let mut child = ssh_spawn_bg( askpass, [ diff --git a/packages/desktop/src-tauri/src/windows.rs b/packages/desktop/src-tauri/src/windows.rs index cf3e399e34..2ddcb0506d 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, @@ -36,6 +44,7 @@ impl MainWindow { r#" window.__OPENCODE__ ??= {{}}; window.__OPENCODE__.updaterEnabled = {UPDATER_ENABLED}; + window.__OPENCODE__.wsl = {wsl_enabled}; "# )); diff --git a/packages/desktop/src/i18n/index.ts b/packages/desktop/src/i18n/index.ts index 376769e282..7b1ebfe696 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 c0ed6f26e3..6d9386da3e 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,7 +1,14 @@ // @refresh reload import { webviewZoom } from "./webview-zoom" import { render } from "solid-js/web" -import { AppBaseProviders, AppInterface, PlatformProvider, Platform, useCommand } from "@opencode-ai/app" +import { + AppBaseProviders, + AppInterface, + PlatformProvider, + Platform, + DisplayBackend, + useCommand, +} from "@opencode-ai/app" import { open, save } from "@tauri-apps/plugin-dialog" import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener" @@ -28,7 +35,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, invoke } from "@tauri-apps/api/core" import { createMenu } from "./menu" @@ -48,14 +55,6 @@ type SshPrompt = { id: string; prompt: string } const sshPromptEvent = "opencode:ssh-prompt" const sshPrompts: SshPrompt[] = [] -type DesktopPlatform = Platform & { - serverKey: (url: string) => string - isServerLocal: (url: string) => boolean - sshConnect: (command: string) => Promise<{ url: string; key: string; password: string | null }> - sshDisconnect: (key: string) => Promise - wsAuth: (url: string) => { username: string; password: string } | null -} - void listen("ssh_prompt", (event) => { sshPrompts.push(event.payload) window.dispatchEvent(new CustomEvent(sshPromptEvent)) @@ -100,418 +99,449 @@ const listenForDeepLinks = async () => { const createPlatform = ( password: Accessor, sshState: { get: Accessor; set: (value: boolean) => void }, -): DesktopPlatform => ({ - platform: "desktop", - os: (() => { +): 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 - }, + const wslHome = async () => { + if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined + return commands.wslPath("~", "windows").catch(() => undefined) + } - 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 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 + } - const WRITE_DEBOUNCE_MS = 250 + return { + platform: "desktop", + os, + version: pkg.version, - const storeCache = new Map>() - const apiCache = new Map Promise }>() - const memoryCache = new Map() + 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) + }, - const flushAll = async () => { - const apis = Array.from(apiCache.values()) - await Promise.all(apis.map((api) => api.flush().catch(() => undefined))) - } + async openFilePickerDialog(opts) { + const result = await open({ + directory: false, + multiple: opts?.multiple ?? false, + title: opts?.title ?? t("desktop.dialog.chooseFile"), + }) + return handleWslPicker(result) + }, - if ("addEventListener" in globalThis) { - const handleVisibility = () => { - if (document.visibilityState !== "hidden") return - void flushAll() + 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 + } + + 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 } - window.addEventListener("pagehide", () => void flushAll()) - document.addEventListener("visibilitychange", handleVisibility) - } + const WRITE_DEBOUNCE_MS = 250 - 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, + 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))) } - return store - } - const getStore = (name: string) => { - const cached = storeCache.get(name) - if (cached) return cached + if ("addEventListener" in globalThis) { + const handleVisibility = () => { + if (document.visibilityState !== "hidden") return + void flushAll() + } - const store = Store.load(name).catch(() => { - const cached = memoryCache.get(name) + window.addEventListener("pagehide", () => void flushAll()) + document.addEventListener("visibilitychange", handleVisibility) + } + + 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 getStore = (name: string) => { + const cached = storeCache.get(name) if (cached) return cached - const memory = createMemoryStore() - memoryCache.set(name, memory) - return memory - }) + const store = Store.load(name).catch(() => { + const cached = memoryCache.get(name) + if (cached) return cached - storeCache.set(name, store) - return store - } + const memory = createMemoryStore() + memoryCache.set(name, memory) + return memory + }) - const createStorage = (name: string) => { - const pending = new Map() - let timer: ReturnType | undefined - let flushing: Promise | undefined + storeCache.set(name, store) + return store + } - const flush = async () => { - if (flushing) return flushing + const createStorage = (name: string) => { + const pending = new Map() + let timer: ReturnType | undefined + let flushing: Promise | undefined - 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 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 - }) - - return flushing - } - - 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() - }, - } - - return api - } - - 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 () => { - const keys = Array.from(new Set(ssh.values())) - await Promise.all(keys.map((key) => invoke("ssh_disconnect", { key }).catch(() => undefined))) - 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() + })().finally(() => { + flushing = undefined + }) } - }) - .catch(() => undefined) - }, - fetch: (input, init) => { - if (typeof input === "string" && input.startsWith("/") && base) { - input = base + input - } + const schedule = () => { + if (timer) return + timer = setTimeout(() => { + timer = undefined + void flush() + }, WRITE_DEBOUNCE_MS) + } - const origin = (() => { + 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 (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")) + } + notification.close() + } + }) + .catch(() => undefined) + }, + + fetch: (input, init) => { + if (typeof input === "string" && input.startsWith("/") && base) { + input = base + input + } + + const origin = (() => { + try { + const url = input instanceof Request ? input.url : String(input) + return new URL(url).origin + } catch { + return null + } + })() + + const pw = origin ? (auth.get(origin) ?? null) : 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, + }) + } + }, + + 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 + }, + + serverKey: (url) => { + const origin = (() => { + try { + return new URL(url).origin + } catch { + return "" + } + })() + const key = origin ? ssh.get(origin) : undefined + if (key) return `ssh:${key}` + if (origin.includes("localhost") || origin.includes("127.0.0.1") || origin.includes("[::1]")) return "local" + return url + }, + + isServerLocal: (url) => { + const origin = (() => { + try { + return new URL(url).origin + } catch { + return null + } + })() + if (origin && ssh.has(origin)) return false + if (!origin) return false + }, + + sshConnect: async (command) => { + sshState.set(true) try { - const url = input instanceof Request ? input.url : String(input) - return new URL(url).origin + const result = await invoke<{ key: string; url: string; password: string; destination: string }>( + "ssh_connect", + { + command, + }, + ) + const origin = new URL(result.url).origin + ssh.set(origin, result.key) + auth.set(origin, result.password) + return { url: result.url, key: result.key, password: result.password } + } finally { + sshState.set(false) + } + }, + + wsAuth: (url) => { + try { + const origin = new URL(url).origin + const pw = auth.get(origin) ?? password() + if (!pw) return null + return { username: "opencode", password: pw } } catch { return null } - })() + }, - const pw = origin ? (auth.get(origin) ?? null) : password() - - const addHeader = (headers: Headers, password: string) => { - headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`) - } - - const logError = async (url: string, res: Response) => { - if (res.ok) return - // keep it minimal; enough to debug auth/baseUrl issues - const text = await res - .clone() - .text() - .catch(() => "") - console.error("fetch failed", { url, status: res.status, statusText: res.statusText, body: text.slice(0, 400) }) - } - - if (input instanceof Request) { - if (pw) addHeader(input.headers, pw) - return tauriFetch(input).then((res) => { - void logError(input.url, res) - return res - }) - } else { - const headers = new Headers(init?.headers) - if (pw) addHeader(headers, pw) - const url = String(input) - return tauriFetch(url, { - ...(init as any), - headers: headers, - }).then((res) => { - void logError(url, res) - return res - }) - } - }, - - getDefaultServerUrl: async () => { - const result = await commands.getDefaultServerUrl().catch(() => null) - return result - }, - - setDefaultServerUrl: async (url: string | null) => { - await commands.setDefaultServerUrl(url) - }, - - serverKey: (url) => { - const origin = (() => { - try { - return new URL(url).origin - } catch { - return "" + sshDisconnect: async (key) => { + await invoke("ssh_disconnect", { key }) + for (const [origin, k] of ssh.entries()) { + if (k !== key) continue + ssh.delete(origin) + auth.delete(origin) } - })() - const key = origin ? ssh.get(origin) : undefined - if (key) return `ssh:${key}` - if (origin.includes("localhost") || origin.includes("127.0.0.1") || origin.includes("[::1]")) return "local" - return url - }, + }, - isServerLocal: (url) => { - const origin = (() => { - try { - return new URL(url).origin - } catch { - return null - } - })() - if (origin && ssh.has(origin)) return false - if (!origin) return false - return origin.includes("localhost") || origin.includes("127.0.0.1") || origin.includes("[::1]") - }, + setDefaultServerUrl: async (url: string | null) => { + await commands.setDefaultServerUrl(url) + }, - sshConnect: async (command) => { - sshState.set(true) - try { - const result = await invoke<{ key: string; url: string; password: string; destination: string }>("ssh_connect", { - command, - }) - const origin = new URL(result.url).origin - ssh.set(origin, result.key) - auth.set(origin, result.password) - return { url: result.url, key: result.key, password: result.password } - } finally { - sshState.set(false) - } - }, + getDisplayBackend: async () => { + const result = await commands.getDisplayBackend().catch(() => null) + return result + }, - sshDisconnect: async (key) => { - await invoke("ssh_disconnect", { key }) - for (const [origin, k] of ssh.entries()) { - if (k !== key) continue - ssh.delete(origin) - auth.delete(origin) - } - }, + setDisplayBackend: async (backend) => { + await commands.setDisplayBackend(backend) + }, - wsAuth: (url) => { - try { - const origin = new URL(url).origin - const pw = auth.get(origin) ?? password() - if (!pw) return null - return { username: "opencode", password: pw } - } catch { - return null - } - }, + parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), - parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown), + webviewZoom, - webviewZoom, + checkAppExists: async (appName: string) => { + return commands.checkAppExists(appName) + }, - 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") - }) - }, -}) + 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") + }).catch(() => undefined) + }, + } +} let menuTrigger = null as null | ((id: string) => void) createMenu((id) => { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 289d8fcb8d..5d37ba2f52 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.56", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 216f864fee..5eb067f4d3 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.53" +version = "1.1.56" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.53/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.56/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index b3086a180d..7c03e7ef70 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.56", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml index db64a09a98..c4617527d0 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 a2024a7f7c..b460df2430 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.53", + "version": "1.1.56", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index f38731676c..ae6f6fcc29 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -228,8 +228,8 @@ export namespace ACP { const metadata = permission.metadata || {} const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" - - const content = await Bun.file(filepath).text() + const file = Bun.file(filepath) + const content = (await file.exists()) ? await file.text() : "" const newContent = getNewContent(content, diff) if (newContent) { diff --git a/packages/opencode/src/agent/prompt/compaction.txt b/packages/opencode/src/agent/prompt/compaction.txt index b919671a0a..3308627e15 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/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index 4ad92eeb83..c30b8d12a9 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" @@ -20,96 +20,51 @@ 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 === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect: () => { - dialog.clear() - local.model.set( - { - providerID: provider.id, - modelID: model.id, - }, - { recent: true }, - ) - }, + function toOptions(items: typeof favorites, category: string) { + if (!showSections) return [] + return items.flatMap((item) => { + const provider = sync.data.provider.find((x) => x.id === item.providerID) + if (!provider) return [] + const model = provider.models[item.modelID] + if (!model) return [] + return [ + { + key: item, + value: { providerID: provider.id, modelID: model.id }, + title: model.name ?? item.modelID, + description: provider.name, + category, + disabled: provider.id === "opencode" && model.id.includes("-nano"), + footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect: () => { + dialog.clear() + local.model.set({ providerID: provider.id, modelID: model.id }, { recent: true }) }, - ] - }) - : [] + }, + ] + }) + } - const recentOptions = showSections - ? recentList.flatMap((item) => { - const provider = sync.data.provider.find((x) => x.id === item.providerID) - if (!provider) return [] - const model = provider.models[item.modelID] - if (!model) return [] - return [ - { - key: item, - value: { - providerID: provider.id, - modelID: model.id, - }, - title: model.name ?? item.modelID, - description: provider.name, - category: "Recent", - disabled: provider.id === "opencode" && model.id.includes("-nano"), - footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect: () => { - dialog.clear() - local.model.set( - { - providerID: provider.id, - modelID: model.id, - }, - { recent: true }, - ) - }, - }, - ] - }) - : [] + const favoriteOptions = toOptions(favorites, "Favorites") + const recentOptions = toOptions( + recents.filter( + (item) => !favorites.some((fav) => fav.providerID === item.providerID && fav.modelID === item.modelID), + ), + "Recent", + ) const providerOptions = pipe( sync.data.provider, @@ -123,45 +78,26 @@ export function DialogModel(props: { providerID?: string }) { entries(), filter(([_, info]) => info.status !== "deprecated"), filter(([_, info]) => (props.providerID ? info.providerID === props.providerID : true)), - map(([model, info]) => { - const value = { - providerID: provider.id, - modelID: model, - } - return { - value, - title: info.name ?? model, - description: favorites.some( - (item) => item.providerID === value.providerID && item.modelID === value.modelID, - ) - ? "(Favorite)" - : undefined, - category: connected() ? provider.name : undefined, - disabled: provider.id === "opencode" && model.includes("-nano"), - footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, - onSelect() { - dialog.clear() - local.model.set( - { - providerID: provider.id, - modelID: model, - }, - { recent: true }, - ) - }, - } - }), + map(([model, info]) => ({ + value: { providerID: provider.id, modelID: model }, + title: info.name ?? model, + description: favorites.some((item) => item.providerID === provider.id && item.modelID === model) + ? "(Favorite)" + : undefined, + category: connected() ? provider.name : undefined, + disabled: provider.id === "opencode" && model.includes("-nano"), + footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, + onSelect() { + dialog.clear() + local.model.set({ providerID: provider.id, modelID: model }, { recent: true }) + }, + })), filter((x) => { if (!showSections) return true - const value = x.value - const inFavorites = favorites.some( - (item) => item.providerID === value.providerID && item.modelID === value.modelID, - ) - if (inFavorites) return false - const inRecents = recents.some( - (item) => item.providerID === value.providerID && item.modelID === value.modelID, - ) - if (inRecents) return false + if (favorites.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID)) + return false + if (recents.some((item) => item.providerID === x.value.providerID && item.modelID === x.value.modelID)) + return false return true }), sortBy( @@ -175,21 +111,19 @@ export function DialogModel(props: { providerID?: string }) { const popularProviders = !connected() ? pipe( providers(), - map((option) => { - return { - ...option, - category: "Popular providers", - } - }), + map((option) => ({ + ...option, + category: "Popular providers", + })), take(6), ) : [] - // Search shows a single merged list (favorites inline) if (needle) { - const filteredProviders = fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj) - const filteredPopular = fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj) - return [...filteredProviders, ...filteredPopular] + return [ + ...fuzzysort.go(needle, providerOptions, { keys: ["title", "category"] }).map((x) => x.obj), + ...fuzzysort.go(needle, popularProviders, { keys: ["title"] }).map((x) => x.obj), + ] } return [...favoriteOptions, ...recentOptions, ...providerOptions, ...popularProviders] @@ -199,13 +133,11 @@ export function DialogModel(props: { providerID?: string }) { props.providerID ? sync.data.provider.find((x) => x.id === props.providerID) : null, ) - const title = createMemo(() => { - if (provider()) return provider()!.name - return "Select model" - }) + const title = createMemo(() => provider()?.name ?? "Select model") return ( - [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 f8be5577b3..9682bee4ea 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -26,82 +26,67 @@ export function createDialogProviderOptions() { const sync = useSync() const dialog = useDialog() const sdk = useSDK() - const connected = createMemo(() => new Set(sync.data.provider_next.connected)) const options = createMemo(() => { return pipe( sync.data.provider_next.all, sortBy((x) => PROVIDER_PRIORITY[x.id] ?? 99), - map((provider) => { - const isConnected = connected().has(provider.id) - return { - title: provider.name, - value: provider.id, - description: { - opencode: "(Recommended)", - anthropic: "(Claude Max or API key)", - openai: "(ChatGPT Plus/Pro or API key)", - }[provider.id], - category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", - footer: isConnected ? "Connected" : undefined, - async onSelect() { - const methods = sync.data.provider_auth[provider.id] ?? [ - { - type: "api", - label: "API key", - }, - ] - let index: number | null = 0 - if (methods.length > 1) { - index = await new Promise((resolve) => { - dialog.replace( - () => ( - ({ - title: x.label, - value: index, - }))} - onSelect={(option) => resolve(option.value)} - /> - ), - () => resolve(null), - ) - }) - } - if (index == null) return - const method = methods[index] - if (method.type === "oauth") { - const result = await sdk.client.provider.oauth.authorize({ - providerID: provider.id, - method: index, - }) - if (result.data?.method === "code") { - dialog.replace(() => ( - ({ + title: provider.name, + value: provider.id, + description: { + opencode: "(Recommended)", + anthropic: "(Claude Max or API key)", + openai: "(ChatGPT Plus/Pro or API key)", + }[provider.id], + category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", + async onSelect() { + const methods = sync.data.provider_auth[provider.id] ?? [ + { + type: "api", + label: "API key", + }, + ] + let index: number | null = 0 + if (methods.length > 1) { + index = await new Promise((resolve) => { + dialog.replace( + () => ( + ({ + title: x.label, + value: index, + }))} + onSelect={(option) => resolve(option.value)} /> - )) - } - if (result.data?.method === "auto") { - dialog.replace(() => ( - - )) - } + ), + () => resolve(null), + ) + }) + } + if (index == null) return + const method = methods[index] + if (method.type === "oauth") { + const result = await sdk.client.provider.oauth.authorize({ + providerID: provider.id, + method: index, + }) + if (result.data?.method === "code") { + dialog.replace(() => ( + + )) } - if (method.type === "api") { - return dialog.replace(() => ) + if (result.data?.method === "auto") { + dialog.replace(() => ( + + )) } - }, - } - }), + } + if (method.type === "api") { + return dialog.replace(() => ) + } + }, + })), ) }) return options @@ -124,7 +109,6 @@ function AutoMethod(props: AutoMethodProps) { const dialog = useDialog() const sync = useSync() const toast = useToast() - const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "c" && !evt.ctrl && !evt.meta) { @@ -155,16 +139,9 @@ function AutoMethod(props: AutoMethodProps) { {props.title} - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => dialog.clear()} - > - esc - + dialog.clear()}> + esc + 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 f3cd54db6e..3b6b5ef218 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -3,8 +3,7 @@ import { fileURLToPath } from "bun" import { useTheme } from "../context/theme" import { useDialog } from "@tui/ui/dialog" import { useSync } from "@tui/context/sync" -import { For, Match, Switch, Show, createMemo, createSignal } from "solid-js" -import { Installation } from "@/installation" +import { For, Match, Switch, Show, createMemo } from "solid-js" export type DialogStatusProps = {} @@ -12,7 +11,6 @@ export function DialogStatus() { const sync = useSync() const { theme } = useTheme() const dialog = useDialog() - const [hover, setHover] = createSignal(false) const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) @@ -47,18 +45,10 @@ export function DialogStatus() { Status - setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => dialog.clear()} - > - esc - + dialog.clear()}> + esc + - OpenCode v{Installation.VERSION} 0} fallback={No MCP Servers}> {Object.keys(sync.data.mcp).length} MCP Servers diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx index 5e814c3d21..0c5ea9a857 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/header.tsx @@ -7,7 +7,6 @@ import { SplitBorder } from "@tui/component/border" import type { AssistantMessage, Session } from "@opencode-ai/sdk/v2" import { useCommandDialog } from "@tui/component/dialog-command" import { useKeybind } from "../../context/keybind" -import { Installation } from "@/installation" import { useTerminalDimensions } from "@opentui/solid" const Title = (props: { session: Accessor }) => { @@ -87,10 +86,7 @@ export function Header() { Subagent session - - - v{Installation.VERSION} - + - <box flexDirection="row" gap={1} flexShrink={0}> - <ContextInfo context={context} cost={cost} /> - <text fg={theme.textMuted}>v{Installation.VERSION}</text> - </box> + <ContextInfo context={context} cost={cost} /> </box> </Match> </Switch> diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 77872eedad..3532d9e5a2 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -17,7 +17,7 @@ import { useRoute, useRouteData } from "@tui/context/route" import { useSync } from "@tui/context/sync" import { SplitBorder } from "@tui/component/border" import { Spinner } from "@tui/component/spinner" -import { useTheme } from "@tui/context/theme" +import { selectedForeground, useTheme } from "@tui/context/theme" import { BoxRenderable, ScrollBoxRenderable, @@ -141,7 +141,7 @@ export function Session() { }) const dimensions = useTerminalDimensions() - const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "hide") + const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto") const [sidebarOpen, setSidebarOpen] = createSignal(false) const [conceal, setConceal] = createSignal(true) const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true) @@ -1099,9 +1099,6 @@ export function Session() { sessionID={route.sessionID} /> </box> - <Show when={!sidebarVisible() || !wide()}> - <Footer /> - </Show> </Show> <Toast /> </box> @@ -1155,7 +1152,8 @@ function UserMessage(props: { const { theme } = useTheme() const [hover, setHover] = createSignal(false) const queued = createMemo(() => props.pending && props.message.id > props.pending) - const color = createMemo(() => (queued() ? theme.accent : local.agent.color(props.message.agent))) + const color = createMemo(() => local.agent.color(props.message.agent)) + const queuedFg = createMemo(() => selectedForeground(theme, color())) const metadataVisible = createMemo(() => queued() || ctx.showTimestamps()) const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction")) @@ -1217,7 +1215,7 @@ function UserMessage(props: { } > <text fg={theme.textMuted}> - <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span> + <span style={{ bg: color(), fg: queuedFg(), bold: true }}> QUEUED </span> </text> </Show> </box> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index 8b4b614767..642c73b485 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -2,7 +2,6 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { useKeyboard } from "@opentui/solid" -import { createSignal } from "solid-js" export type DialogAlertProps = { title: string @@ -13,7 +12,6 @@ export type DialogAlertProps = { export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() const { theme } = useTheme() - const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "return") { @@ -27,16 +25,9 @@ export function DialogAlert(props: DialogAlertProps) { <text attributes={TextAttributes.BOLD} fg={theme.text}> {props.title} </text> - <box - paddingLeft={1} - paddingRight={1} - backgroundColor={hover() ? theme.primary : undefined} - onMouseOver={() => setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => dialog.clear()} - > - <text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text> - </box> + <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> + esc + </text> </box> <box paddingBottom={1}> <text fg={theme.textMuted}>{props.message}</text> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index 7d9b74cdea..b86bd43251 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" -import { createSignal, For } from "solid-js" +import { For } from "solid-js" import { useKeyboard } from "@opentui/solid" import { Locale } from "@/util/locale" @@ -16,7 +16,6 @@ export type DialogConfirmProps = { export function DialogConfirm(props: DialogConfirmProps) { const dialog = useDialog() const { theme } = useTheme() - const [hover, setHover] = createSignal(false) const [store, setStore] = createStore({ active: "confirm" as "confirm" | "cancel", }) @@ -38,16 +37,9 @@ export function DialogConfirm(props: DialogConfirmProps) { <text attributes={TextAttributes.BOLD} fg={theme.text}> {props.title} </text> - <box - paddingLeft={1} - paddingRight={1} - backgroundColor={hover() ? theme.primary : undefined} - onMouseOver={() => setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => dialog.clear()} - > - <text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text> - </box> + <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> + esc + </text> </box> <box paddingBottom={1}> <text fg={theme.textMuted}>{props.message}</text> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index 957467c5d7..1e8d09bb0b 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -2,7 +2,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" -import { createSignal, onMount, Show, type JSX } from "solid-js" +import { onMount, Show, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" export type DialogExportOptionsProps = { @@ -25,7 +25,6 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() const { theme } = useTheme() let textarea: TextareaRenderable - const [hover, setHover] = createSignal(false) const [store, setStore] = createStore({ thinking: props.defaultThinking, toolDetails: props.defaultToolDetails, @@ -81,16 +80,9 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { <text attributes={TextAttributes.BOLD} fg={theme.text}> Export Options </text> - <box - paddingLeft={1} - paddingRight={1} - backgroundColor={hover() ? theme.primary : undefined} - onMouseOver={() => setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => dialog.clear()} - > - <text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text> - </box> + <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> + esc + </text> </box> <box gap={1}> <box> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index f56347d4af..4e45279303 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -3,13 +3,11 @@ import { useTheme } from "@tui/context/theme" import { useDialog } from "./dialog" import { useKeyboard } from "@opentui/solid" import { useKeybind } from "@tui/context/keybind" -import { createSignal } from "solid-js" export function DialogHelp() { const dialog = useDialog() const { theme } = useTheme() const keybind = useKeybind() - const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "return" || evt.name === "escape") { @@ -23,16 +21,9 @@ export function DialogHelp() { <text attributes={TextAttributes.BOLD} fg={theme.text}> Help </text> - <box - paddingLeft={1} - paddingRight={1} - backgroundColor={hover() ? theme.primary : undefined} - onMouseOver={() => setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => dialog.clear()} - > - <text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc/enter</text> - </box> + <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> + esc/enter + </text> </box> <box paddingBottom={1}> <text fg={theme.textMuted}> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index 03814e17d9..b1b05a0f1a 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -1,7 +1,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" -import { createSignal, onMount, type JSX } from "solid-js" +import { onMount, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" export type DialogPromptProps = { @@ -17,7 +17,6 @@ export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const { theme } = useTheme() let textarea: TextareaRenderable - const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "return") { @@ -40,16 +39,9 @@ export function DialogPrompt(props: DialogPromptProps) { <text attributes={TextAttributes.BOLD} fg={theme.text}> {props.title} </text> - <box - paddingLeft={1} - paddingRight={1} - backgroundColor={hover() ? theme.primary : undefined} - onMouseOver={() => setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => dialog.clear()} - > - <text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text> - </box> + <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> + esc + </text> </box> <box gap={1}> {props.description} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 7792900bcf..151f73cf7c 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -1,7 +1,7 @@ import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" -import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js" +import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" import { createStore } from "solid-js/store" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" @@ -15,6 +15,7 @@ export interface DialogSelectProps<T> { title: string placeholder?: string options: DialogSelectOption<T>[] + flat?: boolean ref?: (ref: DialogSelectRef<T>) => void onMove?: (option: DialogSelectOption<T>) => void onFilter?: (query: string) => void @@ -49,7 +50,6 @@ export type DialogSelectRef<T> = { export function DialogSelect<T>(props: DialogSelectProps<T>) { const dialog = useDialog() const { theme } = useTheme() - const [hover, setHover] = createSignal(false) const [store, setStore] = createStore({ selected: 0, filter: "", @@ -101,7 +101,10 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { setStore("input", "keyboard") }) - const grouped = createMemo(() => { + const flatten = createMemo(() => props.flat && store.filter.length > 0) + + const grouped = createMemo<[string, DialogSelectOption<T>[]][]>(() => { + if (flatten()) return [["", filtered()]] const result = pipe( filtered(), groupBy((x) => x.category ?? ""), @@ -118,10 +121,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { ) }) + const rows = createMemo(() => { + const headers = grouped().reduce((acc, [category], i) => { + if (!category) return acc + return acc + (i > 0 ? 2 : 1) + }, 0) + return flat().length + headers + }) + const dimensions = useTerminalDimensions() - const height = createMemo(() => - Math.min(flat().length + grouped().length * 2 - 1, Math.floor(dimensions().height / 2) - 6), - ) + const height = createMemo(() => Math.min(rows(), Math.floor(dimensions().height / 2) - 6)) const selected = createMemo(() => flat()[store.selected]) @@ -227,16 +236,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { <text fg={theme.text} attributes={TextAttributes.BOLD}> {props.title} </text> - <box - paddingLeft={1} - paddingRight={1} - backgroundColor={hover() ? theme.primary : undefined} - onMouseOver={() => setHover(true)} - onMouseOut={() => setHover(false)} - onMouseUp={() => dialog.clear()} - > - <text fg={hover() ? theme.selectedListItemText : theme.textMuted}>esc</text> - </box> + <text fg={theme.textMuted} onMouseUp={() => dialog.clear()}> + esc + </text> </box> <box paddingTop={1}> <input @@ -319,7 +321,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) { > <Option title={option.title} - footer={option.footer} + footer={flatten() ? (option.category ?? option.footer) : option.footer} description={option.description !== category ? option.description : undefined} active={active()} current={current()} diff --git a/packages/opencode/src/command/template/review.txt b/packages/opencode/src/command/template/review.txt index 9f6fbfcc3a..b745247e7f 100644 --- a/packages/opencode/src/command/template/review.txt +++ b/packages/opencode/src/command/template/review.txt @@ -57,6 +57,8 @@ Use best judgement when processing input. **Performance** - Only flag if obviously problematic. - O(n²) on unbounded data, N+1 queries, blocking I/O on hot paths +**Behavior Changes** - If a behavioral change is introduced, raise it (especially if it's possibly unintentional). + --- ## Before You Flag Something diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index a231a53007..8f0f583ea3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1161,6 +1161,12 @@ export namespace Config { .object({ auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"), prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"), + reserved: z + .number() + .int() + .min(0) + .optional() + .describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."), }) .optional(), experimental: z diff --git a/packages/opencode/src/installation/index.ts b/packages/opencode/src/installation/index.ts index d18c9e31a1..47278bd562 100644 --- a/packages/opencode/src/installation/index.ts +++ b/packages/opencode/src/installation/index.ts @@ -148,6 +148,16 @@ export namespace Installation { break case "brew": { const formula = await getBrewFormula() + if (formula.includes("/")) { + cmd = + $`brew tap anomalyco/tap && cd "$(brew --repo anomalyco/tap)" && git pull --ff-only && brew upgrade ${formula}`.env( + { + HOMEBREW_NO_AUTO_UPDATE: "1", + ...process.env, + }, + ) + break + } cmd = $`brew upgrade ${formula}`.env({ HOMEBREW_NO_AUTO_UPDATE: "1", ...process.env, @@ -188,14 +198,19 @@ export namespace Installation { if (detectedMethod === "brew") { const formula = await getBrewFormula() - if (formula === "opencode") { - return fetch("https://formulae.brew.sh/api/formula/opencode.json") - .then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - .then((data: any) => data.versions.stable) + if (formula.includes("/")) { + const infoJson = await $`brew info --json=v2 ${formula}`.quiet().text() + const info = JSON.parse(infoJson) + const version = info.formulae?.[0]?.versions?.stable + if (!version) throw new Error(`Could not detect version for tap formula: ${formula}`) + return version } + return fetch("https://formulae.brew.sh/api/formula/opencode.json") + .then((res) => { + if (!res.ok) throw new Error(res.statusText) + return res.json() + }) + .then((data: any) => data.versions.stable) } if (detectedMethod === "npm" || detectedMethod === "bun" || detectedMethod === "pnpm") { diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index 2693df04fd..0db03576e9 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -12,15 +12,13 @@ export namespace ProviderError { /input token count.*exceeds the maximum/i, // Google (Gemini) /maximum prompt length is \d+/i, // xAI (Grok) /reduce the length of the messages/i, // Groq - /maximum context length is \d+ tokens/i, // OpenRouter + /maximum context length is \d+ tokens/i, // OpenRouter, DeepSeek /exceeds the limit of \d+/i, // GitHub Copilot /exceeds the available context size/i, // llama.cpp server /greater than the context length/i, // LM Studio /context window exceeds limit/i, // MiniMax - /exceeded model token limit/i, // Kimi For Coding + /exceeded model token limit/i, // Kimi For Coding, Moonshot /context[_ ]length[_ ]exceeded/i, // Generic fallback - /too many tokens/i, // Generic fallback - /token limit exceeded/i, // Generic fallback ] function isOpenAiErrorRetryable(e: APICallError) { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 1cad3b3162..a02a017e77 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -211,7 +211,12 @@ export namespace Provider { const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE") - if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile) return { autoload: false } + const containerCreds = Boolean( + process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI, + ) + + if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds) + return { autoload: false } const providerOptions: AmazonBedrockProviderSettings = { region: defaultRegion, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 01291491d3..7ed89cb259 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -5,6 +5,7 @@ import type { JSONSchema } from "zod/v4/core" import type { Provider } from "./provider" import type { ModelsDev } from "./models" import { iife } from "@/util/iife" +import { Flag } from "@/flag/flag" type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number] @@ -17,6 +18,8 @@ function mimeToModality(mime: string): Modality | undefined { } export namespace ProviderTransform { + export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 + // Maps npm package to the key the AI SDK expects for providerOptions function sdkKey(npm: string): string | undefined { switch (npm) { @@ -723,29 +726,8 @@ export namespace ProviderTransform { return { [key]: options } } - export function maxOutputTokens( - npm: string, - options: Record<string, any>, - modelLimit: number, - globalLimit: number, - ): number { - const modelCap = modelLimit || globalLimit - const standardLimit = Math.min(modelCap, globalLimit) - - if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") { - const thinking = options?.["thinking"] - const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0 - const enabled = thinking?.["type"] === "enabled" - if (enabled && budgetTokens > 0) { - // Return text tokens so that text + thinking <= model cap, preferring 32k text when possible. - if (budgetTokens + standardLimit <= modelCap) { - return standardLimit - } - return modelCap - budgetTokens - } - } - - return standardLimit + export function maxOutputTokens(model: Provider.Model): number { + return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX } export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 { diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index a27ee9a744..7a07e3ef32 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -15,6 +15,17 @@ export namespace Pty { const BUFFER_LIMIT = 1024 * 1024 * 2 const BUFFER_CHUNK = 64 * 1024 + const encoder = new TextEncoder() + + // WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }). + const meta = (cursor: number) => { + const json = JSON.stringify({ cursor }) + const bytes = encoder.encode(json) + const out = new Uint8Array(bytes.length + 1) + out[0] = 0 + out.set(bytes, 1) + return out + } const pty = lazy(async () => { const { spawn } = await import("bun-pty") @@ -68,6 +79,8 @@ export namespace Pty { info: Info process: IPty buffer: string + bufferCursor: number + cursor: number subscribers: Set<WSContext> } @@ -139,23 +152,27 @@ export namespace Pty { info, process: ptyProcess, buffer: "", + bufferCursor: 0, + cursor: 0, subscribers: new Set(), } state().set(id, session) ptyProcess.onData((data) => { - let open = false + session.cursor += data.length + for (const ws of session.subscribers) { if (ws.readyState !== 1) { session.subscribers.delete(ws) continue } - open = true ws.send(data) } - if (open) return + session.buffer += data if (session.buffer.length <= BUFFER_LIMIT) return - session.buffer = session.buffer.slice(-BUFFER_LIMIT) + const excess = session.buffer.length - BUFFER_LIMIT + session.buffer = session.buffer.slice(excess) + session.bufferCursor += excess }) ptyProcess.onExit(({ exitCode }) => { log.info("session exited", { id, exitCode }) @@ -215,28 +232,47 @@ export namespace Pty { } } - export function connect(id: string, ws: WSContext) { + export function connect(id: string, ws: WSContext, cursor?: number) { const session = state().get(id) if (!session) { ws.close() return } log.info("client connected to session", { id }) - session.subscribers.add(ws) - if (session.buffer) { - const buffer = session.buffer.length <= BUFFER_LIMIT ? session.buffer : session.buffer.slice(-BUFFER_LIMIT) - session.buffer = "" + + const start = session.bufferCursor + const end = session.cursor + + const from = + cursor === -1 ? end : typeof cursor === "number" && Number.isSafeInteger(cursor) ? Math.max(0, cursor) : 0 + + const data = (() => { + if (!session.buffer) return "" + if (from >= end) return "" + const offset = Math.max(0, from - start) + if (offset >= session.buffer.length) return "" + return session.buffer.slice(offset) + })() + + if (data) { try { - for (let i = 0; i < buffer.length; i += BUFFER_CHUNK) { - ws.send(buffer.slice(i, i + BUFFER_CHUNK)) + for (let i = 0; i < data.length; i += BUFFER_CHUNK) { + ws.send(data.slice(i, i + BUFFER_CHUNK)) } } catch { - session.subscribers.delete(ws) - session.buffer = buffer ws.close() return } } + + try { + ws.send(meta(end)) + } catch { + ws.close() + return + } + + session.subscribers.add(ws) return { onMessage: (message: string | ArrayBuffer) => { session.process.write(String(message)) diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts index 1ac6cf7971..1085c1175b 100644 --- a/packages/opencode/src/server/routes/pty.ts +++ b/packages/opencode/src/server/routes/pty.ts @@ -151,11 +151,18 @@ export const PtyRoutes = lazy(() => validator("param", z.object({ ptyID: z.string() })), upgradeWebSocket((c) => { const id = c.req.param("ptyID") + const cursor = (() => { + const value = c.req.query("cursor") + if (!value) return + const parsed = Number(value) + if (!Number.isSafeInteger(parsed) || parsed < -1) return + return parsed + })() let handler: ReturnType<typeof Pty.connect> if (!Pty.get(id)) throw new Error("Session not found") return { onOpen(_event, ws) { - handler = Pty.connect(id, ws) + handler = Pty.connect(id, ws, cursor) }, onMessage(event) { handler?.onMessage(String(event.data)) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 015553802a..9fb5206551 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -78,6 +78,9 @@ export namespace Server { }) }) .use((c, next) => { + // Allow CORS preflight requests to succeed without auth. + // Browser clients sending Authorization headers will preflight with OPTIONS. + if (c.req.method === "OPTIONS") return next() const password = Flag.OPENCODE_SERVER_PASSWORD if (!password) return next() const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode" @@ -107,7 +110,12 @@ export namespace Server { if (input.startsWith("http://localhost:")) return input if (input.startsWith("http://127.0.0.1:")) return input - if (input === "tauri://localhost" || input === "http://tauri.localhost") return input + if ( + input === "tauri://localhost" || + input === "http://tauri.localhost" || + input === "https://tauri.localhost" + ) + return input // *.opencode.ai (https only, adjust if needed) if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) { diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 73a70af9d4..5660ca9731 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -6,7 +6,6 @@ import { Instance } from "../project/instance" import { Provider } from "../provider/provider" import { MessageV2 } from "./message-v2" import z from "zod" -import { SessionPrompt } from "./prompt" import { Token } from "../util/token" import { Log } from "../util/log" import { SessionProcessor } from "./processor" @@ -14,6 +13,7 @@ import { fn } from "@/util/fn" import { Agent } from "@/agent/agent" import { Plugin } from "@/plugin" import { Config } from "@/config/config" +import { ProviderTransform } from "@/provider/transform" export namespace SessionCompaction { const log = Log.create({ service: "session.compaction" }) @@ -27,15 +27,22 @@ export namespace SessionCompaction { ), } + const COMPACTION_BUFFER = 20_000 + export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) { const config = await Config.get() if (config.compaction?.auto === false) return false const context = input.model.limit.context if (context === 0) return false - const count = input.tokens.input + input.tokens.cache.read + input.tokens.output - const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX - const usable = input.model.limit.input || context - output - return count > usable + + const count = + input.tokens.total || + input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write + + const reserved = + config.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model)) + const usable = input.model.limit.input ? input.model.limit.input - reserved : context - reserved + return count >= usable } export const PRUNE_MINIMUM = 20_000 @@ -139,8 +146,34 @@ export namespace SessionCompaction { { sessionID: input.sessionID }, { context: [], prompt: undefined }, ) - const defaultPrompt = - "Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation." + const defaultPrompt = `Provide a detailed prompt for continuing our conversation above. +Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next. +The summary that you construct will be used so that another agent can read it and continue the work. + +When constructing the summary, try to stick to this template: +--- +## Goal + +[What goal(s) is the user trying to accomplish?] + +## Instructions + +- [What important instructions did the user give you that are relevant] +- [If there is a plan or spec, include information about it so next agent can continue using it] + +## Discoveries + +[What notable things were learned during this conversation that would be useful for the next agent to know when continuing the work] + +## Accomplished + +[What work has been completed, what work is still in progress, and what work is left?] + +## Relevant files / directories + +[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.] +---` + const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") const result = await processor.process({ user: userMessage, @@ -181,7 +214,7 @@ export namespace SessionCompaction { sessionID: input.sessionID, type: "text", synthetic: true, - text: "Continue if you have next steps", + text: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.", time: { start: Date.now(), end: Date.now(), diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts index 556fad01f5..b0ffaaf70d 100644 --- a/packages/opencode/src/session/index.ts +++ b/packages/opencode/src/session/index.ts @@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" import z from "zod" -import { type LanguageModelUsage, type ProviderMetadata } from "ai" +import { type ProviderMetadata } from "ai" import { Config } from "../config/config" import { Flag } from "../flag/flag" import { Identifier } from "../id/id" @@ -22,6 +22,8 @@ import { Snapshot } from "@/snapshot" import type { Provider } from "@/provider/provider" import { PermissionNext } from "@/permission/next" import { Global } from "@/global" +import type { LanguageModelV2Usage } from "@ai-sdk/provider" +import { iife } from "@/util/iife" export namespace Session { const log = Log.create({ service: "session" }) @@ -439,34 +441,58 @@ export namespace Session { export const getUsage = fn( z.object({ model: z.custom<Provider.Model>(), - usage: z.custom<LanguageModelUsage>(), + usage: z.custom<LanguageModelV2Usage>(), metadata: z.custom<ProviderMetadata>().optional(), }), (input) => { - const cacheReadInputTokens = input.usage.cachedInputTokens ?? 0 - const cacheWriteInputTokens = (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? - // @ts-expect-error - input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? - // @ts-expect-error - input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? - 0) as number - - const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"]) - const adjustedInputTokens = excludesCachedTokens - ? (input.usage.inputTokens ?? 0) - : (input.usage.inputTokens ?? 0) - cacheReadInputTokens - cacheWriteInputTokens const safe = (value: number) => { if (!Number.isFinite(value)) return 0 return value } + const inputTokens = safe(input.usage.inputTokens ?? 0) + const outputTokens = safe(input.usage.outputTokens ?? 0) + const reasoningTokens = safe(input.usage.reasoningTokens ?? 0) + + const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0) + const cacheWriteInputTokens = safe( + (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? + // @ts-expect-error + input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ?? + // @ts-expect-error + input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ?? + 0) as number, + ) + + // OpenRouter provides inputTokens as the total count of input tokens (including cached). + // AFAIK other providers (OpenRouter/OpenAI/Gemini etc.) do it the same way e.g. vercel/ai#8794 (comment) + // Anthropic does it differently though - inputTokens doesn't include cached tokens. + // It looks like OpenCode's cost calculation assumes all providers return inputTokens the same way Anthropic does (I'm guessing getUsage logic was originally implemented with anthropic), so it's causing incorrect cost calculation for OpenRouter and others. + const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"]) + const adjustedInputTokens = safe( + excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens, + ) + + const total = iife(() => { + // Anthropic doesn't provide total_tokens, also ai sdk will vastly undercount if we + // don't compute from components + if ( + input.model.api.npm === "@ai-sdk/anthropic" || + input.model.api.npm === "@ai-sdk/amazon-bedrock" || + input.model.api.npm === "@ai-sdk/google-vertex/anthropic" + ) { + return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens + } + return input.usage.totalTokens + }) const tokens = { - input: safe(adjustedInputTokens), - output: safe(input.usage.outputTokens ?? 0), - reasoning: safe(input.usage?.reasoningTokens ?? 0), + total, + input: adjustedInputTokens, + output: outputTokens, + reasoning: reasoningTokens, cache: { - write: safe(cacheWriteInputTokens), - read: safe(cacheReadInputTokens), + write: cacheWriteInputTokens, + read: cacheReadInputTokens, }, } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4be6e2538f..b8705ec4e3 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -25,8 +25,7 @@ import { Auth } from "@/auth" export namespace LLM { const log = Log.create({ service: "llm" }) - - export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 + export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX export type StreamInput = { user: MessageV2.User @@ -149,14 +148,7 @@ export namespace LLM { ) const maxOutputTokens = - isCodex || provider.id.includes("github-copilot") - ? undefined - : ProviderTransform.maxOutputTokens( - input.model.api.npm, - params.options, - input.model.limit.output, - OUTPUT_TOKEN_MAX, - ) + isCodex || provider.id.includes("github-copilot") ? undefined : ProviderTransform.maxOutputTokens(input.model) const tools = await resolveTools(input) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index e45bfc7728..63159ecc50 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -210,6 +210,7 @@ export namespace MessageV2 { snapshot: z.string().optional(), cost: z.number(), tokens: z.object({ + total: z.number().optional(), input: z.number(), output: z.number(), reasoning: z.number(), @@ -383,6 +384,7 @@ export namespace MessageV2 { summary: z.boolean().optional(), cost: z.number(), tokens: z.object({ + total: z.number().optional(), input: z.number(), output: z.number(), reasoning: z.number(), diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index b5289e903a..8e7472e2f1 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -342,6 +342,9 @@ export namespace SessionProcessor { stack: JSON.stringify(e.stack), }) const error = MessageV2.fromError(e, { providerID: input.model.providerID }) + if (MessageV2.ContextOverflowError.isInstance(error)) { + // TODO: Handle context overflow error + } const retry = SessionRetry.retryable(error) if (retry !== undefined) { attempt++ diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ad7b6f1a91..f49084d9c0 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -52,7 +52,6 @@ globalThis.AI_SDK_LOG_WARNINGS = false export namespace SessionPrompt { const log = Log.create({ service: "session.prompt" }) - export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000 const state = Instance.state( () => { @@ -321,7 +320,18 @@ export namespace SessionPrompt { history: msgs, }) - const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID) + const model = await Provider.getModel(lastUser.model.providerID, lastUser.model.modelID).catch((e) => { + if (Provider.ModelNotFoundError.isInstance(e)) { + const hint = e.data.suggestions?.length ? ` Did you mean: ${e.data.suggestions.join(", ")}?` : "" + Bus.publish(Session.Event.Error, { + sessionID, + error: new NamedError.Unknown({ + message: `Model not found: ${e.data.providerID}/${e.data.modelID}.${hint}`, + }).toObject(), + }) + } + throw e + }) const task = tasks.pop() // pending subtask @@ -843,14 +853,11 @@ export namespace SessionPrompt { const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent())) const model = input.model ?? agent.model ?? (await lastModel(input.sessionID)) - const variant = - input.variant ?? - (agent.variant && - agent.model && - model.providerID === agent.model.providerID && - model.modelID === agent.model.modelID - ? agent.variant - : undefined) + const full = + !input.variant && agent.variant + ? await Provider.getModel(model.providerID, model.modelID).catch(() => undefined) + : undefined + const variant = input.variant ?? (agent.variant && full?.variants?.[agent.variant] ? agent.variant : undefined) const info: MessageV2.Info = { id: input.messageID ?? Identifier.ascending("message"), diff --git a/packages/opencode/src/session/prompt/anthropic-20250930.txt b/packages/opencode/src/session/prompt/anthropic-20250930.txt index 9af9602488..e15080e6c8 100644 --- a/packages/opencode/src/session/prompt/anthropic-20250930.txt +++ b/packages/opencode/src/session/prompt/anthropic-20250930.txt @@ -56,7 +56,7 @@ user: which file contains the implementation of foo? assistant: src/foo.c </example> When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). -Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. +Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. diff --git a/packages/opencode/src/session/prompt/anthropic.txt b/packages/opencode/src/session/prompt/anthropic.txt index f9050a37bf..21d9c0e9f2 100644 --- a/packages/opencode/src/session/prompt/anthropic.txt +++ b/packages/opencode/src/session/prompt/anthropic.txt @@ -13,7 +13,7 @@ When the user directly asks about OpenCode (eg. "can OpenCode do...", "does Open # Tone and style - Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. -- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. +- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. - Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. - NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files. diff --git a/packages/opencode/src/session/prompt/beast.txt b/packages/opencode/src/session/prompt/beast.txt index 974d9a265e..e92e4d020f 100644 --- a/packages/opencode/src/session/prompt/beast.txt +++ b/packages/opencode/src/session/prompt/beast.txt @@ -76,7 +76,7 @@ Carefully read the issue and think hard about a plan to solve it before coding. - Create a todo list in markdown format to track your progress. - Each time you complete a step, check it off using `[x]` syntax. - Each time you check off a step, display the updated todo list to the user. -- Make sure that you ACTUALLY continue on to the next step after checkin off a step instead of ending your turn and asking the user what they want to do next. +- Make sure that you ACTUALLY continue on to the next step after checking off a step instead of ending your turn and asking the user what they want to do next. ## 6. Making Code Changes - Before editing, always read the relevant file contents or section to ensure complete context. diff --git a/packages/opencode/src/session/prompt/codex_header.txt b/packages/opencode/src/session/prompt/codex_header.txt index b4cf311cac..d595cadb0e 100644 --- a/packages/opencode/src/session/prompt/codex_header.txt +++ b/packages/opencode/src/session/prompt/codex_header.txt @@ -60,7 +60,7 @@ You are producing plain text that will later be styled by the CLI. Follow these ## Final answer structure and style guidelines -- Plain text; CLI handles styling. Use structure only when it helps scanability. +- Plain text; CLI handles styling. Use structure only when it helps scannability. - Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help. - Bullets: use - ; merge related points; keep to one line when possible; 4–6 per list ordered by importance; keep phrasing consistent. - Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **. diff --git a/packages/opencode/src/session/prompt/copilot-gpt-5.txt b/packages/opencode/src/session/prompt/copilot-gpt-5.txt index f8e3e6b8c9..87cab043bd 100644 --- a/packages/opencode/src/session/prompt/copilot-gpt-5.txt +++ b/packages/opencode/src/session/prompt/copilot-gpt-5.txt @@ -39,7 +39,7 @@ incremental steps - use the todo tool to track your progress. - What are the edge cases? - What are the potential pitfalls? - How does this fit into the larger context of the codebase? -- What are the dependencies and interactions with other parts of the codee +- What are the dependencies and interactions with other parts of the code ## 2. Codebase Investigation - Explore relevant files and directories. diff --git a/packages/opencode/src/session/prompt/qwen.txt b/packages/opencode/src/session/prompt/qwen.txt index 5e7ce86559..d87fc37957 100644 --- a/packages/opencode/src/session/prompt/qwen.txt +++ b/packages/opencode/src/session/prompt/qwen.txt @@ -12,7 +12,7 @@ When the user directly asks about opencode (eg 'can opencode do...', 'does openc # Tone and style You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). -Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. +Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. diff --git a/packages/opencode/src/session/prompt/trinity.txt b/packages/opencode/src/session/prompt/trinity.txt index 9d7ae1a61b..28ee4c4f26 100644 --- a/packages/opencode/src/session/prompt/trinity.txt +++ b/packages/opencode/src/session/prompt/trinity.txt @@ -2,7 +2,7 @@ You are opencode, an interactive CLI tool that helps users with software enginee # Tone and style You should be concise, direct, and to the point. When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system). -Remember that your output will be displayed on a command line interface. Your responses can use Github-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. +Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences. Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 0d9a865b1f..6d057f539f 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -59,11 +59,12 @@ export namespace SessionRetry { } export function retryable(error: ReturnType<NamedError["toObject"]>) { - // DO NOT retry context overflow errors + // context overflow errors should not be retried if (MessageV2.ContextOverflowError.isInstance(error)) return undefined - if (MessageV2.APIError.isInstance(error)) { if (!error.data.isRetryable) return undefined + if (error.data.responseBody?.includes("FreeUsageLimitError")) + return `Free usage exceeded, add credits https://opencode.ai/zen` return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts index 91a520a9bd..b92fc9979f 100644 --- a/packages/opencode/src/session/summary.ts +++ b/packages/opencode/src/session/summary.ts @@ -9,8 +9,6 @@ import { Identifier } from "@/id/id" import { Snapshot } from "@/snapshot" import { Log } from "@/util/log" -import path from "path" -import { Instance } from "@/project/instance" import { Storage } from "@/storage/storage" import { Bus } from "@/bus" @@ -91,18 +89,7 @@ export namespace SessionSummary { ) async function summarizeSession(input: { sessionID: string; messages: MessageV2.WithParts[] }) { - const files = new Set( - input.messages - .flatMap((x) => x.parts) - .filter((x) => x.type === "patch") - .flatMap((x) => x.files) - .map((x) => path.relative(Instance.worktree, x).replaceAll("\\", "/")), - ) - const diffs = await computeDiff({ messages: input.messages }).then((x) => - x.filter((x) => { - return files.has(x.file) - }), - ) + const diffs = await computeDiff({ messages: input.messages }) await Session.update(input.sessionID, (draft) => { draft.summary = { additions: diffs.reduce((sum, x) => sum + x.additions, 0), @@ -206,7 +193,6 @@ export namespace SessionSummary { for (const part of item.parts) { if (part.type === "step-finish" && part.snapshot) { to = part.snapshot - break } } } diff --git a/packages/opencode/src/tool/bash.txt b/packages/opencode/src/tool/bash.txt index 9fbc9fcf37..47e9378e75 100644 --- a/packages/opencode/src/tool/bash.txt +++ b/packages/opencode/src/tool/bash.txt @@ -87,7 +87,7 @@ Important notes: - If there are no changes to commit (i.e., no untracked files and no modifications), do not create an empty commit # Creating pull requests -Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a Github URL use the gh command to get the information needed. +Use the gh command via the Bash tool for ALL GitHub-related tasks including working with issues, pull requests, checks, and releases. If given a GitHub URL use the gh command to get the information needed. IMPORTANT: When the user asks you to create a pull request, follow these steps carefully: @@ -112,4 +112,4 @@ Important: - Return the PR URL when you're done, so the user can see it # Other common operations -- View comments on a Github PR: gh api repos/foo/bar/pulls/123/comments +- View comments on a GitHub PR: gh api repos/foo/bar/pulls/123/comments diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 2e095136eb..88c778cbb8 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -219,6 +219,44 @@ export namespace Worktree { return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n") } + function failed(result: { stdout?: Uint8Array; stderr?: Uint8Array }) { + return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).flatMap((chunk) => + chunk + .split("\n") + .map((line) => line.trim()) + .flatMap((line) => { + const match = line.match(/^warning:\s+failed to remove\s+(.+):\s+/i) + if (!match) return [] + const value = match[1]?.trim().replace(/^['"]|['"]$/g, "") + if (!value) return [] + return [value] + }), + ) + } + + async function prune(root: string, entries: string[]) { + const base = await canonical(root) + await Promise.all( + entries.map(async (entry) => { + const target = await canonical(path.resolve(root, entry)) + if (target === base) return + if (!target.startsWith(`${base}${path.sep}`)) return + await fs.rm(target, { recursive: true, force: true }).catch(() => undefined) + }), + ) + } + + async function sweep(root: string) { + const first = await $`git clean -ffdx`.quiet().nothrow().cwd(root) + if (first.exitCode === 0) return first + + const entries = failed(first) + if (!entries.length) return first + + await prune(root, entries) + return $`git clean -ffdx`.quiet().nothrow().cwd(root) + } + async function canonical(input: string) { const abs = path.resolve(input) const real = await fs.realpath(abs).catch(() => abs) @@ -536,7 +574,7 @@ export namespace Worktree { throw new ResetFailedError({ message: errorText(resetToTarget) || "Failed to reset worktree to target" }) } - const clean = await $`git clean -fdx`.quiet().nothrow().cwd(worktreePath) + const clean = await sweep(worktreePath) if (clean.exitCode !== 0) { throw new ResetFailedError({ message: errorText(clean) || "Failed to clean worktree" }) } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 0e0bb440aa..02bb5278fc 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -175,100 +175,6 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => { }) }) -describe("ProviderTransform.maxOutputTokens", () => { - test("returns 32k when modelLimit > 32k", () => { - const modelLimit = 100000 - const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai", {}, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("returns modelLimit when modelLimit < 32k", () => { - const modelLimit = 16000 - const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai", {}, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(16000) - }) - - describe("azure", () => { - test("returns 32k when modelLimit > 32k", () => { - const modelLimit = 100000 - const result = ProviderTransform.maxOutputTokens("@ai-sdk/azure", {}, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("returns modelLimit when modelLimit < 32k", () => { - const modelLimit = 16000 - const result = ProviderTransform.maxOutputTokens("@ai-sdk/azure", {}, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(16000) - }) - }) - - describe("bedrock", () => { - test("returns 32k when modelLimit > 32k", () => { - const modelLimit = 100000 - const result = ProviderTransform.maxOutputTokens("@ai-sdk/amazon-bedrock", {}, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("returns modelLimit when modelLimit < 32k", () => { - const modelLimit = 16000 - const result = ProviderTransform.maxOutputTokens("@ai-sdk/amazon-bedrock", {}, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(16000) - }) - }) - - describe("anthropic without thinking options", () => { - test("returns 32k when modelLimit > 32k", () => { - const modelLimit = 100000 - const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", {}, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("returns modelLimit when modelLimit < 32k", () => { - const modelLimit = 16000 - const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", {}, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(16000) - }) - }) - - describe("anthropic with thinking options", () => { - test("returns 32k when budgetTokens + 32k <= modelLimit", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "enabled", - budgetTokens: 10000, - }, - } - const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", options, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - - test("returns modelLimit - budgetTokens when budgetTokens + 32k > modelLimit", () => { - const modelLimit = 50000 - const options = { - thinking: { - type: "enabled", - budgetTokens: 30000, - }, - } - const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", options, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(20000) - }) - - test("returns 32k when thinking type is not enabled", () => { - const modelLimit = 100000 - const options = { - thinking: { - type: "disabled", - budgetTokens: 10000, - }, - } - const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", options, modelLimit, OUTPUT_TOKEN_MAX) - expect(result).toBe(OUTPUT_TOKEN_MAX) - }) - }) -}) - describe("ProviderTransform.schema - gemini array items", () => { test("adds missing items for array properties", () => { const geminiModel = { diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 2e9c091870..452926d12e 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -15,6 +15,7 @@ function createModel(opts: { output: number input?: number cost?: Provider.Model["cost"] + npm?: string }): Provider.Model { return { id: "test-model", @@ -34,7 +35,7 @@ function createModel(opts: { input: { text: true, image: false, audio: false, video: false }, output: { text: true, image: false, audio: false, video: false }, }, - api: { npm: "@ai-sdk/anthropic" }, + api: { npm: opts.npm ?? "@ai-sdk/anthropic" }, options: {}, } as Provider.Model } @@ -70,7 +71,7 @@ describe("session.compaction.isOverflow", () => { directory: tmp.path, fn: async () => { const model = createModel({ context: 100_000, output: 32_000 }) - const tokens = { input: 50_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } } + const tokens = { input: 60_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } } expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true) }, }) @@ -112,6 +113,86 @@ describe("session.compaction.isOverflow", () => { }) }) + // ─── Bug reproduction tests ─────────────────────────────────────────── + // These tests demonstrate that when limit.input is set, isOverflow() + // does not subtract any headroom for the next model response. This means + // compaction only triggers AFTER we've already consumed the full input + // budget, leaving zero room for the next API call's output tokens. + // + // Compare: without limit.input, usable = context - output (reserves space). + // With limit.input, usable = limit.input (reserves nothing). + // + // Related issues: #10634, #8089, #11086, #12621 + // Open PRs: #6875, #12924 + + test("BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Simulate Claude with prompt caching: input limit = 200K, output limit = 32K + const model = createModel({ context: 200_000, input: 200_000, output: 32_000 }) + + // We've used 198K tokens total. Only 2K under the input limit. + // On the next turn, the full conversation (198K) becomes input, + // plus the model needs room to generate output — this WILL overflow. + const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } } + // count = 180K + 3K + 15K = 198K + // usable = limit.input = 200K (no output subtracted!) + // 198K > 200K = false → no compaction triggered + + // WITHOUT limit.input: usable = 200K - 32K = 168K, and 198K > 168K = true ✓ + // WITH limit.input: usable = 200K, and 198K > 200K = false ✗ + + // With 198K used and only 2K headroom, the next turn will overflow. + // Compaction MUST trigger here. + expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true) + }, + }) + }) + + test("BUG: without limit.input, same token count correctly triggers compaction", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Same model but without limit.input — uses context - output instead + const model = createModel({ context: 200_000, output: 32_000 }) + + // Same token usage as above + const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } } + // count = 198K + // usable = context - output = 200K - 32K = 168K + // 198K > 168K = true → compaction correctly triggered + + const result = await SessionCompaction.isOverflow({ tokens, model }) + expect(result).toBe(true) // ← Correct: headroom is reserved + }, + }) + }) + + test("BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + // Two models with identical context/output limits, differing only in limit.input + const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 }) + const withoutInputLimit = createModel({ context: 200_000, output: 32_000 }) + + // 170K total tokens — well above context-output (168K) but below input limit (200K) + const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } } + + const withLimit = await SessionCompaction.isOverflow({ tokens, model: withInputLimit }) + const withoutLimit = await SessionCompaction.isOverflow({ tokens, model: withoutInputLimit }) + + // Both models have identical real capacity — they should agree: + expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output) + expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K) + }, + }) + }) + test("returns false when model context limit is 0", async () => { await using tmp = await tmpdir() await Instance.provide({ @@ -290,4 +371,53 @@ describe("session.getUsage", () => { expect(result.cost).toBe(3 + 1.5) }) + + test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])( + "computes total from components for %s models", + (npm) => { + const model = createModel({ context: 100_000, output: 32_000, npm }) + const usage = { + inputTokens: 1000, + outputTokens: 500, + // These providers typically report total as input + output only, + // excluding cache read/write. + totalTokens: 1500, + cachedInputTokens: 200, + } + if (npm === "@ai-sdk/amazon-bedrock") { + const result = Session.getUsage({ + model, + usage, + metadata: { + bedrock: { + usage: { + cacheWriteInputTokens: 300, + }, + }, + }, + }) + + expect(result.tokens.input).toBe(1000) + expect(result.tokens.cache.read).toBe(200) + expect(result.tokens.cache.write).toBe(300) + expect(result.tokens.total).toBe(2000) + return + } + + const result = Session.getUsage({ + model, + usage, + metadata: { + anthropic: { + cacheCreationInputTokens: 300, + }, + }, + }) + + expect(result.tokens.input).toBe(1000) + expect(result.tokens.cache.read).toBe(200) + expect(result.tokens.cache.write).toBe(300) + expect(result.tokens.total).toBe(2000) + }, + ) }) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 1f7e17e1bd..be0b8e520e 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -314,12 +314,7 @@ describe("session.llm.stream", () => { expect(body.stream).toBe(true) const maxTokens = (body.max_tokens as number | undefined) ?? (body.max_output_tokens as number | undefined) - const expectedMaxTokens = ProviderTransform.maxOutputTokens( - resolved.api.npm, - ProviderTransform.options({ model: resolved, sessionID }), - resolved.limit.output, - LLM.OUTPUT_TOKEN_MAX, - ) + const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved) expect(maxTokens).toBe(expectedMaxTokens) const reasoning = (body.reasoningEffort as string | undefined) ?? (body.reasoning_effort as string | undefined) @@ -442,12 +437,7 @@ describe("session.llm.stream", () => { expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high") const maxTokens = body.max_output_tokens as number | undefined - const expectedMaxTokens = ProviderTransform.maxOutputTokens( - resolved.api.npm, - ProviderTransform.options({ model: resolved, sessionID }), - resolved.limit.output, - LLM.OUTPUT_TOKEN_MAX, - ) + const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved) expect(maxTokens).toBe(expectedMaxTokens) }, }) @@ -565,14 +555,7 @@ describe("session.llm.stream", () => { expect(capture.url.pathname.endsWith("/messages")).toBe(true) expect(body.model).toBe(resolved.api.id) - expect(body.max_tokens).toBe( - ProviderTransform.maxOutputTokens( - resolved.api.npm, - ProviderTransform.options({ model: resolved, sessionID }), - resolved.limit.output, - LLM.OUTPUT_TOKEN_MAX, - ), - ) + expect(body.max_tokens).toBe(ProviderTransform.maxOutputTokens(resolved)) expect(body.temperature).toBe(0.4) expect(body.top_p).toBe(0.9) }, @@ -677,14 +660,7 @@ describe("session.llm.stream", () => { expect(capture.url.pathname).toBe(pathSuffix) expect(config?.temperature).toBe(0.3) expect(config?.topP).toBe(0.8) - expect(config?.maxOutputTokens).toBe( - ProviderTransform.maxOutputTokens( - resolved.api.npm, - ProviderTransform.options({ model: resolved, sessionID }), - resolved.limit.output, - LLM.OUTPUT_TOKEN_MAX, - ), - ) + expect(config?.maxOutputTokens).toBe(ProviderTransform.maxOutputTokens(resolved)) }, }) }) diff --git a/packages/opencode/test/session/prompt-variant.test.ts b/packages/opencode/test/session/prompt-variant.test.ts index 16e8a22444..83ae175c6e 100644 --- a/packages/opencode/test/session/prompt-variant.test.ts +++ b/packages/opencode/test/session/prompt-variant.test.ts @@ -6,55 +6,63 @@ import { tmpdir } from "../fixture/fixture" describe("session.prompt agent variant", () => { test("applies agent variant only when using agent model", async () => { - await using tmp = await tmpdir({ - git: true, - config: { - agent: { - build: { - model: "openai/gpt-5.2", - variant: "xhigh", + const prev = process.env.OPENAI_API_KEY + process.env.OPENAI_API_KEY = "test-openai-key" + + try { + await using tmp = await tmpdir({ + git: true, + config: { + agent: { + build: { + model: "openai/gpt-5.2", + variant: "xhigh", + }, }, }, - }, - }) + }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const session = await Session.create({}) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) - const other = await SessionPrompt.prompt({ - sessionID: session.id, - agent: "build", - model: { providerID: "opencode", modelID: "kimi-k2.5-free" }, - noReply: true, - parts: [{ type: "text", text: "hello" }], - }) - if (other.info.role !== "user") throw new Error("expected user message") - expect(other.info.variant).toBeUndefined() + const other = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + model: { providerID: "opencode", modelID: "kimi-k2.5-free" }, + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + if (other.info.role !== "user") throw new Error("expected user message") + expect(other.info.variant).toBeUndefined() - const match = await SessionPrompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - parts: [{ type: "text", text: "hello again" }], - }) - if (match.info.role !== "user") throw new Error("expected user message") - expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" }) - expect(match.info.variant).toBe("xhigh") + const match = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "hello again" }], + }) + if (match.info.role !== "user") throw new Error("expected user message") + expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" }) + expect(match.info.variant).toBe("xhigh") - const override = await SessionPrompt.prompt({ - sessionID: session.id, - agent: "build", - noReply: true, - variant: "high", - parts: [{ type: "text", text: "hello third" }], - }) - if (override.info.role !== "user") throw new Error("expected user message") - expect(override.info.variant).toBe("high") + const override = await SessionPrompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + variant: "high", + parts: [{ type: "text", text: "hello third" }], + }) + if (override.info.role !== "user") throw new Error("expected user message") + expect(override.info.variant).toBe("high") - await Session.remove(session.id) - }, - }) + await Session.remove(session.id) + }, + }) + } finally { + if (prev === undefined) delete process.env.OPENAI_API_KEY + else process.env.OPENAI_API_KEY = prev + } }) }) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index a483a01527..6768e72d95 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -112,6 +112,15 @@ describe("session.retry.retryable", () => { const error = wrap("not-json") expect(SessionRetry.retryable(error)).toBeUndefined() }) + + test("does not retry context overflow errors", () => { + const error = new MessageV2.ContextOverflowError({ + message: "Input exceeds context window of this model", + responseBody: '{"error":{"code":"context_length_exceeded"}}', + }).toObject() as ReturnType<NamedError["toObject"]> + + expect(SessionRetry.retryable(error)).toBeUndefined() + }) }) describe("session.message-v2.fromError", () => { diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2ac19ea51a..deb0553ba9 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.1.53", + "version": "1.1.56", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 33a0f1c163..af66b914fb 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.1.53", + "version": "1.1.56", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9543e5b579..07ce5c2b05 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -203,6 +203,7 @@ export type AssistantMessage = { summary?: boolean cost: number tokens: { + total?: number input: number output: number reasoning: number @@ -418,6 +419,7 @@ export type StepFinishPart = { snapshot?: string cost: number tokens: { + total?: number input: number output: number reasoning: number @@ -1822,6 +1824,10 @@ export type Config = { * Enable pruning of old tool outputs (default: true) */ prune?: boolean + /** + * Token buffer for compaction. Leaves enough window to avoid overflow during compaction. + */ + reserved?: number } experimental?: { disable_paste_summary?: boolean diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 18022a3384..dd118a3ae5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -6393,6 +6393,9 @@ "tokens": { "type": "object", "properties": { + "total": { + "type": "number" + }, "input": { "type": "number" }, @@ -7023,6 +7026,9 @@ "tokens": { "type": "object", "properties": { + "total": { + "type": "number" + }, "input": { "type": "number" }, @@ -9894,6 +9900,12 @@ "prune": { "description": "Enable pruning of old tool outputs (default: true)", "type": "boolean" + }, + "reserved": { + "description": "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 } } }, diff --git a/packages/slack/package.json b/packages/slack/package.json index 4e8028f99e..240042a37f 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.53", + "version": "1.1.56", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 6948d52889..a75098a2d3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.53", + "version": "1.1.56", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/assets/icons/app/cursor.svg b/packages/ui/src/assets/icons/app/cursor.svg index c2c8c18199..5aa26e8e71 100644 --- a/packages/ui/src/assets/icons/app/cursor.svg +++ b/packages/ui/src/assets/icons/app/cursor.svg @@ -1 +1 @@ -<?xml version="1.0" encoding="UTF-8"?><svg id="cursor_light__Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 532.09"><!--Generator: Adobe Illustrator 29.6.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 9)--><defs><style>.cursor_light__st0{fill:#26251e}</style></defs><path class="cursor_light__st0" d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/></svg> \ No newline at end of file +<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><g clip-path="url(#prefix__clip0_5_17)"><rect width="512" height="512" rx="122" fill="#000"/><g clip-path="url(#prefix__clip1_5_17)"><mask id="prefix__a" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="85" y="89" width="343" height="334"><path d="M85 89h343v334H85V89z" fill="#fff"/></mask><g mask="url(#prefix__a)"><path d="M255.428 423l148.991-83.5L255.428 256l-148.99 83.5 148.99 83.5z" fill="url(#prefix__paint0_linear_5_17)"/><path d="M404.419 339.5v-167L255.428 89v167l148.991 83.5z" fill="url(#prefix__paint1_linear_5_17)"/><path d="M255.428 89l-148.99 83.5v167l148.99-83.5V89z" fill="url(#prefix__paint2_linear_5_17)"/><path d="M404.419 172.5L255.428 423V256l148.991-83.5z" fill="#E4E4E4"/><path d="M404.419 172.5L255.428 256l-148.99-83.5h297.981z" fill="#fff"/></g></g></g><defs><linearGradient id="prefix__paint0_linear_5_17" x1="255.428" y1="256" x2="255.428" y2="423" gradientUnits="userSpaceOnUse"><stop offset=".16" stop-color="#fff" stop-opacity=".39"/><stop offset=".658" stop-color="#fff" stop-opacity=".8"/></linearGradient><linearGradient id="prefix__paint1_linear_5_17" x1="404.419" y1="173.015" x2="257.482" y2="261.497" gradientUnits="userSpaceOnUse"><stop offset=".182" stop-color="#fff" stop-opacity=".31"/><stop offset=".715" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient id="prefix__paint2_linear_5_17" x1="255.428" y1="89" x2="112.292" y2="342.802" gradientUnits="userSpaceOnUse"><stop stop-color="#fff" stop-opacity=".6"/><stop offset=".667" stop-color="#fff" stop-opacity=".22"/></linearGradient><clipPath id="prefix__clip0_5_17"><path fill="#fff" d="M0 0h512v512H0z"/></clipPath><clipPath id="prefix__clip1_5_17"><path fill="#fff" transform="translate(85 89)" d="M0 0h343v334H0z"/></clipPath></defs></svg> diff --git a/packages/ui/src/assets/icons/app/finder.png b/packages/ui/src/assets/icons/app/finder.png index 4edf53bca9..2cff579e61 100644 Binary files a/packages/ui/src/assets/icons/app/finder.png and b/packages/ui/src/assets/icons/app/finder.png differ diff --git a/packages/ui/src/assets/icons/app/zed-dark.svg b/packages/ui/src/assets/icons/app/zed-dark.svg new file mode 100644 index 0000000000..67a99ae4a8 --- /dev/null +++ b/packages/ui/src/assets/icons/app/zed-dark.svg @@ -0,0 +1,15 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"> + <g clip-path="url(#zed_logo-dark-a)"> + <path + fill="#fff" + fill-rule="evenodd" + d="M9 6a3 3 0 0 0-3 3v66H0V9a9 9 0 0 1 9-9h80.379c4.009 0 6.016 4.847 3.182 7.682L43.055 57.187H57V51h6v7.688a4.5 4.5 0 0 1-4.5 4.5H37.055L26.743 73.5H73.5V36h6v37.5a6 6 0 0 1-6 6H20.743L10.243 90H87a3 3 0 0 0 3-3V21h6v66a9 9 0 0 1-9 9H6.621c-4.009 0-6.016-4.847-3.182-7.682L52.757 39H39v6h-6v-7.5a4.5 4.5 0 0 1 4.5-4.5h21.257l10.5-10.5H22.5V60h-6V22.5a6 6 0 0 1 6-6h52.757L85.757 6H9Z" + clip-rule="evenodd" + /> + </g> + <defs> + <clipPath id="zed_logo-dark-a"> + <path fill="#fff" d="M0 0h96v96H0z" /> + </clipPath> + </defs> +</svg> diff --git a/packages/ui/src/assets/icons/app/zed.svg b/packages/ui/src/assets/icons/app/zed.svg index 7c9a0e5914..a845bf1815 100644 --- a/packages/ui/src/assets/icons/app/zed.svg +++ b/packages/ui/src/assets/icons/app/zed.svg @@ -1 +1,15 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"><g clip-path="url(#zed_light__a)"><path fill="currentColor" fill-rule="evenodd" d="M9 6a3 3 0 0 0-3 3v66H0V9a9 9 0 0 1 9-9h80.379c4.009 0 6.016 4.847 3.182 7.682L43.055 57.187H57V51h6v7.688a4.5 4.5 0 0 1-4.5 4.5H37.055L26.743 73.5H73.5V36h6v37.5a6 6 0 0 1-6 6H20.743L10.243 90H87a3 3 0 0 0 3-3V21h6v66a9 9 0 0 1-9 9H6.621c-4.009 0-6.016-4.847-3.182-7.682L52.757 39H39v6h-6v-7.5a4.5 4.5 0 0 1 4.5-4.5h21.257l10.5-10.5H22.5V60h-6V22.5a6 6 0 0 1 6-6h52.757L85.757 6H9Z" clip-rule="evenodd"/></g><defs><clipPath id="zed_light__a"><path fill="#fff" d="M0 0h96v96H0z"/></clipPath></defs></svg> \ No newline at end of file +<svg xmlns="http://www.w3.org/2000/svg" width="96" height="96" fill="none" viewBox="0 0 96 96"> + <g clip-path="url(#zed_logo-a)"> + <path + fill="#000" + fill-rule="evenodd" + d="M9 6a3 3 0 0 0-3 3v66H0V9a9 9 0 0 1 9-9h80.379c4.009 0 6.016 4.847 3.182 7.682L43.055 57.187H57V51h6v7.688a4.5 4.5 0 0 1-4.5 4.5H37.055L26.743 73.5H73.5V36h6v37.5a6 6 0 0 1-6 6H20.743L10.243 90H87a3 3 0 0 0 3-3V21h6v66a9 9 0 0 1-9 9H6.621c-4.009 0-6.016-4.847-3.182-7.682L52.757 39H39v6h-6v-7.5a4.5 4.5 0 0 1 4.5-4.5h21.257l10.5-10.5H22.5V60h-6V22.5a6 6 0 0 1 6-6h52.757L85.757 6H9Z" + clip-rule="evenodd" + /> + </g> + <defs> + <clipPath id="zed_logo-a"> + <path fill="#fff" d="M0 0h96v96H0z" /> + </clipPath> + </defs> +</svg> diff --git a/packages/ui/src/components/app-icon.css b/packages/ui/src/components/app-icon.css index edcdbcceb5..16cb0dcc1c 100644 --- a/packages/ui/src/components/app-icon.css +++ b/packages/ui/src/components/app-icon.css @@ -1,9 +1,5 @@ img[data-component="app-icon"] { display: block; box-sizing: border-box; - padding: 2px; - border-radius: 0.125rem; - background: var(--smoke-light-2); - border: 1px solid var(--smoke-light-alpha-4); object-fit: contain; } diff --git a/packages/ui/src/components/app-icon.tsx b/packages/ui/src/components/app-icon.tsx index e3f2a0fb23..e91638b989 100644 --- a/packages/ui/src/components/app-icon.tsx +++ b/packages/ui/src/components/app-icon.tsx @@ -1,5 +1,5 @@ import type { Component, ComponentProps } from "solid-js" -import { splitProps } from "solid-js" +import { createSignal, onCleanup, onMount, splitProps } from "solid-js" import type { IconName } from "./app-icons/types" import androidStudio from "../assets/icons/app/android-studio.svg" @@ -15,6 +15,7 @@ import textmate from "../assets/icons/app/textmate.png" import vscode from "../assets/icons/app/vscode.svg" import xcode from "../assets/icons/app/xcode.png" import zed from "../assets/icons/app/zed.svg" +import zedDark from "../assets/icons/app/zed-dark.svg" import sublimetext from "../assets/icons/app/sublimetext.svg" const icons = { @@ -34,17 +35,43 @@ const icons = { "sublime-text": sublimetext, } satisfies Record<IconName, string> +const themed: Partial<Record<IconName, { light: string; dark: string }>> = { + zed: { + light: zed, + dark: zedDark, + }, +} + +const scheme = () => { + if (typeof document !== "object") return "light" as const + if (document.documentElement.dataset.colorScheme === "dark") return "dark" as const + return "light" as const +} + export type AppIconProps = Omit<ComponentProps<"img">, "src"> & { id: IconName } export const AppIcon: Component<AppIconProps> = (props) => { const [local, rest] = splitProps(props, ["id", "class", "classList", "alt", "draggable"]) + const [mode, setMode] = createSignal(scheme()) + + onMount(() => { + const sync = () => setMode(scheme()) + const observer = new MutationObserver(sync) + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-color-scheme"], + }) + sync() + onCleanup(() => observer.disconnect()) + }) + return ( <img data-component="app-icon" {...rest} - src={icons[local.id]} + src={themed[local.id]?.[mode()] ?? icons[local.id]} alt={local.alt ?? ""} draggable={local.draggable ?? false} classList={{ diff --git a/packages/ui/src/components/checkbox.css b/packages/ui/src/components/checkbox.css index b10ebbbd19..8ca73d349d 100644 --- a/packages/ui/src/components/checkbox.css +++ b/packages/ui/src/components/checkbox.css @@ -79,9 +79,9 @@ background-color: var(--surface-hover); } - &:focus-within:not([data-readonly]) [data-slot="checkbox-checkbox-control"] { + &:not([data-readonly]) [data-slot="checkbox-checkbox-input"]:focus-visible + [data-slot="checkbox-checkbox-control"] { border-color: var(--border-focus); - box-shadow: 0 0 0 2px var(--surface-focus); + box-shadow: var(--shadow-xs-border-focus); } &[data-checked] [data-slot="checkbox-checkbox-control"], diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index e3e1e56520..4e7c82d788 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -1,5 +1,6 @@ import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs" import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js" +import { Portal } from "solid-js/web" import { createDefaultOptions, styleVariables } from "../pierre" import { getWorkerPool } from "../pierre/worker" import { Icon } from "./icon" @@ -125,11 +126,9 @@ export function Code<T>(props: CodeProps<T>) { let wrapper!: HTMLDivElement let container!: HTMLDivElement let findInput: HTMLInputElement | undefined - let findBar: HTMLDivElement | undefined let findOverlay!: HTMLDivElement let findOverlayFrame: number | undefined let findOverlayScroll: HTMLElement[] = [] - let findScroll: HTMLElement | undefined let observer: MutationObserver | undefined let renderToken = 0 let selectionFrame: number | undefined @@ -159,6 +158,8 @@ export function Code<T>(props: CodeProps<T>) { let findMode: "highlights" | "overlay" = "overlay" let findHits: Range[] = [] + const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 }) + const file = createMemo( () => new File<T>( @@ -291,23 +292,26 @@ export function Code<T>(props: CodeProps<T>) { setFindIndex(0) } - const getScrollParent = (el: HTMLElement): HTMLElement | null => { + const getScrollParent = (el: HTMLElement): HTMLElement | undefined => { let parent = el.parentElement while (parent) { const style = getComputedStyle(parent) if (style.overflowY === "auto" || style.overflowY === "scroll") return parent parent = parent.parentElement } - return null } const positionFindBar = () => { - if (!findBar || !wrapper) return - const scrollTop = findScroll ? findScroll.scrollTop : window.scrollY - findBar.style.position = "absolute" - findBar.style.top = `${scrollTop + 8}px` - findBar.style.right = "8px" - findBar.style.left = "" + if (typeof window === "undefined") return + + const root = getScrollParent(wrapper) ?? wrapper + const rect = root.getBoundingClientRect() + const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height")) + const header = Number.isNaN(title) ? 0 : title + setFindPos({ + top: Math.round(rect.top) + header - 4, + right: Math.round(window.innerWidth - rect.right) + 8, + }) } const scanFind = (root: ShadowRoot, query: string) => { @@ -426,7 +430,6 @@ export function Code<T>(props: CodeProps<T>) { } if (opts?.scroll && active) { scrollToRange(active) - positionFindBar() } return } @@ -435,7 +438,6 @@ export function Code<T>(props: CodeProps<T>) { syncOverlayScroll() if (opts?.scroll && active) { scrollToRange(active) - positionFindBar() } scheduleOverlay() } @@ -464,14 +466,12 @@ export function Code<T>(props: CodeProps<T>) { return } scrollToRange(active) - positionFindBar() return } clearHighlightFind() syncOverlayScroll() scrollToRange(active) - positionFindBar() scheduleOverlay() } @@ -484,11 +484,9 @@ export function Code<T>(props: CodeProps<T>) { findCurrent = host findTarget = host - findScroll = getScrollParent(wrapper) ?? undefined if (!findOpen()) setFindOpen(true) requestAnimationFrame(() => { applyFind({ scroll: true }) - positionFindBar() findInput?.focus() findInput?.select() }) @@ -514,18 +512,18 @@ export function Code<T>(props: CodeProps<T>) { createEffect(() => { if (!findOpen()) return - findScroll = getScrollParent(wrapper) ?? undefined - const target = findScroll ?? window - const handler = () => positionFindBar() - target.addEventListener("scroll", handler, { passive: true }) - window.addEventListener("resize", handler, { passive: true }) - handler() + const update = () => positionFindBar() + requestAnimationFrame(update) + window.addEventListener("resize", update, { passive: true }) + + const root = getScrollParent(wrapper) ?? wrapper + const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update()) + observer?.observe(root) onCleanup(() => { - target.removeEventListener("scroll", handler) - window.removeEventListener("resize", handler) - findScroll = undefined + window.removeEventListener("resize", update) + observer?.disconnect() }) }) @@ -916,6 +914,64 @@ export function Code<T>(props: CodeProps<T>) { pendingSelectionEnd = false }) + const FindBar = (barProps: { class: string; style?: ComponentProps<"div">["style"] }) => ( + <div class={barProps.class} style={barProps.style} onPointerDown={(e) => e.stopPropagation()}> + <Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" /> + <input + ref={findInput} + placeholder="Find" + value={findQuery()} + class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak" + onInput={(e) => { + setFindQuery(e.currentTarget.value) + setFindIndex(0) + applyFind({ reset: true, scroll: true }) + }} + onKeyDown={(e) => { + if (e.key === "Escape") { + e.preventDefault() + closeFind() + return + } + if (e.key !== "Enter") return + e.preventDefault() + stepFind(e.shiftKey ? -1 : 1) + }} + /> + <div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}> + {findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"} + </div> + <div class="flex items-center"> + <button + type="button" + class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none" + disabled={findCount() === 0} + aria-label="Previous match" + onClick={() => stepFind(-1)} + > + <Icon name="chevron-down" size="small" class="rotate-180" /> + </button> + <button + type="button" + class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none" + disabled={findCount() === 0} + aria-label="Next match" + onClick={() => stepFind(1)} + > + <Icon name="chevron-down" size="small" /> + </button> + </div> + <button + type="button" + class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong" + aria-label="Close search" + onClick={closeFind} + > + <Icon name="close-small" size="small" /> + </button> + </div> + ) + return ( <div data-component="code" @@ -936,65 +992,15 @@ export function Code<T>(props: CodeProps<T>) { }} > <Show when={findOpen()}> - <div - ref={findBar} - class="z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md" - onPointerDown={(e) => e.stopPropagation()} - > - <Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" /> - <input - ref={findInput} - placeholder="Find" - value={findQuery()} - class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak" - onInput={(e) => { - setFindQuery(e.currentTarget.value) - setFindIndex(0) - applyFind({ reset: true, scroll: true }) - }} - onKeyDown={(e) => { - if (e.key === "Escape") { - e.preventDefault() - closeFind() - return - } - if (e.key !== "Enter") return - e.preventDefault() - stepFind(e.shiftKey ? -1 : 1) + <Portal> + <FindBar + class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md" + style={{ + top: `${findPos().top}px`, + right: `${findPos().right}px`, }} /> - <div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}> - {findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"} - </div> - <div class="flex items-center"> - <button - type="button" - class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none" - disabled={findCount() === 0} - aria-label="Previous match" - onClick={() => stepFind(-1)} - > - <Icon name="chevron-down" size="small" class="rotate-180" /> - </button> - <button - type="button" - class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none" - disabled={findCount() === 0} - aria-label="Next match" - onClick={() => stepFind(1)} - > - <Icon name="chevron-down" size="small" /> - </button> - </div> - <button - type="button" - class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong" - aria-label="Close search" - onClick={closeFind} - > - <Icon name="close-small" size="small" /> - </button> - </div> + </Portal> </Show> <div ref={container} /> <div ref={findOverlay} class="pointer-events-none absolute inset-0 z-0" /> diff --git a/packages/ui/src/components/collapsible.css b/packages/ui/src/components/collapsible.css index 1f20cf85d9..f23746bf15 100644 --- a/packages/ui/src/components/collapsible.css +++ b/packages/ui/src/components/collapsible.css @@ -32,6 +32,7 @@ /* } */ &:focus-visible { outline: none; + background-color: var(--surface-raised-base-hover); } &[data-disabled] { cursor: not-allowed; @@ -70,6 +71,7 @@ /* } */ &:focus-visible { outline: none; + background-color: var(--surface-raised-base-hover); } &[data-disabled] { cursor: not-allowed; diff --git a/packages/ui/src/components/file-icon.tsx b/packages/ui/src/components/file-icon.tsx index 11465112f2..cadb7ba83e 100644 --- a/packages/ui/src/components/file-icon.tsx +++ b/packages/ui/src/components/file-icon.tsx @@ -538,11 +538,7 @@ const toOpenVariant = (icon: IconName): IconName => { return icon } -const basenameOf = (p: string) => - p - .replace(/[/\\]+$/, "") - .split(/[\\/]/) - .pop() ?? "" +const basenameOf = (p: string) => p.split("\\").join("/").split("/").filter(Boolean).pop() ?? "" const folderNameVariants = (name: string) => { const n = name.toLowerCase() diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index 83847a5338..3f61b3186d 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -877,6 +877,74 @@ ToolRegistry.register({ const data = useData() const i18n = useI18n() const childSessionId = () => props.metadata.sessionId as string | undefined + + const href = createMemo(() => { + const sessionId = childSessionId() + if (!sessionId) return + + const direct = data.sessionHref?.(sessionId) + if (direct) return direct + + if (typeof window === "undefined") return + const path = window.location.pathname + const idx = path.indexOf("/session") + if (idx === -1) return + return `${path.slice(0, idx)}/session/${sessionId}` + }) + + createEffect(() => { + const sessionId = childSessionId() + if (!sessionId) return + const sync = data.syncSession + if (!sync) return + Promise.resolve(sync(sessionId)).catch(() => undefined) + }) + + const handleLinkClick = (e: MouseEvent) => { + const sessionId = childSessionId() + const url = href() + if (!sessionId || !url) return + + e.stopPropagation() + + if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return + + const nav = data.navigateToSession + if (!nav || typeof window === "undefined") return + + e.preventDefault() + const before = window.location.pathname + window.location.search + window.location.hash + nav(sessionId) + setTimeout(() => { + const after = window.location.pathname + window.location.search + window.location.hash + if (after === before) window.location.assign(url) + }, 50) + } + + const trigger = () => ( + <div data-slot="basic-tool-tool-info-structured"> + <div data-slot="basic-tool-tool-info-main"> + <span data-slot="basic-tool-tool-title" class="capitalize"> + {i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })} + </span> + <Show when={props.input.description}> + <Switch> + <Match when={href()}> + {(url) => ( + <a data-slot="basic-tool-tool-subtitle" class="clickable" href={url()} onClick={handleLinkClick}> + {props.input.description} + </a> + )} + </Match> + <Match when={true}> + <span data-slot="basic-tool-tool-subtitle">{props.input.description}</span> + </Match> + </Switch> + </Show> + </div> + </div> + ) + const childToolParts = createMemo(() => { const sessionId = childSessionId() if (!sessionId) return [] @@ -924,13 +992,6 @@ ToolRegistry.register({ }) } - const handleSubtitleClick = () => { - const sessionId = childSessionId() - if (sessionId && data.navigateToSession) { - data.navigateToSession(sessionId) - } - } - const renderChildToolPart = () => { const toolData = childToolPart() if (!toolData) return null @@ -958,21 +1019,7 @@ ToolRegistry.register({ <Switch> <Match when={childPermission()}> <> - <Show - when={childToolPart()} - fallback={ - <BasicTool - icon="task" - defaultOpen={true} - trigger={{ - title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }), - titleClass: "capitalize", - subtitle: props.input.description, - }} - onSubtitleClick={handleSubtitleClick} - /> - } - > + <Show when={childToolPart()} fallback={<BasicTool icon="task" defaultOpen={true} trigger={trigger()} />}> {renderChildToolPart()} </Show> <div data-component="permission-prompt"> @@ -991,16 +1038,7 @@ ToolRegistry.register({ </> </Match> <Match when={true}> - <BasicTool - icon="task" - defaultOpen={true} - trigger={{ - title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }), - titleClass: "capitalize", - subtitle: props.input.description, - }} - onSubtitleClick={handleSubtitleClick} - > + <BasicTool icon="task" defaultOpen={true} trigger={trigger()}> <div ref={autoScroll.scrollRef} onScroll={autoScroll.handleScroll} diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index 25dd2eb40b..a765850d49 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -82,7 +82,7 @@ box-shadow: var(--shadow-xs-border-base); } - &:not([data-expanded]):focus { + &:not([data-expanded]):not(:focus-visible):focus { background-color: transparent; box-shadow: none; } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index c2e26b9c7b..9ffa671e69 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -131,6 +131,11 @@ function isAttachment(part: PartType | undefined) { return mime.startsWith("image/") || mime === "application/pdf" } +function list<T>(value: T[] | undefined | null, fallback: T[]) { + if (Array.isArray(value)) return value + return fallback +} + function AssistantMessageItem(props: { message: AssistantMessage responsePartId: string | undefined @@ -140,7 +145,7 @@ function AssistantMessageItem(props: { }) { const data = useData() const emptyParts: PartType[] = [] - const msgParts = createMemo(() => data.store.part[props.message.id] ?? emptyParts) + const msgParts = createMemo(() => list(data.store.part?.[props.message.id], emptyParts)) const lastTextPart = createMemo(() => { const parts = msgParts() for (let i = parts.length - 1; i >= 0; i--) { @@ -206,7 +211,7 @@ export function SessionTurn( const emptyQuestionParts: { part: ToolPart; message: AssistantMessage }[] = [] const idle = { type: "idle" as const } - const allMessages = createMemo(() => data.store.message[props.sessionID] ?? emptyMessages) + const allMessages = createMemo(() => list(data.store.message?.[props.sessionID], emptyMessages)) const messageIndex = createMemo(() => { const messages = allMessages() ?? emptyMessages @@ -248,7 +253,7 @@ export function SessionTurn( const parts = createMemo(() => { const msg = message() if (!msg) return emptyParts - return data.store.part[msg.id] ?? emptyParts + return list(data.store.part?.[msg.id], emptyParts) }) const attachmentParts = createMemo(() => { @@ -299,7 +304,7 @@ export function SessionTurn( const lastTextPart = createMemo(() => { const msgs = assistantMessages() for (let mi = msgs.length - 1; mi >= 0; mi--) { - const msgParts = data.store.part[msgs[mi].id] ?? emptyParts + const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts) for (let pi = msgParts.length - 1; pi >= 0; pi--) { const part = msgParts[pi] if (part?.type === "text") return part as TextPart @@ -310,8 +315,7 @@ export function SessionTurn( const hasSteps = createMemo(() => { for (const m of assistantMessages()) { - const msgParts = data.store.part[m.id] - if (!msgParts) continue + const msgParts = list(data.store.part?.[m.id], emptyParts) for (const p of msgParts) { if (p?.type === "tool") return true } @@ -319,10 +323,10 @@ export function SessionTurn( return false }) - const permissions = createMemo(() => data.store.permission?.[props.sessionID] ?? emptyPermissions) + const permissions = createMemo(() => list(data.store.permission?.[props.sessionID], emptyPermissions)) const nextPermission = createMemo(() => permissions()[0]) - const questions = createMemo(() => data.store.question?.[props.sessionID] ?? emptyQuestions) + const questions = createMemo(() => list(data.store.question?.[props.sessionID], emptyQuestions)) const nextQuestion = createMemo(() => questions()[0]) const hidden = createMemo(() => { @@ -341,7 +345,7 @@ export function SessionTurn( const result: { part: ToolPart; message: AssistantMessage }[] = [] for (const msg of assistantMessages()) { - const parts = data.store.part[msg.id] ?? emptyParts + const parts = list(data.store.part?.[msg.id], emptyParts) for (const part of parts) { if (part?.type !== "tool") continue const tool = part as ToolPart @@ -365,7 +369,7 @@ export function SessionTurn( const msgs = assistantMessages() if (msgs.length !== 1) return - const msgParts = data.store.part[msgs[0].id] ?? emptyParts + const msgParts = list(data.store.part?.[msgs[0].id], emptyParts) if (msgParts.length !== 1) return const assistantPart = msgParts[0] @@ -380,7 +384,7 @@ export function SessionTurn( let currentTask: ToolPart | undefined for (let mi = msgs.length - 1; mi >= 0; mi--) { - const msgParts = data.store.part[msgs[mi].id] ?? emptyParts + const msgParts = list(data.store.part?.[msgs[mi].id], emptyParts) for (let pi = msgParts.length - 1; pi >= 0; pi--) { const part = msgParts[pi] if (!part) continue @@ -407,12 +411,12 @@ export function SessionTurn( : undefined if (taskSessionId) { - const taskMessages = data.store.message[taskSessionId] ?? emptyMessages + const taskMessages = list(data.store.message?.[taskSessionId], emptyMessages) for (let mi = taskMessages.length - 1; mi >= 0; mi--) { const msg = taskMessages[mi] if (!msg || msg.role !== "assistant") continue - const msgParts = data.store.part[msg.id] ?? emptyParts + const msgParts = list(data.store.part?.[msg.id], emptyParts) for (let pi = msgParts.length - 1; pi >= 0; pi--) { const part = msgParts[pi] if (part) return computeStatusFromPart(part, i18n.t) diff --git a/packages/ui/src/components/switch.css b/packages/ui/src/components/switch.css index 89e8447322..579930f554 100644 --- a/packages/ui/src/components/switch.css +++ b/packages/ui/src/components/switch.css @@ -86,9 +86,9 @@ background-color: var(--surface-hover); } - &:focus-within:not([data-readonly]) [data-slot="switch-control"] { + &:not([data-readonly]) [data-slot="switch-input"]:focus-visible ~ [data-slot="switch-control"] { border-color: var(--border-focus); - box-shadow: 0 0 0 2px var(--surface-focus); + box-shadow: var(--shadow-xs-border-focus); } &[data-checked] [data-slot="switch-control"] { diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 56c3e083f5..7668c29764 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -429,6 +429,11 @@ background-color: var(--surface-raised-base-hover); } + &:has([data-slot="tabs-trigger"]:focus-visible) { + background-color: var(--surface-raised-base-hover); + box-shadow: var(--shadow-xs-border-focus); + } + &:has([data-selected]) { background-color: var(--surface-raised-base-active); color: var(--text-strong); diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css index ed7ba4a20e..de547f9c78 100644 --- a/packages/ui/src/components/toast.css +++ b/packages/ui/src/components/toast.css @@ -7,7 +7,9 @@ flex-direction: column; gap: 8px; max-width: min(400px, calc(100vw - 64px)); + max-height: calc(100dvh - 96px); width: 100%; + overflow: hidden; pointer-events: none; [data-slot="toast-list"] { @@ -17,6 +19,8 @@ list-style: none; margin: 0; padding: 0; + max-height: 100%; + overflow-y: auto; } } @@ -26,6 +30,8 @@ align-items: flex-start; gap: 20px; padding: 16px 20px; + max-height: min(420px, calc(100dvh - 96px)); + overflow: hidden; pointer-events: auto; transition: all 150ms ease-out; @@ -91,8 +97,10 @@ display: flex; flex-direction: column; gap: 2px; + min-height: 0; min-width: 0; - overflow: hidden; + overflow-x: hidden; + overflow-y: auto; } [data-slot="toast-title"] { diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index b55265b794..055e504654 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -1,5 +1,5 @@ import { Tooltip as KobalteTooltip } from "@kobalte/core/tooltip" -import { children, createSignal, Match, onMount, splitProps, Switch, type JSX } from "solid-js" +import { createSignal, Match, splitProps, Switch, type JSX } from "solid-js" import type { ComponentProps } from "solid-js" export interface TooltipProps extends ComponentProps<typeof KobalteTooltip> { @@ -40,32 +40,16 @@ export function Tooltip(props: TooltipProps) { "contentStyle", "inactive", "forceOpen", + "value", ]) - const c = children(() => local.children) - - onMount(() => { - const childElements = c() - if (childElements instanceof HTMLElement) { - childElements.addEventListener("focusin", () => setOpen(true)) - childElements.addEventListener("focusout", () => setOpen(false)) - } else if (Array.isArray(childElements)) { - for (const child of childElements) { - if (child instanceof HTMLElement) { - child.addEventListener("focusin", () => setOpen(true)) - child.addEventListener("focusout", () => setOpen(false)) - } - } - } - }) - return ( <Switch> <Match when={local.inactive}>{local.children}</Match> <Match when={true}> <KobalteTooltip gutter={4} {...others} open={local.forceOpen || open()} onOpenChange={setOpen}> <KobalteTooltip.Trigger as={"div"} data-component="tooltip-trigger" class={local.class}> - {c()} + {local.children} </KobalteTooltip.Trigger> <KobalteTooltip.Portal> <KobalteTooltip.Content @@ -75,7 +59,7 @@ export function Tooltip(props: TooltipProps) { class={local.contentClass} style={local.contentStyle} > - {others.value} + {local.value} {/* <KobalteTooltip.Arrow data-slot="tooltip-arrow" /> */} </KobalteTooltip.Content> </KobalteTooltip.Portal> diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index dcb9adb39c..51bffa0502 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -48,6 +48,10 @@ export type QuestionRejectFn = (input: { requestID: string }) => void export type NavigateToSessionFn = (sessionID: string) => void +export type SessionHrefFn = (sessionID: string) => string + +export type SyncSessionFn = (sessionID: string) => void | Promise<void> + export const { use: useData, provider: DataProvider } = createSimpleContext({ name: "Data", init: (props: { @@ -57,6 +61,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ onQuestionReply?: QuestionReplyFn onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn + onSessionHref?: SessionHrefFn + onSyncSession?: SyncSessionFn }) => { return { get store() { @@ -69,6 +75,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ replyToQuestion: props.onQuestionReply, rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, + sessionHref: props.onSessionHref, + syncSession: props.onSyncSession, } }, }) diff --git a/packages/ui/src/theme/themes/deltarune.json b/packages/ui/src/theme/themes/deltarune.json deleted file mode 100644 index f2ab17ee7f..0000000000 --- a/packages/ui/src/theme/themes/deltarune.json +++ /dev/null @@ -1,231 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkWorldBg": "#0B0B3B", - "darkWorldDeep": "#050520", - "darkWorldPanel": "#151555", - "krisBlue": "#6A7BC4", - "krisCyan": "#75FBED", - "krisIce": "#C7E3F2", - "susiePurple": "#5B209D", - "susieMagenta": "#A017D0", - "susiePink": "#F983D8", - "ralseiGreen": "#33A56C", - "ralseiTeal": "#40E4D4", - "noelleRose": "#DC8998", - "noelleRed": "#DC1510", - "noelleMint": "#ECFFBB", - "noelleCyan": "#77E0FF", - "noelleAqua": "#BBFFFC", - "gold": "#FBCE3C", - "orange": "#F4A731", - "hotPink": "#EB0095", - "queenPink": "#F983D8", - "cyberGreen": "#00FF00", - "white": "#FFFFFF", - "black": "#000000", - "textMuted": "#8888AA" - }, - "theme": { - "primary": { - "dark": "hotPink", - "light": "susieMagenta" - }, - "secondary": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "accent": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "error": { - "dark": "noelleRed", - "light": "noelleRed" - }, - "warning": { - "dark": "gold", - "light": "orange" - }, - "success": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "info": { - "dark": "noelleCyan", - "light": "krisBlue" - }, - "text": { - "dark": "white", - "light": "black" - }, - "textMuted": { - "dark": "textMuted", - "light": "#555577" - }, - "background": { - "dark": "darkWorldBg", - "light": "white" - }, - "backgroundPanel": { - "dark": "darkWorldDeep", - "light": "#F0F0F8" - }, - "backgroundElement": { - "dark": "darkWorldPanel", - "light": "#E5E5F0" - }, - "border": { - "dark": "krisBlue", - "light": "susiePurple" - }, - "borderActive": { - "dark": "hotPink", - "light": "susieMagenta" - }, - "borderSubtle": { - "dark": "#3A3A6A", - "light": "#AAAACC" - }, - "diffAdded": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "diffRemoved": { - "dark": "hotPink", - "light": "noelleRed" - }, - "diffContext": { - "dark": "textMuted", - "light": "#666688" - }, - "diffHunkHeader": { - "dark": "krisBlue", - "light": "susiePurple" - }, - "diffHighlightAdded": { - "dark": "ralseiGreen", - "light": "ralseiTeal" - }, - "diffHighlightRemoved": { - "dark": "noelleRed", - "light": "hotPink" - }, - "diffAddedBg": { - "dark": "#0A2A2A", - "light": "#D4FFEE" - }, - "diffRemovedBg": { - "dark": "#2A0A2A", - "light": "#FFD4E8" - }, - "diffContextBg": { - "dark": "darkWorldDeep", - "light": "#F5F5FA" - }, - "diffLineNumber": { - "dark": "textMuted", - "light": "#666688" - }, - "diffAddedLineNumberBg": { - "dark": "#082020", - "light": "#E0FFF0" - }, - "diffRemovedLineNumberBg": { - "dark": "#200820", - "light": "#FFE0F0" - }, - "markdownText": { - "dark": "white", - "light": "black" - }, - "markdownHeading": { - "dark": "gold", - "light": "orange" - }, - "markdownLink": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "markdownLinkText": { - "dark": "noelleCyan", - "light": "susiePurple" - }, - "markdownCode": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "markdownBlockQuote": { - "dark": "textMuted", - "light": "#666688" - }, - "markdownEmph": { - "dark": "susiePink", - "light": "susieMagenta" - }, - "markdownStrong": { - "dark": "hotPink", - "light": "susiePurple" - }, - "markdownHorizontalRule": { - "dark": "krisBlue", - "light": "susiePurple" - }, - "markdownListItem": { - "dark": "gold", - "light": "orange" - }, - "markdownListEnumeration": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "markdownImage": { - "dark": "susieMagenta", - "light": "susiePurple" - }, - "markdownImageText": { - "dark": "susiePink", - "light": "susieMagenta" - }, - "markdownCodeBlock": { - "dark": "white", - "light": "black" - }, - "syntaxComment": { - "dark": "textMuted", - "light": "#666688" - }, - "syntaxKeyword": { - "dark": "hotPink", - "light": "susieMagenta" - }, - "syntaxFunction": { - "dark": "krisCyan", - "light": "krisBlue" - }, - "syntaxVariable": { - "dark": "gold", - "light": "orange" - }, - "syntaxString": { - "dark": "ralseiTeal", - "light": "ralseiGreen" - }, - "syntaxNumber": { - "dark": "noelleRose", - "light": "noelleRed" - }, - "syntaxType": { - "dark": "noelleCyan", - "light": "krisBlue" - }, - "syntaxOperator": { - "dark": "white", - "light": "black" - }, - "syntaxPunctuation": { - "dark": "krisBlue", - "light": "#555577" - } - } -} diff --git a/packages/ui/src/theme/themes/undertale.json b/packages/ui/src/theme/themes/undertale.json deleted file mode 100644 index bfbd60b2a0..0000000000 --- a/packages/ui/src/theme/themes/undertale.json +++ /dev/null @@ -1,232 +0,0 @@ -{ - "$schema": "https://opencode.ai/theme.json", - "defs": { - "black": "#000000", - "white": "#FFFFFF", - "soulRed": "#FF0000", - "soulOrange": "#FF6600", - "soulYellow": "#FFFF00", - "soulGreen": "#00FF00", - "soulAqua": "#00FFFF", - "soulBlue": "#0000FF", - "soulPurple": "#FF00FF", - "ruinsPurple": "#A349A4", - "ruinsDark": "#380A43", - "snowdinBlue": "#6BA3E5", - "hotlandOrange": "#FF7F27", - "coreGray": "#3A3949", - "battleBg": "#0D0D1A", - "battlePanel": "#1A1A2E", - "uiYellow": "#FFC90E", - "textGray": "#909090", - "damageRed": "#FF3333", - "healGreen": "#00FF00", - "saveYellow": "#FFFF00", - "determinationRed": "#FF0000", - "mttPink": "#FF6EB4", - "waterfall": "#283197", - "waterfallGlow": "#00BFFF" - }, - "theme": { - "primary": { - "dark": "soulRed", - "light": "determinationRed" - }, - "secondary": { - "dark": "uiYellow", - "light": "uiYellow" - }, - "accent": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "error": { - "dark": "damageRed", - "light": "soulRed" - }, - "warning": { - "dark": "uiYellow", - "light": "hotlandOrange" - }, - "success": { - "dark": "healGreen", - "light": "soulGreen" - }, - "info": { - "dark": "soulAqua", - "light": "waterfallGlow" - }, - "text": { - "dark": "white", - "light": "black" - }, - "textMuted": { - "dark": "textGray", - "light": "coreGray" - }, - "background": { - "dark": "black", - "light": "white" - }, - "backgroundPanel": { - "dark": "battleBg", - "light": "#F0F0F0" - }, - "backgroundElement": { - "dark": "battlePanel", - "light": "#E5E5E5" - }, - "border": { - "dark": "white", - "light": "black" - }, - "borderActive": { - "dark": "soulRed", - "light": "determinationRed" - }, - "borderSubtle": { - "dark": "#555555", - "light": "#AAAAAA" - }, - "diffAdded": { - "dark": "healGreen", - "light": "soulGreen" - }, - "diffRemoved": { - "dark": "damageRed", - "light": "soulRed" - }, - "diffContext": { - "dark": "textGray", - "light": "coreGray" - }, - "diffHunkHeader": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "diffHighlightAdded": { - "dark": "soulGreen", - "light": "healGreen" - }, - "diffHighlightRemoved": { - "dark": "soulRed", - "light": "determinationRed" - }, - "diffAddedBg": { - "dark": "#002200", - "light": "#CCFFCC" - }, - "diffRemovedBg": { - "dark": "#220000", - "light": "#FFCCCC" - }, - "diffContextBg": { - "dark": "battleBg", - "light": "#F5F5F5" - }, - "diffLineNumber": { - "dark": "textGray", - "light": "coreGray" - }, - "diffAddedLineNumberBg": { - "dark": "#001A00", - "light": "#E0FFE0" - }, - "diffRemovedLineNumberBg": { - "dark": "#1A0000", - "light": "#FFE0E0" - }, - "markdownText": { - "dark": "white", - "light": "black" - }, - "markdownHeading": { - "dark": "uiYellow", - "light": "hotlandOrange" - }, - "markdownLink": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "markdownLinkText": { - "dark": "waterfallGlow", - "light": "waterfall" - }, - "markdownCode": { - "dark": "healGreen", - "light": "soulGreen" - }, - "markdownBlockQuote": { - "dark": "textGray", - "light": "coreGray" - }, - "markdownEmph": { - "dark": "mttPink", - "light": "soulPurple" - }, - "markdownStrong": { - "dark": "soulRed", - "light": "determinationRed" - }, - "markdownHorizontalRule": { - "dark": "white", - "light": "black" - }, - "markdownListItem": { - "dark": "uiYellow", - "light": "uiYellow" - }, - "markdownListEnumeration": { - "dark": "uiYellow", - "light": "uiYellow" - }, - "markdownImage": { - "dark": "ruinsPurple", - "light": "soulPurple" - }, - "markdownImageText": { - "dark": "mttPink", - "light": "ruinsPurple" - }, - "markdownCodeBlock": { - "dark": "white", - "light": "black" - }, - "syntaxComment": { - "dark": "textGray", - "light": "coreGray" - }, - "syntaxKeyword": { - "dark": "soulRed", - "light": "determinationRed" - }, - "syntaxFunction": { - "dark": "soulAqua", - "light": "soulBlue" - }, - "syntaxVariable": { - "dark": "uiYellow", - "light": "hotlandOrange" - }, - "syntaxString": { - "dark": "healGreen", - "light": "soulGreen" - }, - "syntaxNumber": { - "dark": "mttPink", - "light": "soulPurple" - }, - "syntaxType": { - "dark": "waterfallGlow", - "light": "waterfall" - }, - "syntaxOperator": { - "dark": "white", - "light": "black" - }, - "syntaxPunctuation": { - "dark": "textGray", - "light": "coreGray" - } - } -} diff --git a/packages/util/package.json b/packages/util/package.json index 7c05563d2f..cb744ea088 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.53", + "version": "1.1.56", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs index acaaf12bee..3e3d153e68 100644 --- a/packages/web/astro.config.mjs +++ b/packages/web/astro.config.mjs @@ -32,6 +32,99 @@ export default defineConfig({ solidJs(), starlight({ title: "OpenCode", + defaultLocale: "root", + locales: { + root: { + label: "English", + lang: "en", + dir: "ltr", + }, + ar: { + label: "العربية", + lang: "ar", + dir: "rtl", + }, + bs: { + label: "Bosanski", + lang: "bs-BA", + dir: "ltr", + }, + da: { + label: "Dansk", + lang: "da-DK", + dir: "ltr", + }, + de: { + label: "Deutsch", + lang: "de-DE", + dir: "ltr", + }, + es: { + label: "Espa\u00f1ol", + lang: "es-ES", + dir: "ltr", + }, + fr: { + label: "Fran\u00e7ais", + lang: "fr-FR", + dir: "ltr", + }, + it: { + label: "Italiano", + lang: "it-IT", + dir: "ltr", + }, + ja: { + label: "日本語", + lang: "ja-JP", + dir: "ltr", + }, + ko: { + label: "한국어", + lang: "ko-KR", + dir: "ltr", + }, + nb: { + label: "Norsk Bokm\u00e5l", + lang: "nb-NO", + dir: "ltr", + }, + pl: { + label: "Polski", + lang: "pl-PL", + dir: "ltr", + }, + "pt-br": { + label: "Portugu\u00eas (Brasil)", + lang: "pt-BR", + dir: "ltr", + }, + ru: { + label: "Русский", + lang: "ru-RU", + dir: "ltr", + }, + th: { + label: "ไทย", + lang: "th-TH", + dir: "ltr", + }, + tr: { + label: "T\u00fcrk\u00e7e", + lang: "tr-TR", + dir: "ltr", + }, + "zh-cn": { + label: "简体中文", + lang: "zh-CN", + dir: "ltr", + }, + "zh-tw": { + label: "繁體中文", + lang: "zh-TW", + dir: "ltr", + }, + }, favicon: "/favicon-v3.svg", head: [ { @@ -86,14 +179,53 @@ export default defineConfig({ "enterprise", "troubleshooting", "windows-wsl", - "1-0", { label: "Usage", + translations: { + en: "Usage", + ar: "الاستخدام", + "bs-BA": "Korištenje", + "da-DK": "Brug", + "de-DE": "Nutzung", + "es-ES": "Uso", + "fr-FR": "Utilisation", + "it-IT": "Utilizzo", + "ja-JP": "使い方", + "ko-KR": "사용", + "nb-NO": "Bruk", + "pl-PL": "Użycie", + "pt-BR": "Uso", + "ru-RU": "Использование", + "th-TH": "การใช้งาน", + "tr-TR": "Kullanım", + "zh-CN": "使用", + "zh-TW": "使用", + }, items: ["tui", "cli", "web", "ide", "zen", "share", "github", "gitlab"], }, { label: "Configure", + translations: { + en: "Configure", + ar: "الإعداد", + "bs-BA": "Podešavanje", + "da-DK": "Konfiguration", + "de-DE": "Konfiguration", + "es-ES": "Configuración", + "fr-FR": "Configuration", + "it-IT": "Configurazione", + "ja-JP": "設定", + "ko-KR": "구성", + "nb-NO": "Konfigurasjon", + "pl-PL": "Konfiguracja", + "pt-BR": "Configuração", + "ru-RU": "Настройка", + "th-TH": "การกำหนดค่า", + "tr-TR": "Yapılandırma", + "zh-CN": "配置", + "zh-TW": "設定", + }, items: [ "tools", "rules", @@ -114,6 +246,26 @@ export default defineConfig({ { label: "Develop", + translations: { + en: "Develop", + ar: "التطوير", + "bs-BA": "Razvoj", + "da-DK": "Udvikling", + "de-DE": "Entwicklung", + "es-ES": "Desarrollo", + "fr-FR": "Développement", + "it-IT": "Sviluppo", + "ja-JP": "開発", + "ko-KR": "개발", + "nb-NO": "Utvikling", + "pl-PL": "Rozwój", + "pt-BR": "Desenvolvimento", + "ru-RU": "Разработка", + "th-TH": "การพัฒนา", + "tr-TR": "Geliştirme", + "zh-CN": "开发", + "zh-TW": "開發", + }, items: ["sdk", "server", "plugins", "ecosystem"], }, ], @@ -121,6 +273,7 @@ export default defineConfig({ Hero: "./src/components/Hero.astro", Head: "./src/components/Head.astro", Header: "./src/components/Header.astro", + Footer: "./src/components/Footer.astro", SiteTitle: "./src/components/SiteTitle.astro", }, plugins: [ diff --git a/packages/web/config.mjs b/packages/web/config.mjs index 76ab56fae9..493fc56c92 100644 --- a/packages/web/config.mjs +++ b/packages/web/config.mjs @@ -8,7 +8,7 @@ export default { github: "https://github.com/anomalyco/opencode", discord: "https://opencode.ai/discord", headerLinks: [ - { name: "Home", url: "/" }, - { name: "Docs", url: "/docs/" }, + { name: "app.header.home", url: "/" }, + { name: "app.header.docs", url: "/docs/" }, ], } diff --git a/packages/web/package.json b/packages/web/package.json index 79fda964ff..8b51baa3b2 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.1.53", + "version": "1.1.56", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", @@ -17,7 +17,7 @@ "@astrojs/solid-js": "5.1.0", "@astrojs/starlight": "0.34.3", "@fontsource/ibm-plex-mono": "5.2.5", - "@shikijs/transformers": "3.4.2", + "@shikijs/transformers": "3.20.0", "@types/luxon": "catalog:", "ai": "catalog:", "astro": "5.7.13", @@ -31,11 +31,13 @@ "remeda": "catalog:", "shiki": "catalog:", "solid-js": "catalog:", - "toolbeam-docs-theme": "0.4.8" + "toolbeam-docs-theme": "0.4.8", + "vscode-languageserver-types": "3.17.5" }, "devDependencies": { "opencode": "workspace:*", "@types/node": "catalog:", + "@astrojs/check": "0.9.6", "typescript": "catalog:" } } diff --git a/packages/web/src/components/Footer.astro b/packages/web/src/components/Footer.astro new file mode 100644 index 0000000000..20d65ee9de --- /dev/null +++ b/packages/web/src/components/Footer.astro @@ -0,0 +1,125 @@ +--- +import config from "virtual:starlight/user-config" +import LanguageSelect from "@astrojs/starlight/components/LanguageSelect.astro" +import { Icon } from "@astrojs/starlight/components" + +const { lang, editUrl, lastUpdated, entry } = Astro.locals.starlightRoute +const template = entry.data.template +const issueLink = Astro.locals.t("app.footer.issueLink", "Found a bug? Open an issue") +const discordLink = Astro.locals.t("app.footer.discordLink", "Join our Discord community") + +const github = config.social?.find((item) => item.icon === "github") +const discord = config.social?.find((item) => item.icon === "discord") +--- + +{ + template === "doc" && ( + <footer class="doc"> + <div class="meta sl-flex"> + <div> + { + editUrl && ( + <a href={editUrl} target="_blank" rel="noopener noreferrer" class="sl-flex"> + <Icon name="pencil" size="1em" /> + {Astro.locals.t("page.editLink")} + </a> + ) + } + { + github && ( + <a href={`${github.href}/issues/new`} target="_blank" rel="noopener noreferrer" class="sl-flex"> + <Icon name={github.icon} size="1em" /> + {issueLink} + </a> + ) + } + { + discord && ( + <a href={discord.href} target="_blank" rel="noopener noreferrer" class="sl-flex"> + <Icon name={discord.icon} size="1em" /> + {discordLink} + </a> + ) + } + <LanguageSelect /> + </div> + <div> + <p>© <a target="_blank" rel="noopener noreferrer" href="https://anoma.ly">Anomaly</a></p> + <p title={Astro.locals.t("page.lastUpdated")}> + {Astro.locals.t("page.lastUpdated")} {" "} + { + lastUpdated ? ( + <time datetime={lastUpdated.toISOString()}> + {lastUpdated.toLocaleDateString(lang, { dateStyle: "medium", timeZone: "UTC" })} + </time> + ) : ( + "-" + ) + } + </p> + </div> + </div> + </footer> + ) +} + +<style> + footer.doc { + margin-top: 3rem; + border-top: 1px solid var(--sl-color-border); + } + + .meta { + gap: 0.75rem 3rem; + justify-content: space-between; + flex-wrap: wrap; + margin-block: 3rem 1.5rem; + font-size: var(--sl-text-sm); + } + + @media (min-width: 30rem) { + .meta { + flex-direction: row; + } + } + + .doc a, + .doc p { + padding-block: 0.125rem; + } + + .doc a { + gap: 0.4375rem; + align-items: center; + text-decoration: none; + color: var(--sl-color-text); + font-size: var(--sl-text-sm); + } + + .doc a svg { + opacity: 0.85; + } + + .doc p { + color: var(--sl-color-text-dimmed); + } + + .doc :global(starlight-lang-select) { + display: inline-flex; + margin-top: 0.5rem; + } + + .doc :global(starlight-lang-select select) { + min-width: 7em; + } + + @media (min-width: 30rem) { + .doc p { + text-align: right; + } + } + + .doc p a { + color: var(--sl-color-text-dimmed); + } +</style> diff --git a/packages/web/src/components/Head.astro b/packages/web/src/components/Head.astro index 0eaf115b5f..4b35d26db4 100644 --- a/packages/web/src/components/Head.astro +++ b/packages/web/src/components/Head.astro @@ -1,10 +1,9 @@ --- import { Base64 } from "js-base64"; -import type { Props } from '@astrojs/starlight/props' import Default from '@astrojs/starlight/components/Head.astro' import config from '../../config.mjs' -const base = import.meta.env.BASE_URL.slice(1) +const base = import.meta.env.BASE_URL.replace(/^\//, "").replace(/\/$/, "") const slug = Astro.url.pathname.replace(/^\//, "").replace(/\/$/, ""); const { @@ -12,7 +11,12 @@ const { data: { title , description }, }, } = Astro.locals.starlightRoute; -const isDocs = slug.startsWith("docs") +const isDocs = base === "" ? true : slug === base || slug.startsWith(`${base}/`) +const t = Astro.locals.t as (key: string) => string +const titleSuffix = t("app.head.titleSuffix") +const shareSlug = base === "" ? "s" : `${base}/s` +const isShare = slug === shareSlug || slug.startsWith(`${shareSlug}/`) +const isHome = slug === "" || slug === base let encodedTitle = ''; let ogImage = `${config.url}/social-share.png`; @@ -38,13 +42,13 @@ if (isDocs) { } --- -{ slug === "" && ( -<title>{title} | AI coding agent built for the terminal +{ isHome && ( +{title} | {titleSuffix} )} -{ (!slug.startsWith(`${base}/s`)) && ( +{ !isShare && ( )} diff --git a/packages/web/src/components/Header.astro b/packages/web/src/components/Header.astro index 396200a3eb..bb13c91175 100644 --- a/packages/web/src/components/Header.astro +++ b/packages/web/src/components/Header.astro @@ -1,128 +1,136 @@ --- -import config from '../../config.mjs'; -import astroConfig from 'virtual:starlight/user-config'; -import { Icon } from '@astrojs/starlight/components'; -import { HeaderLinks } from 'toolbeam-docs-theme/components'; -import Default from 'toolbeam-docs-theme/overrides/Header.astro'; -import SocialIcons from 'virtual:starlight/components/SocialIcons'; -import SiteTitle from '@astrojs/starlight/components/SiteTitle.astro'; +import config from "../../config.mjs" +import astroConfig from "virtual:starlight/user-config" +import { getRelativeLocaleUrl } from "astro:i18n" +import { Icon } from "@astrojs/starlight/components" +import Default from "toolbeam-docs-theme/overrides/Header.astro" +import SiteTitle from "@astrojs/starlight/components/SiteTitle.astro" -const path = Astro.url.pathname; +const path = Astro.url.pathname +const locale = Astro.currentLocale || "root" +const route = Astro.locals.starlightRoute +const t = Astro.locals.t as (key: string) => string +const links = astroConfig.social || [] +const headerLinks = config.headerLinks +const sharePath = /\/s(\/|$)/.test(path) -const links = astroConfig.social || []; -const headerLinks = config.headerLinks; - ---- - -{ path.startsWith("/s") -?

-
- -
-
- { - headerLinks?.map(({ name, url }) => ( - {name} - )) - } -
-
- { - links.length > 0 && ( - - ) - } -
-
- : +function href(url: string) { + if (url === "/" || url === "/docs" || url === "/docs/") { + return getRelativeLocaleUrl(locale, "") + } + return url } +--- + +{sharePath ? ( +
+
+ +
+
+ {headerLinks?.map(({ name, url }) => ( + {t(name)} + ))} +
+
+ {links.length > 0 && ( + + )} +
+
+) : ( + +)} + diff --git a/packages/web/src/components/Lander.astro b/packages/web/src/components/Lander.astro index 2bfe0a102b..9713cfff60 100644 --- a/packages/web/src/components/Lander.astro +++ b/packages/web/src/components/Lander.astro @@ -1,7 +1,7 @@ --- import { Image } from 'astro:assets'; +import { getRelativeLocaleUrl } from 'astro:i18n'; import config from "virtual:starlight/user-config"; -import type { Props } from '@astrojs/starlight/props'; import CopyIcon from "../assets/lander/copy.svg"; import CheckIcon from "../assets/lander/check.svg"; @@ -19,8 +19,14 @@ const imageAttrs = { alt: image?.alt || '', }; -const github = config.social.filter(s => s.icon === 'github')[0]; -const discord = config.social.filter(s => s.icon === 'discord')[0]; +const github = (config.social || []).filter(s => s.icon === 'github')[0]; +const discord = (config.social || []).filter(s => s.icon === 'discord')[0]; +const locale = Astro.currentLocale || 'root'; +const t = Astro.locals.t as (key: string) => string; +const docsHref = getRelativeLocaleUrl(locale, "") +const docsCliHref = getRelativeLocaleUrl(locale, "cli") +const docsIdeHref = getRelativeLocaleUrl(locale, "ide") +const docsGithubHref = getRelativeLocaleUrl(locale, "github") const command = "curl -fsSL" const protocol = "https://" @@ -44,19 +50,21 @@ if (image) {
-

The AI coding agent built for the terminal.

+

{t('app.lander.hero.title')}

- - +
+ 0} fallback={

{props.messages.waiting_for_messages}

}> +
+ + + {(msg, msgIndex) => { + const filteredParts = createMemo(() => + msg.parts.filter((x, index) => { + if (x.type === "step-start" && index > 0) return false + if (x.type === "snapshot") return false + if (x.type === "patch") return false + if (x.type === "step-finish") return false + if (x.type === "text" && x.synthetic === true) return false + if (x.type === "tool" && x.tool === "todoread") return false + if (x.type === "text" && !x.text) return false + if (x.type === "tool" && (x.state.status === "pending" || x.state.status === "running")) + return false + return true + }), + ) + + return ( + + + {(part, partIndex) => { + const last = createMemo( + () => + data().messages.length === msgIndex() + 1 && + filteredParts().length === partIndex() + 1, + ) + + onMount(() => { + const hash = window.location.hash.slice(1) + // Wait till all parts are loaded + if ( + hash !== "" && + !hasScrolledToAnchor && + filteredParts().length === partIndex() + 1 && + data().messages.length === msgIndex() + 1 + ) { + hasScrolledToAnchor = true + scrollToAnchor(hash) + } + }) + + return + }} + + + ) + }} + + +
+
+ +
+
+

{getStatusText(connectionStatus(), props.messages)}

+
    +
  • + {props.messages.cost} + {data().cost !== undefined ? ( + {formatCurrency(data().cost, props.messages.locale)} + ) : ( + + )} +
  • +
  • + {props.messages.input_tokens} + {data().tokens.input ? ( + {formatNumber(data().tokens.input, props.messages.locale)} + ) : ( + + )} +
  • +
  • + {props.messages.output_tokens} + {data().tokens.output ? ( + {formatNumber(data().tokens.output, props.messages.locale)} + ) : ( + + )} +
  • +
  • + {props.messages.reasoning_tokens} + {data().tokens.reasoning ? ( + {formatNumber(data().tokens.reasoning, props.messages.locale)} + ) : ( + + )} +
  • +
+
+
+
+
+
+ + +
+
+ 0} fallback={

{props.messages.waiting_for_messages}

}> +
    + + {(msg) => ( +
  • +
    + {props.messages.debug_key}: {msg.id} +
    +
    {JSON.stringify(msg, null, 2)}
    +
  • + )} +
    +
+
+
+
+
+ + + + + + ) } @@ -502,6 +513,8 @@ export function fromV1(v1: Message.Info): MessageWithParts { id: v1.id, sessionID: v1.metadata.sessionID, role: "assistant", + parentID: "", + agent: "build", time: { created: v1.metadata.time.created, completed: v1.metadata.time.completed, @@ -521,7 +534,6 @@ export function fromV1(v1: Message.Info): MessageWithParts { modelID: v1.metadata.assistant!.modelID, providerID: v1.metadata.assistant!.providerID, mode: "build", - system: v1.metadata.assistant!.system, error: v1.metadata.error, parts: v1.parts.flatMap((part, index): MessageV2.Part[] => { const base = { @@ -557,6 +569,8 @@ export function fromV1(v1: Message.Info): MessageWithParts { if (part.toolInvocation.state === "partial-call") { return { status: "pending", + input: {}, + raw: "", } } @@ -596,6 +610,11 @@ export function fromV1(v1: Message.Info): MessageWithParts { id: v1.id, sessionID: v1.metadata.sessionID, role: "user", + agent: "user", + model: { + providerID: "", + modelID: "", + }, time: { created: v1.metadata.time.created, }, diff --git a/packages/web/src/components/SiteTitle.astro b/packages/web/src/components/SiteTitle.astro index 28a30cb232..6f1405cb14 100644 --- a/packages/web/src/components/SiteTitle.astro +++ b/packages/web/src/components/SiteTitle.astro @@ -4,7 +4,7 @@ import config from 'virtual:starlight/user-config'; const { siteTitle, siteTitleHref } = Astro.locals.starlightRoute; --- - + { config.logo && logos.dark && ( <> diff --git a/packages/web/src/components/share/common.tsx b/packages/web/src/components/share/common.tsx index cab2dbdb0c..7ca4daa6ac 100644 --- a/packages/web/src/components/share/common.tsx +++ b/packages/web/src/components/share/common.tsx @@ -1,16 +1,55 @@ -import { createSignal, onCleanup, splitProps } from "solid-js" +import { createContext, createSignal, onCleanup, splitProps, useContext } from "solid-js" import type { JSX } from "solid-js/jsx-runtime" import { IconCheckCircle, IconHashtag } from "../icons" +export type ShareMessages = { locale: string } & Record + +const shareContext = createContext() + +export function ShareI18nProvider(props: { messages: ShareMessages; children: JSX.Element }) { + return {props.children} +} + +export function useShareMessages() { + const value = useContext(shareContext) + if (value) { + return value + } + throw new Error("ShareI18nProvider is required") +} + +export function normalizeLocale(locale: string) { + return locale === "root" ? "en" : locale +} + +export function formatNumber(value: number, locale: string) { + return new Intl.NumberFormat(normalizeLocale(locale)).format(value) +} + +export function formatCurrency(value: number, locale: string) { + return new Intl.NumberFormat(normalizeLocale(locale), { + style: "currency", + currency: "USD", + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(value) +} + +export function formatCount(value: number, locale: string, singular: string, plural: string) { + const unit = value === 1 ? singular : plural + return `${formatNumber(value, locale)} ${unit}` +} + interface AnchorProps extends JSX.HTMLAttributes { id: string } export function AnchorIcon(props: AnchorProps) { const [local, rest] = splitProps(props, ["id", "children"]) const [copied, setCopied] = createSignal(false) + const messages = useShareMessages() return ( -
+ ) } @@ -59,19 +98,33 @@ export function createOverflow() { } } -export function formatDuration(ms: number): string { +export function formatDuration(ms: number, locale: string): string { + const normalized = normalizeLocale(locale) const ONE_SECOND = 1000 const ONE_MINUTE = 60 * ONE_SECOND if (ms >= ONE_MINUTE) { - const minutes = Math.floor(ms / ONE_MINUTE) - return minutes === 1 ? `1min` : `${minutes}mins` + return new Intl.NumberFormat(normalized, { + style: "unit", + unit: "minute", + unitDisplay: "narrow", + maximumFractionDigits: 0, + }).format(Math.floor(ms / ONE_MINUTE)) } if (ms >= ONE_SECOND) { - const seconds = Math.floor(ms / ONE_SECOND) - return `${seconds}s` + return new Intl.NumberFormat(normalized, { + style: "unit", + unit: "second", + unitDisplay: "narrow", + maximumFractionDigits: 0, + }).format(Math.floor(ms / ONE_SECOND)) } - return `${ms}ms` + return new Intl.NumberFormat(normalized, { + style: "unit", + unit: "millisecond", + unitDisplay: "narrow", + maximumFractionDigits: 0, + }).format(ms) } diff --git a/packages/web/src/components/share/content-bash.tsx b/packages/web/src/components/share/content-bash.tsx index 5ccd95c0b8..f8130ecc64 100644 --- a/packages/web/src/components/share/content-bash.tsx +++ b/packages/web/src/components/share/content-bash.tsx @@ -1,6 +1,6 @@ import style from "./content-bash.module.css" import { createResource, createSignal } from "solid-js" -import { createOverflow } from "./common" +import { createOverflow, useShareMessages } from "./common" import { codeToHtml } from "shiki" interface Props { @@ -11,6 +11,7 @@ interface Props { } export function ContentBash(props: Props) { + const messages = useShareMessages() const [commandHtml] = createResource( () => props.command, async (command) => { @@ -59,7 +60,7 @@ export function ContentBash(props: Props) { data-slot="expand-button" onClick={() => setExpanded((e) => !e)} > - {expanded() ? "Show less" : "Show more"} + {expanded() ? messages.show_less : messages.show_more} )}
diff --git a/packages/web/src/components/share/content-code.tsx b/packages/web/src/components/share/content-code.tsx index 2f383b8be1..2978286020 100644 --- a/packages/web/src/components/share/content-code.tsx +++ b/packages/web/src/components/share/content-code.tsx @@ -1,6 +1,5 @@ import { codeToHtml, bundledLanguages } from "shiki" import { createResource, Suspense } from "solid-js" -import { transformerNotationDiff } from "@shikijs/transformers" import style from "./content-code.module.css" interface Props { @@ -20,7 +19,6 @@ export function ContentCode(props: Props) { light: "github-light", dark: "github-dark", }, - transformers: [transformerNotationDiff()], })) as string }, ) diff --git a/packages/web/src/components/share/content-error.tsx b/packages/web/src/components/share/content-error.tsx index 1e8cbeaadd..0f85889976 100644 --- a/packages/web/src/components/share/content-error.tsx +++ b/packages/web/src/components/share/content-error.tsx @@ -1,6 +1,6 @@ import style from "./content-error.module.css" import { type JSX, createSignal } from "solid-js" -import { createOverflow } from "./common" +import { createOverflow, useShareMessages } from "./common" interface Props extends JSX.HTMLAttributes { expand?: boolean @@ -8,6 +8,7 @@ interface Props extends JSX.HTMLAttributes { export function ContentError(props: Props) { const [expanded, setExpanded] = createSignal(false) const overflow = createOverflow() + const messages = useShareMessages() return (
@@ -16,7 +17,7 @@ export function ContentError(props: Props) {
{((!props.expand && overflow.status) || expanded()) && ( )}
diff --git a/packages/web/src/components/share/content-markdown.tsx b/packages/web/src/components/share/content-markdown.tsx index b9b1d5dcb1..10a06bf5ed 100644 --- a/packages/web/src/components/share/content-markdown.tsx +++ b/packages/web/src/components/share/content-markdown.tsx @@ -1,10 +1,9 @@ import { marked } from "marked" import { codeToHtml } from "shiki" import markedShiki from "marked-shiki" -import { createOverflow } from "./common" +import { createOverflow, useShareMessages } from "./common" import { CopyButton } from "./copy-button" import { createResource, createSignal } from "solid-js" -import { transformerNotationDiff } from "@shikijs/transformers" import style from "./content-markdown.module.css" const markedWithShiki = marked.use( @@ -24,7 +23,6 @@ const markedWithShiki = marked.use( light: "github-light", dark: "github-dark", }, - transformers: [transformerNotationDiff()], }) }, }), @@ -44,6 +42,7 @@ export function ContentMarkdown(props: Props) { ) const [expanded, setExpanded] = createSignal(false) const overflow = createOverflow() + const messages = useShareMessages() return (
setExpanded((e) => !e)} > - {expanded() ? "Show less" : "Show more"} + {expanded() ? messages.show_less : messages.show_more} )} diff --git a/packages/web/src/components/share/content-text.tsx b/packages/web/src/components/share/content-text.tsx index 5db12a537b..b549b74a4c 100644 --- a/packages/web/src/components/share/content-text.tsx +++ b/packages/web/src/components/share/content-text.tsx @@ -1,6 +1,6 @@ import style from "./content-text.module.css" import { createSignal } from "solid-js" -import { createOverflow } from "./common" +import { createOverflow, useShareMessages } from "./common" import { CopyButton } from "./copy-button" interface Props { @@ -11,6 +11,7 @@ interface Props { export function ContentText(props: Props) { const [expanded, setExpanded] = createSignal(false) const overflow = createOverflow() + const messages = useShareMessages() return (
setExpanded((e) => !e)} > - {expanded() ? "Show less" : "Show more"} + {expanded() ? messages.show_less : messages.show_more} )} diff --git a/packages/web/src/components/share/copy-button.tsx b/packages/web/src/components/share/copy-button.tsx index 892d5553f4..6c5097a997 100644 --- a/packages/web/src/components/share/copy-button.tsx +++ b/packages/web/src/components/share/copy-button.tsx @@ -1,5 +1,6 @@ import { createSignal } from "solid-js" import { IconClipboard, IconCheckCircle } from "../icons" +import { useShareMessages } from "./common" import styles from "./copy-button.module.css" interface CopyButtonProps { @@ -8,6 +9,7 @@ interface CopyButtonProps { export function CopyButton(props: CopyButtonProps) { const [copied, setCopied] = createSignal(false) + const messages = useShareMessages() function handleCopyClick() { if (props.text) { @@ -20,7 +22,13 @@ export function CopyButton(props: CopyButtonProps) { return (
-
diff --git a/packages/web/src/components/share/part.tsx b/packages/web/src/components/share/part.tsx index f7a6a9304d..45bd97fe33 100644 --- a/packages/web/src/components/share/part.tsx +++ b/packages/web/src/components/share/part.tsx @@ -25,7 +25,7 @@ import { ContentDiff } from "./content-diff" import { ContentText } from "./content-text" import { ContentBash } from "./content-bash" import { ContentError } from "./content-error" -import { formatDuration } from "../share/common" +import { formatCount, formatDuration, formatNumber, normalizeLocale, useShareMessages } from "../share/common" import { ContentMarkdown } from "./content-markdown" import type { MessageV2 } from "opencode/session/message-v2" import type { Diagnostic } from "vscode-languageserver-types" @@ -44,6 +44,7 @@ export interface PartProps { export function Part(props: PartProps) { const [copied, setCopied] = createSignal(false) const id = createMemo(() => props.message.id + "-" + props.index) + const messages = useShareMessages() return (
- @@ -143,11 +144,13 @@ export function Part(props: PartProps) {
{props.last && props.message.role === "assistant" && props.message.time.completed && (
- {DateTime.fromMillis(props.message.time.completed).toLocaleString(DateTime.DATETIME_MED)} + {DateTime.fromMillis(props.message.time.completed) + .setLocale(normalizeLocale(messages.locale)) + .toLocaleString(DateTime.DATETIME_MED)}
)}
@@ -155,13 +158,13 @@ export function Part(props: PartProps) { {props.message.role === "assistant" && props.part.type === "reasoning" && (
- Thinking + {messages.thinking}
- +
- +
@@ -170,13 +173,7 @@ export function Part(props: PartProps) { )} {props.message.role === "user" && props.part.type === "file" && (
-
Attachment
-
{props.part.filename}
-
- )} - {props.message.role === "user" && props.part.type === "file" && ( -
-
Attachment
+
{messages.attachment}
{props.part.filename}
)} @@ -188,7 +185,7 @@ export function Part(props: PartProps) { )} {props.part.type === "tool" && props.part.state.status === "error" && (
- {formatErrorString(props.part.state.error)} + {formatErrorString(props.part.state.error, messages.error)}
)} @@ -343,43 +340,45 @@ function getShikiLang(filename: string) { return type ? (overrides[type] ?? type) : "plaintext" } -function getDiagnostics(diagnosticsByFile: Record, currentFile: string): JSX.Element[] { +function getDiagnostics( + diagnosticsByFile: Record, + currentFile: string, + label: string, +): JSX.Element[] { const result: JSX.Element[] = [] if (diagnosticsByFile === undefined || diagnosticsByFile[currentFile] === undefined) return result - for (const diags of Object.values(diagnosticsByFile)) { - for (const d of diags) { - if (d.severity !== 1) continue + for (const d of diagnosticsByFile[currentFile]) { + if (d.severity !== 1) continue - const line = d.range.start.line + 1 - const column = d.range.start.character + 1 + const line = d.range.start.line + 1 + const column = d.range.start.character + 1 - result.push( -
-          
-            Error
-          
-          
-            [{line}:{column}]
-          
-          {d.message}
-        
, - ) - } + result.push( +
+        
+          {label}
+        
+        
+          [{line}:{column}]
+        
+        {d.message}
+      
, + ) } return result } -function formatErrorString(error: string): JSX.Element { +function formatErrorString(error: string, label: string): JSX.Element { const errorMarker = "Error: " const startsWithError = error.startsWith(errorMarker) return startsWithError ? (
       
-        Error
+        {label}
       
       {error.slice(errorMarker.length)}
     
@@ -391,6 +390,7 @@ function formatErrorString(error: string): JSX.Element { } export function TodoWriteTool(props: ToolProps) { + const messages = useShareMessages() const priority: Record = { in_progress: 0, pending: 1, @@ -406,9 +406,9 @@ export function TodoWriteTool(props: ToolProps) { <>
- - Creating plan - Completing plan + + {messages.creating_plan} + {messages.completing_plan}
@@ -429,6 +429,8 @@ export function TodoWriteTool(props: ToolProps) { } export function GrepTool(props: ToolProps) { + const messages = useShareMessages() + return ( <>
@@ -439,7 +441,12 @@ export function GrepTool(props: ToolProps) { 0}> @@ -482,6 +489,8 @@ export function ListTool(props: ToolProps) { } export function WebFetchTool(props: ToolProps) { + const messages = useShareMessages() + return ( <>
@@ -491,7 +500,7 @@ export function WebFetchTool(props: ToolProps) {
- {formatErrorString(props.state.output)} + {formatErrorString(props.state.output, messages.error)} @@ -505,6 +514,7 @@ export function WebFetchTool(props: ToolProps) { } export function ReadTool(props: ToolProps) { + const messages = useShareMessages() const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd)) return ( @@ -518,10 +528,10 @@ export function ReadTool(props: ToolProps) {
- {formatErrorString(props.state.output)} + {formatErrorString(props.state.output, messages.error)} - + @@ -537,8 +547,11 @@ export function ReadTool(props: ToolProps) { } export function WriteTool(props: ToolProps) { + const messages = useShareMessages() const filePath = createMemo(() => stripWorkingDirectory(props.state.input?.filePath, props.message.path.cwd)) - const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath)) + const diagnostics = createMemo(() => + getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath, messages.error), + ) return ( <> @@ -554,10 +567,10 @@ export function WriteTool(props: ToolProps) {
- {formatErrorString(props.state.output)} + {formatErrorString(props.state.output, messages.error)} - + @@ -568,8 +581,11 @@ export function WriteTool(props: ToolProps) { } export function EditTool(props: ToolProps) { + const messages = useShareMessages() const filePath = createMemo(() => stripWorkingDirectory(props.state.input.filePath, props.message.path.cwd)) - const diagnostics = createMemo(() => getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath)) + const diagnostics = createMemo(() => + getDiagnostics(props.state.metadata?.diagnostics, props.state.input.filePath, messages.error), + ) return ( <> @@ -582,7 +598,7 @@ export function EditTool(props: ToolProps) {
- {formatErrorString(props.state.metadata?.message || "")} + {formatErrorString(props.state.metadata?.message || "", messages.error)}
@@ -609,6 +625,8 @@ export function BashTool(props: ToolProps) { } export function GlobTool(props: ToolProps) { + const messages = useShareMessages() + return ( <>
@@ -619,7 +637,12 @@ export function GlobTool(props: ToolProps) { 0}>
@@ -639,11 +662,12 @@ interface ResultsButtonProps extends ParentProps { } function ResultsButton(props: ResultsButtonProps) { const [show, setShow] = createSignal(false) + const messages = useShareMessages() return ( <>