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..ffa6ed7cd3 --- /dev/null +++ b/.github/VOUCHED.td @@ -0,0 +1,18 @@ +# 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 +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 fcb2f8f0cf..1bfb04df22 100644 --- a/bun.lock +++ b/bun.lock @@ -190,6 +190,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-clipboard-manager": "~2", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-http": "~2", @@ -469,7 +470,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", @@ -484,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:", @@ -616,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=="], @@ -638,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=="], @@ -848,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=="], @@ -1784,6 +1807,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], + "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], + "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.6", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA=="], "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="], @@ -1958,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=="], @@ -1986,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=="], @@ -2388,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=="], @@ -3166,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=="], @@ -3318,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=="], @@ -3518,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=="], @@ -3860,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=="], @@ -3958,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=="], @@ -4022,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=="], @@ -4094,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=="], @@ -4288,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=="], @@ -4362,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=="], @@ -4588,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=="], @@ -4598,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=="], @@ -4618,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=="], @@ -4862,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=="], @@ -5108,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=="], @@ -5248,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/flake.nix b/flake.nix index ea78b1a434..40e9d337f5 100644 --- a/flake.nix +++ b/flake.nix @@ -30,6 +30,26 @@ }; }); + overlays = { + default = + final: _prev: + let + node_modules = final.callPackage ./nix/node_modules.nix { + inherit rev; + }; + opencode = final.callPackage ./nix/opencode.nix { + inherit node_modules; + }; + desktop = final.callPackage ./nix/desktop.nix { + inherit opencode; + }; + in + { + inherit opencode; + opencode-desktop = desktop; + }; + }; + packages = forEachSystem ( pkgs: let 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 ba1ff15bf2..5abffb555a 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -135,6 +135,16 @@ const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS8"), new sst.Secret("ZEN_MODELS9"), new sst.Secret("ZEN_MODELS10"), + new sst.Secret("ZEN_MODELS11"), + new sst.Secret("ZEN_MODELS12"), + new sst.Secret("ZEN_MODELS13"), + new sst.Secret("ZEN_MODELS14"), + new sst.Secret("ZEN_MODELS15"), + new sst.Secret("ZEN_MODELS16"), + new sst.Secret("ZEN_MODELS17"), + new sst.Secret("ZEN_MODELS18"), + new sst.Secret("ZEN_MODELS19"), + new sst.Secret("ZEN_MODELS20"), ] const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") diff --git a/nix/hashes.json b/nix/hashes.json index eb1578dcde..d334ce6668 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-UBz5qXhO+Xy6XptVdbo9V0wKsvZgItmHkWDm6I5VRCk=", - "aarch64-linux": "sha256-G2ezu/ThZR3kYfHnbD0EOcLoAa6hwtICpmo9r+bqibE=", - "aarch64-darwin": "sha256-PhSE23OzNlyfNFP5LffA3AtyN+hsyCeGInmDBBRjr0g=", - "x86_64-darwin": "sha256-vWusYJD+7ClDLUFy1wEqRLf9hY8V43iqdqnZ6YWkh1Q=" + "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/nix/opencode.nix b/nix/opencode.nix index 23d9fbe34e..b7d6f95947 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -34,6 +34,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { ''; env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json"; + env.OPENCODE_DISABLE_MODELS_FETCH = true; env.OPENCODE_VERSION = finalAttrs.version; env.OPENCODE_CHANNEL = "local"; @@ -79,7 +80,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { writableTmpDirAsHomeHook ]; doInstallCheck = true; - versionCheckKeepEnvironment = [ "HOME" ]; + versionCheckKeepEnvironment = [ "HOME" "OPENCODE_DISABLE_MODELS_FETCH" ]; versionCheckProgramArg = "--version"; passthru = { diff --git a/nix/scripts/canonicalize-node-modules.ts b/nix/scripts/canonicalize-node-modules.ts index faa6f63402..7997a3cd23 100644 --- a/nix/scripts/canonicalize-node-modules.ts +++ b/nix/scripts/canonicalize-node-modules.ts @@ -1,27 +1,32 @@ import { lstat, mkdir, readdir, rm, symlink } from "fs/promises" import { join, relative } from "path" -type SemverLike = { - valid: (value: string) => string | null - rcompare: (left: string, right: string) => number -} - type Entry = { dir: string version: string - label: string } +async function isDirectory(path: string) { + try { + const info = await lstat(path) + return info.isDirectory() + } catch { + return false + } +} + +const isValidSemver = (v: string) => Bun.semver.satisfies(v, "x.x.x") + const root = process.cwd() const bunRoot = join(root, "node_modules/.bun") const linkRoot = join(bunRoot, "node_modules") const directories = (await readdir(bunRoot)).sort() + const versions = new Map() for (const entry of directories) { const full = join(bunRoot, entry) - const info = await lstat(full) - if (!info.isDirectory()) { + if (!(await isDirectory(full))) { continue } const parsed = parseEntry(entry) @@ -29,37 +34,23 @@ for (const entry of directories) { continue } const list = versions.get(parsed.name) ?? [] - list.push({ dir: full, version: parsed.version, label: entry }) + list.push({ dir: full, version: parsed.version }) versions.set(parsed.name, list) } -const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as - | SemverLike - | { - default: SemverLike - } -const semver = "default" in semverModule ? semverModule.default : semverModule const selections = new Map() for (const [slug, list] of versions) { list.sort((a, b) => { - const left = semver.valid(a.version) - const right = semver.valid(b.version) - if (left && right) { - const delta = semver.rcompare(left, right) - if (delta !== 0) { - return delta - } - } - if (left && !right) { - return -1 - } - if (!left && right) { - return 1 - } + const aValid = isValidSemver(a.version) + const bValid = isValidSemver(b.version) + if (aValid && bValid) return -Bun.semver.order(a.version, b.version) + if (aValid) return -1 + if (bValid) return 1 return b.version.localeCompare(a.version) }) - selections.set(slug, list[0]) + const first = list[0] + if (first) selections.set(slug, first) } await rm(linkRoot, { recursive: true, force: true }) @@ -77,10 +68,7 @@ for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0] await mkdir(parent, { recursive: true }) const linkPath = join(parent, leaf) const desired = join(entry.dir, "node_modules", slug) - const exists = await lstat(desired) - .then((info) => info.isDirectory()) - .catch(() => false) - if (!exists) { + if (!(await isDirectory(desired))) { continue } const relativeTarget = relative(parent, desired) diff --git a/nix/scripts/normalize-bun-binaries.ts b/nix/scripts/normalize-bun-binaries.ts index 531d8fd056..978ab325b7 100644 --- a/nix/scripts/normalize-bun-binaries.ts +++ b/nix/scripts/normalize-bun-binaries.ts @@ -8,7 +8,7 @@ type PackageManifest = { const root = process.cwd() const bunRoot = join(root, "node_modules/.bun") -const bunEntries = (await safeReadDir(bunRoot)).sort() +const bunEntries = (await readdir(bunRoot)).sort() let rewritten = 0 for (const entry of bunEntries) { @@ -45,11 +45,11 @@ for (const entry of bunEntries) { } } -console.log(`[normalize-bun-binaries] rewrote ${rewritten} links`) +console.log(`[normalize-bun-binaries] rebuilt ${rewritten} links`) async function collectPackages(modulesRoot: string) { const found: string[] = [] - const topLevel = (await safeReadDir(modulesRoot)).sort() + const topLevel = (await readdir(modulesRoot)).sort() for (const name of topLevel) { if (name === ".bin" || name === ".bun") { continue @@ -59,7 +59,7 @@ async function collectPackages(modulesRoot: string) { continue } if (name.startsWith("@")) { - const scoped = (await safeReadDir(full)).sort() + const scoped = (await readdir(full)).sort() for (const child of scoped) { const scopedDir = join(full, child) if (await isDirectory(scopedDir)) { @@ -121,14 +121,6 @@ async function isDirectory(path: string) { } } -async function safeReadDir(path: string) { - try { - return await readdir(path) - } catch { - return [] - } -} - function normalizeBinName(name: string) { const slash = name.lastIndexOf("/") if (slash >= 0) { 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/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts index eceb82b741..a8e7f33526 100644 --- a/packages/app/e2e/settings/settings-keybinds.spec.ts +++ b/packages/app/e2e/settings/settings-keybinds.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures" import { openSettings, closeDialog, withSession } from "../actions" -import { keybindButtonSelector } from "../selectors" +import { keybindButtonSelector, terminalSelector } from "../selectors" import { modKey } from "../utils" test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => { @@ -267,11 +267,14 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) => await closeDialog(page, dialog) - await page.keyboard.press(`${modKey}+Y`) - await page.waitForTimeout(100) + const terminal = page.locator(terminalSelector) + await expect(terminal).not.toBeVisible() - const pageStable = await page.evaluate(() => document.readyState === "complete") - expect(pageStable).toBe(true) + await page.keyboard.press(`${modKey}+Y`) + await expect(terminal).toBeVisible() + + await page.keyboard.press(`${modKey}+Y`) + await expect(terminal).not.toBeVisible() }) test("changing command palette keybind works", async ({ page, gotoSession }) => { diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 10819e69ff..ea85829e0b 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ expect: { timeout: 10_000, }, - fullyParallel: true, + fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1", forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts index df2107f76d..112e2bc60a 100644 --- a/packages/app/script/e2e-local.ts +++ b/packages/app/script/e2e-local.ts @@ -55,6 +55,7 @@ const extraArgs = (() => { const [serverPort, webPort] = await Promise.all([freePort(), freePort()]) const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-")) +const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1" const serverEnv = { ...process.env, @@ -83,58 +84,95 @@ const runnerEnv = { PLAYWRIGHT_PORT: String(webPort), } satisfies Record -const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], { - cwd: opencodeDir, - env: serverEnv, - stdout: "inherit", - stderr: "inherit", -}) +let seed: ReturnType | undefined +let runner: ReturnType | undefined +let server: { stop: () => Promise | void } | undefined +let inst: { Instance: { disposeAll: () => Promise | void } } | undefined +let cleaned = false -const seedExit = await seed.exited -if (seedExit !== 0) { - process.exit(seedExit) +const cleanup = async () => { + if (cleaned) return + cleaned = true + + if (seed && seed.exitCode === null) seed.kill("SIGTERM") + if (runner && runner.exitCode === null) runner.kill("SIGTERM") + + const jobs = [ + inst?.Instance.disposeAll(), + server?.stop(), + keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }), + ].filter(Boolean) + await Promise.allSettled(jobs) } -Object.assign(process.env, serverEnv) -process.env.AGENT = "1" -process.env.OPENCODE = "1" +const shutdown = (code: number, reason: string) => { + process.exitCode = code + void cleanup().finally(() => { + console.error(`e2e-local shutdown: ${reason}`) + process.exit(code) + }) +} -const log = await import("../../opencode/src/util/log") -const install = await import("../../opencode/src/installation") -await log.Log.init({ - print: true, - dev: install.Installation.isLocal(), - level: "WARN", +const reportInternalError = (reason: string, error: unknown) => { + console.warn(`e2e-local ignored server error: ${reason}`) + console.warn(error) +} + +process.once("SIGINT", () => shutdown(130, "SIGINT")) +process.once("SIGTERM", () => shutdown(143, "SIGTERM")) +process.once("SIGHUP", () => shutdown(129, "SIGHUP")) +process.once("uncaughtException", (error) => { + reportInternalError("uncaughtException", error) +}) +process.once("unhandledRejection", (error) => { + reportInternalError("unhandledRejection", error) }) -const servermod = await import("../../opencode/src/server/server") -const inst = await import("../../opencode/src/project/instance") -const server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" }) -console.log(`opencode server listening on http://127.0.0.1:${serverPort}`) +let code = 1 + +try { + seed = Bun.spawn(["bun", "script/seed-e2e.ts"], { + cwd: opencodeDir, + env: serverEnv, + stdout: "inherit", + stderr: "inherit", + }) + + const seedExit = await seed.exited + if (seedExit !== 0) { + code = seedExit + } else { + Object.assign(process.env, serverEnv) + process.env.AGENT = "1" + process.env.OPENCODE = "1" + + const log = await import("../../opencode/src/util/log") + const install = await import("../../opencode/src/installation") + await log.Log.init({ + print: true, + dev: install.Installation.isLocal(), + level: "WARN", + }) + + const servermod = await import("../../opencode/src/server/server") + inst = await import("../../opencode/src/project/instance") + server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" }) + console.log(`opencode server listening on http://127.0.0.1:${serverPort}`) -const result = await (async () => { - try { await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`) - - const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], { + runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], { cwd: appDir, env: runnerEnv, stdout: "inherit", stderr: "inherit", }) - - return { code: await runner.exited } - } catch (error) { - return { error } - } finally { - await inst.Instance.disposeAll() - await server.stop() + code = await runner.exited } -})() - -if ("error" in result) { - console.error(result.error) - process.exit(1) +} catch (error) { + console.error(error) + code = 1 +} finally { + await cleanup() } -process.exit(result.code) +process.exit(code) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 8a111472ba..5bbe86e209 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) { ) } -export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element }) { +export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { const platform = usePlatform() const stored = (() => { @@ -106,7 +106,7 @@ export function AppInterface(props: { defaultUrl?: string; children?: JSX.Elemen } return ( - + 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 2bccddc291..4f495d27d1 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -35,6 +35,7 @@ import { Persist, persisted } from "@/utils/persist" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" @@ -97,6 +98,7 @@ export const PromptInput: Component = (props) => { const command = useCommand() const permission = usePermission() const language = useLanguage() + const platform = usePlatform() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement let scrollRef!: HTMLDivElement @@ -205,7 +207,7 @@ export const PromptInput: Component = (props) => { historyIndex: number savedPrompt: Prompt | null placeholder: number - dragging: boolean + draggingType: "image" | "@mention" | null mode: "normal" | "shell" applyingHistory: boolean }>({ @@ -213,7 +215,7 @@ export const PromptInput: Component = (props) => { historyIndex: -1, savedPrompt: null, placeholder: Math.floor(Math.random() * EXAMPLES.length), - dragging: false, + draggingType: null, mode: "normal", applyingHistory: false, }) @@ -413,7 +415,7 @@ export const PromptInput: Component = (props) => { } = useFilteredList({ items: slashCommands, key: (x) => x?.id, - filterKeys: ["trigger", "title", "description"], + filterKeys: ["trigger", "title"], onSelect: handleSlashSelect, }) @@ -760,8 +762,13 @@ export const PromptInput: Component = (props) => { editor: () => editorRef, isFocused, isDialogActive: () => !!dialog.active, - setDragging: (value) => setStore("dragging", value), + setDraggingType: (type) => setStore("draggingType", type), + focusEditor: () => { + editorRef.focus() + setCursorPosition(editorRef, promptLength(prompt.current())) + }, addPart, + readClipboardImage: platform.readClipboardImage, }) const { abort, handleSubmit } = createPromptSubmit({ @@ -780,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, }) @@ -946,11 +953,14 @@ export const PromptInput: Component = (props) => { "group/prompt-input": true, "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true, "rounded-[14px] overflow-clip focus-within:shadow-xs-border": true, - "border-icon-info-active border-dashed": store.dragging, + "border-icon-info-active border-dashed": store.draggingType !== null, [props.class ?? ""]: !!props.class, }} > - + { @@ -983,6 +993,9 @@ export const PromptInput: Component = (props) => { aria-multiline="true" aria-label={placeholder()} contenteditable="true" + autocapitalize="off" + autocorrect="off" + spellcheck={false} onInput={handleInput} onPaste={handlePaste} onCompositionStart={() => setComposing(true)} diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 4ea2cfb90f..0f778b5181 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -11,8 +11,10 @@ type PromptAttachmentsInput = { editor: () => HTMLDivElement | undefined isFocused: () => boolean isDialogActive: () => boolean - setDragging: (value: boolean) => void + setDraggingType: (type: "image" | "@mention" | null) => void + focusEditor: () => void addPart: (part: ContentPart) => void + readClipboardImage?: () => Promise } export function createPromptAttachments(input: PromptAttachmentsInput) { @@ -29,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, @@ -75,6 +77,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { } const plainText = clipboardData.getData("text/plain") ?? "" + + // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images + if (input.readClipboardImage && !plainText) { + const file = await input.readClipboardImage() + if (file) { + await addImageAttachment(file) + return + } + } + if (!plainText) return input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) } @@ -84,15 +96,18 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { event.preventDefault() const hasFiles = event.dataTransfer?.types.includes("Files") + const hasText = event.dataTransfer?.types.includes("text/plain") if (hasFiles) { - input.setDragging(true) + input.setDraggingType("image") + } else if (hasText) { + input.setDraggingType("@mention") } } const handleGlobalDragLeave = (event: DragEvent) => { if (input.isDialogActive()) return if (!event.relatedTarget) { - input.setDragging(false) + input.setDraggingType(null) } } @@ -100,7 +115,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { if (input.isDialogActive()) return event.preventDefault() - input.setDragging(false) + input.setDraggingType(null) + + const plainText = event.dataTransfer?.getData("text/plain") + const filePrefix = "file:" + if (plainText?.startsWith(filePrefix)) { + const filePath = plainText.slice(filePrefix.length) + input.focusEditor() + input.addPart({ type: "file", path: filePath, content: "@" + filePath, start: 0, end: 0 }) + return + } const dropped = event.dataTransfer?.files if (!dropped) return 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 b284c38841..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 @@ -64,4 +64,214 @@ describe("buildRequestParts", () => { expect(fooFiles).toHaveLength(2) expect(synthetic).toHaveLength(1) }) + + test("handles Windows paths correctly (simulated on macOS)", () => { + const prompt: Prompt = [{ type: "file", path: "src\\foo.ts", content: "@src\\foo.ts", start: 0, end: 11 }] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@src\\foo.ts", + messageID: "msg_win_1", + sessionID: "ses_win_1", + sessionDirectory: "D:\\projects\\myapp", // Windows path + }) + + // Should create valid file URLs + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // URL should be parseable + expect(() => new URL(filePart.url)).not.toThrow() + // Should not have encoded backslashes in wrong place + expect(filePart.url).not.toContain("%5C") + // Should have normalized to forward slashes + expect(filePart.url).toContain("/src/foo.ts") + } + }) + + test("handles Windows absolute path with special characters", () => { + const prompt: Prompt = [{ type: "file", path: "file#name.txt", content: "@file#name.txt", start: 0, end: 14 }] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@file#name.txt", + messageID: "msg_win_2", + sessionID: "ses_win_2", + sessionDirectory: "C:\\Users\\test\\Documents", // Windows path + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // URL should be parseable + expect(() => new URL(filePart.url)).not.toThrow() + // 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]:/) + } + }) + + test("handles Linux absolute paths correctly", () => { + const prompt: Prompt = [{ type: "file", path: "src/app.ts", content: "@src/app.ts", start: 0, end: 10 }] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@src/app.ts", + messageID: "msg_linux_1", + sessionID: "ses_linux_1", + sessionDirectory: "/home/user/project", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // URL should be parseable + expect(() => new URL(filePart.url)).not.toThrow() + // Should be a normal Unix path + expect(filePart.url).toBe("file:///home/user/project/src/app.ts") + } + }) + + test("handles macOS paths correctly", () => { + const prompt: Prompt = [{ type: "file", path: "README.md", content: "@README.md", start: 0, end: 9 }] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@README.md", + messageID: "msg_mac_1", + sessionID: "ses_mac_1", + sessionDirectory: "/Users/kelvin/Projects/opencode", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // URL should be parseable + expect(() => new URL(filePart.url)).not.toThrow() + // Should be a normal Unix path + expect(filePart.url).toBe("file:///Users/kelvin/Projects/opencode/README.md") + } + }) + + test("handles context files with Windows paths", () => { + const prompt: Prompt = [] + + const result = buildRequestParts({ + prompt, + context: [ + { key: "ctx:1", type: "file", path: "src\\utils\\helper.ts" }, + { key: "ctx:2", type: "file", path: "test\\unit.test.ts", comment: "check tests" }, + ], + images: [], + text: "test", + messageID: "msg_win_ctx", + sessionID: "ses_win_ctx", + sessionDirectory: "D:\\workspace\\app", + }) + + const fileParts = result.requestParts.filter((part) => part.type === "file") + expect(fileParts).toHaveLength(2) + + // All file URLs should be valid + fileParts.forEach((part) => { + if (part.type === "file") { + expect(() => new URL(part.url)).not.toThrow() + expect(part.url).not.toContain("%5C") // No encoded backslashes + } + }) + }) + + test("handles absolute Windows paths (user manually specifies full path)", () => { + const prompt: Prompt = [ + { type: "file", path: "D:\\other\\project\\file.ts", content: "@D:\\other\\project\\file.ts", start: 0, end: 25 }, + ] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@D:\\other\\project\\file.ts", + messageID: "msg_abs", + sessionID: "ses_abs", + sessionDirectory: "C:\\current\\project", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // Should handle absolute path that differs from sessionDirectory + expect(() => new URL(filePart.url)).not.toThrow() + expect(filePart.url).toContain("/D:/other/project/file.ts") + } + }) + + test("handles selection with query parameters on Windows", () => { + const prompt: Prompt = [ + { + type: "file", + path: "src\\App.tsx", + content: "@src\\App.tsx", + start: 0, + end: 11, + selection: { startLine: 10, startChar: 0, endLine: 20, endChar: 5 }, + }, + ] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@src\\App.tsx", + messageID: "msg_sel", + sessionID: "ses_sel", + sessionDirectory: "C:\\project", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // Should have query parameters + expect(filePart.url).toContain("?start=10&end=20") + // Should be valid URL + expect(() => new URL(filePart.url)).not.toThrow() + // Query params should parse correctly + const url = new URL(filePart.url) + expect(url.searchParams.get("start")).toBe("10") + expect(url.searchParams.get("end")).toBe("20") + } + }) + + test("handles file paths with dots and special segments on Windows", () => { + const prompt: Prompt = [ + { type: "file", path: "..\\..\\shared\\util.ts", content: "@..\\..\\shared\\util.ts", start: 0, end: 21 }, + ] + + const result = buildRequestParts({ + prompt, + context: [], + images: [], + text: "@..\\..\\shared\\util.ts", + messageID: "msg_dots", + sessionID: "ses_dots", + sessionDirectory: "C:\\projects\\myapp\\src", + }) + + const filePart = result.requestParts.find((part) => part.type === "file") + expect(filePart).toBeDefined() + if (filePart?.type === "file") { + // Should be valid URL + expect(() => new URL(filePart.url)).not.toThrow() + // Should preserve .. segments (backend normalizes) + expect(filePart.url).toContain("/..") + } + }) }) 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 7010a1fd84..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,14 +28,12 @@ type BuildRequestPartsInput = { sessionDirectory: string } -const absolute = (directory: string, path: string) => - path.startsWith("/") ? path : (directory + "/" + path).replace("//", "/") - -const encodeFilePath = (filepath: string): string => - filepath - .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) => selection ? `?start=${selection.startLine}&end=${selection.endLine}` : "" diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx index f5a4d399ef..e05b47d7cf 100644 --- a/packages/app/src/components/prompt-input/drag-overlay.tsx +++ b/packages/app/src/components/prompt-input/drag-overlay.tsx @@ -2,16 +2,16 @@ import { Component, Show } from "solid-js" import { Icon } from "@opencode-ai/ui/icon" type PromptDragOverlayProps = { - dragging: boolean + type: "image" | "@mention" | null label: string } export const PromptDragOverlay: Component = (props) => { return ( - +

- + {props.label}
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 5ed5eedada..49d75a95ec 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -37,7 +37,7 @@ type PromptSubmitInput = { resetHistoryNavigation: () => void setMode: (mode: "normal" | "shell") => void setPopover: (popover: "at" | "slash" | null) => void - newSessionWorktree?: string + newSessionWorktree?: Accessor onNewSessionWorktreeReset?: () => void onSubmit?: () => void } @@ -137,7 +137,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const projectDirectory = sdk.directory const isNewSession = !params.id - const worktreeSelection = input.newSessionWorktree ?? "main" + const worktreeSelection = input.newSessionWorktree?.() || "main" let sessionDirectory = projectDirectory let client = sdk.client @@ -200,7 +200,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } } - if (!session) return + if (!session) { + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: language.t("prompt.toast.promptSendFailed.description"), + }) + return + } input.onSubmit?.() diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 68903a455b..e90df9a948 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -79,15 +79,16 @@ describe("getSessionContextMetrics", () => { expect(metrics.context?.usage).toBeNull() }) - test("memoizes by message and provider array identity", () => { + test("recomputes when message array is mutated in place", () => { const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] const providers = [{ id: "openai", models: {} }] const one = getSessionContextMetrics(messages, providers) + messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75)) const two = getSessionContextMetrics(messages, providers) - const three = getSessionContextMetrics([...messages], providers) - expect(two).toBe(one) - expect(three).not.toBe(one) + expect(one.context?.message.id).toBe("a1") + expect(two.context?.message.id).toBe("a2") + expect(two.totalCost).toBe(1) }) }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 2b6edbd951..357205afb5 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -34,8 +34,6 @@ type Metrics = { context: Context | undefined } -const cache = new WeakMap>() - const tokenTotal = (msg: AssistantMessage) => { return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write } @@ -80,15 +78,5 @@ const build = (messages: Message[], providers: Provider[]): Metrics => { } export function getSessionContextMetrics(messages: Message[], providers: Provider[]) { - const byProvider = cache.get(messages) - if (byProvider) { - const hit = byProvider.get(providers) - if (hit) return hit - } - - const value = build(messages, providers) - const next = byProvider ?? new WeakMap() - next.set(providers, value) - if (!byProvider) cache.set(messages, next) - return value + return build(messages, providers) } diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 7eaafc8542..18b607b965 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,23 +148,23 @@ 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 canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal()) @@ -158,6 +172,7 @@ export function SessionHeader() { 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 +298,7 @@ export function SessionHeader() { )} @@ -303,6 +322,7 @@ export function SessionHeader() { {(mount) => (
+ -
- +
) } 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 64adc797c9..97491d0d36 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -3,6 +3,7 @@ import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitPr import { usePlatform } from "@/context/platform" import { useSDK } from "@/context/sdk" import { monoFontFamily, useSettings } from "@/context/settings" +import { parseKeybind, matchKeybind } from "@/context/command" import { SerializeAddon } from "@/addons/serialize" import { LocalPTY } from "@/context/terminal" import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme" @@ -10,6 +11,8 @@ import { useLanguage } from "@/context/language" import { showToast } from "@opencode-ai/ui/toast" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" +const TOGGLE_TERMINAL_ID = "terminal.toggle" +const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" export interface TerminalProps extends ComponentProps<"div"> { pty: LocalPTY onSubmit?: () => void @@ -71,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 @@ -161,13 +166,16 @@ export const Terminal = (props: TerminalProps) => { const once = { value: false } - const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`) + const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`) + url.searchParams.set("directory", sdk.directory) + url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) url.protocol = url.protocol === "https:" ? "wss:" : "ws:" if (window.__OPENCODE__?.serverPassword) { url.username = "opencode" url.password = window.__OPENCODE__?.serverPassword } const socket = new WebSocket(url) + socket.binaryType = "arraybuffer" cleanups.push(() => { if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close() }) @@ -237,12 +245,11 @@ export const Terminal = (props: TerminalProps) => { return true } - // allow for ctrl-` to toggle terminal in parent - if (event.ctrlKey && key === "`") { - return true - } + // allow for toggle terminal keybinds in parent + const config = settings.keybinds.get(TOGGLE_TERMINAL_ID) ?? DEFAULT_TOGGLE_TERMINAL_KEYBIND + const keybinds = parseKeybind(config) - return false + return matchKeybind(keybinds, event) }) const fit = new mod.FitAddon() @@ -287,26 +294,6 @@ export const Terminal = (props: TerminalProps) => { handleResize = () => fit.fit() window.addEventListener("resize", handleResize) cleanups.push(() => window.removeEventListener("resize", handleResize)) - const limit = 16_384 - const min = 32 - const windowMs = 750 - const seed = tail.length > limit ? tail.slice(-limit) : tail - let sync = seed.length >= min - let syncUntil = 0 - const stopSync = () => { - sync = false - syncUntil = 0 - } - - const overlap = (data: string) => { - if (!seed) return 0 - const max = Math.min(seed.length, data.length) - if (max < min) return 0 - for (let i = max; i >= min; i--) { - if (seed.slice(-i) === data.slice(0, i)) return i - } - return 0 - } const onResize = t.onResize(async (size) => { if (socket.readyState === WebSocket.OPEN) { @@ -323,7 +310,6 @@ export const Terminal = (props: TerminalProps) => { }) cleanups.push(() => disposeIfDisposable(onResize)) const onData = t.onData((data) => { - if (data) stopSync() if (socket.readyState === WebSocket.OPEN) { socket.send(data) } @@ -341,7 +327,6 @@ export const Terminal = (props: TerminalProps) => { const handleOpen = () => { local.onConnect?.() - if (sync) syncUntil = Date.now() + windowMs sdk.client.pty .update({ ptyID: local.pty.id, @@ -355,31 +340,31 @@ export const Terminal = (props: TerminalProps) => { socket.addEventListener("open", handleOpen) cleanups.push(() => socket.removeEventListener("open", handleOpen)) + const decoder = new TextDecoder() + const handleMessage = (event: MessageEvent) => { if (disposed) return + if (event.data instanceof ArrayBuffer) { + // WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }). + const bytes = new Uint8Array(event.data) + if (bytes[0] !== 0) return + const json = decoder.decode(bytes.subarray(1)) + try { + const meta = JSON.parse(json) as { cursor?: unknown } + const next = meta?.cursor + if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) { + cursor = next + } + } catch { + // ignore + } + return + } + const data = typeof event.data === "string" ? event.data : "" if (!data) return - - const next = (() => { - if (!sync) return data - if (syncUntil && Date.now() > syncUntil) { - stopSync() - return data - } - const n = overlap(data) - if (!n) { - stopSync() - return data - } - const trimmed = data.slice(n) - if (trimmed) stopSync() - return trimmed - })() - - if (!next) return - - t.write(next) - tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit) + t.write(data) + cursor += data.length } socket.addEventListener("message", handleMessage) cleanups.push(() => socket.removeEventListener("message", handleMessage)) @@ -433,7 +418,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 dba9ae06dc..f2a3c44b6c 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { createPathHelpers, stripQueryAndHash, unquoteGitPath } from "./path" +import { createPathHelpers, stripQueryAndHash, unquoteGitPath, encodeFilePath } from "./path" describe("file path helpers", () => { test("normalizes file inputs against workspace root", () => { @@ -25,3 +25,328 @@ describe("file path helpers", () => { expect(unquoteGitPath("a/b/c.ts")).toBe("a/b/c.ts") }) }) + +describe("encodeFilePath", () => { + describe("Linux/Unix paths", () => { + test("should handle Linux absolute path", () => { + const linuxPath = "/home/user/project/README.md" + const result = encodeFilePath(linuxPath) + const fileUrl = `file://${result}` + + // Should create a valid URL + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/home/user/project/README.md") + + const url = new URL(fileUrl) + expect(url.protocol).toBe("file:") + expect(url.pathname).toBe("/home/user/project/README.md") + }) + + test("should handle Linux path with special characters", () => { + const linuxPath = "/home/user/file#name with spaces.txt" + const result = encodeFilePath(linuxPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/home/user/file%23name%20with%20spaces.txt") + }) + + test("should handle Linux relative path", () => { + const relativePath = "src/components/App.tsx" + const result = encodeFilePath(relativePath) + + expect(result).toBe("src/components/App.tsx") + }) + + test("should handle Linux root directory", () => { + const result = encodeFilePath("/") + expect(result).toBe("/") + }) + + test("should handle Linux path with all special chars", () => { + const path = "/path/to/file#with?special%chars&more.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("%23") // # + expect(result).toContain("%3F") // ? + expect(result).toContain("%25") // % + expect(result).toContain("%26") // & + }) + }) + + describe("macOS paths", () => { + test("should handle macOS absolute path", () => { + const macPath = "/Users/kelvin/Projects/opencode/README.md" + const result = encodeFilePath(macPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/Users/kelvin/Projects/opencode/README.md") + }) + + test("should handle macOS path with spaces", () => { + const macPath = "/Users/kelvin/My Documents/file.txt" + const result = encodeFilePath(macPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("My%20Documents") + }) + }) + + describe("Windows paths", () => { + test("should handle Windows absolute path with backslashes", () => { + const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + // Should create a valid, parseable URL + expect(() => new URL(fileUrl)).not.toThrow() + + const url = new URL(fileUrl) + expect(url.protocol).toBe("file:") + expect(url.pathname).toContain("README.bs.md") + expect(result).toBe("/D:/dev/projects/opencode/README.bs.md") + }) + + test("should handle mixed separator path (Windows + Unix)", () => { + // This is what happens in build-request-parts.ts when concatenating paths + const mixedPath = "D:\\dev\\projects\\opencode/README.bs.md" + const result = encodeFilePath(mixedPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/D:/dev/projects/opencode/README.bs.md") + }) + + test("should handle Windows path with spaces", () => { + const windowsPath = "C:\\Program Files\\MyApp\\file with spaces.txt" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("Program%20Files") + expect(result).toContain("file%20with%20spaces.txt") + }) + + test("should handle Windows path with special chars in filename", () => { + const windowsPath = "D:\\projects\\file#name with ?marks.txt" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toContain("file%23name%20with%20%3Fmarks.txt") + }) + + test("should handle Windows root directory", () => { + const windowsPath = "C:\\" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/C:/") + }) + + test("should handle Windows relative path with backslashes", () => { + const windowsPath = "src\\components\\App.tsx" + const result = encodeFilePath(windowsPath) + + // Relative paths shouldn't get the leading slash + expect(result).toBe("src/components/App.tsx") + }) + + test("should NOT create invalid URL like the bug report", () => { + // This is the exact scenario from bug report by @alexyaroshuk + const windowsPath = "D:\\dev\\projects\\opencode\\README.bs.md" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + // The bug was creating: file://D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md + expect(result).not.toContain("%5C") // Should not have encoded backslashes + expect(result).not.toBe("D%3A%5Cdev%5Cprojects%5Copencode/README.bs.md") + + // Should be valid + expect(() => new URL(fileUrl)).not.toThrow() + }) + + test("should handle lowercase drive letters", () => { + const windowsPath = "c:\\users\\test\\file.txt" + const result = encodeFilePath(windowsPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/c:/users/test/file.txt") + }) + }) + + describe("Cross-platform compatibility", () => { + test("should preserve Unix paths unchanged (except encoding)", () => { + const unixPath = "/usr/local/bin/app" + const result = encodeFilePath(unixPath) + expect(result).toBe("/usr/local/bin/app") + }) + + test("should normalize Windows paths for cross-platform use", () => { + const windowsPath = "C:\\Users\\test\\file.txt" + const result = encodeFilePath(windowsPath) + // Should convert to forward slashes and add leading / + expect(result).not.toContain("\\") + expect(result).toMatch(/^\/[A-Za-z]:\//) + }) + + test("should handle relative paths the same on all platforms", () => { + const unixRelative = "src/app.ts" + const windowsRelative = "src\\app.ts" + + const unixResult = encodeFilePath(unixRelative) + const windowsResult = encodeFilePath(windowsRelative) + + // Both should normalize to forward slashes + expect(unixResult).toBe("src/app.ts") + expect(windowsResult).toBe("src/app.ts") + }) + }) + + describe("Edge cases", () => { + test("should handle empty path", () => { + const result = encodeFilePath("") + expect(result).toBe("") + }) + + test("should handle path with multiple consecutive slashes", () => { + const result = encodeFilePath("//path//to///file.txt") + // Multiple slashes should be preserved (backend handles normalization) + expect(result).toBe("//path//to///file.txt") + }) + + test("should encode Unicode characters", () => { + const unicodePath = "/home/user/文档/README.md" + const result = encodeFilePath(unicodePath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + // Unicode should be encoded + expect(result).toContain("%E6%96%87%E6%A1%A3") + }) + + test("should handle already normalized Windows path", () => { + // Path that's already been normalized (has / before drive letter) + const alreadyNormalized = "/D:/path/file.txt" + const result = encodeFilePath(alreadyNormalized) + + // Should not add another leading slash + expect(result).toBe("/D:/path/file.txt") + expect(result).not.toContain("//D") + }) + + test("should handle just drive letter", () => { + const justDrive = "D:" + const result = encodeFilePath(justDrive) + const fileUrl = `file://${result}` + + expect(result).toBe("/D:") + expect(() => new URL(fileUrl)).not.toThrow() + }) + + test("should handle Windows path with trailing backslash", () => { + const trailingBackslash = "C:\\Users\\test\\" + const result = encodeFilePath(trailingBackslash) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/C:/Users/test/") + }) + + test("should handle very long paths", () => { + const longPath = "C:\\Users\\test\\" + "verylongdirectoryname\\".repeat(20) + "file.txt" + const result = encodeFilePath(longPath) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).not.toContain("\\") + }) + + test("should handle paths with dots", () => { + const pathWithDots = "C:\\Users\\..\\test\\.\\file.txt" + const result = encodeFilePath(pathWithDots) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + // Dots should be preserved (backend normalizes) + expect(result).toContain("..") + expect(result).toContain("/./") + }) + }) + + describe("Regression tests for PR #12424", () => { + test("should handle file with # in name", () => { + const path = "/path/to/file#name.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/path/to/file%23name.txt") + }) + + test("should handle file with ? in name", () => { + const path = "/path/to/file?name.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/path/to/file%3Fname.txt") + }) + + test("should handle file with % in name", () => { + const path = "/path/to/file%name.txt" + const result = encodeFilePath(path) + const fileUrl = `file://${result}` + + expect(() => new URL(fileUrl)).not.toThrow() + expect(result).toBe("/path/to/file%25name.txt") + }) + }) + + describe("Integration with file:// URL construction", () => { + test("should work with query parameters (Linux)", () => { + const path = "/home/user/file.txt" + const encoded = encodeFilePath(path) + const fileUrl = `file://${encoded}?start=10&end=20` + + const url = new URL(fileUrl) + expect(url.searchParams.get("start")).toBe("10") + expect(url.searchParams.get("end")).toBe("20") + expect(url.pathname).toBe("/home/user/file.txt") + }) + + test("should work with query parameters (Windows)", () => { + const path = "C:\\Users\\test\\file.txt" + const encoded = encodeFilePath(path) + const fileUrl = `file://${encoded}?start=10&end=20` + + const url = new URL(fileUrl) + expect(url.searchParams.get("start")).toBe("10") + expect(url.searchParams.get("end")).toBe("20") + }) + + test("should parse correctly in URL constructor (Linux)", () => { + const path = "/var/log/app.log" + const fileUrl = `file://${encodeFilePath(path)}` + const url = new URL(fileUrl) + + expect(url.protocol).toBe("file:") + expect(url.pathname).toBe("/var/log/app.log") + }) + + test("should parse correctly in URL constructor (Windows)", () => { + const path = "D:\\logs\\app.log" + const fileUrl = `file://${encodeFilePath(path)}` + const url = new URL(fileUrl) + + expect(url.protocol).toBe("file:") + expect(url.pathname).toContain("app.log") + }) + }) +}) diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 155f05aafa..859fdc0406 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -81,9 +81,23 @@ export function decodeFilePath(input: string) { } export function encodeFilePath(filepath: string): string { - return filepath + // 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) + // 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..af4bbdccc6 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -12,10 +12,12 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo const platform = usePlatform() const abort = new AbortController() + // Prefer the WebView fetch implementation for streaming responses. + // @tauri-apps/plugin-http 2.5.x has known issues with streaming/cancellation that can + // retain native resources in the Rust process. const eventSdk = createOpencodeClient({ baseUrl: server.url, signal: abort.signal, - fetch: platform.fetch, }) const emitter = createGlobalEmitter<{ [key: string]: Event diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index f79b9fc958..ad63f3c202 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import type { Message, Part, Project, Session } from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client" import { createStore } from "solid-js/store" import type { State } from "./types" import { applyDirectoryEvent, applyGlobalEvent } from "./event-reducer" @@ -34,6 +34,29 @@ const textPart = (id: string, sessionID: string, messageID: string) => text: id, }) as Part +const permissionRequest = (id: string, sessionID: string, title = id) => + ({ + id, + sessionID, + permission: title, + patterns: ["*"], + metadata: {}, + always: [], + }) as PermissionRequest + +const questionRequest = (id: string, sessionID: string, title = id) => + ({ + id, + sessionID, + questions: [ + { + question: title, + header: title, + options: [{ label: title, description: title }], + }, + ], + }) as QuestionRequest + const baseState = (input: Partial = {}) => ({ status: "complete", @@ -164,6 +187,264 @@ describe("applyDirectoryEvent", () => { expect(store.session_status.ses_1).toBeUndefined() }) + test("cleans session caches when deleted and decrements only root totals", () => { + const cases = [ + { info: rootSession({ id: "ses_1" }), expectedTotal: 1 }, + { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 }, + ] + + for (const item of cases) { + const message = userMessage("msg_1", item.info.id) + const [store, setStore] = createStore( + baseState({ + session: [ + rootSession({ id: "ses_1" }), + rootSession({ id: "ses_2", parentID: "ses_1" }), + rootSession({ id: "ses_3" }), + ], + sessionTotal: 2, + message: { [item.info.id]: [message] }, + part: { [message.id]: [textPart("prt_1", item.info.id, message.id)] }, + session_diff: { [item.info.id]: [] }, + todo: { [item.info.id]: [] }, + permission: { [item.info.id]: [] }, + question: { [item.info.id]: [] }, + session_status: { [item.info.id]: { type: "busy" } }, + }), + ) + + applyDirectoryEvent({ + event: { type: "session.deleted", properties: { info: item.info } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.session.find((x) => x.id === item.info.id)).toBeUndefined() + expect(store.sessionTotal).toBe(item.expectedTotal) + expect(store.message[item.info.id]).toBeUndefined() + expect(store.part[message.id]).toBeUndefined() + expect(store.session_diff[item.info.id]).toBeUndefined() + expect(store.todo[item.info.id]).toBeUndefined() + expect(store.permission[item.info.id]).toBeUndefined() + expect(store.question[item.info.id]).toBeUndefined() + expect(store.session_status[item.info.id]).toBeUndefined() + } + }) + + test("upserts and removes messages while clearing orphaned parts", () => { + const sessionID = "ses_1" + const [store, setStore] = createStore( + baseState({ + message: { [sessionID]: [userMessage("msg_1", sessionID), userMessage("msg_3", sessionID)] }, + part: { msg_2: [textPart("prt_1", sessionID, "msg_2")] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "message.updated", properties: { info: userMessage("msg_2", sessionID) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_2", "msg_3"]) + + applyDirectoryEvent({ + event: { + type: "message.updated", + properties: { + info: { + ...userMessage("msg_2", sessionID), + role: "assistant", + } as Message, + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.find((x) => x.id === "msg_2")?.role).toBe("assistant") + + applyDirectoryEvent({ + event: { type: "message.removed", properties: { sessionID, messageID: "msg_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.message[sessionID]?.map((x) => x.id)).toEqual(["msg_1", "msg_3"]) + expect(store.part.msg_2).toBeUndefined() + }) + + test("upserts and prunes message parts", () => { + const sessionID = "ses_1" + const messageID = "msg_1" + const [store, setStore] = createStore( + baseState({ + part: { [messageID]: [textPart("prt_1", sessionID, messageID), textPart("prt_3", sessionID, messageID)] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "message.part.updated", properties: { part: textPart("prt_2", sessionID, messageID) } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.part[messageID]?.map((x) => x.id)).toEqual(["prt_1", "prt_2", "prt_3"]) + + applyDirectoryEvent({ + event: { + type: "message.part.updated", + properties: { + part: { + ...textPart("prt_2", sessionID, messageID), + text: "changed", + } as Part, + }, + }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + const updated = store.part[messageID]?.find((x) => x.id === "prt_2") + expect(updated?.type).toBe("text") + if (updated?.type === "text") expect(updated.text).toBe("changed") + + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_1" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + applyDirectoryEvent({ + event: { type: "message.part.removed", properties: { messageID, partID: "prt_3" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + + expect(store.part[messageID]).toBeUndefined() + }) + + test("tracks permission and question request lifecycles", () => { + const sessionID = "ses_1" + const [store, setStore] = createStore( + baseState({ + permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] }, + question: { [sessionID]: [questionRequest("q_1", sessionID), questionRequest("q_3", sessionID)] }, + }), + ) + + applyDirectoryEvent({ + event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID) }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_2", "perm_3"]) + + applyDirectoryEvent({ + event: { type: "permission.asked", properties: permissionRequest("perm_2", sessionID, "updated") }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.find((x) => x.id === "perm_2")?.permission).toBe("updated") + + applyDirectoryEvent({ + event: { type: "permission.replied", properties: { sessionID, requestID: "perm_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"]) + + applyDirectoryEvent({ + event: { type: "question.asked", properties: questionRequest("q_2", sessionID) }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"]) + + applyDirectoryEvent({ + event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated") + + applyDirectoryEvent({ + event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + }) + expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"]) + }) + + test("updates vcs branch in store and cache", () => { + const [store, setStore] = createStore(baseState()) + const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] }) + + applyDirectoryEvent({ + event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } }, + store, + setStore, + push() {}, + directory: "/tmp", + loadLsp() {}, + vcsCache: { + store: cacheStore, + setStore: setCacheStore, + ready: () => true, + }, + }) + + expect(store.vcs).toEqual({ branch: "feature/test" }) + expect(cacheStore.value).toEqual({ branch: "feature/test" }) + }) + test("routes disposal and lsp events to side-effect handlers", () => { const [store, setStore] = createStore(baseState()) const pushes: string[] = [] diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts index c421a58b67..2a13e40204 100644 --- a/packages/app/src/context/layout-scroll.test.ts +++ b/packages/app/src/context/layout-scroll.test.ts @@ -1,36 +1,44 @@ -import { describe, expect, test } from "bun:test" +import { describe, expect, test, vi } from "bun:test" import { createScrollPersistence } from "./layout-scroll" describe("createScrollPersistence", () => { - test("debounces persisted scroll writes", async () => { - const snapshot = { - session: { - review: { x: 0, y: 0 }, - }, - } as Record> - const writes: Array> = [] - const scroll = createScrollPersistence({ - debounceMs: 10, - getSnapshot: (sessionKey) => snapshot[sessionKey], - onFlush: (sessionKey, next) => { - snapshot[sessionKey] = next - writes.push(next) - }, - }) + test("debounces persisted scroll writes", () => { + vi.useFakeTimers() + try { + const snapshot = { + session: { + review: { x: 0, y: 0 }, + }, + } as Record> + const writes: Array> = [] + const scroll = createScrollPersistence({ + debounceMs: 10, + getSnapshot: (sessionKey) => snapshot[sessionKey], + onFlush: (sessionKey, next) => { + snapshot[sessionKey] = next + writes.push(next) + }, + }) - for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) { - scroll.setScroll("session", "review", { x: 0, y: i }) + for (const i of Array.from({ length: 30 }, (_, n) => n + 1)) { + scroll.setScroll("session", "review", { x: 0, y: i }) + } + + vi.advanceTimersByTime(9) + expect(writes).toHaveLength(0) + + vi.advanceTimersByTime(1) + + expect(writes).toHaveLength(1) + expect(writes[0]?.review).toEqual({ x: 0, y: 30 }) + + scroll.setScroll("session", "review", { x: 0, y: 30 }) + vi.advanceTimersByTime(20) + + expect(writes).toHaveLength(1) + scroll.dispose() + } finally { + vi.useRealTimers() } - - await new Promise((resolve) => setTimeout(resolve, 40)) - - expect(writes).toHaveLength(1) - expect(writes[0]?.review).toEqual({ x: 0, y: 30 }) - - scroll.setScroll("session", "review", { x: 0, y: 30 }) - await new Promise((resolve) => setTimeout(resolve, 20)) - - expect(writes).toHaveLength(1) - scroll.dispose() }) }) 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 127b9260b3..7aa6c65540 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -57,6 +57,12 @@ export type Platform = { /** Set the default server URL to use on app startup (platform-specific) */ setDefaultServerUrl?(url: string | null): Promise | void + /** Get the 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 @@ -65,8 +71,13 @@ export type Platform = { /** Check if an editor app exists (desktop only) */ checkAppExists?(appName: string): Promise + + /** Read image from clipboard (desktop only) */ + 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/server.tsx b/packages/app/src/context/server.tsx index 72693e6ef6..351407d91b 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -28,13 +28,14 @@ function projectsKey(url: string) { export const { use: useServer, provider: ServerProvider } = createSimpleContext({ name: "Server", - init: (props: { defaultUrl: string }) => { + init: (props: { defaultUrl: string; isSidecar?: boolean }) => { const platform = usePlatform() const [store, setStore, _, ready] = persisted( Persist.global("server", ["server.v3"]), createStore({ list: [] as string[], + currentSidecarUrl: "", projects: {} as Record, lastProject: {} as Record, }), @@ -59,7 +60,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const fallback = normalizeServerUrl(props.defaultUrl) if (fallback && url === fallback) { - setState("active", url) + batch(() => { + if (!store.list.includes(url)) { + // Add the fallback url to the list if it's not already in the list + setStore("list", store.list.length, url) + } + setState("active", url) + }) return } @@ -89,7 +96,20 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (state.active) return const url = normalizeServerUrl(props.defaultUrl) if (!url) return - setState("active", url) + batch(() => { + // Remove the previous startup sidecar url + if (store.currentSidecarUrl) { + remove(store.currentSidecarUrl) + } + + // Add the new sidecar url + if (props.isSidecar && props.defaultUrl) { + add(props.defaultUrl) + setStore("currentSidecarUrl", props.defaultUrl) + } + + setState("active", url) + }) }) const isReady = createMemo(() => ready() && !!state.active) 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 77a3edb062..201d63660a 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -44,8 +44,10 @@ export const dict = { "command.session.new": "جلسة جديدة", "command.file.open": "فتح ملف", + "command.tab.close": "إغلاق علامة التبويب", "command.context.addSelection": "إضافة التحديد إلى السياق", "command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي", + "command.input.focus": "التركيز على حقل الإدخال", "command.terminal.toggle": "تبديل المحطة الطرفية", "command.fileTree.toggle": "تبديل شجرة الملفات", "command.review.toggle": "تبديل المراجعة", @@ -211,6 +213,7 @@ export const dict = { "prompt.popover.emptyResults": "لا توجد نتائج مطابقة", "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة", "prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا", + "prompt.dropzone.file.label": "أفلت لإشارة @ للملف", "prompt.slash.badge.custom": "مخصص", "prompt.slash.badge.skill": "مهارة", "prompt.slash.badge.mcp": "mcp", @@ -232,6 +235,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "فشل إرسال أمر shell", "prompt.toast.commandSendFailed.title": "فشل إرسال الأمر", "prompt.toast.promptSendFailed.title": "فشل إرسال الموجه", + "prompt.toast.promptSendFailed.description": "تعذر استرداد الجلسة", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} من {{total}} مفعل", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index a743a3d896..b7f2d74857 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -44,8 +44,10 @@ export const dict = { "command.session.new": "Nova sessão", "command.file.open": "Abrir arquivo", + "command.tab.close": "Fechar aba", "command.context.addSelection": "Adicionar seleção ao contexto", "command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual", + "command.input.focus": "Focar entrada", "command.terminal.toggle": "Alternar terminal", "command.fileTree.toggle": "Alternar árvore de arquivos", "command.review.toggle": "Alternar revisão", @@ -211,6 +213,7 @@ export const dict = { "prompt.popover.emptyResults": "Nenhum resultado correspondente", "prompt.popover.emptyCommands": "Nenhum comando correspondente", "prompt.dropzone.label": "Solte imagens ou PDFs aqui", + "prompt.dropzone.file.label": "Solte para @mencionar arquivo", "prompt.slash.badge.custom": "personalizado", "prompt.slash.badge.skill": "skill", "prompt.slash.badge.mcp": "mcp", @@ -232,6 +235,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Falha ao enviar comando shell", "prompt.toast.commandSendFailed.title": "Falha ao enviar comando", "prompt.toast.promptSendFailed.title": "Falha ao enviar prompt", + "prompt.toast.promptSendFailed.description": "Não foi possível recuperar a sessão", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} de {{total}} habilitados", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index ce37989c25..05eca1628e 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -47,6 +47,7 @@ export const dict = { "command.tab.close": "Zatvori karticu", "command.context.addSelection": "Dodaj odabir u kontekst", "command.context.addSelection.description": "Dodaj odabrane linije iz trenutne datoteke", + "command.input.focus": "Fokusiraj polje za unos", "command.terminal.toggle": "Prikaži/sakrij terminal", "command.fileTree.toggle": "Prikaži/sakrij stablo datoteka", "command.review.toggle": "Prikaži/sakrij pregled", @@ -219,6 +220,7 @@ export const dict = { "prompt.popover.emptyResults": "Nema rezultata", "prompt.popover.emptyCommands": "Nema komandi", "prompt.dropzone.label": "Spusti slike ili PDF-ove ovdje", + "prompt.dropzone.file.label": "Spusti za @spominjanje datoteke", "prompt.slash.badge.custom": "prilagođeno", "prompt.slash.badge.skill": "skill", "prompt.slash.badge.mcp": "mcp", @@ -240,6 +242,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Neuspješno slanje shell naredbe", "prompt.toast.commandSendFailed.title": "Neuspješno slanje komande", "prompt.toast.promptSendFailed.title": "Neuspješno slanje upita", + "prompt.toast.promptSendFailed.description": "Nije moguće dohvatiti sesiju", "dialog.mcp.title": "MCP-ovi", "dialog.mcp.description": "{{enabled}} od {{total}} omogućeno", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 88704607b3..8ea4907c1b 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -44,8 +44,10 @@ export const dict = { "command.session.new": "Ny session", "command.file.open": "Åbn fil", + "command.tab.close": "Luk fane", "command.context.addSelection": "Tilføj markering til kontekst", "command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil", + "command.input.focus": "Fokuser inputfelt", "command.terminal.toggle": "Skift terminal", "command.fileTree.toggle": "Skift filtræ", "command.review.toggle": "Skift gennemgang", @@ -211,6 +213,7 @@ export const dict = { "prompt.popover.emptyResults": "Ingen matchende resultater", "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slip billeder eller PDF'er her", + "prompt.dropzone.file.label": "Slip for at @nævne fil", "prompt.slash.badge.custom": "brugerdefineret", "prompt.slash.badge.skill": "skill", "prompt.slash.badge.mcp": "mcp", @@ -232,6 +235,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Kunne ikke sende shell-kommando", "prompt.toast.commandSendFailed.title": "Kunne ikke sende kommando", "prompt.toast.promptSendFailed.title": "Kunne ikke sende forespørgsel", + "prompt.toast.promptSendFailed.description": "Kunne ikke hente session", "dialog.mcp.title": "MCP'er", "dialog.mcp.description": "{{enabled}} af {{total}} aktiveret", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index a4d12d4454..a4884a1033 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -48,8 +48,10 @@ export const dict = { "command.session.new": "Neue Sitzung", "command.file.open": "Datei öffnen", + "command.tab.close": "Tab schließen", "command.context.addSelection": "Auswahl zum Kontext hinzufügen", "command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen", + "command.input.focus": "Eingabefeld fokussieren", "command.terminal.toggle": "Terminal umschalten", "command.fileTree.toggle": "Dateibaum umschalten", "command.review.toggle": "Überprüfung umschalten", @@ -253,6 +255,7 @@ export const dict = { "prompt.popover.emptyResults": "Keine passenden Ergebnisse", "prompt.popover.emptyCommands": "Keine passenden Befehle", "prompt.dropzone.label": "Bilder oder PDFs hier ablegen", + "prompt.dropzone.file.label": "Ablegen zum @Erwähnen der Datei", "prompt.slash.badge.custom": "benutzerdefiniert", "prompt.slash.badge.skill": "skill", "prompt.slash.badge.mcp": "mcp", @@ -275,6 +278,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Shell-Befehl konnte nicht gesendet werden", "prompt.toast.commandSendFailed.title": "Befehl konnte nicht gesendet werden", "prompt.toast.promptSendFailed.title": "Eingabe konnte nicht gesendet werden", + "prompt.toast.promptSendFailed.description": "Sitzung konnte nicht abgerufen werden", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} von {{total}} aktiviert", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 4d7d571afb..b0ffa70f84 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -47,6 +47,7 @@ export const dict = { "command.tab.close": "Close tab", "command.context.addSelection": "Add selection to context", "command.context.addSelection.description": "Add selected lines from the current file", + "command.input.focus": "Focus input", "command.terminal.toggle": "Toggle terminal", "command.fileTree.toggle": "Toggle file tree", "command.review.toggle": "Toggle review", @@ -207,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", @@ -256,6 +257,7 @@ export const dict = { "prompt.popover.emptyResults": "No matching results", "prompt.popover.emptyCommands": "No matching commands", "prompt.dropzone.label": "Drop images or PDFs here", + "prompt.dropzone.file.label": "Drop to @mention file", "prompt.slash.badge.custom": "custom", "prompt.slash.badge.skill": "skill", "prompt.slash.badge.mcp": "mcp", @@ -277,6 +279,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Failed to send shell command", "prompt.toast.commandSendFailed.title": "Failed to send command", "prompt.toast.promptSendFailed.title": "Failed to send prompt", + "prompt.toast.promptSendFailed.description": "Unable to retrieve session", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} of {{total}} enabled", @@ -585,6 +588,7 @@ export const dict = { "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", @@ -595,6 +599,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 5d48ba4949..50d9060703 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -44,8 +44,10 @@ export const dict = { "command.session.new": "Nueva sesión", "command.file.open": "Abrir archivo", + "command.tab.close": "Cerrar pestaña", "command.context.addSelection": "Añadir selección al contexto", "command.context.addSelection.description": "Añadir las líneas seleccionadas del archivo actual", + "command.input.focus": "Enfocar entrada", "command.terminal.toggle": "Alternar terminal", "command.fileTree.toggle": "Alternar árbol de archivos", "command.review.toggle": "Alternar revisión", @@ -211,6 +213,7 @@ export const dict = { "prompt.popover.emptyResults": "Sin resultados coincidentes", "prompt.popover.emptyCommands": "Sin comandos coincidentes", "prompt.dropzone.label": "Suelta imágenes o PDFs aquí", + "prompt.dropzone.file.label": "Suelta para @mencionar archivo", "prompt.slash.badge.custom": "personalizado", "prompt.slash.badge.skill": "skill", "prompt.slash.badge.mcp": "mcp", @@ -232,6 +235,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Fallo al enviar comando de shell", "prompt.toast.commandSendFailed.title": "Fallo al enviar comando", "prompt.toast.promptSendFailed.title": "Fallo al enviar prompt", + "prompt.toast.promptSendFailed.description": "No se pudo recuperar la sesión", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} de {{total}} habilitados", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index a76e57ff15..7ad39f3406 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -44,8 +44,10 @@ export const dict = { "command.session.new": "Nouvelle session", "command.file.open": "Ouvrir un fichier", + "command.tab.close": "Fermer l'onglet", "command.context.addSelection": "Ajouter la sélection au contexte", "command.context.addSelection.description": "Ajouter les lignes sélectionnées du fichier actuel", + "command.input.focus": "Focus input", "command.terminal.toggle": "Basculer le terminal", "command.fileTree.toggle": "Basculer l'arborescence des fichiers", "command.review.toggle": "Basculer la revue", @@ -211,6 +213,7 @@ export const dict = { "prompt.popover.emptyResults": "Aucun résultat correspondant", "prompt.popover.emptyCommands": "Aucune commande correspondante", "prompt.dropzone.label": "Déposez des images ou des PDF ici", + "prompt.dropzone.file.label": "Déposez pour @mentionner le fichier", "prompt.slash.badge.custom": "personnalisé", "prompt.slash.badge.skill": "skill", "prompt.slash.badge.mcp": "mcp", @@ -232,6 +235,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Échec de l'envoi de la commande shell", "prompt.toast.commandSendFailed.title": "Échec de l'envoi de la commande", "prompt.toast.promptSendFailed.title": "Échec de l'envoi du message", + "prompt.toast.promptSendFailed.description": "Impossible de récupérer la session", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} sur {{total}} activés", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index e41dea9dc7..a39bfbaf33 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -44,8 +44,10 @@ export const dict = { "command.session.new": "新しいセッション", "command.file.open": "ファイルを開く", + "command.tab.close": "タブを閉じる", "command.context.addSelection": "選択範囲をコンテキストに追加", "command.context.addSelection.description": "現在のファイルから選択した行を追加", + "command.input.focus": "入力欄にフォーカス", "command.terminal.toggle": "ターミナルの切り替え", "command.fileTree.toggle": "ファイルツリーを切り替え", "command.review.toggle": "レビューの切り替え", @@ -210,6 +212,7 @@ export const dict = { "prompt.popover.emptyResults": "一致する結果がありません", "prompt.popover.emptyCommands": "一致するコマンドがありません", "prompt.dropzone.label": "画像またはPDFをここにドロップ", + "prompt.dropzone.file.label": "ドロップして@メンションファイルを追加", "prompt.slash.badge.custom": "カスタム", "prompt.slash.badge.skill": "スキル", "prompt.slash.badge.mcp": "mcp", @@ -231,6 +234,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "シェルコマンドの送信に失敗しました", "prompt.toast.commandSendFailed.title": "コマンドの送信に失敗しました", "prompt.toast.promptSendFailed.title": "プロンプトの送信に失敗しました", + "prompt.toast.promptSendFailed.description": "セッションを取得できませんでした", "dialog.mcp.title": "MCP", "dialog.mcp.description": "{{total}}個中{{enabled}}個が有効", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index a4f42a583e..b5927b2107 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -48,8 +48,10 @@ export const dict = { "command.session.new": "새 세션", "command.file.open": "파일 열기", + "command.tab.close": "탭 닫기", "command.context.addSelection": "선택 영역을 컨텍스트에 추가", "command.context.addSelection.description": "현재 파일에서 선택한 줄을 추가", + "command.input.focus": "입력창 포커스", "command.terminal.toggle": "터미널 토글", "command.fileTree.toggle": "파일 트리 토글", "command.review.toggle": "검토 토글", @@ -214,6 +216,7 @@ export const dict = { "prompt.popover.emptyResults": "일치하는 결과 없음", "prompt.popover.emptyCommands": "일치하는 명령어 없음", "prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요", + "prompt.dropzone.file.label": "드롭하여 파일 @멘션 추가", "prompt.slash.badge.custom": "사용자 지정", "prompt.slash.badge.skill": "스킬", "prompt.slash.badge.mcp": "mcp", @@ -235,6 +238,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "셸 명령 전송 실패", "prompt.toast.commandSendFailed.title": "명령 전송 실패", "prompt.toast.promptSendFailed.title": "프롬프트 전송 실패", + "prompt.toast.promptSendFailed.description": "세션을 가져올 수 없습니다", "dialog.mcp.title": "MCP", "dialog.mcp.description": "{{total}}개 중 {{enabled}}개 활성화됨", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 3de7837f80..7d8cdd27f3 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -47,8 +47,10 @@ export const dict = { "command.session.new": "Ny sesjon", "command.file.open": "Åpne fil", + "command.tab.close": "Lukk fane", "command.context.addSelection": "Legg til markering i kontekst", "command.context.addSelection.description": "Legg til valgte linjer fra gjeldende fil", + "command.input.focus": "Fokuser inndata", "command.terminal.toggle": "Veksle terminal", "command.fileTree.toggle": "Veksle filtre", "command.review.toggle": "Veksle gjennomgang", @@ -214,6 +216,7 @@ export const dict = { "prompt.popover.emptyResults": "Ingen matchende resultater", "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slipp bilder eller PDF-er her", + "prompt.dropzone.file.label": "Slipp for å @nevne fil", "prompt.slash.badge.custom": "egendefinert", "prompt.slash.badge.skill": "skill", "prompt.slash.badge.mcp": "mcp", @@ -235,6 +238,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Kunne ikke sende shell-kommando", "prompt.toast.commandSendFailed.title": "Kunne ikke sende kommando", "prompt.toast.promptSendFailed.title": "Kunne ikke sende forespørsel", + "prompt.toast.promptSendFailed.description": "Kunne ikke hente økt", "dialog.mcp.title": "MCP-er", "dialog.mcp.description": "{{enabled}} av {{total}} aktivert", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 44bc4677be..76a47ea26f 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -44,8 +44,10 @@ export const dict = { "command.session.new": "Nowa sesja", "command.file.open": "Otwórz plik", + "command.tab.close": "Zamknij kartę", "command.context.addSelection": "Dodaj zaznaczenie do kontekstu", "command.context.addSelection.description": "Dodaj zaznaczone linie z bieżącego pliku", + "command.input.focus": "Fokus na pole wejściowe", "command.terminal.toggle": "Przełącz terminal", "command.fileTree.toggle": "Przełącz drzewo plików", "command.review.toggle": "Przełącz przegląd", @@ -211,6 +213,7 @@ export const dict = { "prompt.popover.emptyResults": "Brak pasujących wyników", "prompt.popover.emptyCommands": "Brak pasujących poleceń", "prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj", + "prompt.dropzone.file.label": "Upuść, aby @wspomnieć plik", "prompt.slash.badge.custom": "własne", "prompt.slash.badge.skill": "skill", "prompt.slash.badge.mcp": "mcp", @@ -232,6 +235,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Nie udało się wysłać polecenia powłoki", "prompt.toast.commandSendFailed.title": "Nie udało się wysłać polecenia", "prompt.toast.promptSendFailed.title": "Nie udało się wysłać zapytania", + "prompt.toast.promptSendFailed.description": "Nie udało się pobrać sesji", "dialog.mcp.title": "MCP", "dialog.mcp.description": "{{enabled}} z {{total}} włączone", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 28785c0e9f..e83ce37618 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -44,8 +44,10 @@ export const dict = { "command.session.new": "Новая сессия", "command.file.open": "Открыть файл", + "command.tab.close": "Закрыть вкладку", "command.context.addSelection": "Добавить выделение в контекст", "command.context.addSelection.description": "Добавить выбранные строки из текущего файла", + "command.input.focus": "Фокус на поле ввода", "command.terminal.toggle": "Переключить терминал", "command.fileTree.toggle": "Переключить дерево файлов", "command.review.toggle": "Переключить обзор", @@ -211,6 +213,7 @@ export const dict = { "prompt.popover.emptyResults": "Нет совпадений", "prompt.popover.emptyCommands": "Нет совпадающих команд", "prompt.dropzone.label": "Перетащите изображения или PDF сюда", + "prompt.dropzone.file.label": "Отпустите для @упоминания файла", "prompt.slash.badge.custom": "своё", "prompt.slash.badge.skill": "навык", "prompt.slash.badge.mcp": "mcp", @@ -232,6 +235,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "Не удалось отправить команду оболочки", "prompt.toast.commandSendFailed.title": "Не удалось отправить команду", "prompt.toast.promptSendFailed.title": "Не удалось отправить запрос", + "prompt.toast.promptSendFailed.description": "Не удалось получить сессию", "dialog.mcp.title": "MCP", "dialog.mcp.description": "{{enabled}} из {{total}} включено", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 9858f39d77..2be19d15b1 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -44,8 +44,10 @@ export const dict = { "command.session.new": "เซสชันใหม่", "command.file.open": "เปิดไฟล์", + "command.tab.close": "ปิดแท็บ", "command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท", "command.context.addSelection.description": "เพิ่มบรรทัดที่เลือกจากไฟล์ปัจจุบัน", + "command.input.focus": "โฟกัสช่องป้อนข้อมูล", "command.terminal.toggle": "สลับเทอร์มินัล", "command.fileTree.toggle": "สลับต้นไม้ไฟล์", "command.review.toggle": "สลับการตรวจสอบ", @@ -216,6 +218,7 @@ export const dict = { "prompt.popover.emptyResults": "ไม่พบผลลัพธ์ที่ตรงกัน", "prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน", "prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่", + "prompt.dropzone.file.label": "วางเพื่อ @กล่าวถึงไฟล์", "prompt.slash.badge.custom": "กำหนดเอง", "prompt.slash.badge.skill": "skill", "prompt.slash.badge.mcp": "mcp", @@ -237,6 +240,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "ไม่สามารถส่งคำสั่งเชลล์", "prompt.toast.commandSendFailed.title": "ไม่สามารถส่งคำสั่ง", "prompt.toast.promptSendFailed.title": "ไม่สามารถส่งพร้อมท์", + "prompt.toast.promptSendFailed.description": "ไม่สามารถดึงเซสชันได้", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "{{enabled}} จาก {{total}} ที่เปิดใช้งาน", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index a8fda6f3a6..a48f9e5494 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -48,8 +48,10 @@ export const dict = { "command.session.new": "新建会话", "command.file.open": "打开文件", + "command.tab.close": "关闭标签页", "command.context.addSelection": "将所选内容添加到上下文", "command.context.addSelection.description": "添加当前文件中选中的行", + "command.input.focus": "聚焦输入框", "command.terminal.toggle": "切换终端", "command.fileTree.toggle": "切换文件树", "command.review.toggle": "切换审查", @@ -252,6 +254,7 @@ export const dict = { "prompt.popover.emptyResults": "没有匹配的结果", "prompt.popover.emptyCommands": "没有匹配的命令", "prompt.dropzone.label": "将图片或 PDF 拖到这里", + "prompt.dropzone.file.label": "拖放以 @提及文件", "prompt.slash.badge.custom": "自定义", "prompt.slash.badge.skill": "技能", "prompt.slash.badge.mcp": "mcp", @@ -273,6 +276,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "发送 shell 命令失败", "prompt.toast.commandSendFailed.title": "发送命令失败", "prompt.toast.promptSendFailed.title": "发送提示失败", + "prompt.toast.promptSendFailed.description": "无法获取会话", "dialog.mcp.title": "MCPs", "dialog.mcp.description": "已启用 {{enabled}} / {{total}}", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 319f5c51d1..60363fc99e 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -48,8 +48,10 @@ export const dict = { "command.session.new": "新增工作階段", "command.file.open": "開啟檔案", + "command.tab.close": "關閉分頁", "command.context.addSelection": "將選取內容加入上下文", "command.context.addSelection.description": "加入目前檔案中選取的行", + "command.input.focus": "聚焦輸入框", "command.terminal.toggle": "切換終端機", "command.fileTree.toggle": "切換檔案樹", "command.review.toggle": "切換審查", @@ -249,6 +251,7 @@ export const dict = { "prompt.popover.emptyResults": "沒有符合的結果", "prompt.popover.emptyCommands": "沒有符合的命令", "prompt.dropzone.label": "將圖片或 PDF 拖到這裡", + "prompt.dropzone.file.label": "拖放以 @提及檔案", "prompt.slash.badge.custom": "自訂", "prompt.slash.badge.skill": "技能", "prompt.slash.badge.mcp": "mcp", @@ -270,6 +273,7 @@ export const dict = { "prompt.toast.shellSendFailed.title": "傳送 shell 命令失敗", "prompt.toast.commandSendFailed.title": "傳送命令失敗", "prompt.toast.promptSendFailed.title": "傳送提示失敗", + "prompt.toast.promptSendFailed.description": "無法取得工作階段", "dialog.mcp.title": "MCP", "dialog.mcp.description": "已啟用 {{enabled}} / {{total}}", 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 2f4db85649..b2a17b96b9 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -15,6 +15,7 @@ export default function Layout(props: ParentProps) { const params = useParams() const navigate = useNavigate() const language = useLanguage() + let invalid = "" const directory = createMemo(() => { return decode64(params.dir) ?? "" }) @@ -22,12 +23,14 @@ export default function Layout(props: ParentProps) { createEffect(() => { if (!params.dir) return if (directory()) return + if (invalid === params.dir) return + invalid = params.dir showToast({ variant: "error", title: language.t("common.requestFailed"), description: language.t("directory.error.invalidUrl"), }) - navigate("/") + navigate("/", { replace: true }) }) return ( 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..c55a3719da 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1272,8 +1272,6 @@ export default function Layout(props: ParentProps) { ), ) - await globalSDK.client.instance.dispose({ directory }).catch(() => undefined) - setBusy(directory, false) dismiss() @@ -1938,7 +1936,7 @@ export default function Layout(props: ParentProps) { direction="horizontal" size={layout.sidebar.width()} min={244} - max={window.innerWidth * 0.3 + 64} + max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64} collapseThreshold={244} onResize={layout.sidebar.resize} onCollapse={layout.sidebar.close} diff --git a/packages/app/src/pages/layout/deep-links.ts b/packages/app/src/pages/layout/deep-links.ts index 772e6ece6b..7bdb002a36 100644 --- a/packages/app/src/pages/layout/deep-links.ts +++ b/packages/app/src/pages/layout/deep-links.ts @@ -2,7 +2,15 @@ export const deepLinkEvent = "opencode:deep-link" export const parseDeepLink = (input: string) => { if (!input.startsWith("opencode://")) return - const url = new URL(input) + if (typeof URL.canParse === "function" && !URL.canParse(input)) return + const url = (() => { + try { + return new URL(input) + } catch { + return undefined + } + })() + if (!url) return if (url.hostname !== "open-project") return const directory = url.searchParams.get("directory") if (!directory) return diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index 8a8ea78c77..83d8f4748a 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -12,6 +12,27 @@ describe("layout deep links", () => { expect(parseDeepLink("https://example.com")).toBeUndefined() }) + test("ignores malformed deep links safely", () => { + expect(() => parseDeepLink("opencode://open-project/%E0%A4%A%")).not.toThrow() + expect(parseDeepLink("opencode://open-project/%E0%A4%A%")).toBeUndefined() + }) + + test("parses links when URL.canParse is unavailable", () => { + const original = Object.getOwnPropertyDescriptor(URL, "canParse") + Object.defineProperty(URL, "canParse", { configurable: true, value: undefined }) + try { + expect(parseDeepLink("opencode://open-project?directory=/tmp/demo")).toBe("/tmp/demo") + } finally { + if (original) Object.defineProperty(URL, "canParse", original) + if (!original) Reflect.deleteProperty(URL, "canParse") + } + }) + + test("ignores open-project deep links without directory", () => { + expect(parseDeepLink("opencode://open-project")).toBeUndefined() + expect(parseDeepLink("opencode://open-project?directory=")).toBeUndefined() + }) + test("collects only valid open-project directories", () => { const result = collectOpenProjectDeepLinks([ "opencode://open-project?directory=/a", @@ -39,6 +60,14 @@ describe("layout workspace helpers", () => { expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo") }) + test("preserves posix and drive roots in workspace key", () => { + expect(workspaceKey("/")).toBe("/") + expect(workspaceKey("///")).toBe("/") + expect(workspaceKey("C:\\")).toBe("C:\\") + expect(workspaceKey("C:\\\\\\")).toBe("C:\\") + expect(workspaceKey("C:///")).toBe("C:/") + }) + test("keeps local first while preserving known order", () => { const result = syncWorkspaceOrder("/root", ["/root", "/b", "/c"], ["/root", "/c", "/a", "/b"]) expect(result).toEqual(["/root", "/c", "/b"]) diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 4d144f34ec..6a1e7c0123 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,7 +1,12 @@ import { getFilename } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" -export const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") +export const workspaceKey = (directory: string) => { + const drive = directory.match(/^([A-Za-z]:)[\\/]+$/) + if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}` + if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/" + return directory.replace(/[\\/]+$/, "") +} export function sortSessions(now: number) { const oneMinuteAgo = now - 60 * 1000 @@ -21,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 11bad84b02..13c1e55ef8 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -1,6 +1,8 @@ +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" @@ -86,6 +88,8 @@ export const SortableWorkspace = (props: { project: LocalProject mobile?: boolean }): JSX.Element => { + const navigate = useNavigate() + const params = useParams() const globalSync = useGlobalSync() const language = useLanguage() const sortable = createSortable(props.directory) @@ -111,8 +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 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) } @@ -163,11 +169,9 @@ export const SortableWorkspace = (props: { openOnDblClick={false} /> - +
+ +
) @@ -192,7 +196,9 @@ export const SortableWorkspace = (props: { when={workspaceEditActive()} fallback={ @@ -200,7 +206,13 @@ export const SortableWorkspace = (props: { } > -
{header()}
+
+ {header()} +
+ + + { + event.preventDefault() + event.stopPropagation() + props.ctx.setHoverSession(undefined) + props.ctx.clearHoverProjectSoon() + navigate(`/${slug()}/session`) + }} + /> + +
@@ -267,13 +298,15 @@ export const SortableWorkspace = (props: {