diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 9a888adbb2..3aeef82d62 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -1,4 +1,5 @@
# web + desktop packages
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
+packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin
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..3b7bb8914a
--- /dev/null
+++ b/.github/VOUCHED.td
@@ -0,0 +1,20 @@
+# 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
+-florianleibert
+fwang
+iamdavidhill
+jayair
+kitlangton
+kommander
+r44vc0rp
+rekram1-node
+-spider-yamet clawdbot/llm psychosis, spam pinging the team
+thdxr
diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml
index cba04facce..65fbf0f3d6 100644
--- a/.github/actions/setup-bun/action.yml
+++ b/.github/actions/setup-bun/action.yml
@@ -3,20 +3,17 @@ description: "Setup Bun with caching and install dependencies"
runs:
using: "composite"
steps:
+ - name: Mount Bun Cache
+ uses: useblacksmith/stickydisk@v1
+ with:
+ key: ${{ github.repository }}-bun-cache-${{ runner.os }}
+ path: ~/.bun
+
- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version-file: package.json
- - name: Cache ~/.bun
- id: cache-bun
- uses: actions/cache@v4
- with:
- path: ~/.bun
- key: ${{ runner.os }}-bun-${{ hashFiles('package.json') }}-${{ hashFiles('bun.lockb', 'bun.lock') }}
- restore-keys: |
- ${{ runner.os }}-bun-${{ hashFiles('package.json') }}-
-
- name: Install dependencies
run: bun install
shell: bash
diff --git a/.github/actions/setup-git-committer/action.yml b/.github/actions/setup-git-committer/action.yml
new file mode 100644
index 0000000000..87d2f5d0d4
--- /dev/null
+++ b/.github/actions/setup-git-committer/action.yml
@@ -0,0 +1,43 @@
+name: "Setup Git Committer"
+description: "Create app token and configure git user"
+inputs:
+ opencode-app-id:
+ description: "OpenCode GitHub App ID"
+ required: true
+ opencode-app-secret:
+ description: "OpenCode GitHub App private key"
+ required: true
+outputs:
+ token:
+ description: "GitHub App token"
+ value: ${{ steps.apptoken.outputs.token }}
+ app-slug:
+ description: "GitHub App slug"
+ value: ${{ steps.apptoken.outputs.app-slug }}
+runs:
+ using: "composite"
+ steps:
+ - name: Create app token
+ id: apptoken
+ uses: actions/create-github-app-token@v2
+ with:
+ app-id: ${{ inputs.opencode-app-id }}
+ private-key: ${{ inputs.opencode-app-secret }}
+ owner: ${{ github.repository_owner }}
+
+ - name: Configure git user
+ run: |
+ slug="${{ steps.apptoken.outputs.app-slug }}"
+ git config --global user.name "${slug}[bot]"
+ git config --global user.email "${slug}[bot]@users.noreply.github.com"
+ shell: bash
+
+ - name: Clear checkout auth
+ run: |
+ git config --local --unset-all http.https://github.com/.extraheader || true
+ shell: bash
+
+ - name: Configure git remote
+ run: |
+ git remote set-url origin https://x-access-token:${{ steps.apptoken.outputs.token }}@github.com/${{ github.repository }}
+ shell: bash
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index b4369fa1a4..8cf030eceb 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,3 +1,7 @@
### 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.
+
+**If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!**
+
### How did you verify your code works?
diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml
index d92133ea6a..20d2bc18d8 100644
--- a/.github/workflows/beta.yml
+++ b/.github/workflows/beta.yml
@@ -1,34 +1,33 @@
name: beta
on:
- push:
- branches: [dev]
- pull_request:
- types: [opened, synchronize, labeled, unlabeled]
+ workflow_dispatch:
+ schedule:
+ - cron: "0 * * * *"
jobs:
sync:
- if: |
- github.event_name == 'push' ||
- (github.event_name == 'pull_request' &&
- contains(github.event.pull_request.labels.*.name, 'contributor'))
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
- pull-requests: read
+ pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
- name: Setup Bun
uses: ./.github/actions/setup-bun
- - name: Configure Git
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
+ - name: Setup Git Committer
+ id: setup-git-committer
+ uses: ./.github/actions/setup-git-committer
+ with:
+ opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
+ opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Sync beta branch
env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }}
run: bun script/beta.ts
diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml
index cb5c45063f..e0e571b469 100644
--- a/.github/workflows/close-stale-prs.yml
+++ b/.github/workflows/close-stale-prs.yml
@@ -18,6 +18,7 @@ permissions:
jobs:
close-stale-prs:
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- name: Close inactive PRs
uses: actions/github-script@v8
@@ -25,59 +26,210 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const DAYS_INACTIVE = 60
+ const MAX_RETRIES = 3
+
+ // Adaptive delay: fast for small batches, slower for large to respect
+ // GitHub's 80 content-generating requests/minute limit
+ const SMALL_BATCH_THRESHOLD = 10
+ const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
+ const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
+
+ const startTime = Date.now()
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
const { owner, repo } = context.repo
const dryRun = context.payload.inputs?.dryRun === "true"
- const stalePrs = []
core.info(`Dry run mode: ${dryRun}`)
+ core.info(`Cutoff date: ${cutoff.toISOString()}`)
- const prs = await github.paginate(github.rest.pulls.list, {
- owner,
- repo,
- state: "open",
- per_page: 100,
- sort: "updated",
- direction: "asc",
- })
+ function sleep(ms) {
+ return new Promise(resolve => setTimeout(resolve, ms))
+ }
- for (const pr of prs) {
- const lastUpdated = new Date(pr.updated_at)
- if (lastUpdated > cutoff) {
- core.info(`PR ${pr.number} is fresh`)
- continue
+ async function withRetry(fn, description = 'API call') {
+ let lastError
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
+ try {
+ const result = await fn()
+ return result
+ } catch (error) {
+ lastError = error
+ const isRateLimited = error.status === 403 &&
+ (error.message?.includes('rate limit') || error.message?.includes('secondary'))
+
+ if (!isRateLimited) {
+ throw error
+ }
+
+ // Parse retry-after header, default to 60 seconds
+ const retryAfter = error.response?.headers?.['retry-after']
+ ? parseInt(error.response.headers['retry-after'])
+ : 60
+
+ // Exponential backoff: retryAfter * 2^attempt
+ const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
+
+ core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
+
+ await sleep(backoffMs)
+ }
+ }
+ core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
+ throw lastError
+ }
+
+ const query = `
+ query($owner: String!, $repo: String!, $cursor: String) {
+ repository(owner: $owner, name: $repo) {
+ pullRequests(first: 100, states: OPEN, after: $cursor) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ nodes {
+ number
+ title
+ author {
+ login
+ }
+ createdAt
+ commits(last: 1) {
+ nodes {
+ commit {
+ committedDate
+ }
+ }
+ }
+ comments(last: 1) {
+ nodes {
+ createdAt
+ }
+ }
+ reviews(last: 1) {
+ nodes {
+ createdAt
+ }
+ }
+ }
+ }
+ }
+ }
+ `
+
+ const allPrs = []
+ let cursor = null
+ let hasNextPage = true
+ let pageCount = 0
+
+ while (hasNextPage) {
+ pageCount++
+ core.info(`Fetching page ${pageCount} of open PRs...`)
+
+ const result = await withRetry(
+ () => github.graphql(query, { owner, repo, cursor }),
+ `GraphQL page ${pageCount}`
+ )
+
+ allPrs.push(...result.repository.pullRequests.nodes)
+ hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
+ cursor = result.repository.pullRequests.pageInfo.endCursor
+
+ core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
+
+ // Delay between pagination requests (use small batch delay for reads)
+ if (hasNextPage) {
+ await sleep(SMALL_BATCH_DELAY_MS)
+ }
+ }
+
+ core.info(`Found ${allPrs.length} open pull requests`)
+
+ const stalePrs = allPrs.filter((pr) => {
+ const dates = [
+ new Date(pr.createdAt),
+ pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
+ pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
+ pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
+ ].filter((d) => d !== null)
+
+ const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
+
+ if (!lastActivity || lastActivity > cutoff) {
+ core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
+ return false
}
- stalePrs.push(pr)
- }
+ core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
+ return true
+ })
if (!stalePrs.length) {
core.info("No stale pull requests found.")
return
}
+ core.info(`Found ${stalePrs.length} stale pull requests`)
+
+ // ============================================
+ // Close stale PRs
+ // ============================================
+ const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
+ ? LARGE_BATCH_DELAY_MS
+ : SMALL_BATCH_DELAY_MS
+
+ core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
+
+ let closedCount = 0
+ let skippedCount = 0
+
for (const pr of stalePrs) {
const issue_number = pr.number
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
if (dryRun) {
- core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`)
+ core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
continue
}
- await github.rest.issues.createComment({
- owner,
- repo,
- issue_number,
- body: closeComment,
- })
+ try {
+ // Add comment
+ await withRetry(
+ () => github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number,
+ body: closeComment,
+ }),
+ `Comment on PR #${issue_number}`
+ )
- await github.rest.pulls.update({
- owner,
- repo,
- pull_number: issue_number,
- state: "closed",
- })
+ // Close PR
+ await withRetry(
+ () => github.rest.pulls.update({
+ owner,
+ repo,
+ pull_number: issue_number,
+ state: "closed",
+ }),
+ `Close PR #${issue_number}`
+ )
- core.info(`Closed PR #${issue_number} from ${pr.user.login}`)
+ closedCount++
+ core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
+
+ // Delay before processing next PR
+ await sleep(requestDelayMs)
+ } catch (error) {
+ skippedCount++
+ core.error(`Failed to close PR #${issue_number}: ${error.message}`)
+ }
}
+
+ const elapsed = Math.round((Date.now() - startTime) / 1000)
+ core.info(`\n========== Summary ==========`)
+ core.info(`Total open PRs found: ${allPrs.length}`)
+ core.info(`Stale PRs identified: ${stalePrs.length}`)
+ core.info(`PRs closed: ${closedCount}`)
+ core.info(`PRs skipped (errors): ${skippedCount}`)
+ core.info(`Elapsed time: ${elapsed}s`)
+ core.info(`=============================`)
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/containers.yml b/.github/workflows/containers.yml
new file mode 100644
index 0000000000..c7df066d41
--- /dev/null
+++ b/.github/workflows/containers.yml
@@ -0,0 +1,45 @@
+name: containers
+
+on:
+ push:
+ branches:
+ - dev
+ paths:
+ - packages/containers/**
+ - .github/workflows/containers.yml
+ - package.json
+ workflow_dispatch:
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ build:
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ env:
+ REGISTRY: ghcr.io/${{ github.repository_owner }}
+ TAG: "24.04"
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: ./.github/actions/setup-bun
+
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Login to GHCR
+ uses: docker/login-action@v3
+ with:
+ registry: ghcr.io
+ username: ${{ github.repository_owner }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push containers
+ run: bun ./packages/containers/script/build.ts --push
+ env:
+ REGISTRY: ${{ env.REGISTRY }}
+ TAG: ${{ env.TAG }}
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/deploy.yml b/.github/workflows/deploy.yml
index 25466a63e0..c08d7edf3b 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -21,6 +21,15 @@ jobs:
with:
node-version: "24"
+ # Workaround for Pulumi version conflict:
+ # GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag
+ # from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065).
+ # SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict.
+ # Removing the system language plugin forces SST to use its bundled compatible version.
+ # TODO: Remove when sst supports Pulumi >3.210.0
+ - name: Fix Pulumi version conflict
+ run: sudo rm -f /usr/local/bin/pulumi-language-nodejs
+
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
diff --git a/.github/workflows/docs-locale-sync.yml b/.github/workflows/docs-locale-sync.yml
new file mode 100644
index 0000000000..8cd0cc52e2
--- /dev/null
+++ b/.github/workflows/docs-locale-sync.yml
@@ -0,0 +1,85 @@
+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. You MUST use the Task tool for translation work and launch subagents with subagent_type `translator` (defined in .opencode/agent/translator.md).
+ 3. Do not translate directly in the primary agent. Use translator subagent output as the source for locale text updates.
+ 4. Run translator subagent Task calls in parallel whenever file/locale translation work is independent.
+ 5. Preserve frontmatter keys, internal links, code blocks, and existing locale-specific metadata unless the English change requires an update.
+ 6. Keep locale docs structure aligned with their corresponding English pages.
+ 7. Do not modify English source docs in packages/web/src/content/docs/*.mdx.
+ 8. 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/generate.yml b/.github/workflows/generate.yml
index cbbab479e1..706ab2989e 100644
--- a/.github/workflows/generate.yml
+++ b/.github/workflows/generate.yml
@@ -4,8 +4,6 @@ on:
push:
branches:
- dev
- pull_request:
- workflow_dispatch:
jobs:
generate:
@@ -16,14 +14,17 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- ref: ${{ github.event.pull_request.head.ref || github.ref_name }}
- 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: Generate
run: ./script/generate.ts
@@ -33,10 +34,8 @@ jobs:
echo "No changes to commit"
exit 0
fi
- git config --local user.email "action@github.com"
- git config --local user.name "GitHub Action"
git add -A
- git commit -m "chore: generate"
+ git commit -m "chore: generate" --allow-empty
git push origin HEAD:${{ github.ref_name }} --no-verify
# if ! git push origin HEAD:${{ github.event.pull_request.head.ref || github.ref_name }} --no-verify; then
# echo ""
diff --git a/.github/workflows/nix-hashes.yml b/.github/workflows/nix-hashes.yml
index 63ab561887..894dbf47b1 100644
--- a/.github/workflows/nix-hashes.yml
+++ b/.github/workflows/nix-hashes.yml
@@ -6,133 +6,143 @@ permissions:
on:
workflow_dispatch:
push:
+ branches: [dev]
paths:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
- "flake.lock"
- - ".github/workflows/nix-hashes.yml"
- pull_request:
- paths:
- - "bun.lock"
- - "package.json"
- - "packages/*/package.json"
- - "flake.lock"
+ - "nix/node_modules.nix"
+ - "nix/scripts/**"
+ - "patches/**"
- ".github/workflows/nix-hashes.yml"
jobs:
- nix-hashes:
- if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
- runs-on: blacksmith-4vcpu-ubuntu-2404
- env:
- TITLE: node_modules hashes
+ # Native runners required: bun install cross-compilation flags (--os/--cpu)
+ # do not produce byte-identical node_modules as native installs.
+ compute-hash:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - system: x86_64-linux
+ runner: blacksmith-4vcpu-ubuntu-2404
+ - system: aarch64-linux
+ runner: blacksmith-4vcpu-ubuntu-2404-arm
+ - system: x86_64-darwin
+ runner: macos-15-intel
+ - system: aarch64-darwin
+ runner: macos-latest
+ runs-on: ${{ matrix.runner }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
- with:
- token: ${{ secrets.GITHUB_TOKEN }}
- fetch-depth: 0
- ref: ${{ github.head_ref || github.ref_name }}
- repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- - name: Configure git
- run: |
- git config --global user.email "action@github.com"
- git config --global user.name "Github Action"
-
- - name: Pull latest changes
+ - name: Compute node_modules hash
+ id: hash
env:
- TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
- run: |
- BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
- git pull --rebase --autostash origin "$BRANCH"
-
- - name: Compute all node_modules hashes
+ SYSTEM: ${{ matrix.system }}
run: |
set -euo pipefail
- HASH_FILE="nix/hashes.json"
- SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
+ BUILD_LOG=$(mktemp)
+ trap 'rm -f "$BUILD_LOG"' EXIT
- if [ ! -f "$HASH_FILE" ]; then
- mkdir -p "$(dirname "$HASH_FILE")"
- echo '{"nodeModules":{}}' > "$HASH_FILE"
+ # Build with fakeHash to trigger hash mismatch and reveal correct hash
+ nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
+
+ # Extract hash from build log with portability
+ HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
+
+ if [ -z "$HASH" ]; then
+ echo "::error::Failed to compute hash for ${SYSTEM}"
+ cat "$BUILD_LOG"
+ exit 1
fi
- for SYSTEM in $SYSTEMS; do
- echo "Computing hash for ${SYSTEM}..."
- BUILD_LOG=$(mktemp)
- trap 'rm -f "$BUILD_LOG"' EXIT
+ echo "$HASH" > hash.txt
+ echo "Computed hash for ${SYSTEM}: $HASH"
- # The updater derivations use fakeHash, so they will fail and reveal the correct hash
- UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules"
+ - name: Upload hash
+ uses: actions/upload-artifact@v4
+ with:
+ name: hash-${{ matrix.system }}
+ path: hash.txt
+ retention-days: 1
- nix build "$UPDATER_ATTR" --no-link 2>&1 | tee "$BUILD_LOG" || true
+ update-hashes:
+ needs: compute-hash
+ if: github.event_name != 'pull_request'
+ runs-on: blacksmith-4vcpu-ubuntu-2404
- CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ persist-credentials: false
+ fetch-depth: 0
+ ref: ${{ github.ref_name }}
- if [ -z "$CORRECT_HASH" ]; then
- CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
- fi
+ - name: Setup git committer
+ uses: ./.github/actions/setup-git-committer
+ with:
+ opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
+ opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- if [ -z "$CORRECT_HASH" ]; then
- echo "Failed to determine correct node_modules hash for ${SYSTEM}."
- cat "$BUILD_LOG"
- exit 1
- fi
+ - name: Pull latest changes
+ run: |
+ git pull --rebase --autostash origin "$GITHUB_REF_NAME"
- echo " ${SYSTEM}: ${CORRECT_HASH}"
- jq --arg sys "$SYSTEM" --arg h "$CORRECT_HASH" \
- '.nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp"
- mv "${HASH_FILE}.tmp" "$HASH_FILE"
- done
+ - name: Download hash artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: hashes
+ pattern: hash-*
- echo "All hashes computed:"
- cat "$HASH_FILE"
-
- - name: Commit ${{ env.TITLE }} changes
- env:
- TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
+ - name: Update hashes.json
run: |
set -euo pipefail
HASH_FILE="nix/hashes.json"
- echo "Checking for changes..."
- summarize() {
- local status="$1"
- {
- echo "### Nix $TITLE"
- echo ""
- echo "- ref: ${GITHUB_REF_NAME}"
- echo "- status: ${status}"
- } >> "$GITHUB_STEP_SUMMARY"
- if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
- echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
+ [ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
+
+ for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
+ FILE="hashes/hash-${SYSTEM}/hash.txt"
+ if [ -f "$FILE" ]; then
+ HASH="$(tr -d '[:space:]' < "$FILE")"
+ echo "${SYSTEM}: ${HASH}"
+ jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json
+ mv tmp.json "$HASH_FILE"
+ else
+ echo "::warning::Missing hash for ${SYSTEM}"
fi
- echo "" >> "$GITHUB_STEP_SUMMARY"
- }
+ done
- FILES=("$HASH_FILE")
- STATUS="$(git status --short -- "${FILES[@]}" || true)"
- if [ -z "$STATUS" ]; then
- echo "No changes detected."
- summarize "no changes"
+ cat "$HASH_FILE"
+
+ - name: Commit changes
+ run: |
+ set -euo pipefail
+
+ HASH_FILE="nix/hashes.json"
+
+ if [ -z "$(git status --short -- "$HASH_FILE")" ]; then
+ echo "No changes to commit"
+ echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
+ echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY"
exit 0
fi
- echo "Changes detected:"
- echo "$STATUS"
- git add "${FILES[@]}"
+ git add "$HASH_FILE"
git commit -m "chore: update nix node_modules hashes"
- BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
- git pull --rebase --autostash origin "$BRANCH"
- git push origin HEAD:"$BRANCH"
- echo "Changes pushed successfully"
+ git pull --rebase --autostash origin "$GITHUB_REF_NAME"
+ git push origin HEAD:"$GITHUB_REF_NAME"
- summarize "committed $(git rev-parse --short HEAD)"
+ echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
+ echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index a169d90af6..a1b492258b 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -36,7 +36,15 @@ jobs:
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
+ with:
+ fetch-depth: 0
+
- uses: ./.github/actions/setup-bun
+
+ - name: Install OpenCode
+ if: inputs.bump || inputs.version
+ run: bun i -g opencode-ai
+
- id: version
run: |
./script/version.ts
@@ -44,6 +52,7 @@ jobs:
GH_TOKEN: ${{ github.token }}
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_VERSION: ${{ inputs.version }}
+ OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
outputs:
version: ${{ steps.version.outputs.version }}
release: ${{ steps.version.outputs.release }}
@@ -94,7 +103,7 @@ jobs:
target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404
target: x86_64-unknown-linux-gnu
- - host: blacksmith-4vcpu-ubuntu-2404-arm
+ - host: blacksmith-8vcpu-ubuntu-2404-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.settings.host }}
steps:
@@ -124,6 +133,15 @@ jobs:
- uses: ./.github/actions/setup-bun
+ - name: Cache apt packages
+ if: contains(matrix.settings.host, 'ubuntu')
+ uses: actions/cache@v4
+ with:
+ path: /var/cache/apt/archives
+ key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-${{ hashFiles('.github/workflows/publish.yml') }}
+ restore-keys: |
+ ${{ runner.os }}-${{ matrix.settings.target }}-apt-
+
- name: install dependencies (ubuntu only)
if: contains(matrix.settings.host, 'ubuntu')
run: |
@@ -145,8 +163,8 @@ jobs:
cd packages/desktop
bun ./scripts/prepare.ts
env:
- OPENCODE_VERSION: ${{ needs.publish.outputs.version }}
- GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
+ OPENCODE_VERSION: ${{ needs.version.outputs.version }}
+ GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -188,17 +206,13 @@ jobs:
needs:
- version
- build-cli
- # - build-tauri
+ - build-tauri
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- uses: actions/checkout@v3
- uses: ./.github/actions/setup-bun
- - name: Install OpenCode
- if: inputs.bump || inputs.version
- run: bun i -g opencode-ai
-
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
@@ -217,17 +231,26 @@ jobs:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- - name: Setup Git Identity
- run: |
- git config --global user.email "opencode@sst.dev"
- git config --global user.name "opencode"
- git remote set-url origin https://x-access-token:${{ secrets.SST_GITHUB_TOKEN }}@github.com/${{ github.repository }}
+ - 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: actions/download-artifact@v4
with:
name: opencode-cli
path: packages/opencode/dist
+ - name: Cache apt packages (AUR)
+ uses: actions/cache@v4
+ with:
+ path: /var/cache/apt/archives
+ key: ${{ runner.os }}-apt-aur-${{ hashFiles('.github/workflows/publish.yml') }}
+ restore-keys: |
+ ${{ runner.os }}-apt-aur-
+
- name: Setup SSH for AUR
run: |
sudo apt-get update
@@ -244,6 +267,5 @@ jobs:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
AUR_KEY: ${{ secrets.AUR_KEY }}
- GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
- OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
+ GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
NPM_CONFIG_PROVENANCE: false
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index f6cbb16edc..647b9e1886 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,11 +1,38 @@
name: test
on:
+ push:
+ branches:
+ - dev
pull_request:
workflow_dispatch:
jobs:
- test:
- name: test (${{ matrix.settings.name }})
+ unit:
+ name: unit (linux)
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ defaults:
+ run:
+ shell: bash
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ token: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Setup Bun
+ uses: ./.github/actions/setup-bun
+
+ - name: Configure git identity
+ run: |
+ git config --global user.email "bot@opencode.ai"
+ git config --global user.name "opencode"
+
+ - name: Run unit tests
+ run: bun turbo test
+
+ e2e:
+ name: e2e (${{ matrix.settings.name }})
+ needs: unit
strategy:
fail-fast: false
matrix:
@@ -13,17 +40,12 @@ jobs:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
playwright: bunx playwright install --with-deps
- workdir: .
- command: |
- git config --global user.email "bot@opencode.ai"
- git config --global user.name "opencode"
- bun turbo test
- name: windows
- host: windows-latest
+ host: blacksmith-4vcpu-windows-2025
playwright: bunx playwright install
- workdir: packages/app
- command: bun test:e2e:local
runs-on: ${{ matrix.settings.host }}
+ env:
+ PLAYWRIGHT_BROWSERS_PATH: 0
defaults:
run:
shell: bash
@@ -40,87 +62,10 @@ jobs:
working-directory: packages/app
run: ${{ matrix.settings.playwright }}
- - name: Set OS-specific paths
- run: |
- if [ "${{ runner.os }}" = "Windows" ]; then
- printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}\\opencode-e2e" >> "$GITHUB_ENV"
- printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}\\opencode-e2e\\home" >> "$GITHUB_ENV"
- printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}\\opencode-e2e\\share" >> "$GITHUB_ENV"
- printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}\\opencode-e2e\\cache" >> "$GITHUB_ENV"
- printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}\\opencode-e2e\\config" >> "$GITHUB_ENV"
- printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}\\opencode-e2e\\state" >> "$GITHUB_ENV"
- else
- printf '%s\n' "OPENCODE_E2E_ROOT=${{ runner.temp }}/opencode-e2e" >> "$GITHUB_ENV"
- printf '%s\n' "OPENCODE_TEST_HOME=${{ runner.temp }}/opencode-e2e/home" >> "$GITHUB_ENV"
- printf '%s\n' "XDG_DATA_HOME=${{ runner.temp }}/opencode-e2e/share" >> "$GITHUB_ENV"
- printf '%s\n' "XDG_CACHE_HOME=${{ runner.temp }}/opencode-e2e/cache" >> "$GITHUB_ENV"
- printf '%s\n' "XDG_CONFIG_HOME=${{ runner.temp }}/opencode-e2e/config" >> "$GITHUB_ENV"
- printf '%s\n' "XDG_STATE_HOME=${{ runner.temp }}/opencode-e2e/state" >> "$GITHUB_ENV"
- fi
-
- - name: Seed opencode data
- if: matrix.settings.name != 'windows'
- working-directory: packages/opencode
- run: bun script/seed-e2e.ts
- env:
- OPENCODE_DISABLE_SHARE: "true"
- OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
- OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
- OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
- OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
- XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
- XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
- XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
- XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
- OPENCODE_E2E_PROJECT_DIR: ${{ github.workspace }}
- OPENCODE_E2E_SESSION_TITLE: "E2E Session"
- OPENCODE_E2E_MESSAGE: "Seeded for UI e2e"
- OPENCODE_E2E_MODEL: "opencode/gpt-5-nano"
-
- - name: Run opencode server
- if: matrix.settings.name != 'windows'
- working-directory: packages/opencode
- run: bun dev -- --print-logs --log-level WARN serve --port 4096 --hostname 127.0.0.1 &
- env:
- OPENCODE_DISABLE_SHARE: "true"
- OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
- OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
- OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
- OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
- XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
- XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
- XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
- XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
- OPENCODE_CLIENT: "app"
-
- - name: Wait for opencode server
- if: matrix.settings.name != 'windows'
- run: |
- for i in {1..120}; do
- curl -fsS "http://127.0.0.1:4096/global/health" > /dev/null && exit 0
- sleep 1
- done
- exit 1
-
- - name: run
- working-directory: ${{ matrix.settings.workdir }}
- run: ${{ matrix.settings.command }}
+ - name: Run app e2e tests
+ run: bun --cwd packages/app test:e2e:local
env:
CI: true
- OPENCODE_DISABLE_SHARE: "true"
- OPENCODE_DISABLE_LSP_DOWNLOAD: "true"
- OPENCODE_DISABLE_DEFAULT_PLUGINS: "true"
- OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true"
- OPENCODE_TEST_HOME: ${{ env.OPENCODE_TEST_HOME }}
- XDG_DATA_HOME: ${{ env.XDG_DATA_HOME }}
- XDG_CACHE_HOME: ${{ env.XDG_CACHE_HOME }}
- XDG_CONFIG_HOME: ${{ env.XDG_CONFIG_HOME }}
- XDG_STATE_HOME: ${{ env.XDG_STATE_HOME }}
- PLAYWRIGHT_SERVER_HOST: "127.0.0.1"
- PLAYWRIGHT_SERVER_PORT: "4096"
- VITE_OPENCODE_SERVER_HOST: "127.0.0.1"
- VITE_OPENCODE_SERVER_PORT: "4096"
- OPENCODE_CLIENT: "app"
timeout-minutes: 30
- name: Upload Playwright artifacts
@@ -133,3 +78,18 @@ jobs:
path: |
packages/app/e2e/test-results
packages/app/e2e/playwright-report
+
+ required:
+ name: test (linux)
+ runs-on: blacksmith-4vcpu-ubuntu-2404
+ needs:
+ - unit
+ - e2e
+ if: always()
+ steps:
+ - name: Verify upstream test jobs passed
+ run: |
+ echo "unit=${{ needs.unit.result }}"
+ echo "e2e=${{ needs.e2e.result }}"
+ test "${{ needs.unit.result }}" = "success"
+ test "${{ needs.e2e.result }}" = "success"
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
index 011e23f5f6..b247d24b40 100644
--- a/.github/workflows/typecheck.yml
+++ b/.github/workflows/typecheck.yml
@@ -1,6 +1,8 @@
name: typecheck
on:
+ push:
+ branches: [dev]
pull_request:
branches: [dev]
workflow_dispatch:
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/.gitignore b/.gitignore
index 78a77f8198..ce3d19e778 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ node_modules
.env
.idea
.vscode
+.codex
*~
playground
tmp
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/command/commit.md b/.opencode/command/commit.md
index 8260029195..d8a420b173 100644
--- a/.opencode/command/commit.md
+++ b/.opencode/command/commit.md
@@ -1,6 +1,6 @@
---
description: git commit and push
-model: opencode/glm-4.7
+model: opencode/kimi-k2.5
subtask: true
---
diff --git a/.opencode/opencode.jsonc b/.opencode/opencode.jsonc
index c3f0b7070d..e2350c907b 100644
--- a/.opencode/opencode.jsonc
+++ b/.opencode/opencode.jsonc
@@ -1,6 +1,5 @@
{
"$schema": "https://opencode.ai/config.json",
- // "plugin": ["opencode-openai-codex-auth"],
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
@@ -9,12 +8,7 @@
"options": {},
},
},
- "mcp": {
- "context7": {
- "type": "remote",
- "url": "https://mcp.context7.com/mcp",
- },
- },
+ "mcp": {},
"tools": {
"github-triage": false,
"github-pr-search": false,
diff --git a/.opencode/skill/bun-file-io/SKILL.md b/.opencode/skill/bun-file-io/SKILL.md
index ea39507d26..f78de33094 100644
--- a/.opencode/skill/bun-file-io/SKILL.md
+++ b/.opencode/skill/bun-file-io/SKILL.md
@@ -32,6 +32,9 @@ description: Use this when you are working on file operations like reading, writ
- Decode tool stderr with `Bun.readableStreamToText`.
- For large writes, use `Bun.write(Bun.file(path), text)`.
+NOTE: Bun.file(...).exists() will return `false` if the value is a directory.
+Use Filesystem.exists(...) instead if path can be file or directory
+
## Quick checklist
- Use Bun APIs first.
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/.prettierignore b/.prettierignore
index aa3a7ce238..a2a2776596 100644
--- a/.prettierignore
+++ b/.prettierignore
@@ -1 +1,2 @@
-sst-env.d.ts
\ No newline at end of file
+sst-env.d.ts
+packages/desktop/src/bindings.ts
diff --git a/AGENTS.md b/AGENTS.md
index c3f8e50d05..d51134c0e2 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,81 +1,112 @@
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- The default branch in this repo is `dev`.
+- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
+- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
## Style Guide
+### General Principles
+
- Keep things in one function unless composable or reusable
-- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
+- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
-### Avoid let statements
+### Naming
-We don't like `let` statements, especially combined with if/else statements.
-Prefer `const`.
-
-Good:
+Prefer single word names for variables and functions. Only use multiple words if necessary.
```ts
-const foo = condition ? 1 : 2
+// Good
+const foo = 1
+function journal(dir: string) {}
+
+// Bad
+const fooBar = 1
+function prepareJournal(dir: string) {}
```
-Bad:
+Reduce total variable count by inlining when a value is only used once.
```ts
-let foo
+// Good
+const journal = await Bun.file(path.join(dir, "journal.json")).json()
+// Bad
+const journalPath = path.join(dir, "journal.json")
+const journal = await Bun.file(journalPath).json()
+```
+
+### Destructuring
+
+Avoid unnecessary destructuring. Use dot notation to preserve context.
+
+```ts
+// Good
+obj.a
+obj.b
+
+// Bad
+const { a, b } = obj
+```
+
+### Variables
+
+Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
+
+```ts
+// Good
+const foo = condition ? 1 : 2
+
+// Bad
+let foo
if (condition) foo = 1
else foo = 2
```
-### Avoid else statements
+### Control Flow
-Prefer early returns or using an `iife` to avoid else statements.
-
-Good:
+Avoid `else` statements. Prefer early returns.
```ts
+// Good
function foo() {
if (condition) return 1
return 2
}
-```
-Bad:
-
-```ts
+// Bad
function foo() {
if (condition) return 1
else return 2
}
```
-### Prefer single word naming
+### Schema Definitions (Drizzle)
-Try your best to find a single word name for your variables, functions, etc.
-Only use multiple words if you cannot.
-
-Good:
+Use snake_case for field names so column names don't need to be redefined as strings.
```ts
-const foo = 1
-const bar = 2
-const baz = 3
-```
+// Good
+const table = sqliteTable("session", {
+ id: text().primaryKey(),
+ project_id: text().notNull(),
+ created_at: integer().notNull(),
+})
-Bad:
-
-```ts
-const fooBar = 1
-const barBaz = 2
-const bazFoo = 3
+// Bad
+const table = sqliteTable("session", {
+ id: text("id").primaryKey(),
+ projectID: text("project_id").notNull(),
+ createdAt: integer("created_at").notNull(),
+})
```
## Testing
-You MUST avoid using `mocks` as much as possible.
-Tests MUST test actual implementation, do not duplicate logic into a test.
+- Avoid mocks as much as possible
+- Test actual implementation, do not duplicate logic into tests
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/README.ar.md b/README.ar.md
index 2abceb300d..4c8ac5fcc3 100644
--- a/README.ar.md
+++ b/README.ar.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.br.md b/README.br.md
index 6a58241c98..ee5e85fd44 100644
--- a/README.br.md
+++ b/README.br.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.bs.md b/README.bs.md
new file mode 100644
index 0000000000..56a1e72fb6
--- /dev/null
+++ b/README.bs.md
@@ -0,0 +1,136 @@
+
+
+
+
+
+
+
+
+
+OpenCode je open source AI agent za programiranje.
+
+
+
+
+
+
+
+ English |
+ 简体中文 |
+ 繁體中文 |
+ 한국어 |
+ Deutsch |
+ Español |
+ Français |
+ Italiano |
+ Dansk |
+ 日本語 |
+ Polski |
+ Русский |
+ Bosanski |
+ العربية |
+ Norsk |
+ Português (Brasil) |
+ ไทย |
+ Türkçe
+
+
+[](https://opencode.ai)
+
+---
+
+### Instalacija
+
+```bash
+# YOLO
+curl -fsSL https://opencode.ai/install | bash
+
+# Package manageri
+npm i -g opencode-ai@latest # ili bun/pnpm/yarn
+scoop install opencode # Windows
+choco install opencode # Windows
+brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
+brew install opencode # macOS i Linux (zvanična brew formula, rjeđe se ažurira)
+paru -S opencode-bin # Arch Linux
+mise use -g opencode # Bilo koji OS
+nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji dev branch
+```
+
+> [!TIP]
+> Ukloni verzije starije od 0.1.x prije instalacije.
+
+### Desktop aplikacija (BETA)
+
+OpenCode je dostupan i kao desktop aplikacija. Preuzmi je direktno sa [stranice izdanja](https://github.com/anomalyco/opencode/releases) ili sa [opencode.ai/download](https://opencode.ai/download).
+
+| Platforma | Preuzimanje |
+| --------------------- | ------------------------------------- |
+| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
+| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
+| Windows | `opencode-desktop-windows-x64.exe` |
+| Linux | `.deb`, `.rpm`, ili AppImage |
+
+```bash
+# macOS (Homebrew)
+brew install --cask opencode-desktop
+# Windows (Scoop)
+scoop bucket add extras; scoop install extras/opencode-desktop
+```
+
+#### Instalacijski direktorij
+
+Instalacijska skripta koristi sljedeći redoslijed prioriteta za putanju instalacije:
+
+1. `$OPENCODE_INSTALL_DIR` - Prilagođeni instalacijski direktorij
+2. `$XDG_BIN_DIR` - Putanja usklađena sa XDG Base Directory specifikacijom
+3. `$HOME/bin` - Standardni korisnički bin direktorij (ako postoji ili se može kreirati)
+4. `$HOME/.opencode/bin` - Podrazumijevana rezervna lokacija
+
+```bash
+# Primjeri
+OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
+XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
+```
+
+### Agenti
+
+OpenCode uključuje dva ugrađena agenta između kojih možeš prebacivati tasterom `Tab`.
+
+- **build** - Podrazumijevani agent sa punim pristupom za razvoj
+- **plan** - Agent samo za čitanje za analizu i istraživanje koda
+ - Podrazumijevano zabranjuje izmjene datoteka
+ - Traži dozvolu prije pokretanja bash komandi
+ - Idealan za istraživanje nepoznatih codebase-ova ili planiranje izmjena
+
+Uključen je i **general** pod-agent za složene pretrage i višekoračne zadatke.
+Koristi se interno i može se pozvati pomoću `@general` u porukama.
+
+Saznaj više o [agentima](https://opencode.ai/docs/agents).
+
+### Dokumentacija
+
+Za više informacija o konfiguraciji OpenCode-a, [**pogledaj dokumentaciju**](https://opencode.ai/docs).
+
+### Doprinosi
+
+Ako želiš doprinositi OpenCode-u, pročitaj [upute za doprinošenje](./CONTRIBUTING.md) prije slanja pull requesta.
+
+### Gradnja na OpenCode-u
+
+Ako radiš na projektu koji je povezan s OpenCode-om i koristi "opencode" kao dio naziva, npr. "opencode-dashboard" ili "opencode-mobile", dodaj napomenu u svoj README da projekat nije napravio OpenCode tim i da nije povezan s nama.
+
+### FAQ
+
+#### Po čemu se razlikuje od Claude Code-a?
+
+Po mogućnostima je vrlo sličan Claude Code-u. Ključne razlike su:
+
+- 100% open source
+- Nije vezan za jednog provajdera. Iako preporučujemo modele koje nudimo kroz [OpenCode Zen](https://opencode.ai/zen), OpenCode možeš koristiti s Claude, OpenAI, Google ili čak lokalnim modelima. Kako modeli napreduju, razlike među njima će se smanjivati, a cijene padati, zato je nezavisnost od provajdera važna.
+- LSP podrška odmah po instalaciji
+- Fokus na TUI. OpenCode grade neovim korisnici i kreatori [terminal.shop](https://terminal.shop); pomjeraćemo granice onoga što je moguće u terminalu.
+- Klijent/server arhitektura. To, recimo, omogućava da OpenCode radi na tvom računaru dok ga daljinski koristiš iz mobilne aplikacije, što znači da je TUI frontend samo jedan od mogućih klijenata.
+
+---
+
+**Pridruži se našoj zajednici** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
diff --git a/README.da.md b/README.da.md
index 7e7dda42a8..79928fd944 100644
--- a/README.da.md
+++ b/README.da.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.de.md b/README.de.md
index c949dd00f4..ccb3ad07dc 100644
--- a/README.de.md
+++ b/README.de.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.es.md b/README.es.md
index 3e3797ed30..e5a7d8e8dd 100644
--- a/README.es.md
+++ b/README.es.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.fr.md b/README.fr.md
index 00133b1e9f..5436009903 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.it.md b/README.it.md
index 89692a3668..cbc8a5f6d2 100644
--- a/README.it.md
+++ b/README.it.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.ja.md b/README.ja.md
index 5f3a9e189e..8827efae88 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.ko.md b/README.ko.md
index 213f46bfe7..806dc642c1 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.md b/README.md
index 7e20902547..2cd1e2aa01 100644
--- a/README.md
+++ b/README.md
@@ -27,10 +27,12 @@
日本語 |
Polski |
Русский |
+ Bosanski |
العربية |
Norsk |
Português (Brasil) |
- ไทย
+ ไทย |
+ Türkçe
[](https://opencode.ai)
@@ -81,7 +83,7 @@ The install script respects the following priority order for the installation pa
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path
-3. `$HOME/bin` - Standard user binary directory (if exists or can be created)
+3. `$HOME/bin` - Standard user binary directory (if it exists or can be created)
4. `$HOME/.opencode/bin` - Default fallback
```bash
@@ -94,20 +96,20 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
OpenCode includes two built-in agents you can switch between with the `Tab` key.
-- **build** - Default, full access agent for development work
+- **build** - Default, full-access agent for development work
- **plan** - Read-only agent for analysis and code exploration
- Denies file edits by default
- Asks permission before running bash commands
- Ideal for exploring unfamiliar codebases or planning changes
-Also, included is a **general** subagent for complex searches and multistep tasks.
+Also included is a **general** subagent for complex searches and multistep tasks.
This is used internally and can be invoked using `@general` in messages.
Learn more about [agents](https://opencode.ai/docs/agents).
### Documentation
-For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs).
+For more info on how to configure OpenCode, [**head over to our docs**](https://opencode.ai/docs).
### Contributing
@@ -115,7 +117,7 @@ If you're interested in contributing to OpenCode, please read our [contributing
### Building on OpenCode
-If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
+If you are working on a project that's related to OpenCode and is using "opencode" as part of its name, for example "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
### FAQ
@@ -124,10 +126,10 @@ If you are working on a project that's related to OpenCode and is using "opencod
It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source
-- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
-- Out of the box LSP support
+- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
+- Out-of-the-box LSP support
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
-- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
+- A client/server architecture. This, for example, can allow OpenCode to run on your computer while you drive it remotely from a mobile app, meaning that the TUI frontend is just one of the possible clients.
---
diff --git a/README.no.md b/README.no.md
index 44371df5ed..90b631fef2 100644
--- a/README.no.md
+++ b/README.no.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.pl.md b/README.pl.md
index b183cd6245..ae653a7fa0 100644
--- a/README.pl.md
+++ b/README.pl.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.ru.md b/README.ru.md
index c192036b54..cf15c6ebce 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.th.md b/README.th.md
index a4b306a6c4..4077abc011 100644
--- a/README.th.md
+++ b/README.th.md
@@ -30,7 +30,8 @@
العربية |
Norsk |
Português (Brasil) |
- ไทย
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.tr.md b/README.tr.md
new file mode 100644
index 0000000000..e3055e7a99
--- /dev/null
+++ b/README.tr.md
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+Açık kaynaklı yapay zeka kodlama asistanı.
+
+
+
+
+
+
+
+ English |
+ 简体中文 |
+ 繁體中文 |
+ 한국어 |
+ Deutsch |
+ Español |
+ Français |
+ Italiano |
+ Dansk |
+ 日本語 |
+ Polski |
+ Русский |
+ العربية |
+ Norsk |
+ Português (Brasil) |
+ ไทย |
+ Türkçe
+
+
+[](https://opencode.ai)
+
+---
+
+### Kurulum
+
+```bash
+# YOLO
+curl -fsSL https://opencode.ai/install | bash
+
+# Paket yöneticileri
+npm i -g opencode-ai@latest # veya bun/pnpm/yarn
+scoop install opencode # Windows
+choco install opencode # Windows
+brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
+brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
+paru -S opencode-bin # Arch Linux
+mise use -g opencode # Tüm işletim sistemleri
+nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
+```
+
+> [!TIP]
+> Kurulumdan önce 0.1.x'ten eski sürümleri kaldırın.
+
+### Masaüstü Uygulaması (BETA)
+
+OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz.
+
+| Platform | İndirme |
+| --------------------- | ------------------------------------- |
+| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
+| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
+| Windows | `opencode-desktop-windows-x64.exe` |
+| Linux | `.deb`, `.rpm` veya AppImage |
+
+```bash
+# macOS (Homebrew)
+brew install --cask opencode-desktop
+# Windows (Scoop)
+scoop bucket add extras; scoop install extras/opencode-desktop
+```
+
+#### Kurulum Dizini (Installation Directory)
+
+Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder:
+
+1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini
+2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol
+3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa)
+4. `$HOME/.opencode/bin` - Varsayılan yedek konum
+
+```bash
+# Örnekler
+OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
+XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
+```
+
+### Ajanlar
+
+OpenCode, `Tab` tuşuyla aralarında geçiş yapabileceğiniz iki yerleşik (built-in) ajan içerir.
+
+- **build** - Varsayılan, geliştirme çalışmaları için tam erişimli ajan
+- **plan** - Analiz ve kod keşfi için salt okunur ajan
+ - Varsayılan olarak dosya düzenlemelerini reddeder
+ - Bash komutlarını çalıştırmadan önce izin ister
+ - Tanımadığınız kod tabanlarını keşfetmek veya değişiklikleri planlamak için ideal
+
+Ayrıca, karmaşık aramalar ve çok adımlı görevler için bir **genel** alt ajan bulunmaktadır.
+Bu dahili olarak kullanılır ve mesajlarda `@general` ile çağrılabilir.
+
+[Ajanlar](https://opencode.ai/docs/agents) hakkında daha fazla bilgi edinin.
+
+### Dokümantasyon
+
+OpenCode'u nasıl yapılandıracağınız hakkında daha fazla bilgi için [**dokümantasyonumuza göz atın**](https://opencode.ai/docs).
+
+### Katkıda Bulunma
+
+OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermeden önce [katkıda bulunma dokümanlarımızı](./CONTRIBUTING.md) okuyun.
+
+### OpenCode Üzerine Geliştirme
+
+OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
+
+### SSS
+
+#### Bu Claude Code'dan nasıl farklı?
+
+Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
+
+- %100 açık kaynak
+- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
+- Kurulum gerektirmeyen hazır LSP desteği
+- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
+- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
+
+---
+
+**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
diff --git a/README.zh.md b/README.zh.md
index 9ebbe8ce93..6970fe34ef 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.zht.md b/README.zht.md
index 298b5b35ac..a045f45490 100644
--- a/README.zht.md
+++ b/README.zht.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
@@ -124,7 +126,7 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
- 100% 開源。
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
- 內建 LSP (語言伺服器協定) 支援。
-- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造;我們將不斷挑戰終端機介面的極限。
+- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造。我們將不斷挑戰終端機介面的極限。
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
---
diff --git a/bun.lock b/bun.lock
index fa4d84e4ec..1da47fa9fb 100644
--- a/bun.lock
+++ b/bun.lock
@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -44,7 +44,7 @@
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
- "ghostty-web": "0.3.0",
+ "ghostty-web": "0.4.0",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -182,13 +182,15 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/i18n": "2.2.1",
"@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",
@@ -213,7 +215,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -242,7 +244,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -258,35 +260,36 @@
},
"packages/opencode": {
"name": "opencode",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
- "@agentclientprotocol/sdk": "0.13.0",
- "@ai-sdk/amazon-bedrock": "3.0.73",
- "@ai-sdk/anthropic": "2.0.57",
+ "@agentclientprotocol/sdk": "0.14.1",
+ "@ai-sdk/amazon-bedrock": "3.0.74",
+ "@ai-sdk/anthropic": "2.0.58",
"@ai-sdk/azure": "2.0.91",
- "@ai-sdk/cerebras": "1.0.34",
+ "@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cohere": "2.0.22",
- "@ai-sdk/deepinfra": "1.0.31",
- "@ai-sdk/gateway": "2.0.25",
+ "@ai-sdk/deepinfra": "1.0.33",
+ "@ai-sdk/gateway": "2.0.30",
"@ai-sdk/google": "2.0.52",
- "@ai-sdk/google-vertex": "3.0.97",
+ "@ai-sdk/google-vertex": "3.0.98",
"@ai-sdk/groq": "2.0.34",
"@ai-sdk/mistral": "2.0.27",
"@ai-sdk/openai": "2.0.89",
- "@ai-sdk/openai-compatible": "1.0.30",
+ "@ai-sdk/openai-compatible": "1.0.32",
"@ai-sdk/perplexity": "2.0.23",
"@ai-sdk/provider": "2.0.1",
"@ai-sdk/provider-utils": "3.0.20",
- "@ai-sdk/togetherai": "1.0.31",
- "@ai-sdk/vercel": "1.0.31",
+ "@ai-sdk/togetherai": "1.0.34",
+ "@ai-sdk/vercel": "1.0.33",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
- "@gitlab/gitlab-ai-provider": "3.3.1",
+ "@gitlab/gitlab-ai-provider": "3.5.0",
+ "@gitlab/opencode-gitlab-auth": "1.3.2",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -297,9 +300,9 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
- "@openrouter/ai-sdk-provider": "1.5.2",
- "@opentui/core": "0.1.75",
- "@opentui/solid": "0.1.75",
+ "@openrouter/ai-sdk-provider": "1.5.4",
+ "@opentui/core": "0.1.79",
+ "@opentui/solid": "0.1.79",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -307,8 +310,9 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
+ "ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
- "bun-pty": "0.4.4",
+ "bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
@@ -362,7 +366,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -382,7 +386,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -393,7 +397,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -406,7 +410,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -448,7 +452,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"zod": "catalog:",
},
@@ -459,14 +463,14 @@
},
"packages/web": {
"name": "@opencode-ai/web",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
"@astrojs/solid-js": "5.1.0",
"@astrojs/starlight": "0.34.3",
"@fontsource/ibm-plex-mono": "5.2.5",
- "@shikijs/transformers": "3.4.2",
+ "@shikijs/transformers": "3.20.0",
"@types/luxon": "catalog:",
"ai": "catalog:",
"astro": "5.7.13",
@@ -481,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:",
@@ -495,7 +501,7 @@
"tree-sitter-bash",
],
"patchedDependencies": {
- "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch",
+ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
},
"overrides": {
"@types/bun": "catalog:",
@@ -516,12 +522,12 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
- "@types/bun": "1.3.5",
+ "@types/bun": "1.3.9",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
- "ai": "5.0.119",
+ "ai": "5.0.124",
"diff": "8.0.2",
"dompurify": "3.3.1",
"fuzzysort": "3.1.0",
@@ -557,25 +563,33 @@
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
- "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="],
+ "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
- "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.73", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EAAGJ/dfbAZaqIhK3w52hq6cftSLZwXdC6uHKh8Cls1T0N4MxS6ykDf54UyFO3bZWkQxR+Mdw1B3qireGOxtJQ=="],
+ "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
- "@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XOK0dJsAGoPYi/lfR4KFBi8xhvJ46oCpAxUD6FmJAuJ4eh0qlj5zDt+myvzM8gvN7S6K7zHD+mdWlOPKGQT8Vg=="],
+ "@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="],
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
- "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-87qFcYNvDF/89hB//MQjYTb3tlsAfmgeZrZ34RESeBTZpSgs0EzYOMqPMwFTHUNp4wteoifikDJbaS/9Da8cfw=="],
+ "@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="],
- "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rq+FX55ne7lMiqai7NcvvDZj4HLsr+hg77WayqmySqc6zhw3tIOLxd4Ty6OpwNj0C0bVMi3iCl2zvJIEirh9XA=="],
+ "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
+
+ "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="],
+
+ "@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4d5EKu0OW7Gf5WFpGo4ixn0iWEwA+GpteqUjEznWGmi7qdLE5zdkbRik5B1HrDDiw5P90yO51xBex/Fp50JcVA=="],
+
+ "@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WWOz5Kj+5fVe94h7WeReqjUOVtAquDE2kM575FUc8CsVxH2tRfA5cLa8nu3bknSezsKt3i67YM6mvCRxiXCkWA=="],
+
+ "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
- "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.97", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s4tI7Z15i6FlbtCvS4SBRal8wRfkOXJzKxlS6cU4mJW/QfUfoVy4b22836NVNJwDvkG/HkDSfzwm/X8mn46MhA=="],
+ "@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.98", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ=="],
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
@@ -591,9 +605,9 @@
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
- "@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RlYubjStoZQxna4Ng91Vvo8YskvL7lW9zj68IwZfCnaDBSAp1u6Nhc5BR4ZtKnY6PA3XEtu4bATIQl7yiiQ+Lw=="],
+ "@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
- "@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="],
+ "@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="],
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="],
@@ -605,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=="],
@@ -627,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=="],
@@ -837,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=="],
@@ -911,8 +945,22 @@
"@expressive-code/plugin-text-markers": ["@expressive-code/plugin-text-markers@0.41.6", "", { "dependencies": { "@expressive-code/core": "^0.41.6" } }, "sha512-PBFa1wGyYzRExMDzBmAWC6/kdfG1oLn4pLpBeTfIRrALPjcGA/59HP3e7q9J0Smk4pC7U+lWkA2LHR8FYV8U7Q=="],
+ "@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
+
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
+ "@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
+
+ "@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
+
+ "@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
+
+ "@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
+
+ "@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
+
+ "@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
+
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
@@ -925,7 +973,9 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
- "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="],
+ "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.5.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-OoAwCz4fOci3h/2l+PRHMclclh3IaFq8w1es2wvBJ8ca7vtglKsBYT7dvmYpsXlu7pg9mopbjcexvmVCQEUTAQ=="],
+
+ "@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.2", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-pvGrC+aDVLY8bRCC/fZaG/Qihvt2r4by5xbTo5JTSz9O7yIcR6xG2d9Wkuu4bcXFz674z2C+i5bUk+J/RSdBpg=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -1119,6 +1169,8 @@
"@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="],
+ "@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
+
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
@@ -1221,27 +1273,27 @@
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
- "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.2", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "@toon-format/toon": "^2.0.0", "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" }, "optionalPeers": ["@toon-format/toon"] }, "sha512-3Th0vmJ9pjnwcPc2H1f59Mb0LFvwaREZAScfOQIpUxAHjZ7ZawVKDP27qgsteZPmMYqccNMy4r4Y3kgUnNcKAg=="],
+ "@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
- "@opentui/core": ["@opentui/core@0.1.75", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="],
+ "@opentui/core": ["@opentui/core@0.1.79", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.79", "@opentui/core-darwin-x64": "0.1.79", "@opentui/core-linux-arm64": "0.1.79", "@opentui/core-linux-x64": "0.1.79", "@opentui/core-win32-arm64": "0.1.79", "@opentui/core-win32-x64": "0.1.79", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-job/t09w8A/aHb/WuaVbimu5fIffyN+PCuVO5cYhXEg/NkOkC/WdFi80B8bwncR/DBPyLAh6oJ3EG86grOVo5g=="],
- "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="],
+ "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.79", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kgsGniV+DM5G1P3GideyJhvfnthNKcVCAm2mPTIr9InQ3L0gS/Feh7zgwOS/jxDvdlQbOWGKMk2Z3JApeC1MLw=="],
- "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="],
+ "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.79", "", { "os": "darwin", "cpu": "x64" }, "sha512-OpyAmFqAAKQ2CeFmf/oLWcNksmP6Ryx/3R5dbKXThOudMCeQvfvInJTRbc2jTn9VFpf+Qj4BgHkJg1h90tf/EA=="],
- "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="],
+ "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.79", "", { "os": "linux", "cpu": "arm64" }, "sha512-DCa5YaknS4bWhFt8TMEGH+qmTinyzuY8hoZbO4crtWXAxofPP7Pas76Cwxlvis/PyLffA+pPxAl1l5sUZpsvqw=="],
- "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="],
+ "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.79", "", { "os": "linux", "cpu": "x64" }, "sha512-V6xjvFfHh3NGvsuuDae1KHPRZXHMEE8XL0A/GM6v4I4OCC23kDmkK60Vn6OptQwAzwwbz0X0IX+Ut/GQU9qGgA=="],
- "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="],
+ "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.79", "", { "os": "win32", "cpu": "arm64" }, "sha512-sPRKnVzOdT5szI59tte7pxwwkYA+07EQN+6miFAvkFuiLmRUngONUD8HVjL7nCnxcPFqxaU4Rvl1y40ST86g8g=="],
- "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="],
+ "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.79", "", { "os": "win32", "cpu": "x64" }, "sha512-vmQcFTvKf9fqajnDtgU6/uAsiTGwx8//khqHVBmiTEXUsiT792Ki9l8sgNughbuldqG5iZOiF6IaAWU1H67UpA=="],
- "@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="],
+ "@opentui/solid": ["@opentui/solid@0.1.79", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.79", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-c5+0jexKxb8GwRDDkQ/U6isZZqClAzHccXmYiLYmSnqdoQQp2lIGHLartL+K8lfIQrsKClzP2ZHumN6nexRfRg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1359,6 +1411,8 @@
"@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
+ "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
+
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
@@ -1753,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=="],
@@ -1797,7 +1853,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
- "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
+ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -1909,7 +1965,7 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
- "@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
+ "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -1927,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=="],
@@ -1935,6 +2007,8 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
+ "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
+
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -1947,10 +2021,14 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
- "ai": ["ai@5.0.119", "", { "dependencies": { "@ai-sdk/gateway": "2.0.25", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HUOwhc17fl2SZTJGZyA/99aNu706qKfXaUBCy9vgZiXBwrxg2eTzn2BCz7kmYDsfx6Fg2ACBy2icm41bsDXCTw=="],
+ "ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
+
+ "ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
+ "ajv-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=="],
@@ -2009,10 +2087,14 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
+ "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
+
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
+ "avvio": ["avvio@9.1.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw=="],
+
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
"aws-sdk": ["aws-sdk@2.1692.0", "", { "dependencies": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", "xml2js": "0.6.2" } }, "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw=="],
@@ -2097,9 +2179,9 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
- "bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
+ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
- "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
+ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
@@ -2349,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=="],
@@ -2455,16 +2539,26 @@
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
+ "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
+
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
+ "fast-json-stringify": ["fast-json-stringify@6.2.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw=="],
+
+ "fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
+
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="],
+ "fastify": ["fastify@5.7.4", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA=="],
+
+ "fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
+
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -2479,6 +2573,8 @@
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
+ "find-my-way": ["find-my-way@9.4.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w=="],
+
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
@@ -2555,7 +2651,7 @@
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
- "ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="],
+ "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
@@ -2845,6 +2941,8 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
+ "json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
+
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@@ -2881,6 +2979,8 @@
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
+ "light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
+
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
@@ -3111,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=="],
@@ -3183,6 +3285,8 @@
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
+ "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
+
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@@ -3261,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=="],
@@ -3289,6 +3395,12 @@
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
+ "pino": ["pino@10.3.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA=="],
+
+ "pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
+
+ "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
+
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
@@ -3341,6 +3453,8 @@
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
+ "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
+
"promise.allsettled": ["promise.allsettled@1.0.7", "", { "dependencies": { "array.prototype.map": "^1.0.5", "call-bind": "^1.0.2", "define-properties": "^1.2.0", "es-abstract": "^1.22.1", "get-intrinsic": "^1.2.1", "iterate-value": "^1.0.2" } }, "sha512-hezvKvQQmsFkOdrZfYxUxkyxl8mgFQeT259Ajj9PXdbg9VzBCWrItOev72JyWxkCD5VSSqAeHmlN3tWx4DlmsA=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
@@ -3363,6 +3477,8 @@
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
+ "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
+
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
@@ -3397,6 +3513,8 @@
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
+ "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
+
"recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="],
"recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="],
@@ -3449,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=="],
@@ -3463,6 +3585,8 @@
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
+ "ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
+
"retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="],
"retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="],
@@ -3475,6 +3599,8 @@
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
+ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
+
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
@@ -3497,6 +3623,10 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
+ "safe-regex2": ["safe-regex2@5.0.0", "", { "dependencies": { "ret": "~0.5.0" } }, "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw=="],
+
+ "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
+
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.2.1", "", {}, "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="],
@@ -3505,6 +3635,8 @@
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
+ "secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
+
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -3519,6 +3651,8 @@
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
+ "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
+
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
@@ -3581,6 +3715,8 @@
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
+ "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="],
+
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -3589,6 +3725,8 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
+ "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
+
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
@@ -3691,6 +3829,8 @@
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
+ "thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
+
"three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="],
"thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="],
@@ -3773,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=="],
@@ -3871,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=="],
@@ -3935,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=="],
@@ -3975,7 +4151,9 @@
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
- "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
+ "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
+
+ "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
@@ -3983,11 +4161,13 @@
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
- "@ai-sdk/cerebras/@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=="],
+ "@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
- "@ai-sdk/deepinfra/@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=="],
+ "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
- "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
+ "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
+
+ "@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
@@ -3997,12 +4177,14 @@
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
- "@ai-sdk/togetherai/@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=="],
+ "@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
- "@ai-sdk/vercel/@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=="],
+ "@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/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=="],
@@ -4069,6 +4251,8 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
+ "@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
+
"@gitlab/gitlab-ai-provider/openai": ["openai@6.17.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -4195,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=="],
@@ -4269,8 +4453,20 @@
"@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=="],
+
+ "ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
+
+ "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
+
+ "ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
+
+ "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@2.0.56", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FGlqwWc3tAYqDHE8r8hQGQLcMiPUwgz90oU2QygUH930OWtCLapFkSu114DgVaIN/qoM1DUX+inv0Ee74Fgp5g=="],
+
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -4359,6 +4555,8 @@
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
+ "light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
+
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
@@ -4381,11 +4579,11 @@
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
- "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
+ "opencode/@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=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
- "opencode/@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=="],
+ "opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
@@ -4483,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=="],
@@ -4493,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=="],
@@ -4513,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=="],
@@ -4757,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=="],
@@ -4803,6 +5013,14 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+ "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
+
+ "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
+
+ "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
+
+ "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
+
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -4995,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=="],
@@ -5135,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.lock b/flake.lock
index 16fb71c0a5..10fa973cfe 100644
--- a/flake.lock
+++ b/flake.lock
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
- "lastModified": 1768393167,
- "narHash": "sha256-n2063BRjHde6DqAz2zavhOOiLUwA3qXt7jQYHyETjX8=",
+ "lastModified": 1770073757,
+ "narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
"owner": "NixOS",
"repo": "nixpkgs",
- "rev": "2f594d5af95d4fdac67fba60376ec11e482041cb",
+ "rev": "47472570b1e607482890801aeaf29bfb749884f6",
"type": "github"
},
"original": {
diff --git a/flake.nix b/flake.nix
index e4d214a0b9..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
@@ -42,28 +62,15 @@
desktop = pkgs.callPackage ./nix/desktop.nix {
inherit opencode;
};
- # nixpkgs cpu naming to bun cpu naming
- cpuMap = { x86_64 = "x64"; aarch64 = "arm64"; };
- # matrix of node_modules builds - these will always fail due to fakeHash usage
- # but allow computation of the correct hash from any build machine for any cpu/os
- # see the update-nix-hashes workflow for usage
- moduleUpdaters = pkgs.lib.listToAttrs (
- pkgs.lib.concatMap (cpu:
- map (os: {
- name = "${cpu}-${os}_node_modules";
- value = node_modules.override {
- bunCpu = cpuMap.${cpu};
- bunOs = os;
- hash = pkgs.lib.fakeHash;
- };
- }) [ "linux" "darwin" ]
- ) [ "x86_64" "aarch64" ]
- );
in
{
default = opencode;
inherit opencode desktop;
- } // moduleUpdaters
+ # Updater derivation with fakeHash - build fails and reveals correct hash
+ node_modules_updater = node_modules.override {
+ hash = pkgs.lib.fakeHash;
+ };
+ }
);
};
}
diff --git a/github/index.ts b/github/index.ts
index 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 5b08e9ceaa..4e5a14b045 100644
--- a/infra/console.ts
+++ b/infra/console.ts
@@ -133,6 +133,18 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS6"),
new sst.Secret("ZEN_MODELS7"),
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")
@@ -154,14 +166,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
-let logProcessor
-if ($app.stage === "production" || $app.stage === "frank") {
- const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
- logProcessor = new sst.cloudflare.Worker("LogProcessor", {
- handler: "packages/console/function/src/log-processor.ts",
- link: [HONEYCOMB_API_KEY],
- })
-}
+const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
+ handler: "packages/console/function/src/log-processor.ts",
+ link: [new sst.Secret("HONEYCOMB_API_KEY")],
+})
new sst.cloudflare.x.SolidStart("Console", {
domain,
@@ -199,7 +207,7 @@ new sst.cloudflare.x.SolidStart("Console", {
transform: {
worker: {
placement: { mode: "smart" },
- tailConsumers: logProcessor ? [{ service: logProcessor.nodes.worker.scriptName }] : [],
+ tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
},
},
},
diff --git a/nix/desktop.nix b/nix/desktop.nix
index 9625f75c27..efdc2bd72e 100644
--- a/nix/desktop.nix
+++ b/nix/desktop.nix
@@ -45,8 +45,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
rustc
jq
makeWrapper
- ]
- ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
+ ] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
buildInputs = lib.optionals stdenv.isLinux [
dbus
@@ -61,6 +60,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
gst_all_1.gstreamer
gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-good
+ gst_all_1.gst-plugins-bad
];
strictDeps = true;
@@ -97,4 +97,4 @@ rustPlatform.buildRustPackage (finalAttrs: {
mainProgram = "opencode-desktop";
inherit (opencode.meta) platforms;
};
-})
\ No newline at end of file
+})
diff --git a/nix/hashes.json b/nix/hashes.json
index f50c7a23df..a14d2afaf3 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
{
"nodeModules": {
- "x86_64-linux": "sha256-gUWzUsk81miIrjg0fZQmsIQG4pZYmEHgzN6BaXI+lfc=",
- "aarch64-linux": "sha256-gwEG75ha/ojTO2iAObTmLTtEkXIXJ7BThzfI5CqlJh8=",
- "aarch64-darwin": "sha256-20RGG2GkUItCzD67gDdoSLfexttM8abS//FKO9bfjoM=",
- "x86_64-darwin": "sha256-i2VawFuR1UbjPVYoybU6aJDJfFo0tcvtl1aM31Y2mTQ="
+ "x86_64-linux": "sha256-pp2gb4nxiIT3VltB6Xli2wZPH32JfnMsI+BbihyU1+E=",
+ "aarch64-linux": "sha256-hJwxhBICZz/pbIxQsF/sIpZTlFIgLpcAyF44O8wxMdU=",
+ "aarch64-darwin": "sha256-DPONXP52XOg/ApdSnLp32a+K5XCOnDGhbTUto2Rme0g=",
+ "x86_64-darwin": "sha256-KX1h5LRJSgthpbOPqWlbM/sPf8cvQrdRJvxtrz/FzBQ="
}
}
diff --git a/nix/node_modules.nix b/nix/node_modules.nix
index 981a60ef9b..e918846c24 100644
--- a/nix/node_modules.nix
+++ b/nix/node_modules.nix
@@ -2,8 +2,6 @@
lib,
stdenvNoCC,
bun,
- bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64",
- bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin",
rev ? "dirty",
hash ?
(lib.pipe ./hashes.json [
@@ -16,6 +14,9 @@ let
builtins.readFile
builtins.fromJSON
];
+ platform = stdenvNoCC.hostPlatform;
+ bunCpu = if platform.isAarch64 then "arm64" else "x64";
+ bunOs = if platform.isLinux then "linux" else "darwin";
in
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
@@ -29,7 +30,7 @@ stdenvNoCC.mkDerivation {
../bun.lock
../package.json
../patches
- ../install
+ ../install # required by desktop build (cli.rs include_str!)
]
);
};
@@ -39,23 +40,22 @@ stdenvNoCC.mkDerivation {
"SOCKS_SERVER"
];
- nativeBuildInputs = [
- bun
- ];
+ nativeBuildInputs = [ bun ];
dontConfigure = true;
buildPhase = ''
runHook preBuild
- export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--cpu="${bunCpu}" \
--os="${bunOs}" \
+ --filter '!./' \
+ --filter './packages/opencode' \
+ --filter './packages/desktop' \
--frozen-lockfile \
--ignore-scripts \
- --no-progress \
- --linker=isolated
+ --no-progress
bun --bun ${./scripts/canonicalize-node-modules.ts}
bun --bun ${./scripts/normalize-bun-binaries.ts}
runHook postBuild
@@ -63,10 +63,8 @@ stdenvNoCC.mkDerivation {
installPhase = ''
runHook preInstall
-
mkdir -p $out
find . -type d -name node_modules -exec cp -R --parents {} $out \;
-
runHook postInstall
'';
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/package.json b/package.json
index 4267ef6456..ae790e0a5e 100644
--- a/package.json
+++ b/package.json
@@ -4,9 +4,11 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
- "packageManager": "bun@1.3.5",
+ "packageManager": "bun@1.3.9",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
+ "dev:desktop": "bun --cwd packages/desktop tauri dev",
+ "dev:web": "bun --cwd packages/app dev",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
@@ -21,7 +23,7 @@
"packages/slack"
],
"catalog": {
- "@types/bun": "1.3.5",
+ "@types/bun": "1.3.9",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -38,7 +40,7 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
- "ai": "5.0.119",
+ "ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
@@ -99,6 +101,6 @@
"@types/node": "catalog:"
},
"patchedDependencies": {
- "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
+ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch"
}
}
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/AGENTS.md b/packages/app/e2e/AGENTS.md
new file mode 100644
index 0000000000..59662dbea5
--- /dev/null
+++ b/packages/app/e2e/AGENTS.md
@@ -0,0 +1,176 @@
+# E2E Testing Guide
+
+## Build/Lint/Test Commands
+
+```bash
+# Run all e2e tests
+bun test:e2e
+
+# Run specific test file
+bun test:e2e -- app/home.spec.ts
+
+# Run single test by title
+bun test:e2e -- -g "home renders and shows core entrypoints"
+
+# Run tests with UI mode (for debugging)
+bun test:e2e:ui
+
+# Run tests locally with full server setup
+bun test:e2e:local
+
+# View test report
+bun test:e2e:report
+
+# Typecheck
+bun typecheck
+```
+
+## Test Structure
+
+All tests live in `packages/app/e2e/`:
+
+```
+e2e/
+├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk)
+├── actions.ts # Reusable action helpers
+├── selectors.ts # DOM selectors
+├── utils.ts # Utilities (serverUrl, modKey, path helpers)
+└── [feature]/
+ └── *.spec.ts # Test files
+```
+
+## Test Patterns
+
+### Basic Test Structure
+
+```typescript
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { withSession } from "../actions"
+
+test("test description", async ({ page, sdk, gotoSession }) => {
+ await gotoSession() // or gotoSession(sessionID)
+
+ // Your test code
+ await expect(page.locator(promptSelector)).toBeVisible()
+})
+```
+
+### Using Fixtures
+
+- `page` - Playwright page
+- `sdk` - OpenCode SDK client for API calls
+- `gotoSession(sessionID?)` - Navigate to session
+
+### Helper Functions
+
+**Actions** (`actions.ts`):
+
+- `openPalette(page)` - Open command palette
+- `openSettings(page)` - Open settings dialog
+- `closeDialog(page, dialog)` - Close any dialog
+- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
+- `withSession(sdk, title, callback)` - Create temp session
+- `clickListItem(container, filter)` - Click list item by key/text
+
+**Selectors** (`selectors.ts`):
+
+- `promptSelector` - Prompt input
+- `terminalSelector` - Terminal panel
+- `sessionItemSelector(id)` - Session in sidebar
+- `listItemSelector` - Generic list items
+
+**Utils** (`utils.ts`):
+
+- `modKey` - Meta (Mac) or Control (Linux/Win)
+- `serverUrl` - Backend server URL
+- `sessionPath(dir, id?)` - Build session URL
+
+## Code Style Guidelines
+
+### Imports
+
+Always import from `../fixtures`, not `@playwright/test`:
+
+```typescript
+// ✅ Good
+import { test, expect } from "../fixtures"
+
+// ❌ Bad
+import { test, expect } from "@playwright/test"
+```
+
+### Naming Conventions
+
+- Test files: `feature-name.spec.ts`
+- Test names: lowercase, descriptive: `"sidebar can be toggled"`
+- Variables: camelCase
+- Constants: SCREAMING_SNAKE_CASE
+
+### Error Handling
+
+Tests should clean up after themselves:
+
+```typescript
+test("test with cleanup", async ({ page, sdk, gotoSession }) => {
+ await withSession(sdk, "test session", async (session) => {
+ await gotoSession(session.id)
+ // Test code...
+ }) // Auto-deletes session
+})
+```
+
+### Timeouts
+
+Default: 60s per test, 10s per assertion. Override when needed:
+
+```typescript
+test.setTimeout(120_000) // For long LLM operations
+test("slow test", async () => {
+ await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
+})
+```
+
+### Selectors
+
+Use `data-component`, `data-action`, or semantic roles:
+
+```typescript
+// ✅ Good
+await page.locator('[data-component="prompt-input"]').click()
+await page.getByRole("button", { name: "Open settings" }).click()
+
+// ❌ Bad
+await page.locator(".css-class-name").click()
+await page.locator("#id-name").click()
+```
+
+### Keyboard Shortcuts
+
+Use `modKey` for cross-platform compatibility:
+
+```typescript
+import { modKey } from "../utils"
+
+await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
+await page.keyboard.press(`${modKey}+Comma`) // Open settings
+```
+
+## Writing New Tests
+
+1. Choose appropriate folder or create new one
+2. Import from `../fixtures`
+3. Use helper functions from `../actions` and `../selectors`
+4. Clean up any created resources
+5. Use specific selectors (avoid CSS classes)
+6. Test one feature per test file
+
+## Local Development
+
+For UI debugging, use:
+
+```bash
+bun test:e2e:ui
+```
+
+This opens Playwright's interactive UI for step-through debugging.
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
new file mode 100644
index 0000000000..3467effa6b
--- /dev/null
+++ b/packages/app/e2e/actions.ts
@@ -0,0 +1,421 @@
+import { expect, type Locator, type Page } from "@playwright/test"
+import fs from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import { execSync } from "node:child_process"
+import { modKey, serverUrl } from "./utils"
+import {
+ sessionItemSelector,
+ dropdownMenuTriggerSelector,
+ dropdownMenuContentSelector,
+ projectMenuTriggerSelector,
+ projectWorkspacesToggleSelector,
+ titlebarRightSelector,
+ popoverBodySelector,
+ listItemSelector,
+ listItemKeySelector,
+ listItemKeyStartsWithSelector,
+ workspaceItemSelector,
+ workspaceMenuTriggerSelector,
+} from "./selectors"
+import type { createSdk } from "./utils"
+
+export async function defocus(page: Page) {
+ await page
+ .evaluate(() => {
+ const el = document.activeElement
+ if (el instanceof HTMLElement) el.blur()
+ })
+ .catch(() => undefined)
+}
+
+export async function openPalette(page: Page) {
+ await defocus(page)
+ await page.keyboard.press(`${modKey}+P`)
+
+ const dialog = page.getByRole("dialog")
+ await expect(dialog).toBeVisible()
+ await expect(dialog.getByRole("textbox").first()).toBeVisible()
+ return dialog
+}
+
+export async function closeDialog(page: Page, dialog: Locator) {
+ await page.keyboard.press("Escape")
+ const closed = await dialog
+ .waitFor({ state: "detached", timeout: 1500 })
+ .then(() => true)
+ .catch(() => false)
+
+ if (closed) return
+
+ await page.keyboard.press("Escape")
+ const closedSecond = await dialog
+ .waitFor({ state: "detached", timeout: 1500 })
+ .then(() => true)
+ .catch(() => false)
+
+ if (closedSecond) return
+
+ await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
+ await expect(dialog).toHaveCount(0)
+}
+
+export async function isSidebarClosed(page: Page) {
+ const main = page.locator("main")
+ const classes = (await main.getAttribute("class")) ?? ""
+ return classes.includes("xl:border-l")
+}
+
+export async function toggleSidebar(page: Page) {
+ await defocus(page)
+ await page.keyboard.press(`${modKey}+B`)
+}
+
+export async function openSidebar(page: Page) {
+ if (!(await isSidebarClosed(page))) return
+
+ const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+ const visible = await button
+ .isVisible()
+ .then((x) => x)
+ .catch(() => false)
+
+ if (visible) await button.click()
+ if (!visible) await toggleSidebar(page)
+
+ const main = page.locator("main")
+ const opened = await expect(main)
+ .not.toHaveClass(/xl:border-l/, { timeout: 1500 })
+ .then(() => true)
+ .catch(() => false)
+
+ if (opened) return
+
+ await toggleSidebar(page)
+ await expect(main).not.toHaveClass(/xl:border-l/)
+}
+
+export async function closeSidebar(page: Page) {
+ if (await isSidebarClosed(page)) return
+
+ const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
+ const visible = await button
+ .isVisible()
+ .then((x) => x)
+ .catch(() => false)
+
+ if (visible) await button.click()
+ if (!visible) await toggleSidebar(page)
+
+ const main = page.locator("main")
+ const closed = await expect(main)
+ .toHaveClass(/xl:border-l/, { timeout: 1500 })
+ .then(() => true)
+ .catch(() => false)
+
+ if (closed) return
+
+ await toggleSidebar(page)
+ await expect(main).toHaveClass(/xl:border-l/)
+}
+
+export async function openSettings(page: Page) {
+ await defocus(page)
+
+ const dialog = page.getByRole("dialog")
+ await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
+
+ const opened = await dialog
+ .waitFor({ state: "visible", timeout: 3000 })
+ .then(() => true)
+ .catch(() => false)
+
+ if (opened) return dialog
+
+ await page.getByRole("button", { name: "Settings" }).first().click()
+ await expect(dialog).toBeVisible()
+ return dialog
+}
+
+export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
+ await page.addInitScript(
+ (args: { directory: string; serverUrl: string; extra: string[] }) => {
+ const key = "opencode.global.dat:server"
+ const raw = localStorage.getItem(key)
+ const parsed = (() => {
+ if (!raw) return undefined
+ try {
+ return JSON.parse(raw) as unknown
+ } catch {
+ return undefined
+ }
+ })()
+
+ const store = parsed && typeof parsed === "object" ? (parsed as Record) : {}
+ const list = Array.isArray(store.list) ? store.list : []
+ const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
+ const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
+ const nextProjects = { ...(projects as Record) }
+
+ const add = (origin: string, directory: string) => {
+ const current = nextProjects[origin]
+ const items = Array.isArray(current) ? current : []
+ const existing = items.filter(
+ (p): p is { worktree: string; expanded?: boolean } =>
+ !!p &&
+ typeof p === "object" &&
+ "worktree" in p &&
+ typeof (p as { worktree?: unknown }).worktree === "string",
+ )
+
+ if (existing.some((p) => p.worktree === directory)) return
+ nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
+ }
+
+ const directories = [args.directory, ...args.extra]
+ for (const directory of directories) {
+ add("local", directory)
+ add(args.serverUrl, directory)
+ }
+
+ localStorage.setItem(
+ key,
+ JSON.stringify({
+ list,
+ projects: nextProjects,
+ lastProject,
+ }),
+ )
+ },
+ { directory: input.directory, serverUrl, extra: input.extra ?? [] },
+ )
+}
+
+export async function createTestProject() {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
+
+ await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
+
+ execSync("git init", { cwd: root, stdio: "ignore" })
+ execSync("git add -A", { cwd: root, stdio: "ignore" })
+ execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
+ cwd: root,
+ stdio: "ignore",
+ })
+
+ return root
+}
+
+export async function cleanupTestProject(directory: string) {
+ await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
+}
+
+export function sessionIDFromUrl(url: string) {
+ const match = /\/session\/([^/?#]+)/.exec(url)
+ return match?.[1]
+}
+
+export async function hoverSessionItem(page: Page, sessionID: string) {
+ const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
+ await expect(sessionEl).toBeVisible()
+ await sessionEl.hover()
+ return sessionEl
+}
+
+export async function openSessionMoreMenu(page: Page, sessionID: string) {
+ await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
+
+ const scroller = page.locator(".session-scroller").first()
+ await expect(scroller).toBeVisible()
+ await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
+
+ const menu = page
+ .locator(dropdownMenuContentSelector)
+ .filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
+ .filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
+ .filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
+ .first()
+
+ const opened = await menu
+ .isVisible()
+ .then((x) => x)
+ .catch(() => false)
+
+ if (opened) return menu
+
+ const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
+ await expect(menuTrigger).toBeVisible()
+ await menuTrigger.click()
+
+ await expect(menu).toBeVisible()
+ return menu
+}
+
+export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
+ const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
+ await expect(item).toBeVisible()
+ await item.click({ force: options?.force })
+}
+
+export async function confirmDialog(page: Page, buttonName: string | RegExp) {
+ const dialog = page.getByRole("dialog").first()
+ await expect(dialog).toBeVisible()
+
+ const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
+ await expect(button).toBeVisible()
+ await button.click()
+}
+
+export async function openSharePopover(page: Page) {
+ const rightSection = page.locator(titlebarRightSelector)
+ const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
+ await expect(shareButton).toBeVisible()
+
+ const popoverBody = page
+ .locator(popoverBodySelector)
+ .filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
+ .first()
+
+ const opened = await popoverBody
+ .isVisible()
+ .then((x) => x)
+ .catch(() => false)
+
+ if (!opened) {
+ await shareButton.click()
+ await expect(popoverBody).toBeVisible()
+ }
+ return { rightSection, popoverBody }
+}
+
+export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
+ const button = page.getByRole("button").filter({ hasText: buttonName }).first()
+ await expect(button).toBeVisible()
+ await button.click()
+}
+
+export async function clickListItem(
+ container: Locator | Page,
+ filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
+): Promise {
+ let item: Locator
+
+ if (typeof filter === "string" || filter instanceof RegExp) {
+ item = container.locator(listItemSelector).filter({ hasText: filter }).first()
+ } else if (filter.keyStartsWith) {
+ item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
+ } else if (filter.key) {
+ item = container.locator(listItemKeySelector(filter.key)).first()
+ } else if (filter.text) {
+ item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
+ } else {
+ throw new Error("Invalid filter provided to clickListItem")
+ }
+
+ await expect(item).toBeVisible()
+ await item.click()
+ return item
+}
+
+export async function withSession(
+ sdk: ReturnType,
+ title: string,
+ callback: (session: { id: string; title: string }) => Promise,
+): Promise {
+ const session = await sdk.session.create({ title }).then((r) => r.data)
+ if (!session?.id) throw new Error("Session create did not return an id")
+
+ try {
+ return await callback(session)
+ } finally {
+ await sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
+ }
+}
+
+export async function openStatusPopover(page: Page) {
+ await defocus(page)
+
+ const rightSection = page.locator(titlebarRightSelector)
+ const trigger = rightSection.getByRole("button", { name: /status/i }).first()
+
+ const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
+
+ const opened = await popoverBody
+ .isVisible()
+ .then((x) => x)
+ .catch(() => false)
+
+ if (!opened) {
+ await expect(trigger).toBeVisible()
+ await trigger.click()
+ await expect(popoverBody).toBeVisible()
+ }
+
+ return { rightSection, popoverBody }
+}
+
+export async function openProjectMenu(page: Page, projectSlug: string) {
+ const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
+ await expect(trigger).toHaveCount(1)
+
+ await trigger.focus()
+ await page.keyboard.press("Enter")
+
+ const menu = page.locator(dropdownMenuContentSelector).first()
+ const opened = await menu
+ .waitFor({ state: "visible", timeout: 1500 })
+ .then(() => true)
+ .catch(() => false)
+
+ if (opened) {
+ const viewport = page.viewportSize()
+ const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
+ const y = viewport ? Math.max(viewport.height - 5, 0) : 800
+ await page.mouse.move(x, y)
+ return menu
+ }
+
+ await trigger.click({ force: true })
+
+ await expect(menu).toBeVisible()
+
+ const viewport = page.viewportSize()
+ const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
+ const y = viewport ? Math.max(viewport.height - 5, 0) : 800
+ await page.mouse.move(x, y)
+ return menu
+}
+
+export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
+ const current = await page
+ .getByRole("button", { name: "New workspace" })
+ .first()
+ .isVisible()
+ .then((x) => x)
+ .catch(() => false)
+
+ if (current === enabled) return
+
+ await openProjectMenu(page, projectSlug)
+
+ const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
+ await expect(toggle).toBeVisible()
+ await toggle.click({ force: true })
+
+ const expected = enabled ? "New workspace" : "New session"
+ await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
+}
+
+export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
+ const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
+ await expect(item).toBeVisible()
+ await item.hover()
+
+ const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
+ await expect(trigger).toBeVisible()
+ await trigger.click({ force: true })
+
+ const menu = page.locator(dropdownMenuContentSelector).first()
+ await expect(menu).toBeVisible()
+ return menu
+}
diff --git a/packages/app/e2e/home.spec.ts b/packages/app/e2e/app/home.spec.ts
similarity index 88%
rename from packages/app/e2e/home.spec.ts
rename to packages/app/e2e/app/home.spec.ts
index c6fb0e3b07..f21dc40ec2 100644
--- a/packages/app/e2e/home.spec.ts
+++ b/packages/app/e2e/app/home.spec.ts
@@ -1,5 +1,5 @@
-import { test, expect } from "./fixtures"
-import { serverName } from "./utils"
+import { test, expect } from "../fixtures"
+import { serverName } from "../utils"
test("home renders and shows core entrypoints", async ({ page }) => {
await page.goto("/")
diff --git a/packages/app/e2e/navigation.spec.ts b/packages/app/e2e/app/navigation.spec.ts
similarity index 66%
rename from packages/app/e2e/navigation.spec.ts
rename to packages/app/e2e/app/navigation.spec.ts
index 76923af6ed..328c950df3 100644
--- a/packages/app/e2e/navigation.spec.ts
+++ b/packages/app/e2e/app/navigation.spec.ts
@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { dirPath, promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { dirPath } from "../utils"
test("project route redirects to /session", async ({ page, directory, slug }) => {
await page.goto(dirPath(directory))
diff --git a/packages/app/e2e/app/palette.spec.ts b/packages/app/e2e/app/palette.spec.ts
new file mode 100644
index 0000000000..3ccfd7a925
--- /dev/null
+++ b/packages/app/e2e/app/palette.spec.ts
@@ -0,0 +1,11 @@
+import { test, expect } from "../fixtures"
+import { openPalette } from "../actions"
+
+test("search palette opens and closes", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openPalette(page)
+
+ await page.keyboard.press("Escape")
+ await expect(dialog).toHaveCount(0)
+})
diff --git a/packages/app/e2e/server-default.spec.ts b/packages/app/e2e/app/server-default.spec.ts
similarity index 64%
rename from packages/app/e2e/server-default.spec.ts
rename to packages/app/e2e/app/server-default.spec.ts
index b6b16f0bcc..adbc83473b 100644
--- a/packages/app/e2e/server-default.spec.ts
+++ b/packages/app/e2e/app/server-default.spec.ts
@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { serverName, serverUrl } from "./utils"
+import { test, expect } from "../fixtures"
+import { serverName, serverUrl } from "../utils"
+import { clickListItem, closeDialog, clickMenuItem } from "../actions"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -33,31 +34,18 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
await expect(row).toBeVisible()
- const menu = row.locator('[data-component="icon-button"]').last()
- await menu.click()
- await page.getByRole("menuitem", { name: "Set as default" }).click()
+ const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
+ await expect(menuTrigger).toBeVisible()
+ await menuTrigger.click({ force: true })
+
+ const menu = page.locator('[data-component="dropdown-menu-content"]').first()
+ await expect(menu).toBeVisible()
+ await clickMenuItem(menu, /set as default/i)
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
await expect(row.getByText("Default", { exact: true })).toBeVisible()
- await page.keyboard.press("Escape")
- const closed = await dialog
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
-
- if (!closed) {
- await page.keyboard.press("Escape")
- const closedSecond = await dialog
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
-
- if (!closedSecond) {
- await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
- await expect(dialog).toHaveCount(0)
- }
- }
+ await closeDialog(page, dialog)
await ensurePopoverOpen()
diff --git a/packages/app/e2e/app/session.spec.ts b/packages/app/e2e/app/session.spec.ts
new file mode 100644
index 0000000000..c7fdfdc542
--- /dev/null
+++ b/packages/app/e2e/app/session.spec.ts
@@ -0,0 +1,16 @@
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { withSession } from "../actions"
+
+test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
+ const title = `e2e smoke ${Date.now()}`
+
+ await withSession(sdk, title, async (session) => {
+ await gotoSession(session.id)
+
+ const prompt = page.locator(promptSelector)
+ await prompt.click()
+ await page.keyboard.type("hello from e2e")
+ await expect(prompt).toContainText("hello from e2e")
+ })
+})
diff --git a/packages/app/e2e/app/titlebar-history.spec.ts b/packages/app/e2e/app/titlebar-history.spec.ts
new file mode 100644
index 0000000000..9d6091176e
--- /dev/null
+++ b/packages/app/e2e/app/titlebar-history.spec.ts
@@ -0,0 +1,124 @@
+import { test, expect } from "../fixtures"
+import { defocus, openSidebar, withSession } from "../actions"
+import { promptSelector } from "../selectors"
+import { modKey } from "../utils"
+
+test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const stamp = Date.now()
+
+ await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
+ await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
+ await gotoSession(one.id)
+
+ await openSidebar(page)
+
+ const link = page.locator(`[data-session-id="${two.id}"] a`).first()
+ await expect(link).toBeVisible()
+ await link.scrollIntoViewIfNeeded()
+ await link.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
+ await expect(page.locator(promptSelector)).toBeVisible()
+
+ const back = page.getByRole("button", { name: "Back" })
+ const forward = page.getByRole("button", { name: "Forward" })
+
+ await expect(back).toBeVisible()
+ await expect(back).toBeEnabled()
+ await back.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
+ await expect(page.locator(promptSelector)).toBeVisible()
+
+ await expect(forward).toBeVisible()
+ await expect(forward).toBeEnabled()
+ await forward.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
+ await expect(page.locator(promptSelector)).toBeVisible()
+ })
+ })
+})
+
+test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const stamp = Date.now()
+
+ await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => {
+ await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => {
+ await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => {
+ await gotoSession(a.id)
+
+ await openSidebar(page)
+
+ const second = page.locator(`[data-session-id="${b.id}"] a`).first()
+ await expect(second).toBeVisible()
+ await second.scrollIntoViewIfNeeded()
+ await second.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
+ await expect(page.locator(promptSelector)).toBeVisible()
+
+ const back = page.getByRole("button", { name: "Back" })
+ const forward = page.getByRole("button", { name: "Forward" })
+
+ await expect(back).toBeVisible()
+ await expect(back).toBeEnabled()
+ await back.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session/${a.id}(?:\\?|#|$)`))
+ await expect(page.locator(promptSelector)).toBeVisible()
+
+ await openSidebar(page)
+
+ const third = page.locator(`[data-session-id="${c.id}"] a`).first()
+ await expect(third).toBeVisible()
+ await third.scrollIntoViewIfNeeded()
+ await third.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
+ await expect(page.locator(promptSelector)).toBeVisible()
+
+ await expect(forward).toBeVisible()
+ await expect(forward).toBeDisabled()
+ })
+ })
+ })
+})
+
+test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const stamp = Date.now()
+
+ await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => {
+ await withSession(sdk, `e2e titlebar shortcuts 2 ${stamp}`, async (two) => {
+ await gotoSession(one.id)
+
+ await openSidebar(page)
+
+ const link = page.locator(`[data-session-id="${two.id}"] a`).first()
+ await expect(link).toBeVisible()
+ await link.scrollIntoViewIfNeeded()
+ await link.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
+ await expect(page.locator(promptSelector)).toBeVisible()
+
+ await defocus(page)
+ await page.keyboard.press(`${modKey}+[`)
+
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
+ await expect(page.locator(promptSelector)).toBeVisible()
+
+ await defocus(page)
+ await page.keyboard.press(`${modKey}+]`)
+
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
+ await expect(page.locator(promptSelector)).toBeVisible()
+ })
+ })
+})
diff --git a/packages/app/e2e/file-tree.spec.ts b/packages/app/e2e/file-tree.spec.ts
deleted file mode 100644
index 0b04eb2468..0000000000
--- a/packages/app/e2e/file-tree.spec.ts
+++ /dev/null
@@ -1,37 +0,0 @@
-import { test, expect } from "./fixtures"
-
-test.skip("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
- await gotoSession()
-
- const toggle = page.getByRole("button", { name: "Toggle file tree" })
- const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
-
- if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
- await expect(treeTabs).toBeVisible()
-
- await treeTabs.locator('[data-slot="tabs-trigger"]').nth(1).click()
-
- const node = (name: string) => treeTabs.getByRole("button", { name, exact: true })
-
- await expect(node("packages")).toBeVisible()
- await node("packages").click()
-
- await expect(node("app")).toBeVisible()
- await node("app").click()
-
- await expect(node("src")).toBeVisible()
- await node("src").click()
-
- await expect(node("components")).toBeVisible()
- await node("components").click()
-
- await expect(node("file-tree.tsx")).toBeVisible()
- await node("file-tree.tsx").click()
-
- const tab = page.getByRole("tab", { name: "file-tree.tsx" })
- await expect(tab).toBeVisible()
- await tab.click()
-
- const code = page.locator('[data-component="code"]').first()
- await expect(code.getByText("export default function FileTree")).toBeVisible()
-})
diff --git a/packages/app/e2e/file-open.spec.ts b/packages/app/e2e/files/file-open.spec.ts
similarity index 53%
rename from packages/app/e2e/file-open.spec.ts
rename to packages/app/e2e/files/file-open.spec.ts
index fb7104b6b0..3c636d748a 100644
--- a/packages/app/e2e/file-open.spec.ts
+++ b/packages/app/e2e/files/file-open.spec.ts
@@ -1,20 +1,15 @@
-import { test, expect } from "./fixtures"
-import { modKey } from "./utils"
+import { test, expect } from "../fixtures"
+import { openPalette, clickListItem } from "../actions"
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
await gotoSession()
- await page.keyboard.press(`${modKey}+P`)
-
- const dialog = page.getByRole("dialog")
- await expect(dialog).toBeVisible()
+ const dialog = await openPalette(page)
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
- const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
- await expect(fileItem).toBeVisible()
- await fileItem.click()
+ await clickListItem(dialog, { keyStartsWith: "file:" })
await expect(dialog).toHaveCount(0)
diff --git a/packages/app/e2e/files/file-tree.spec.ts b/packages/app/e2e/files/file-tree.spec.ts
new file mode 100644
index 0000000000..321d96af57
--- /dev/null
+++ b/packages/app/e2e/files/file-tree.spec.ts
@@ -0,0 +1,49 @@
+import { test, expect } from "../fixtures"
+
+test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const toggle = page.getByRole("button", { name: "Toggle file tree" })
+ const panel = page.locator("#file-tree-panel")
+ const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
+
+ await expect(toggle).toBeVisible()
+ if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
+ await expect(toggle).toHaveAttribute("aria-expanded", "true")
+ await expect(panel).toBeVisible()
+ await expect(treeTabs).toBeVisible()
+
+ const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
+ await expect(allTab).toBeVisible()
+ await allTab.click()
+ await expect(allTab).toHaveAttribute("aria-selected", "true")
+
+ const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
+ await expect(tree).toBeVisible()
+
+ const expand = async (name: string) => {
+ const folder = tree.getByRole("button", { name, exact: true }).first()
+ await expect(folder).toBeVisible()
+ await expect(folder).toHaveAttribute("aria-expanded", /true|false/)
+ if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
+ await expect(folder).toHaveAttribute("aria-expanded", "true")
+ }
+
+ await expand("packages")
+ await expand("app")
+ await expand("src")
+ await expand("components")
+
+ const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
+ await expect(file).toBeVisible()
+ await file.click()
+
+ const tab = page.getByRole("tab", { name: "file-tree.tsx" })
+ await expect(tab).toBeVisible()
+ await tab.click()
+ await expect(tab).toHaveAttribute("aria-selected", "true")
+
+ const code = page.locator('[data-component="code"]').first()
+ await expect(code).toBeVisible()
+ await expect(code).toContainText("export default function FileTree")
+})
diff --git a/packages/app/e2e/file-viewer.spec.ts b/packages/app/e2e/files/file-viewer.spec.ts
similarity index 59%
rename from packages/app/e2e/file-viewer.spec.ts
rename to packages/app/e2e/files/file-viewer.spec.ts
index 1e0f8a6f23..5283844975 100644
--- a/packages/app/e2e/file-viewer.spec.ts
+++ b/packages/app/e2e/files/file-viewer.spec.ts
@@ -1,5 +1,5 @@
-import { test, expect } from "./fixtures"
-import { modKey } from "./utils"
+import { test, expect } from "../fixtures"
+import { openPalette, clickListItem } from "../actions"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()
@@ -7,21 +7,12 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
const sep = process.platform === "win32" ? "\\" : "/"
const file = ["packages", "app", "package.json"].join(sep)
- await page.keyboard.press(`${modKey}+P`)
-
- const dialog = page.getByRole("dialog")
- await expect(dialog).toBeVisible()
+ const dialog = await openPalette(page)
const input = dialog.getByRole("textbox").first()
await input.fill(file)
- const fileItem = dialog
- .locator(
- '[data-slot="list-item"][data-key^="file:"][data-key*="packages"][data-key*="app"][data-key$="package.json"]',
- )
- .first()
- await expect(fileItem).toBeVisible()
- await fileItem.click()
+ await clickListItem(dialog, { text: /packages.*app.*package.json/ })
await expect(dialog).toHaveCount(0)
diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts
index c5315ff194..ea41ed8516 100644
--- a/packages/app/e2e/fixtures.ts
+++ b/packages/app/e2e/fixtures.ts
@@ -1,9 +1,21 @@
-import { test as base, expect } from "@playwright/test"
-import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils"
+import { test as base, expect, type Page } from "@playwright/test"
+import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
+import { promptSelector } from "./selectors"
+import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
+
+export const settingsKey = "settings.v3"
type TestFixtures = {
sdk: ReturnType
gotoSession: (sessionID?: string) => Promise
+ withProject: (
+ callback: (project: {
+ directory: string
+ slug: string
+ gotoSession: (sessionID?: string) => Promise
+ }) => Promise,
+ options?: { extra?: string[] },
+ ) => Promise
}
type WorkerFixtures = {
@@ -29,54 +41,7 @@ export const test = base.extend({
await use(createSdk(directory))
},
gotoSession: async ({ page, directory }, use) => {
- await page.addInitScript(
- (input: { directory: string; serverUrl: string }) => {
- const key = "opencode.global.dat:server"
- const raw = localStorage.getItem(key)
- const parsed = (() => {
- if (!raw) return undefined
- try {
- return JSON.parse(raw) as unknown
- } catch {
- return undefined
- }
- })()
-
- const store = parsed && typeof parsed === "object" ? (parsed as Record) : {}
- const list = Array.isArray(store.list) ? store.list : []
- const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
- const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
- const nextProjects = { ...(projects as Record) }
-
- const add = (origin: string) => {
- const current = nextProjects[origin]
- const items = Array.isArray(current) ? current : []
- const existing = items.filter(
- (p): p is { worktree: string; expanded?: boolean } =>
- !!p &&
- typeof p === "object" &&
- "worktree" in p &&
- typeof (p as { worktree?: unknown }).worktree === "string",
- )
-
- if (existing.some((p) => p.worktree === input.directory)) return
- nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing]
- }
-
- add("local")
- add(input.serverUrl)
-
- localStorage.setItem(
- key,
- JSON.stringify({
- list,
- projects: nextProjects,
- lastProject,
- }),
- )
- },
- { directory, serverUrl },
- )
+ await seedStorage(page, { directory })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
@@ -84,6 +49,39 @@ export const test = base.extend({
}
await use(gotoSession)
},
+ withProject: async ({ page }, use) => {
+ await use(async (callback, options) => {
+ const directory = await createTestProject()
+ const slug = dirSlug(directory)
+ await seedStorage(page, { directory, extra: options?.extra })
+
+ const gotoSession = async (sessionID?: string) => {
+ await page.goto(sessionPath(directory, sessionID))
+ await expect(page.locator(promptSelector)).toBeVisible()
+ }
+
+ try {
+ await gotoSession()
+ return await callback({ directory, slug, gotoSession })
+ } finally {
+ await cleanupTestProject(directory)
+ }
+ })
+ },
})
+async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
+ await seedProjects(page, input)
+ await page.addInitScript(() => {
+ localStorage.setItem(
+ "opencode.global.dat:model",
+ JSON.stringify({
+ recent: [{ providerID: "opencode", modelID: "big-pickle" }],
+ user: [],
+ variant: {},
+ }),
+ )
+ })
+}
+
export { expect }
diff --git a/packages/app/e2e/model-picker.spec.ts b/packages/app/e2e/models/model-picker.spec.ts
similarity index 85%
rename from packages/app/e2e/model-picker.spec.ts
rename to packages/app/e2e/models/model-picker.spec.ts
index 9e64b3dfb0..01e72464cc 100644
--- a/packages/app/e2e/model-picker.spec.ts
+++ b/packages/app/e2e/models/model-picker.spec.ts
@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { clickListItem } from "../actions"
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
await gotoSession()
@@ -32,9 +33,7 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
await input.fill(model)
- const item = dialog.locator(`[data-slot="list-item"][data-key="${key}"]`)
- await expect(item).toBeVisible()
- await item.click()
+ await clickListItem(dialog, { key })
await expect(dialog).toHaveCount(0)
diff --git a/packages/app/e2e/models-visibility.spec.ts b/packages/app/e2e/models/models-visibility.spec.ts
similarity index 66%
rename from packages/app/e2e/models-visibility.spec.ts
rename to packages/app/e2e/models/models-visibility.spec.ts
index 680ba96a31..c699111793 100644
--- a/packages/app/e2e/models-visibility.spec.ts
+++ b/packages/app/e2e/models/models-visibility.spec.ts
@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { modKey, promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { closeDialog, openSettings, clickListItem } from "../actions"
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
await gotoSession()
@@ -27,18 +28,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
- const settings = page.getByRole("dialog")
-
- await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
- const opened = await settings
- .waitFor({ state: "visible", timeout: 3000 })
- .then(() => true)
- .catch(() => false)
-
- if (!opened) {
- await page.getByRole("button", { name: "Settings" }).first().click()
- await expect(settings).toBeVisible()
- }
+ const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
@@ -52,22 +42,7 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
- await page.keyboard.press("Escape")
- const closed = await settings
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (!closed) {
- await page.keyboard.press("Escape")
- const closedSecond = await settings
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (!closedSecond) {
- await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
- await expect(settings).toHaveCount(0)
- }
- }
+ await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
diff --git a/packages/app/e2e/palette.spec.ts b/packages/app/e2e/palette.spec.ts
deleted file mode 100644
index 617c55ac16..0000000000
--- a/packages/app/e2e/palette.spec.ts
+++ /dev/null
@@ -1,15 +0,0 @@
-import { test, expect } from "./fixtures"
-import { modKey } from "./utils"
-
-test("search palette opens and closes", async ({ page, gotoSession }) => {
- await gotoSession()
-
- await page.keyboard.press(`${modKey}+P`)
-
- const dialog = page.getByRole("dialog")
- await expect(dialog).toBeVisible()
- await expect(dialog.getByRole("textbox").first()).toBeVisible()
-
- await page.keyboard.press("Escape")
- await expect(dialog).toHaveCount(0)
-})
diff --git a/packages/app/e2e/projects/project-edit.spec.ts b/packages/app/e2e/projects/project-edit.spec.ts
new file mode 100644
index 0000000000..4a286fea75
--- /dev/null
+++ b/packages/app/e2e/projects/project-edit.spec.ts
@@ -0,0 +1,53 @@
+import { test, expect } from "../fixtures"
+import { openSidebar } from "../actions"
+
+test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ await withProject(async () => {
+ await openSidebar(page)
+
+ const open = async () => {
+ const header = page.locator(".group\\/project").first()
+ await header.hover()
+ const trigger = header.getByRole("button", { name: "More options" }).first()
+ await expect(trigger).toBeVisible()
+ await trigger.click({ force: true })
+
+ const menu = page.locator('[data-component="dropdown-menu-content"]').first()
+ await expect(menu).toBeVisible()
+
+ const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
+ await expect(editItem).toBeVisible()
+ await editItem.click({ force: true })
+
+ const dialog = page.getByRole("dialog")
+ await expect(dialog).toBeVisible()
+ await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
+ return dialog
+ }
+
+ const name = `e2e project ${Date.now()}`
+ const startup = `echo e2e_${Date.now()}`
+
+ const dialog = await open()
+
+ const nameInput = dialog.getByLabel("Name")
+ await nameInput.fill(name)
+
+ const startupInput = dialog.getByLabel("Workspace startup script")
+ await startupInput.fill(startup)
+
+ await dialog.getByRole("button", { name: "Save" }).click()
+ await expect(dialog).toHaveCount(0)
+
+ const header = page.locator(".group\\/project").first()
+ await expect(header).toContainText(name)
+
+ const reopened = await open()
+ await expect(reopened.getByLabel("Name")).toHaveValue(name)
+ await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
+ await reopened.getByRole("button", { name: "Cancel" }).click()
+ await expect(reopened).toHaveCount(0)
+ })
+})
diff --git a/packages/app/e2e/projects/projects-close.spec.ts b/packages/app/e2e/projects/projects-close.spec.ts
new file mode 100644
index 0000000000..4b39ed82c3
--- /dev/null
+++ b/packages/app/e2e/projects/projects-close.spec.ts
@@ -0,0 +1,72 @@
+import { test, expect } from "../fixtures"
+import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
+import { projectCloseHoverSelector, projectSwitchSelector } from "../selectors"
+import { dirSlug } from "../utils"
+
+test("can close a project via hover card close button", async ({ page, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const other = await createTestProject()
+ const otherSlug = dirSlug(other)
+
+ try {
+ await withProject(
+ async () => {
+ await openSidebar(page)
+
+ const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+ await expect(otherButton).toBeVisible()
+ await otherButton.hover()
+
+ const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
+ await expect(close).toBeVisible()
+ await close.click()
+
+ await expect(otherButton).toHaveCount(0)
+ },
+ { extra: [other] },
+ )
+ } finally {
+ await cleanupTestProject(other)
+ }
+})
+
+test("closing active project navigates to another open project", async ({ page, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const other = await createTestProject()
+ const otherSlug = dirSlug(other)
+
+ try {
+ await withProject(
+ async ({ slug }) => {
+ await openSidebar(page)
+
+ const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+ await expect(otherButton).toBeVisible()
+ await otherButton.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+
+ const menu = await openProjectMenu(page, otherSlug)
+
+ await clickMenuItem(menu, /^Close$/i, { force: true })
+
+ await expect
+ .poll(() => {
+ const pathname = new URL(page.url()).pathname
+ if (new RegExp(`^/${slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
+ if (pathname === "/") return "home"
+ return ""
+ })
+ .toMatch(/^(project|home)$/)
+
+ await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
+ await expect(otherButton).toHaveCount(0)
+ },
+ { extra: [other] },
+ )
+ } finally {
+ await cleanupTestProject(other)
+ }
+})
diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts
new file mode 100644
index 0000000000..a817412cde
--- /dev/null
+++ b/packages/app/e2e/projects/projects-switch.spec.ts
@@ -0,0 +1,35 @@
+import { test, expect } from "../fixtures"
+import { defocus, createTestProject, cleanupTestProject } from "../actions"
+import { projectSwitchSelector } from "../selectors"
+import { dirSlug } from "../utils"
+
+test("can switch between projects from sidebar", async ({ page, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const other = await createTestProject()
+ const otherSlug = dirSlug(other)
+
+ try {
+ await withProject(
+ async ({ directory }) => {
+ await defocus(page)
+
+ const currentSlug = dirSlug(directory)
+ const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
+ await expect(otherButton).toBeVisible()
+ await otherButton.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
+
+ const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
+ await expect(currentButton).toBeVisible()
+ await currentButton.click()
+
+ await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
+ },
+ { extra: [other] },
+ )
+ } finally {
+ await cleanupTestProject(other)
+ }
+})
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/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts
new file mode 100644
index 0000000000..071c398b22
--- /dev/null
+++ b/packages/app/e2e/projects/workspaces.spec.ts
@@ -0,0 +1,375 @@
+import { base64Decode } from "@opencode-ai/util/encode"
+import fs from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+import type { Page } from "@playwright/test"
+
+import { test, expect } from "../fixtures"
+
+test.describe.configure({ mode: "serial" })
+import {
+ cleanupTestProject,
+ clickMenuItem,
+ confirmDialog,
+ openProjectMenu,
+ openSidebar,
+ openWorkspaceMenu,
+ setWorkspacesEnabled,
+} from "../actions"
+import {
+ inlineInputSelector,
+ projectSwitchSelector,
+ projectWorkspacesToggleSelector,
+ workspaceItemSelector,
+} from "../selectors"
+import { dirSlug } from "../utils"
+
+function slugFromUrl(url: string) {
+ return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
+}
+
+async function setupWorkspaceTest(page: Page, project: { slug: string }) {
+ const rootSlug = project.slug
+ await openSidebar(page)
+
+ await setWorkspacesEnabled(page, rootSlug, true)
+
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+ await expect
+ .poll(
+ () => {
+ const slug = slugFromUrl(page.url())
+ return slug.length > 0 && slug !== rootSlug
+ },
+ { timeout: 45_000 },
+ )
+ .toBe(true)
+
+ const slug = slugFromUrl(page.url())
+ const dir = base64Decode(slug)
+
+ 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)
+
+ return { rootSlug, slug, directory: dir }
+}
+
+test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ await withProject(async ({ slug }) => {
+ await openSidebar(page)
+
+ await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+ await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
+
+ await setWorkspacesEnabled(page, slug, true)
+ await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+ await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
+
+ await setWorkspacesEnabled(page, slug, false)
+ await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+ await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
+ })
+})
+
+test("can create a workspace", async ({ page, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ await withProject(async ({ slug }) => {
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, slug, true)
+
+ await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+
+ await expect
+ .poll(
+ () => {
+ const currentSlug = slugFromUrl(page.url())
+ return currentSlug.length > 0 && currentSlug !== slug
+ },
+ { timeout: 45_000 },
+ )
+ .toBe(true)
+
+ const workspaceSlug = slugFromUrl(page.url())
+ const workspaceDir = base64Decode(workspaceSlug)
+
+ await openSidebar(page)
+
+ await expect
+ .poll(
+ async () => {
+ const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
+ try {
+ await item.hover({ timeout: 500 })
+ return true
+ } catch {
+ return false
+ }
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(true)
+
+ await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
+
+ await cleanupTestProject(workspaceDir)
+ })
+})
+
+test("non-git projects keep workspace mode disabled", async ({ page, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
+ const nonGitSlug = dirSlug(nonGit)
+
+ await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
+
+ try {
+ await withProject(
+ async () => {
+ await openSidebar(page)
+
+ const nonGitButton = page.locator(projectSwitchSelector(nonGitSlug)).first()
+ await expect(nonGitButton).toBeVisible()
+ await nonGitButton.click()
+ await expect(page).toHaveURL(new RegExp(`/${nonGitSlug}/session`))
+
+ const menu = await openProjectMenu(page, nonGitSlug)
+ const toggle = menu.locator(projectWorkspacesToggleSelector(nonGitSlug)).first()
+
+ await expect(toggle).toBeVisible()
+ await expect(toggle).toBeDisabled()
+
+ await expect(menu.getByRole("menuitem", { name: "New workspace" })).toHaveCount(0)
+ await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
+ },
+ { extra: [nonGit] },
+ )
+ } finally {
+ await cleanupTestProject(nonGit)
+ }
+})
+
+test("can rename a workspace", async ({ page, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ await withProject(async (project) => {
+ const { slug } = await setupWorkspaceTest(page, project)
+
+ const rename = `e2e workspace ${Date.now()}`
+ const menu = await openWorkspaceMenu(page, slug)
+ await clickMenuItem(menu, /^Rename$/i, { force: true })
+
+ await expect(menu).toHaveCount(0)
+
+ const item = page.locator(workspaceItemSelector(slug)).first()
+ await expect(item).toBeVisible()
+ const input = item.locator(inlineInputSelector).first()
+ await expect(input).toBeVisible()
+ await input.fill(rename)
+ await input.press("Enter")
+ await expect(item).toContainText(rename)
+ })
+})
+
+test("can reset a workspace", async ({ page, sdk, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ await withProject(async (project) => {
+ const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
+
+ const readme = path.join(createdDir, "README.md")
+ const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
+ const original = await fs.readFile(readme, "utf8")
+ const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
+ await fs.writeFile(readme, dirty, "utf8")
+ await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
+
+ await expect
+ .poll(async () => {
+ return await fs
+ .stat(extra)
+ .then(() => true)
+ .catch(() => false)
+ })
+ .toBe(true)
+
+ await expect
+ .poll(async () => {
+ const files = await sdk.file
+ .status({ directory: createdDir })
+ .then((r) => r.data ?? [])
+ .catch(() => [])
+ return files.length
+ })
+ .toBeGreaterThan(0)
+
+ const menu = await openWorkspaceMenu(page, slug)
+ await clickMenuItem(menu, /^Reset$/i, { force: true })
+ await confirmDialog(page, /^Reset workspace$/i)
+
+ await expect
+ .poll(
+ async () => {
+ const files = await sdk.file
+ .status({ directory: createdDir })
+ .then((r) => r.data ?? [])
+ .catch(() => [])
+ return files.length
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(0)
+
+ await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
+
+ await expect
+ .poll(async () => {
+ return await fs
+ .stat(extra)
+ .then(() => true)
+ .catch(() => false)
+ })
+ .toBe(false)
+ })
+})
+
+test("can delete a workspace", async ({ page, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ await withProject(async (project) => {
+ const { rootSlug, slug } = await setupWorkspaceTest(page, project)
+
+ const menu = await openWorkspaceMenu(page, slug)
+ await clickMenuItem(menu, /^Delete$/i, { force: true })
+ await confirmDialog(page, /^Delete workspace$/i)
+
+ await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+ await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
+ await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
+ })
+})
+
+test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+ await withProject(async ({ slug: rootSlug }) => {
+ const workspaces = [] as { directory: string; slug: string }[]
+
+ const listSlugs = async () => {
+ const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
+ const slugs = await nodes.evaluateAll((els) => {
+ return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
+ })
+ return slugs
+ }
+
+ const waitReady = async (slug: string) => {
+ 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)
+ }
+
+ const drag = async (from: string, to: string) => {
+ const src = page.locator(workspaceItemSelector(from)).first()
+ const dst = page.locator(workspaceItemSelector(to)).first()
+
+ await src.scrollIntoViewIfNeeded()
+ await dst.scrollIntoViewIfNeeded()
+
+ const a = await src.boundingBox()
+ const b = await dst.boundingBox()
+ if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
+
+ await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
+ await page.mouse.up()
+ }
+
+ try {
+ await openSidebar(page)
+
+ await setWorkspacesEnabled(page, rootSlug, true)
+
+ for (const _ of [0, 1]) {
+ const prev = slugFromUrl(page.url())
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+ await expect
+ .poll(
+ () => {
+ const slug = slugFromUrl(page.url())
+ return slug.length > 0 && slug !== rootSlug && slug !== prev
+ },
+ { timeout: 45_000 },
+ )
+ .toBe(true)
+
+ const slug = slugFromUrl(page.url())
+ const dir = base64Decode(slug)
+ workspaces.push({ slug, directory: dir })
+
+ await openSidebar(page)
+ }
+
+ if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
+
+ const a = workspaces[0].slug
+ const b = workspaces[1].slug
+
+ await waitReady(a)
+ await waitReady(b)
+
+ const list = async () => {
+ const slugs = await listSlugs()
+ return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
+ }
+
+ await expect
+ .poll(async () => {
+ const slugs = await list()
+ return slugs.length === 2
+ })
+ .toBe(true)
+
+ const before = await list()
+ const from = before[1]
+ const to = before[0]
+ if (!from || !to) throw new Error("Failed to resolve initial workspace order")
+
+ await drag(from, to)
+
+ await expect.poll(async () => await list()).toEqual([from, to])
+ } finally {
+ await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
+ }
+ })
+})
diff --git a/packages/app/e2e/context.spec.ts b/packages/app/e2e/prompt/context.spec.ts
similarity index 61%
rename from packages/app/e2e/context.spec.ts
rename to packages/app/e2e/prompt/context.spec.ts
index beabd2eb7d..80aa9ea334 100644
--- a/packages/app/e2e/context.spec.ts
+++ b/packages/app/e2e/prompt/context.spec.ts
@@ -1,16 +1,13 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { withSession } from "../actions"
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}`
- const created = await sdk.session.create({ title }).then((r) => r.data)
- if (!created?.id) throw new Error("Session create did not return an id")
- const sessionID = created.id
-
- try {
+ await withSession(sdk, title, async (session) => {
await sdk.session.promptAsync({
- sessionID,
+ sessionID: session.id,
noReply: true,
parts: [
{
@@ -22,12 +19,12 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
await expect
.poll(async () => {
- const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
+ const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
- await gotoSession(sessionID)
+ await gotoSession(session.id)
const contextButton = page
.locator('[data-component="button"]')
@@ -39,7 +36,5 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
- } finally {
- await sdk.session.delete({ sessionID }).catch(() => undefined)
- }
+ })
})
diff --git a/packages/app/e2e/prompt-mention.spec.ts b/packages/app/e2e/prompt/prompt-mention.spec.ts
similarity index 90%
rename from packages/app/e2e/prompt-mention.spec.ts
rename to packages/app/e2e/prompt/prompt-mention.spec.ts
index 113b8465f7..5cc9f6e685 100644
--- a/packages/app/e2e/prompt-mention.spec.ts
+++ b/packages/app/e2e/prompt/prompt-mention.spec.ts
@@ -1,5 +1,5 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
await gotoSession()
diff --git a/packages/app/e2e/prompt-slash-open.spec.ts b/packages/app/e2e/prompt/prompt-slash-open.spec.ts
similarity index 86%
rename from packages/app/e2e/prompt-slash-open.spec.ts
rename to packages/app/e2e/prompt/prompt-slash-open.spec.ts
index 3c29d405c1..b4a93099d9 100644
--- a/packages/app/e2e/prompt-slash-open.spec.ts
+++ b/packages/app/e2e/prompt/prompt-slash-open.spec.ts
@@ -1,5 +1,5 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
await gotoSession()
diff --git a/packages/app/e2e/prompt.spec.ts b/packages/app/e2e/prompt/prompt.spec.ts
similarity index 89%
rename from packages/app/e2e/prompt.spec.ts
rename to packages/app/e2e/prompt/prompt.spec.ts
index 3e5892ce8d..07d242c634 100644
--- a/packages/app/e2e/prompt.spec.ts
+++ b/packages/app/e2e/prompt/prompt.spec.ts
@@ -1,10 +1,6 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
-
-function sessionIDFromUrl(url: string) {
- const match = /\/session\/([^/?#]+)/.exec(url)
- return match?.[1]
-}
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { sessionIDFromUrl, withSession } from "../actions"
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)
diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts
new file mode 100644
index 0000000000..842433891e
--- /dev/null
+++ b/packages/app/e2e/selectors.ts
@@ -0,0 +1,60 @@
+export const promptSelector = '[data-component="prompt-input"]'
+export const terminalSelector = '[data-component="terminal"]'
+
+export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
+export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
+export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
+export const settingsThemeSelector = '[data-action="settings-theme"]'
+export const settingsFontSelector = '[data-action="settings-font"]'
+export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
+export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
+export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
+export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
+export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
+export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
+export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
+export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
+
+export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
+
+export const projectSwitchSelector = (slug: string) =>
+ `${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
+
+export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
+
+export const projectMenuTriggerSelector = (slug: string) =>
+ `${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
+
+export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
+
+export const projectWorkspacesToggleSelector = (slug: string) =>
+ `[data-action="project-workspaces-toggle"][data-project="${slug}"]`
+
+export const titlebarRightSelector = "#opencode-titlebar-right"
+
+export const popoverBodySelector = '[data-slot="popover-body"]'
+
+export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
+
+export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
+
+export const inlineInputSelector = '[data-component="inline-input"]'
+
+export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
+
+export const workspaceItemSelector = (slug: string) =>
+ `${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
+
+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}"]`
+
+export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
+
+export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]`
diff --git a/packages/app/e2e/session.spec.ts b/packages/app/e2e/session.spec.ts
deleted file mode 100644
index 19e25a4213..0000000000
--- a/packages/app/e2e/session.spec.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { test, expect } from "./fixtures"
-import { promptSelector } from "./utils"
-
-test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
- const title = `e2e smoke ${Date.now()}`
- const created = await sdk.session.create({ title }).then((r) => r.data)
-
- if (!created?.id) throw new Error("Session create did not return an id")
- const sessionID = created.id
-
- try {
- await gotoSession(sessionID)
-
- const prompt = page.locator(promptSelector)
- await prompt.click()
- await page.keyboard.type("hello from e2e")
- await expect(prompt).toContainText("hello from e2e")
- } finally {
- await sdk.session.delete({ sessionID }).catch(() => undefined)
- }
-})
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..2a250dd866
--- /dev/null
+++ b/packages/app/e2e/session/session-undo-redo.spec.ts
@@ -0,0 +1,235 @@
+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" &&
+ m.parts.filter((p) => p.type === "text").some((p) => p.text.includes(input.token)),
+ )
+ if (users.length === 0) return false
+
+ const user = users[users.length - 1]
+ if (!user) return false
+ 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()
+ })
+ })
+})
+
+test("slash undo/redo traverses multi-step revert stack", async ({ page, withProject }) => {
+ test.setTimeout(120_000)
+
+ const firstToken = `undo_redo_first_${Date.now()}`
+ const secondToken = `undo_redo_second_${Date.now()}`
+
+ await withProject(async (project) => {
+ const sdk = createSdk(project.directory)
+
+ await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
+ await project.gotoSession(session.id)
+
+ const first = await seedConversation({
+ page,
+ sdk,
+ sessionID: session.id,
+ token: firstToken,
+ })
+ const second = await seedConversation({
+ page,
+ sdk,
+ sessionID: session.id,
+ token: secondToken,
+ })
+
+ expect(first.userMessageID).not.toBe(second.userMessageID)
+
+ const firstMessage = page.locator(`[data-message-id="${first.userMessageID}"]`)
+ const secondMessage = page.locator(`[data-message-id="${second.userMessageID}"]`)
+
+ await expect(firstMessage.first()).toBeVisible()
+ await expect(secondMessage.first()).toBeVisible()
+
+ await second.prompt.click()
+ await page.keyboard.press(`${modKey}+A`)
+ await page.keyboard.press("Backspace")
+ 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(second.userMessageID)
+
+ await expect(firstMessage.first()).toBeVisible()
+ await expect(secondMessage).toHaveCount(0)
+
+ await second.prompt.click()
+ await page.keyboard.press(`${modKey}+A`)
+ await page.keyboard.press("Backspace")
+ await page.keyboard.type("/undo")
+ 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(first.userMessageID)
+
+ await expect(firstMessage).toHaveCount(0)
+ await expect(secondMessage).toHaveCount(0)
+
+ await second.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,
+ })
+ .toBe(second.userMessageID)
+
+ await expect(firstMessage.first()).toBeVisible()
+ await expect(secondMessage).toHaveCount(0)
+
+ await second.prompt.click()
+ await page.keyboard.press(`${modKey}+A`)
+ await page.keyboard.press("Backspace")
+ await page.keyboard.type("/redo")
+ 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(firstMessage.first()).toBeVisible()
+ await expect(secondMessage.first()).toBeVisible()
+ })
+ })
+})
diff --git a/packages/app/e2e/session/session.spec.ts b/packages/app/e2e/session/session.spec.ts
new file mode 100644
index 0000000000..4610fb3315
--- /dev/null
+++ b/packages/app/e2e/session/session.spec.ts
@@ -0,0 +1,157 @@
+import { test, expect } from "../fixtures"
+import {
+ openSidebar,
+ openSessionMoreMenu,
+ clickMenuItem,
+ confirmDialog,
+ openSharePopover,
+ withSession,
+} from "../actions"
+import { sessionItemSelector, inlineInputSelector } from "../selectors"
+
+const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
+
+type Sdk = Parameters[0]
+
+async function seedMessage(sdk: Sdk, sessionID: string) {
+ await sdk.session.promptAsync({
+ sessionID,
+ noReply: true,
+ parts: [{ type: "text", text: "e2e seed" }],
+ })
+
+ await expect
+ .poll(
+ async () => {
+ const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
+ return messages.length
+ },
+ { timeout: 30_000 },
+ )
+ .toBeGreaterThan(0)
+}
+
+test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
+ const stamp = Date.now()
+ const originalTitle = `e2e rename test ${stamp}`
+ const newTitle = `e2e renamed ${stamp}`
+
+ await withSession(sdk, originalTitle, async (session) => {
+ await seedMessage(sdk, session.id)
+ await gotoSession(session.id)
+
+ const menu = await openSessionMoreMenu(page, session.id)
+ await clickMenuItem(menu, /rename/i)
+
+ const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
+ await expect(input).toBeVisible()
+ await input.fill(newTitle)
+ await input.press("Enter")
+
+ await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
+ })
+})
+
+test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
+ const stamp = Date.now()
+ const title = `e2e archive test ${stamp}`
+
+ await withSession(sdk, title, async (session) => {
+ await seedMessage(sdk, session.id)
+ await gotoSession(session.id)
+ const menu = await openSessionMoreMenu(page, session.id)
+ await clickMenuItem(menu, /archive/i)
+
+ await expect
+ .poll(
+ async () => {
+ const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+ return data?.time?.archived
+ },
+ { timeout: 30_000 },
+ )
+ .not.toBeUndefined()
+
+ await openSidebar(page)
+ await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
+ })
+})
+
+test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
+ const stamp = Date.now()
+ const title = `e2e delete test ${stamp}`
+
+ await withSession(sdk, title, async (session) => {
+ await seedMessage(sdk, session.id)
+ await gotoSession(session.id)
+ const menu = await openSessionMoreMenu(page, session.id)
+ await clickMenuItem(menu, /delete/i)
+ await confirmDialog(page, /delete/i)
+
+ await expect
+ .poll(
+ async () => {
+ const data = await sdk.session
+ .get({ sessionID: session.id })
+ .then((r) => r.data)
+ .catch(() => undefined)
+ return data?.id
+ },
+ { timeout: 30_000 },
+ )
+ .toBeUndefined()
+
+ await openSidebar(page)
+ await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
+ })
+})
+
+test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
+ test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
+
+ const stamp = Date.now()
+ const title = `e2e share test ${stamp}`
+
+ await withSession(sdk, title, async (session) => {
+ await seedMessage(sdk, session.id)
+ await gotoSession(session.id)
+
+ const { rightSection, popoverBody } = await openSharePopover(page)
+ await popoverBody.getByRole("button", { name: "Publish" }).first().click()
+
+ await expect
+ .poll(
+ async () => {
+ const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+ return data?.share?.url || undefined
+ },
+ { timeout: 30_000 },
+ )
+ .not.toBeUndefined()
+
+ const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
+ await expect(copyButton).toBeVisible({ timeout: 30_000 })
+
+ const sharedPopover = await openSharePopover(page)
+ const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
+ await expect(unpublish).toBeVisible({ timeout: 30_000 })
+ await unpublish.click()
+
+ await expect
+ .poll(
+ async () => {
+ const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
+ return data?.share?.url || undefined
+ },
+ { timeout: 30_000 },
+ )
+ .toBeUndefined()
+
+ await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
+
+ const unsharedPopover = await openSharePopover(page)
+ await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
+ timeout: 30_000,
+ })
+ })
+})
diff --git a/packages/app/e2e/settings-providers.spec.ts b/packages/app/e2e/settings-providers.spec.ts
deleted file mode 100644
index 326a9fad1d..0000000000
--- a/packages/app/e2e/settings-providers.spec.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import { test, expect } from "./fixtures"
-import { modKey, promptSelector } from "./utils"
-
-test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
- await gotoSession()
-
- const dialog = page.getByRole("dialog")
-
- await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
-
- const opened = await dialog
- .waitFor({ state: "visible", timeout: 3000 })
- .then(() => true)
- .catch(() => false)
-
- if (!opened) {
- await page.getByRole("button", { name: "Settings" }).first().click()
- await expect(dialog).toBeVisible()
- }
-
- await dialog.getByRole("tab", { name: "Providers" }).click()
- await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
- await expect(dialog.getByText("Popular providers", { exact: true })).toBeVisible()
-
- await dialog.getByRole("button", { name: "Show more providers" }).click()
-
- const providerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder("Search providers") })
-
- await expect(providerDialog).toBeVisible()
- await expect(providerDialog.getByPlaceholder("Search providers")).toBeVisible()
- await expect(providerDialog.locator('[data-slot="list-item"]').first()).toBeVisible()
-
- await page.keyboard.press("Escape")
- await expect(providerDialog).toHaveCount(0)
- await expect(page.locator(promptSelector)).toBeVisible()
-
- const stillOpen = await dialog.isVisible().catch(() => false)
- if (!stillOpen) return
-
- await page.keyboard.press("Escape")
- const closed = await dialog
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (closed) return
-
- await page.keyboard.press("Escape")
- const closedSecond = await dialog
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
- if (closedSecond) return
-
- await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
- await expect(dialog).toHaveCount(0)
-})
diff --git a/packages/app/e2e/settings.spec.ts b/packages/app/e2e/settings.spec.ts
deleted file mode 100644
index 09dc942cc9..0000000000
--- a/packages/app/e2e/settings.spec.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import { test, expect } from "./fixtures"
-import { modKey } from "./utils"
-
-test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
- await gotoSession()
-
- const dialog = page.getByRole("dialog")
-
- await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
-
- const opened = await dialog
- .waitFor({ state: "visible", timeout: 3000 })
- .then(() => true)
- .catch(() => false)
-
- if (!opened) {
- await page.getByRole("button", { name: "Settings" }).first().click()
- await expect(dialog).toBeVisible()
- }
-
- await dialog.getByRole("tab", { name: "Shortcuts" }).click()
- await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
- await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
-
- await page.keyboard.press("Escape")
-
- const closed = await dialog
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
-
- if (closed) return
-
- await page.keyboard.press("Escape")
- const closedSecond = await dialog
- .waitFor({ state: "detached", timeout: 1500 })
- .then(() => true)
- .catch(() => false)
-
- if (closedSecond) return
-
- await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
- await expect(dialog).toHaveCount(0)
-})
diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts
new file mode 100644
index 0000000000..5e98bd158a
--- /dev/null
+++ b/packages/app/e2e/settings/settings-keybinds.spec.ts
@@ -0,0 +1,392 @@
+import { test, expect } from "../fixtures"
+import { openSettings, closeDialog, withSession } from "../actions"
+import { keybindButtonSelector, terminalSelector } from "../selectors"
+import { modKey } from "../utils"
+
+test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+ const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle")).first()
+ await expect(keybindButton).toBeVisible()
+
+ const initialKeybind = await keybindButton.textContent()
+ expect(initialKeybind).toContain("B")
+
+ await keybindButton.click()
+ await expect(keybindButton).toHaveText(/press/i)
+
+ await page.keyboard.press(`${modKey}+Shift+KeyH`)
+ await page.waitForTimeout(100)
+
+ const newKeybind = await keybindButton.textContent()
+ expect(newKeybind).toContain("H")
+
+ const stored = await page.evaluate(() => {
+ const raw = localStorage.getItem("settings.v3")
+ return raw ? JSON.parse(raw) : null
+ })
+ expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h")
+
+ await closeDialog(page, dialog)
+
+ const main = page.locator("main")
+ const initialClasses = (await main.getAttribute("class")) ?? ""
+ const initiallyClosed = initialClasses.includes("xl:border-l")
+
+ await page.keyboard.press(`${modKey}+Shift+H`)
+ await page.waitForTimeout(100)
+
+ const afterToggleClasses = (await main.getAttribute("class")) ?? ""
+ const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
+ expect(afterToggleClosed).toBe(!initiallyClosed)
+
+ await page.keyboard.press(`${modKey}+Shift+H`)
+ await page.waitForTimeout(100)
+
+ const finalClasses = (await main.getAttribute("class")) ?? ""
+ const finalClosed = finalClasses.includes("xl:border-l")
+ expect(finalClosed).toBe(initiallyClosed)
+})
+
+test("sidebar toggle keybind guards against shortcut conflicts", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+ const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
+ await expect(keybindButton).toBeVisible()
+
+ const initialKeybind = await keybindButton.textContent()
+ expect(initialKeybind).toContain("B")
+
+ await keybindButton.click()
+ await expect(keybindButton).toHaveText(/press/i)
+
+ await page.keyboard.press(`${modKey}+Shift+KeyP`)
+ await page.waitForTimeout(100)
+
+ const toast = page.locator('[data-component="toast"]').last()
+ await expect(toast).toBeVisible()
+ await expect(toast).toContainText(/already/i)
+
+ await keybindButton.click()
+ await expect(keybindButton).toContainText("B")
+
+ const stored = await page.evaluate(() => {
+ const raw = localStorage.getItem("settings.v3")
+ return raw ? JSON.parse(raw) : null
+ })
+ expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
+
+ await closeDialog(page, dialog)
+})
+
+test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
+ await page.addInitScript(() => {
+ localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
+ })
+
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+ const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
+ await expect(keybindButton).toBeVisible()
+
+ const customKeybind = await keybindButton.textContent()
+ expect(customKeybind).toContain("X")
+
+ const resetButton = dialog.getByRole("button", { name: "Reset to defaults" })
+ await expect(resetButton).toBeVisible()
+ await expect(resetButton).toBeEnabled()
+ await resetButton.click()
+ await page.waitForTimeout(100)
+
+ const restoredKeybind = await keybindButton.textContent()
+ expect(restoredKeybind).toContain("B")
+
+ const stored = await page.evaluate(() => {
+ const raw = localStorage.getItem("settings.v3")
+ return raw ? JSON.parse(raw) : null
+ })
+ expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
+
+ await closeDialog(page, dialog)
+})
+
+test("clearing a keybind works", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+ const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
+ await expect(keybindButton).toBeVisible()
+
+ const initialKeybind = await keybindButton.textContent()
+ expect(initialKeybind).toContain("B")
+
+ await keybindButton.click()
+ await expect(keybindButton).toHaveText(/press/i)
+
+ await page.keyboard.press("Delete")
+ await page.waitForTimeout(100)
+
+ const clearedKeybind = await keybindButton.textContent()
+ expect(clearedKeybind).toMatch(/unassigned|press/i)
+
+ const stored = await page.evaluate(() => {
+ const raw = localStorage.getItem("settings.v3")
+ return raw ? JSON.parse(raw) : null
+ })
+ expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none")
+
+ await closeDialog(page, dialog)
+
+ await page.keyboard.press(`${modKey}+B`)
+ await page.waitForTimeout(100)
+
+ const stillOnSession = page.url().includes("/session")
+ expect(stillOnSession).toBe(true)
+})
+
+test("changing settings open keybind works", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+ const keybindButton = dialog.locator(keybindButtonSelector("settings.open"))
+ await expect(keybindButton).toBeVisible()
+
+ const initialKeybind = await keybindButton.textContent()
+ expect(initialKeybind).toContain(",")
+
+ await keybindButton.click()
+ await expect(keybindButton).toHaveText(/press/i)
+
+ await page.keyboard.press(`${modKey}+Slash`)
+ await page.waitForTimeout(100)
+
+ const newKeybind = await keybindButton.textContent()
+ expect(newKeybind).toContain("/")
+
+ const stored = await page.evaluate(() => {
+ const raw = localStorage.getItem("settings.v3")
+ return raw ? JSON.parse(raw) : null
+ })
+ expect(stored?.keybinds?.["settings.open"]).toBe("mod+/")
+
+ await closeDialog(page, dialog)
+
+ const settingsDialog = page.getByRole("dialog")
+ await expect(settingsDialog).toHaveCount(0)
+
+ await page.keyboard.press(`${modKey}+Slash`)
+ await page.waitForTimeout(100)
+
+ await expect(settingsDialog).toBeVisible()
+
+ await closeDialog(page, settingsDialog)
+})
+
+test("changing new session keybind works", async ({ page, sdk, gotoSession }) => {
+ await withSession(sdk, "test session for keybind", async (session) => {
+ await gotoSession(session.id)
+
+ const initialUrl = page.url()
+ expect(initialUrl).toContain(`/session/${session.id}`)
+
+ const dialog = await openSettings(page)
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+ const keybindButton = dialog.locator(keybindButtonSelector("session.new"))
+ await expect(keybindButton).toBeVisible()
+
+ await keybindButton.click()
+ await expect(keybindButton).toHaveText(/press/i)
+
+ await page.keyboard.press(`${modKey}+Shift+KeyN`)
+ await page.waitForTimeout(100)
+
+ const newKeybind = await keybindButton.textContent()
+ expect(newKeybind).toContain("N")
+
+ const stored = await page.evaluate(() => {
+ const raw = localStorage.getItem("settings.v3")
+ return raw ? JSON.parse(raw) : null
+ })
+ expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n")
+
+ await closeDialog(page, dialog)
+
+ await page.keyboard.press(`${modKey}+Shift+N`)
+ await page.waitForTimeout(200)
+
+ const newUrl = page.url()
+ expect(newUrl).toMatch(/\/session\/?$/)
+ expect(newUrl).not.toContain(session.id)
+ })
+})
+
+test("changing file open keybind works", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+ const keybindButton = dialog.locator(keybindButtonSelector("file.open"))
+ await expect(keybindButton).toBeVisible()
+
+ const initialKeybind = await keybindButton.textContent()
+ expect(initialKeybind).toContain("P")
+
+ await keybindButton.click()
+ await expect(keybindButton).toHaveText(/press/i)
+
+ await page.keyboard.press(`${modKey}+Shift+KeyF`)
+ await page.waitForTimeout(100)
+
+ const newKeybind = await keybindButton.textContent()
+ expect(newKeybind).toContain("F")
+
+ const stored = await page.evaluate(() => {
+ const raw = localStorage.getItem("settings.v3")
+ return raw ? JSON.parse(raw) : null
+ })
+ expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f")
+
+ await closeDialog(page, dialog)
+
+ const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) })
+ await expect(filePickerDialog).toHaveCount(0)
+
+ await page.keyboard.press(`${modKey}+Shift+F`)
+ await page.waitForTimeout(100)
+
+ await expect(filePickerDialog).toBeVisible()
+
+ await page.keyboard.press("Escape")
+ await expect(filePickerDialog).toHaveCount(0)
+})
+
+test("changing terminal toggle keybind works", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+ const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
+ await expect(keybindButton).toBeVisible()
+
+ await keybindButton.click()
+ await expect(keybindButton).toHaveText(/press/i)
+
+ await page.keyboard.press(`${modKey}+KeyY`)
+ await page.waitForTimeout(100)
+
+ const newKeybind = await keybindButton.textContent()
+ expect(newKeybind).toContain("Y")
+
+ const stored = await page.evaluate(() => {
+ const raw = localStorage.getItem("settings.v3")
+ return raw ? JSON.parse(raw) : null
+ })
+ expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y")
+
+ await closeDialog(page, dialog)
+
+ const terminal = page.locator(terminalSelector)
+ await expect(terminal).not.toBeVisible()
+
+ await page.keyboard.press(`${modKey}+Y`)
+ await expect(terminal).toBeVisible()
+
+ await page.keyboard.press(`${modKey}+Y`)
+ await expect(terminal).not.toBeVisible()
+})
+
+test("terminal toggle keybind persists after reload", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+ const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
+ await expect(keybindButton).toBeVisible()
+
+ await keybindButton.click()
+ await expect(keybindButton).toHaveText(/press/i)
+
+ await page.keyboard.press(`${modKey}+Shift+KeyY`)
+ await page.waitForTimeout(100)
+
+ await expect(keybindButton).toContainText("Y")
+ await closeDialog(page, dialog)
+
+ await page.reload()
+
+ await expect
+ .poll(async () => {
+ return await page.evaluate(() => {
+ const raw = localStorage.getItem("settings.v3")
+ if (!raw) return
+ const parsed = JSON.parse(raw)
+ return parsed?.keybinds?.["terminal.toggle"]
+ })
+ })
+ .toBe("mod+shift+y")
+
+ const reloaded = await openSettings(page)
+ await reloaded.getByRole("tab", { name: "Shortcuts" }).click()
+ const reloadedKeybind = reloaded.locator(keybindButtonSelector("terminal.toggle")).first()
+ await expect(reloadedKeybind).toContainText("Y")
+ await closeDialog(page, reloaded)
+})
+
+test("changing command palette keybind works", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+
+ const keybindButton = dialog.locator(keybindButtonSelector("command.palette"))
+ await expect(keybindButton).toBeVisible()
+
+ const initialKeybind = await keybindButton.textContent()
+ expect(initialKeybind).toContain("P")
+
+ await keybindButton.click()
+ await expect(keybindButton).toHaveText(/press/i)
+
+ await page.keyboard.press(`${modKey}+Shift+KeyK`)
+ await page.waitForTimeout(100)
+
+ const newKeybind = await keybindButton.textContent()
+ expect(newKeybind).toContain("K")
+
+ const stored = await page.evaluate(() => {
+ const raw = localStorage.getItem("settings.v3")
+ return raw ? JSON.parse(raw) : null
+ })
+ expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k")
+
+ await closeDialog(page, dialog)
+
+ const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() })
+ await expect(palette).toHaveCount(0)
+
+ await page.keyboard.press(`${modKey}+Shift+K`)
+ await page.waitForTimeout(100)
+
+ await expect(palette).toBeVisible()
+ await expect(palette.getByRole("textbox").first()).toBeVisible()
+
+ await page.keyboard.press("Escape")
+ await expect(palette).toHaveCount(0)
+})
diff --git a/packages/app/e2e/settings/settings-models.spec.ts b/packages/app/e2e/settings/settings-models.spec.ts
new file mode 100644
index 0000000000..f7397abe86
--- /dev/null
+++ b/packages/app/e2e/settings/settings-models.spec.ts
@@ -0,0 +1,122 @@
+import { test, expect } from "../fixtures"
+import { promptSelector } from "../selectors"
+import { closeDialog, openSettings } from "../actions"
+
+test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ await page.locator(promptSelector).click()
+ await page.keyboard.type("/model")
+
+ const command = page.locator('[data-slash-id="model.choose"]')
+ await expect(command).toBeVisible()
+ await command.hover()
+ await page.keyboard.press("Enter")
+
+ const picker = page.getByRole("dialog")
+ await expect(picker).toBeVisible()
+
+ const target = picker.locator('[data-slot="list-item"]').first()
+ await expect(target).toBeVisible()
+
+ const key = await target.getAttribute("data-key")
+ if (!key) throw new Error("Failed to resolve model key from list item")
+
+ const name = (await target.locator("span").first().innerText()).trim()
+ if (!name) throw new Error("Failed to resolve model name from list item")
+
+ await page.keyboard.press("Escape")
+ await expect(picker).toHaveCount(0)
+
+ const settings = await openSettings(page)
+
+ await settings.getByRole("tab", { name: "Models" }).click()
+ const search = settings.getByPlaceholder("Search models")
+ await expect(search).toBeVisible()
+ await search.fill(name)
+
+ const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
+ const input = toggle.locator('[data-slot="switch-input"]')
+ await expect(toggle).toBeVisible()
+ await expect(input).toHaveAttribute("aria-checked", "true")
+ await toggle.locator('[data-slot="switch-control"]').click()
+ await expect(input).toHaveAttribute("aria-checked", "false")
+
+ await closeDialog(page, settings)
+
+ await page.locator(promptSelector).click()
+ await page.keyboard.type("/model")
+ await expect(command).toBeVisible()
+ await command.hover()
+ await page.keyboard.press("Enter")
+
+ const pickerAgain = page.getByRole("dialog")
+ await expect(pickerAgain).toBeVisible()
+ await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
+
+ await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
+
+ await page.keyboard.press("Escape")
+ await expect(pickerAgain).toHaveCount(0)
+})
+
+test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ await page.locator(promptSelector).click()
+ await page.keyboard.type("/model")
+
+ const command = page.locator('[data-slash-id="model.choose"]')
+ await expect(command).toBeVisible()
+ await command.hover()
+ await page.keyboard.press("Enter")
+
+ const picker = page.getByRole("dialog")
+ await expect(picker).toBeVisible()
+
+ const target = picker.locator('[data-slot="list-item"]').first()
+ await expect(target).toBeVisible()
+
+ const key = await target.getAttribute("data-key")
+ if (!key) throw new Error("Failed to resolve model key from list item")
+
+ const name = (await target.locator("span").first().innerText()).trim()
+ if (!name) throw new Error("Failed to resolve model name from list item")
+
+ await page.keyboard.press("Escape")
+ await expect(picker).toHaveCount(0)
+
+ const settings = await openSettings(page)
+
+ await settings.getByRole("tab", { name: "Models" }).click()
+ const search = settings.getByPlaceholder("Search models")
+ await expect(search).toBeVisible()
+ await search.fill(name)
+
+ const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
+ const input = toggle.locator('[data-slot="switch-input"]')
+ await expect(toggle).toBeVisible()
+ await expect(input).toHaveAttribute("aria-checked", "true")
+
+ await toggle.locator('[data-slot="switch-control"]').click()
+ await expect(input).toHaveAttribute("aria-checked", "false")
+
+ await toggle.locator('[data-slot="switch-control"]').click()
+ await expect(input).toHaveAttribute("aria-checked", "true")
+
+ await closeDialog(page, settings)
+
+ await page.locator(promptSelector).click()
+ await page.keyboard.type("/model")
+ await expect(command).toBeVisible()
+ await command.hover()
+ await page.keyboard.press("Enter")
+
+ const pickerAgain = page.getByRole("dialog")
+ await expect(pickerAgain).toBeVisible()
+
+ await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible()
+
+ await page.keyboard.press("Escape")
+ await expect(pickerAgain).toHaveCount(0)
+})
diff --git a/packages/app/e2e/settings/settings-providers.spec.ts b/packages/app/e2e/settings/settings-providers.spec.ts
new file mode 100644
index 0000000000..a55eb34981
--- /dev/null
+++ b/packages/app/e2e/settings/settings-providers.spec.ts
@@ -0,0 +1,136 @@
+import { test, expect } from "../fixtures"
+import { closeDialog, openSettings } from "../actions"
+
+test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const settings = await openSettings(page)
+ await settings.getByRole("tab", { name: "Providers" }).click()
+
+ const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+ await expect(customProviderSection).toBeVisible()
+
+ const connectButton = customProviderSection.getByRole("button", { name: "Connect" })
+ await connectButton.click()
+
+ const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
+ await expect(providerDialog).toBeVisible()
+
+ await providerDialog.getByLabel("Provider ID").fill("test-provider")
+ await providerDialog.getByLabel("Display name").fill("Test Provider")
+ await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake")
+ await providerDialog.getByLabel("API key").fill("fake-key")
+
+ await providerDialog.getByPlaceholder("model-id").first().fill("test-model")
+ await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model")
+
+ await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider")
+ await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider")
+ await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake")
+ await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key")
+ await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model")
+ await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model")
+
+ await page.keyboard.press("Escape")
+ await expect(providerDialog).toHaveCount(0)
+
+ await closeDialog(page, settings)
+})
+
+test("custom provider form shows validation errors", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const settings = await openSettings(page)
+ await settings.getByRole("tab", { name: "Providers" }).click()
+
+ const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+ await customProviderSection.getByRole("button", { name: "Connect" }).click()
+
+ const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
+ await expect(providerDialog).toBeVisible()
+
+ await providerDialog.getByLabel("Provider ID").fill("invalid provider id")
+ await providerDialog.getByLabel("Base URL").fill("not-a-url")
+
+ await providerDialog.getByRole("button", { name: /submit|save/i }).click()
+
+ await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible()
+ await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible()
+
+ await page.keyboard.press("Escape")
+ await expect(providerDialog).toHaveCount(0)
+
+ await closeDialog(page, settings)
+})
+
+test("custom provider form can add and remove models", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const settings = await openSettings(page)
+ await settings.getByRole("tab", { name: "Providers" }).click()
+
+ const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+ await customProviderSection.getByRole("button", { name: "Connect" }).click()
+
+ const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
+ await expect(providerDialog).toBeVisible()
+
+ await providerDialog.getByLabel("Provider ID").fill("multi-model-test")
+ await providerDialog.getByLabel("Display name").fill("Multi Model Test")
+ await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi")
+
+ await providerDialog.getByPlaceholder("model-id").first().fill("model-1")
+ await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1")
+
+ const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count()
+ await providerDialog.getByRole("button", { name: "Add model" }).click()
+ const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count()
+ expect(idInputsAfter).toBe(idInputsBefore + 1)
+
+ await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2")
+ await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2")
+
+ await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2")
+ await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2")
+
+ await page.keyboard.press("Escape")
+ await expect(providerDialog).toHaveCount(0)
+
+ await closeDialog(page, settings)
+})
+
+test("custom provider form can add and remove headers", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const settings = await openSettings(page)
+ await settings.getByRole("tab", { name: "Providers" }).click()
+
+ const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
+ await customProviderSection.getByRole("button", { name: "Connect" }).click()
+
+ const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
+ await expect(providerDialog).toBeVisible()
+
+ await providerDialog.getByLabel("Provider ID").fill("header-test")
+ await providerDialog.getByLabel("Display name").fill("Header Test")
+ await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers")
+
+ await providerDialog.getByPlaceholder("model-id").first().fill("model-x")
+ await providerDialog.getByPlaceholder("Display Name").first().fill("Model X")
+
+ const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count()
+ await providerDialog.getByRole("button", { name: "Add header" }).click()
+ const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count()
+ expect(headerInputsAfter).toBe(headerInputsBefore + 1)
+
+ await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization")
+ await providerDialog.getByPlaceholder("value").first().fill("Bearer token123")
+
+ await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization")
+ await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123")
+
+ await page.keyboard.press("Escape")
+ await expect(providerDialog).toHaveCount(0)
+
+ await closeDialog(page, settings)
+})
diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts
new file mode 100644
index 0000000000..42534968b2
--- /dev/null
+++ b/packages/app/e2e/settings/settings.spec.ts
@@ -0,0 +1,454 @@
+import { test, expect, settingsKey } from "../fixtures"
+import { closeDialog, openSettings } from "../actions"
+import {
+ settingsColorSchemeSelector,
+ settingsFontSelector,
+ settingsLanguageSelectSelector,
+ settingsNotificationsAgentSelector,
+ settingsNotificationsErrorsSelector,
+ settingsNotificationsPermissionsSelector,
+ settingsReleaseNotesSelector,
+ settingsSoundsAgentSelector,
+ settingsSoundsErrorsSelector,
+ settingsSoundsPermissionsSelector,
+ settingsThemeSelector,
+ settingsUpdatesStartupSelector,
+} from "../selectors"
+
+test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+
+ await dialog.getByRole("tab", { name: "Shortcuts" }).click()
+ await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
+ await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
+
+ await closeDialog(page, dialog)
+})
+
+test("changing language updates settings labels", async ({ page, gotoSession }) => {
+ await page.addInitScript(() => {
+ localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
+ })
+
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+
+ const heading = dialog.getByRole("heading", { level: 2 })
+ await expect(heading).toHaveText("General")
+
+ const select = dialog.locator(settingsLanguageSelectSelector)
+ await expect(select).toBeVisible()
+ await select.locator('[data-slot="select-select-trigger"]').click()
+
+ await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
+
+ await expect(heading).toHaveText("Allgemein")
+
+ await select.locator('[data-slot="select-select-trigger"]').click()
+ await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
+ await expect(heading).toHaveText("General")
+})
+
+test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ const select = dialog.locator(settingsColorSchemeSelector)
+ await expect(select).toBeVisible()
+
+ await select.locator('[data-slot="select-select-trigger"]').click()
+ await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
+
+ const colorScheme = await page.evaluate(() => {
+ return document.documentElement.getAttribute("data-color-scheme")
+ })
+ expect(colorScheme).toBe("dark")
+
+ await select.locator('[data-slot="select-select-trigger"]').click()
+ await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click()
+
+ const lightColorScheme = await page.evaluate(() => {
+ return document.documentElement.getAttribute("data-color-scheme")
+ })
+ expect(lightColorScheme).toBe("light")
+})
+
+test("changing theme persists in localStorage", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ const select = dialog.locator(settingsThemeSelector)
+ await expect(select).toBeVisible()
+
+ await select.locator('[data-slot="select-select-trigger"]').click()
+
+ const items = page.locator('[data-slot="select-select-item"]')
+ const count = await items.count()
+ expect(count).toBeGreaterThan(1)
+
+ const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
+ expect(firstTheme).toBeTruthy()
+
+ await items.nth(1).click()
+
+ await page.keyboard.press("Escape")
+
+ const storedThemeId = await page.evaluate(() => {
+ return localStorage.getItem("opencode-theme-id")
+ })
+
+ expect(storedThemeId).not.toBeNull()
+ expect(storedThemeId).not.toBe("oc-1")
+
+ const dataTheme = await page.evaluate(() => {
+ return document.documentElement.getAttribute("data-theme")
+ })
+ expect(dataTheme).toBe(storedThemeId)
+})
+
+test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ const select = dialog.locator(settingsFontSelector)
+ await expect(select).toBeVisible()
+
+ const initialFontFamily = await page.evaluate(() => {
+ return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
+ })
+ expect(initialFontFamily).toContain("IBM Plex Mono")
+
+ await select.locator('[data-slot="select-select-trigger"]').click()
+
+ const items = page.locator('[data-slot="select-select-item"]')
+ await items.nth(2).click()
+
+ await page.waitForTimeout(100)
+
+ const stored = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
+
+ const newFontFamily = await page.evaluate(() => {
+ return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
+ })
+ expect(newFontFamily).not.toBe(initialFontFamily)
+})
+
+test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+
+ const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
+ await expect(colorSchemeSelect).toBeVisible()
+ await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
+ await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
+ await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
+
+ const fontSelect = dialog.locator(settingsFontSelector)
+ await expect(fontSelect).toBeVisible()
+
+ const initialFontFamily = await page.evaluate(() => {
+ return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
+ })
+
+ const initialSettings = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ const currentFont =
+ (await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
+ await fontSelect.locator('[data-slot="select-select-trigger"]').click()
+
+ const fontItems = page.locator('[data-slot="select-select-item"]')
+ expect(await fontItems.count()).toBeGreaterThan(1)
+
+ if (currentFont) {
+ await fontItems.filter({ hasNotText: currentFont }).first().click()
+ }
+ if (!currentFont) {
+ await fontItems.nth(1).click()
+ }
+
+ await expect
+ .poll(async () => {
+ return await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+ })
+ .toMatchObject({
+ appearance: {
+ font: expect.any(String),
+ },
+ })
+
+ const updatedSettings = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ const updatedFontFamily = await page.evaluate(() => {
+ return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
+ })
+ expect(updatedFontFamily).not.toBe(initialFontFamily)
+ expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
+
+ await closeDialog(page, dialog)
+ await page.reload()
+
+ await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
+
+ await expect
+ .poll(async () => {
+ return await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+ })
+ .toMatchObject({
+ appearance: {
+ font: updatedSettings?.appearance?.font,
+ },
+ })
+
+ const rehydratedSettings = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ await expect
+ .poll(async () => {
+ return await page.evaluate(() => {
+ return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
+ })
+ })
+ .not.toBe(initialFontFamily)
+
+ const rehydratedFontFamily = await page.evaluate(() => {
+ return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
+ })
+ expect(rehydratedFontFamily).not.toBe(initialFontFamily)
+ expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
+})
+
+test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ const switchContainer = dialog.locator(settingsNotificationsAgentSelector)
+ await expect(switchContainer).toBeVisible()
+
+ const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+ const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+ expect(initialState).toBe(true)
+
+ await switchContainer.locator('[data-slot="switch-control"]').click()
+ await page.waitForTimeout(100)
+
+ const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+ expect(newState).toBe(false)
+
+ const stored = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ expect(stored?.notifications?.agent).toBe(false)
+})
+
+test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector)
+ await expect(switchContainer).toBeVisible()
+
+ const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+ const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+ expect(initialState).toBe(true)
+
+ await switchContainer.locator('[data-slot="switch-control"]').click()
+ await page.waitForTimeout(100)
+
+ const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+ expect(newState).toBe(false)
+
+ const stored = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ expect(stored?.notifications?.permissions).toBe(false)
+})
+
+test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ const switchContainer = dialog.locator(settingsNotificationsErrorsSelector)
+ await expect(switchContainer).toBeVisible()
+
+ const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+ const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+ expect(initialState).toBe(false)
+
+ await switchContainer.locator('[data-slot="switch-control"]').click()
+ await page.waitForTimeout(100)
+
+ const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+ expect(newState).toBe(true)
+
+ const stored = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ expect(stored?.notifications?.errors).toBe(true)
+})
+
+test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ const select = dialog.locator(settingsSoundsAgentSelector)
+ await expect(select).toBeVisible()
+
+ await select.locator('[data-slot="select-select-trigger"]').click()
+
+ const items = page.locator('[data-slot="select-select-item"]')
+ await items.nth(2).click()
+
+ const stored = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ expect(stored?.sounds?.agent).not.toBe("staplebops-01")
+})
+
+test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ const permissionsSelect = dialog.locator(settingsSoundsPermissionsSelector)
+ const errorsSelect = dialog.locator(settingsSoundsErrorsSelector)
+ await expect(permissionsSelect).toBeVisible()
+ await expect(errorsSelect).toBeVisible()
+
+ const initial = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ const permissionsCurrent =
+ (await permissionsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
+ await permissionsSelect.locator('[data-slot="select-select-trigger"]').click()
+ const permissionItems = page.locator('[data-slot="select-select-item"]')
+ expect(await permissionItems.count()).toBeGreaterThan(1)
+ if (permissionsCurrent) {
+ await permissionItems.filter({ hasNotText: permissionsCurrent }).first().click()
+ }
+ if (!permissionsCurrent) {
+ await permissionItems.nth(1).click()
+ }
+
+ const errorsCurrent =
+ (await errorsSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
+ await errorsSelect.locator('[data-slot="select-select-trigger"]').click()
+ const errorItems = page.locator('[data-slot="select-select-item"]')
+ expect(await errorItems.count()).toBeGreaterThan(1)
+ if (errorsCurrent) {
+ await errorItems.filter({ hasNotText: errorsCurrent }).first().click()
+ }
+ if (!errorsCurrent) {
+ await errorItems.nth(1).click()
+ }
+
+ await expect
+ .poll(async () => {
+ return await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+ })
+ .toMatchObject({
+ sounds: {
+ permissions: expect.any(String),
+ errors: expect.any(String),
+ },
+ })
+
+ const stored = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ expect(stored?.sounds?.permissions).not.toBe(initial?.sounds?.permissions)
+ expect(stored?.sounds?.errors).not.toBe(initial?.sounds?.errors)
+})
+
+test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ const switchContainer = dialog.locator(settingsUpdatesStartupSelector)
+ await expect(switchContainer).toBeVisible()
+
+ const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+
+ const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled)
+ if (isDisabled) {
+ test.skip()
+ return
+ }
+
+ const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+ expect(initialState).toBe(true)
+
+ await switchContainer.locator('[data-slot="switch-control"]').click()
+ await page.waitForTimeout(100)
+
+ const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+ expect(newState).toBe(false)
+
+ const stored = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ expect(stored?.updates?.startup).toBe(false)
+})
+
+test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const dialog = await openSettings(page)
+ const switchContainer = dialog.locator(settingsReleaseNotesSelector)
+ await expect(switchContainer).toBeVisible()
+
+ const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
+ const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+ expect(initialState).toBe(true)
+
+ await switchContainer.locator('[data-slot="switch-control"]').click()
+ await page.waitForTimeout(100)
+
+ const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
+ expect(newState).toBe(false)
+
+ const stored = await page.evaluate((key) => {
+ const raw = localStorage.getItem(key)
+ return raw ? JSON.parse(raw) : null
+ }, settingsKey)
+
+ expect(stored?.general?.releaseNotes).toBe(false)
+})
diff --git a/packages/app/e2e/sidebar.spec.ts b/packages/app/e2e/sidebar.spec.ts
deleted file mode 100644
index 925590f510..0000000000
--- a/packages/app/e2e/sidebar.spec.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { test, expect } from "./fixtures"
-import { modKey } from "./utils"
-
-test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
- await gotoSession()
-
- const main = page.locator("main")
- const closedClass = /xl:border-l/
- const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))
-
- if (isClosed) {
- await page.keyboard.press(`${modKey}+B`)
- await expect(main).not.toHaveClass(closedClass)
- }
-
- await page.keyboard.press(`${modKey}+B`)
- await expect(main).toHaveClass(closedClass)
-
- await page.keyboard.press(`${modKey}+B`)
- await expect(main).not.toHaveClass(closedClass)
-})
diff --git a/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts
new file mode 100644
index 0000000000..e37f94f3a7
--- /dev/null
+++ b/packages/app/e2e/sidebar/sidebar-popover-actions.spec.ts
@@ -0,0 +1,36 @@
+import { test, expect } from "../fixtures"
+import { closeSidebar, hoverSessionItem } from "../actions"
+import { projectSwitchSelector, sessionItemSelector } from "../selectors"
+
+test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
+ const stamp = Date.now()
+
+ const one = await sdk.session.create({ title: `e2e sidebar popover archive 1 ${stamp}` }).then((r) => r.data)
+ const two = await sdk.session.create({ title: `e2e sidebar popover archive 2 ${stamp}` }).then((r) => r.data)
+
+ if (!one?.id) throw new Error("Session create did not return an id")
+ if (!two?.id) throw new Error("Session create did not return an id")
+
+ try {
+ await gotoSession(one.id)
+ await closeSidebar(page)
+
+ const project = page.locator(projectSwitchSelector(slug)).first()
+ await expect(project).toBeVisible()
+ await project.hover()
+
+ await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
+ await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
+
+ const item = await hoverSessionItem(page, one.id)
+ await item
+ .getByRole("button", { name: /archive/i })
+ .first()
+ .click()
+
+ await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
+ } finally {
+ await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
+ await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
+ }
+})
diff --git a/packages/app/e2e/sidebar-session-links.spec.ts b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts
similarity index 52%
rename from packages/app/e2e/sidebar-session-links.spec.ts
rename to packages/app/e2e/sidebar/sidebar-session-links.spec.ts
index fab64736e2..cda2278a95 100644
--- a/packages/app/e2e/sidebar-session-links.spec.ts
+++ b/packages/app/e2e/sidebar/sidebar-session-links.spec.ts
@@ -1,33 +1,8 @@
-import { test, expect } from "./fixtures"
-import { modKey, promptSelector } from "./utils"
+import { test, expect } from "../fixtures"
+import { openSidebar, withSession } from "../actions"
+import { promptSelector } from "../selectors"
-type Locator = {
- first: () => Locator
- getAttribute: (name: string) => Promise
- scrollIntoViewIfNeeded: () => Promise
- click: () => Promise
-}
-
-type Page = {
- locator: (selector: string) => Locator
- keyboard: {
- press: (key: string) => Promise
- }
-}
-
-type Fixtures = {
- page: Page
- slug: string
- sdk: {
- session: {
- create: (input: { title: string }) => Promise<{ data?: { id?: string } }>
- delete: (input: { sessionID: string }) => Promise
- }
- }
- gotoSession: (sessionID?: string) => Promise
-}
-
-test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }: Fixtures) => {
+test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data)
@@ -39,12 +14,7 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
try {
await gotoSession(one.id)
- const main = page.locator("main")
- const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
- if (collapsed) {
- await page.keyboard.press(`${modKey}+B`)
- await expect(main).not.toHaveClass(/xl:border-l/)
- }
+ await openSidebar(page)
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(target).toBeVisible()
diff --git a/packages/app/e2e/sidebar/sidebar.spec.ts b/packages/app/e2e/sidebar/sidebar.spec.ts
new file mode 100644
index 0000000000..5c78c2220d
--- /dev/null
+++ b/packages/app/e2e/sidebar/sidebar.spec.ts
@@ -0,0 +1,37 @@
+import { test, expect } from "../fixtures"
+import { openSidebar, toggleSidebar, withSession } from "../actions"
+
+test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ await openSidebar(page)
+
+ await toggleSidebar(page)
+ await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+
+ await toggleSidebar(page)
+ await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
+})
+
+test("sidebar collapsed state persists across navigation and reload", async ({ page, sdk, gotoSession }) => {
+ await withSession(sdk, "sidebar persist session 1", async (session1) => {
+ await withSession(sdk, "sidebar persist session 2", async (session2) => {
+ await gotoSession(session1.id)
+
+ await openSidebar(page)
+ await toggleSidebar(page)
+ await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+
+ await gotoSession(session2.id)
+ await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+
+ await page.reload()
+ await expect(page.locator("main")).toHaveClass(/xl:border-l/)
+
+ const opened = await page.evaluate(
+ () => JSON.parse(localStorage.getItem("opencode.global.dat:layout") ?? "{}").sidebar?.opened,
+ )
+ await expect(opened).toBe(false)
+ })
+ })
+})
diff --git a/packages/app/e2e/status/status-popover.spec.ts b/packages/app/e2e/status/status-popover.spec.ts
new file mode 100644
index 0000000000..d53578a491
--- /dev/null
+++ b/packages/app/e2e/status/status-popover.spec.ts
@@ -0,0 +1,94 @@
+import { test, expect } from "../fixtures"
+import { openStatusPopover } from "../actions"
+
+test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const { popoverBody } = await openStatusPopover(page)
+
+ await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible()
+ await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible()
+ await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible()
+ await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible()
+
+ await page.keyboard.press("Escape")
+ await expect(popoverBody).toHaveCount(0)
+})
+
+test("status popover servers tab shows current server", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const { popoverBody } = await openStatusPopover(page)
+
+ const serversTab = popoverBody.getByRole("tab", { name: /servers/i })
+ await expect(serversTab).toHaveAttribute("aria-selected", "true")
+
+ const serverList = popoverBody.locator('[role="tabpanel"]').first()
+ await expect(serverList.locator("button").first()).toBeVisible()
+})
+
+test("status popover can switch to mcp tab", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const { popoverBody } = await openStatusPopover(page)
+
+ const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i })
+ await mcpTab.click()
+
+ const ariaSelected = await mcpTab.getAttribute("aria-selected")
+ expect(ariaSelected).toBe("true")
+
+ const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first()
+ await expect(mcpContent).toBeVisible()
+})
+
+test("status popover can switch to lsp tab", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const { popoverBody } = await openStatusPopover(page)
+
+ const lspTab = popoverBody.getByRole("tab", { name: /lsp/i })
+ await lspTab.click()
+
+ const ariaSelected = await lspTab.getAttribute("aria-selected")
+ expect(ariaSelected).toBe("true")
+
+ const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first()
+ await expect(lspContent).toBeVisible()
+})
+
+test("status popover can switch to plugins tab", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const { popoverBody } = await openStatusPopover(page)
+
+ const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i })
+ await pluginsTab.click()
+
+ const ariaSelected = await pluginsTab.getAttribute("aria-selected")
+ expect(ariaSelected).toBe("true")
+
+ const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first()
+ await expect(pluginsContent).toBeVisible()
+})
+
+test("status popover closes on escape", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const { popoverBody } = await openStatusPopover(page)
+ await expect(popoverBody).toBeVisible()
+
+ await page.keyboard.press("Escape")
+ await expect(popoverBody).toHaveCount(0)
+})
+
+test("status popover closes when clicking outside", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ const { popoverBody } = await openStatusPopover(page)
+ await expect(popoverBody).toBeVisible()
+
+ await page.getByRole("main").click({ position: { x: 5, y: 5 } })
+
+ await expect(popoverBody).toHaveCount(0)
+})
diff --git a/packages/app/e2e/terminal-init.spec.ts b/packages/app/e2e/terminal/terminal-init.spec.ts
similarity index 83%
rename from packages/app/e2e/terminal-init.spec.ts
rename to packages/app/e2e/terminal/terminal-init.spec.ts
index cfde2d0193..87934b66e3 100644
--- a/packages/app/e2e/terminal-init.spec.ts
+++ b/packages/app/e2e/terminal/terminal-init.spec.ts
@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { promptSelector, terminalSelector, terminalToggleKey } from "./utils"
+import { test, expect } from "../fixtures"
+import { promptSelector, terminalSelector } from "../selectors"
+import { terminalToggleKey } from "../utils"
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
await gotoSession()
diff --git a/packages/app/e2e/terminal.spec.ts b/packages/app/e2e/terminal/terminal.spec.ts
similarity index 74%
rename from packages/app/e2e/terminal.spec.ts
rename to packages/app/e2e/terminal/terminal.spec.ts
index fc558b6325..ef88aa34e5 100644
--- a/packages/app/e2e/terminal.spec.ts
+++ b/packages/app/e2e/terminal/terminal.spec.ts
@@ -1,5 +1,6 @@
-import { test, expect } from "./fixtures"
-import { terminalSelector, terminalToggleKey } from "./utils"
+import { test, expect } from "../fixtures"
+import { terminalSelector } from "../selectors"
+import { terminalToggleKey } from "../utils"
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
await gotoSession()
diff --git a/packages/app/e2e/thinking-level.spec.ts b/packages/app/e2e/thinking-level.spec.ts
new file mode 100644
index 0000000000..92200933e5
--- /dev/null
+++ b/packages/app/e2e/thinking-level.spec.ts
@@ -0,0 +1,25 @@
+import { test, expect } from "./fixtures"
+import { modelVariantCycleSelector } from "./selectors"
+
+test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
+ await gotoSession()
+
+ await page.addStyleTag({
+ content: `${modelVariantCycleSelector} { display: inline-block !important; }`,
+ })
+
+ const button = page.locator(modelVariantCycleSelector)
+ const exists = (await button.count()) > 0
+ test.skip(!exists, "current model has no variants")
+ if (!exists) return
+
+ await expect(button).toBeVisible()
+
+ const before = (await button.innerText()).trim()
+ await button.click()
+ await expect(button).not.toHaveText(before)
+
+ const after = (await button.innerText()).trim()
+ await button.click()
+ await expect(button).not.toHaveText(after)
+})
diff --git a/packages/app/e2e/titlebar-history.spec.ts b/packages/app/e2e/titlebar-history.spec.ts
deleted file mode 100644
index d4aa605e6d..0000000000
--- a/packages/app/e2e/titlebar-history.spec.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-import { test, expect } from "./fixtures"
-import { modKey, promptSelector } from "./utils"
-
-test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
- await page.setViewportSize({ width: 1400, height: 800 })
-
- const stamp = Date.now()
- const one = await sdk.session.create({ title: `e2e titlebar history 1 ${stamp}` }).then((r) => r.data)
- const two = await sdk.session.create({ title: `e2e titlebar history 2 ${stamp}` }).then((r) => r.data)
-
- if (!one?.id) throw new Error("Session create did not return an id")
- if (!two?.id) throw new Error("Session create did not return an id")
-
- try {
- await gotoSession(one.id)
-
- const main = page.locator("main")
- const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
- if (collapsed) {
- await page.keyboard.press(`${modKey}+B`)
- await expect(main).not.toHaveClass(/xl:border-l/)
- }
-
- const link = page.locator(`[data-session-id="${two.id}"] a`).first()
- await expect(link).toBeVisible()
- await link.scrollIntoViewIfNeeded()
- await link.click()
-
- await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
- await expect(page.locator(promptSelector)).toBeVisible()
-
- const back = page.getByRole("button", { name: "Back" })
- const forward = page.getByRole("button", { name: "Forward" })
-
- await expect(back).toBeVisible()
- await expect(back).toBeEnabled()
- await back.click()
-
- await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
- await expect(page.locator(promptSelector)).toBeVisible()
-
- await expect(forward).toBeVisible()
- await expect(forward).toBeEnabled()
- await forward.click()
-
- await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
- await expect(page.locator(promptSelector)).toBeVisible()
- } finally {
- await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
- await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
- }
-})
diff --git a/packages/app/e2e/tsconfig.json b/packages/app/e2e/tsconfig.json
index 76438a03cc..18e88ddc9c 100644
--- a/packages/app/e2e/tsconfig.json
+++ b/packages/app/e2e/tsconfig.json
@@ -2,7 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
- "types": ["node"]
+ "types": ["node", "bun"]
},
"include": ["./**/*.ts"]
}
diff --git a/packages/app/e2e/utils.ts b/packages/app/e2e/utils.ts
index eb0395950a..ec6cdf8302 100644
--- a/packages/app/e2e/utils.ts
+++ b/packages/app/e2e/utils.ts
@@ -10,9 +10,6 @@ export const serverName = `${serverHost}:${serverPort}`
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
export const terminalToggleKey = "Control+Backquote"
-export const promptSelector = '[data-component="prompt-input"]'
-export const terminalSelector = '[data-component="terminal"]'
-
export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
}
diff --git a/packages/app/package.json b/packages/app/package.json
index 61e7edcd16..cdfa543c4b 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -1,11 +1,12 @@
{
"name": "@opencode-ai/app",
- "version": "0.0.0-ci-202601291718",
+ "version": "1.1.59",
"description": "",
"type": "module",
"exports": {
".": "./src/index.ts",
- "./vite": "./vite.js"
+ "./vite": "./vite.js",
+ "./index.css": "./src/index.css"
},
"scripts": {
"typecheck": "tsgo -b",
@@ -13,7 +14,9 @@
"dev": "vite",
"build": "vite build",
"serve": "vite preview",
- "test": "playwright test",
+ "test": "bun run test:unit",
+ "test:unit": "bun test --preload ./happydom.ts ./src",
+ "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "bun script/e2e-local.ts",
"test:e2e:ui": "playwright test --ui",
@@ -54,7 +57,7 @@
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
- "ghostty-web": "0.3.0",
+ "ghostty-web": "0.4.0",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
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 2c7be2ad95..112e2bc60a 100644
--- a/packages/app/script/e2e-local.ts
+++ b/packages/app/script/e2e-local.ts
@@ -55,10 +55,11 @@ 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,
- OPENCODE_DISABLE_SHARE: "true",
+ OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
@@ -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/addons/serialize.test.ts b/packages/app/src/addons/serialize.test.ts
index 7fb1a61f35..7f6780557d 100644
--- a/packages/app/src/addons/serialize.test.ts
+++ b/packages/app/src/addons/serialize.test.ts
@@ -36,7 +36,7 @@ function writeAndWait(term: Terminal, data: string): Promise {
})
}
-describe.skip("SerializeAddon", () => {
+describe("SerializeAddon", () => {
describe("ANSI color preservation", () => {
test("should preserve text attributes (bold, italic, underline)", async () => {
const { term, addon } = createTerminal()
diff --git a/packages/app/src/addons/serialize.ts b/packages/app/src/addons/serialize.ts
index 4309a725e5..4cab55b3f2 100644
--- a/packages/app/src/addons/serialize.ts
+++ b/packages/app/src/addons/serialize.ts
@@ -56,6 +56,39 @@ interface IBufferCell {
isDim(): boolean
}
+type TerminalBuffers = {
+ active?: IBuffer
+ normal?: IBuffer
+ alternate?: IBuffer
+}
+
+const isRecord = (value: unknown): value is Record => {
+ return typeof value === "object" && value !== null
+}
+
+const isBuffer = (value: unknown): value is IBuffer => {
+ if (!isRecord(value)) return false
+ if (typeof value.length !== "number") return false
+ if (typeof value.cursorX !== "number") return false
+ if (typeof value.cursorY !== "number") return false
+ if (typeof value.baseY !== "number") return false
+ if (typeof value.viewportY !== "number") return false
+ if (typeof value.getLine !== "function") return false
+ if (typeof value.getNullCell !== "function") return false
+ return true
+}
+
+const getTerminalBuffers = (value: ITerminalCore): TerminalBuffers | undefined => {
+ if (!isRecord(value)) return
+ const raw = value.buffer
+ if (!isRecord(raw)) return
+ const active = isBuffer(raw.active) ? raw.active : undefined
+ const normal = isBuffer(raw.normal) ? raw.normal : undefined
+ const alternate = isBuffer(raw.alternate) ? raw.alternate : undefined
+ if (!active && !normal) return
+ return { active, normal, alternate }
+}
+
// ============================================================================
// Types
// ============================================================================
@@ -241,19 +274,19 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _rowEnd(row: number, isLastRow: boolean): void {
let rowSeparator = ""
- if (this._nullCellCount > 0) {
+ const nextLine = isLastRow ? undefined : this._buffer.getLine(row + 1)
+ const wrapped = !!nextLine?.isWrapped
+
+ if (this._nullCellCount > 0 && wrapped) {
this._currentRow += " ".repeat(this._nullCellCount)
- this._nullCellCount = 0
}
- if (!isLastRow) {
- const nextLine = this._buffer.getLine(row + 1)
+ this._nullCellCount = 0
- if (!nextLine?.isWrapped) {
- rowSeparator = "\r\n"
- this._lastCursorRow = row + 1
- this._lastCursorCol = 0
- }
+ if (!isLastRow && !wrapped) {
+ rowSeparator = "\r\n"
+ this._lastCursorRow = row + 1
+ this._lastCursorCol = 0
}
this._allRows[this._rowIndex] = this._currentRow
@@ -389,7 +422,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
- const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
+ const styleChanged = sgrSeq.length > 0
if (styleChanged) {
if (this._nullCellCount > 0) {
@@ -442,12 +475,24 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
}
- if (!excludeFinalCursorPosition) {
- const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
- const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
- const cursorCol = this._buffer.cursorX + 1
- content += `\u001b[${cursorRow};${cursorCol}H`
- }
+ if (excludeFinalCursorPosition) return content
+
+ const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
+ const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
+ const cursorCol = this._buffer.cursorX + 1
+ content += `\u001b[${cursorRow};${cursorCol}H`
+
+ const line = this._buffer.getLine(absoluteCursorRow)
+ const cell = line?.getCell(this._buffer.cursorX)
+ const style = (() => {
+ if (!cell) return this._buffer.getNullCell()
+ if (cell.getWidth() !== 0) return cell
+ if (this._buffer.cursorX > 0) return line?.getCell(this._buffer.cursorX - 1) ?? cell
+ return cell
+ })()
+
+ const sgrSeq = this._diffStyle(style, this._cursorStyle)
+ if (sgrSeq.length) content += `\u001b[${sgrSeq.join(";")}m`
return content
}
@@ -486,14 +531,13 @@ export class SerializeAddon implements ITerminalAddon {
throw new Error("Cannot use addon until it has been loaded")
}
- const terminal = this._terminal as any
- const buffer = terminal.buffer
+ const buffer = getTerminalBuffers(this._terminal)
if (!buffer) {
return ""
}
- const normalBuffer = buffer.normal || buffer.active
+ const normalBuffer = buffer.normal ?? buffer.active
const altBuffer = buffer.alternate
if (!normalBuffer) {
@@ -521,14 +565,13 @@ export class SerializeAddon implements ITerminalAddon {
throw new Error("Cannot use addon until it has been loaded")
}
- const terminal = this._terminal as any
- const buffer = terminal.buffer
+ const buffer = getTerminalBuffers(this._terminal)
if (!buffer) {
return ""
}
- const activeBuffer = buffer.active || buffer.normal
+ const activeBuffer = buffer.active ?? buffer.normal
if (!activeBuffer) {
return ""
}
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 11fdb57432..e49b725a19 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -30,7 +30,7 @@ import { HighlightsProvider } from "@/context/highlights"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import { ErrorPage } from "./pages/error"
-import { Suspense } from "solid-js"
+import { Suspense, JSX } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
@@ -43,7 +43,7 @@ function UiI18nBridge(props: ParentProps) {
declare global {
interface Window {
- __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] }
+ __OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
}
}
@@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) {
)
}
-export function AppInterface(props: { defaultUrl?: string }) {
+export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) {
const platform = usePlatform()
const stored = (() => {
@@ -106,12 +106,12 @@ export function AppInterface(props: { defaultUrl?: string }) {
}
return (
-
+
(
+ root={(routerProps) => (
@@ -119,7 +119,10 @@ export function AppInterface(props: { defaultUrl?: string }) {
- {props.children}
+
+ {props.children}
+ {routerProps.children}
+
diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx
index 28a947f3b3..53773ed9ea 100644
--- a/packages/app/src/components/dialog-custom-provider.tsx
+++ b/packages/app/src/components/dialog-custom-provider.tsx
@@ -124,16 +124,16 @@ export function DialogCustomProvider(props: Props) {
const key = apiKey && !env ? apiKey : undefined
const idError = !providerID
- ? "Provider ID is required"
+ ? language.t("provider.custom.error.providerID.required")
: !PROVIDER_ID.test(providerID)
- ? "Use lowercase letters, numbers, hyphens, or underscores"
+ ? language.t("provider.custom.error.providerID.format")
: undefined
- const nameError = !name ? "Display name is required" : undefined
+ const nameError = !name ? language.t("provider.custom.error.name.required") : undefined
const urlError = !baseURL
- ? "Base URL is required"
+ ? language.t("provider.custom.error.baseURL.required")
: !/^https?:\/\//.test(baseURL)
- ? "Must start with http:// or https://"
+ ? language.t("provider.custom.error.baseURL.format")
: undefined
const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
@@ -141,21 +141,21 @@ export function DialogCustomProvider(props: Props) {
const existsError = idError
? undefined
: existingProvider && !disabled
- ? "That provider ID already exists"
+ ? language.t("provider.custom.error.providerID.exists")
: undefined
const seenModels = new Set()
const modelErrors = form.models.map((m) => {
const id = m.id.trim()
const modelIdError = !id
- ? "Required"
+ ? language.t("provider.custom.error.required")
: seenModels.has(id)
- ? "Duplicate"
+ ? language.t("provider.custom.error.duplicate")
: (() => {
seenModels.add(id)
return undefined
})()
- const modelNameError = !m.name.trim() ? "Required" : undefined
+ const modelNameError = !m.name.trim() ? language.t("provider.custom.error.required") : undefined
return { id: modelIdError, name: modelNameError }
})
const modelsValid = modelErrors.every((m) => !m.id && !m.name)
@@ -168,14 +168,14 @@ export function DialogCustomProvider(props: Props) {
if (!key && !value) return {}
const keyError = !key
- ? "Required"
+ ? language.t("provider.custom.error.required")
: seenHeaders.has(key.toLowerCase())
- ? "Duplicate"
+ ? language.t("provider.custom.error.duplicate")
: (() => {
seenHeaders.add(key.toLowerCase())
return undefined
})()
- const valueError = !value ? "Required" : undefined
+ const valueError = !value ? language.t("provider.custom.error.required") : undefined
return { key: keyError, value: valueError }
})
const headersValid = headerErrors.every((h) => !h.key && !h.value)
@@ -278,64 +278,64 @@ export function DialogCustomProvider(props: Props) {
-
Custom provider
+
{language.t("provider.custom.title")}
@@ -223,7 +223,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
value={store.startup}
onChange={(v) => setStore("startup", v)}
spellcheck={false}
- class="max-h-40 w-full font-mono text-xs no-scrollbar"
+ class="max-h-14 w-full overflow-y-auto font-mono text-xs"
/>
diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx
index b9a7d6ed9b..6e7af3d902 100644
--- a/packages/app/src/components/dialog-select-directory.tsx
+++ b/packages/app/src/components/dialog-select-directory.tsx
@@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort"
-import { createMemo } from "solid-js"
+import { createMemo, createResource, createSignal } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
+import type { ListRef } from "@opencode-ai/ui/list"
interface DialogSelectDirectoryProps {
title?: string
@@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps {
onSelect: (result: string | string[] | null) => void
}
+type Row = {
+ absolute: string
+ search: string
+}
+
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const dialog = useDialog()
const language = useLanguage()
- const home = createMemo(() => sync.data.path.home)
+ const [filter, setFilter] = createSignal("")
- const start = createMemo(() => sync.data.path.home || sync.data.path.directory)
+ let list: ListRef | undefined
+
+ const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
+
+ const [fallbackPath] = createResource(
+ () => (missingBase() ? true : undefined),
+ async () => {
+ return sdk.client.path
+ .get()
+ .then((x) => x.data)
+ .catch(() => undefined)
+ },
+ { initialValue: undefined },
+ )
+
+ const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
+
+ const start = createMemo(
+ () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
+ )
const cache = new Map>>()
+ const clean = (value: string) => {
+ const first = (value ?? "").split(/\r?\n/)[0] ?? ""
+ return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
+ }
+
function normalize(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
@@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return ""
}
- function display(path: string) {
+ function parentOf(input: string) {
+ const v = trimTrailing(input)
+ if (v === "/") return v
+ if (v === "//") return v
+ if (/^[A-Za-z]:\/$/.test(v)) return v
+
+ const i = v.lastIndexOf("/")
+ if (i <= 0) return "/"
+ if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
+ return v.slice(0, i)
+ }
+
+ function modeOf(input: string) {
+ const raw = normalizeDriveRoot(input.trim())
+ if (!raw) return "relative" as const
+ if (raw.startsWith("~")) return "tilde" as const
+ if (rootOf(raw)) return "absolute" as const
+ return "relative" as const
+ }
+
+ function display(path: string, input: string) {
const full = trimTrailing(path)
+ if (modeOf(input) === "absolute") return full
+
+ return tildeOf(full) || full
+ }
+
+ function tildeOf(absolute: string) {
+ const full = trimTrailing(absolute)
const h = home()
- if (!h) return full
+ if (!h) return ""
const hn = trimTrailing(h)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
- return full
+ return ""
}
- function scoped(filter: string) {
+ function row(absolute: string): Row {
+ const full = trimTrailing(absolute)
+ const tilde = tildeOf(full)
+
+ const withSlash = (value: string) => {
+ if (!value) return ""
+ if (value.endsWith("/")) return value
+ return value + "/"
+ }
+
+ const search = Array.from(
+ new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
+ ).join("\n")
+ return { absolute: full, search }
+ }
+
+ function scoped(value: string) {
const base = start()
if (!base) return
- const raw = normalizeDriveRoot(filter.trim())
+ const raw = normalizeDriveRoot(value)
if (!raw) return { directory: trimTrailing(base), path: "" }
const h = home()
@@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}
const directories = async (filter: string) => {
- const input = scoped(filter)
- if (!input) return [] as string[]
+ const value = clean(filter)
+ const scopedInput = scoped(value)
+ if (!scopedInput) return [] as string[]
- const raw = normalizeDriveRoot(filter.trim())
+ const raw = normalizeDriveRoot(value)
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
- const query = normalizeDriveRoot(input.path)
+ const query = normalizeDriveRoot(scopedInput.path)
- if (!isPath) {
- const results = await sdk.client.find
- .files({ directory: input.directory, query, type: "directory", limit: 50 })
+ const find = () =>
+ sdk.client.find
+ .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])
- return results.map((rel) => join(input.directory, rel)).slice(0, 50)
+ if (!isPath) {
+ const results = await find()
+
+ return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
@@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const cap = 12
const branch = 4
- let paths = [input.directory]
+ let paths = [scopedInput.directory]
for (const part of head) {
if (part === "..") {
- paths = paths.map((p) => {
- const v = trimTrailing(p)
- if (v === "/") return v
- if (/^[A-Za-z]:\/$/.test(v)) return v
- const i = v.lastIndexOf("/")
- if (i <= 0) return "/"
- return v.slice(0, i)
- })
+ paths = paths.map(parentOf)
continue
}
@@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
- return Array.from(new Set(out)).slice(0, 50)
+ const deduped = Array.from(new Set(out))
+ const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
+ const expand = !raw.endsWith("/")
+ if (!expand || !tail) {
+ const items = base ? Array.from(new Set([base, ...deduped])) : deduped
+ return items.slice(0, 50)
+ }
+
+ const needle = tail.toLowerCase()
+ const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
+ const target = exact[0]
+ if (!target) return deduped.slice(0, 50)
+
+ const children = await match(target, "", 30)
+ const items = Array.from(new Set([...deduped, ...children]))
+ return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
+ }
+
+ const items = async (value: string) => {
+ const results = await directories(value)
+ return results.map(row)
}
function resolve(absolute: string) {
@@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")}
- items={directories}
- key={(x) => x}
+ items={items}
+ key={(x) => x.absolute}
+ filterKeys={["search"]}
+ ref={(r) => (list = r)}
+ onFilter={(value) => setFilter(clean(value))}
+ onKeyEvent={(e, item) => {
+ if (e.key !== "Tab") return
+ if (e.shiftKey) return
+ if (!item) return
+
+ e.preventDefault()
+ e.stopPropagation()
+
+ const value = display(item.absolute, filter())
+ list?.setFilter(value.endsWith("/") ? value : value + "/")
+ }}
onSelect={(path) => {
if (!path) return
- resolve(path)
+ resolve(path.absolute)
}}
>
- {(absolute) => {
- const path = display(absolute)
+ {(item) => {
+ const path = display(item.absolute, filter())
+ if (path === "~") {
+ return (
+
+ )
+ }
return (
-
+
{getDirectory(path)}
{getFilename(path)}
+ /
diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx
index 64b83d31bf..8e221577b9 100644
--- a/packages/app/src/components/dialog-select-file.tsx
+++ b/packages/app/src/components/dialog-select-file.tsx
@@ -1,17 +1,23 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
+import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useParams } from "@solidjs/router"
-import { createMemo, createSignal, onCleanup, Show } from "solid-js"
+import { useNavigate, useParams } from "@solidjs/router"
+import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
+import { decode64 } from "@/utils/base64"
+import { getRelativeTime } from "@/utils/time"
-type EntryType = "command" | "file"
+type EntryType = "command" | "file" | "session"
type Entry = {
id: string
@@ -22,6 +28,10 @@ type Entry = {
category: string
option?: CommandOption
path?: string
+ directory?: string
+ sessionID?: string
+ archived?: number
+ updated?: number
}
type DialogSelectFileMode = "all" | "files"
@@ -33,9 +43,13 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const file = useFile()
const dialog = useDialog()
const params = useParams()
+ const navigate = useNavigate()
+ const globalSDK = useGlobalSDK()
+ const globalSync = useGlobalSync()
const filesOnly = () => props.mode === "files"
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
+ const view = createMemo(() => layout.view(sessionKey))
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const common = [
@@ -73,6 +87,54 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
path,
})
+ const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
+ const project = createMemo(() => {
+ const directory = projectDirectory()
+ if (!directory) return
+ return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
+ })
+ const workspaces = createMemo(() => {
+ const directory = projectDirectory()
+ const current = project()
+ if (!current) return directory ? [directory] : []
+
+ const dirs = [current.worktree, ...(current.sandboxes ?? [])]
+ if (directory && !dirs.includes(directory)) return [...dirs, directory]
+ return dirs
+ })
+ const homedir = createMemo(() => globalSync.data.path.home)
+ const label = (directory: string) => {
+ const current = project()
+ const kind =
+ current && directory === current.worktree
+ ? language.t("workspace.type.local")
+ : language.t("workspace.type.sandbox")
+ const [store] = globalSync.child(directory, { bootstrap: false })
+ const home = homedir()
+ const path = home ? directory.replace(home, "~") : directory
+ const name = store.vcs?.branch ?? getFilename(directory)
+ return `${kind} : ${name || path}`
+ }
+
+ const sessionItem = (input: {
+ directory: string
+ id: string
+ title: string
+ description: string
+ archived?: number
+ updated?: number
+ }): Entry => ({
+ id: `session:${input.directory}:${input.id}`,
+ type: "session",
+ title: input.title,
+ description: input.description,
+ category: language.t("command.category.session"),
+ directory: input.directory,
+ sessionID: input.id,
+ archived: input.archived,
+ updated: input.updated,
+ })
+
const list = createMemo(() => allowed().map(commandItem))
const picks = createMemo(() => {
@@ -122,6 +184,69 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return out
}
+ const sessionToken = { value: 0 }
+ let sessionInflight: Promise | undefined
+ let sessionAll: Entry[] | undefined
+
+ const sessions = (text: string) => {
+ const query = text.trim()
+ if (!query) {
+ sessionToken.value += 1
+ sessionInflight = undefined
+ sessionAll = undefined
+ return [] as Entry[]
+ }
+
+ if (sessionAll) return sessionAll
+ if (sessionInflight) return sessionInflight
+
+ const current = sessionToken.value
+ const dirs = workspaces()
+ if (dirs.length === 0) return [] as Entry[]
+
+ sessionInflight = Promise.all(
+ dirs.map((directory) => {
+ const description = label(directory)
+ return globalSDK.client.session
+ .list({ directory, roots: true })
+ .then((x) =>
+ (x.data ?? [])
+ .filter((s) => !!s?.id)
+ .map((s) => ({
+ id: s.id,
+ title: s.title ?? language.t("command.session.new"),
+ description,
+ directory,
+ archived: s.time?.archived,
+ updated: s.time?.updated,
+ })),
+ )
+ .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
+ }),
+ )
+ .then((results) => {
+ if (sessionToken.value !== current) return [] as Entry[]
+ const seen = new Set()
+ const next = results
+ .flat()
+ .filter((item) => {
+ const key = `${item.directory}:${item.id}`
+ if (seen.has(key)) return false
+ seen.add(key)
+ return true
+ })
+ .map(sessionItem)
+ sessionAll = next
+ return next
+ })
+ .catch(() => [] as Entry[])
+ .finally(() => {
+ sessionInflight = undefined
+ })
+
+ return sessionInflight
+ }
+
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
@@ -146,9 +271,10 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const files = await file.searchFiles(query)
return files.map(fileItem)
}
- const files = await file.searchFiles(query)
+
+ const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const entries = files.map(fileItem)
- return [...list(), ...entries]
+ return [...list(), ...nextSessions, ...entries]
}
const handleMove = (item: Entry | undefined) => {
@@ -162,6 +288,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const value = file.tab(path)
tabs().open(value)
file.load(path)
+ if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
@@ -178,6 +305,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return
}
+ if (item.type === "session") {
+ if (!item.directory || !item.sessionID) return
+ navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
+ return
+ }
+
if (!item.path) return
open(item.path)
}
@@ -202,13 +335,12 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
- groupBy={(item) => item.category}
+ groupBy={grouped() ? (item) => item.category : () => ""}
onMove={handleMove}
onSelect={handleSelect}
>
{(item) => (
-
@@ -223,18 +355,48 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
}
>
-
-
-
{item.title}
-
- {item.description}
+
+
+
+ {item.title}
+
+ {item.description}
+
+
+
+ {formatKeybind(item.keybind ?? "")}
-
- {formatKeybind(item.keybind ?? "")}
-
-
-
+
+
+
+
+
+
+
+ {item.title}
+
+
+
+ {item.description}
+
+
+
+
+
+
+ {getRelativeTime(new Date(item.updated!).toISOString())}
+
+
+
+
+
)}
diff --git a/packages/app/src/components/dialog-select-model-unpaid.tsx b/packages/app/src/components/dialog-select-model-unpaid.tsx
index 089c4c0cd7..78c169777e 100644
--- a/packages/app/src/components/dialog-select-model-unpaid.tsx
+++ b/packages/app/src/components/dialog-select-model-unpaid.tsx
@@ -34,11 +34,14 @@ export const DialogSelectModelUnpaid: Component = () => {
})
return (
-
-
+
+
{language.t("dialog.model.unpaid.freeModels.title")}
(listRef = ref)}
items={local.model.list}
current={local.model.current()}
@@ -76,8 +79,6 @@ export const DialogSelectModelUnpaid: Component = () => {
)}
-
-
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx
index 4f0dcc3ee6..26021f06aa 100644
--- a/packages/app/src/components/dialog-select-model.tsx
+++ b/packages/app/src/components/dialog-select-model.tsx
@@ -54,7 +54,6 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
- forceMount={false}
value={
(props: {
+type ModelSelectorTriggerProps = Omit, "as" | "ref">
+
+export function ModelSelectorPopover(props: {
provider?: string
children?: JSX.Element
- triggerAs?: T
- triggerProps?: ComponentProps
+ triggerAs?: ValidComponent
+ triggerProps?: ModelSelectorTriggerProps
}) {
const [store, setStore] = createStore<{
open: boolean
@@ -177,11 +178,7 @@ export function ModelSelectorPopover(props: {
placement="top-start"
gutter={8}
>
- setStore("trigger", el)}
- as={props.triggerAs ?? "div"}
- {...(props.triggerProps as any)}
- >
+ setStore("trigger", el)} as={props.triggerAs ?? "div"} {...props.triggerProps}>
{props.children}
@@ -214,7 +211,7 @@ export function ModelSelectorPopover(props: {
class="p-1"
action={
-
+
(props: {
onClick={handleConnectProvider}
/>
-
+
void
}
-async function checkHealth(url: string, platform: ReturnType): Promise {
- const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
- const sdk = createOpencodeClient({
- baseUrl: url,
- fetch: platform.fetch,
- signal,
- })
- return sdk.global
- .health()
- .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
- .catch(() => ({ healthy: false }))
-}
-
function AddRow(props: AddRowProps) {
return (
@@ -131,7 +116,7 @@ export function DialogSelectServer() {
const globalSDK = useGlobalSDK()
const language = useLanguage()
const [store, setStore] = createStore({
- status: {} as Record
,
+ status: {} as Record,
addServer: {
url: "",
adding: false,
@@ -165,6 +150,7 @@ export function DialogSelectServer() {
{ initialValue: null },
)
const canDefault = createMemo(() => !!platform.getDefaultServerUrl && !!platform.setDefaultServerUrl)
+ const fetcher = platform.fetch ?? globalThis.fetch
const looksComplete = (value: string) => {
const normalized = normalizeServerUrl(value)
@@ -180,7 +166,7 @@ export function DialogSelectServer() {
if (!looksComplete(value)) return
const normalized = normalizeServerUrl(value)
if (!normalized) return
- const result = await checkHealth(normalized, platform)
+ const result = await checkServerHealth(normalized, fetcher)
setStatus(result.healthy)
}
@@ -227,7 +213,7 @@ export function DialogSelectServer() {
if (!list.length) return list
const active = current()
const order = new Map(list.map((url, index) => [url, index] as const))
- const rank = (value?: ServerStatus) => {
+ const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
@@ -242,10 +228,10 @@ export function DialogSelectServer() {
})
async function refreshHealth() {
- const results: Record = {}
+ const results: Record = {}
await Promise.all(
items().map(async (url) => {
- results[url] = await checkHealth(url, platform)
+ results[url] = await checkServerHealth(url, fetcher)
}),
)
setStore("status", reconcile(results))
@@ -300,7 +286,7 @@ export function DialogSelectServer() {
setStore("addServer", { adding: true, error: "" })
- const result = await checkHealth(normalized, platform)
+ const result = await checkServerHealth(normalized, fetcher)
setStore("addServer", { adding: false })
if (!result.healthy) {
@@ -327,7 +313,7 @@ export function DialogSelectServer() {
setStore("editServer", { busy: true, error: "" })
- const result = await checkHealth(normalized, platform)
+ const result = await checkServerHealth(normalized, fetcher)
setStore("editServer", { busy: false })
if (!result.healthy) {
@@ -369,6 +355,9 @@ export function DialogSelectServer() {
async function handleRemove(url: string) {
server.remove(url)
+ if ((await platform.getDefaultServerUrl?.()) === url) {
+ platform.setDefaultServerUrl?.(null)
+ }
}
return (
@@ -410,35 +399,6 @@ export function DialogSelectServer() {
}
>
{(i) => {
- const [truncated, setTruncated] = createSignal(false)
- let nameRef: HTMLSpanElement | undefined
- let versionRef: HTMLSpanElement | undefined
-
- const check = () => {
- const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
- const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
- setTruncated(nameTruncated || versionTruncated)
- }
-
- createEffect(() => {
- check()
- window.addEventListener("resize", check)
- onCleanup(() => window.removeEventListener("resize", check))
- })
-
- const tooltipValue = () => {
- const name = serverDisplayName(i)
- const version = store.status[i]?.version
- return (
-
- {name}
-
- {version}
-
-
- )
- }
-
return (
}
>
-
-
-
-
- {serverDisplayName(i)}
-
-
-
- {store.status[i]?.version}
-
-
+
{language.t("dialog.server.status.default")}
-
-
+ }
+ />
diff --git a/packages/app/src/components/file-tree.test.ts b/packages/app/src/components/file-tree.test.ts
new file mode 100644
index 0000000000..29e20b4807
--- /dev/null
+++ b/packages/app/src/components/file-tree.test.ts
@@ -0,0 +1,78 @@
+import { beforeAll, describe, expect, mock, test } from "bun:test"
+
+let shouldListRoot: typeof import("./file-tree").shouldListRoot
+let shouldListExpanded: typeof import("./file-tree").shouldListExpanded
+let dirsToExpand: typeof import("./file-tree").dirsToExpand
+
+beforeAll(async () => {
+ mock.module("@solidjs/router", () => ({
+ useNavigate: () => () => undefined,
+ useParams: () => ({}),
+ }))
+ mock.module("@/context/file", () => ({
+ useFile: () => ({
+ tree: {
+ state: () => undefined,
+ list: () => Promise.resolve(),
+ children: () => [],
+ expand: () => {},
+ collapse: () => {},
+ },
+ }),
+ }))
+ mock.module("@opencode-ai/ui/collapsible", () => ({
+ Collapsible: {
+ Trigger: (props: { children?: unknown }) => props.children,
+ Content: (props: { children?: unknown }) => props.children,
+ },
+ }))
+ mock.module("@opencode-ai/ui/file-icon", () => ({ FileIcon: () => null }))
+ mock.module("@opencode-ai/ui/icon", () => ({ Icon: () => null }))
+ mock.module("@opencode-ai/ui/tooltip", () => ({ Tooltip: (props: { children?: unknown }) => props.children }))
+ const mod = await import("./file-tree")
+ shouldListRoot = mod.shouldListRoot
+ shouldListExpanded = mod.shouldListExpanded
+ dirsToExpand = mod.dirsToExpand
+})
+
+describe("file tree fetch discipline", () => {
+ test("root lists on mount unless already loaded or loading", () => {
+ expect(shouldListRoot({ level: 0 })).toBe(true)
+ expect(shouldListRoot({ level: 0, dir: { loaded: true } })).toBe(false)
+ expect(shouldListRoot({ level: 0, dir: { loading: true } })).toBe(false)
+ expect(shouldListRoot({ level: 1 })).toBe(false)
+ })
+
+ test("nested dirs list only when expanded and stale", () => {
+ expect(shouldListExpanded({ level: 1 })).toBe(false)
+ expect(shouldListExpanded({ level: 1, dir: { expanded: false } })).toBe(false)
+ expect(shouldListExpanded({ level: 1, dir: { expanded: true } })).toBe(true)
+ expect(shouldListExpanded({ level: 1, dir: { expanded: true, loaded: true } })).toBe(false)
+ expect(shouldListExpanded({ level: 1, dir: { expanded: true, loading: true } })).toBe(false)
+ expect(shouldListExpanded({ level: 0, dir: { expanded: true } })).toBe(false)
+ })
+
+ test("allowed auto-expand picks only collapsed dirs", () => {
+ const expanded = new Set
()
+ const filter = { dirs: new Set(["src", "src/components"]) }
+
+ const first = dirsToExpand({
+ level: 0,
+ filter,
+ expanded: (dir) => expanded.has(dir),
+ })
+
+ expect(first).toEqual(["src", "src/components"])
+
+ for (const dir of first) expanded.add(dir)
+
+ const second = dirsToExpand({
+ level: 0,
+ filter,
+ expanded: (dir) => expanded.has(dir),
+ })
+
+ expect(second).toEqual([])
+ expect(dirsToExpand({ level: 1, filter, expanded: () => false })).toEqual([])
+ })
+})
diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx
index d43310b195..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"
@@ -8,6 +9,7 @@ import {
createMemo,
For,
Match,
+ on,
Show,
splitProps,
Switch,
@@ -18,6 +20,10 @@ import {
import { Dynamic } from "solid-js/web"
import type { FileNode } from "@opencode-ai/sdk/v2"
+function pathToFileUrl(filepath: string): string {
+ return `file://${encodeFilePath(filepath)}`
+}
+
type Kind = "add" | "del" | "mix"
type Filter = {
@@ -25,6 +31,34 @@ type Filter = {
dirs: Set
}
+export function shouldListRoot(input: { level: number; dir?: { loaded?: boolean; loading?: boolean } }) {
+ if (input.level !== 0) return false
+ if (input.dir?.loaded) return false
+ if (input.dir?.loading) return false
+ return true
+}
+
+export function shouldListExpanded(input: {
+ level: number
+ dir?: { expanded?: boolean; loaded?: boolean; loading?: boolean }
+}) {
+ if (input.level === 0) return false
+ if (!input.dir?.expanded) return false
+ if (input.dir.loaded) return false
+ if (input.dir.loading) return false
+ return true
+}
+
+export function dirsToExpand(input: {
+ level: number
+ filter?: { dirs: Set }
+ expanded: (dir: string) => boolean
+}) {
+ if (input.level !== 0) return []
+ if (!input.filter) return []
+ return [...input.filter.dirs].filter((dir) => !input.expanded(dir))
+}
+
export default function FileTree(props: {
path: string
class?: string
@@ -111,29 +145,89 @@ export default function FileTree(props: {
createEffect(() => {
const current = filter()
- if (!current) return
- if (level !== 0) return
-
- for (const dir of current.dirs) {
- const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false
- if (expanded) continue
- file.tree.expand(dir)
- }
+ const dirs = dirsToExpand({
+ level,
+ filter: current,
+ expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
+ })
+ for (const dir of dirs) file.tree.expand(dir)
})
+ createEffect(
+ on(
+ () => props.path,
+ (path) => {
+ const dir = untrack(() => file.tree.state(path))
+ if (!shouldListRoot({ level, dir })) return
+ void file.tree.list(path)
+ },
+ { defer: false },
+ ),
+ )
+
createEffect(() => {
- const path = props.path
- untrack(() => void file.tree.list(path))
+ const dir = file.tree.state(props.path)
+ if (!shouldListExpanded({ level, dir })) return
+ void file.tree.list(props.path)
})
const nodes = createMemo(() => {
const nodes = file.tree.children(props.path)
const current = filter()
if (!current) return nodes
- return nodes.filter((node) => {
+
+ const parent = (path: string) => {
+ const idx = path.lastIndexOf("/")
+ if (idx === -1) return ""
+ return path.slice(0, idx)
+ }
+
+ const leaf = (path: string) => {
+ const idx = path.lastIndexOf("/")
+ return idx === -1 ? path : path.slice(idx + 1)
+ }
+
+ const out = nodes.filter((node) => {
if (node.type === "file") return current.files.has(node.path)
return current.dirs.has(node.path)
})
+
+ const seen = new Set(out.map((node) => node.path))
+
+ for (const dir of current.dirs) {
+ if (parent(dir) !== props.path) continue
+ if (seen.has(dir)) continue
+ out.push({
+ name: leaf(dir),
+ path: dir,
+ absolute: dir,
+ type: "directory",
+ ignored: false,
+ })
+ seen.add(dir)
+ }
+
+ for (const item of current.files) {
+ if (parent(item) !== props.path) continue
+ if (seen.has(item)) continue
+ out.push({
+ name: leaf(item),
+ path: item,
+ absolute: item,
+ type: "file",
+ ignored: false,
+ })
+ seen.add(item)
+ }
+
+ 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 = (
@@ -160,7 +254,7 @@ export default function FileTree(props: {
onDragStart={(e: DragEvent) => {
if (!draggable()) return
e.dataTransfer?.setData("text/plain", `file:${local.node.path}`)
- e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`)
+ e.dataTransfer?.setData("text/uri-list", pathToFileUrl(local.node.path))
if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy"
const dragImage = document.createElement("div")
@@ -194,7 +288,7 @@ export default function FileTree(props: {
: kind === "del"
? "color: var(--icon-diff-delete-base)"
: kind === "mix"
- ? "color: var(--icon-diff-modified-base)"
+ ? "color: var(--icon-warning-active)"
: undefined
return (
@@ -236,7 +330,7 @@ export default function FileTree(props: {
? "background-color: var(--icon-diff-add-base)"
: kind === "del"
? "background-color: var(--icon-diff-delete-base)"
- : "background-color: var(--icon-diff-modified-base)"
+ : "background-color: var(--icon-warning-active)"
return
}
@@ -274,7 +368,6 @@ export default function FileTree(props: {
return (
()
+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"
+import { createPromptSubmit } from "./prompt-input/submit"
+import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover"
+import { PromptContextItems } from "./prompt-input/context-items"
+import { PromptImageAttachments } from "./prompt-input/image-attachments"
+import { PromptDragOverlay } from "./prompt-input/drag-overlay"
+import { promptPlaceholder } from "./prompt-input/placeholder"
+import { ImagePreview } from "@opencode-ai/ui/image-preview"
interface PromptInputProps {
class?: string
@@ -105,21 +83,9 @@ const EXAMPLES = [
"prompt.example.25",
] as const
-interface SlashCommand {
- id: string
- trigger: string
- title: string
- description?: string
- keybind?: string
- type: "builtin" | "custom"
-}
-
export const PromptInput: Component = (props) => {
- const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
- const globalSync = useGlobalSync()
- const platform = usePlatform()
const local = useLocal()
const files = useFile()
const prompt = usePrompt()
@@ -132,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
@@ -171,6 +138,7 @@ export const PromptInput: Component = (props) => {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
+ const view = createMemo(() => layout.view(sessionKey))
const commentInReview = (path: string) => {
const sessionID = params.id
@@ -189,12 +157,14 @@ export const PromptInput: Component = (props) => {
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) {
+ if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("changes")
requestAnimationFrame(() => comments.setFocus(focus))
return
}
+ if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
@@ -228,8 +198,8 @@ export const PromptInput: Component = (props) => {
},
)
const working = createMemo(() => status()?.type !== "idle")
- const imageAttachments = createMemo(
- () => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[],
+ const imageAttachments = createMemo(() =>
+ prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
)
const [store, setStore] = createStore<{
@@ -237,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
}>({
@@ -245,10 +215,18 @@ export const PromptInput: Component = (props) => {
historyIndex: -1,
savedPrompt: null,
placeholder: Math.floor(Math.random() * EXAMPLES.length),
- dragging: false,
+ draggingType: null,
mode: "normal",
applyingHistory: false,
})
+ const placeholder = createMemo(() =>
+ promptPlaceholder({
+ mode: store.mode,
+ commentCount: commentCount(),
+ example: language.t(EXAMPLES[store.placeholder]),
+ t: (key, params) => language.t(key as Parameters[0], params as never),
+ }),
+ )
const MAX_HISTORY = 100
const [history, setHistory] = persisted(
@@ -268,20 +246,6 @@ export const PromptInput: Component = (props) => {
}),
)
- const clonePromptParts = (prompt: Prompt): Prompt =>
- prompt.map((part) => {
- if (part.type === "text") return { ...part }
- if (part.type === "image") return { ...part }
- if (part.type === "agent") return { ...part }
- return {
- ...part,
- selection: part.selection ? { ...part.selection } : undefined,
- }
- })
-
- const promptLength = (prompt: Prompt) =>
- prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
-
const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
const length = position === "start" ? 0 : promptLength(p)
setStore("applyingHistory", true)
@@ -325,110 +289,6 @@ export const PromptInput: Component = (props) => {
const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
- const addImageAttachment = async (file: File) => {
- if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
-
- const reader = new FileReader()
- reader.onload = () => {
- const dataUrl = reader.result as string
- const attachment: ImageAttachmentPart = {
- type: "image",
- id: crypto.randomUUID(),
- filename: file.name,
- mime: file.type,
- dataUrl,
- }
- const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
- prompt.set([...prompt.current(), attachment], cursorPosition)
- }
- reader.readAsDataURL(file)
- }
-
- const removeImageAttachment = (id: string) => {
- const current = prompt.current()
- const next = current.filter((part) => part.type !== "image" || part.id !== id)
- prompt.set(next, prompt.cursor())
- }
-
- const handlePaste = async (event: ClipboardEvent) => {
- if (!isFocused()) return
- const clipboardData = event.clipboardData
- if (!clipboardData) return
-
- event.preventDefault()
- event.stopPropagation()
-
- const items = Array.from(clipboardData.items)
- const fileItems = items.filter((item) => item.kind === "file")
- const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
-
- if (imageItems.length > 0) {
- for (const item of imageItems) {
- const file = item.getAsFile()
- if (file) await addImageAttachment(file)
- }
- return
- }
-
- if (fileItems.length > 0) {
- showToast({
- title: language.t("prompt.toast.pasteUnsupported.title"),
- description: language.t("prompt.toast.pasteUnsupported.description"),
- })
- return
- }
-
- const plainText = clipboardData.getData("text/plain") ?? ""
- if (!plainText) return
- addPart({ type: "text", content: plainText, start: 0, end: 0 })
- }
-
- const handleGlobalDragOver = (event: DragEvent) => {
- if (dialog.active) return
-
- event.preventDefault()
- const hasFiles = event.dataTransfer?.types.includes("Files")
- if (hasFiles) {
- setStore("dragging", true)
- }
- }
-
- const handleGlobalDragLeave = (event: DragEvent) => {
- if (dialog.active) return
-
- // relatedTarget is null when leaving the document window
- if (!event.relatedTarget) {
- setStore("dragging", false)
- }
- }
-
- const handleGlobalDrop = async (event: DragEvent) => {
- if (dialog.active) return
-
- event.preventDefault()
- setStore("dragging", false)
-
- const dropped = event.dataTransfer?.files
- if (!dropped) return
-
- for (const file of Array.from(dropped)) {
- if (ACCEPTED_FILE_TYPES.includes(file.type)) {
- await addImageAttachment(file)
- }
- }
- }
-
- onMount(() => {
- document.addEventListener("dragover", handleGlobalDragOver)
- document.addEventListener("dragleave", handleGlobalDragLeave)
- document.addEventListener("drop", handleGlobalDrop)
- })
- onCleanup(() => {
- document.removeEventListener("dragover", handleGlobalDragOver)
- document.removeEventListener("dragleave", handleGlobalDragLeave)
- document.removeEventListener("drop", handleGlobalDrop)
- })
-
createEffect(() => {
if (!isFocused()) setStore("popover", null)
})
@@ -439,10 +299,6 @@ export const PromptInput: Component = (props) => {
if (!isFocused()) setComposing(false)
})
- type AtOption =
- | { type: "agent"; name: string; display: string }
- | { type: "file"; path: string; display: string; recent?: boolean }
-
const agentList = createMemo(() =>
sync.data.agent
.filter((agent) => !agent.hidden && agent.mode !== "primary")
@@ -517,6 +373,7 @@ export const PromptInput: Component = (props) => {
title: cmd.name,
description: cmd.description,
type: "custom" as const,
+ source: cmd.source,
}))
return [...custom, ...builtin]
@@ -558,7 +415,7 @@ export const PromptInput: Component = (props) => {
} = useFilteredList({
items: slashCommands,
key: (x) => x?.id,
- filterKeys: ["trigger", "title", "description"],
+ filterKeys: ["trigger", "title"],
onSelect: handleSlashSelect,
})
@@ -651,7 +508,7 @@ export const PromptInput: Component = (props) => {
on(
() => prompt.current(),
(currentParts) => {
- const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
+ const inputParts = currentParts.filter((part) => part.type !== "image")
if (mirror.input) {
mirror.input = false
@@ -821,36 +678,6 @@ export const PromptInput: Component = (props) => {
queueScroll()
}
- const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => {
- let remaining = offset
- const nodes = Array.from(editorRef.childNodes)
-
- for (const node of nodes) {
- const length = getNodeLength(node)
- const isText = node.nodeType === Node.TEXT_NODE
- const isPill =
- node.nodeType === Node.ELEMENT_NODE &&
- ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
- const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
-
- if (isText && remaining <= length) {
- if (edge === "start") range.setStart(node, remaining)
- if (edge === "end") range.setEnd(node, remaining)
- return
- }
-
- if ((isPill || isBreak) && remaining <= length) {
- if (edge === "start" && remaining === 0) range.setStartBefore(node)
- if (edge === "start" && remaining > 0) range.setStartAfter(node)
- if (edge === "end" && remaining === 0) range.setEndBefore(node)
- if (edge === "end" && remaining > 0) range.setEndAfter(node)
- return
- }
-
- remaining -= length
- }
- }
-
const addPart = (part: ContentPart) => {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return
@@ -868,8 +695,8 @@ export const PromptInput: Component = (props) => {
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
- setRangeEdge(range, "start", start)
- setRangeEdge(range, "end", cursorPosition)
+ setRangeEdge(editorRef, range, "start", start)
+ setRangeEdge(editorRef, range, "end", cursorPosition)
}
range.deleteContents()
@@ -908,82 +735,63 @@ export const PromptInput: Component = (props) => {
setStore("popover", null)
}
- const abort = async () => {
- const sessionID = params.id
- if (!sessionID) return Promise.resolve()
- const queued = pending.get(sessionID)
- if (queued) {
- queued.abort.abort()
- queued.cleanup()
- pending.delete(sessionID)
- return Promise.resolve()
- }
- return sdk.client.session
- .abort({
- sessionID,
- })
- .catch(() => {})
- }
-
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
- const text = prompt
- .map((p) => ("content" in p ? p.content : ""))
- .join("")
- .trim()
- const hasImages = prompt.some((part) => part.type === "image")
- if (!text && !hasImages) return
-
- const entry = clonePromptParts(prompt)
const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
- const lastEntry = currentHistory.entries[0]
- if (lastEntry && isPromptEqual(lastEntry, entry)) return
-
- setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
+ const next = prependHistoryEntry(currentHistory.entries, prompt)
+ if (next === currentHistory.entries) return
+ setCurrentHistory("entries", next)
}
const navigateHistory = (direction: "up" | "down") => {
- const entries = store.mode === "shell" ? shellHistory.entries : history.entries
- const current = store.historyIndex
-
- if (direction === "up") {
- if (entries.length === 0) return false
- if (current === -1) {
- setStore("savedPrompt", clonePromptParts(prompt.current()))
- setStore("historyIndex", 0)
- applyHistoryPrompt(entries[0], "start")
- return true
- }
- if (current < entries.length - 1) {
- const next = current + 1
- setStore("historyIndex", next)
- applyHistoryPrompt(entries[next], "start")
- return true
- }
- return false
- }
-
- if (current > 0) {
- const next = current - 1
- setStore("historyIndex", next)
- applyHistoryPrompt(entries[next], "end")
- return true
- }
- if (current === 0) {
- setStore("historyIndex", -1)
- const saved = store.savedPrompt
- if (saved) {
- applyHistoryPrompt(saved, "end")
- setStore("savedPrompt", null)
- return true
- }
- applyHistoryPrompt(DEFAULT_PROMPT, "end")
- return true
- }
-
- return false
+ const result = navigatePromptHistory({
+ direction,
+ entries: store.mode === "shell" ? shellHistory.entries : history.entries,
+ historyIndex: store.historyIndex,
+ currentPrompt: prompt.current(),
+ savedPrompt: store.savedPrompt,
+ })
+ if (!result.handled) return false
+ setStore("historyIndex", result.historyIndex)
+ setStore("savedPrompt", result.savedPrompt)
+ applyHistoryPrompt(result.prompt, result.cursor)
+ return true
}
+ const { addImageAttachment, removeImageAttachment, handlePaste } = createPromptAttachments({
+ editor: () => editorRef,
+ isFocused,
+ isDialogActive: () => !!dialog.active,
+ setDraggingType: (type) => setStore("draggingType", type),
+ focusEditor: () => {
+ editorRef.focus()
+ setCursorPosition(editorRef, promptLength(prompt.current()))
+ },
+ addPart,
+ readClipboardImage: platform.readClipboardImage,
+ })
+
+ const { abort, handleSubmit } = createPromptSubmit({
+ info,
+ imageAttachments,
+ commentCount,
+ mode: () => store.mode,
+ working,
+ editor: () => editorRef,
+ queueScroll,
+ promptLength,
+ addToHistory,
+ resetHistoryNavigation: () => {
+ setStore("historyIndex", -1)
+ setStore("savedPrompt", null)
+ },
+ setMode: (mode) => setStore("mode", mode),
+ setPopover: (popover) => setStore("popover", popover),
+ newSessionWorktree: () => props.newSessionWorktree,
+ onNewSessionWorktreeReset: props.onNewSessionWorktreeReset,
+ onSubmit: props.onSubmit,
+ })
+
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Backspace") {
const selection = window.getSelection()
@@ -1122,730 +930,58 @@ export const PromptInput: Component = (props) => {
}
}
- const handleSubmit = async (event: Event) => {
- event.preventDefault()
-
- const currentPrompt = prompt.current()
- const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
- const images = imageAttachments().slice()
- const mode = store.mode
-
- if (text.trim().length === 0 && images.length === 0) {
- if (working()) abort()
- return
- }
-
- const currentModel = local.model.current()
- const currentAgent = local.agent.current()
- if (!currentModel || !currentAgent) {
- showToast({
- title: language.t("prompt.toast.modelAgentRequired.title"),
- description: language.t("prompt.toast.modelAgentRequired.description"),
- })
- return
- }
-
- const errorMessage = (err: unknown) => {
- if (err && typeof err === "object" && "data" in err) {
- const data = (err as { data?: { message?: string } }).data
- if (data?.message) return data.message
- }
- if (err instanceof Error) return err.message
- return language.t("common.requestFailed")
- }
-
- addToHistory(currentPrompt, mode)
- setStore("historyIndex", -1)
- setStore("savedPrompt", null)
-
- const projectDirectory = sdk.directory
- const isNewSession = !params.id
- const worktreeSelection = props.newSessionWorktree ?? "main"
-
- let sessionDirectory = projectDirectory
- let client = sdk.client
-
- if (isNewSession) {
- if (worktreeSelection === "create") {
- const createdWorktree = await client.worktree
- .create({ directory: projectDirectory })
- .then((x) => x.data)
- .catch((err) => {
- showToast({
- title: language.t("prompt.toast.worktreeCreateFailed.title"),
- description: errorMessage(err),
- })
- return undefined
- })
-
- if (!createdWorktree?.directory) {
- showToast({
- title: language.t("prompt.toast.worktreeCreateFailed.title"),
- description: language.t("common.requestFailed"),
- })
- return
- }
- WorktreeState.pending(createdWorktree.directory)
- sessionDirectory = createdWorktree.directory
- }
-
- if (worktreeSelection !== "main" && worktreeSelection !== "create") {
- sessionDirectory = worktreeSelection
- }
-
- if (sessionDirectory !== projectDirectory) {
- client = createOpencodeClient({
- baseUrl: sdk.url,
- fetch: platform.fetch,
- directory: sessionDirectory,
- throwOnError: true,
- })
- globalSync.child(sessionDirectory)
- }
-
- props.onNewSessionWorktreeReset?.()
- }
-
- let session = info()
- if (!session && isNewSession) {
- session = await client.session
- .create()
- .then((x) => x.data ?? undefined)
- .catch((err) => {
- showToast({
- title: language.t("prompt.toast.sessionCreateFailed.title"),
- description: errorMessage(err),
- })
- return undefined
- })
- if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
- }
- if (!session) return
-
- props.onSubmit?.()
-
- const model = {
- modelID: currentModel.id,
- providerID: currentModel.provider.id,
- }
- const agent = currentAgent.name
- const variant = local.model.variant.current()
-
- const clearInput = () => {
- prompt.reset()
- setStore("mode", "normal")
- setStore("popover", null)
- }
-
- const restoreInput = () => {
- prompt.set(currentPrompt, promptLength(currentPrompt))
- setStore("mode", mode)
- setStore("popover", null)
- requestAnimationFrame(() => {
- editorRef.focus()
- setCursorPosition(editorRef, promptLength(currentPrompt))
- queueScroll()
- })
- }
-
- if (mode === "shell") {
- clearInput()
- client.session
- .shell({
- sessionID: session.id,
- agent,
- model,
- command: text,
- })
- .catch((err) => {
- showToast({
- title: language.t("prompt.toast.shellSendFailed.title"),
- description: errorMessage(err),
- })
- restoreInput()
- })
- return
- }
-
- if (text.startsWith("/")) {
- const [cmdName, ...args] = text.split(" ")
- const commandName = cmdName.slice(1)
- const customCommand = sync.data.command.find((c) => c.name === commandName)
- if (customCommand) {
- clearInput()
- client.session
- .command({
- sessionID: session.id,
- command: commandName,
- arguments: args.join(" "),
- agent,
- model: `${model.providerID}/${model.modelID}`,
- variant,
- parts: images.map((attachment) => ({
- id: Identifier.ascending("part"),
- type: "file" as const,
- mime: attachment.mime,
- url: attachment.dataUrl,
- filename: attachment.filename,
- })),
- })
- .catch((err) => {
- showToast({
- title: language.t("prompt.toast.commandSendFailed.title"),
- description: errorMessage(err),
- })
- restoreInput()
- })
- return
- }
- }
-
- const toAbsolutePath = (path: string) =>
- path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
-
- const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
- const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
-
- const fileAttachmentParts = fileAttachments.map((attachment) => {
- const absolute = toAbsolutePath(attachment.path)
- const query = attachment.selection
- ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
- : ""
- return {
- id: Identifier.ascending("part"),
- type: "file" as const,
- mime: "text/plain",
- url: `file://${absolute}${query}`,
- filename: getFilename(attachment.path),
- source: {
- type: "file" as const,
- text: {
- value: attachment.content,
- start: attachment.start,
- end: attachment.end,
- },
- path: absolute,
- },
- }
- })
-
- const agentAttachmentParts = agentAttachments.map((attachment) => ({
- id: Identifier.ascending("part"),
- type: "agent" as const,
- name: attachment.name,
- source: {
- value: attachment.content,
- start: attachment.start,
- end: attachment.end,
- },
- }))
-
- const usedUrls = new Set(fileAttachmentParts.map((part) => part.url))
-
- const context = prompt.context.items().slice()
-
- const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
-
- const contextParts: Array<
- | {
- id: string
- type: "text"
- text: string
- synthetic?: boolean
- }
- | {
- id: string
- type: "file"
- mime: string
- url: string
- filename?: string
- }
- > = []
-
- const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
- const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
- const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
- const range =
- start === undefined || end === undefined
- ? "this file"
- : start === end
- ? `line ${start}`
- : `lines ${start} through ${end}`
-
- return `The user made the following comment regarding ${range} of ${path}: ${comment}`
- }
-
- const addContextFile = (input: { path: string; selection?: FileSelection; comment?: string }) => {
- const absolute = toAbsolutePath(input.path)
- const query = input.selection ? `?start=${input.selection.startLine}&end=${input.selection.endLine}` : ""
- const url = `file://${absolute}${query}`
-
- const comment = input.comment?.trim()
- if (!comment && usedUrls.has(url)) return
- usedUrls.add(url)
-
- if (comment) {
- contextParts.push({
- id: Identifier.ascending("part"),
- type: "text",
- text: commentNote(input.path, input.selection, comment),
- synthetic: true,
- })
- }
-
- contextParts.push({
- id: Identifier.ascending("part"),
- type: "file",
- mime: "text/plain",
- url,
- filename: getFilename(input.path),
- })
- }
-
- for (const item of context) {
- if (item.type !== "file") continue
- addContextFile({ path: item.path, selection: item.selection, comment: item.comment })
- }
-
- const imageAttachmentParts = images.map((attachment) => ({
- id: Identifier.ascending("part"),
- type: "file" as const,
- mime: attachment.mime,
- url: attachment.dataUrl,
- filename: attachment.filename,
- }))
-
- const messageID = Identifier.ascending("message")
- const textPart = {
- id: Identifier.ascending("part"),
- type: "text" as const,
- text,
- }
- const requestParts = [
- textPart,
- ...fileAttachmentParts,
- ...contextParts,
- ...agentAttachmentParts,
- ...imageAttachmentParts,
- ]
-
- const optimisticParts = requestParts.map((part) => ({
- ...part,
- sessionID: session.id,
- messageID,
- })) as unknown as Part[]
-
- const optimisticMessage: Message = {
- id: messageID,
- sessionID: session.id,
- role: "user",
- time: { created: Date.now() },
- agent,
- model,
- }
-
- const addOptimisticMessage = () => {
- if (sessionDirectory === projectDirectory) {
- sync.set(
- produce((draft) => {
- const messages = draft.message[session.id]
- if (!messages) {
- draft.message[session.id] = [optimisticMessage]
- } else {
- const result = Binary.search(messages, messageID, (m) => m.id)
- messages.splice(result.index, 0, optimisticMessage)
- }
- draft.part[messageID] = optimisticParts
- .filter((p) => !!p?.id)
- .slice()
- .sort((a, b) => a.id.localeCompare(b.id))
- }),
- )
- return
- }
-
- globalSync.child(sessionDirectory)[1](
- produce((draft) => {
- const messages = draft.message[session.id]
- if (!messages) {
- draft.message[session.id] = [optimisticMessage]
- } else {
- const result = Binary.search(messages, messageID, (m) => m.id)
- messages.splice(result.index, 0, optimisticMessage)
- }
- draft.part[messageID] = optimisticParts
- .filter((p) => !!p?.id)
- .slice()
- .sort((a, b) => a.id.localeCompare(b.id))
- }),
- )
- }
-
- const removeOptimisticMessage = () => {
- if (sessionDirectory === projectDirectory) {
- sync.set(
- produce((draft) => {
- const messages = draft.message[session.id]
- if (messages) {
- const result = Binary.search(messages, messageID, (m) => m.id)
- if (result.found) messages.splice(result.index, 1)
- }
- delete draft.part[messageID]
- }),
- )
- return
- }
-
- globalSync.child(sessionDirectory)[1](
- produce((draft) => {
- const messages = draft.message[session.id]
- if (messages) {
- const result = Binary.search(messages, messageID, (m) => m.id)
- if (result.found) messages.splice(result.index, 1)
- }
- delete draft.part[messageID]
- }),
- )
- }
-
- for (const item of commentItems) {
- prompt.context.remove(item.key)
- }
-
- clearInput()
- addOptimisticMessage()
-
- const waitForWorktree = async () => {
- const worktree = WorktreeState.get(sessionDirectory)
- if (!worktree || worktree.status !== "pending") return true
-
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "busy" })
- }
-
- const controller = new AbortController()
-
- const cleanup = () => {
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "idle" })
- }
- removeOptimisticMessage()
- for (const item of commentItems) {
- prompt.context.add({
- type: "file",
- path: item.path,
- selection: item.selection,
- comment: item.comment,
- commentID: item.commentID,
- commentOrigin: item.commentOrigin,
- preview: item.preview,
- })
- }
- restoreInput()
- }
-
- pending.set(session.id, { abort: controller, cleanup })
-
- const abort = new Promise>>((resolve) => {
- if (controller.signal.aborted) {
- resolve({ status: "failed", message: "aborted" })
- return
- }
- controller.signal.addEventListener(
- "abort",
- () => {
- resolve({ status: "failed", message: "aborted" })
- },
- { once: true },
- )
- })
-
- const timeoutMs = 5 * 60 * 1000
- const timer = { id: undefined as number | undefined }
- const timeout = new Promise>>((resolve) => {
- timer.id = window.setTimeout(() => {
- resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
- }, timeoutMs)
- })
-
- const result = await Promise.race([WorktreeState.wait(sessionDirectory), abort, timeout]).finally(() => {
- if (timer.id === undefined) return
- clearTimeout(timer.id)
- })
- pending.delete(session.id)
- if (controller.signal.aborted) return false
- if (result.status === "failed") throw new Error(result.message)
- return true
- }
-
- const send = async () => {
- const ok = await waitForWorktree()
- if (!ok) return
- await client.session.prompt({
- sessionID: session.id,
- agent,
- model,
- messageID,
- parts: requestParts,
- variant,
- })
- }
-
- void send().catch((err) => {
- pending.delete(session.id)
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "idle" })
- }
- showToast({
- title: language.t("prompt.toast.promptSendFailed.title"),
- description: errorMessage(err),
- })
- removeOptimisticMessage()
- for (const item of commentItems) {
- prompt.context.add({
- type: "file",
- path: item.path,
- selection: item.selection,
- comment: item.comment,
- commentID: item.commentID,
- commentOrigin: item.commentOrigin,
- preview: item.preview,
- })
- }
- restoreInput()
- })
- }
-
return (
-
- {
- if (store.popover === "slash") slashPopoverRef = el
- }}
- class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
- overflow-auto no-scrollbar flex flex-col p-2 rounded-md
- border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
- onMouseDown={(e) => e.preventDefault()}
- >
-
-
- 0}
- fallback={{language.t("prompt.popover.emptyResults")}
}
- >
-
- {(item) => (
- handleAtSelect(item)}
- onMouseEnter={() => setAtActive(atKey(item))}
- >
-
-
-
-
- {(() => {
- const path = (item as { type: "file"; path: string }).path
- return path.endsWith("/") ? path : getDirectory(path)
- })()}
-
-
-
- {getFilename((item as { type: "file"; path: string }).path)}
-
-
-
- >
- }
- >
-
-
- @{(item as { type: "agent"; name: string }).name}
-
-
-
- )}
-
-
-
-
- 0}
- fallback={{language.t("prompt.popover.emptyCommands")}
}
- >
-
- {(cmd) => (
- handleSlashSelect(cmd)}
- onMouseEnter={() => setSlashActive(cmd.id)}
- >
-
- /{cmd.trigger}
-
- {cmd.description}
-
-
-
-
-
- {language.t("prompt.slash.badge.custom")}
-
-
-
- {command.keybind(cmd.id)}
-
-
-
- )}
-
-
-
-
-
-
+
(slashPopoverRef = el)}
+ atFlat={atFlat()}
+ atActive={atActive() ?? undefined}
+ atKey={atKey}
+ setAtActive={setAtActive}
+ onAtSelect={handleAtSelect}
+ slashFlat={slashFlat()}
+ slashActive={slashActive() ?? undefined}
+ setSlashActive={setSlashActive}
+ onSlashSelect={handleSlashSelect}
+ commandKeybind={command.keybind}
+ t={(key) => language.t(key as Parameters[0])}
+ />
-
+
+ {
+ const active = comments.active()
+ return !!item.commentID && item.commentID === active?.id && item.path === active?.file
+ }}
+ openComment={openComment}
+ remove={(item) => {
+ if (item.commentID) comments.remove(item.path, item.commentID)
+ prompt.context.remove(item.key)
+ }}
+ t={(key) => language.t(key as Parameters[0])}
+ />
+
+ dialog.show(() => )
+ }
+ onRemove={removeImageAttachment}
+ removeLabel={language.t("prompt.attachment.remove")}
+ />
(scrollRef = el)}>
= (props) => {
}}
role="textbox"
aria-multiline="true"
- aria-label={
- store.mode === "shell"
- ? language.t("prompt.placeholder.shell")
- : commentCount() > 1
- ? language.t("prompt.placeholder.summarizeComments")
- : commentCount() === 1
- ? language.t("prompt.placeholder.summarizeComment")
- : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })
- }
+ aria-label={placeholder()}
contenteditable="true"
+ autocapitalize="off"
+ autocorrect="off"
+ spellcheck={false}
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
@@ -1880,18 +1011,12 @@ export const PromptInput: Component
= (props) => {
/>
- {store.mode === "shell"
- ? language.t("prompt.placeholder.shell")
- : commentCount() > 1
- ? language.t("prompt.placeholder.summarizeComments")
- : commentCount() === 1
- ? language.t("prompt.placeholder.summarizeComment")
- : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
+ {placeholder()}
-
-
+
+
@@ -1903,6 +1028,7 @@ export const PromptInput: Component
= (props) => {
@@ -1910,7 +1036,8 @@ export const PromptInput: Component = (props) => {
options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
- class="capitalize"
+ class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-full" : "max-w-[120px]"}`}
+ valueClass="truncate"
variant="ghost"
/>
@@ -1919,40 +1046,56 @@ export const PromptInput: Component = (props) => {
fallback={
- dialog.show(() => )}>
+ dialog.show(() => )}
+ >
- {local.model.current()?.name ?? language.t("dialog.model.select.title")}
-
+
+ {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+
+
}
>
-
+
- {local.model.current()?.name ?? language.t("dialog.model.select.title")}
-
+
+ {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+
+
0}>
local.model.variant.cycle()}
@@ -1964,6 +1107,7 @@ export const PromptInput: Component = (props) => {
@@ -1993,7 +1137,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => {
e.currentTarget.value = ""
}}
/>
-
+
fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")}
>
@@ -2043,7 +1187,7 @@ export const PromptInput: Component = (props) => {
>
= (props) => {
)
}
-
-function createTextFragment(content: string): DocumentFragment {
- const fragment = document.createDocumentFragment()
- const segments = content.split("\n")
- segments.forEach((segment, index) => {
- if (segment) {
- fragment.appendChild(document.createTextNode(segment))
- } else if (segments.length > 1) {
- fragment.appendChild(document.createTextNode("\u200B"))
- }
- if (index < segments.length - 1) {
- fragment.appendChild(document.createElement("br"))
- }
- })
- return fragment
-}
-
-function getNodeLength(node: Node): number {
- if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
- return (node.textContent ?? "").replace(/\u200B/g, "").length
-}
-
-function getTextLength(node: Node): number {
- if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
- if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
- let length = 0
- for (const child of Array.from(node.childNodes)) {
- length += getTextLength(child)
- }
- return length
-}
-
-function getCursorPosition(parent: HTMLElement): number {
- const selection = window.getSelection()
- if (!selection || selection.rangeCount === 0) return 0
- const range = selection.getRangeAt(0)
- if (!parent.contains(range.startContainer)) return 0
- const preCaretRange = range.cloneRange()
- preCaretRange.selectNodeContents(parent)
- preCaretRange.setEnd(range.startContainer, range.startOffset)
- return getTextLength(preCaretRange.cloneContents())
-}
-
-function setCursorPosition(parent: HTMLElement, position: number) {
- let remaining = position
- let node = parent.firstChild
- while (node) {
- const length = getNodeLength(node)
- const isText = node.nodeType === Node.TEXT_NODE
- const isPill =
- node.nodeType === Node.ELEMENT_NODE &&
- ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
- const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
-
- if (isText && remaining <= length) {
- const range = document.createRange()
- const selection = window.getSelection()
- range.setStart(node, remaining)
- range.collapse(true)
- selection?.removeAllRanges()
- selection?.addRange(range)
- return
- }
-
- if ((isPill || isBreak) && remaining <= length) {
- const range = document.createRange()
- const selection = window.getSelection()
- if (remaining === 0) {
- range.setStartBefore(node)
- }
- if (remaining > 0 && isPill) {
- range.setStartAfter(node)
- }
- if (remaining > 0 && isBreak) {
- const next = node.nextSibling
- if (next && next.nodeType === Node.TEXT_NODE) {
- range.setStart(next, 0)
- }
- if (!next || next.nodeType !== Node.TEXT_NODE) {
- range.setStartAfter(node)
- }
- }
- range.collapse(true)
- selection?.removeAllRanges()
- selection?.addRange(range)
- return
- }
-
- remaining -= length
- node = node.nextSibling
- }
-
- const fallbackRange = document.createRange()
- const fallbackSelection = window.getSelection()
- const last = parent.lastChild
- if (last && last.nodeType === Node.TEXT_NODE) {
- const len = last.textContent ? last.textContent.length : 0
- fallbackRange.setStart(last, len)
- }
- if (!last || last.nodeType !== Node.TEXT_NODE) {
- fallbackRange.selectNodeContents(parent)
- }
- fallbackRange.collapse(false)
- fallbackSelection?.removeAllRanges()
- fallbackSelection?.addRange(fallbackRange)
-}
diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts
new file mode 100644
index 0000000000..0f778b5181
--- /dev/null
+++ b/packages/app/src/components/prompt-input/attachments.ts
@@ -0,0 +1,156 @@
+import { onCleanup, onMount } from "solid-js"
+import { showToast } from "@opencode-ai/ui/toast"
+import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
+import { useLanguage } from "@/context/language"
+import { getCursorPosition } from "./editor-dom"
+
+export const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+export const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
+
+type PromptAttachmentsInput = {
+ editor: () => HTMLDivElement | undefined
+ isFocused: () => boolean
+ isDialogActive: () => boolean
+ setDraggingType: (type: "image" | "@mention" | null) => void
+ focusEditor: () => void
+ addPart: (part: ContentPart) => void
+ readClipboardImage?: () => Promise
+}
+
+export function createPromptAttachments(input: PromptAttachmentsInput) {
+ const prompt = usePrompt()
+ const language = useLanguage()
+
+ const addImageAttachment = async (file: File) => {
+ if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
+
+ const reader = new FileReader()
+ reader.onload = () => {
+ const editor = input.editor()
+ if (!editor) return
+ const dataUrl = reader.result as string
+ const attachment: ImageAttachmentPart = {
+ type: "image",
+ id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
+ filename: file.name,
+ mime: file.type,
+ dataUrl,
+ }
+ const cursorPosition = prompt.cursor() ?? getCursorPosition(editor)
+ prompt.set([...prompt.current(), attachment], cursorPosition)
+ }
+ reader.readAsDataURL(file)
+ }
+
+ const removeImageAttachment = (id: string) => {
+ const current = prompt.current()
+ const next = current.filter((part) => part.type !== "image" || part.id !== id)
+ prompt.set(next, prompt.cursor())
+ }
+
+ const handlePaste = async (event: ClipboardEvent) => {
+ if (!input.isFocused()) return
+ const clipboardData = event.clipboardData
+ if (!clipboardData) return
+
+ event.preventDefault()
+ event.stopPropagation()
+
+ const items = Array.from(clipboardData.items)
+ const fileItems = items.filter((item) => item.kind === "file")
+ const imageItems = fileItems.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
+
+ if (imageItems.length > 0) {
+ for (const item of imageItems) {
+ const file = item.getAsFile()
+ if (file) await addImageAttachment(file)
+ }
+ return
+ }
+
+ if (fileItems.length > 0) {
+ showToast({
+ title: language.t("prompt.toast.pasteUnsupported.title"),
+ description: language.t("prompt.toast.pasteUnsupported.description"),
+ })
+ return
+ }
+
+ 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 })
+ }
+
+ const handleGlobalDragOver = (event: DragEvent) => {
+ if (input.isDialogActive()) return
+
+ event.preventDefault()
+ const hasFiles = event.dataTransfer?.types.includes("Files")
+ const hasText = event.dataTransfer?.types.includes("text/plain")
+ if (hasFiles) {
+ input.setDraggingType("image")
+ } else if (hasText) {
+ input.setDraggingType("@mention")
+ }
+ }
+
+ const handleGlobalDragLeave = (event: DragEvent) => {
+ if (input.isDialogActive()) return
+ if (!event.relatedTarget) {
+ input.setDraggingType(null)
+ }
+ }
+
+ const handleGlobalDrop = async (event: DragEvent) => {
+ if (input.isDialogActive()) return
+
+ event.preventDefault()
+ 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
+
+ for (const file of Array.from(dropped)) {
+ if (ACCEPTED_FILE_TYPES.includes(file.type)) {
+ await addImageAttachment(file)
+ }
+ }
+ }
+
+ onMount(() => {
+ document.addEventListener("dragover", handleGlobalDragOver)
+ document.addEventListener("dragleave", handleGlobalDragLeave)
+ document.addEventListener("drop", handleGlobalDrop)
+ })
+
+ onCleanup(() => {
+ document.removeEventListener("dragover", handleGlobalDragOver)
+ document.removeEventListener("dragleave", handleGlobalDragLeave)
+ document.removeEventListener("drop", handleGlobalDrop)
+ })
+
+ return {
+ addImageAttachment,
+ removeImageAttachment,
+ handlePaste,
+ }
+}
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
new file mode 100644
index 0000000000..72bdecc01f
--- /dev/null
+++ b/packages/app/src/components/prompt-input/build-request-parts.test.ts
@@ -0,0 +1,277 @@
+import { describe, expect, test } from "bun:test"
+import type { Prompt } from "@/context/prompt"
+import { buildRequestParts } from "./build-request-parts"
+
+describe("buildRequestParts", () => {
+ test("builds typed request and optimistic parts without cast path", () => {
+ const prompt: Prompt = [
+ { type: "text", content: "hello", start: 0, end: 5 },
+ {
+ type: "file",
+ path: "src/foo.ts",
+ content: "@src/foo.ts",
+ start: 5,
+ end: 16,
+ selection: { startLine: 4, startChar: 1, endLine: 6, endChar: 1 },
+ },
+ { type: "agent", name: "planner", content: "@planner", start: 16, end: 24 },
+ ]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [{ key: "ctx:1", type: "file", path: "src/bar.ts", comment: "check this" }],
+ images: [
+ { type: "image", id: "img_1", filename: "a.png", mime: "image/png", dataUrl: "data:image/png;base64,AAA" },
+ ],
+ text: "hello @src/foo.ts @planner",
+ messageID: "msg_1",
+ sessionID: "ses_1",
+ sessionDirectory: "/repo",
+ })
+
+ expect(result.requestParts[0]?.type).toBe("text")
+ expect(result.requestParts.some((part) => part.type === "agent")).toBe(true)
+ expect(
+ result.requestParts.some((part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts")),
+ ).toBe(true)
+ expect(result.requestParts.some((part) => part.type === "text" && part.synthetic)).toBe(true)
+
+ expect(result.optimisticParts).toHaveLength(result.requestParts.length)
+ expect(result.optimisticParts.every((part) => part.sessionID === "ses_1" && part.messageID === "msg_1")).toBe(true)
+ })
+
+ test("deduplicates context files when prompt already includes same path", () => {
+ const prompt: Prompt = [{ type: "file", path: "src/foo.ts", content: "@src/foo.ts", start: 0, end: 11 }]
+
+ const result = buildRequestParts({
+ prompt,
+ context: [
+ { key: "ctx:dup", type: "file", path: "src/foo.ts" },
+ { key: "ctx:comment", type: "file", path: "src/foo.ts", comment: "focus here" },
+ ],
+ images: [],
+ text: "@src/foo.ts",
+ messageID: "msg_2",
+ sessionID: "ses_2",
+ sessionDirectory: "/repo",
+ })
+
+ const fooFiles = result.requestParts.filter(
+ (part) => part.type === "file" && part.url.startsWith("file:///repo/src/foo.ts"),
+ )
+ const synthetic = result.requestParts.filter((part) => part.type === "text" && part.synthetic)
+
+ 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
new file mode 100644
index 0000000000..0cc54dc2b7
--- /dev/null
+++ b/packages/app/src/components/prompt-input/build-request-parts.ts
@@ -0,0 +1,179 @@
+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"
+
+type PromptRequestPart = (TextPartInput | FilePartInput | AgentPartInput) & { id: string }
+
+type ContextFile = {
+ key: string
+ type: "file"
+ path: string
+ selection?: FileSelection
+ comment?: string
+ commentID?: string
+ commentOrigin?: "review" | "file"
+ preview?: string
+}
+
+type BuildRequestPartsInput = {
+ prompt: Prompt
+ context: ContextFile[]
+ images: ImageAttachmentPart[]
+ text: string
+ messageID: string
+ sessionID: string
+ sessionDirectory: string
+}
+
+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}` : ""
+
+const isFileAttachment = (part: Prompt[number]): part is FileAttachmentPart => part.type === "file"
+const isAgentAttachment = (part: Prompt[number]): part is AgentPart => part.type === "agent"
+
+const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
+ const start = selection ? Math.min(selection.startLine, selection.endLine) : undefined
+ const end = selection ? Math.max(selection.startLine, selection.endLine) : undefined
+ const range =
+ start === undefined || end === undefined
+ ? "this file"
+ : start === end
+ ? `line ${start}`
+ : `lines ${start} through ${end}`
+ return `The user made the following comment regarding ${range} of ${path}: ${comment}`
+}
+
+const toOptimisticPart = (part: PromptRequestPart, sessionID: string, messageID: string): Part => {
+ if (part.type === "text") {
+ return {
+ id: part.id,
+ type: "text",
+ text: part.text,
+ synthetic: part.synthetic,
+ ignored: part.ignored,
+ time: part.time,
+ metadata: part.metadata,
+ sessionID,
+ messageID,
+ }
+ }
+ if (part.type === "file") {
+ return {
+ id: part.id,
+ type: "file",
+ mime: part.mime,
+ filename: part.filename,
+ url: part.url,
+ source: part.source,
+ sessionID,
+ messageID,
+ }
+ }
+ return {
+ id: part.id,
+ type: "agent",
+ name: part.name,
+ source: part.source,
+ sessionID,
+ messageID,
+ }
+}
+
+export function buildRequestParts(input: BuildRequestPartsInput) {
+ const requestParts: PromptRequestPart[] = [
+ {
+ id: Identifier.ascending("part"),
+ type: "text",
+ text: input.text,
+ },
+ ]
+
+ const files = input.prompt.filter(isFileAttachment).map((attachment) => {
+ const path = absolute(input.sessionDirectory, attachment.path)
+ return {
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: "text/plain",
+ url: `file://${encodeFilePath(path)}${fileQuery(attachment.selection)}`,
+ filename: getFilename(attachment.path),
+ source: {
+ type: "file",
+ text: {
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
+ },
+ path,
+ },
+ } satisfies PromptRequestPart
+ })
+
+ const agents = input.prompt.filter(isAgentAttachment).map((attachment) => {
+ return {
+ id: Identifier.ascending("part"),
+ type: "agent",
+ name: attachment.name,
+ source: {
+ value: attachment.content,
+ start: attachment.start,
+ end: attachment.end,
+ },
+ } satisfies PromptRequestPart
+ })
+
+ const used = new Set(files.map((part) => part.url))
+ const context = input.context.flatMap((item) => {
+ const path = absolute(input.sessionDirectory, item.path)
+ const url = `file://${encodeFilePath(path)}${fileQuery(item.selection)}`
+ const comment = item.comment?.trim()
+ if (!comment && used.has(url)) return []
+ used.add(url)
+
+ const filePart = {
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: "text/plain",
+ url,
+ filename: getFilename(item.path),
+ } satisfies PromptRequestPart
+
+ if (!comment) return [filePart]
+
+ return [
+ {
+ id: Identifier.ascending("part"),
+ type: "text",
+ text: commentNote(item.path, item.selection, comment),
+ synthetic: true,
+ } satisfies PromptRequestPart,
+ filePart,
+ ]
+ })
+
+ const images = input.images.map((attachment) => {
+ return {
+ id: Identifier.ascending("part"),
+ type: "file",
+ mime: attachment.mime,
+ url: attachment.dataUrl,
+ filename: attachment.filename,
+ } satisfies PromptRequestPart
+ })
+
+ requestParts.push(...files, ...context, ...agents, ...images)
+
+ return {
+ requestParts,
+ optimisticParts: requestParts.map((part) => toOptimisticPart(part, input.sessionID, input.messageID)),
+ }
+}
diff --git a/packages/app/src/components/prompt-input/context-items.tsx b/packages/app/src/components/prompt-input/context-items.tsx
new file mode 100644
index 0000000000..a843e109d8
--- /dev/null
+++ b/packages/app/src/components/prompt-input/context-items.tsx
@@ -0,0 +1,82 @@
+import { Component, For, Show } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
+import type { ContextItem } from "@/context/prompt"
+
+type PromptContextItem = ContextItem & { key: string }
+
+type ContextItemsProps = {
+ items: PromptContextItem[]
+ active: (item: PromptContextItem) => boolean
+ openComment: (item: PromptContextItem) => void
+ remove: (item: PromptContextItem) => void
+ t: (key: string) => string
+}
+
+export const PromptContextItems: Component = (props) => {
+ return (
+ 0}>
+
+
+ )
+}
diff --git a/packages/app/src/components/prompt-input/drag-overlay.tsx b/packages/app/src/components/prompt-input/drag-overlay.tsx
new file mode 100644
index 0000000000..e05b47d7cf
--- /dev/null
+++ b/packages/app/src/components/prompt-input/drag-overlay.tsx
@@ -0,0 +1,20 @@
+import { Component, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+
+type PromptDragOverlayProps = {
+ type: "image" | "@mention" | null
+ label: string
+}
+
+export const PromptDragOverlay: Component = (props) => {
+ return (
+
+
+
+ )
+}
diff --git a/packages/app/src/components/prompt-input/editor-dom.test.ts b/packages/app/src/components/prompt-input/editor-dom.test.ts
new file mode 100644
index 0000000000..fce8b4b953
--- /dev/null
+++ b/packages/app/src/components/prompt-input/editor-dom.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, test } from "bun:test"
+import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom"
+
+describe("prompt-input editor dom", () => {
+ test("createTextFragment preserves newlines with br and zero-width placeholders", () => {
+ const fragment = createTextFragment("foo\n\nbar")
+ const container = document.createElement("div")
+ container.appendChild(fragment)
+
+ expect(container.childNodes.length).toBe(5)
+ expect(container.childNodes[0]?.textContent).toBe("foo")
+ expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR")
+ expect(container.childNodes[2]?.textContent).toBe("\u200B")
+ expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR")
+ expect(container.childNodes[4]?.textContent).toBe("bar")
+ })
+
+ test("length helpers treat breaks as one char and ignore zero-width chars", () => {
+ const container = document.createElement("div")
+ container.appendChild(document.createTextNode("ab\u200B"))
+ container.appendChild(document.createElement("br"))
+ container.appendChild(document.createTextNode("cd"))
+
+ expect(getNodeLength(container.childNodes[0]!)).toBe(2)
+ expect(getNodeLength(container.childNodes[1]!)).toBe(1)
+ expect(getTextLength(container)).toBe(5)
+ })
+
+ test("setCursorPosition and getCursorPosition round-trip with pills and breaks", () => {
+ const container = document.createElement("div")
+ const pill = document.createElement("span")
+ pill.dataset.type = "file"
+ pill.textContent = "@file"
+ container.appendChild(document.createTextNode("ab"))
+ container.appendChild(pill)
+ container.appendChild(document.createElement("br"))
+ container.appendChild(document.createTextNode("cd"))
+ document.body.appendChild(container)
+
+ setCursorPosition(container, 2)
+ expect(getCursorPosition(container)).toBe(2)
+
+ setCursorPosition(container, 7)
+ expect(getCursorPosition(container)).toBe(7)
+
+ setCursorPosition(container, 8)
+ expect(getCursorPosition(container)).toBe(8)
+
+ container.remove()
+ })
+})
diff --git a/packages/app/src/components/prompt-input/editor-dom.ts b/packages/app/src/components/prompt-input/editor-dom.ts
new file mode 100644
index 0000000000..3116ceb126
--- /dev/null
+++ b/packages/app/src/components/prompt-input/editor-dom.ts
@@ -0,0 +1,135 @@
+export function createTextFragment(content: string): DocumentFragment {
+ const fragment = document.createDocumentFragment()
+ const segments = content.split("\n")
+ segments.forEach((segment, index) => {
+ if (segment) {
+ fragment.appendChild(document.createTextNode(segment))
+ } else if (segments.length > 1) {
+ fragment.appendChild(document.createTextNode("\u200B"))
+ }
+ if (index < segments.length - 1) {
+ fragment.appendChild(document.createElement("br"))
+ }
+ })
+ return fragment
+}
+
+export function getNodeLength(node: Node): number {
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ return (node.textContent ?? "").replace(/\u200B/g, "").length
+}
+
+export function getTextLength(node: Node): number {
+ if (node.nodeType === Node.TEXT_NODE) return (node.textContent ?? "").replace(/\u200B/g, "").length
+ if (node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR") return 1
+ let length = 0
+ for (const child of Array.from(node.childNodes)) {
+ length += getTextLength(child)
+ }
+ return length
+}
+
+export function getCursorPosition(parent: HTMLElement): number {
+ const selection = window.getSelection()
+ if (!selection || selection.rangeCount === 0) return 0
+ const range = selection.getRangeAt(0)
+ if (!parent.contains(range.startContainer)) return 0
+ const preCaretRange = range.cloneRange()
+ preCaretRange.selectNodeContents(parent)
+ preCaretRange.setEnd(range.startContainer, range.startOffset)
+ return getTextLength(preCaretRange.cloneContents())
+}
+
+export function setCursorPosition(parent: HTMLElement, position: number) {
+ let remaining = position
+ let node = parent.firstChild
+ while (node) {
+ const length = getNodeLength(node)
+ const isText = node.nodeType === Node.TEXT_NODE
+ const isPill =
+ node.nodeType === Node.ELEMENT_NODE &&
+ ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+ if (isText && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ range.setStart(node, remaining)
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ if ((isPill || isBreak) && remaining <= length) {
+ const range = document.createRange()
+ const selection = window.getSelection()
+ if (remaining === 0) {
+ range.setStartBefore(node)
+ }
+ if (remaining > 0 && isPill) {
+ range.setStartAfter(node)
+ }
+ if (remaining > 0 && isBreak) {
+ const next = node.nextSibling
+ if (next && next.nodeType === Node.TEXT_NODE) {
+ range.setStart(next, 0)
+ }
+ if (!next || next.nodeType !== Node.TEXT_NODE) {
+ range.setStartAfter(node)
+ }
+ }
+ range.collapse(true)
+ selection?.removeAllRanges()
+ selection?.addRange(range)
+ return
+ }
+
+ remaining -= length
+ node = node.nextSibling
+ }
+
+ const fallbackRange = document.createRange()
+ const fallbackSelection = window.getSelection()
+ const last = parent.lastChild
+ if (last && last.nodeType === Node.TEXT_NODE) {
+ const len = last.textContent ? last.textContent.length : 0
+ fallbackRange.setStart(last, len)
+ }
+ if (!last || last.nodeType !== Node.TEXT_NODE) {
+ fallbackRange.selectNodeContents(parent)
+ }
+ fallbackRange.collapse(false)
+ fallbackSelection?.removeAllRanges()
+ fallbackSelection?.addRange(fallbackRange)
+}
+
+export function setRangeEdge(parent: HTMLElement, range: Range, edge: "start" | "end", offset: number) {
+ let remaining = offset
+ const nodes = Array.from(parent.childNodes)
+
+ for (const node of nodes) {
+ const length = getNodeLength(node)
+ const isText = node.nodeType === Node.TEXT_NODE
+ const isPill =
+ node.nodeType === Node.ELEMENT_NODE &&
+ ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+ const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+ if (isText && remaining <= length) {
+ if (edge === "start") range.setStart(node, remaining)
+ if (edge === "end") range.setEnd(node, remaining)
+ return
+ }
+
+ if ((isPill || isBreak) && remaining <= length) {
+ if (edge === "start" && remaining === 0) range.setStartBefore(node)
+ if (edge === "start" && remaining > 0) range.setStartAfter(node)
+ if (edge === "end" && remaining === 0) range.setEndBefore(node)
+ if (edge === "end" && remaining > 0) range.setEndAfter(node)
+ return
+ }
+
+ remaining -= length
+ }
+}
diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts
new file mode 100644
index 0000000000..54be9cb75b
--- /dev/null
+++ b/packages/app/src/components/prompt-input/history.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test"
+import type { Prompt } from "@/context/prompt"
+import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history"
+
+const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
+
+describe("prompt-input history", () => {
+ test("prependHistoryEntry skips empty prompt and deduplicates consecutive entries", () => {
+ const first = prependHistoryEntry([], DEFAULT_PROMPT)
+ expect(first).toEqual([])
+
+ const withOne = prependHistoryEntry([], text("hello"))
+ expect(withOne).toHaveLength(1)
+
+ const deduped = prependHistoryEntry(withOne, text("hello"))
+ expect(deduped).toBe(withOne)
+ })
+
+ test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
+ const entries = [text("third"), text("second"), text("first")]
+ const up = navigatePromptHistory({
+ direction: "up",
+ entries,
+ historyIndex: -1,
+ currentPrompt: text("draft"),
+ savedPrompt: null,
+ })
+ expect(up.handled).toBe(true)
+ if (!up.handled) throw new Error("expected handled")
+ expect(up.historyIndex).toBe(0)
+ expect(up.cursor).toBe("start")
+
+ const down = navigatePromptHistory({
+ direction: "down",
+ entries,
+ historyIndex: up.historyIndex,
+ currentPrompt: text("ignored"),
+ savedPrompt: up.savedPrompt,
+ })
+ expect(down.handled).toBe(true)
+ if (!down.handled) throw new Error("expected handled")
+ expect(down.historyIndex).toBe(-1)
+ expect(down.prompt[0]?.type === "text" ? down.prompt[0].content : "").toBe("draft")
+ })
+
+ test("helpers clone prompt and count text content length", () => {
+ const original: Prompt = [
+ { type: "text", content: "one", start: 0, end: 3 },
+ {
+ type: "file",
+ path: "src/a.ts",
+ content: "@src/a.ts",
+ start: 3,
+ end: 12,
+ selection: { startLine: 1, startChar: 1, endLine: 2, endChar: 1 },
+ },
+ { type: "image", id: "1", filename: "img.png", mime: "image/png", dataUrl: "data:image/png;base64,abc" },
+ ]
+ const copy = clonePromptParts(original)
+ expect(copy).not.toBe(original)
+ expect(promptLength(copy)).toBe(12)
+ if (copy[1]?.type !== "file") throw new Error("expected file")
+ copy[1].selection!.startLine = 9
+ if (original[1]?.type !== "file") throw new Error("expected file")
+ expect(original[1].selection?.startLine).toBe(1)
+ })
+})
diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts
new file mode 100644
index 0000000000..63164f0ba3
--- /dev/null
+++ b/packages/app/src/components/prompt-input/history.ts
@@ -0,0 +1,160 @@
+import type { Prompt } from "@/context/prompt"
+
+const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+export const MAX_HISTORY = 100
+
+export function clonePromptParts(prompt: Prompt): Prompt {
+ return prompt.map((part) => {
+ if (part.type === "text") return { ...part }
+ if (part.type === "image") return { ...part }
+ if (part.type === "agent") return { ...part }
+ return {
+ ...part,
+ selection: part.selection ? { ...part.selection } : undefined,
+ }
+ })
+}
+
+export function promptLength(prompt: Prompt) {
+ return prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
+}
+
+export function prependHistoryEntry(entries: Prompt[], prompt: Prompt, max = MAX_HISTORY) {
+ const text = prompt
+ .map((part) => ("content" in part ? part.content : ""))
+ .join("")
+ .trim()
+ const hasImages = prompt.some((part) => part.type === "image")
+ if (!text && !hasImages) return entries
+
+ const entry = clonePromptParts(prompt)
+ const last = entries[0]
+ if (last && isPromptEqual(last, entry)) return entries
+ return [entry, ...entries].slice(0, max)
+}
+
+function isPromptEqual(promptA: Prompt, promptB: Prompt) {
+ if (promptA.length !== promptB.length) return false
+ for (let i = 0; i < promptA.length; i++) {
+ const partA = promptA[i]
+ const partB = promptB[i]
+ if (partA.type !== partB.type) return false
+ if (partA.type === "text" && partA.content !== (partB.type === "text" ? partB.content : "")) return false
+ if (partA.type === "file") {
+ if (partA.path !== (partB.type === "file" ? partB.path : "")) return false
+ const a = partA.selection
+ const b = partB.type === "file" ? partB.selection : undefined
+ const sameSelection =
+ (!a && !b) ||
+ (!!a &&
+ !!b &&
+ a.startLine === b.startLine &&
+ a.startChar === b.startChar &&
+ a.endLine === b.endLine &&
+ a.endChar === b.endChar)
+ if (!sameSelection) return false
+ }
+ if (partA.type === "agent" && partA.name !== (partB.type === "agent" ? partB.name : "")) return false
+ if (partA.type === "image" && partA.id !== (partB.type === "image" ? partB.id : "")) return false
+ }
+ return true
+}
+
+type HistoryNavInput = {
+ direction: "up" | "down"
+ entries: Prompt[]
+ historyIndex: number
+ currentPrompt: Prompt
+ savedPrompt: Prompt | null
+}
+
+type HistoryNavResult =
+ | {
+ handled: false
+ historyIndex: number
+ savedPrompt: Prompt | null
+ }
+ | {
+ handled: true
+ historyIndex: number
+ savedPrompt: Prompt | null
+ prompt: Prompt
+ cursor: "start" | "end"
+ }
+
+export function navigatePromptHistory(input: HistoryNavInput): HistoryNavResult {
+ if (input.direction === "up") {
+ if (input.entries.length === 0) {
+ return {
+ handled: false,
+ historyIndex: input.historyIndex,
+ savedPrompt: input.savedPrompt,
+ }
+ }
+
+ if (input.historyIndex === -1) {
+ return {
+ handled: true,
+ historyIndex: 0,
+ savedPrompt: clonePromptParts(input.currentPrompt),
+ prompt: input.entries[0],
+ cursor: "start",
+ }
+ }
+
+ if (input.historyIndex < input.entries.length - 1) {
+ const next = input.historyIndex + 1
+ return {
+ handled: true,
+ historyIndex: next,
+ savedPrompt: input.savedPrompt,
+ prompt: input.entries[next],
+ cursor: "start",
+ }
+ }
+
+ return {
+ handled: false,
+ historyIndex: input.historyIndex,
+ savedPrompt: input.savedPrompt,
+ }
+ }
+
+ if (input.historyIndex > 0) {
+ const next = input.historyIndex - 1
+ return {
+ handled: true,
+ historyIndex: next,
+ savedPrompt: input.savedPrompt,
+ prompt: input.entries[next],
+ cursor: "end",
+ }
+ }
+
+ if (input.historyIndex === 0) {
+ if (input.savedPrompt) {
+ return {
+ handled: true,
+ historyIndex: -1,
+ savedPrompt: null,
+ prompt: input.savedPrompt,
+ cursor: "end",
+ }
+ }
+
+ return {
+ handled: true,
+ historyIndex: -1,
+ savedPrompt: null,
+ prompt: DEFAULT_PROMPT,
+ cursor: "end",
+ }
+ }
+
+ return {
+ handled: false,
+ historyIndex: input.historyIndex,
+ savedPrompt: input.savedPrompt,
+ }
+}
diff --git a/packages/app/src/components/prompt-input/image-attachments.tsx b/packages/app/src/components/prompt-input/image-attachments.tsx
new file mode 100644
index 0000000000..ba3addf0a1
--- /dev/null
+++ b/packages/app/src/components/prompt-input/image-attachments.tsx
@@ -0,0 +1,51 @@
+import { Component, For, Show } from "solid-js"
+import { Icon } from "@opencode-ai/ui/icon"
+import type { ImageAttachmentPart } from "@/context/prompt"
+
+type PromptImageAttachmentsProps = {
+ attachments: ImageAttachmentPart[]
+ onOpen: (attachment: ImageAttachmentPart) => void
+ onRemove: (id: string) => void
+ removeLabel: string
+}
+
+export const PromptImageAttachments: Component = (props) => {
+ return (
+ 0}>
+
+
+ {(attachment) => (
+
+
+
+
+ }
+ >
+ props.onOpen(attachment)}
+ />
+
+ props.onRemove(attachment.id)}
+ class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+ aria-label={props.removeLabel}
+ >
+
+
+
+ {attachment.filename}
+
+
+ )}
+
+
+
+ )
+}
diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts
new file mode 100644
index 0000000000..b633df8295
--- /dev/null
+++ b/packages/app/src/components/prompt-input/placeholder.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, test } from "bun:test"
+import { promptPlaceholder } from "./placeholder"
+
+describe("promptPlaceholder", () => {
+ const t = (key: string, params?: Record
) => `${key}${params?.example ? `:${params.example}` : ""}`
+
+ test("returns shell placeholder in shell mode", () => {
+ const value = promptPlaceholder({
+ mode: "shell",
+ commentCount: 0,
+ example: "example",
+ t,
+ })
+ expect(value).toBe("prompt.placeholder.shell")
+ })
+
+ test("returns summarize placeholders for comment context", () => {
+ expect(promptPlaceholder({ mode: "normal", commentCount: 1, example: "example", t })).toBe(
+ "prompt.placeholder.summarizeComment",
+ )
+ expect(promptPlaceholder({ mode: "normal", commentCount: 2, example: "example", t })).toBe(
+ "prompt.placeholder.summarizeComments",
+ )
+ })
+
+ test("returns default placeholder with example", () => {
+ const value = promptPlaceholder({
+ mode: "normal",
+ commentCount: 0,
+ example: "translated-example",
+ t,
+ })
+ expect(value).toBe("prompt.placeholder.normal:translated-example")
+ })
+})
diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts
new file mode 100644
index 0000000000..07f6a43b51
--- /dev/null
+++ b/packages/app/src/components/prompt-input/placeholder.ts
@@ -0,0 +1,13 @@
+type PromptPlaceholderInput = {
+ mode: "normal" | "shell"
+ commentCount: number
+ example: string
+ t: (key: string, params?: Record) => string
+}
+
+export function promptPlaceholder(input: PromptPlaceholderInput) {
+ if (input.mode === "shell") return input.t("prompt.placeholder.shell")
+ if (input.commentCount > 1) return input.t("prompt.placeholder.summarizeComments")
+ if (input.commentCount === 1) return input.t("prompt.placeholder.summarizeComment")
+ return input.t("prompt.placeholder.normal", { example: input.example })
+}
diff --git a/packages/app/src/components/prompt-input/slash-popover.tsx b/packages/app/src/components/prompt-input/slash-popover.tsx
new file mode 100644
index 0000000000..b97bb67522
--- /dev/null
+++ b/packages/app/src/components/prompt-input/slash-popover.tsx
@@ -0,0 +1,144 @@
+import { Component, For, Match, Show, Switch } from "solid-js"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Icon } from "@opencode-ai/ui/icon"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+
+export type AtOption =
+ | { type: "agent"; name: string; display: string }
+ | { type: "file"; path: string; display: string; recent?: boolean }
+
+export interface SlashCommand {
+ id: string
+ trigger: string
+ title: string
+ description?: string
+ keybind?: string
+ type: "builtin" | "custom"
+ source?: "command" | "mcp" | "skill"
+}
+
+type PromptPopoverProps = {
+ popover: "at" | "slash" | null
+ setSlashPopoverRef: (el: HTMLDivElement) => void
+ atFlat: AtOption[]
+ atActive?: string
+ atKey: (item: AtOption) => string
+ setAtActive: (id: string) => void
+ onAtSelect: (item: AtOption) => void
+ slashFlat: SlashCommand[]
+ slashActive?: string
+ setSlashActive: (id: string) => void
+ onSlashSelect: (item: SlashCommand) => void
+ commandKeybind: (id: string) => string | undefined
+ t: (key: string) => string
+}
+
+export const PromptPopover: Component = (props) => {
+ return (
+
+ {
+ if (props.popover === "slash") props.setSlashPopoverRef(el)
+ }}
+ class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
+ overflow-auto no-scrollbar flex flex-col p-2 rounded-md
+ border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
+ onMouseDown={(e) => e.preventDefault()}
+ >
+
+
+ 0}
+ fallback={{props.t("prompt.popover.emptyResults")}
}
+ >
+
+ {(item) => (
+ props.onAtSelect(item)}
+ onMouseEnter={() => props.setAtActive(props.atKey(item))}
+ >
+
+
+
+
+ {item.type === "file"
+ ? item.path.endsWith("/")
+ ? item.path
+ : getDirectory(item.path)
+ : ""}
+
+
+
+ {item.type === "file" ? getFilename(item.path) : ""}
+
+
+
+ >
+ }
+ >
+
+
+ @{item.type === "agent" ? item.name : ""}
+
+
+
+ )}
+
+
+
+
+ 0}
+ fallback={{props.t("prompt.popover.emptyCommands")}
}
+ >
+
+ {(cmd) => (
+ props.onSlashSelect(cmd)}
+ onMouseEnter={() => props.setSlashActive(cmd.id)}
+ >
+
+ /{cmd.trigger}
+
+ {cmd.description}
+
+
+
+
+
+ {cmd.source === "skill"
+ ? props.t("prompt.slash.badge.skill")
+ : cmd.source === "mcp"
+ ? props.t("prompt.slash.badge.mcp")
+ : props.t("prompt.slash.badge.custom")}
+
+
+
+ {props.commandKeybind(cmd.id)}
+
+
+
+ )}
+
+
+
+
+
+
+ )
+}
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
new file mode 100644
index 0000000000..49d75a95ec
--- /dev/null
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -0,0 +1,417 @@
+import { Accessor } from "solid-js"
+import { useNavigate, useParams } from "@solidjs/router"
+import { createOpencodeClient, type Message } from "@opencode-ai/sdk/v2/client"
+import { showToast } from "@opencode-ai/ui/toast"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { useLocal } from "@/context/local"
+import { usePrompt, type ImageAttachmentPart, type Prompt } from "@/context/prompt"
+import { useLayout } from "@/context/layout"
+import { useSDK } from "@/context/sdk"
+import { useSync } from "@/context/sync"
+import { useGlobalSync } from "@/context/global-sync"
+import { usePlatform } from "@/context/platform"
+import { useLanguage } from "@/context/language"
+import { Identifier } from "@/utils/id"
+import { Worktree as WorktreeState } from "@/utils/worktree"
+import type { FileSelection } from "@/context/file"
+import { setCursorPosition } from "./editor-dom"
+import { buildRequestParts } from "./build-request-parts"
+
+type PendingPrompt = {
+ abort: AbortController
+ cleanup: VoidFunction
+}
+
+const pending = new Map()
+
+type PromptSubmitInput = {
+ info: Accessor<{ id: string } | undefined>
+ imageAttachments: Accessor
+ commentCount: Accessor
+ mode: Accessor<"normal" | "shell">
+ working: Accessor
+ editor: () => HTMLDivElement | undefined
+ queueScroll: () => void
+ promptLength: (prompt: Prompt) => number
+ addToHistory: (prompt: Prompt, mode: "normal" | "shell") => void
+ resetHistoryNavigation: () => void
+ setMode: (mode: "normal" | "shell") => void
+ setPopover: (popover: "at" | "slash" | null) => void
+ newSessionWorktree?: Accessor
+ onNewSessionWorktreeReset?: () => void
+ onSubmit?: () => void
+}
+
+type CommentItem = {
+ path: string
+ selection?: FileSelection
+ comment?: string
+ commentID?: string
+ commentOrigin?: "review" | "file"
+ preview?: string
+}
+
+export function createPromptSubmit(input: PromptSubmitInput) {
+ const navigate = useNavigate()
+ const sdk = useSDK()
+ const sync = useSync()
+ const globalSync = useGlobalSync()
+ const platform = usePlatform()
+ const local = useLocal()
+ const prompt = usePrompt()
+ const layout = useLayout()
+ const language = useLanguage()
+ const params = useParams()
+
+ const errorMessage = (err: unknown) => {
+ if (err && typeof err === "object" && "data" in err) {
+ const data = (err as { data?: { message?: string } }).data
+ if (data?.message) return data.message
+ }
+ if (err instanceof Error) return err.message
+ return language.t("common.requestFailed")
+ }
+
+ const abort = async () => {
+ const sessionID = params.id
+ if (!sessionID) return Promise.resolve()
+ const queued = pending.get(sessionID)
+ if (queued) {
+ queued.abort.abort()
+ queued.cleanup()
+ pending.delete(sessionID)
+ return Promise.resolve()
+ }
+ return sdk.client.session
+ .abort({
+ sessionID,
+ })
+ .catch(() => {})
+ }
+
+ const restoreCommentItems = (items: CommentItem[]) => {
+ for (const item of items) {
+ prompt.context.add({
+ type: "file",
+ path: item.path,
+ selection: item.selection,
+ comment: item.comment,
+ commentID: item.commentID,
+ commentOrigin: item.commentOrigin,
+ preview: item.preview,
+ })
+ }
+ }
+
+ const removeCommentItems = (items: { key: string }[]) => {
+ for (const item of items) {
+ prompt.context.remove(item.key)
+ }
+ }
+
+ const handleSubmit = async (event: Event) => {
+ event.preventDefault()
+
+ const currentPrompt = prompt.current()
+ const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
+ const images = input.imageAttachments().slice()
+ const mode = input.mode()
+
+ if (text.trim().length === 0 && images.length === 0 && input.commentCount() === 0) {
+ if (input.working()) abort()
+ return
+ }
+
+ const currentModel = local.model.current()
+ const currentAgent = local.agent.current()
+ if (!currentModel || !currentAgent) {
+ showToast({
+ title: language.t("prompt.toast.modelAgentRequired.title"),
+ description: language.t("prompt.toast.modelAgentRequired.description"),
+ })
+ return
+ }
+
+ input.addToHistory(currentPrompt, mode)
+ input.resetHistoryNavigation()
+
+ const projectDirectory = sdk.directory
+ const isNewSession = !params.id
+ const worktreeSelection = input.newSessionWorktree?.() || "main"
+
+ let sessionDirectory = projectDirectory
+ let client = sdk.client
+
+ if (isNewSession) {
+ if (worktreeSelection === "create") {
+ const createdWorktree = await client.worktree
+ .create({ directory: projectDirectory })
+ .then((x) => x.data)
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.worktreeCreateFailed.title"),
+ description: errorMessage(err),
+ })
+ return undefined
+ })
+
+ if (!createdWorktree?.directory) {
+ showToast({
+ title: language.t("prompt.toast.worktreeCreateFailed.title"),
+ description: language.t("common.requestFailed"),
+ })
+ return
+ }
+ WorktreeState.pending(createdWorktree.directory)
+ sessionDirectory = createdWorktree.directory
+ }
+
+ if (worktreeSelection !== "main" && worktreeSelection !== "create") {
+ sessionDirectory = worktreeSelection
+ }
+
+ if (sessionDirectory !== projectDirectory) {
+ client = createOpencodeClient({
+ baseUrl: sdk.url,
+ fetch: platform.fetch,
+ directory: sessionDirectory,
+ throwOnError: true,
+ })
+ globalSync.child(sessionDirectory)
+ }
+
+ input.onNewSessionWorktreeReset?.()
+ }
+
+ let session = input.info()
+ if (!session && isNewSession) {
+ session = await client.session
+ .create()
+ .then((x) => x.data ?? undefined)
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.sessionCreateFailed.title"),
+ description: errorMessage(err),
+ })
+ return undefined
+ })
+ if (session) {
+ layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
+ navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
+ }
+ }
+ if (!session) {
+ showToast({
+ title: language.t("prompt.toast.promptSendFailed.title"),
+ description: language.t("prompt.toast.promptSendFailed.description"),
+ })
+ return
+ }
+
+ input.onSubmit?.()
+
+ const model = {
+ modelID: currentModel.id,
+ providerID: currentModel.provider.id,
+ }
+ const agent = currentAgent.name
+ const variant = local.model.variant.current()
+
+ const clearInput = () => {
+ prompt.reset()
+ input.setMode("normal")
+ input.setPopover(null)
+ }
+
+ const restoreInput = () => {
+ prompt.set(currentPrompt, input.promptLength(currentPrompt))
+ input.setMode(mode)
+ input.setPopover(null)
+ requestAnimationFrame(() => {
+ const editor = input.editor()
+ if (!editor) return
+ editor.focus()
+ setCursorPosition(editor, input.promptLength(currentPrompt))
+ input.queueScroll()
+ })
+ }
+
+ if (mode === "shell") {
+ clearInput()
+ client.session
+ .shell({
+ sessionID: session.id,
+ agent,
+ model,
+ command: text,
+ })
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.shellSendFailed.title"),
+ description: errorMessage(err),
+ })
+ restoreInput()
+ })
+ return
+ }
+
+ if (text.startsWith("/")) {
+ const [cmdName, ...args] = text.split(" ")
+ const commandName = cmdName.slice(1)
+ const customCommand = sync.data.command.find((c) => c.name === commandName)
+ if (customCommand) {
+ clearInput()
+ client.session
+ .command({
+ sessionID: session.id,
+ command: commandName,
+ arguments: args.join(" "),
+ agent,
+ model: `${model.providerID}/${model.modelID}`,
+ variant,
+ parts: images.map((attachment) => ({
+ id: Identifier.ascending("part"),
+ type: "file" as const,
+ mime: attachment.mime,
+ url: attachment.dataUrl,
+ filename: attachment.filename,
+ })),
+ })
+ .catch((err) => {
+ showToast({
+ title: language.t("prompt.toast.commandSendFailed.title"),
+ description: errorMessage(err),
+ })
+ restoreInput()
+ })
+ return
+ }
+ }
+
+ const context = prompt.context.items().slice()
+ const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
+
+ const messageID = Identifier.ascending("message")
+ const { requestParts, optimisticParts } = buildRequestParts({
+ prompt: currentPrompt,
+ context,
+ images,
+ text,
+ sessionID: session.id,
+ messageID,
+ sessionDirectory,
+ })
+
+ const optimisticMessage: Message = {
+ id: messageID,
+ sessionID: session.id,
+ role: "user",
+ time: { created: Date.now() },
+ agent,
+ model,
+ }
+
+ const addOptimisticMessage = () =>
+ sync.session.optimistic.add({
+ directory: sessionDirectory,
+ sessionID: session.id,
+ message: optimisticMessage,
+ parts: optimisticParts,
+ })
+
+ const removeOptimisticMessage = () =>
+ sync.session.optimistic.remove({
+ directory: sessionDirectory,
+ sessionID: session.id,
+ messageID,
+ })
+
+ removeCommentItems(commentItems)
+ clearInput()
+ addOptimisticMessage()
+
+ const waitForWorktree = async () => {
+ const worktree = WorktreeState.get(sessionDirectory)
+ if (!worktree || worktree.status !== "pending") return true
+
+ if (sessionDirectory === projectDirectory) {
+ sync.set("session_status", session.id, { type: "busy" })
+ }
+
+ const controller = new AbortController()
+ const cleanup = () => {
+ if (sessionDirectory === projectDirectory) {
+ sync.set("session_status", session.id, { type: "idle" })
+ }
+ removeOptimisticMessage()
+ restoreCommentItems(commentItems)
+ restoreInput()
+ }
+
+ pending.set(session.id, { abort: controller, cleanup })
+
+ const abortWait = new Promise>>((resolve) => {
+ if (controller.signal.aborted) {
+ resolve({ status: "failed", message: "aborted" })
+ return
+ }
+ controller.signal.addEventListener(
+ "abort",
+ () => {
+ resolve({ status: "failed", message: "aborted" })
+ },
+ { once: true },
+ )
+ })
+
+ const timeoutMs = 5 * 60 * 1000
+ const timer = { id: undefined as number | undefined }
+ const timeout = new Promise>>((resolve) => {
+ timer.id = window.setTimeout(() => {
+ resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") })
+ }, timeoutMs)
+ })
+
+ const result = await Promise.race([WorktreeState.wait(sessionDirectory), abortWait, timeout]).finally(() => {
+ if (timer.id === undefined) return
+ clearTimeout(timer.id)
+ })
+ pending.delete(session.id)
+ if (controller.signal.aborted) return false
+ if (result.status === "failed") throw new Error(result.message)
+ return true
+ }
+
+ const send = async () => {
+ const ok = await waitForWorktree()
+ if (!ok) return
+ await client.session.prompt({
+ sessionID: session.id,
+ agent,
+ model,
+ messageID,
+ parts: requestParts,
+ variant,
+ })
+ }
+
+ void send().catch((err) => {
+ pending.delete(session.id)
+ if (sessionDirectory === projectDirectory) {
+ sync.set("session_status", session.id, { type: "idle" })
+ }
+ showToast({
+ title: language.t("prompt.toast.promptSendFailed.title"),
+ description: errorMessage(err),
+ })
+ removeOptimisticMessage()
+ restoreCommentItems(commentItems)
+ restoreInput()
+ })
+ }
+
+ return {
+ abort,
+ handleSubmit,
+ }
+}
diff --git a/packages/app/src/components/question-dock.tsx b/packages/app/src/components/question-dock.tsx
new file mode 100644
index 0000000000..f626fcc9b2
--- /dev/null
+++ b/packages/app/src/components/question-dock.tsx
@@ -0,0 +1,295 @@
+import { For, Show, createMemo, type Component } from "solid-js"
+import { createStore } from "solid-js/store"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { showToast } from "@opencode-ai/ui/toast"
+import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
+import { useLanguage } from "@/context/language"
+import { useSDK } from "@/context/sdk"
+
+export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
+ const sdk = useSDK()
+ const language = useLanguage()
+
+ const questions = createMemo(() => props.request.questions)
+ const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
+
+ const [store, setStore] = createStore({
+ tab: 0,
+ answers: [] as QuestionAnswer[],
+ custom: [] as string[],
+ editing: false,
+ sending: false,
+ })
+
+ const question = createMemo(() => questions()[store.tab])
+ const confirm = createMemo(() => !single() && store.tab === questions().length)
+ const options = createMemo(() => question()?.options ?? [])
+ const input = createMemo(() => store.custom[store.tab] ?? "")
+ const multi = createMemo(() => question()?.multiple === true)
+ const customPicked = createMemo(() => {
+ const value = input()
+ if (!value) return false
+ return store.answers[store.tab]?.includes(value) ?? false
+ })
+
+ const fail = (err: unknown) => {
+ const message = err instanceof Error ? err.message : String(err)
+ showToast({ title: language.t("common.requestFailed"), description: message })
+ }
+
+ const reply = (answers: QuestionAnswer[]) => {
+ if (store.sending) return
+
+ setStore("sending", true)
+ sdk.client.question
+ .reply({ requestID: props.request.id, answers })
+ .catch(fail)
+ .finally(() => setStore("sending", false))
+ }
+
+ const reject = () => {
+ if (store.sending) return
+
+ setStore("sending", true)
+ sdk.client.question
+ .reject({ requestID: props.request.id })
+ .catch(fail)
+ .finally(() => setStore("sending", false))
+ }
+
+ const submit = () => {
+ reply(questions().map((_, i) => store.answers[i] ?? []))
+ }
+
+ const pick = (answer: string, custom: boolean = false) => {
+ const answers = [...store.answers]
+ answers[store.tab] = [answer]
+ setStore("answers", answers)
+
+ if (custom) {
+ const inputs = [...store.custom]
+ inputs[store.tab] = answer
+ setStore("custom", inputs)
+ }
+
+ if (single()) {
+ reply([[answer]])
+ return
+ }
+
+ setStore("tab", store.tab + 1)
+ }
+
+ const toggle = (answer: string) => {
+ const existing = store.answers[store.tab] ?? []
+ const next = [...existing]
+ const index = next.indexOf(answer)
+ if (index === -1) next.push(answer)
+ if (index !== -1) next.splice(index, 1)
+
+ const answers = [...store.answers]
+ answers[store.tab] = next
+ setStore("answers", answers)
+ }
+
+ const selectTab = (index: number) => {
+ setStore("tab", index)
+ setStore("editing", false)
+ }
+
+ const selectOption = (optIndex: number) => {
+ if (store.sending) return
+
+ if (optIndex === options().length) {
+ setStore("editing", true)
+ return
+ }
+
+ const opt = options()[optIndex]
+ if (!opt) return
+ if (multi()) {
+ toggle(opt.label)
+ return
+ }
+ pick(opt.label)
+ }
+
+ const handleCustomSubmit = (e: Event) => {
+ e.preventDefault()
+ if (store.sending) return
+
+ const value = input().trim()
+ if (!value) {
+ setStore("editing", false)
+ return
+ }
+
+ if (multi()) {
+ const existing = store.answers[store.tab] ?? []
+ const next = [...existing]
+ if (!next.includes(value)) next.push(value)
+
+ const answers = [...store.answers]
+ answers[store.tab] = next
+ setStore("answers", answers)
+ setStore("editing", false)
+ return
+ }
+
+ pick(value, true)
+ setStore("editing", false)
+ }
+
+ return (
+
+
+
+
+ {(q, index) => {
+ const active = () => index() === store.tab
+ const answered = () => (store.answers[index()]?.length ?? 0) > 0
+ return (
+ selectTab(index())}
+ >
+ {q.header}
+
+ )
+ }}
+
+ selectTab(questions().length)}
+ >
+ {language.t("ui.common.confirm")}
+
+
+
+
+
+
+
+ {question()?.question}
+ {multi() ? " " + language.t("ui.question.multiHint") : ""}
+
+
+
+ {(opt, i) => {
+ const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
+ return (
+ selectOption(i())}
+ >
+ {opt.label}
+
+ {opt.description}
+
+
+
+
+
+ )
+ }}
+
+ selectOption(options().length)}
+ >
+ {language.t("ui.messagePart.option.typeOwnAnswer")}
+
+ {input()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{language.t("ui.messagePart.review.title")}
+
+ {(q, index) => {
+ const value = () => store.answers[index()]?.join(", ") ?? ""
+ const answered = () => Boolean(value())
+ return (
+
+ {q.question}
+
+ {answered() ? value() : language.t("ui.question.review.notAnswered")}
+
+
+ )
+ }}
+
+
+
+
+
+
+ {language.t("ui.common.dismiss")}
+
+
+
+
+ {language.t("ui.common.submit")}
+
+
+
+ selectTab(store.tab + 1)}
+ disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
+ >
+ {language.t("ui.common.next")}
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx
new file mode 100644
index 0000000000..b43c07882c
--- /dev/null
+++ b/packages/app/src/components/server/server-row.tsx
@@ -0,0 +1,77 @@
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { JSXElement, ParentProps, Show, createEffect, createSignal, onCleanup, onMount } from "solid-js"
+import { serverDisplayName } from "@/context/server"
+import type { ServerHealth } from "@/utils/server-health"
+
+interface ServerRowProps extends ParentProps {
+ url: string
+ status?: ServerHealth
+ class?: string
+ nameClass?: string
+ versionClass?: string
+ dimmed?: boolean
+ badge?: JSXElement
+}
+
+export function ServerRow(props: ServerRowProps) {
+ const [truncated, setTruncated] = createSignal(false)
+ let nameRef: HTMLSpanElement | undefined
+ let versionRef: HTMLSpanElement | undefined
+
+ const check = () => {
+ const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
+ const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
+ setTruncated(nameTruncated || versionTruncated)
+ }
+
+ createEffect(() => {
+ props.url
+ props.status?.version
+ if (typeof requestAnimationFrame === "function") {
+ requestAnimationFrame(check)
+ return
+ }
+ check()
+ })
+
+ onMount(() => {
+ check()
+ if (typeof window === "undefined") return
+ window.addEventListener("resize", check)
+ onCleanup(() => window.removeEventListener("resize", check))
+ })
+
+ const tooltipValue = () => (
+
+ {serverDisplayName(props.url)}
+
+ {props.status?.version}
+
+
+ )
+
+ return (
+
+
+
+
+ {serverDisplayName(props.url)}
+
+
+
+ {props.status?.version}
+
+
+ {props.badge}
+ {props.children}
+
+
+ )
+}
diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx
index 1e37d8f6a2..4e5dae139c 100644
--- a/packages/app/src/components/session-context-usage.tsx
+++ b/packages/app/src/components/session-context-usage.tsx
@@ -3,12 +3,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { Button } from "@opencode-ai/ui/button"
import { useParams } from "@solidjs/router"
-import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
-import { findLast } from "@opencode-ai/util/array"
import { useLayout } from "@/context/layout"
import { useSync } from "@/context/sync"
import { useLanguage } from "@/context/language"
+import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
interface SessionContextUsageProps {
variant?: "button" | "indicator"
@@ -23,6 +22,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const variant = createMemo(() => props.variant ?? "button")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
+ const view = createMemo(() => layout.view(sessionKey))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const usd = createMemo(
@@ -33,30 +33,15 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}),
)
+ const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
+ const context = createMemo(() => metrics().context)
const cost = createMemo(() => {
- const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
- return usd().format(total)
- })
-
- const context = createMemo(() => {
- const locale = language.locale()
- const last = findLast(messages(), (x) => {
- if (x.role !== "assistant") return false
- const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
- return total > 0
- }) as AssistantMessage
- if (!last) return
- const total =
- last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
- const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
- return {
- tokens: total.toLocaleString(locale),
- percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
- }
+ return usd().format(metrics().totalCost)
})
const openContext = () => {
if (!params.id) return
+ if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
tabs().open("context")
@@ -64,8 +49,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}
const circle = () => (
-
-
+
)
@@ -75,11 +60,11 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
{(ctx) => (
<>
- {ctx().tokens}
+ {ctx().total.toLocaleString(language.locale())}
{language.t("context.usage.tokens")}
- {ctx().percentage ?? 0}%
+ {ctx().usage ?? 0}%
{language.t("context.usage.usage")}
>
diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts
new file mode 100644
index 0000000000..e90df9a948
--- /dev/null
+++ b/packages/app/src/components/session/session-context-metrics.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, test } from "bun:test"
+import type { Message } from "@opencode-ai/sdk/v2/client"
+import { getSessionContextMetrics } from "./session-context-metrics"
+
+const assistant = (
+ id: string,
+ tokens: { input: number; output: number; reasoning: number; read: number; write: number },
+ cost: number,
+ providerID = "openai",
+ modelID = "gpt-4.1",
+) => {
+ return {
+ id,
+ role: "assistant",
+ providerID,
+ modelID,
+ cost,
+ tokens: {
+ input: tokens.input,
+ output: tokens.output,
+ reasoning: tokens.reasoning,
+ cache: {
+ read: tokens.read,
+ write: tokens.write,
+ },
+ },
+ time: { created: 1 },
+ } as unknown as Message
+}
+
+const user = (id: string) => {
+ return {
+ id,
+ role: "user",
+ cost: 0,
+ time: { created: 1 },
+ } as unknown as Message
+}
+
+describe("getSessionContextMetrics", () => {
+ test("computes totals and usage from latest assistant with tokens", () => {
+ const messages = [
+ user("u1"),
+ assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
+ assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
+ ]
+ const providers = [
+ {
+ id: "openai",
+ name: "OpenAI",
+ models: {
+ "gpt-4.1": {
+ name: "GPT-4.1",
+ limit: { context: 1000 },
+ },
+ },
+ },
+ ]
+
+ const metrics = getSessionContextMetrics(messages, providers)
+
+ expect(metrics.totalCost).toBe(1.75)
+ expect(metrics.context?.message.id).toBe("a2")
+ expect(metrics.context?.total).toBe(500)
+ expect(metrics.context?.usage).toBe(50)
+ expect(metrics.context?.providerLabel).toBe("OpenAI")
+ expect(metrics.context?.modelLabel).toBe("GPT-4.1")
+ })
+
+ test("preserves fallback labels and null usage when model metadata is missing", () => {
+ const messages = [assistant("a1", { input: 40, output: 10, reasoning: 0, read: 0, write: 0 }, 0.1, "p-1", "m-1")]
+ const providers = [{ id: "p-1", models: {} }]
+
+ const metrics = getSessionContextMetrics(messages, providers)
+
+ expect(metrics.context?.providerLabel).toBe("p-1")
+ expect(metrics.context?.modelLabel).toBe("m-1")
+ expect(metrics.context?.limit).toBeUndefined()
+ expect(metrics.context?.usage).toBeNull()
+ })
+
+ 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)
+
+ 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
new file mode 100644
index 0000000000..357205afb5
--- /dev/null
+++ b/packages/app/src/components/session/session-context-metrics.ts
@@ -0,0 +1,82 @@
+import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
+
+type Provider = {
+ id: string
+ name?: string
+ models: Record
+}
+
+type Model = {
+ name?: string
+ limit: {
+ context: number
+ }
+}
+
+type Context = {
+ message: AssistantMessage
+ provider?: Provider
+ model?: Model
+ providerLabel: string
+ modelLabel: string
+ limit: number | undefined
+ input: number
+ output: number
+ reasoning: number
+ cacheRead: number
+ cacheWrite: number
+ total: number
+ usage: number | null
+}
+
+type Metrics = {
+ totalCost: number
+ context: Context | undefined
+}
+
+const tokenTotal = (msg: AssistantMessage) => {
+ return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write
+}
+
+const lastAssistantWithTokens = (messages: Message[]) => {
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const msg = messages[i]
+ if (msg.role !== "assistant") continue
+ if (tokenTotal(msg) <= 0) continue
+ return msg
+ }
+}
+
+const build = (messages: Message[], providers: Provider[]): Metrics => {
+ const totalCost = messages.reduce((sum, msg) => sum + (msg.role === "assistant" ? msg.cost : 0), 0)
+ const message = lastAssistantWithTokens(messages)
+ if (!message) return { totalCost, context: undefined }
+
+ const provider = providers.find((item) => item.id === message.providerID)
+ const model = provider?.models[message.modelID]
+ const limit = model?.limit.context
+ const total = tokenTotal(message)
+
+ return {
+ totalCost,
+ context: {
+ message,
+ provider,
+ model,
+ providerLabel: provider?.name ?? message.providerID,
+ modelLabel: model?.name ?? message.modelID,
+ limit,
+ input: message.tokens.input,
+ output: message.tokens.output,
+ reasoning: message.tokens.reasoning,
+ cacheRead: message.tokens.cache.read,
+ cacheWrite: message.tokens.cache.write,
+ total,
+ usage: limit ? Math.round((total / limit) * 100) : null,
+ },
+ }
+}
+
+export function getSessionContextMetrics(messages: Message[], providers: Provider[]) {
+ return build(messages, providers)
+}
diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx
index 37733caff6..8aae44863e 100644
--- a/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app/src/components/session/session-context-tab.tsx
@@ -11,8 +11,9 @@ import { Accordion } from "@opencode-ai/ui/accordion"
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
import { Code } from "@opencode-ai/ui/code"
import { Markdown } from "@opencode-ai/ui/markdown"
-import type { AssistantMessage, Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
+import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
import { useLanguage } from "@/context/language"
+import { getSessionContextMetrics } from "./session-context-metrics"
interface SessionContextTabProps {
messages: () => Message[]
@@ -34,44 +35,11 @@ export function SessionContextTab(props: SessionContextTabProps) {
}),
)
- const ctx = createMemo(() => {
- const last = findLast(props.messages(), (x) => {
- if (x.role !== "assistant") return false
- const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
- return total > 0
- }) as AssistantMessage
- if (!last) return
-
- const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
- const model = provider?.models[last.modelID]
- const limit = model?.limit.context
-
- const input = last.tokens.input
- const output = last.tokens.output
- const reasoning = last.tokens.reasoning
- const cacheRead = last.tokens.cache.read
- const cacheWrite = last.tokens.cache.write
- const total = input + output + reasoning + cacheRead + cacheWrite
- const usage = limit ? Math.round((total / limit) * 100) : null
-
- return {
- message: last,
- provider,
- model,
- limit,
- input,
- output,
- reasoning,
- cacheRead,
- cacheWrite,
- total,
- usage,
- }
- })
+ const metrics = createMemo(() => getSessionContextMetrics(props.messages(), sync.data.provider.all))
+ const ctx = createMemo(() => metrics().context)
const cost = createMemo(() => {
- const total = props.messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
- return usd().format(total)
+ return usd().format(metrics().totalCost)
})
const counts = createMemo(() => {
@@ -114,14 +82,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
const providerLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
- return c.provider?.name ?? c.message.providerID
+ return c.providerLabel
})
const modelLabel = createMemo(() => {
const c = ctx()
if (!c) return "—"
- if (c.model?.name) return c.model.name
- return c.message.modelID
+ return c.modelLabel
})
const breakdown = createMemo(
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx
index 449f2091bd..54e24a6fbb 100644
--- a/packages/app/src/components/session/session-header.tsx
+++ b/packages/app/src/components/session/session-header.tsx
@@ -6,18 +6,23 @@ import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
+import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path"
import { decode64 } from "@/utils/base64"
+import { Persist, persisted } from "@/utils/persist"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
+import { AppIcon } from "@opencode-ai/ui/app-icon"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { Keybind } from "@opencode-ai/ui/keybind"
+import { showToast } from "@opencode-ai/ui/toast"
import { StatusPopover } from "../status-popover"
export function SessionHeader() {
@@ -25,6 +30,7 @@ export function SessionHeader() {
const layout = useLayout()
const params = useParams()
const command = useCommand()
+ const server = useServer()
const sync = useSync()
const platform = usePlatform()
const language = useLanguage()
@@ -48,6 +54,169 @@ export function SessionHeader() {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
+ const OPEN_APPS = [
+ "vscode",
+ "cursor",
+ "zed",
+ "textmate",
+ "antigravity",
+ "finder",
+ "terminal",
+ "iterm2",
+ "ghostty",
+ "xcode",
+ "android-studio",
+ "powershell",
+ "sublime-text",
+ ] as const
+ type OpenApp = (typeof OPEN_APPS)[number]
+
+ const MAC_APPS = [
+ { id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
+ { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
+ { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
+ { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
+ { id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
+ { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
+ { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
+ { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
+ { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
+ { id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
+ { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+ ] as const
+
+ const WINDOWS_APPS = [
+ { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
+ { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
+ { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
+ { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
+ { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+ ] as const
+
+ const LINUX_APPS = [
+ { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
+ { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
+ { id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
+ { id: "sublime-text", label: "Sublime Text", icon: "sublime-text", openWith: "Sublime Text" },
+ ] as const
+
+ const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
+ if (platform.platform === "desktop" && platform.os) return platform.os
+ if (typeof navigator !== "object") return "unknown"
+ const value = navigator.platform || navigator.userAgent
+ if (/Mac/i.test(value)) return "macos"
+ if (/Win/i.test(value)) return "windows"
+ if (/Linux/i.test(value)) return "linux"
+ return "unknown"
+ })
+
+ 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 = apps()
+
+ setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial>)
+
+ void Promise.all(
+ 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>)
+ })
+ })
+
+ const options = createMemo(() => {
+ return [
+ { id: "finder", label: fileManager().label, icon: fileManager().icon },
+ ...apps().filter((app) => exists[app.id]),
+ ] as const
+ })
+
+ type OpenIcon = OpenApp | "file-explorer"
+ const base = new Set(["finder", "vscode", "cursor", "zed"])
+ const size = (id: OpenIcon) => (base.has(id) ? "size-4" : "size-[19px]")
+
+ const checksReady = createMemo(() => {
+ if (platform.platform !== "desktop") return true
+ if (!platform.checkAppExists) return true
+ const list = apps()
+ return list.every((app) => exists[app.id] !== undefined)
+ })
+
+ const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
+ const [menu, setMenu] = createStore({ open: false })
+
+ const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
+ const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
+
+ createEffect(() => {
+ if (platform.platform !== "desktop") return
+ if (!checksReady()) return
+ const value = prefs.app
+ if (options().some((o) => o.id === value)) return
+ setPrefs("app", options()[0]?.id ?? "finder")
+ })
+
+ const openDir = (app: OpenApp) => {
+ const directory = projectDirectory()
+ if (!directory) return
+ if (!canOpen()) return
+
+ const item = options().find((o) => o.id === app)
+ const openWith = item && "openWith" in item ? item.openWith : undefined
+ Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ })
+ })
+ }
+
+ const copyPath = () => {
+ const directory = projectDirectory()
+ if (!directory) return
+ navigator.clipboard
+ .writeText(directory)
+ .then(() => {
+ showToast({
+ variant: "success",
+ icon: "circle-check",
+ title: language.t("session.share.copy.copied"),
+ description: directory,
+ })
+ })
+ .catch((err: unknown) => {
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: err instanceof Error ? err.message : String(err),
+ })
+ })
+ }
+
const [state, setState] = createStore({
share: false,
unshare: false,
@@ -130,7 +299,7 @@ export function SessionHeader() {
command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
@@ -141,7 +310,11 @@ export function SessionHeader() {
- {(keybind) => {keybind()} }
+
+ {(keybind) => (
+ {keybind()}
+ )}
+
)}
@@ -151,6 +324,105 @@ export function SessionHeader() {
+
+
+
+
+
+
+ {language.t("session.header.open.copyPath")}
+
+
+
+ }
+ >
+
+
+
openDir(current().id)}
+ aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
+ >
+
+ Open
+
+
+
setMenu("open", open)}
+ >
+
+
+
+
+ {language.t("session.header.openIn")}
+ {
+ if (!OPEN_APPS.includes(value as OpenApp)) return
+ setPrefs("app", value as OpenApp)
+ }}
+ >
+ {options().map((o) => (
+ {
+ setMenu("open", false)
+ openDir(o.id)
+ }}
+ >
+
+ {o.label}
+
+
+
+
+ ))}
+
+
+
+ {
+ setMenu("open", false)
+ copyPath()
+ }}
+ >
+
+
+
+
+ {language.t("session.header.open.copyPath")}
+
+
+
+
+
+
+
+
+
+
-
+
layout.fileTree.toggle()}
+ class="group/review-toggle size-6 p-0"
+ onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
- aria-expanded={layout.fileTree.opened()}
+ aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
+
+
+
+
+
+
+ layout.fileTree.toggle()}
+ aria-label={language.t("command.fileTree.toggle")}
+ aria-expanded={layout.fileTree.opened()}
+ aria-controls="file-tree-panel"
+ >
+
+
diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx
index 9306e8acb6..480cd58c13 100644
--- a/packages/app/src/components/session/session-new-view.tsx
+++ b/packages/app/src/components/session/session-new-view.tsx
@@ -1,6 +1,7 @@
import { Show, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
+import { useSDK } from "@/context/sdk"
import { useLanguage } from "@/context/language"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
@@ -15,6 +16,7 @@ interface NewSessionViewProps {
export function NewSessionView(props: NewSessionViewProps) {
const sync = useSync()
+ const sdk = useSDK()
const language = useLanguage()
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
@@ -24,11 +26,11 @@ export function NewSessionView(props: NewSessionViewProps) {
if (options().includes(selection)) return selection
return MAIN_WORKTREE
})
- const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory)
+ const projectRoot = createMemo(() => sync.project?.worktree ?? sdk.directory)
const isWorktree = createMemo(() => {
const project = sync.project
if (!project) return false
- return sync.data.path.directory !== project.worktree
+ return sdk.directory !== project.worktree
})
const label = (value: string) => {
@@ -45,7 +47,7 @@ export function NewSessionView(props: NewSessionViewProps) {
}
return (
-
+
{language.t("command.session.new")}
diff --git a/packages/app/src/components/session/session-sortable-tab.tsx b/packages/app/src/components/session/session-sortable-tab.tsx
index 06609fcfb8..516f3c8ede 100644
--- a/packages/app/src/components/session/session-sortable-tab.tsx
+++ b/packages/app/src/components/session/session-sortable-tab.tsx
@@ -3,11 +3,12 @@ import type { JSX } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { getFilename } from "@opencode-ai/util/path"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
+import { useCommand } from "@/context/command"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
return (
@@ -27,6 +28,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
const file = useFile()
const language = useLanguage()
+ const command = useCommand()
const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab))
return (
@@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
+
v
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
-
+
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx
index 3b08652bbb..72135c342e 100644
--- a/packages/app/src/components/settings-general.tsx
+++ b/packages/app/src/components/settings-general.tsx
@@ -1,8 +1,10 @@
-import { Component, createMemo, type JSX } from "solid-js"
+import { Component, Show, createEffect, createMemo, createResource, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
import { Select } from "@opencode-ai/ui/select"
import { Switch } from "@opencode-ai/ui/switch"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { showToast } from "@opencode-ai/ui/toast"
import { useLanguage } from "@/context/language"
@@ -40,6 +42,8 @@ export const SettingsGeneral: Component = () => {
checking: false,
})
+ const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
+
const check = () => {
if (!platform.checkUpdate) return
setStore("checking", true)
@@ -148,6 +152,7 @@ export const SettingsGeneral: Component = () => {
description={language.t("settings.general.row.language.description")}
>
o.value === language.locale())}
value={(o) => o.value}
@@ -164,6 +169,7 @@ export const SettingsGeneral: Component = () => {
description={language.t("settings.general.row.appearance.description")}
>
o.value === theme.colorScheme())}
value={(o) => o.value}
@@ -190,6 +196,7 @@ export const SettingsGeneral: Component = () => {
}
>
o.id === theme.themeId())}
value={(o) => o.id}
@@ -214,6 +221,7 @@ export const SettingsGeneral: Component = () => {
description={language.t("settings.general.row.font.description")}
>
o.value === settings.appearance.font())}
value={(o) => o.value}
@@ -243,30 +251,36 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
- settings.notifications.setAgent(checked)}
- />
+
+ settings.notifications.setAgent(checked)}
+ />
+
- settings.notifications.setPermissions(checked)}
- />
+
+ settings.notifications.setPermissions(checked)}
+ />
+
- settings.notifications.setErrors(checked)}
- />
+
+ settings.notifications.setErrors(checked)}
+ />
+
@@ -281,6 +295,7 @@ export const SettingsGeneral: Component = () => {
description={language.t("settings.general.sounds.agent.description")}
>
o.id === settings.sounds.agent())}
value={(o) => o.id}
@@ -305,6 +320,7 @@ export const SettingsGeneral: Component = () => {
description={language.t("settings.general.sounds.permissions.description")}
>
o.id === settings.sounds.permissions())}
value={(o) => o.id}
@@ -329,6 +345,7 @@ export const SettingsGeneral: Component = () => {
description={language.t("settings.general.sounds.errors.description")}
>
o.id === settings.sounds.errors())}
value={(o) => o.id}
@@ -350,6 +367,34 @@ export const SettingsGeneral: Component = () => {
+
+ {(_) => {
+ const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
+ const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
+
+ return (
+
+
{language.t("settings.desktop.section.wsl")}
+
+
+
+
+ platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
+ />
+
+
+
+
+ )
+ }}
+
+
{/* Updates Section */}
{language.t("settings.general.section.updates")}
@@ -359,21 +404,25 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
-
settings.updates.setStartup(checked)}
- />
+
+ settings.updates.setStartup(checked)}
+ />
+
- settings.general.setReleaseNotes(checked)}
- />
+
+ settings.general.setReleaseNotes(checked)}
+ />
+
{
+
+
+ {(_) => {
+ const [valueResource, actions] = createResource(() => platform.getDisplayBackend?.())
+ const value = () => (valueResource.state === "pending" ? undefined : valueResource.latest)
+
+ const onChange = (checked: boolean) =>
+ platform.setDisplayBackend?.(checked ? "wayland" : "auto").finally(() => actions.refetch())
+
+ return (
+
+
{language.t("settings.general.section.display")}
+
+
+
+ {language.t("settings.general.row.wayland.title")}
+
+
+
+
+
+
+ }
+ description={language.t("settings.general.row.wayland.description")}
+ >
+
+
+
+
+
+
+ )
+ }}
+
)
}
interface SettingsRowProps {
- title: string
+ title: string | JSX.Element
description: string | JSX.Element
children: JSX.Element
}
diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx
index 393da0c2ab..79e000f371 100644
--- a/packages/app/src/components/settings-keybinds.tsx
+++ b/packages/app/src/components/settings-keybinds.tsx
@@ -44,7 +44,7 @@ function groupFor(id: string): KeybindGroup {
if (id === PALETTE_ID) return "General"
if (id.startsWith("terminal.")) return "Terminal"
if (id.startsWith("model.") || id.startsWith("agent.") || id.startsWith("mcp.")) return "Model and agent"
- if (id.startsWith("file.")) return "Navigation"
+ if (id.startsWith("file.") || id.startsWith("fileTree.")) return "Navigation"
if (id.startsWith("prompt.")) return "Prompt"
if (
id.startsWith("session.") ||
@@ -396,6 +396,7 @@ export const SettingsKeybinds: Component = () => {
{title(id)}
{
-
+
{language.t("settings.providers.section.connected")}
{
)}
-
+
-
+
Custom provider
{language.t("settings.providers.tag.custom")}
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 102c477a10..6e89990178 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -1,4 +1,4 @@
-import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
+import { createEffect, createMemo, For, onCleanup, Show } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useNavigate } from "@solidjs/router"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -7,30 +7,15 @@ import { Tabs } from "@opencode-ai/ui/tabs"
import { Button } from "@opencode-ai/ui/button"
import { Switch } from "@opencode-ai/ui/switch"
import { Icon } from "@opencode-ai/ui/icon"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
-import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
+import { normalizeServerUrl, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
-import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { DialogSelectServer } from "./dialog-select-server"
import { showToast } from "@opencode-ai/ui/toast"
-
-type ServerStatus = { healthy: boolean; version?: string }
-
-async function checkHealth(url: string, platform: ReturnType
): Promise {
- const signal = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(3000)
- const sdk = createOpencodeClient({
- baseUrl: url,
- fetch: platform.fetch,
- signal,
- })
- return sdk.global
- .health()
- .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
- .catch(() => ({ healthy: false }))
-}
+import { ServerRow } from "@/components/server/server-row"
+import { checkServerHealth, type ServerHealth } from "@/utils/server-health"
export function StatusPopover() {
const sync = useSync()
@@ -42,10 +27,11 @@ export function StatusPopover() {
const navigate = useNavigate()
const [store, setStore] = createStore({
- status: {} as Record,
+ status: {} as Record,
loading: null as string | null,
defaultServerUrl: undefined as string | undefined,
})
+ const fetcher = platform.fetch ?? globalThis.fetch
const servers = createMemo(() => {
const current = server.url
@@ -60,7 +46,7 @@ export function StatusPopover() {
if (!list.length) return list
const active = server.url
const order = new Map(list.map((url, index) => [url, index] as const))
- const rank = (value?: ServerStatus) => {
+ const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
if (value?.healthy === false) return 2
return 1
@@ -75,10 +61,10 @@ export function StatusPopover() {
})
async function refreshHealth() {
- const results: Record = {}
+ const results: Record = {}
await Promise.all(
servers().map(async (url) => {
- results[url] = await checkHealth(url, platform)
+ results[url] = await checkServerHealth(url, fetcher)
}),
)
setStore("status", reconcile(results))
@@ -155,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={
@@ -213,78 +199,43 @@ export function StatusPopover() {
const isDefault = () => url === store.defaultServerUrl
const status = () => store.status[url]
const isBlocked = () => status()?.healthy === false
- const [truncated, setTruncated] = createSignal(false)
- let nameRef: HTMLSpanElement | undefined
- let versionRef: HTMLSpanElement | undefined
-
- onMount(() => {
- const check = () => {
- const nameTruncated = nameRef ? nameRef.scrollWidth > nameRef.clientWidth : false
- const versionTruncated = versionRef ? versionRef.scrollWidth > versionRef.clientWidth : false
- setTruncated(nameTruncated || versionTruncated)
- }
- check()
- window.addEventListener("resize", check)
- onCleanup(() => window.removeEventListener("resize", check))
- })
-
- const tooltipValue = () => {
- const name = serverDisplayName(url)
- const version = status()?.version
- return (
-
- {name}
-
- {version}
-
-
- )
- }
return (
-
- {
- if (isBlocked()) return
- server.setActive(url)
- navigate("/")
- }}
+ {
+ if (isBlocked()) return
+ server.setActive(url)
+ navigate("/")
+ }}
+ >
+
+
+ {language.t("common.default")}
+
+
+ }
>
-
-
- {serverDisplayName(url)}
-
-
-
- {status()?.version}
-
-
-
-
- {language.t("common.default")}
-
-
-
-
+
+
)
}}
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index d388448024..09c04db402 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -1,13 +1,18 @@
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
+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"
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
@@ -52,6 +57,7 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
}
export const Terminal = (props: TerminalProps) => {
+ const platform = usePlatform()
const sdk = useSDK()
const settings = useSettings()
const theme = useTheme()
@@ -68,6 +74,9 @@ export const Terminal = (props: TerminalProps) => {
let handleTextareaBlur: () => void
let disposed = false
const cleanups: VoidFunction[] = []
+ 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
@@ -82,7 +91,7 @@ export const Terminal = (props: TerminalProps) => {
}
const getTerminalColors = (): TerminalColors => {
- const mode = theme.mode()
+ const mode = theme.mode() === "dark" ? "dark" : "light"
const fallback = DEFAULT_TERMINAL_COLORS[mode]
const currentTheme = theme.themes()[theme.themeId()]
if (!currentTheme) return fallback
@@ -108,33 +117,46 @@ export const Terminal = (props: TerminalProps) => {
const colors = getTerminalColors()
setTerminalColors(colors)
if (!term) return
- const setOption = (term as unknown as { setOption?: (key: string, value: TerminalColors) => void }).setOption
- if (!setOption) return
- setOption("theme", colors)
+ setOptionIfSupported(term, "theme", colors)
})
createEffect(() => {
const font = monoFontFamily(settings.appearance.font())
if (!term) return
- const setOption = (term as unknown as { setOption?: (key: string, value: string) => void }).setOption
- if (!setOption) return
- setOption("fontFamily", font)
+ setOptionIfSupported(term, "fontFamily", font)
})
const focusTerminal = () => {
const t = term
if (!t) return
t.focus()
+ t.textarea?.focus()
setTimeout(() => t.textarea?.focus(), 0)
}
const handlePointerDown = () => {
const activeElement = document.activeElement
- if (activeElement instanceof HTMLElement && activeElement !== container) {
+ if (activeElement instanceof HTMLElement && activeElement !== container && !container.contains(activeElement)) {
activeElement.blur()
}
focusTerminal()
}
+ const handleLinkClick = (event: MouseEvent) => {
+ if (!event.shiftKey && !event.ctrlKey && !event.metaKey) return
+ if (event.altKey) return
+ if (event.button !== 0) return
+
+ const t = term
+ if (!t) return
+
+ const text = getHoveredLinkText(t)
+ if (!text) return
+
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ platform.openLink(text)
+ }
+
onMount(() => {
const run = async () => {
const loaded = await loadGhostty()
@@ -145,12 +167,16 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
- const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
+ const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`)
+ url.searchParams.set("directory", sdk.directory)
+ url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0))
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__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()
})
@@ -160,12 +186,27 @@ export const Terminal = (props: TerminalProps) => {
}
ws = socket
+ const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
+ const restoreSize =
+ restore &&
+ typeof local.pty.cols === "number" &&
+ Number.isSafeInteger(local.pty.cols) &&
+ local.pty.cols > 0 &&
+ typeof local.pty.rows === "number" &&
+ Number.isSafeInteger(local.pty.rows) &&
+ local.pty.rows > 0
+ ? { cols: local.pty.cols, rows: local.pty.rows }
+ : undefined
+
const t = new mod.Terminal({
cursorBlink: true,
cursorStyle: "bar",
+ cols: restoreSize?.cols,
+ rows: restoreSize?.rows,
fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()),
- allowTransparency: true,
+ allowTransparency: false,
+ convertEol: true,
theme: terminalColors(),
scrollback: 10_000,
ghostty: g,
@@ -178,67 +219,64 @@ export const Terminal = (props: TerminalProps) => {
ghostty = g
term = t
- const copy = () => {
+ const handleCopy = (event: ClipboardEvent) => {
const selection = t.getSelection()
- if (!selection) return false
+ if (!selection) return
- const body = document.body
- if (body) {
- const textarea = document.createElement("textarea")
- textarea.value = selection
- textarea.setAttribute("readonly", "")
- textarea.style.position = "fixed"
- textarea.style.opacity = "0"
- body.appendChild(textarea)
- textarea.select()
- const copied = document.execCommand("copy")
- body.removeChild(textarea)
- if (copied) return true
- }
+ const clipboard = event.clipboardData
+ if (!clipboard) return
- const clipboard = navigator.clipboard
- if (clipboard?.writeText) {
- clipboard.writeText(selection).catch(() => {})
- return true
- }
+ event.preventDefault()
+ clipboard.setData("text/plain", selection)
+ }
- return false
+ const handlePaste = (event: ClipboardEvent) => {
+ const clipboard = event.clipboardData
+ const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
+ if (!text) return
+
+ event.preventDefault()
+ event.stopPropagation()
+ t.paste(text)
}
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
- copy()
+ document.execCommand("copy")
return true
}
- if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
- if (!t.hasSelection()) return true
- copy()
- 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)
- // allow for ctrl-` to toggle terminal in parent
- if (event.ctrlKey && key === "`") {
- return true
- }
-
- return false
+ return matchKeybind(keybinds, event)
})
+ container.addEventListener("copy", handleCopy, true)
+ cleanups.push(() => container.removeEventListener("copy", handleCopy, true))
+
+ container.addEventListener("paste", handlePaste, true)
+ cleanups.push(() => container.removeEventListener("paste", handlePaste, true))
+
const fit = new mod.FitAddon()
const serializer = new SerializeAddon()
- cleanups.push(() => (fit as unknown as { dispose?: VoidFunction }).dispose?.())
+ cleanups.push(() => disposeIfDisposable(fit))
t.loadAddon(serializer)
t.loadAddon(fit)
fitAddon = fit
serializeAddon = serializer
t.open(container)
+
container.addEventListener("pointerdown", handlePointerDown)
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
+ container.addEventListener("click", handleLinkClick, { capture: true })
+ cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
+
handleTextareaFocus = () => {
t.options.cursorBlink = true
}
@@ -253,22 +291,29 @@ export const Terminal = (props: TerminalProps) => {
focusTerminal()
- if (local.pty.buffer) {
- if (local.pty.rows && local.pty.cols) {
- t.resize(local.pty.cols, local.pty.rows)
- }
- t.write(local.pty.buffer, () => {
- if (local.pty.scrollY) {
- t.scrollToLine(local.pty.scrollY)
- }
- fitAddon.fit()
- })
+ const startResize = () => {
+ fit.observeResize()
+ handleResize = () => fit.fit()
+ window.addEventListener("resize", handleResize)
+ cleanups.push(() => window.removeEventListener("resize", handleResize))
+ }
+
+ if (restore && restoreSize) {
+ t.write(restore, () => {
+ fit.fit()
+ if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
+ startResize()
+ })
+ } else {
+ fit.fit()
+ if (restore) {
+ t.write(restore, () => {
+ if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY)
+ })
+ }
+ startResize()
}
- fit.observeResize()
- handleResize = () => fit.fit()
- window.addEventListener("resize", handleResize)
- cleanups.push(() => window.removeEventListener("resize", handleResize))
const onResize = t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
await sdk.client.pty
@@ -282,19 +327,19 @@ export const Terminal = (props: TerminalProps) => {
.catch(() => {})
}
})
- cleanups.push(() => (onResize as unknown as { dispose?: VoidFunction }).dispose?.())
+ cleanups.push(() => disposeIfDisposable(onResize))
const onData = t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
}
})
- cleanups.push(() => (onData as unknown as { dispose?: VoidFunction }).dispose?.())
+ cleanups.push(() => disposeIfDisposable(onData))
const onKey = t.onKey((key) => {
if (key.key == "Enter") {
props.onSubmit?.()
}
})
- cleanups.push(() => (onKey as unknown as { dispose?: VoidFunction }).dispose?.())
+ cleanups.push(() => disposeIfDisposable(onKey))
// t.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
@@ -314,8 +359,31 @@ export const Terminal = (props: TerminalProps) => {
socket.addEventListener("open", handleOpen)
cleanups.push(() => socket.removeEventListener("open", handleOpen))
+ const decoder = new TextDecoder()
+
const handleMessage = (event: MessageEvent) => {
- t.write(event.data)
+ 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
+ t.write(data)
+ cursor += data.length
}
socket.addEventListener("message", handleMessage)
cleanups.push(() => socket.removeEventListener("message", handleMessage))
@@ -369,6 +437,7 @@ export const Terminal = (props: TerminalProps) => {
props.onCleanup({
...local.pty,
buffer,
+ cursor,
rows: t.rows,
cols: t.cols,
scrollY: t.getViewportY(),
diff --git a/packages/app/src/components/titlebar-history.test.ts b/packages/app/src/components/titlebar-history.test.ts
new file mode 100644
index 0000000000..25035d7ccf
--- /dev/null
+++ b/packages/app/src/components/titlebar-history.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history"
+
+function history(): TitlebarHistory {
+ return { stack: [], index: 0, action: undefined }
+}
+
+describe("titlebar history", () => {
+ test("append and trim keeps max bounded", () => {
+ let state = history()
+ state = applyPath(state, "/", 3)
+ state = applyPath(state, "/a", 3)
+ state = applyPath(state, "/b", 3)
+ state = applyPath(state, "/c", 3)
+
+ expect(state.stack).toEqual(["/a", "/b", "/c"])
+ expect(state.stack.length).toBe(3)
+ expect(state.index).toBe(2)
+ })
+
+ test("back and forward indexes stay correct after trimming", () => {
+ let state = history()
+ state = applyPath(state, "/", 3)
+ state = applyPath(state, "/a", 3)
+ state = applyPath(state, "/b", 3)
+ state = applyPath(state, "/c", 3)
+
+ expect(state.stack).toEqual(["/a", "/b", "/c"])
+ expect(state.index).toBe(2)
+
+ const back = backPath(state)
+ expect(back?.to).toBe("/b")
+ expect(back?.state.index).toBe(1)
+
+ const afterBack = applyPath(back!.state, back!.to, 3)
+ expect(afterBack.stack).toEqual(["/a", "/b", "/c"])
+ expect(afterBack.index).toBe(1)
+
+ const forward = forwardPath(afterBack)
+ expect(forward?.to).toBe("/c")
+ expect(forward?.state.index).toBe(2)
+
+ const afterForward = applyPath(forward!.state, forward!.to, 3)
+ expect(afterForward.stack).toEqual(["/a", "/b", "/c"])
+ expect(afterForward.index).toBe(2)
+ })
+
+ test("action-driven navigation does not push duplicate history entries", () => {
+ const state: TitlebarHistory = {
+ stack: ["/", "/a", "/b"],
+ index: 2,
+ action: undefined,
+ }
+
+ const back = backPath(state)
+ expect(back?.to).toBe("/a")
+
+ const next = applyPath(back!.state, back!.to, 10)
+ expect(next.stack).toEqual(["/", "/a", "/b"])
+ expect(next.index).toBe(1)
+ expect(next.action).toBeUndefined()
+ })
+})
diff --git a/packages/app/src/components/titlebar-history.ts b/packages/app/src/components/titlebar-history.ts
new file mode 100644
index 0000000000..44dbbfa3a4
--- /dev/null
+++ b/packages/app/src/components/titlebar-history.ts
@@ -0,0 +1,57 @@
+export const MAX_TITLEBAR_HISTORY = 100
+
+export type TitlebarAction = "back" | "forward" | undefined
+
+export type TitlebarHistory = {
+ stack: string[]
+ index: number
+ action: TitlebarAction
+}
+
+export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
+ if (!state.stack.length) {
+ const stack = current === "/" ? ["/"] : ["/", current]
+ return { stack, index: stack.length - 1, action: undefined }
+ }
+
+ const active = state.stack[state.index]
+ if (current === active) {
+ if (!state.action) return state
+ return { ...state, action: undefined }
+ }
+
+ if (state.action) return { ...state, action: undefined }
+
+ return pushPath(state, current, max)
+}
+
+export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory {
+ const stack = state.stack.slice(0, state.index + 1).concat(path)
+ const next = trimHistory(stack, stack.length - 1, max)
+ return { ...state, ...next, action: undefined }
+}
+
+export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) {
+ if (stack.length <= max) return { stack, index }
+ const cut = stack.length - max
+ return {
+ stack: stack.slice(cut),
+ index: Math.max(0, index - cut),
+ }
+}
+
+export function backPath(state: TitlebarHistory) {
+ if (state.index <= 0) return
+ const index = state.index - 1
+ const to = state.stack[index]
+ if (!to) return
+ return { state: { ...state, index, action: "back" as const }, to }
+}
+
+export function forwardPath(state: TitlebarHistory) {
+ if (state.index >= state.stack.length - 1) return
+ const index = state.index + 1
+ const to = state.stack[index]
+ if (!to) return
+ return { state: { ...state, index, action: "forward" as const }, to }
+}
diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx
index 001f7a5679..e7b8066ae8 100644
--- a/packages/app/src/components/titlebar.tsx
+++ b/packages/app/src/components/titlebar.tsx
@@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
+import { applyPath, backPath, forwardPath } from "./titlebar-history"
export function Titlebar() {
const layout = useLayout()
@@ -24,6 +25,8 @@ export function Titlebar() {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const web = createMemo(() => platform.platform === "web")
+ const zoom = () => platform.webviewZoom?.() ?? 1
+ const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined)
const [history, setHistory] = createStore({
stack: [] as string[],
@@ -37,25 +40,9 @@ export function Titlebar() {
const current = path()
untrack(() => {
- if (!history.stack.length) {
- const stack = current === "/" ? ["/"] : ["/", current]
- setHistory({ stack, index: stack.length - 1 })
- return
- }
-
- const active = history.stack[history.index]
- if (current === active) {
- if (history.action) setHistory("action", undefined)
- return
- }
-
- if (history.action) {
- setHistory("action", undefined)
- return
- }
-
- const next = history.stack.slice(0, history.index + 1).concat(current)
- setHistory({ stack: next, index: next.length - 1 })
+ const next = applyPath(history, current)
+ if (next === history) return
+ setHistory(next)
})
})
@@ -63,29 +50,49 @@ export function Titlebar() {
const canForward = createMemo(() => history.index < history.stack.length - 1)
const back = () => {
- if (!canBack()) return
- const index = history.index - 1
- const to = history.stack[index]
- if (!to) return
- setHistory({ index, action: "back" })
- navigate(to)
+ const next = backPath(history)
+ if (!next) return
+ setHistory(next.state)
+ navigate(next.to)
}
const forward = () => {
- if (!canForward()) return
- const index = history.index + 1
- const to = history.stack[index]
- if (!to) return
- setHistory({ index, action: "forward" })
- navigate(to)
+ const next = forwardPath(history)
+ if (!next) return
+ setHistory(next.state)
+ navigate(next.to)
}
+ command.register(() => [
+ {
+ 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,
+ },
+ ])
+
const getWin = () => {
if (platform.platform !== "desktop") return
const tauri = (
window as unknown as {
- __TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise } } }
+ __TAURI__?: {
+ window?: {
+ getCurrentWindow?: () => {
+ startDragging?: () => Promise
+ toggleMaximize?: () => Promise
+ }
+ }
+ }
}
).__TAURI__
if (!tauri?.window?.getCurrentWindow) return
@@ -131,21 +138,33 @@ export function Titlebar() {
void win.startDragging().catch(() => undefined)
}
+ const maximize = (e: MouseEvent) => {
+ if (platform.platform !== "desktop") return
+ if (interactive(e.target)) return
+ if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return
+
+ const win = getWin()
+ if (!win?.toggleMaximize) return
+
+ e.preventDefault()
+ void win.toggleMaximize().catch(() => undefined)
+ }
+
return (
-
+
@@ -235,9 +251,8 @@ export function Titlebar() {
"pr-6": !windows(),
}}
onMouseDown={drag}
- data-tauri-drag-region
>
-
+
diff --git a/packages/app/src/context/command-keybind.test.ts b/packages/app/src/context/command-keybind.test.ts
new file mode 100644
index 0000000000..4e38efd8da
--- /dev/null
+++ b/packages/app/src/context/command-keybind.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, test } from "bun:test"
+import { formatKeybind, matchKeybind, parseKeybind } from "./command"
+
+describe("command keybind helpers", () => {
+ test("parseKeybind handles aliases and multiple combos", () => {
+ const keybinds = parseKeybind("control+option+k, mod+shift+comma")
+
+ expect(keybinds).toHaveLength(2)
+ expect(keybinds[0]).toEqual({
+ key: "k",
+ ctrl: true,
+ meta: false,
+ shift: false,
+ alt: true,
+ })
+ expect(keybinds[1]?.shift).toBe(true)
+ expect(keybinds[1]?.key).toBe("comma")
+ expect(Boolean(keybinds[1]?.ctrl || keybinds[1]?.meta)).toBe(true)
+ })
+
+ test("parseKeybind treats none and empty as disabled", () => {
+ expect(parseKeybind("none")).toEqual([])
+ expect(parseKeybind("")).toEqual([])
+ })
+
+ test("matchKeybind normalizes punctuation keys", () => {
+ const keybinds = parseKeybind("ctrl+comma, shift+plus, meta+space")
+
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true }))).toBe(true)
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: "+", shiftKey: true }))).toBe(true)
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: " ", metaKey: true }))).toBe(true)
+ expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false)
+ })
+
+ test("formatKeybind returns human readable output", () => {
+ const display = formatKeybind("ctrl+alt+arrowup")
+
+ expect(display).toContain("↑")
+ expect(display.includes("Ctrl") || display.includes("⌃")).toBe(true)
+ expect(display.includes("Alt") || display.includes("⌥")).toBe(true)
+ expect(formatKeybind("none")).toBe("")
+ })
+})
diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts
new file mode 100644
index 0000000000..2b956287c5
--- /dev/null
+++ b/packages/app/src/context/command.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, test } from "bun:test"
+import { upsertCommandRegistration } from "./command"
+
+describe("upsertCommandRegistration", () => {
+ test("replaces keyed registrations", () => {
+ const one = () => [{ id: "one", title: "One" }]
+ const two = () => [{ id: "two", title: "Two" }]
+
+ const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
+
+ expect(next).toHaveLength(1)
+ expect(next[0]?.options).toBe(two)
+ })
+
+ test("keeps unkeyed registrations additive", () => {
+ const one = () => [{ id: "one", title: "One" }]
+ const two = () => [{ id: "two", title: "Two" }]
+
+ const next = upsertCommandRegistration([{ options: one }], { options: two })
+
+ expect(next).toHaveLength(2)
+ expect(next[0]?.options).toBe(two)
+ expect(next[1]?.options).toBe(one)
+ })
+})
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx
index 7915695840..e6a16fd4bb 100644
--- a/packages/app/src/context/command.tsx
+++ b/packages/app/src/context/command.tsx
@@ -64,6 +64,16 @@ export type CommandCatalogItem = {
slash?: string
}
+export type CommandRegistration = {
+ key?: string
+ options: Accessor
+}
+
+export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
+ if (entry.key === undefined) return [entry, ...registrations]
+ return [entry, ...registrations.filter((x) => x.key !== entry.key)]
+}
+
export function parseKeybind(config: string): Keybind[] {
if (!config || config === "none") return []
@@ -166,9 +176,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const settings = useSettings()
const language = useLanguage()
const [store, setStore] = createStore({
- registrations: [] as Accessor[],
+ registrations: [] as CommandRegistration[],
suspendCount: 0,
})
+ const warnedDuplicates = new Set()
const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"),
@@ -187,8 +198,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const all: CommandOption[] = []
for (const reg of store.registrations) {
- for (const opt of reg()) {
- if (seen.has(opt.id)) continue
+ for (const opt of reg.options()) {
+ if (seen.has(opt.id)) {
+ if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
+ warnedDuplicates.add(opt.id)
+ console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
+ }
+ continue
+ }
seen.add(opt.id)
all.push(opt)
}
@@ -296,14 +313,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
document.removeEventListener("keydown", handleKeyDown)
})
+ function register(cb: () => CommandOption[]): void
+ function register(key: string, cb: () => CommandOption[]): void
+ function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
+ const id = typeof key === "string" ? key : undefined
+ const next = typeof key === "function" ? key : cb
+ if (!next) return
+ const options = createMemo(next)
+ const entry: CommandRegistration = {
+ key: id,
+ options,
+ }
+ setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
+ onCleanup(() => {
+ setStore("registrations", (arr) => arr.filter((x) => x !== entry))
+ })
+ }
+
return {
- register(cb: () => CommandOption[]) {
- const results = createMemo(cb)
- setStore("registrations", (arr) => [results, ...arr])
- onCleanup(() => {
- setStore("registrations", (arr) => arr.filter((x) => x !== results))
- })
- },
+ register,
trigger(id: string, source?: "palette" | "keybind" | "slash") {
run(id, source)
},
diff --git a/packages/app/src/context/comments.test.ts b/packages/app/src/context/comments.test.ts
new file mode 100644
index 0000000000..4f223e5f86
--- /dev/null
+++ b/packages/app/src/context/comments.test.ts
@@ -0,0 +1,112 @@
+import { beforeAll, describe, expect, mock, test } from "bun:test"
+import { createRoot } from "solid-js"
+import type { LineComment } from "./comments"
+
+let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest
+
+beforeAll(async () => {
+ mock.module("@solidjs/router", () => ({
+ useNavigate: () => () => undefined,
+ useParams: () => ({}),
+ }))
+ mock.module("@opencode-ai/ui/context", () => ({
+ createSimpleContext: () => ({
+ use: () => undefined,
+ provider: () => undefined,
+ }),
+ }))
+ const mod = await import("./comments")
+ createCommentSessionForTest = mod.createCommentSessionForTest
+})
+
+function line(file: string, id: string, time: number): LineComment {
+ return {
+ id,
+ file,
+ comment: id,
+ time,
+ selection: { start: 1, end: 1 },
+ }
+}
+
+describe("comments session indexing", () => {
+ test("keeps file list behavior and aggregate chronological order", () => {
+ createRoot((dispose) => {
+ const now = Date.now()
+ const comments = createCommentSessionForTest({
+ "a.ts": [line("a.ts", "a-late", now + 20_000), line("a.ts", "a-early", now + 1_000)],
+ "b.ts": [line("b.ts", "b-mid", now + 10_000)],
+ })
+
+ expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a-late", "a-early"])
+ expect(comments.all().map((item) => item.id)).toEqual(["a-early", "b-mid", "a-late"])
+
+ const next = comments.add({
+ file: "b.ts",
+ comment: "next",
+ selection: { start: 2, end: 2 },
+ })
+
+ expect(comments.list("b.ts").at(-1)?.id).toBe(next.id)
+ expect(comments.all().map((item) => item.time)).toEqual(
+ comments
+ .all()
+ .map((item) => item.time)
+ .slice()
+ .sort((a, b) => a - b),
+ )
+
+ dispose()
+ })
+ })
+
+ test("remove updates file and aggregate indexes consistently", () => {
+ createRoot((dispose) => {
+ const comments = createCommentSessionForTest({
+ "a.ts": [line("a.ts", "a1", 10), line("a.ts", "shared", 20)],
+ "b.ts": [line("b.ts", "shared", 30)],
+ })
+
+ comments.setFocus({ file: "a.ts", id: "shared" })
+ comments.setActive({ file: "a.ts", id: "shared" })
+ comments.remove("a.ts", "shared")
+
+ expect(comments.list("a.ts").map((item) => item.id)).toEqual(["a1"])
+ expect(
+ comments
+ .all()
+ .filter((item) => item.id === "shared")
+ .map((item) => item.file),
+ ).toEqual(["b.ts"])
+ expect(comments.focus()).toBeNull()
+ expect(comments.active()).toEqual({ file: "a.ts", id: "shared" })
+
+ dispose()
+ })
+ })
+
+ test("clear resets file and aggregate indexes plus focus state", () => {
+ createRoot((dispose) => {
+ const comments = createCommentSessionForTest({
+ "a.ts": [line("a.ts", "a1", 10)],
+ })
+
+ const next = comments.add({
+ file: "b.ts",
+ comment: "next",
+ selection: { start: 2, end: 2 },
+ })
+
+ comments.setActive({ file: "b.ts", id: next.id })
+ comments.clear()
+
+ expect(comments.list("a.ts")).toEqual([])
+ expect(comments.list("b.ts")).toEqual([])
+ expect(comments.all()).toEqual([])
+ expect(comments.focus()).toBeNull()
+ expect(comments.active()).toBeNull()
+
+ dispose()
+ })
+ })
+})
diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx
index f555145874..b91f029bc8 100644
--- a/packages/app/src/context/comments.tsx
+++ b/packages/app/src/context/comments.tsx
@@ -1,8 +1,9 @@
-import { batch, createMemo, createRoot, onCleanup } from "solid-js"
-import { createStore } from "solid-js/store"
+import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
+import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams } from "@solidjs/router"
import { Persist, persisted } from "@/utils/persist"
+import { createScopedCache } from "@/utils/scoped-cache"
import type { SelectedLineRange } from "@/context/file"
export type LineComment = {
@@ -18,28 +19,28 @@ type CommentFocus = { file: string; id: string }
const WORKSPACE_KEY = "__workspace__"
const MAX_COMMENT_SESSIONS = 20
-type CommentSession = ReturnType
-
-type CommentCacheEntry = {
- value: CommentSession
- dispose: VoidFunction
+type CommentStore = {
+ comments: Record
}
-function createCommentSession(dir: string, id: string | undefined) {
- const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
+function aggregate(comments: Record) {
+ return Object.keys(comments)
+ .flatMap((file) => comments[file] ?? [])
+ .slice()
+ .sort((a, b) => a.time - b.time)
+}
- const [store, setStore, _, ready] = persisted(
- Persist.scoped(dir, id, "comments", [legacy]),
- createStore<{
- comments: Record
- }>({
- comments: {},
- }),
- )
+function insert(items: LineComment[], next: LineComment) {
+ const index = items.findIndex((item) => item.time > next.time)
+ if (index < 0) return [...items, next]
+ return [...items.slice(0, index), next, ...items.slice(index)]
+}
+function createCommentSessionState(store: Store, setStore: SetStoreFunction) {
const [state, setState] = createStore({
focus: null as CommentFocus | null,
active: null as CommentFocus | null,
+ all: aggregate(store.comments),
})
const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) =>
@@ -52,13 +53,14 @@ function createCommentSession(dir: string, id: string | undefined) {
const add = (input: Omit) => {
const next: LineComment = {
- id: crypto.randomUUID(),
+ id: crypto.randomUUID?.() ?? Math.random().toString(16).slice(2),
time: Date.now(),
...input,
}
batch(() => {
setStore("comments", input.file, (items) => [...(items ?? []), next])
+ setState("all", (items) => insert(items, next))
setFocus({ file: input.file, id: next.id })
})
@@ -66,28 +68,72 @@ function createCommentSession(dir: string, id: string | undefined) {
}
const remove = (file: string, id: string) => {
- setStore("comments", file, (items) => (items ?? []).filter((x) => x.id !== id))
- setFocus((current) => (current?.id === id ? null : current))
+ batch(() => {
+ setStore("comments", file, (items) => (items ?? []).filter((item) => item.id !== id))
+ setState("all", (items) => items.filter((item) => !(item.file === file && item.id === id)))
+ setFocus((current) => (current?.id === id ? null : current))
+ })
}
- const all = createMemo(() => {
- const files = Object.keys(store.comments)
- const items = files.flatMap((file) => store.comments[file] ?? [])
- return items.slice().sort((a, b) => a.time - b.time)
+ const clear = () => {
+ batch(() => {
+ setStore("comments", reconcile({}))
+ setState("all", [])
+ setFocus(null)
+ setActive(null)
+ })
+ }
+
+ return {
+ list,
+ all: () => state.all,
+ add,
+ remove,
+ clear,
+ focus: () => state.focus,
+ setFocus,
+ clearFocus: () => setFocus(null),
+ active: () => state.active,
+ setActive,
+ clearActive: () => setActive(null),
+ reindex: () => setState("all", aggregate(store.comments)),
+ }
+}
+
+export function createCommentSessionForTest(comments: Record = {}) {
+ const [store, setStore] = createStore({ comments })
+ return createCommentSessionState(store, setStore)
+}
+
+function createCommentSession(dir: string, id: string | undefined) {
+ const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
+
+ const [store, setStore, _, ready] = persisted(
+ Persist.scoped(dir, id, "comments", [legacy]),
+ createStore({
+ comments: {},
+ }),
+ )
+ const session = createCommentSessionState(store, setStore)
+
+ createEffect(() => {
+ if (!ready()) return
+ session.reindex()
})
return {
ready,
- list,
- all,
- add,
- remove,
- focus: createMemo(() => state.focus),
- setFocus,
- clearFocus: () => setFocus(null),
- active: createMemo(() => state.active),
- setActive,
- clearActive: () => setActive(null),
+ list: session.list,
+ all: session.all,
+ add: session.add,
+ remove: session.remove,
+ clear: session.clear,
+ focus: session.focus,
+ setFocus: session.setFocus,
+ clearFocus: session.clearFocus,
+ active: session.active,
+ setActive: session.setActive,
+ clearActive: session.clearActive,
}
}
@@ -96,44 +142,27 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
gate: false,
init: () => {
const params = useParams()
- const cache = new Map()
+ const cache = createScopedCache(
+ (key) => {
+ const split = key.lastIndexOf("\n")
+ const dir = split >= 0 ? key.slice(0, split) : key
+ const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
+ return createRoot((dispose) => ({
+ value: createCommentSession(dir, id === WORKSPACE_KEY ? undefined : id),
+ dispose,
+ }))
+ },
+ {
+ maxEntries: MAX_COMMENT_SESSIONS,
+ dispose: (entry) => entry.dispose(),
+ },
+ )
- const disposeAll = () => {
- for (const entry of cache.values()) {
- entry.dispose()
- }
- cache.clear()
- }
-
- onCleanup(disposeAll)
-
- const prune = () => {
- while (cache.size > MAX_COMMENT_SESSIONS) {
- const first = cache.keys().next().value
- if (!first) return
- const entry = cache.get(first)
- entry?.dispose()
- cache.delete(first)
- }
- }
+ onCleanup(() => cache.clear())
const load = (dir: string, id: string | undefined) => {
- const key = `${dir}:${id ?? WORKSPACE_KEY}`
- const existing = cache.get(key)
- if (existing) {
- cache.delete(key)
- cache.set(key, existing)
- return existing.value
- }
-
- const entry = createRoot((dispose) => ({
- value: createCommentSession(dir, id),
- dispose,
- }))
-
- cache.set(key, entry)
- prune()
- return entry.value
+ const key = `${dir}\n${id ?? WORKSPACE_KEY}`
+ return cache.get(key).value
}
const session = createMemo(() => load(params.dir!, params.id))
@@ -144,6 +173,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
all: () => session().all(),
add: (input: Omit) => session().add(input),
remove: (file: string, id: string) => session().remove(file, id),
+ clear: () => session().clear(),
focus: () => session().focus(),
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
clearFocus: () => session().clearFocus(),
diff --git a/packages/app/src/context/file-content-eviction-accounting.test.ts b/packages/app/src/context/file-content-eviction-accounting.test.ts
new file mode 100644
index 0000000000..4ef5f947c7
--- /dev/null
+++ b/packages/app/src/context/file-content-eviction-accounting.test.ts
@@ -0,0 +1,65 @@
+import { afterEach, describe, expect, test } from "bun:test"
+import {
+ evictContentLru,
+ getFileContentBytesTotal,
+ getFileContentEntryCount,
+ removeFileContentBytes,
+ resetFileContentLru,
+ setFileContentBytes,
+ touchFileContent,
+} from "./file/content-cache"
+
+describe("file content eviction accounting", () => {
+ afterEach(() => {
+ resetFileContentLru()
+ })
+
+ test("updates byte totals incrementally for set, overwrite, remove, and reset", () => {
+ setFileContentBytes("a", 10)
+ setFileContentBytes("b", 15)
+ expect(getFileContentBytesTotal()).toBe(25)
+ expect(getFileContentEntryCount()).toBe(2)
+
+ setFileContentBytes("a", 5)
+ expect(getFileContentBytesTotal()).toBe(20)
+ expect(getFileContentEntryCount()).toBe(2)
+
+ touchFileContent("a")
+ expect(getFileContentBytesTotal()).toBe(20)
+
+ removeFileContentBytes("b")
+ expect(getFileContentBytesTotal()).toBe(5)
+ expect(getFileContentEntryCount()).toBe(1)
+
+ resetFileContentLru()
+ expect(getFileContentBytesTotal()).toBe(0)
+ expect(getFileContentEntryCount()).toBe(0)
+ })
+
+ test("evicts by entry cap using LRU order", () => {
+ for (const i of Array.from({ length: 41 }, (_, n) => n)) {
+ setFileContentBytes(`f-${i}`, 1)
+ }
+
+ const evicted: string[] = []
+ evictContentLru(undefined, (path) => evicted.push(path))
+
+ expect(evicted).toEqual(["f-0"])
+ expect(getFileContentEntryCount()).toBe(40)
+ expect(getFileContentBytesTotal()).toBe(40)
+ })
+
+ test("evicts by byte cap while preserving protected entries", () => {
+ const chunk = 8 * 1024 * 1024
+ setFileContentBytes("a", chunk)
+ setFileContentBytes("b", chunk)
+ setFileContentBytes("c", chunk)
+
+ const evicted: string[] = []
+ evictContentLru(new Set(["a"]), (path) => evicted.push(path))
+
+ expect(evicted).toEqual(["b"])
+ expect(getFileContentEntryCount()).toBe(2)
+ expect(getFileContentBytesTotal()).toBe(chunk * 2)
+ })
+})
diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx
index 7509334edb..88b70cd41d 100644
--- a/packages/app/src/context/file.tsx
+++ b/packages/app/src/context/file.tsx
@@ -1,269 +1,46 @@
-import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
-import { createStore, produce } from "solid-js/store"
+import { batch, createEffect, createMemo, onCleanup } from "solid-js"
+import { createStore, produce, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
-import type { FileContent, FileNode } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useLanguage } from "@/context/language"
-import { Persist, persisted } from "@/utils/persist"
+import { useLayout } from "@/context/layout"
+import { createPathHelpers } from "./file/path"
+import {
+ approxBytes,
+ evictContentLru,
+ getFileContentBytesTotal,
+ getFileContentEntryCount,
+ hasFileContent,
+ removeFileContentBytes,
+ resetFileContentLru,
+ setFileContentBytes,
+ touchFileContent,
+} from "./file/content-cache"
+import { createFileViewCache } from "./file/view-cache"
+import { createFileTreeStore } from "./file/tree-store"
+import { invalidateFromWatcher } from "./file/watcher"
+import {
+ selectionFromLines,
+ type FileState,
+ type FileSelection,
+ type FileViewState,
+ type SelectedLineRange,
+} from "./file/types"
-export type FileSelection = {
- startLine: number
- startChar: number
- endLine: number
- endChar: number
-}
-
-export type SelectedLineRange = {
- start: number
- end: number
- side?: "additions" | "deletions"
- endSide?: "additions" | "deletions"
-}
-
-export type FileViewState = {
- scrollTop?: number
- scrollLeft?: number
- selectedLines?: SelectedLineRange | null
-}
-
-export type FileState = {
- path: string
- name: string
- loaded?: boolean
- loading?: boolean
- error?: string
- content?: FileContent
-}
-
-type DirectoryState = {
- expanded: boolean
- loaded?: boolean
- loading?: boolean
- error?: string
- children?: string[]
-}
-
-function stripFileProtocol(input: string) {
- if (!input.startsWith("file://")) return input
- return input.slice("file://".length)
-}
-
-function stripQueryAndHash(input: string) {
- const hashIndex = input.indexOf("#")
- const queryIndex = input.indexOf("?")
-
- if (hashIndex !== -1 && queryIndex !== -1) {
- return input.slice(0, Math.min(hashIndex, queryIndex))
- }
-
- if (hashIndex !== -1) return input.slice(0, hashIndex)
- if (queryIndex !== -1) return input.slice(0, queryIndex)
- return input
-}
-
-function unquoteGitPath(input: string) {
- if (!input.startsWith('"')) return input
- if (!input.endsWith('"')) return input
- const body = input.slice(1, -1)
- const bytes: number[] = []
-
- for (let i = 0; i < body.length; i++) {
- const char = body[i]!
- if (char !== "\\") {
- bytes.push(char.charCodeAt(0))
- continue
- }
-
- const next = body[i + 1]
- if (!next) {
- bytes.push("\\".charCodeAt(0))
- continue
- }
-
- if (next >= "0" && next <= "7") {
- const chunk = body.slice(i + 1, i + 4)
- const match = chunk.match(/^[0-7]{1,3}/)
- if (!match) {
- bytes.push(next.charCodeAt(0))
- i++
- continue
- }
- bytes.push(parseInt(match[0], 8))
- i += match[0].length
- continue
- }
-
- const escaped =
- next === "n"
- ? "\n"
- : next === "r"
- ? "\r"
- : next === "t"
- ? "\t"
- : next === "b"
- ? "\b"
- : next === "f"
- ? "\f"
- : next === "v"
- ? "\v"
- : next === "\\" || next === '"'
- ? next
- : undefined
-
- bytes.push((escaped ?? next).charCodeAt(0))
- i++
- }
-
- return new TextDecoder().decode(new Uint8Array(bytes))
-}
-
-export function selectionFromLines(range: SelectedLineRange): FileSelection {
- const startLine = Math.min(range.start, range.end)
- const endLine = Math.max(range.start, range.end)
- return {
- startLine,
- endLine,
- startChar: 0,
- endChar: 0,
- }
-}
-
-function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
- if (range.start <= range.end) return range
-
- const startSide = range.side
- const endSide = range.endSide ?? startSide
-
- return {
- ...range,
- start: range.end,
- end: range.start,
- side: endSide,
- endSide: startSide !== endSide ? startSide : undefined,
- }
-}
-
-const WORKSPACE_KEY = "__workspace__"
-const MAX_FILE_VIEW_SESSIONS = 20
-const MAX_VIEW_FILES = 500
-
-const MAX_FILE_CONTENT_ENTRIES = 40
-const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
-
-const contentLru = new Map()
-
-function approxBytes(content: FileContent) {
- const patchBytes =
- content.patch?.hunks.reduce((total, hunk) => {
- return total + hunk.lines.reduce((sum, line) => sum + line.length, 0)
- }, 0) ?? 0
-
- return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
-}
-
-function touchContent(path: string, bytes?: number) {
- const prev = contentLru.get(path)
- if (prev === undefined && bytes === undefined) return
- const value = bytes ?? prev ?? 0
- contentLru.delete(path)
- contentLru.set(path, value)
-}
-
-type ViewSession = ReturnType
-
-type ViewCacheEntry = {
- value: ViewSession
- dispose: VoidFunction
-}
-
-function createViewSession(dir: string, id: string | undefined) {
- const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
-
- const [view, setView, _, ready] = persisted(
- Persist.scoped(dir, id, "file-view", [legacyViewKey]),
- createStore<{
- file: Record
- }>({
- file: {},
- }),
- )
-
- const meta = { pruned: false }
-
- const pruneView = (keep?: string) => {
- const keys = Object.keys(view.file)
- if (keys.length <= MAX_VIEW_FILES) return
-
- const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
- if (drop.length === 0) return
-
- setView(
- produce((draft) => {
- for (const key of drop) {
- delete draft.file[key]
- }
- }),
- )
- }
-
- createEffect(() => {
- if (!ready()) return
- if (meta.pruned) return
- meta.pruned = true
- pruneView()
- })
-
- const scrollTop = (path: string) => view.file[path]?.scrollTop
- const scrollLeft = (path: string) => view.file[path]?.scrollLeft
- const selectedLines = (path: string) => view.file[path]?.selectedLines
-
- const setScrollTop = (path: string, top: number) => {
- setView("file", path, (current) => {
- if (current?.scrollTop === top) return current
- return {
- ...(current ?? {}),
- scrollTop: top,
- }
- })
- pruneView(path)
- }
-
- const setScrollLeft = (path: string, left: number) => {
- setView("file", path, (current) => {
- if (current?.scrollLeft === left) return current
- return {
- ...(current ?? {}),
- scrollLeft: left,
- }
- })
- pruneView(path)
- }
-
- const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
- const next = range ? normalizeSelectedLines(range) : null
- setView("file", path, (current) => {
- if (current?.selectedLines === next) return current
- return {
- ...(current ?? {}),
- selectedLines: next,
- }
- })
- pruneView(path)
- }
-
- return {
- ready,
- scrollTop,
- scrollLeft,
- selectedLines,
- setScrollTop,
- setScrollLeft,
- setSelectedLines,
- }
+export type { FileSelection, SelectedLineRange, FileViewState, FileState }
+export { selectionFromLines }
+export {
+ evictContentLru,
+ getFileContentBytesTotal,
+ getFileContentEntryCount,
+ removeFileContentBytes,
+ resetFileContentLru,
+ setFileContentBytes,
+ touchFileContent,
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({
@@ -271,168 +48,77 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
gate: false,
init: () => {
const sdk = useSDK()
- const sync = useSync()
+ useSync()
const params = useParams()
const language = useLanguage()
+ const layout = useLayout()
const scope = createMemo(() => sdk.directory)
-
- const directory = createMemo(() => sync.data.path.directory)
-
- function normalize(input: string) {
- const root = directory()
- const prefix = root.endsWith("/") ? root : root + "/"
-
- let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
-
- if (path.startsWith(prefix)) {
- path = path.slice(prefix.length)
- }
-
- if (path.startsWith(root)) {
- path = path.slice(root.length)
- }
-
- if (path.startsWith("./")) {
- path = path.slice(2)
- }
-
- if (path.startsWith("/")) {
- path = path.slice(1)
- }
-
- return path
- }
-
- function tab(input: string) {
- const path = normalize(input)
- return `file://${path}`
- }
-
- function pathFromTab(tabValue: string) {
- if (!tabValue.startsWith("file://")) return
- return normalize(tabValue)
- }
+ const path = createPathHelpers(scope)
+ const tabs = layout.tabs(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const inflight = new Map>()
- const treeInflight = new Map>()
-
- const search = (query: string, dirs: "true" | "false") =>
- sdk.client.find.files({ query, dirs }).then(
- (x) => (x.data ?? []).map(normalize),
- () => [],
- )
-
const [store, setStore] = createStore<{
file: Record
}>({
file: {},
})
- const [tree, setTree] = createStore<{
- node: Record
- dir: Record
- }>({
- node: {},
- dir: { "": { expanded: true } },
+ const tree = createFileTreeStore({
+ scope,
+ normalizeDir: path.normalizeDir,
+ list: (dir) => sdk.client.file.list({ path: dir }).then((x) => x.data ?? []),
+ onError: (message) => {
+ showToast({
+ variant: "error",
+ title: language.t("toast.file.listFailed.title"),
+ description: message,
+ })
+ },
})
const evictContent = (keep?: Set) => {
- const protectedSet = keep ?? new Set()
- const total = () => {
- return Array.from(contentLru.values()).reduce((sum, bytes) => sum + bytes, 0)
- }
-
- while (contentLru.size > MAX_FILE_CONTENT_ENTRIES || total() > MAX_FILE_CONTENT_BYTES) {
- const path = contentLru.keys().next().value
- if (!path) return
-
- if (protectedSet.has(path)) {
- touchContent(path)
- if (contentLru.size <= protectedSet.size) return
- continue
- }
-
- contentLru.delete(path)
- if (!store.file[path]) continue
+ evictContentLru(keep, (target) => {
+ if (!store.file[target]) return
setStore(
"file",
- path,
+ target,
produce((draft) => {
draft.content = undefined
draft.loaded = false
}),
)
- }
+ })
}
createEffect(() => {
scope()
inflight.clear()
- treeInflight.clear()
- contentLru.clear()
- setStore("file", {})
- setTree("node", {})
- setTree("dir", { "": { expanded: true } })
+ resetFileContentLru()
+ batch(() => {
+ setStore("file", reconcile({}))
+ tree.reset()
+ })
})
- const viewCache = new Map()
+ const viewCache = createFileViewCache()
+ const view = createMemo(() => viewCache.load(scope(), params.id))
- const disposeViews = () => {
- for (const entry of viewCache.values()) {
- entry.dispose()
- }
- viewCache.clear()
+ const ensure = (file: string) => {
+ if (!file) return
+ if (store.file[file]) return
+ setStore("file", file, { path: file, name: getFilename(file) })
}
- const pruneViews = () => {
- while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
- const first = viewCache.keys().next().value
- if (!first) return
- const entry = viewCache.get(first)
- entry?.dispose()
- viewCache.delete(first)
- }
- }
-
- const loadView = (dir: string, id: string | undefined) => {
- const key = `${dir}:${id ?? WORKSPACE_KEY}`
- const existing = viewCache.get(key)
- if (existing) {
- viewCache.delete(key)
- viewCache.set(key, existing)
- return existing.value
- }
-
- const entry = createRoot((dispose) => ({
- value: createViewSession(dir, id),
- dispose,
- }))
-
- viewCache.set(key, entry)
- pruneViews()
- return entry.value
- }
-
- const view = createMemo(() => loadView(params.dir!, params.id))
-
- function ensure(path: string) {
- if (!path) return
- if (store.file[path]) return
- setStore("file", path, { path, name: getFilename(path) })
- }
-
- function load(input: string, options?: { force?: boolean }) {
- const path = normalize(input)
- if (!path) return Promise.resolve()
+ const load = (input: string, options?: { force?: boolean }) => {
+ const file = path.normalize(input)
+ if (!file) return Promise.resolve()
const directory = scope()
- const key = `${directory}\n${path}`
- const client = sdk.client
+ const key = `${directory}\n${file}`
+ ensure(file)
- ensure(path)
-
- const current = store.file[path]
+ const current = store.file[file]
if (!options?.force && current?.loaded) return Promise.resolve()
const pending = inflight.get(key)
@@ -440,21 +126,21 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setStore(
"file",
- path,
+ file,
produce((draft) => {
draft.loading = true
draft.error = undefined
}),
)
- const promise = client.file
- .read({ path })
+ const promise = sdk.client.file
+ .read({ path: file })
.then((x) => {
if (scope() !== directory) return
const content = x.data
setStore(
"file",
- path,
+ file,
produce((draft) => {
draft.loaded = true
draft.loading = false
@@ -463,14 +149,14 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
)
if (!content) return
- touchContent(path, approxBytes(content))
- evictContent(new Set([path]))
+ touchFileContent(file, approxBytes(content))
+ evictContent(new Set([file]))
})
.catch((e) => {
if (scope() !== directory) return
setStore(
"file",
- path,
+ file,
produce((draft) => {
draft.loading = false
draft.error = e.message
@@ -490,225 +176,80 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return promise
}
- function normalizeDir(input: string) {
- return normalize(input).replace(/\/+$/, "")
- }
-
- function ensureDir(path: string) {
- if (tree.dir[path]) return
- setTree("dir", path, { expanded: false })
- }
-
- function listDir(input: string, options?: { force?: boolean }) {
- const dir = normalizeDir(input)
- ensureDir(dir)
-
- const current = tree.dir[dir]
- if (!options?.force && current?.loaded) return Promise.resolve()
-
- const pending = treeInflight.get(dir)
- if (pending) return pending
-
- setTree(
- "dir",
- dir,
- produce((draft) => {
- draft.loading = true
- draft.error = undefined
- }),
+ const search = (query: string, dirs: "true" | "false") =>
+ sdk.client.find.files({ query, dirs }).then(
+ (x) => (x.data ?? []).map(path.normalize),
+ () => [],
)
- const directory = scope()
-
- const promise = sdk.client.file
- .list({ path: dir })
- .then((x) => {
- if (scope() !== directory) return
- const nodes = x.data ?? []
- const prevChildren = tree.dir[dir]?.children ?? []
- const nextChildren = nodes.map((node) => node.path)
- const nextSet = new Set(nextChildren)
-
- setTree(
- "node",
- produce((draft) => {
- const removedDirs: string[] = []
-
- for (const child of prevChildren) {
- if (nextSet.has(child)) continue
- const existing = draft[child]
- if (existing?.type === "directory") removedDirs.push(child)
- delete draft[child]
- }
-
- if (removedDirs.length > 0) {
- const keys = Object.keys(draft)
- for (const key of keys) {
- for (const removed of removedDirs) {
- if (!key.startsWith(removed + "/")) continue
- delete draft[key]
- break
- }
- }
- }
-
- for (const node of nodes) {
- draft[node.path] = node
- }
- }),
- )
-
- setTree(
- "dir",
- dir,
- produce((draft) => {
- draft.loaded = true
- draft.loading = false
- draft.children = nextChildren
- }),
- )
- })
- .catch((e) => {
- if (scope() !== directory) return
- setTree(
- "dir",
- dir,
- produce((draft) => {
- draft.loading = false
- draft.error = e.message
- }),
- )
- showToast({
- variant: "error",
- title: language.t("toast.file.listFailed.title"),
- description: e.message,
- })
- })
- .finally(() => {
- treeInflight.delete(dir)
- })
-
- treeInflight.set(dir, promise)
- return promise
- }
-
- function expandDir(input: string) {
- const dir = normalizeDir(input)
- ensureDir(dir)
- setTree("dir", dir, "expanded", true)
- void listDir(dir)
- }
-
- function collapseDir(input: string) {
- const dir = normalizeDir(input)
- ensureDir(dir)
- setTree("dir", dir, "expanded", false)
- }
-
- function dirState(input: string) {
- const dir = normalizeDir(input)
- return tree.dir[dir]
- }
-
- function children(input: string) {
- const dir = normalizeDir(input)
- const ids = tree.dir[dir]?.children
- if (!ids) return []
- const out: FileNode[] = []
- for (const id of ids) {
- const node = tree.node[id]
- if (node) out.push(node)
- }
- return out
- }
-
const stop = sdk.event.listen((e) => {
- const event = e.details
- if (event.type !== "file.watcher.updated") return
- const path = normalize(event.properties.file)
- if (!path) return
- if (path.startsWith(".git/")) return
-
- if (store.file[path]) {
- load(path, { force: true })
- }
-
- const kind = event.properties.event
- if (kind === "change") {
- const dir = (() => {
- if (path === "") return ""
- const node = tree.node[path]
- if (node?.type !== "directory") return
- return path
- })()
- if (dir === undefined) return
- if (!tree.dir[dir]?.loaded) return
- listDir(dir, { force: true })
- return
- }
- if (kind !== "add" && kind !== "unlink") return
-
- const parent = path.split("/").slice(0, -1).join("/")
- if (!tree.dir[parent]?.loaded) return
-
- listDir(parent, { force: true })
+ 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 })
+ },
+ node: tree.node,
+ isDirLoaded: tree.isLoaded,
+ refreshDir: (dir) => {
+ void tree.listDir(dir, { force: true })
+ },
+ })
})
const get = (input: string) => {
- const path = normalize(input)
- const file = store.file[path]
- const content = file?.content
- if (!content) return file
- if (contentLru.has(path)) {
- touchContent(path)
- return file
+ const file = path.normalize(input)
+ const state = store.file[file]
+ const content = state?.content
+ if (!content) return state
+ if (hasFileContent(file)) {
+ touchFileContent(file)
+ return state
}
- touchContent(path, approxBytes(content))
- return file
+ touchFileContent(file, approxBytes(content))
+ return state
}
- const scrollTop = (input: string) => view().scrollTop(normalize(input))
- const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
- const selectedLines = (input: string) => view().selectedLines(normalize(input))
+ const scrollTop = (input: string) => view().scrollTop(path.normalize(input))
+ const scrollLeft = (input: string) => view().scrollLeft(path.normalize(input))
+ const selectedLines = (input: string) => view().selectedLines(path.normalize(input))
const setScrollTop = (input: string, top: number) => {
- const path = normalize(input)
- view().setScrollTop(path, top)
+ view().setScrollTop(path.normalize(input), top)
}
const setScrollLeft = (input: string, left: number) => {
- const path = normalize(input)
- view().setScrollLeft(path, left)
+ view().setScrollLeft(path.normalize(input), left)
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
- const path = normalize(input)
- view().setSelectedLines(path, range)
+ view().setSelectedLines(path.normalize(input), range)
}
onCleanup(() => {
stop()
- disposeViews()
+ viewCache.clear()
})
return {
ready: () => view().ready(),
- normalize,
- tab,
- pathFromTab,
+ normalize: path.normalize,
+ tab: path.tab,
+ pathFromTab: path.pathFromTab,
tree: {
- list: listDir,
- refresh: (input: string) => listDir(input, { force: true }),
- state: dirState,
- children,
- expand: expandDir,
- collapse: collapseDir,
+ list: tree.listDir,
+ refresh: (input: string) => tree.listDir(input, { force: true }),
+ state: tree.dirState,
+ children: tree.children,
+ expand: tree.expandDir,
+ collapse: tree.collapseDir,
toggle(input: string) {
- if (dirState(input)?.expanded) {
- collapseDir(input)
+ if (tree.dirState(input)?.expanded) {
+ tree.collapseDir(input)
return
}
- expandDir(input)
+ tree.expandDir(input)
},
},
get,
diff --git a/packages/app/src/context/file/content-cache.ts b/packages/app/src/context/file/content-cache.ts
new file mode 100644
index 0000000000..4b72406883
--- /dev/null
+++ b/packages/app/src/context/file/content-cache.ts
@@ -0,0 +1,88 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+
+const MAX_FILE_CONTENT_ENTRIES = 40
+const MAX_FILE_CONTENT_BYTES = 20 * 1024 * 1024
+
+const lru = new Map()
+let total = 0
+
+export function approxBytes(content: FileContent) {
+ const patchBytes =
+ content.patch?.hunks.reduce((sum, hunk) => {
+ return sum + hunk.lines.reduce((lineSum, line) => lineSum + line.length, 0)
+ }, 0) ?? 0
+
+ return (content.content.length + (content.diff?.length ?? 0) + patchBytes) * 2
+}
+
+function setBytes(path: string, nextBytes: number) {
+ const prev = lru.get(path)
+ if (prev !== undefined) total -= prev
+ lru.delete(path)
+ lru.set(path, nextBytes)
+ total += nextBytes
+}
+
+function touch(path: string, bytes?: number) {
+ const prev = lru.get(path)
+ if (prev === undefined && bytes === undefined) return
+ setBytes(path, bytes ?? prev ?? 0)
+}
+
+function remove(path: string) {
+ const prev = lru.get(path)
+ if (prev === undefined) return
+ lru.delete(path)
+ total -= prev
+}
+
+function reset() {
+ lru.clear()
+ total = 0
+}
+
+export function evictContentLru(keep: Set | undefined, evict: (path: string) => void) {
+ const set = keep ?? new Set()
+
+ while (lru.size > MAX_FILE_CONTENT_ENTRIES || total > MAX_FILE_CONTENT_BYTES) {
+ const path = lru.keys().next().value
+ if (!path) return
+
+ if (set.has(path)) {
+ touch(path)
+ if (lru.size <= set.size) return
+ continue
+ }
+
+ remove(path)
+ evict(path)
+ }
+}
+
+export function resetFileContentLru() {
+ reset()
+}
+
+export function setFileContentBytes(path: string, bytes: number) {
+ setBytes(path, bytes)
+}
+
+export function removeFileContentBytes(path: string) {
+ remove(path)
+}
+
+export function touchFileContent(path: string, bytes?: number) {
+ touch(path, bytes)
+}
+
+export function getFileContentBytesTotal() {
+ return total
+}
+
+export function getFileContentEntryCount() {
+ return lru.size
+}
+
+export function hasFileContent(path: string) {
+ return lru.has(path)
+}
diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts
new file mode 100644
index 0000000000..f2a3c44b6c
--- /dev/null
+++ b/packages/app/src/context/file/path.test.ts
@@ -0,0 +1,352 @@
+import { describe, expect, test } from "bun:test"
+import { createPathHelpers, stripQueryAndHash, unquoteGitPath, encodeFilePath } from "./path"
+
+describe("file path helpers", () => {
+ test("normalizes file inputs against workspace root", () => {
+ const path = createPathHelpers(() => "/repo")
+ expect(path.normalize("file:///repo/src/app.ts?x=1#h")).toBe("src/app.ts")
+ expect(path.normalize("/repo/src/app.ts")).toBe("src/app.ts")
+ expect(path.normalize("./src/app.ts")).toBe("src/app.ts")
+ expect(path.normalizeDir("src/components///")).toBe("src/components")
+ expect(path.tab("src/app.ts")).toBe("file://src/app.ts")
+ expect(path.pathFromTab("file://src/app.ts")).toBe("src/app.ts")
+ expect(path.pathFromTab("other://src/app.ts")).toBeUndefined()
+ })
+
+ test("keeps query/hash stripping behavior stable", () => {
+ expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts")
+ expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts")
+ expect(stripQueryAndHash("a/b.ts")).toBe("a/b.ts")
+ })
+
+ test("unquotes git escaped octal path strings", () => {
+ expect(unquoteGitPath('"a/\\303\\251.txt"')).toBe("a/\u00e9.txt")
+ expect(unquoteGitPath('"plain\\nname"')).toBe("plain\nname")
+ 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
new file mode 100644
index 0000000000..859fdc0406
--- /dev/null
+++ b/packages/app/src/context/file/path.ts
@@ -0,0 +1,148 @@
+export function stripFileProtocol(input: string) {
+ if (!input.startsWith("file://")) return input
+ return input.slice("file://".length)
+}
+
+export function stripQueryAndHash(input: string) {
+ const hashIndex = input.indexOf("#")
+ const queryIndex = input.indexOf("?")
+
+ if (hashIndex !== -1 && queryIndex !== -1) {
+ return input.slice(0, Math.min(hashIndex, queryIndex))
+ }
+
+ if (hashIndex !== -1) return input.slice(0, hashIndex)
+ if (queryIndex !== -1) return input.slice(0, queryIndex)
+ return input
+}
+
+export function unquoteGitPath(input: string) {
+ if (!input.startsWith('"')) return input
+ if (!input.endsWith('"')) return input
+ const body = input.slice(1, -1)
+ const bytes: number[] = []
+
+ for (let i = 0; i < body.length; i++) {
+ const char = body[i]!
+ if (char !== "\\") {
+ bytes.push(char.charCodeAt(0))
+ continue
+ }
+
+ const next = body[i + 1]
+ if (!next) {
+ bytes.push("\\".charCodeAt(0))
+ continue
+ }
+
+ if (next >= "0" && next <= "7") {
+ const chunk = body.slice(i + 1, i + 4)
+ const match = chunk.match(/^[0-7]{1,3}/)
+ if (!match) {
+ bytes.push(next.charCodeAt(0))
+ i++
+ continue
+ }
+ bytes.push(parseInt(match[0], 8))
+ i += match[0].length
+ continue
+ }
+
+ const escaped =
+ next === "n"
+ ? "\n"
+ : next === "r"
+ ? "\r"
+ : next === "t"
+ ? "\t"
+ : next === "b"
+ ? "\b"
+ : next === "f"
+ ? "\f"
+ : next === "v"
+ ? "\v"
+ : next === "\\" || next === '"'
+ ? next
+ : undefined
+
+ bytes.push((escaped ?? next).charCodeAt(0))
+ i++
+ }
+
+ return new TextDecoder().decode(new Uint8Array(bytes))
+}
+
+export function decodeFilePath(input: string) {
+ try {
+ return decodeURIComponent(input)
+ } catch {
+ return input
+ }
+}
+
+export function encodeFilePath(filepath: string): string {
+ // Normalize Windows paths: convert backslashes to forward slashes
+ let normalized = filepath.replace(/\\/g, "/")
+
+ // Handle Windows absolute paths (D:/path -> /D:/path for proper file:// URLs)
+ if (/^[A-Za-z]:/.test(normalized)) {
+ normalized = "/" + normalized
+ }
+
+ // Encode each path segment (preserving forward slashes as path separators)
+ // Keep the colon in Windows drive letters (`/C:/...`) so downstream file URL parsers
+ // can reliably detect drives.
+ return normalized
+ .split("/")
+ .map((segment, index) => {
+ if (index === 1 && /^[A-Za-z]:$/.test(segment)) return segment
+ return encodeURIComponent(segment)
+ })
+ .join("/")
+}
+
+export function createPathHelpers(scope: () => string) {
+ const normalize = (input: string) => {
+ const root = scope()
+ const prefix = root.endsWith("/") ? root : root + "/"
+
+ let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
+
+ if (path.startsWith(prefix)) {
+ path = path.slice(prefix.length)
+ }
+
+ if (path.startsWith(root)) {
+ path = path.slice(root.length)
+ }
+
+ if (path.startsWith("./")) {
+ path = path.slice(2)
+ }
+
+ if (path.startsWith("/")) {
+ path = path.slice(1)
+ }
+
+ return path
+ }
+
+ const tab = (input: string) => {
+ const path = normalize(input)
+ return `file://${encodeFilePath(path)}`
+ }
+
+ const pathFromTab = (tabValue: string) => {
+ if (!tabValue.startsWith("file://")) return
+ return normalize(tabValue)
+ }
+
+ const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "")
+
+ return {
+ normalize,
+ tab,
+ pathFromTab,
+ normalizeDir,
+ }
+}
diff --git a/packages/app/src/context/file/tree-store.ts b/packages/app/src/context/file/tree-store.ts
new file mode 100644
index 0000000000..a86051d286
--- /dev/null
+++ b/packages/app/src/context/file/tree-store.ts
@@ -0,0 +1,170 @@
+import { createStore, produce, reconcile } from "solid-js/store"
+import type { FileNode } from "@opencode-ai/sdk/v2"
+
+type DirectoryState = {
+ expanded: boolean
+ loaded?: boolean
+ loading?: boolean
+ error?: string
+ children?: string[]
+}
+
+type TreeStoreOptions = {
+ scope: () => string
+ normalizeDir: (input: string) => string
+ list: (input: string) => Promise
+ onError: (message: string) => void
+}
+
+export function createFileTreeStore(options: TreeStoreOptions) {
+ const [tree, setTree] = createStore<{
+ node: Record
+ dir: Record
+ }>({
+ node: {},
+ dir: { "": { expanded: true } },
+ })
+
+ const inflight = new Map>()
+
+ const reset = () => {
+ inflight.clear()
+ setTree("node", reconcile({}))
+ setTree("dir", reconcile({}))
+ setTree("dir", "", { expanded: true })
+ }
+
+ const ensureDir = (path: string) => {
+ if (tree.dir[path]) return
+ setTree("dir", path, { expanded: false })
+ }
+
+ const listDir = (input: string, opts?: { force?: boolean }) => {
+ const dir = options.normalizeDir(input)
+ ensureDir(dir)
+
+ const current = tree.dir[dir]
+ if (!opts?.force && current?.loaded) return Promise.resolve()
+
+ const pending = inflight.get(dir)
+ if (pending) return pending
+
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loading = true
+ draft.error = undefined
+ }),
+ )
+
+ const directory = options.scope()
+
+ const promise = options
+ .list(dir)
+ .then((nodes) => {
+ if (options.scope() !== directory) return
+ const prevChildren = tree.dir[dir]?.children ?? []
+ const nextChildren = nodes.map((node) => node.path)
+ const nextSet = new Set(nextChildren)
+
+ setTree(
+ "node",
+ produce((draft) => {
+ const removedDirs: string[] = []
+
+ for (const child of prevChildren) {
+ if (nextSet.has(child)) continue
+ const existing = draft[child]
+ if (existing?.type === "directory") removedDirs.push(child)
+ delete draft[child]
+ }
+
+ if (removedDirs.length > 0) {
+ const keys = Object.keys(draft)
+ for (const key of keys) {
+ for (const removed of removedDirs) {
+ if (!key.startsWith(removed + "/")) continue
+ delete draft[key]
+ break
+ }
+ }
+ }
+
+ for (const node of nodes) {
+ draft[node.path] = node
+ }
+ }),
+ )
+
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loaded = true
+ draft.loading = false
+ draft.children = nextChildren
+ }),
+ )
+ })
+ .catch((e) => {
+ if (options.scope() !== directory) return
+ setTree(
+ "dir",
+ dir,
+ produce((draft) => {
+ draft.loading = false
+ draft.error = e.message
+ }),
+ )
+ options.onError(e.message)
+ })
+ .finally(() => {
+ inflight.delete(dir)
+ })
+
+ inflight.set(dir, promise)
+ return promise
+ }
+
+ const expandDir = (input: string) => {
+ const dir = options.normalizeDir(input)
+ ensureDir(dir)
+ setTree("dir", dir, "expanded", true)
+ void listDir(dir)
+ }
+
+ const collapseDir = (input: string) => {
+ const dir = options.normalizeDir(input)
+ ensureDir(dir)
+ setTree("dir", dir, "expanded", false)
+ }
+
+ const dirState = (input: string) => {
+ const dir = options.normalizeDir(input)
+ return tree.dir[dir]
+ }
+
+ const children = (input: string) => {
+ const dir = options.normalizeDir(input)
+ const ids = tree.dir[dir]?.children
+ if (!ids) return []
+ const out: FileNode[] = []
+ for (const id of ids) {
+ const node = tree.node[id]
+ if (node) out.push(node)
+ }
+ return out
+ }
+
+ return {
+ listDir,
+ expandDir,
+ collapseDir,
+ dirState,
+ children,
+ node: (path: string) => tree.node[path],
+ isLoaded: (path: string) => Boolean(tree.dir[path]?.loaded),
+ reset,
+ }
+}
diff --git a/packages/app/src/context/file/types.ts b/packages/app/src/context/file/types.ts
new file mode 100644
index 0000000000..7ce8a37c25
--- /dev/null
+++ b/packages/app/src/context/file/types.ts
@@ -0,0 +1,41 @@
+import type { FileContent } from "@opencode-ai/sdk/v2"
+
+export type FileSelection = {
+ startLine: number
+ startChar: number
+ endLine: number
+ endChar: number
+}
+
+export type SelectedLineRange = {
+ start: number
+ end: number
+ side?: "additions" | "deletions"
+ endSide?: "additions" | "deletions"
+}
+
+export type FileViewState = {
+ scrollTop?: number
+ scrollLeft?: number
+ selectedLines?: SelectedLineRange | null
+}
+
+export type FileState = {
+ path: string
+ name: string
+ loaded?: boolean
+ loading?: boolean
+ error?: string
+ content?: FileContent
+}
+
+export function selectionFromLines(range: SelectedLineRange): FileSelection {
+ const startLine = Math.min(range.start, range.end)
+ const endLine = Math.max(range.start, range.end)
+ return {
+ startLine,
+ endLine,
+ startChar: 0,
+ endChar: 0,
+ }
+}
diff --git a/packages/app/src/context/file/view-cache.ts b/packages/app/src/context/file/view-cache.ts
new file mode 100644
index 0000000000..2614b2fb53
--- /dev/null
+++ b/packages/app/src/context/file/view-cache.ts
@@ -0,0 +1,136 @@
+import { createEffect, createRoot } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { Persist, persisted } from "@/utils/persist"
+import { createScopedCache } from "@/utils/scoped-cache"
+import type { FileViewState, SelectedLineRange } from "./types"
+
+const WORKSPACE_KEY = "__workspace__"
+const MAX_FILE_VIEW_SESSIONS = 20
+const MAX_VIEW_FILES = 500
+
+function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
+ if (range.start <= range.end) return range
+
+ const startSide = range.side
+ const endSide = range.endSide ?? startSide
+
+ return {
+ ...range,
+ start: range.end,
+ end: range.start,
+ side: endSide,
+ endSide: startSide !== endSide ? startSide : undefined,
+ }
+}
+
+function createViewSession(dir: string, id: string | undefined) {
+ const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
+
+ const [view, setView, _, ready] = persisted(
+ Persist.scoped(dir, id, "file-view", [legacyViewKey]),
+ createStore<{
+ file: Record
+ }>({
+ file: {},
+ }),
+ )
+
+ const meta = { pruned: false }
+
+ const pruneView = (keep?: string) => {
+ const keys = Object.keys(view.file)
+ if (keys.length <= MAX_VIEW_FILES) return
+
+ const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
+ if (drop.length === 0) return
+
+ setView(
+ produce((draft) => {
+ for (const key of drop) {
+ delete draft.file[key]
+ }
+ }),
+ )
+ }
+
+ createEffect(() => {
+ if (!ready()) return
+ if (meta.pruned) return
+ meta.pruned = true
+ pruneView()
+ })
+
+ const scrollTop = (path: string) => view.file[path]?.scrollTop
+ const scrollLeft = (path: string) => view.file[path]?.scrollLeft
+ const selectedLines = (path: string) => view.file[path]?.selectedLines
+
+ const setScrollTop = (path: string, top: number) => {
+ setView("file", path, (current) => {
+ if (current?.scrollTop === top) return current
+ return {
+ ...(current ?? {}),
+ scrollTop: top,
+ }
+ })
+ pruneView(path)
+ }
+
+ const setScrollLeft = (path: string, left: number) => {
+ setView("file", path, (current) => {
+ if (current?.scrollLeft === left) return current
+ return {
+ ...(current ?? {}),
+ scrollLeft: left,
+ }
+ })
+ pruneView(path)
+ }
+
+ const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
+ const next = range ? normalizeSelectedLines(range) : null
+ setView("file", path, (current) => {
+ if (current?.selectedLines === next) return current
+ return {
+ ...(current ?? {}),
+ selectedLines: next,
+ }
+ })
+ pruneView(path)
+ }
+
+ return {
+ ready,
+ scrollTop,
+ scrollLeft,
+ selectedLines,
+ setScrollTop,
+ setScrollLeft,
+ setSelectedLines,
+ }
+}
+
+export function createFileViewCache() {
+ const cache = createScopedCache(
+ (key) => {
+ const split = key.lastIndexOf("\n")
+ const dir = split >= 0 ? key.slice(0, split) : key
+ const id = split >= 0 ? key.slice(split + 1) : WORKSPACE_KEY
+ return createRoot((dispose) => ({
+ value: createViewSession(dir, id === WORKSPACE_KEY ? undefined : id),
+ dispose,
+ }))
+ },
+ {
+ maxEntries: MAX_FILE_VIEW_SESSIONS,
+ dispose: (entry) => entry.dispose(),
+ },
+ )
+
+ return {
+ load: (dir: string, id: string | undefined) => {
+ const key = `${dir}\n${id ?? WORKSPACE_KEY}`
+ return cache.get(key).value
+ },
+ clear: () => cache.clear(),
+ }
+}
diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts
new file mode 100644
index 0000000000..9536b52536
--- /dev/null
+++ b/packages/app/src/context/file/watcher.test.ts
@@ -0,0 +1,149 @@
+import { describe, expect, test } from "bun:test"
+import { invalidateFromWatcher } from "./watcher"
+
+describe("file watcher invalidation", () => {
+ test("reloads open files and refreshes loaded parent on add", () => {
+ const loads: string[] = []
+ const refresh: string[] = []
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: "src/new.ts",
+ event: "add",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: (path) => path === "src/new.ts",
+ loadFile: (path) => loads.push(path),
+ node: () => undefined,
+ isDirLoaded: (path) => path === "src",
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ expect(loads).toEqual(["src/new.ts"])
+ 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[] = []
+
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: "src",
+ event: "change",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => false,
+ loadFile: () => {},
+ node: () => ({ path: "src", type: "directory", name: "src", absolute: "/repo/src", ignored: false }),
+ isDirLoaded: (path) => path === "src",
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: "src/file.ts",
+ event: "change",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => false,
+ loadFile: () => {},
+ node: () => ({
+ path: "src/file.ts",
+ type: "file",
+ name: "file.ts",
+ absolute: "/repo/src/file.ts",
+ ignored: false,
+ }),
+ isDirLoaded: () => true,
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ expect(refresh).toEqual(["src"])
+ })
+
+ test("ignores invalid or git watcher updates", () => {
+ const refresh: string[] = []
+
+ invalidateFromWatcher(
+ {
+ type: "file.watcher.updated",
+ properties: {
+ file: ".git/index.lock",
+ event: "change",
+ },
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => true,
+ loadFile: () => {
+ throw new Error("should not load")
+ },
+ node: () => undefined,
+ isDirLoaded: () => true,
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ invalidateFromWatcher(
+ {
+ type: "project.updated",
+ properties: {},
+ },
+ {
+ normalize: (input) => input,
+ hasFile: () => false,
+ loadFile: () => {},
+ node: () => undefined,
+ isDirLoaded: () => true,
+ refreshDir: (path) => refresh.push(path),
+ },
+ )
+
+ expect(refresh).toEqual([])
+ })
+})
diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts
new file mode 100644
index 0000000000..fbf7199279
--- /dev/null
+++ b/packages/app/src/context/file/watcher.ts
@@ -0,0 +1,53 @@
+import type { FileNode } from "@opencode-ai/sdk/v2"
+
+type WatcherEvent = {
+ type: string
+ properties: unknown
+}
+
+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
+ refreshDir: (path: string) => void
+}
+
+export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) {
+ if (event.type !== "file.watcher.updated") return
+ const props =
+ typeof event.properties === "object" && event.properties ? (event.properties as Record) : undefined
+ const rawPath = typeof props?.file === "string" ? props.file : undefined
+ const kind = typeof props?.event === "string" ? props.event : undefined
+ if (!rawPath) return
+ if (!kind) return
+
+ const path = ops.normalize(rawPath)
+ if (!path) return
+ if (path.startsWith(".git/")) return
+
+ if (ops.hasFile(path) || ops.isOpen?.(path)) {
+ ops.loadFile(path)
+ }
+
+ if (kind === "change") {
+ const dir = (() => {
+ if (path === "") return ""
+ const node = ops.node(path)
+ if (node?.type !== "directory") return
+ return path
+ })()
+ if (dir === undefined) return
+ if (!ops.isDirLoaded(dir)) return
+ ops.refreshDir(dir)
+ return
+ }
+ if (kind !== "add" && kind !== "unlink") return
+
+ const parent = path.split("/").slice(0, -1).join("/")
+ if (!ops.isDirLoaded(parent)) return
+
+ ops.refreshDir(parent)
+}
diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx
index 0cd4f6c997..cb610bf6ed 100644
--- a/packages/app/src/context/global-sdk.tsx
+++ b/packages/app/src/context/global-sdk.tsx
@@ -12,10 +12,19 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const platform = usePlatform()
const abort = new AbortController()
+ const auth = (() => {
+ if (typeof window === "undefined") return
+ const password = window.__OPENCODE__?.serverPassword
+ if (!password) return
+ return {
+ Authorization: `Basic ${btoa(`opencode:${password}`)}`,
+ }
+ })()
+
const eventSdk = createOpencodeClient({
baseUrl: server.url,
signal: abort.signal,
- fetch: platform.fetch,
+ headers: auth,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts
new file mode 100644
index 0000000000..396b412318
--- /dev/null
+++ b/packages/app/src/context/global-sync.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, test } from "bun:test"
+import {
+ canDisposeDirectory,
+ estimateRootSessionTotal,
+ loadRootSessionsWithFallback,
+ pickDirectoriesToEvict,
+} from "./global-sync"
+
+describe("pickDirectoriesToEvict", () => {
+ test("keeps pinned stores and evicts idle stores", () => {
+ const now = 5_000
+ const picks = pickDirectoriesToEvict({
+ stores: ["a", "b", "c", "d"],
+ state: new Map([
+ ["a", { lastAccessAt: 1_000 }],
+ ["b", { lastAccessAt: 4_900 }],
+ ["c", { lastAccessAt: 4_800 }],
+ ["d", { lastAccessAt: 3_000 }],
+ ]),
+ pins: new Set(["a"]),
+ max: 2,
+ ttl: 1_500,
+ now,
+ })
+
+ expect(picks).toEqual(["d", "c"])
+ })
+})
+
+describe("loadRootSessionsWithFallback", () => {
+ test("uses limited roots query when supported", async () => {
+ const calls: Array<{ directory: string; roots: true; limit?: number }> = []
+ let fallback = 0
+
+ const result = await loadRootSessionsWithFallback({
+ directory: "dir",
+ limit: 10,
+ list: async (query) => {
+ calls.push(query)
+ return { data: [] }
+ },
+ onFallback: () => {
+ fallback += 1
+ },
+ })
+
+ expect(result.data).toEqual([])
+ expect(result.limited).toBe(true)
+ expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
+ expect(fallback).toBe(0)
+ })
+
+ test("falls back to full roots query on limited-query failure", async () => {
+ const calls: Array<{ directory: string; roots: true; limit?: number }> = []
+ let fallback = 0
+
+ const result = await loadRootSessionsWithFallback({
+ directory: "dir",
+ limit: 25,
+ list: async (query) => {
+ calls.push(query)
+ if (query.limit) throw new Error("unsupported")
+ return { data: [] }
+ },
+ onFallback: () => {
+ fallback += 1
+ },
+ })
+
+ expect(result.data).toEqual([])
+ expect(result.limited).toBe(false)
+ expect(calls).toEqual([
+ { directory: "dir", roots: true, limit: 25 },
+ { directory: "dir", roots: true },
+ ])
+ expect(fallback).toBe(1)
+ })
+})
+
+describe("estimateRootSessionTotal", () => {
+ test("keeps exact total for full fetches", () => {
+ expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
+ })
+
+ test("marks has-more for full-limit limited fetches", () => {
+ expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11)
+ })
+
+ test("keeps exact total when limited fetch is under limit", () => {
+ expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9)
+ })
+})
+
+describe("canDisposeDirectory", () => {
+ test("rejects pinned or inflight directories", () => {
+ expect(
+ canDisposeDirectory({
+ directory: "dir",
+ hasStore: true,
+ pinned: true,
+ booting: false,
+ loadingSessions: false,
+ }),
+ ).toBe(false)
+ expect(
+ canDisposeDirectory({
+ directory: "dir",
+ hasStore: true,
+ pinned: false,
+ booting: true,
+ loadingSessions: false,
+ }),
+ ).toBe(false)
+ expect(
+ canDisposeDirectory({
+ directory: "dir",
+ hasStore: true,
+ pinned: false,
+ booting: false,
+ loadingSessions: true,
+ }),
+ ).toBe(false)
+ })
+
+ test("accepts idle unpinned directory store", () => {
+ expect(
+ canDisposeDirectory({
+ directory: "dir",
+ hasStore: true,
+ pinned: false,
+ booting: false,
+ loadingSessions: false,
+ }),
+ ).toBe(true)
+ })
+})
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index ad3d124b2c..e2bf449807 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -1,40 +1,22 @@
import {
- type Message,
- type Agent,
- type Session,
- type Part,
type Config,
type Path,
type Project,
- type FileDiff,
- type Todo,
- type SessionStatus,
- type ProviderListResponse,
type ProviderAuthResponse,
- type Command,
- type McpStatus,
- type LspStatus,
- type VcsInfo,
- type PermissionRequest,
- type QuestionRequest,
+ type ProviderListResponse,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
-import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
-import { Binary } from "@opencode-ai/util/binary"
-import { retry } from "@opencode-ai/util/retry"
+import { createStore, produce, reconcile } from "solid-js/store"
import { useGlobalSDK } from "./global-sdk"
import type { InitError } from "../pages/error"
import {
- batch,
createContext,
createEffect,
untrack,
getOwner,
- runWithOwner,
useContext,
onCleanup,
onMount,
- type Accessor,
type ParentProps,
Switch,
Match,
@@ -44,89 +26,25 @@ import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
import { useLanguage } from "@/context/language"
import { Persist, persisted } from "@/utils/persist"
+import { createRefreshQueue } from "./global-sync/queue"
+import { createChildStoreManager } from "./global-sync/child-store"
+import { trimSessions } from "./global-sync/session-trim"
+import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
+import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
+import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
+import { sanitizeProject } from "./global-sync/utils"
+import type { ProjectMeta } from "./global-sync/types"
+import { SESSION_RECENT_LIMIT } from "./global-sync/types"
-type ProjectMeta = {
- name?: string
- icon?: {
- override?: string
- color?: string
- }
- commands?: {
- start?: string
- }
-}
-
-type State = {
- status: "loading" | "partial" | "complete"
- agent: Agent[]
- command: Command[]
- project: string
- projectMeta: ProjectMeta | undefined
- icon: string | undefined
- provider: ProviderListResponse
- config: Config
+type GlobalStore = {
+ ready: boolean
+ error?: InitError
path: Path
- session: Session[]
- sessionTotal: number
- session_status: {
- [sessionID: string]: SessionStatus
- }
- session_diff: {
- [sessionID: string]: FileDiff[]
- }
- todo: {
- [sessionID: string]: Todo[]
- }
- permission: {
- [sessionID: string]: PermissionRequest[]
- }
- question: {
- [sessionID: string]: QuestionRequest[]
- }
- mcp: {
- [name: string]: McpStatus
- }
- lsp: LspStatus[]
- vcs: VcsInfo | undefined
- limit: number
- message: {
- [sessionID: string]: Message[]
- }
- part: {
- [messageID: string]: Part[]
- }
-}
-
-type VcsCache = {
- store: Store<{ value: VcsInfo | undefined }>
- setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
- ready: Accessor
-}
-
-type MetaCache = {
- store: Store<{ value: ProjectMeta | undefined }>
- setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
- ready: Accessor
-}
-
-type IconCache = {
- store: Store<{ value: string | undefined }>
- setStore: SetStoreFunction<{ value: string | undefined }>
- ready: Accessor
-}
-
-type ChildOptions = {
- bootstrap?: boolean
-}
-
-function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
- return {
- ...input,
- all: input.all.map((provider) => ({
- ...provider,
- models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
- })),
- }
+ project: Project[]
+ provider: ProviderListResponse
+ provider_auth: ProviderAuthResponse
+ config: Config
+ reload: undefined | "pending" | "complete"
}
function createGlobalSync() {
@@ -135,51 +53,23 @@ function createGlobalSync() {
const language = useLanguage()
const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner")
- const vcsCache = new Map()
- const metaCache = new Map()
- const iconCache = new Map()
+
+ const stats = {
+ evictions: 0,
+ loadSessionsFallback: 0,
+ }
const sdkCache = new Map>()
- const sdkFor = (directory: string) => {
- const cached = sdkCache.get(directory)
- if (cached) return cached
-
- const sdk = createOpencodeClient({
- baseUrl: globalSDK.url,
- fetch: platform.fetch,
- directory,
- throwOnError: true,
- })
- sdkCache.set(directory, sdk)
- return sdk
- }
+ const booting = new Map>()
+ const sessionLoads = new Map>()
+ const sessionMeta = new Map()
const [projectCache, setProjectCache, , projectCacheReady] = persisted(
Persist.global("globalSync.project", ["globalSync.project.v1"]),
createStore({ value: [] as Project[] }),
)
- const sanitizeProject = (project: Project) => {
- if (!project.icon?.url && !project.icon?.override) return project
- return {
- ...project,
- icon: {
- ...project.icon,
- url: undefined,
- override: undefined,
- },
- }
- }
- const [globalStore, setGlobalStore] = createStore<{
- ready: boolean
- error?: InitError
- path: Path
- project: Project[]
- provider: ProviderListResponse
- provider_auth: ProviderAuthResponse
- config: Config
- reload: undefined | "pending" | "complete"
- }>({
+ const [globalStore, setGlobalStore] = createStore({
ready: false,
path: { state: "", config: "", worktree: "", directory: "", home: "" },
project: projectCache.value,
@@ -189,72 +79,61 @@ function createGlobalSync() {
reload: undefined,
})
- const queued = new Set()
- let root = false
- let running = false
- let timer: ReturnType | undefined
+ const updateStats = (activeDirectoryStores: number) => {
+ if (!import.meta.env.DEV) return
+ ;(
+ globalThis as {
+ __OPENCODE_GLOBAL_SYNC_STATS?: {
+ activeDirectoryStores: number
+ evictions: number
+ loadSessionsFullFetchFallback: number
+ }
+ }
+ ).__OPENCODE_GLOBAL_SYNC_STATS = {
+ activeDirectoryStores,
+ evictions: stats.evictions,
+ loadSessionsFullFetchFallback: stats.loadSessionsFallback,
+ }
+ }
const paused = () => untrack(() => globalStore.reload) !== undefined
- const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
+ const queue = createRefreshQueue({
+ paused,
+ bootstrap,
+ bootstrapInstance,
+ })
- const take = (count: number) => {
- if (queued.size === 0) return [] as string[]
- const items: string[] = []
- for (const item of queued) {
- queued.delete(item)
- items.push(item)
- if (items.length >= count) break
- }
- return items
- }
+ const children = createChildStoreManager({
+ owner,
+ markStats: updateStats,
+ incrementEvictions: () => {
+ stats.evictions += 1
+ updateStats(Object.keys(children.children).length)
+ },
+ isBooting: (directory) => booting.has(directory),
+ isLoadingSessions: (directory) => sessionLoads.has(directory),
+ onBootstrap: (directory) => {
+ void bootstrapInstance(directory)
+ },
+ onDispose: (directory) => {
+ queue.clear(directory)
+ sessionMeta.delete(directory)
+ sdkCache.delete(directory)
+ },
+ })
- const schedule = () => {
- if (timer) return
- timer = setTimeout(() => {
- timer = undefined
- void drain()
- }, 0)
- }
-
- const push = (directory: string) => {
- if (!directory) return
- queued.add(directory)
- if (paused()) return
- schedule()
- }
-
- const refresh = () => {
- root = true
- if (paused()) return
- schedule()
- }
-
- async function drain() {
- if (running) return
- running = true
- try {
- while (true) {
- if (paused()) return
-
- if (root) {
- root = false
- await bootstrap()
- await tick()
- continue
- }
-
- const dirs = take(2)
- if (dirs.length === 0) return
-
- await Promise.all(dirs.map((dir) => bootstrapInstance(dir)))
- await tick()
- }
- } finally {
- running = false
- if (paused()) return
- if (root || queued.size) schedule()
- }
+ const sdkFor = (directory: string) => {
+ const cached = sdkCache.get(directory)
+ if (cached) return cached
+ const sdk = createOpencodeClient({
+ baseUrl: globalSDK.url,
+ fetch: platform.fetch,
+ directory,
+ throwOnError: true,
+ })
+ sdkCache.set(directory, sdk)
+ return sdk
}
createEffect(() => {
@@ -278,196 +157,47 @@ function createGlobalSync() {
createEffect(() => {
if (globalStore.reload !== "complete") return
setGlobalStore("reload", undefined)
- refresh()
+ queue.refresh()
})
- const children: Record, SetStoreFunction]> = {}
- const booting = new Map>()
- const sessionLoads = new Map>()
- const sessionMeta = new Map()
-
- const sessionRecentWindow = 4 * 60 * 60 * 1000
- const sessionRecentLimit = 50
-
- function sessionUpdatedAt(session: Session) {
- return session.time.updated ?? session.time.created
- }
-
- function compareSessionRecent(a: Session, b: Session) {
- const aUpdated = sessionUpdatedAt(a)
- const bUpdated = sessionUpdatedAt(b)
- if (aUpdated !== bUpdated) return bUpdated - aUpdated
- return a.id.localeCompare(b.id)
- }
-
- function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
- if (limit <= 0) return [] as Session[]
- const selected: Session[] = []
- const seen = new Set()
- for (const session of sessions) {
- if (!session?.id) continue
- if (seen.has(session.id)) continue
- seen.add(session.id)
-
- if (sessionUpdatedAt(session) <= cutoff) continue
-
- const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
- if (index === -1) selected.push(session)
- if (index !== -1) selected.splice(index, 0, session)
- if (selected.length > limit) selected.pop()
- }
- return selected
- }
-
- function trimSessions(input: Session[], options: { limit: number; permission: Record }) {
- const limit = Math.max(0, options.limit)
- const cutoff = Date.now() - sessionRecentWindow
- const all = input
- .filter((s) => !!s?.id)
- .filter((s) => !s.time?.archived)
- .sort((a, b) => a.id.localeCompare(b.id))
-
- const roots = all.filter((s) => !s.parentID)
- const children = all.filter((s) => !!s.parentID)
-
- const base = roots.slice(0, limit)
- const recent = takeRecentSessions(roots.slice(limit), sessionRecentLimit, cutoff)
- const keepRoots = [...base, ...recent]
-
- const keepRootIds = new Set(keepRoots.map((s) => s.id))
- const keepChildren = children.filter((s) => {
- if (s.parentID && keepRootIds.has(s.parentID)) return true
- const perms = options.permission[s.id] ?? []
- if (perms.length > 0) return true
- return sessionUpdatedAt(s) > cutoff
- })
-
- return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id))
- }
-
- function ensureChild(directory: string) {
- if (!directory) console.error("No directory provided")
- if (!children[directory]) {
- const vcs = runWithOwner(owner, () =>
- persisted(
- Persist.workspace(directory, "vcs", ["vcs.v1"]),
- createStore({ value: undefined as VcsInfo | undefined }),
- ),
- )
- if (!vcs) throw new Error("Failed to create persisted cache")
- const vcsStore = vcs[0]
- const vcsReady = vcs[3]
- vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
-
- const meta = runWithOwner(owner, () =>
- persisted(
- Persist.workspace(directory, "project", ["project.v1"]),
- createStore({ value: undefined as ProjectMeta | undefined }),
- ),
- )
- if (!meta) throw new Error("Failed to create persisted project metadata")
- metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
-
- const icon = runWithOwner(owner, () =>
- persisted(
- Persist.workspace(directory, "icon", ["icon.v1"]),
- createStore({ value: undefined as string | undefined }),
- ),
- )
- if (!icon) throw new Error("Failed to create persisted project icon")
- iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
-
- const init = () => {
- const child = createStore({
- project: "",
- projectMeta: meta[0].value,
- icon: icon[0].value,
- provider: { all: [], connected: [], default: {} },
- config: {},
- path: { state: "", config: "", worktree: "", directory: "", home: "" },
- status: "loading" as const,
- agent: [],
- command: [],
- session: [],
- sessionTotal: 0,
- session_status: {},
- session_diff: {},
- todo: {},
- permission: {},
- question: {},
- mcp: {},
- lsp: [],
- vcs: vcsStore.value,
- limit: 5,
- message: {},
- part: {},
- })
-
- children[directory] = child
-
- createEffect(() => {
- if (!vcsReady()) return
- const cached = vcsStore.value
- if (!cached?.branch) return
- child[1]("vcs", (value) => value ?? cached)
- })
-
- createEffect(() => {
- child[1]("projectMeta", meta[0].value)
- })
-
- createEffect(() => {
- child[1]("icon", icon[0].value)
- })
- }
-
- runWithOwner(owner, init)
- }
- const childStore = children[directory]
- if (!childStore) throw new Error("Failed to create store")
- return childStore
- }
-
- function child(directory: string, options: ChildOptions = {}) {
- const childStore = ensureChild(directory)
- const shouldBootstrap = options.bootstrap ?? true
- if (shouldBootstrap && childStore[0].status === "loading") {
- void bootstrapInstance(directory)
- }
- return childStore
- }
-
async function loadSessions(directory: string) {
const pending = sessionLoads.get(directory)
if (pending) return pending
- const [store, setStore] = child(directory, { bootstrap: false })
+ children.pin(directory)
+ const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, { limit: store.limit, permission: store.permission })
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
}
+ children.unpin(directory)
return
}
- const promise = globalSDK.client.session
- .list({ directory, roots: true })
+ const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
+ const promise = loadRootSessionsWithFallback({
+ directory,
+ limit,
+ list: (query) => globalSDK.client.session.list(query),
+ onFallback: () => {
+ stats.loadSessionsFallback += 1
+ updateStats(Object.keys(children.children).length)
+ },
+ })
.then((x) => {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
- .sort((a, b) => a.id.localeCompare(b.id))
-
- // Read the current limit at resolve-time so callers that bump the limit while
- // a request is in-flight still get the expanded result.
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
const limit = store.limit
-
- const children = store.session.filter((s) => !!s.parentID)
- const sessions = trimSessions([...nonArchived, ...children], { limit, permission: store.permission })
-
- // Store total session count (used for "load more" pagination)
- setStore("sessionTotal", nonArchived.length)
+ const childSessions = store.session.filter((s) => !!s.parentID)
+ const sessions = trimSessions([...nonArchived, ...childSessions], { limit, permission: store.permission })
+ setStore(
+ "sessionTotal",
+ estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
+ )
setStore("session", reconcile(sessions, { key: "id" }))
sessionMeta.set(directory, { limit })
})
@@ -480,6 +210,7 @@ function createGlobalSync() {
sessionLoads.set(directory, promise)
promise.finally(() => {
sessionLoads.delete(directory)
+ children.unpin(directory)
})
return promise
}
@@ -489,563 +220,99 @@ function createGlobalSync() {
const pending = booting.get(directory)
if (pending) return pending
+ children.pin(directory)
const promise = (async () => {
- const [store, setStore] = ensureChild(directory)
- const cache = vcsCache.get(directory)
+ const child = children.ensureChild(directory)
+ const cache = children.vcsCache.get(directory)
if (!cache) return
- const meta = metaCache.get(directory)
- if (!meta) return
const sdk = sdkFor(directory)
-
- setStore("status", "loading")
-
- // projectMeta is synced from persisted storage in ensureChild.
- // vcs is seeded from persisted storage in ensureChild.
-
- const blockingRequests = {
- project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
- provider: () =>
- sdk.provider.list().then((x) => {
- setStore("provider", normalizeProviderList(x.data!))
- }),
- agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
- config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
- }
-
- try {
- await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
- } catch (err) {
- console.error("Failed to bootstrap instance", err)
- const project = getFilename(directory)
- const message = err instanceof Error ? err.message : String(err)
- showToast({ title: `Failed to reload ${project}`, description: message })
- setStore("status", "partial")
- return
- }
-
- if (store.status !== "complete") setStore("status", "partial")
-
- Promise.all([
- sdk.path.get().then((x) => setStore("path", x.data!)),
- sdk.command.list().then((x) => setStore("command", x.data ?? [])),
- sdk.session.status().then((x) => setStore("session_status", x.data!)),
- loadSessions(directory),
- sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
- sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
- sdk.vcs.get().then((x) => {
- const next = x.data ?? store.vcs
- setStore("vcs", next)
- if (next?.branch) cache.setStore("value", next)
- }),
- sdk.permission.list().then((x) => {
- const grouped: Record = {}
- for (const perm of x.data ?? []) {
- if (!perm?.id || !perm.sessionID) continue
- const existing = grouped[perm.sessionID]
- if (existing) {
- existing.push(perm)
- continue
- }
- grouped[perm.sessionID] = [perm]
- }
-
- batch(() => {
- for (const sessionID of Object.keys(store.permission)) {
- if (grouped[sessionID]) continue
- setStore("permission", sessionID, [])
- }
- for (const [sessionID, permissions] of Object.entries(grouped)) {
- setStore(
- "permission",
- sessionID,
- reconcile(
- permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
- { key: "id" },
- ),
- )
- }
- })
- }),
- sdk.question.list().then((x) => {
- const grouped: Record = {}
- for (const question of x.data ?? []) {
- if (!question?.id || !question.sessionID) continue
- const existing = grouped[question.sessionID]
- if (existing) {
- existing.push(question)
- continue
- }
- grouped[question.sessionID] = [question]
- }
-
- batch(() => {
- for (const sessionID of Object.keys(store.question)) {
- if (grouped[sessionID]) continue
- setStore("question", sessionID, [])
- }
- for (const [sessionID, questions] of Object.entries(grouped)) {
- setStore(
- "question",
- sessionID,
- reconcile(
- questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)),
- { key: "id" },
- ),
- )
- }
- })
- }),
- ]).then(() => {
- setStore("status", "complete")
+ await bootstrapDirectory({
+ directory,
+ sdk,
+ store: child[0],
+ setStore: child[1],
+ vcsCache: cache,
+ loadSessions,
})
})()
booting.set(directory, promise)
promise.finally(() => {
booting.delete(directory)
+ children.unpin(directory)
})
return promise
}
- function purgeMessageParts(setStore: SetStoreFunction, messageID: string | undefined) {
- if (!messageID) return
- setStore(
- produce((draft) => {
- delete draft.part[messageID]
- }),
- )
- }
-
- function purgeSessionData(store: Store, setStore: SetStoreFunction, sessionID: string | undefined) {
- if (!sessionID) return
-
- const messages = store.message[sessionID]
- const messageIDs = (messages ?? []).map((m) => m.id).filter((id): id is string => !!id)
-
- setStore(
- produce((draft) => {
- delete draft.message[sessionID]
- delete draft.session_diff[sessionID]
- delete draft.todo[sessionID]
- delete draft.permission[sessionID]
- delete draft.question[sessionID]
- delete draft.session_status[sessionID]
-
- for (const messageID of messageIDs) {
- delete draft.part[messageID]
- }
- }),
- )
- }
-
const unsub = globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
if (directory === "global") {
- switch (event?.type) {
- case "global.disposed": {
- refresh()
- return
- }
- case "project.updated": {
- const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
- if (result.found) {
- setGlobalStore("project", result.index, reconcile(event.properties))
+ applyGlobalEvent({
+ event,
+ project: globalStore.project,
+ refresh: queue.refresh,
+ setGlobalProject(next) {
+ if (typeof next === "function") {
+ setGlobalStore("project", produce(next))
return
}
- setGlobalStore(
- "project",
- produce((draft) => {
- draft.splice(result.index, 0, event.properties)
- }),
- )
- break
- }
- }
+ setGlobalStore("project", next)
+ },
+ })
return
}
- const existing = children[directory]
+ const existing = children.children[directory]
if (!existing) return
-
+ children.mark(directory)
const [store, setStore] = existing
-
- const cleanupSessionCaches = (sessionID: string) => {
- if (!sessionID) return
-
- const hasAny =
- store.message[sessionID] !== undefined ||
- store.session_diff[sessionID] !== undefined ||
- store.todo[sessionID] !== undefined ||
- store.permission[sessionID] !== undefined ||
- store.question[sessionID] !== undefined ||
- store.session_status[sessionID] !== undefined
-
- if (!hasAny) return
-
- setStore(
- produce((draft) => {
- const messages = draft.message[sessionID]
- if (messages) {
- for (const message of messages) {
- const id = message?.id
- if (!id) continue
- delete draft.part[id]
- }
- }
-
- delete draft.message[sessionID]
- delete draft.session_diff[sessionID]
- delete draft.todo[sessionID]
- delete draft.permission[sessionID]
- delete draft.question[sessionID]
- delete draft.session_status[sessionID]
- }),
- )
- }
-
- switch (event.type) {
- case "server.instance.disposed": {
- push(directory)
- return
- }
- case "session.created": {
- const info = event.properties.info
- const result = Binary.search(store.session, info.id, (s) => s.id)
- if (result.found) {
- setStore("session", result.index, reconcile(info))
- break
- }
- const next = store.session.slice()
- next.splice(result.index, 0, info)
- const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission })
- setStore("session", reconcile(trimmed, { key: "id" }))
- if (!info.parentID) {
- setStore("sessionTotal", (value) => value + 1)
- }
- break
- }
- case "session.updated": {
- const info = event.properties.info
- const result = Binary.search(store.session, info.id, (s) => s.id)
- if (info.time.archived) {
- if (result.found) {
- setStore(
- "session",
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- }
- cleanupSessionCaches(info.id)
- if (info.parentID) break
- setStore("sessionTotal", (value) => Math.max(0, value - 1))
- break
- }
- if (result.found) {
- setStore("session", result.index, reconcile(info))
- break
- }
- const next = store.session.slice()
- next.splice(result.index, 0, info)
- const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission })
- setStore("session", reconcile(trimmed, { key: "id" }))
- break
- }
- case "session.deleted": {
- const sessionID = event.properties.info.id
- const result = Binary.search(store.session, sessionID, (s) => s.id)
- if (result.found) {
- setStore(
- "session",
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- }
- cleanupSessionCaches(sessionID)
- if (event.properties.info.parentID) break
- setStore("sessionTotal", (value) => Math.max(0, value - 1))
- break
- }
- case "session.diff":
- setStore("session_diff", event.properties.sessionID, reconcile(event.properties.diff, { key: "file" }))
- break
- case "todo.updated":
- setStore("todo", event.properties.sessionID, reconcile(event.properties.todos, { key: "id" }))
- break
- case "session.status": {
- setStore("session_status", event.properties.sessionID, reconcile(event.properties.status))
- break
- }
- case "message.updated": {
- const messages = store.message[event.properties.info.sessionID]
- if (!messages) {
- setStore("message", event.properties.info.sessionID, [event.properties.info])
- break
- }
- const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
- if (result.found) {
- setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
- break
- }
- setStore(
- "message",
- event.properties.info.sessionID,
- produce((draft) => {
- draft.splice(result.index, 0, event.properties.info)
- }),
- )
- break
- }
- case "message.removed": {
- const sessionID = event.properties.sessionID
- const messageID = event.properties.messageID
-
- setStore(
- produce((draft) => {
- const messages = draft.message[sessionID]
- if (messages) {
- const result = Binary.search(messages, messageID, (m) => m.id)
- if (result.found) {
- messages.splice(result.index, 1)
- }
- }
-
- delete draft.part[messageID]
- }),
- )
- break
- }
- case "message.part.updated": {
- const part = event.properties.part
- const parts = store.part[part.messageID]
- if (!parts) {
- setStore("part", part.messageID, [part])
- break
- }
- const result = Binary.search(parts, part.id, (p) => p.id)
- if (result.found) {
- setStore("part", part.messageID, result.index, reconcile(part))
- break
- }
- setStore(
- "part",
- part.messageID,
- produce((draft) => {
- draft.splice(result.index, 0, part)
- }),
- )
- break
- }
- case "message.part.removed": {
- const messageID = event.properties.messageID
- const parts = store.part[messageID]
- if (!parts) break
- const result = Binary.search(parts, event.properties.partID, (p) => p.id)
- if (result.found) {
- setStore(
- produce((draft) => {
- const list = draft.part[messageID]
- if (!list) return
- const next = Binary.search(list, event.properties.partID, (p) => p.id)
- if (!next.found) return
- list.splice(next.index, 1)
- if (list.length === 0) delete draft.part[messageID]
- }),
- )
- }
- break
- }
- case "vcs.branch.updated": {
- const next = { branch: event.properties.branch }
- setStore("vcs", next)
- const cache = vcsCache.get(directory)
- if (cache) cache.setStore("value", next)
- break
- }
- case "permission.asked": {
- const sessionID = event.properties.sessionID
- const permissions = store.permission[sessionID]
- if (!permissions) {
- setStore("permission", sessionID, [event.properties])
- break
- }
-
- const result = Binary.search(permissions, event.properties.id, (p) => p.id)
- if (result.found) {
- setStore("permission", sessionID, result.index, reconcile(event.properties))
- break
- }
-
- setStore(
- "permission",
- sessionID,
- produce((draft) => {
- draft.splice(result.index, 0, event.properties)
- }),
- )
- break
- }
- case "permission.replied": {
- const permissions = store.permission[event.properties.sessionID]
- if (!permissions) break
- const result = Binary.search(permissions, event.properties.requestID, (p) => p.id)
- if (!result.found) break
- setStore(
- "permission",
- event.properties.sessionID,
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- break
- }
- case "question.asked": {
- const sessionID = event.properties.sessionID
- const questions = store.question[sessionID]
- if (!questions) {
- setStore("question", sessionID, [event.properties])
- break
- }
-
- const result = Binary.search(questions, event.properties.id, (q) => q.id)
- if (result.found) {
- setStore("question", sessionID, result.index, reconcile(event.properties))
- break
- }
-
- setStore(
- "question",
- sessionID,
- produce((draft) => {
- draft.splice(result.index, 0, event.properties)
- }),
- )
- break
- }
- case "question.replied":
- case "question.rejected": {
- const questions = store.question[event.properties.sessionID]
- if (!questions) break
- const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
- if (!result.found) break
- setStore(
- "question",
- event.properties.sessionID,
- produce((draft) => {
- draft.splice(result.index, 1)
- }),
- )
- break
- }
- case "lsp.updated": {
+ applyDirectoryEvent({
+ event,
+ directory,
+ store,
+ setStore,
+ push: queue.push,
+ vcsCache: children.vcsCache.get(directory),
+ loadLsp: () => {
sdkFor(directory)
.lsp.status()
.then((x) => setStore("lsp", x.data ?? []))
- break
- }
- }
+ },
+ })
})
+
onCleanup(unsub)
onCleanup(() => {
- if (!timer) return
- clearTimeout(timer)
+ queue.dispose()
+ })
+ onCleanup(() => {
+ for (const directory of Object.keys(children.children)) {
+ children.disposeDirectory(directory)
+ }
})
async function bootstrap() {
- const health = await globalSDK.client.global
- .health()
- .then((x) => x.data)
- .catch(() => undefined)
- if (!health?.healthy) {
- showToast({
- variant: "error",
- title: language.t("dialog.server.add.error"),
- description: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
- })
- setGlobalStore("ready", true)
- return
- }
-
- const tasks = [
- retry(() =>
- globalSDK.client.path.get().then((x) => {
- setGlobalStore("path", x.data!)
- }),
- ),
- retry(() =>
- globalSDK.client.global.config.get().then((x) => {
- setGlobalStore("config", x.data!)
- }),
- ),
- retry(() =>
- globalSDK.client.project.list().then(async (x) => {
- const projects = (x.data ?? [])
- .filter((p) => !!p?.id)
- .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
- .slice()
- .sort((a, b) => a.id.localeCompare(b.id))
- setGlobalStore("project", projects)
- }),
- ),
- retry(() =>
- globalSDK.client.provider.list().then((x) => {
- setGlobalStore("provider", normalizeProviderList(x.data!))
- }),
- ),
- retry(() =>
- globalSDK.client.provider.auth().then((x) => {
- setGlobalStore("provider_auth", x.data ?? {})
- }),
- ),
- ]
-
- const results = await Promise.allSettled(tasks)
- const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
-
- if (errors.length) {
- const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
- const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: message + more,
- })
- }
-
- setGlobalStore("ready", true)
+ await bootstrapGlobal({
+ globalSDK: globalSDK.client,
+ connectErrorTitle: language.t("dialog.server.add.error"),
+ connectErrorDescription: language.t("error.globalSync.connectFailed", { url: globalSDK.url }),
+ requestFailedTitle: language.t("common.requestFailed"),
+ setGlobalStore,
+ })
}
onMount(() => {
- bootstrap()
+ void bootstrap()
})
function projectMeta(directory: string, patch: ProjectMeta) {
- const [store, setStore] = ensureChild(directory)
- const cached = metaCache.get(directory)
- if (!cached) return
- const previous = store.projectMeta ?? {}
- const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
- const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
- const next = {
- ...previous,
- ...patch,
- icon,
- commands,
- }
- cached.setStore("value", next)
- setStore("projectMeta", next)
+ children.projectMeta(directory, patch)
}
function projectIcon(directory: string, value: string | undefined) {
- const [store, setStore] = ensureChild(directory)
- const cached = iconCache.get(directory)
- if (!cached) return
- if (store.icon === value) return
- cached.setStore("value", value)
- setStore("icon", value)
+ children.projectIcon(directory, value)
}
return {
@@ -1057,7 +324,7 @@ function createGlobalSync() {
get error() {
return globalStore.error
},
- child,
+ child: children.child,
bootstrap,
updateConfig: (config: Config) => {
setGlobalStore("reload", "pending")
@@ -1093,3 +360,6 @@ export function useGlobalSync() {
if (!context) throw new Error("useGlobalSync must be used within GlobalSyncProvider")
return context
}
+
+export { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
+export { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts
new file mode 100644
index 0000000000..2137a19a82
--- /dev/null
+++ b/packages/app/src/context/global-sync/bootstrap.ts
@@ -0,0 +1,195 @@
+import {
+ type Config,
+ type Path,
+ type PermissionRequest,
+ type Project,
+ type ProviderAuthResponse,
+ type ProviderListResponse,
+ type QuestionRequest,
+ createOpencodeClient,
+} from "@opencode-ai/sdk/v2/client"
+import { batch } from "solid-js"
+import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
+import { retry } from "@opencode-ai/util/retry"
+import { getFilename } from "@opencode-ai/util/path"
+import { showToast } from "@opencode-ai/ui/toast"
+import { cmp, normalizeProviderList } from "./utils"
+import type { State, VcsCache } from "./types"
+
+type GlobalStore = {
+ ready: boolean
+ path: Path
+ project: Project[]
+ provider: ProviderListResponse
+ provider_auth: ProviderAuthResponse
+ config: Config
+ reload: undefined | "pending" | "complete"
+}
+
+export async function bootstrapGlobal(input: {
+ globalSDK: ReturnType
+ connectErrorTitle: string
+ connectErrorDescription: string
+ requestFailedTitle: string
+ setGlobalStore: SetStoreFunction
+}) {
+ const health = await input.globalSDK.global
+ .health()
+ .then((x) => x.data)
+ .catch(() => undefined)
+ if (!health?.healthy) {
+ showToast({
+ variant: "error",
+ title: input.connectErrorTitle,
+ description: input.connectErrorDescription,
+ })
+ input.setGlobalStore("ready", true)
+ return
+ }
+
+ const tasks = [
+ retry(() =>
+ input.globalSDK.path.get().then((x) => {
+ input.setGlobalStore("path", x.data!)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.global.config.get().then((x) => {
+ input.setGlobalStore("config", x.data!)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.project.list().then((x) => {
+ const projects = (x.data ?? [])
+ .filter((p) => !!p?.id)
+ .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+ .slice()
+ .sort((a, b) => cmp(a.id, b.id))
+ input.setGlobalStore("project", projects)
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.provider.list().then((x) => {
+ input.setGlobalStore("provider", normalizeProviderList(x.data!))
+ }),
+ ),
+ retry(() =>
+ input.globalSDK.provider.auth().then((x) => {
+ input.setGlobalStore("provider_auth", x.data ?? {})
+ }),
+ ),
+ ]
+
+ const results = await Promise.allSettled(tasks)
+ const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason)
+ if (errors.length) {
+ const message = errors[0] instanceof Error ? errors[0].message : String(errors[0])
+ const more = errors.length > 1 ? ` (+${errors.length - 1} more)` : ""
+ showToast({
+ variant: "error",
+ title: input.requestFailedTitle,
+ description: message + more,
+ })
+ }
+ input.setGlobalStore("ready", true)
+}
+
+function groupBySession(input: T[]) {
+ return input.reduce>((acc, item) => {
+ if (!item?.id || !item.sessionID) return acc
+ const list = acc[item.sessionID]
+ if (list) list.push(item)
+ if (!list) acc[item.sessionID] = [item]
+ return acc
+ }, {})
+}
+
+export async function bootstrapDirectory(input: {
+ directory: string
+ sdk: ReturnType
+ store: Store
+ setStore: SetStoreFunction
+ vcsCache: VcsCache
+ loadSessions: (directory: string) => Promise | void
+}) {
+ input.setStore("status", "loading")
+
+ const blockingRequests = {
+ project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)),
+ provider: () =>
+ input.sdk.provider.list().then((x) => {
+ input.setStore("provider", normalizeProviderList(x.data!))
+ }),
+ agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])),
+ config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)),
+ }
+
+ try {
+ await Promise.all(Object.values(blockingRequests).map((p) => retry(p)))
+ } catch (err) {
+ console.error("Failed to bootstrap instance", err)
+ const project = getFilename(input.directory)
+ const message = err instanceof Error ? err.message : String(err)
+ showToast({ title: `Failed to reload ${project}`, description: message })
+ input.setStore("status", "partial")
+ return
+ }
+
+ if (input.store.status !== "complete") input.setStore("status", "partial")
+
+ Promise.all([
+ input.sdk.path.get().then((x) => input.setStore("path", x.data!)),
+ input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])),
+ input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)),
+ input.loadSessions(input.directory),
+ input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)),
+ input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)),
+ input.sdk.vcs.get().then((x) => {
+ const next = x.data ?? input.store.vcs
+ input.setStore("vcs", next)
+ if (next?.branch) input.vcsCache.setStore("value", next)
+ }),
+ input.sdk.permission.list().then((x) => {
+ const grouped = groupBySession(
+ (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
+ )
+ batch(() => {
+ for (const sessionID of Object.keys(input.store.permission)) {
+ if (grouped[sessionID]) continue
+ input.setStore("permission", sessionID, [])
+ }
+ for (const [sessionID, permissions] of Object.entries(grouped)) {
+ input.setStore(
+ "permission",
+ sessionID,
+ reconcile(
+ permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+ })
+ }),
+ input.sdk.question.list().then((x) => {
+ const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
+ batch(() => {
+ for (const sessionID of Object.keys(input.store.question)) {
+ if (grouped[sessionID]) continue
+ input.setStore("question", sessionID, [])
+ }
+ for (const [sessionID, questions] of Object.entries(grouped)) {
+ input.setStore(
+ "question",
+ sessionID,
+ reconcile(
+ questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
+ { key: "id" },
+ ),
+ )
+ }
+ })
+ }),
+ ]).then(() => {
+ input.setStore("status", "complete")
+ })
+}
diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts
new file mode 100644
index 0000000000..2feb7fe088
--- /dev/null
+++ b/packages/app/src/context/global-sync/child-store.ts
@@ -0,0 +1,263 @@
+import { createRoot, createEffect, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
+import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
+import { Persist, persisted } from "@/utils/persist"
+import type { VcsInfo } from "@opencode-ai/sdk/v2/client"
+import {
+ DIR_IDLE_TTL_MS,
+ MAX_DIR_STORES,
+ type ChildOptions,
+ type DirState,
+ type IconCache,
+ type MetaCache,
+ type ProjectMeta,
+ type State,
+ type VcsCache,
+} from "./types"
+import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
+
+export function createChildStoreManager(input: {
+ owner: Owner
+ markStats: (activeDirectoryStores: number) => void
+ incrementEvictions: () => void
+ isBooting: (directory: string) => boolean
+ isLoadingSessions: (directory: string) => boolean
+ onBootstrap: (directory: string) => void
+ onDispose: (directory: string) => void
+}) {
+ const children: Record, SetStoreFunction]> = {}
+ const vcsCache = new Map()
+ const metaCache = new Map()
+ const iconCache = new Map()
+ const lifecycle = new Map()
+ const pins = new Map()
+ const ownerPins = new WeakMap>()
+ const disposers = new Map void>()
+
+ const mark = (directory: string) => {
+ if (!directory) return
+ lifecycle.set(directory, { lastAccessAt: Date.now() })
+ runEviction()
+ }
+
+ const pin = (directory: string) => {
+ if (!directory) return
+ pins.set(directory, (pins.get(directory) ?? 0) + 1)
+ mark(directory)
+ }
+
+ const unpin = (directory: string) => {
+ if (!directory) return
+ const next = (pins.get(directory) ?? 0) - 1
+ if (next > 0) {
+ pins.set(directory, next)
+ return
+ }
+ pins.delete(directory)
+ runEviction()
+ }
+
+ const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
+
+ const pinForOwner = (directory: string) => {
+ const current = getOwner()
+ if (!current) return
+ if (current === input.owner) return
+ const key = current as object
+ const set = ownerPins.get(key)
+ if (set?.has(directory)) return
+ if (set) set.add(directory)
+ if (!set) ownerPins.set(key, new Set([directory]))
+ pin(directory)
+ onCleanup(() => {
+ const set = ownerPins.get(key)
+ if (set) {
+ set.delete(directory)
+ if (set.size === 0) ownerPins.delete(key)
+ }
+ unpin(directory)
+ })
+ }
+
+ function disposeDirectory(directory: string) {
+ if (
+ !canDisposeDirectory({
+ directory,
+ hasStore: !!children[directory],
+ pinned: pinned(directory),
+ booting: input.isBooting(directory),
+ loadingSessions: input.isLoadingSessions(directory),
+ })
+ ) {
+ return false
+ }
+
+ vcsCache.delete(directory)
+ metaCache.delete(directory)
+ iconCache.delete(directory)
+ lifecycle.delete(directory)
+ const dispose = disposers.get(directory)
+ if (dispose) {
+ dispose()
+ disposers.delete(directory)
+ }
+ delete children[directory]
+ input.onDispose(directory)
+ input.markStats(Object.keys(children).length)
+ return true
+ }
+
+ function runEviction() {
+ const stores = Object.keys(children)
+ if (stores.length === 0) return
+ const list = pickDirectoriesToEvict({
+ stores,
+ state: lifecycle,
+ pins: new Set(stores.filter(pinned)),
+ max: MAX_DIR_STORES,
+ ttl: DIR_IDLE_TTL_MS,
+ now: Date.now(),
+ })
+ if (list.length === 0) return
+ for (const directory of list) {
+ if (!disposeDirectory(directory)) continue
+ input.incrementEvictions()
+ }
+ }
+
+ function ensureChild(directory: string) {
+ if (!directory) console.error("No directory provided")
+ if (!children[directory]) {
+ const vcs = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "vcs", ["vcs.v1"]),
+ createStore({ value: undefined as VcsInfo | undefined }),
+ ),
+ )
+ if (!vcs) throw new Error("Failed to create persisted cache")
+ const vcsStore = vcs[0]
+ const vcsReady = vcs[3]
+ vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcsReady })
+
+ const meta = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "project", ["project.v1"]),
+ createStore({ value: undefined as ProjectMeta | undefined }),
+ ),
+ )
+ if (!meta) throw new Error("Failed to create persisted project metadata")
+ metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
+
+ const icon = runWithOwner(input.owner, () =>
+ persisted(
+ Persist.workspace(directory, "icon", ["icon.v1"]),
+ createStore({ value: undefined as string | undefined }),
+ ),
+ )
+ if (!icon) throw new Error("Failed to create persisted project icon")
+ iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
+
+ const init = () =>
+ createRoot((dispose) => {
+ const child = createStore({
+ project: "",
+ projectMeta: meta[0].value,
+ icon: icon[0].value,
+ provider: { all: [], connected: [], default: {} },
+ config: {},
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
+ status: "loading" as const,
+ agent: [],
+ command: [],
+ session: [],
+ sessionTotal: 0,
+ session_status: {},
+ session_diff: {},
+ todo: {},
+ permission: {},
+ question: {},
+ mcp: {},
+ lsp: [],
+ vcs: vcsStore.value,
+ limit: 5,
+ message: {},
+ part: {},
+ })
+ children[directory] = child
+ disposers.set(directory, dispose)
+
+ createEffect(() => {
+ if (!vcsReady()) return
+ const cached = vcsStore.value
+ if (!cached?.branch) return
+ child[1]("vcs", (value) => value ?? cached)
+ })
+ createEffect(() => {
+ child[1]("projectMeta", meta[0].value)
+ })
+ createEffect(() => {
+ child[1]("icon", icon[0].value)
+ })
+ })
+
+ runWithOwner(input.owner, init)
+ input.markStats(Object.keys(children).length)
+ }
+ mark(directory)
+ const childStore = children[directory]
+ if (!childStore) throw new Error("Failed to create store")
+ return childStore
+ }
+
+ function child(directory: string, options: ChildOptions = {}) {
+ const childStore = ensureChild(directory)
+ pinForOwner(directory)
+ const shouldBootstrap = options.bootstrap ?? true
+ if (shouldBootstrap && childStore[0].status === "loading") {
+ input.onBootstrap(directory)
+ }
+ return childStore
+ }
+
+ function projectMeta(directory: string, patch: ProjectMeta) {
+ const [store, setStore] = ensureChild(directory)
+ const cached = metaCache.get(directory)
+ if (!cached) return
+ const previous = store.projectMeta ?? {}
+ const icon = patch.icon ? { ...(previous.icon ?? {}), ...patch.icon } : previous.icon
+ const commands = patch.commands ? { ...(previous.commands ?? {}), ...patch.commands } : previous.commands
+ const next = {
+ ...previous,
+ ...patch,
+ icon,
+ commands,
+ }
+ cached.setStore("value", next)
+ setStore("projectMeta", next)
+ }
+
+ function projectIcon(directory: string, value: string | undefined) {
+ const [store, setStore] = ensureChild(directory)
+ const cached = iconCache.get(directory)
+ if (!cached) return
+ if (store.icon === value) return
+ cached.setStore("value", value)
+ setStore("icon", value)
+ }
+
+ return {
+ children,
+ ensureChild,
+ child,
+ projectMeta,
+ projectIcon,
+ mark,
+ pin,
+ unpin,
+ pinned,
+ disposeDirectory,
+ runEviction,
+ vcsCache,
+ metaCache,
+ iconCache,
+ }
+}
diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts
new file mode 100644
index 0000000000..ad63f3c202
--- /dev/null
+++ b/packages/app/src/context/global-sync/event-reducer.test.ts
@@ -0,0 +1,482 @@
+import { describe, expect, test } from "bun:test"
+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"
+
+const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
+ ({
+ id: input.id,
+ parentID: input.parentID,
+ time: {
+ created: 1,
+ updated: 1,
+ archived: input.archived,
+ },
+ }) as Session
+
+const userMessage = (id: string, sessionID: string) =>
+ ({
+ id,
+ sessionID,
+ role: "user",
+ time: { created: 1 },
+ agent: "assistant",
+ model: { providerID: "openai", modelID: "gpt" },
+ }) as Message
+
+const textPart = (id: string, sessionID: string, messageID: string) =>
+ ({
+ id,
+ sessionID,
+ messageID,
+ type: "text",
+ 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",
+ agent: [],
+ command: [],
+ project: "",
+ projectMeta: undefined,
+ icon: undefined,
+ provider: {} as State["provider"],
+ config: {} as State["config"],
+ path: { directory: "/tmp" } as State["path"],
+ session: [],
+ sessionTotal: 0,
+ session_status: {},
+ session_diff: {},
+ todo: {},
+ permission: {},
+ question: {},
+ mcp: {},
+ lsp: [],
+ vcs: undefined,
+ limit: 10,
+ message: {},
+ part: {},
+ ...input,
+ }) as State
+
+describe("applyGlobalEvent", () => {
+ test("upserts project.updated in sorted position", () => {
+ const project = [{ id: "a" }, { id: "c" }] as Project[]
+ let refreshCount = 0
+ applyGlobalEvent({
+ event: { type: "project.updated", properties: { id: "b" } },
+ project,
+ refresh: () => {
+ refreshCount += 1
+ },
+ setGlobalProject(next) {
+ if (typeof next === "function") next(project)
+ },
+ })
+
+ expect(project.map((x) => x.id)).toEqual(["a", "b", "c"])
+ expect(refreshCount).toBe(0)
+ })
+
+ test("handles global.disposed by triggering refresh", () => {
+ let refreshCount = 0
+ applyGlobalEvent({
+ event: { type: "global.disposed" },
+ project: [],
+ refresh: () => {
+ refreshCount += 1
+ },
+ setGlobalProject() {},
+ })
+
+ expect(refreshCount).toBe(1)
+ })
+})
+
+describe("applyDirectoryEvent", () => {
+ test("inserts root sessions in sorted order and updates sessionTotal", () => {
+ const [store, setStore] = createStore(
+ baseState({
+ session: [rootSession({ id: "b" })],
+ sessionTotal: 1,
+ }),
+ )
+
+ applyDirectoryEvent({
+ event: { type: "session.created", properties: { info: rootSession({ id: "a" }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.session.map((x) => x.id)).toEqual(["a", "b"])
+ expect(store.sessionTotal).toBe(2)
+
+ applyDirectoryEvent({
+ event: { type: "session.created", properties: { info: rootSession({ id: "c", parentID: "a" }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.sessionTotal).toBe(2)
+ })
+
+ test("cleans session caches when archived", () => {
+ const message = userMessage("msg_1", "ses_1")
+ const [store, setStore] = createStore(
+ baseState({
+ session: [rootSession({ id: "ses_1" }), rootSession({ id: "ses_2" })],
+ sessionTotal: 2,
+ message: { ses_1: [message] },
+ part: { [message.id]: [textPart("prt_1", "ses_1", message.id)] },
+ session_diff: { ses_1: [] },
+ todo: { ses_1: [] },
+ permission: { ses_1: [] },
+ question: { ses_1: [] },
+ session_status: { ses_1: { type: "busy" } },
+ }),
+ )
+
+ applyDirectoryEvent({
+ event: { type: "session.updated", properties: { info: rootSession({ id: "ses_1", archived: 10 }) } },
+ store,
+ setStore,
+ push() {},
+ directory: "/tmp",
+ loadLsp() {},
+ })
+
+ expect(store.session.map((x) => x.id)).toEqual(["ses_2"])
+ expect(store.sessionTotal).toBe(1)
+ expect(store.message.ses_1).toBeUndefined()
+ expect(store.part[message.id]).toBeUndefined()
+ expect(store.session_diff.ses_1).toBeUndefined()
+ expect(store.todo.ses_1).toBeUndefined()
+ expect(store.permission.ses_1).toBeUndefined()
+ expect(store.question.ses_1).toBeUndefined()
+ 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[] = []
+ let lspLoads = 0
+
+ applyDirectoryEvent({
+ event: { type: "server.instance.disposed" },
+ store,
+ setStore,
+ push(directory) {
+ pushes.push(directory)
+ },
+ directory: "/tmp",
+ loadLsp() {
+ lspLoads += 1
+ },
+ })
+
+ applyDirectoryEvent({
+ event: { type: "lsp.updated" },
+ store,
+ setStore,
+ push(directory) {
+ pushes.push(directory)
+ },
+ directory: "/tmp",
+ loadLsp() {
+ lspLoads += 1
+ },
+ })
+
+ expect(pushes).toEqual(["/tmp"])
+ expect(lspLoads).toBe(1)
+ })
+})
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
new file mode 100644
index 0000000000..c658d82c8b
--- /dev/null
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -0,0 +1,319 @@
+import { Binary } from "@opencode-ai/util/binary"
+import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
+import type {
+ FileDiff,
+ Message,
+ Part,
+ PermissionRequest,
+ Project,
+ QuestionRequest,
+ Session,
+ SessionStatus,
+ Todo,
+} from "@opencode-ai/sdk/v2/client"
+import type { State, VcsCache } from "./types"
+import { trimSessions } from "./session-trim"
+
+export function applyGlobalEvent(input: {
+ event: { type: string; properties?: unknown }
+ project: Project[]
+ setGlobalProject: (next: Project[] | ((draft: Project[]) => void)) => void
+ refresh: () => void
+}) {
+ if (input.event.type === "global.disposed") {
+ input.refresh()
+ return
+ }
+
+ if (input.event.type !== "project.updated") return
+ const properties = input.event.properties as Project
+ const result = Binary.search(input.project, properties.id, (s) => s.id)
+ if (result.found) {
+ input.setGlobalProject((draft) => {
+ draft[result.index] = { ...draft[result.index], ...properties }
+ })
+ return
+ }
+ input.setGlobalProject((draft) => {
+ draft.splice(result.index, 0, properties)
+ })
+}
+
+function cleanupSessionCaches(store: Store, setStore: SetStoreFunction, sessionID: string) {
+ if (!sessionID) return
+ const hasAny =
+ store.message[sessionID] !== undefined ||
+ store.session_diff[sessionID] !== undefined ||
+ store.todo[sessionID] !== undefined ||
+ store.permission[sessionID] !== undefined ||
+ store.question[sessionID] !== undefined ||
+ store.session_status[sessionID] !== undefined
+ if (!hasAny) return
+ setStore(
+ produce((draft) => {
+ const messages = draft.message[sessionID]
+ if (messages) {
+ for (const message of messages) {
+ const id = message?.id
+ if (!id) continue
+ delete draft.part[id]
+ }
+ }
+ delete draft.message[sessionID]
+ delete draft.session_diff[sessionID]
+ delete draft.todo[sessionID]
+ delete draft.permission[sessionID]
+ delete draft.question[sessionID]
+ delete draft.session_status[sessionID]
+ }),
+ )
+}
+
+export function applyDirectoryEvent(input: {
+ event: { type: string; properties?: unknown }
+ store: Store
+ setStore: SetStoreFunction
+ push: (directory: string) => void
+ directory: string
+ loadLsp: () => void
+ vcsCache?: VcsCache
+}) {
+ const event = input.event
+ switch (event.type) {
+ case "server.instance.disposed": {
+ input.push(input.directory)
+ return
+ }
+ case "session.created": {
+ const info = (event.properties as { info: Session }).info
+ const result = Binary.search(input.store.session, info.id, (s) => s.id)
+ if (result.found) {
+ input.setStore("session", result.index, reconcile(info))
+ break
+ }
+ const next = input.store.session.slice()
+ next.splice(result.index, 0, info)
+ const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
+ input.setStore("session", reconcile(trimmed, { key: "id" }))
+ if (!info.parentID) input.setStore("sessionTotal", (value) => value + 1)
+ break
+ }
+ case "session.updated": {
+ const info = (event.properties as { info: Session }).info
+ const result = Binary.search(input.store.session, info.id, (s) => s.id)
+ if (info.time.archived) {
+ if (result.found) {
+ input.setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ cleanupSessionCaches(input.store, input.setStore, info.id)
+ if (info.parentID) break
+ input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+ break
+ }
+ if (result.found) {
+ input.setStore("session", result.index, reconcile(info))
+ break
+ }
+ const next = input.store.session.slice()
+ next.splice(result.index, 0, info)
+ const trimmed = trimSessions(next, { limit: input.store.limit, permission: input.store.permission })
+ input.setStore("session", reconcile(trimmed, { key: "id" }))
+ break
+ }
+ case "session.deleted": {
+ const info = (event.properties as { info: Session }).info
+ const result = Binary.search(input.store.session, info.id, (s) => s.id)
+ if (result.found) {
+ input.setStore(
+ "session",
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ }
+ cleanupSessionCaches(input.store, input.setStore, info.id)
+ if (info.parentID) break
+ input.setStore("sessionTotal", (value) => Math.max(0, value - 1))
+ break
+ }
+ case "session.diff": {
+ const props = event.properties as { sessionID: string; diff: FileDiff[] }
+ input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
+ break
+ }
+ case "todo.updated": {
+ const props = event.properties as { sessionID: string; todos: Todo[] }
+ input.setStore("todo", props.sessionID, reconcile(props.todos, { key: "id" }))
+ break
+ }
+ case "session.status": {
+ const props = event.properties as { sessionID: string; status: SessionStatus }
+ input.setStore("session_status", props.sessionID, reconcile(props.status))
+ break
+ }
+ case "message.updated": {
+ const info = (event.properties as { info: Message }).info
+ const messages = input.store.message[info.sessionID]
+ if (!messages) {
+ input.setStore("message", info.sessionID, [info])
+ break
+ }
+ const result = Binary.search(messages, info.id, (m) => m.id)
+ if (result.found) {
+ input.setStore("message", info.sessionID, result.index, reconcile(info))
+ break
+ }
+ input.setStore(
+ "message",
+ info.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, info)
+ }),
+ )
+ break
+ }
+ case "message.removed": {
+ const props = event.properties as { sessionID: string; messageID: string }
+ input.setStore(
+ produce((draft) => {
+ const messages = draft.message[props.sessionID]
+ if (messages) {
+ const result = Binary.search(messages, props.messageID, (m) => m.id)
+ if (result.found) messages.splice(result.index, 1)
+ }
+ delete draft.part[props.messageID]
+ }),
+ )
+ break
+ }
+ case "message.part.updated": {
+ const part = (event.properties as { part: Part }).part
+ const parts = input.store.part[part.messageID]
+ if (!parts) {
+ input.setStore("part", part.messageID, [part])
+ break
+ }
+ const result = Binary.search(parts, part.id, (p) => p.id)
+ if (result.found) {
+ input.setStore("part", part.messageID, result.index, reconcile(part))
+ break
+ }
+ input.setStore(
+ "part",
+ part.messageID,
+ produce((draft) => {
+ draft.splice(result.index, 0, part)
+ }),
+ )
+ break
+ }
+ case "message.part.removed": {
+ const props = event.properties as { messageID: string; partID: string }
+ const parts = input.store.part[props.messageID]
+ if (!parts) break
+ const result = Binary.search(parts, props.partID, (p) => p.id)
+ if (result.found) {
+ input.setStore(
+ produce((draft) => {
+ const list = draft.part[props.messageID]
+ if (!list) return
+ const next = Binary.search(list, props.partID, (p) => p.id)
+ if (!next.found) return
+ list.splice(next.index, 1)
+ if (list.length === 0) delete draft.part[props.messageID]
+ }),
+ )
+ }
+ break
+ }
+ case "vcs.branch.updated": {
+ const props = event.properties as { branch: string }
+ const next = { branch: props.branch }
+ input.setStore("vcs", next)
+ if (input.vcsCache) input.vcsCache.setStore("value", next)
+ break
+ }
+ case "permission.asked": {
+ const permission = event.properties as PermissionRequest
+ const permissions = input.store.permission[permission.sessionID]
+ if (!permissions) {
+ input.setStore("permission", permission.sessionID, [permission])
+ break
+ }
+ const result = Binary.search(permissions, permission.id, (p) => p.id)
+ if (result.found) {
+ input.setStore("permission", permission.sessionID, result.index, reconcile(permission))
+ break
+ }
+ input.setStore(
+ "permission",
+ permission.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, permission)
+ }),
+ )
+ break
+ }
+ case "permission.replied": {
+ const props = event.properties as { sessionID: string; requestID: string }
+ const permissions = input.store.permission[props.sessionID]
+ if (!permissions) break
+ const result = Binary.search(permissions, props.requestID, (p) => p.id)
+ if (!result.found) break
+ input.setStore(
+ "permission",
+ props.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
+ case "question.asked": {
+ const question = event.properties as QuestionRequest
+ const questions = input.store.question[question.sessionID]
+ if (!questions) {
+ input.setStore("question", question.sessionID, [question])
+ break
+ }
+ const result = Binary.search(questions, question.id, (q) => q.id)
+ if (result.found) {
+ input.setStore("question", question.sessionID, result.index, reconcile(question))
+ break
+ }
+ input.setStore(
+ "question",
+ question.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 0, question)
+ }),
+ )
+ break
+ }
+ case "question.replied":
+ case "question.rejected": {
+ const props = event.properties as { sessionID: string; requestID: string }
+ const questions = input.store.question[props.sessionID]
+ if (!questions) break
+ const result = Binary.search(questions, props.requestID, (q) => q.id)
+ if (!result.found) break
+ input.setStore(
+ "question",
+ props.sessionID,
+ produce((draft) => {
+ draft.splice(result.index, 1)
+ }),
+ )
+ break
+ }
+ case "lsp.updated": {
+ input.loadLsp()
+ break
+ }
+ }
+}
diff --git a/packages/app/src/context/global-sync/eviction.ts b/packages/app/src/context/global-sync/eviction.ts
new file mode 100644
index 0000000000..676a6ee17e
--- /dev/null
+++ b/packages/app/src/context/global-sync/eviction.ts
@@ -0,0 +1,28 @@
+import type { DisposeCheck, EvictPlan } from "./types"
+
+export function pickDirectoriesToEvict(input: EvictPlan) {
+ const overflow = Math.max(0, input.stores.length - input.max)
+ let pendingOverflow = overflow
+ const sorted = input.stores
+ .filter((dir) => !input.pins.has(dir))
+ .slice()
+ .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
+ const output: string[] = []
+ for (const dir of sorted) {
+ const last = input.state.get(dir)?.lastAccessAt ?? 0
+ const idle = input.now - last >= input.ttl
+ if (!idle && pendingOverflow <= 0) continue
+ output.push(dir)
+ if (pendingOverflow > 0) pendingOverflow -= 1
+ }
+ return output
+}
+
+export function canDisposeDirectory(input: DisposeCheck) {
+ if (!input.directory) return false
+ if (!input.hasStore) return false
+ if (input.pinned) return false
+ if (input.booting) return false
+ if (input.loadingSessions) return false
+ return true
+}
diff --git a/packages/app/src/context/global-sync/queue.ts b/packages/app/src/context/global-sync/queue.ts
new file mode 100644
index 0000000000..c3468583b9
--- /dev/null
+++ b/packages/app/src/context/global-sync/queue.ts
@@ -0,0 +1,83 @@
+type QueueInput = {
+ paused: () => boolean
+ bootstrap: () => Promise
+ bootstrapInstance: (directory: string) => Promise | void
+}
+
+export function createRefreshQueue(input: QueueInput) {
+ const queued = new Set()
+ let root = false
+ let running = false
+ let timer: ReturnType | undefined
+
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
+
+ const take = (count: number) => {
+ if (queued.size === 0) return [] as string[]
+ const items: string[] = []
+ for (const item of queued) {
+ queued.delete(item)
+ items.push(item)
+ if (items.length >= count) break
+ }
+ return items
+ }
+
+ const schedule = () => {
+ if (timer) return
+ timer = setTimeout(() => {
+ timer = undefined
+ void drain()
+ }, 0)
+ }
+
+ const push = (directory: string) => {
+ if (!directory) return
+ queued.add(directory)
+ if (input.paused()) return
+ schedule()
+ }
+
+ const refresh = () => {
+ root = true
+ if (input.paused()) return
+ schedule()
+ }
+
+ async function drain() {
+ if (running) return
+ running = true
+ try {
+ while (true) {
+ if (input.paused()) return
+ if (root) {
+ root = false
+ await input.bootstrap()
+ await tick()
+ continue
+ }
+ const dirs = take(2)
+ if (dirs.length === 0) return
+ await Promise.all(dirs.map((dir) => input.bootstrapInstance(dir)))
+ await tick()
+ }
+ } finally {
+ running = false
+ if (input.paused()) return
+ if (root || queued.size) schedule()
+ }
+ }
+
+ return {
+ push,
+ refresh,
+ clear(directory: string) {
+ queued.delete(directory)
+ },
+ dispose() {
+ if (!timer) return
+ clearTimeout(timer)
+ timer = undefined
+ },
+ }
+}
diff --git a/packages/app/src/context/global-sync/session-load.ts b/packages/app/src/context/global-sync/session-load.ts
new file mode 100644
index 0000000000..443aa84502
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-load.ts
@@ -0,0 +1,26 @@
+import type { RootLoadArgs } from "./types"
+
+export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
+ try {
+ const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
+ return {
+ data: result.data,
+ limit: input.limit,
+ limited: true,
+ } as const
+ } catch {
+ input.onFallback()
+ const result = await input.list({ directory: input.directory, roots: true })
+ return {
+ data: result.data,
+ limit: input.limit,
+ limited: false,
+ } as const
+ }
+}
+
+export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
+ if (!input.limited) return input.count
+ if (input.count < input.limit) return input.count
+ return input.count + 1
+}
diff --git a/packages/app/src/context/global-sync/session-trim.test.ts b/packages/app/src/context/global-sync/session-trim.test.ts
new file mode 100644
index 0000000000..be12c074b5
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-trim.test.ts
@@ -0,0 +1,59 @@
+import { describe, expect, test } from "bun:test"
+import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { trimSessions } from "./session-trim"
+
+const session = (input: { id: string; parentID?: string; created: number; updated?: number; archived?: number }) =>
+ ({
+ id: input.id,
+ parentID: input.parentID,
+ time: {
+ created: input.created,
+ updated: input.updated,
+ archived: input.archived,
+ },
+ }) as Session
+
+describe("trimSessions", () => {
+ test("keeps base roots and recent roots beyond the limit", () => {
+ const now = 1_000_000
+ const list = [
+ session({ id: "a", created: now - 100_000 }),
+ session({ id: "b", created: now - 90_000 }),
+ session({ id: "c", created: now - 80_000 }),
+ session({ id: "d", created: now - 70_000, updated: now - 1_000 }),
+ session({ id: "e", created: now - 60_000, archived: now - 10 }),
+ ]
+
+ const result = trimSessions(list, { limit: 2, permission: {}, now })
+ expect(result.map((x) => x.id)).toEqual(["a", "b", "c", "d"])
+ })
+
+ test("keeps children when root is kept, permission exists, or child is recent", () => {
+ const now = 1_000_000
+ const list = [
+ session({ id: "root-1", created: now - 1000 }),
+ session({ id: "root-2", created: now - 2000 }),
+ session({ id: "z-root", created: now - 30_000_000 }),
+ session({ id: "child-kept-by-root", parentID: "root-1", created: now - 20_000_000 }),
+ session({ id: "child-kept-by-permission", parentID: "z-root", created: now - 20_000_000 }),
+ session({ id: "child-kept-by-recency", parentID: "z-root", created: now - 500 }),
+ session({ id: "child-trimmed", parentID: "z-root", created: now - 20_000_000 }),
+ ]
+
+ const result = trimSessions(list, {
+ limit: 2,
+ permission: {
+ "child-kept-by-permission": [{ id: "perm-1" } as PermissionRequest],
+ },
+ now,
+ })
+
+ expect(result.map((x) => x.id)).toEqual([
+ "child-kept-by-permission",
+ "child-kept-by-recency",
+ "child-kept-by-root",
+ "root-1",
+ "root-2",
+ ])
+ })
+})
diff --git a/packages/app/src/context/global-sync/session-trim.ts b/packages/app/src/context/global-sync/session-trim.ts
new file mode 100644
index 0000000000..800ba74a68
--- /dev/null
+++ b/packages/app/src/context/global-sync/session-trim.ts
@@ -0,0 +1,56 @@
+import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
+import { cmp } from "./utils"
+import { SESSION_RECENT_LIMIT, SESSION_RECENT_WINDOW } from "./types"
+
+export function sessionUpdatedAt(session: Session) {
+ return session.time.updated ?? session.time.created
+}
+
+export function compareSessionRecent(a: Session, b: Session) {
+ const aUpdated = sessionUpdatedAt(a)
+ const bUpdated = sessionUpdatedAt(b)
+ if (aUpdated !== bUpdated) return bUpdated - aUpdated
+ return cmp(a.id, b.id)
+}
+
+export function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
+ if (limit <= 0) return [] as Session[]
+ const selected: Session[] = []
+ const seen = new Set()
+ for (const session of sessions) {
+ if (!session?.id) continue
+ if (seen.has(session.id)) continue
+ seen.add(session.id)
+ if (sessionUpdatedAt(session) <= cutoff) continue
+ const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0)
+ if (index === -1) selected.push(session)
+ if (index !== -1) selected.splice(index, 0, session)
+ if (selected.length > limit) selected.pop()
+ }
+ return selected
+}
+
+export function trimSessions(
+ input: Session[],
+ options: { limit: number; permission: Record; now?: number },
+) {
+ const limit = Math.max(0, options.limit)
+ const cutoff = (options.now ?? Date.now()) - SESSION_RECENT_WINDOW
+ const all = input
+ .filter((s) => !!s?.id)
+ .filter((s) => !s.time?.archived)
+ .sort((a, b) => cmp(a.id, b.id))
+ const roots = all.filter((s) => !s.parentID)
+ const children = all.filter((s) => !!s.parentID)
+ const base = roots.slice(0, limit)
+ const recent = takeRecentSessions(roots.slice(limit), SESSION_RECENT_LIMIT, cutoff)
+ const keepRoots = [...base, ...recent]
+ const keepRootIds = new Set(keepRoots.map((s) => s.id))
+ const keepChildren = children.filter((s) => {
+ if (s.parentID && keepRootIds.has(s.parentID)) return true
+ const perms = options.permission[s.id] ?? []
+ if (perms.length > 0) return true
+ return sessionUpdatedAt(s) > cutoff
+ })
+ return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
+}
diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts
new file mode 100644
index 0000000000..ade0b973a2
--- /dev/null
+++ b/packages/app/src/context/global-sync/types.ts
@@ -0,0 +1,134 @@
+import type {
+ Agent,
+ Command,
+ Config,
+ FileDiff,
+ LspStatus,
+ McpStatus,
+ Message,
+ Part,
+ Path,
+ PermissionRequest,
+ Project,
+ ProviderListResponse,
+ QuestionRequest,
+ Session,
+ SessionStatus,
+ Todo,
+ VcsInfo,
+} from "@opencode-ai/sdk/v2/client"
+import type { Accessor } from "solid-js"
+import type { SetStoreFunction, Store } from "solid-js/store"
+
+export type ProjectMeta = {
+ name?: string
+ icon?: {
+ override?: string
+ color?: string
+ }
+ commands?: {
+ start?: string
+ }
+}
+
+export type State = {
+ status: "loading" | "partial" | "complete"
+ agent: Agent[]
+ command: Command[]
+ project: string
+ projectMeta: ProjectMeta | undefined
+ icon: string | undefined
+ provider: ProviderListResponse
+ config: Config
+ path: Path
+ session: Session[]
+ sessionTotal: number
+ session_status: {
+ [sessionID: string]: SessionStatus
+ }
+ session_diff: {
+ [sessionID: string]: FileDiff[]
+ }
+ todo: {
+ [sessionID: string]: Todo[]
+ }
+ permission: {
+ [sessionID: string]: PermissionRequest[]
+ }
+ question: {
+ [sessionID: string]: QuestionRequest[]
+ }
+ mcp: {
+ [name: string]: McpStatus
+ }
+ lsp: LspStatus[]
+ vcs: VcsInfo | undefined
+ limit: number
+ message: {
+ [sessionID: string]: Message[]
+ }
+ part: {
+ [messageID: string]: Part[]
+ }
+}
+
+export type VcsCache = {
+ store: Store<{ value: VcsInfo | undefined }>
+ setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
+ ready: Accessor
+}
+
+export type MetaCache = {
+ store: Store<{ value: ProjectMeta | undefined }>
+ setStore: SetStoreFunction<{ value: ProjectMeta | undefined }>
+ ready: Accessor
+}
+
+export type IconCache = {
+ store: Store<{ value: string | undefined }>
+ setStore: SetStoreFunction<{ value: string | undefined }>
+ ready: Accessor
+}
+
+export type ChildOptions = {
+ bootstrap?: boolean
+}
+
+export type DirState = {
+ lastAccessAt: number
+}
+
+export type EvictPlan = {
+ stores: string[]
+ state: Map
+ pins: Set
+ max: number
+ ttl: number
+ now: number
+}
+
+export type DisposeCheck = {
+ directory: string
+ hasStore: boolean
+ pinned: boolean
+ booting: boolean
+ loadingSessions: boolean
+}
+
+export type RootLoadArgs = {
+ directory: string
+ limit: number
+ list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
+ onFallback: () => void
+}
+
+export type RootLoadResult = {
+ data?: Session[]
+ limit: number
+ limited: boolean
+}
+
+export const MAX_DIR_STORES = 30
+export const DIR_IDLE_TTL_MS = 20 * 60 * 1000
+export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000
+export const SESSION_RECENT_LIMIT = 50
diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts
new file mode 100644
index 0000000000..6b78134a61
--- /dev/null
+++ b/packages/app/src/context/global-sync/utils.ts
@@ -0,0 +1,25 @@
+import type { Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
+
+export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+
+export function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
+ return {
+ ...input,
+ all: input.all.map((provider) => ({
+ ...provider,
+ models: Object.fromEntries(Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated")),
+ })),
+ }
+}
+
+export function sanitizeProject(project: Project) {
+ if (!project.icon?.url && !project.icon?.override) return project
+ return {
+ ...project,
+ icon: {
+ ...project.icon,
+ url: undefined,
+ override: undefined,
+ },
+ }
+}
diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx
index 1b93c9b051..22f7bcca1e 100644
--- a/packages/app/src/context/language.tsx
+++ b/packages/app/src/context/language.tsx
@@ -18,6 +18,7 @@ import { dict as ar } from "@/i18n/ar"
import { dict as no } from "@/i18n/no"
import { dict as br } from "@/i18n/br"
import { dict as th } from "@/i18n/th"
+import { dict as bs } from "@/i18n/bs"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
@@ -33,6 +34,7 @@ import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
+import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
export type Locale =
| "en"
@@ -50,6 +52,7 @@ export type Locale =
| "no"
| "br"
| "th"
+ | "bs"
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten
@@ -66,12 +69,33 @@ const LOCALES: readonly Locale[] = [
"ja",
"pl",
"ru",
+ "bs",
"ar",
"no",
"br",
"th",
]
+type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen"
+const PARITY_CHECK: Record, Record> = {
+ zh,
+ zht,
+ ko,
+ de,
+ es,
+ fr,
+ da,
+ ja,
+ pl,
+ ru,
+ ar,
+ no,
+ br,
+ th,
+ bs,
+}
+void PARITY_CHECK
+
function detectLocale(): Locale {
if (typeof navigator !== "object") return "en"
@@ -99,6 +123,7 @@ function detectLocale(): Locale {
return "no"
if (language.toLowerCase().startsWith("pt")) return "br"
if (language.toLowerCase().startsWith("th")) return "th"
+ if (language.toLowerCase().startsWith("bs")) return "bs"
}
return "en"
@@ -129,6 +154,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
if (store.locale === "no") return "no"
if (store.locale === "br") return "br"
if (store.locale === "th") return "th"
+ if (store.locale === "bs") return "bs"
return "en"
})
@@ -154,6 +180,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) }
if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) }
if (locale() === "th") return { ...base, ...i18n.flatten({ ...th, ...uiTh }) }
+ if (locale() === "bs") return { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }
return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }
})
@@ -175,6 +202,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
no: "language.no",
br: "language.br",
th: "language.th",
+ bs: "language.bs",
}
const label = (value: Locale) => t(labelKey[value])
diff --git a/packages/app/src/context/layout-scroll.test.ts b/packages/app/src/context/layout-scroll.test.ts
index c565653850..2a13e40204 100644
--- a/packages/app/src/context/layout-scroll.test.ts
+++ b/packages/app/src/context/layout-scroll.test.ts
@@ -1,73 +1,44 @@
-import { describe, expect, test } from "bun:test"
-import { createRoot } from "solid-js"
-import { createStore } from "solid-js/store"
-import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
+import { describe, expect, test, vi } from "bun:test"
import { createScrollPersistence } from "./layout-scroll"
describe("createScrollPersistence", () => {
- test.skip("debounces persisted scroll writes", async () => {
- const key = "layout-scroll.test"
- const data = new Map()
- const writes: string[] = []
- const stats = { flushes: 0 }
-
- const storage = {
- getItem: (k: string) => data.get(k) ?? null,
- setItem: (k: string, v: string) => {
- data.set(k, v)
- if (k === key) writes.push(v)
- },
- removeItem: (k: string) => {
- data.delete(k)
- },
- } as SyncStorage
-
- await new Promise((resolve, reject) => {
- createRoot((dispose) => {
- const [raw, setRaw] = createStore({
- sessionView: {} as Record }>,
- })
-
- const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage })
-
- const scroll = createScrollPersistence({
- debounceMs: 30,
- getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
- onFlush: (sessionKey, next) => {
- stats.flushes += 1
-
- const current = store.sessionView[sessionKey]
- if (!current) {
- setStore("sessionView", sessionKey, { scroll: next })
- return
- }
- setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
- },
- })
-
- const run = async () => {
- await new Promise((r) => setTimeout(r, 0))
- writes.length = 0
-
- for (const i of Array.from({ length: 100 }, (_, n) => n)) {
- scroll.setScroll("session", "review", { x: 0, y: i })
- }
-
- await new Promise((r) => setTimeout(r, 120))
-
- expect(stats.flushes).toBeGreaterThanOrEqual(1)
- expect(writes.length).toBeGreaterThanOrEqual(1)
- expect(writes.length).toBeLessThanOrEqual(2)
- }
-
- void run()
- .then(resolve)
- .catch(reject)
- .finally(() => {
- scroll.dispose()
- dispose()
- })
+ 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 })
+ }
+
+ 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()
+ }
})
})
diff --git a/packages/app/src/context/layout.test.ts b/packages/app/src/context/layout.test.ts
new file mode 100644
index 0000000000..582d5edbd2
--- /dev/null
+++ b/packages/app/src/context/layout.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, test } from "bun:test"
+import { createRoot, createSignal } from "solid-js"
+import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout"
+
+describe("layout session-key helpers", () => {
+ test("couples touch and scroll seed in order", () => {
+ const calls: string[] = []
+ const result = ensureSessionKey(
+ "dir/a",
+ (key) => calls.push(`touch:${key}`),
+ (key) => calls.push(`seed:${key}`),
+ )
+
+ expect(result).toBe("dir/a")
+ expect(calls).toEqual(["touch:dir/a", "seed:dir/a"])
+ })
+
+ test("reads dynamic accessor keys lazily", () => {
+ const seen: string[] = []
+
+ createRoot((dispose) => {
+ const [key, setKey] = createSignal("dir/one")
+ const read = createSessionKeyReader(key, (value) => seen.push(value))
+
+ expect(read()).toBe("dir/one")
+ setKey("dir/two")
+ expect(read()).toBe("dir/two")
+
+ dispose()
+ })
+
+ expect(seen).toEqual(["dir/one", "dir/two"])
+ })
+})
+
+describe("pruneSessionKeys", () => {
+ test("keeps active key and drops lowest-used keys", () => {
+ const drop = pruneSessionKeys({
+ keep: "k4",
+ max: 3,
+ used: new Map([
+ ["k1", 1],
+ ["k2", 2],
+ ["k3", 3],
+ ["k4", 4],
+ ]),
+ view: ["k1", "k2", "k4"],
+ tabs: ["k1", "k3", "k4"],
+ })
+
+ expect(drop).toEqual(["k1"])
+ expect(drop.includes("k4")).toBe(false)
+ })
+
+ test("does not prune without keep key", () => {
+ const drop = pruneSessionKeys({
+ keep: undefined,
+ max: 1,
+ used: new Map([
+ ["k1", 1],
+ ["k2", 2],
+ ]),
+ view: ["k1"],
+ tabs: ["k2"],
+ })
+
+ expect(drop).toEqual([])
+ })
+})
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index d30fd11cfb..4019b2f29d 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -1,9 +1,10 @@
import { createStore, produce } from "solid-js/store"
-import { batch, createEffect, createMemo, on, onCleanup, onMount, type Accessor } from "solid-js"
+import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
+import { usePlatform } from "./platform"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { same } from "@/utils/same"
@@ -33,18 +34,64 @@ type SessionTabs = {
type SessionView = {
scroll: Record
reviewOpen?: string[]
+ pendingMessage?: string
+ pendingMessageAt?: number
+}
+
+type TabHandoff = {
+ dir: string
+ id: string
+ at: number
}
export type LocalProject = Partial & { worktree: string; expanded: boolean }
export type ReviewDiffStyle = "unified" | "split"
+export function ensureSessionKey(key: string, touch: (key: string) => void, seed: (key: string) => void) {
+ touch(key)
+ seed(key)
+ return key
+}
+
+export function createSessionKeyReader(sessionKey: string | Accessor, ensure: (key: string) => void) {
+ const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
+ return () => {
+ const value = key()
+ ensure(value)
+ return value
+ }
+}
+
+export function pruneSessionKeys(input: {
+ keep?: string
+ max: number
+ used: Map
+ view: string[]
+ tabs: string[]
+}) {
+ if (!input.keep) return []
+
+ const keys = new Set([...input.view, ...input.tabs])
+ if (keys.size <= input.max) return []
+
+ const score = (key: string) => {
+ if (key === input.keep) return Number.MAX_SAFE_INTEGER
+ return input.used.get(key) ?? 0
+ }
+
+ return Array.from(keys)
+ .sort((a, b) => score(b) - score(a))
+ .slice(input.max)
+}
+
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
init: () => {
const globalSdk = useGlobalSDK()
const globalSync = useGlobalSync()
const server = useServer()
+ const platform = usePlatform()
const isRecord = (value: unknown): value is Record =>
typeof value === "object" && value !== null && !Array.isArray(value)
@@ -63,6 +110,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})()
+ const review = value.review
const fileTree = value.fileTree
const migratedFileTree = (() => {
if (!isRecord(fileTree)) return fileTree
@@ -77,10 +125,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})()
- if (migratedSidebar === sidebar && migratedFileTree === fileTree) return value
+ const migratedReview = (() => {
+ if (!isRecord(review)) return review
+ if (typeof review.panelOpened === "boolean") return review
+
+ const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
+ return {
+ ...review,
+ panelOpened: opened,
+ }
+ })()
+
+ if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
return {
...value,
sidebar: migratedSidebar,
+ review: migratedReview,
fileTree: migratedFileTree,
}
}
@@ -101,6 +161,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
review: {
diffStyle: "split" as ReviewDiffStyle,
+ panelOpened: true,
},
fileTree: {
opened: true,
@@ -115,10 +176,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
sessionTabs: {} as Record,
sessionView: {} as Record,
+ handoff: {
+ tabs: undefined as TabHandoff | undefined,
+ },
}),
)
const MAX_SESSION_KEYS = 50
+ const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map()
@@ -137,29 +202,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
for (const entry of SESSION_STATE_KEYS) {
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
- void removePersisted(target)
+ void removePersisted(target, platform)
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
- void removePersisted({ key: legacyKey })
+ void removePersisted({ key: legacyKey }, platform)
}
}
}
function prune(keep?: string) {
- if (!keep) return
-
- const keys = new Set()
- for (const key of Object.keys(store.sessionView)) keys.add(key)
- for (const key of Object.keys(store.sessionTabs)) keys.add(key)
- if (keys.size <= MAX_SESSION_KEYS) return
-
- const score = (key: string) => {
- if (key === keep) return Number.MAX_SAFE_INTEGER
- return used.get(key) ?? 0
- }
-
- const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
- const drop = ordered.slice(MAX_SESSION_KEYS)
+ const drop = pruneSessionKeys({
+ keep,
+ max: MAX_SESSION_KEYS,
+ used,
+ view: Object.keys(store.sessionView),
+ tabs: Object.keys(store.sessionTabs),
+ })
if (drop.length === 0) return
setStore(
@@ -207,6 +265,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
})
+ const ensureKey = (key: string) => ensureSessionKey(key, touch, (sessionKey) => scroll.seed(sessionKey))
+
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
@@ -411,6 +471,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return {
ready,
+ handoff: {
+ tabs: createMemo(() => store.handoff?.tabs),
+ setTabs(dir: string, id: string) {
+ setStore("handoff", "tabs", { dir, id, at: Date.now() })
+ },
+ clearTabs() {
+ if (!store.handoff?.tabs) return
+ setStore("handoff", "tabs", undefined)
+ },
+ },
projects: {
list,
open(directory: string) {
@@ -468,7 +538,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) {
- setStore("review", { diffStyle })
+ setStore("review", { diffStyle, panelOpened: true })
return
}
setStore("review", "diffStyle", diffStyle)
@@ -536,25 +606,54 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("mobileSidebar", "opened", (x) => !x)
},
},
+ pendingMessage: {
+ set(sessionKey: string, messageID: string) {
+ const at = Date.now()
+ touch(sessionKey)
+ const current = store.sessionView[sessionKey]
+ if (!current) {
+ setStore("sessionView", sessionKey, {
+ scroll: {},
+ pendingMessage: messageID,
+ pendingMessageAt: at,
+ })
+ prune(meta.active ?? sessionKey)
+ return
+ }
+
+ setStore(
+ "sessionView",
+ sessionKey,
+ produce((draft) => {
+ draft.pendingMessage = messageID
+ draft.pendingMessageAt = at
+ }),
+ )
+ },
+ consume(sessionKey: string) {
+ const current = store.sessionView[sessionKey]
+ const message = current?.pendingMessage
+ const at = current?.pendingMessageAt
+ if (!message || !at) return
+
+ setStore(
+ "sessionView",
+ sessionKey,
+ produce((draft) => {
+ delete draft.pendingMessage
+ delete draft.pendingMessageAt
+ }),
+ )
+
+ if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
+ return message
+ },
+ },
view(sessionKey: string | Accessor) {
- const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
-
- touch(key())
- scroll.seed(key())
-
- createEffect(
- on(
- key,
- (value) => {
- touch(value)
- scroll.seed(value)
- },
- { defer: true },
- ),
- )
-
+ const key = createSessionKeyReader(sessionKey, ensureKey)
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
+ const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
function setTerminalOpened(next: boolean) {
const current = store.terminal
@@ -568,6 +667,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("terminal", "opened", next)
}
+ function setReviewPanelOpened(next: boolean) {
+ const current = store.review
+ if (!current) {
+ setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
+ return
+ }
+
+ const value = current.panelOpened ?? true
+ if (value === next) return
+ setStore("review", "panelOpened", next)
+ }
+
return {
scroll(tab: string) {
return scroll.scroll(key(), tab)
@@ -587,6 +698,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setTerminalOpened(!terminalOpened())
},
},
+ reviewPanel: {
+ opened: reviewPanelOpened,
+ open() {
+ setReviewPanelOpened(true)
+ },
+ close() {
+ setReviewPanelOpened(false)
+ },
+ toggle() {
+ setReviewPanelOpened(!reviewPanelOpened())
+ },
+ },
review: {
open: createMemo(() => s().reviewOpen),
setOpen(open: string[]) {
@@ -607,28 +730,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
tabs(sessionKey: string | Accessor) {
- const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
-
- touch(key())
-
- createEffect(
- on(
- key,
- (value) => {
- touch(value)
- },
- { defer: true },
- ),
- )
-
+ const key = createSessionKeyReader(sessionKey, ensureKey)
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
return {
tabs,
- active: createMemo(() => (tabs().active === "review" ? undefined : tabs().active)),
+ active: createMemo(() => tabs().active),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
setActive(tab: string | undefined) {
const session = key()
- if (tab === "review") return
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: [], active: tab })
} else {
@@ -645,10 +754,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
async open(tab: string) {
- if (tab === "review") return
const session = key()
const current = store.sessionTabs[session] ?? { all: [] }
+ if (tab === "review") {
+ if (!store.sessionTabs[session]) {
+ setStore("sessionTabs", session, { all: current.all.filter((x) => x !== "review"), active: tab })
+ return
+ }
+ setStore("sessionTabs", session, "active", tab)
+ return
+ }
+
if (tab === "context") {
const all = [tab, ...current.all.filter((x) => x !== tab)]
if (!store.sessionTabs[session]) {
@@ -681,13 +798,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const current = store.sessionTabs[session]
if (!current) return
+ if (tab === "review") {
+ if (current.active !== tab) return
+ setStore("sessionTabs", session, "active", current.all[0])
+ return
+ }
+
const all = current.all.filter((x) => x !== tab)
+ if (current.active !== tab) {
+ setStore("sessionTabs", session, "all", all)
+ return
+ }
+
+ const index = current.all.findIndex((f) => f === tab)
+ const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
batch(() => {
setStore("sessionTabs", session, "all", all)
- if (current.active !== tab) return
-
- const index = current.all.findIndex((f) => f === tab)
- const next = all[index - 1] ?? all[0]
setStore("sessionTabs", session, "active", next)
})
},
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/notification-index.ts b/packages/app/src/context/notification-index.ts
new file mode 100644
index 0000000000..0b316e7ec1
--- /dev/null
+++ b/packages/app/src/context/notification-index.ts
@@ -0,0 +1,66 @@
+type NotificationIndexItem = {
+ directory?: string
+ session?: string
+ viewed: boolean
+ type: string
+}
+
+export function buildNotificationIndex(list: T[]) {
+ const sessionAll = new Map()
+ const sessionUnseen = new Map()
+ const sessionUnseenCount = new Map()
+ const sessionUnseenHasError = new Map()
+ const projectAll = new Map()
+ const projectUnseen = new Map()
+ const projectUnseenCount = new Map()
+ const projectUnseenHasError = new Map()
+
+ for (const notification of list) {
+ const session = notification.session
+ if (session) {
+ const all = sessionAll.get(session)
+ if (all) all.push(notification)
+ else sessionAll.set(session, [notification])
+
+ if (!notification.viewed) {
+ const unseen = sessionUnseen.get(session)
+ if (unseen) unseen.push(notification)
+ else sessionUnseen.set(session, [notification])
+
+ sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
+ if (notification.type === "error") sessionUnseenHasError.set(session, true)
+ }
+ }
+
+ const directory = notification.directory
+ if (directory) {
+ const all = projectAll.get(directory)
+ if (all) all.push(notification)
+ else projectAll.set(directory, [notification])
+
+ if (!notification.viewed) {
+ const unseen = projectUnseen.get(directory)
+ if (unseen) unseen.push(notification)
+ else projectUnseen.set(directory, [notification])
+
+ projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
+ if (notification.type === "error") projectUnseenHasError.set(directory, true)
+ }
+ }
+ }
+
+ return {
+ session: {
+ all: sessionAll,
+ unseen: sessionUnseen,
+ unseenCount: sessionUnseenCount,
+ unseenHasError: sessionUnseenHasError,
+ },
+ project: {
+ all: projectAll,
+ unseen: projectUnseen,
+ unseenCount: projectUnseenCount,
+ unseenHasError: projectUnseenHasError,
+ },
+ }
+}
diff --git a/packages/app/src/context/notification.test.ts b/packages/app/src/context/notification.test.ts
new file mode 100644
index 0000000000..44bacb7049
--- /dev/null
+++ b/packages/app/src/context/notification.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, test } from "bun:test"
+import { buildNotificationIndex } from "./notification-index"
+
+type Notification = {
+ type: "turn-complete" | "error"
+ session: string
+ directory: string
+ viewed: boolean
+ time: number
+}
+
+const turn = (session: string, directory: string, viewed = false): Notification => ({
+ type: "turn-complete",
+ session,
+ directory,
+ viewed,
+ time: 1,
+})
+
+const error = (session: string, directory: string, viewed = false): Notification => ({
+ type: "error",
+ session,
+ directory,
+ viewed,
+ time: 1,
+})
+
+describe("buildNotificationIndex", () => {
+ test("builds unseen counts and unseen error flags", () => {
+ const list = [
+ turn("s1", "d1", false),
+ error("s1", "d1", false),
+ turn("s1", "d1", true),
+ turn("s2", "d1", false),
+ error("s3", "d2", true),
+ ]
+
+ const index = buildNotificationIndex(list)
+
+ expect(index.session.all.get("s1")?.length).toBe(3)
+ expect(index.session.unseen.get("s1")?.length).toBe(2)
+ expect(index.session.unseenCount.get("s1")).toBe(2)
+ expect(index.session.unseenHasError.get("s1")).toBe(true)
+
+ expect(index.session.unseenCount.get("s2")).toBe(1)
+ expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
+ expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
+ expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
+
+ expect(index.project.unseenCount.get("d1")).toBe(3)
+ expect(index.project.unseenHasError.get("d1")).toBe(true)
+ expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
+ expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
+ })
+
+ test("updates selectors after viewed transitions", () => {
+ const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
+ const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
+
+ const before = buildNotificationIndex(list)
+ const after = buildNotificationIndex(next)
+
+ expect(before.session.unseenCount.get("s1")).toBe(2)
+ expect(before.session.unseenHasError.get("s1")).toBe(true)
+ expect(before.project.unseenCount.get("d1")).toBe(3)
+ expect(before.project.unseenHasError.get("d1")).toBe(true)
+
+ expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
+ expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
+ expect(after.project.unseenCount.get("d1")).toBe(1)
+ expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
+ })
+})
diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx
index 6c110cae14..cade70a53b 100644
--- a/packages/app/src/context/notification.tsx
+++ b/packages/app/src/context/notification.tsx
@@ -13,6 +13,7 @@ import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound"
+import { buildNotificationIndex } from "./notification-index"
type NotificationBase = {
directory?: string
@@ -68,7 +69,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
}),
)
- const meta = { pruned: false }
+ const meta = { pruned: false, disposed: false }
createEffect(() => {
if (!ready()) return
@@ -81,49 +82,18 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
setStore("list", (list) => pruneNotifications([...list, notification]))
}
- const index = createMemo(() => {
- const sessionAll = new Map()
- const sessionUnseen = new Map()
- const projectAll = new Map()
- const projectUnseen = new Map()
+ const index = createMemo(() => buildNotificationIndex(store.list))
- for (const notification of store.list) {
- const session = notification.session
- if (session) {
- const list = sessionAll.get(session)
- if (list) list.push(notification)
- else sessionAll.set(session, [notification])
- if (!notification.viewed) {
- const unseen = sessionUnseen.get(session)
- if (unseen) unseen.push(notification)
- else sessionUnseen.set(session, [notification])
- }
- }
-
- const directory = notification.directory
- if (directory) {
- const list = projectAll.get(directory)
- if (list) list.push(notification)
- else projectAll.set(directory, [notification])
- if (!notification.viewed) {
- const unseen = projectUnseen.get(directory)
- if (unseen) unseen.push(notification)
- else projectUnseen.set(directory, [notification])
- }
- }
- }
-
- return {
- session: {
- all: sessionAll,
- unseen: sessionUnseen,
- },
- project: {
- all: projectAll,
- unseen: projectUnseen,
- },
- }
- })
+ const lookup = (directory: string, sessionID?: string) => {
+ if (!sessionID) return Promise.resolve(undefined)
+ const [syncStore] = globalSync.child(directory, { bootstrap: false })
+ const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
+ if (match.found) return Promise.resolve(syncStore.session[match.index])
+ return globalSDK.client.session
+ .get({ directory, sessionID })
+ .then((x) => x.data)
+ .catch(() => undefined)
+ }
const unsub = globalSDK.event.listen((e) => {
const event = e.details
@@ -143,61 +113,65 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
switch (event.type) {
case "session.idle": {
const sessionID = event.properties.sessionID
- const [syncStore] = globalSync.child(directory, { bootstrap: false })
- const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
- const session = match.found ? syncStore.session[match.index] : undefined
- if (session?.parentID) break
+ void lookup(directory, sessionID).then((session) => {
+ if (meta.disposed) return
+ if (!session) return
+ if (session.parentID) return
- playSound(soundSrc(settings.sounds.agent()))
+ playSound(soundSrc(settings.sounds.agent()))
- append({
- directory,
- time,
- viewed: viewed(sessionID),
- type: "turn-complete",
- session: sessionID,
+ append({
+ directory,
+ time,
+ viewed: viewed(sessionID),
+ type: "turn-complete",
+ session: sessionID,
+ })
+
+ const href = `/${base64Encode(directory)}/session/${sessionID}`
+ if (settings.notifications.agent()) {
+ void platform.notify(
+ language.t("notification.session.responseReady.title"),
+ session.title ?? sessionID,
+ href,
+ )
+ }
})
-
- const href = `/${base64Encode(directory)}/session/${sessionID}`
- if (settings.notifications.agent()) {
- void platform.notify(
- language.t("notification.session.responseReady.title"),
- session?.title ?? sessionID,
- href,
- )
- }
break
}
case "session.error": {
const sessionID = event.properties.sessionID
- const [syncStore] = globalSync.child(directory, { bootstrap: false })
- const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
- const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
- if (session?.parentID) break
+ void lookup(directory, sessionID).then((session) => {
+ if (meta.disposed) return
+ if (session?.parentID) return
- playSound(soundSrc(settings.sounds.errors()))
+ playSound(soundSrc(settings.sounds.errors()))
- const error = "error" in event.properties ? event.properties.error : undefined
- append({
- directory,
- time,
- viewed: viewed(sessionID),
- type: "error",
- session: sessionID ?? "global",
- error,
+ const error = "error" in event.properties ? event.properties.error : undefined
+ append({
+ directory,
+ time,
+ viewed: viewed(sessionID),
+ type: "error",
+ session: sessionID ?? "global",
+ error,
+ })
+ const description =
+ session?.title ??
+ (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
+ const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
+ if (settings.notifications.errors()) {
+ void platform.notify(language.t("notification.session.error.title"), description, href)
+ }
})
- const description =
- session?.title ??
- (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
- const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
- if (settings.notifications.errors()) {
- void platform.notify(language.t("notification.session.error.title"), description, href)
- }
break
}
}
})
- onCleanup(unsub)
+ onCleanup(() => {
+ meta.disposed = true
+ unsub()
+ })
return {
ready,
@@ -208,6 +182,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
unseen(session: string) {
return index().session.unseen.get(session) ?? empty
},
+ unseenCount(session: string) {
+ return index().session.unseenCount.get(session) ?? 0
+ },
+ unseenHasError(session: string) {
+ return index().session.unseenHasError.get(session) ?? false
+ },
markViewed(session: string) {
setStore("list", (n) => n.session === session, "viewed", true)
},
@@ -219,6 +199,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
unseen(directory: string) {
return index().project.unseen.get(directory) ?? empty
},
+ unseenCount(directory: string) {
+ return index().project.unseenCount.get(directory) ?? 0
+ },
+ unseenHasError(directory: string) {
+ return index().project.unseenHasError.get(directory) ?? false
+ },
markViewed(directory: string) {
setStore("list", (n) => n.directory === directory, "viewed", true)
},
diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx
index f6fb157f06..e260c1977e 100644
--- a/packages/app/src/context/platform.tsx
+++ b/packages/app/src/context/platform.tsx
@@ -1,5 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
+import type { Accessor } from "solid-js"
export type Platform = {
/** Platform discriminator */
@@ -14,6 +15,9 @@ export type Platform = {
/** Open a URL in the default browser */
openLink(url: string): void
+ /** Open a local path in a local app (desktop only) */
+ openPath?(path: string, app?: string): Promise
+
/** Restart the app */
restart(): Promise
@@ -53,10 +57,33 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise | void
+ /** Get the configured WSL integration (desktop only) */
+ getWslEnabled?(): Promise
+
+ /** Set the configured WSL integration (desktop only) */
+ setWslEnabled?(config: boolean): Promise