Compare commits

..
Author SHA1 Message Date
Simon Klee 9973c7e739 simplify 2026-04-28 10:16:37 +02:00
Simon Klee 7141d76e1c add test 2026-04-28 10:16:37 +02:00
Simon Klee 81e8cb81a5 fix(tui): startup rejection handling
Propagate renderer startup failures from the TUI promise instead of
leaving opencode hanging, and destroy any partially initialized
renderer before rejecting to restore terminal state.
2026-04-28 10:16:37 +02:00
1593 changed files with 62455 additions and 119899 deletions
+1
View File
@@ -1,5 +1,6 @@
name: Bug report name: Bug report
description: Report an issue that should be fixed description: Report an issue that should be fixed
labels: ["bug"]
body: body:
- type: textarea - type: textarea
id: description id: description
@@ -1,5 +1,6 @@
name: 🚀 Feature Request name: 🚀 Feature Request
description: Suggest an idea, feature, or enhancement description: Suggest an idea, feature, or enhancement
labels: [discussion]
title: "[FEATURE]:" title: "[FEATURE]:"
body: body:
+1
View File
@@ -1,5 +1,6 @@
name: Question name: Question
description: Ask a question description: Ask a question
labels: ["question"]
body: body:
- type: textarea - type: textarea
id: question id: question
+1 -1
View File
@@ -11,6 +11,6 @@ MrMushrooooom
nexxeln nexxeln
R44VC0RP R44VC0RP
rekram1-node rekram1-node
RhysSullivan
thdxr thdxr
simonklee simonklee
vimtor
+38
View File
@@ -0,0 +1,38 @@
# 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
-agusbasari29 AI PR slop
ariane-emory
-atharvau AI review spamming literally every PR
-borealbytes
-carycooper777
-danieljoshuanazareth
-danieljoshuanazareth
-davidbernat looks to be a clawdbot that spams team and sends super weird emails, doesnt appear to be a real person
edemaine
-florianleibert
fwang
iamdavidhill
jayair
kitlangton
kommander
-opencode2026
-opencodeengineer bot that spams issues
r44vc0rp
rekram1-node
-ricardo-m-l
-robinmordasiewicz
rubdos
shantur
simonklee
-spider-yamet clawdbot/llm psychosis, spam pinging the team
-terisuke
thdxr
-toastythebot
+170
View File
@@ -0,0 +1,170 @@
name: daily-issues-recap
on:
schedule:
# Run at 6 PM EST (23:00 UTC, or 22:00 UTC during daylight saving)
- cron: "0 23 * * *"
workflow_dispatch: # Allow manual trigger for testing
jobs:
daily-recap:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
issues: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Generate daily issues recap
id: recap
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh issue*": "allow",
"gh search*": "allow"
},
"webfetch": "deny",
"edit": "deny",
"write": "deny"
}
run: |
# Get today's date range
TODAY=$(date -u +%Y-%m-%d)
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily issues recap for the OpenCode repository.
TODAY'S DATE: ${TODAY}
STEP 1: Gather today's issues
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:
**Severity Assessment:**
- CRITICAL: Crashes, data loss, security issues, blocks major functionality
- HIGH: Significant bugs affecting many users, important features broken
- MEDIUM: Bugs with workarounds, minor features broken
- LOW: Minor issues, cosmetic, nice-to-haves
**Activity Assessment:**
- Note issues with high comment counts or engagement
- Note issues from repeat reporters (check if author has filed before)
STEP 3: Cross-reference with existing issues
For issues that seem like feature requests or recurring bugs:
- Search for similar older issues to identify patterns
- Note if this is a frequently requested feature
- Identify any issues that are duplicates of long-standing requests
STEP 4: Generate the recap
Create a structured recap with these sections:
===DISCORD_START===
**Daily Issues Recap - ${TODAY}**
**Summary Stats**
- Total issues opened today: [count]
- By category: [bugs/features/questions]
**Critical/High Priority Issues**
[List any CRITICAL or HIGH severity issues with brief descriptions and issue numbers]
**Most Active/Discussed**
[Issues with significant engagement or from active community members]
**Trending Topics**
[Patterns noticed - e.g., 'Multiple reports about X', 'Continued interest in Y feature']
**Duplicates & Related**
[Issues that relate to existing open issues]
===DISCORD_END===
STEP 5: Format for Discord
Format the recap as a Discord-compatible message:
- Use Discord markdown (**, __, etc.)
- BE EXTREMELY CONCISE - this is an EOD summary, not a detailed report
- Use hyperlinked issue numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/issues/1234>)
- Group related issues on single lines where possible
- Add emoji sparingly for critical items only
- HARD LIMIT: Keep under 1800 characters total
- Skip sections that have nothing notable (e.g., if no critical issues, omit that section)
- Prioritize signal over completeness - only surface what matters
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/recap_raw.txt
# Extract only the Discord message between markers
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/recap_raw.txt | grep -v '===DISCORD' > /tmp/recap.txt
echo "recap_file=/tmp/recap.txt" >> $GITHUB_OUTPUT
- name: Post to Discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
cat /tmp/recap.txt
exit 0
fi
# Read the recap
RECAP_RAW=$(cat /tmp/recap.txt)
RECAP_LENGTH=${#RECAP_RAW}
echo "Recap length: ${RECAP_LENGTH} chars"
# Function to post a message to Discord
post_to_discord() {
local msg="$1"
local content=$(echo "$msg" | jq -Rs '.')
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"content\": ${content}}" \
"$DISCORD_WEBHOOK_URL"
sleep 1
}
# If under limit, send as single message
if [ "$RECAP_LENGTH" -le 1950 ]; then
post_to_discord "$RECAP_RAW"
else
echo "Splitting into multiple messages..."
remaining="$RECAP_RAW"
while [ ${#remaining} -gt 0 ]; do
if [ ${#remaining} -le 1950 ]; then
post_to_discord "$remaining"
break
else
chunk="${remaining:0:1900}"
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
chunk="${remaining:0:$last_newline}"
remaining="${remaining:$((last_newline+1))}"
else
chunk="${remaining:0:1900}"
remaining="${remaining:1900}"
fi
post_to_discord "$chunk"
fi
done
fi
echo "Posted daily recap to Discord"
+173
View File
@@ -0,0 +1,173 @@
name: daily-pr-recap
on:
schedule:
# Run at 5pm EST (22:00 UTC, or 21:00 UTC during daylight saving)
- cron: "0 22 * * *"
workflow_dispatch: # Allow manual trigger for testing
jobs:
pr-recap:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- uses: ./.github/actions/setup-bun
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Generate daily PR recap
id: recap
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh pr*": "allow",
"gh search*": "allow"
},
"webfetch": "deny",
"edit": "deny",
"write": "deny"
}
run: |
TODAY=$(date -u +%Y-%m-%d)
opencode run -m opencode/claude-sonnet-4-5 "Generate a daily PR activity recap for the OpenCode repository.
TODAY'S DATE: ${TODAY}
STEP 1: Gather PR data
Run these commands to gather PR information. ONLY include OPEN PRs created or updated TODAY (${TODAY}):
# 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
# 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
For promising PRs, run:
gh pr view [NUMBER] --repo ${{ github.repository }} --json comments --jq '[.comments[] | select(.author.login != \"copilot-pull-request-reviewer\" and .author.login != \"github-actions\")] | length'
IMPORTANT: When counting comments/activity, EXCLUDE these bot accounts:
- copilot-pull-request-reviewer
- github-actions
STEP 3: Identify what matters (ONLY from today's PRs)
**Bug Fixes From Today:**
- PRs with 'fix' or 'bug' in title created/updated today
- Small bug fixes (< 100 lines changed) that are easy to review
- Bug fixes from community contributors
**High Activity Today:**
- PRs with significant human comments today (excluding bots listed above)
- PRs with back-and-forth discussion today
**Quick Wins:**
- Small PRs (< 50 lines) that are approved or nearly approved
- PRs that just need a final review
STEP 4: Generate the recap
Create a structured recap:
===DISCORD_START===
**Daily PR Recap - ${TODAY}**
**New PRs Today**
[PRs opened today - group by type: bug fixes, features, etc.]
**Active PRs Today**
[PRs with activity/updates today - significant discussion]
**Quick Wins**
[Small PRs ready to merge]
===DISCORD_END===
STEP 5: Format for Discord
- Use Discord markdown (**, __, etc.)
- BE EXTREMELY CONCISE - surface what we might miss
- Use hyperlinked PR numbers with suppressed embeds: [#1234](<https://github.com/${{ github.repository }}/pull/1234>)
- Include PR author: [#1234](<url>) (@author)
- For bug fixes, add brief description of what it fixes
- Show line count for quick wins: \"(+15/-3 lines)\"
- HARD LIMIT: Keep under 1800 characters total
- Skip empty sections
- Focus on PRs that need human eyes
OUTPUT: Output ONLY the content between ===DISCORD_START=== and ===DISCORD_END=== markers. Include the markers so I can extract it." > /tmp/pr_recap_raw.txt
# Extract only the Discord message between markers
sed -n '/===DISCORD_START===/,/===DISCORD_END===/p' /tmp/pr_recap_raw.txt | grep -v '===DISCORD' > /tmp/pr_recap.txt
echo "recap_file=/tmp/pr_recap.txt" >> $GITHUB_OUTPUT
- name: Post to Discord
env:
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }}
run: |
if [ -z "$DISCORD_WEBHOOK_URL" ]; then
echo "Warning: DISCORD_ISSUES_WEBHOOK_URL secret not set, skipping Discord post"
cat /tmp/pr_recap.txt
exit 0
fi
# Read the recap
RECAP_RAW=$(cat /tmp/pr_recap.txt)
RECAP_LENGTH=${#RECAP_RAW}
echo "Recap length: ${RECAP_LENGTH} chars"
# Function to post a message to Discord
post_to_discord() {
local msg="$1"
local content=$(echo "$msg" | jq -Rs '.')
curl -s -H "Content-Type: application/json" \
-X POST \
-d "{\"content\": ${content}}" \
"$DISCORD_WEBHOOK_URL"
sleep 1
}
# If under limit, send as single message
if [ "$RECAP_LENGTH" -le 1950 ]; then
post_to_discord "$RECAP_RAW"
else
echo "Splitting into multiple messages..."
remaining="$RECAP_RAW"
while [ ${#remaining} -gt 0 ]; do
if [ ${#remaining} -le 1950 ]; then
post_to_discord "$remaining"
break
else
chunk="${remaining:0:1900}"
last_newline=$(echo "$chunk" | grep -bo $'\n' | tail -1 | cut -d: -f1)
if [ -n "$last_newline" ] && [ "$last_newline" -gt 500 ]; then
chunk="${remaining:0:$last_newline}"
remaining="${remaining:$((last_newline+1))}"
else
chunk="${remaining:0:1900}"
remaining="${remaining:1900}"
fi
post_to_discord "$chunk"
fi
done
fi
echo "Posted daily PR recap to Discord"
-7
View File
@@ -36,10 +36,3 @@ jobs:
PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }} PLANETSCALE_SERVICE_TOKEN_NAME: ${{ secrets.PLANETSCALE_SERVICE_TOKEN_NAME }}
PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }} PLANETSCALE_SERVICE_TOKEN: ${{ secrets.PLANETSCALE_SERVICE_TOKEN }}
STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }} STRIPE_SECRET_KEY: ${{ github.ref_name == 'production' && secrets.STRIPE_SECRET_KEY_PROD || secrets.STRIPE_SECRET_KEY_DEV }}
HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
SENTRY_RELEASE: web@${{ github.sha }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_RELEASE: web@${{ github.sha }}
+195 -51
View File
@@ -88,7 +88,7 @@ jobs:
- name: Build - name: Build
id: build id: build
run: | run: |
./packages/opencode/script/build.ts ${{ (github.ref_name == 'beta' && '--sourcemaps') || '' }} ./packages/opencode/script/build.ts
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -209,6 +209,182 @@ jobs:
packages/opencode/dist/opencode-windows-x64 packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline packages/opencode/dist/opencode-windows-x64-baseline
build-tauri:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
settings:
- host: macos-latest
target: x86_64-apple-darwin
- host: macos-latest
target: aarch64-apple-darwin
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: windows-2025
target: aarch64-pc-windows-msvc
- host: blacksmith-4vcpu-windows-2025
target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404
target: x86_64-unknown-linux-gnu
- host: blacksmith-8vcpu-ubuntu-2404-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.settings.host }}
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: apple-actions/import-codesign-certs@v2
if: ${{ runner.os == 'macOS' }}
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Verify Certificate
if: ${{ runner.os == 'macOS' }}
run: |
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported."
- name: Setup Apple API Key
if: ${{ runner.os == 'macOS' }}
run: |
echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4
with:
path: ~/apt-cache
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: |
mkdir -p ~/apt-cache && chmod -R a+rw ~/apt-cache
sudo apt-get update
sudo apt-get install -y --no-install-recommends -o dir::cache::archives="$HOME/apt-cache" libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
sudo chmod -R a+rw ~/apt-cache
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: packages/desktop/src-tauri
shared-key: ${{ matrix.settings.target }}
- name: Prepare
run: |
cd packages/desktop
bun ./scripts/prepare.ts
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
- name: Resolve tauri portable SHA
if: contains(matrix.settings.host, 'ubuntu')
run: echo "TAURI_PORTABLE_SHA=$(git ls-remote https://github.com/tauri-apps/tauri.git refs/heads/feat/truly-portable-appimage | cut -f1)" >> "$GITHUB_ENV"
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- name: Install tauri-cli from portable appimage branch
uses: taiki-e/cache-cargo-install-action@v3
if: contains(matrix.settings.host, 'ubuntu')
with:
tool: tauri-cli
git: https://github.com/tauri-apps/tauri
# branch: feat/truly-portable-appimage
rev: ${{ env.TAURI_PORTABLE_SHA }}
- name: Show tauri-cli version
if: contains(matrix.settings.host, 'ubuntu')
run: cargo tauri --version
- 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: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
timeout-minutes: 60
with:
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ${{ (github.ref_name == 'beta' && './src-tauri/tauri.beta.conf.json') || './src-tauri/tauri.prod.conf.json' }} --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.version.outputs.release }}
tagName: ${{ needs.version.outputs.tag }}
releaseDraft: true
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
repo: ${{ (github.ref_name == 'beta' && 'opencode-beta') || '' }}
releaseCommitish: ${{ github.sha }}
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
- name: Verify signed Windows desktop artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
)
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
build-electron: build-electron:
needs: needs:
- build-cli - build-cli
@@ -304,7 +480,7 @@ jobs:
- name: Prepare - name: Prepare
run: bun ./scripts/prepare.ts run: bun ./scripts/prepare.ts
working-directory: packages/desktop working-directory: packages/desktop-electron
env: env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
@@ -315,21 +491,14 @@ jobs:
- name: Build - name: Build
run: bun run build run: bun run build
working-directory: packages/desktop working-directory: packages/desktop-electron
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: ${{ (github.ref_name == 'beta' && 'beta') || 'production' }}
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
- name: Package and publish - name: Package and publish
if: needs.version.outputs.release if: needs.version.outputs.release
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish always --config electron-builder.config.ts
working-directory: packages/desktop working-directory: packages/desktop-electron
timeout-minutes: 60 timeout-minutes: 60
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
@@ -343,43 +512,19 @@ jobs:
- name: Package (no publish) - name: Package (no publish)
if: ${{ !needs.version.outputs.release }} if: ${{ !needs.version.outputs.release }}
run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts run: npx electron-builder ${{ matrix.settings.platform_flag }} --publish never --config electron-builder.config.ts
working-directory: packages/desktop working-directory: packages/desktop-electron
timeout-minutes: 60 timeout-minutes: 60
env: env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Create and upload macOS .app.tar.gz
if: runner.os == 'macOS' && needs.version.outputs.release
working-directory: packages/desktop/dist
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
APP_DIR="mac"
OUT_NAME="opencode-desktop-mac-x64.app.tar.gz"
elif [[ "${{ matrix.settings.target }}" == "aarch64-apple-darwin" ]]; then
APP_DIR="mac-arm64"
OUT_NAME="opencode-desktop-mac-arm64.app.tar.gz"
else
echo "Unknown macOS target: ${{ matrix.settings.target }}"
exit 1
fi
APP_PATH=$(find "$APP_DIR" -maxdepth 1 -name "*.app" -type d | head -1)
if [ -z "$APP_PATH" ]; then
echo "No .app bundle found in $APP_DIR"
exit 1
fi
tar -czf "$OUT_NAME" -C "$(dirname "$APP_PATH")" "$(basename "$APP_PATH")"
gh release upload "v${{ needs.version.outputs.version }}" "$OUT_NAME" --clobber --repo "${{ needs.version.outputs.repo }}"
- name: Verify signed Windows Electron artifacts - name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows' if: runner.os == 'Windows'
shell: pwsh shell: pwsh
run: | run: |
$files = @() $files = @()
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\dist\*.exe" | Select-Object -ExpandProperty FullName $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName $files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName
foreach ($file in $files | Select-Object -Unique) { foreach ($file in $files | Select-Object -Unique) {
$sig = Get-AuthenticodeSignature $file $sig = Get-AuthenticodeSignature $file
@@ -390,20 +535,21 @@ jobs:
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
with: with:
name: opencode-desktop-${{ matrix.settings.target }} name: opencode-electron-${{ matrix.settings.target }}
path: packages/desktop/dist/* path: packages/desktop-electron/dist/*
- uses: actions/upload-artifact@v4 - uses: actions/upload-artifact@v4
if: needs.version.outputs.release if: needs.version.outputs.release
with: with:
name: latest-yml-${{ matrix.settings.target }} name: latest-yml-${{ matrix.settings.target }}
path: packages/desktop/dist/latest*.yml path: packages/desktop-electron/dist/latest*.yml
publish: publish:
needs: needs:
- version - version
- build-cli - build-cli
- sign-cli-windows - sign-cli-windows
- build-tauri
- build-electron - build-electron
if: always() && !failure() && !cancelled() if: always() && !failure() && !cancelled()
runs-on: blacksmith-4vcpu-ubuntu-2404 runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -430,6 +576,13 @@ jobs:
node-version: "24" node-version: "24"
registry-url: "https://registry.npmjs.org" registry-url: "https://registry.npmjs.org"
- 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 - uses: actions/download-artifact@v4
with: with:
name: opencode-cli name: opencode-cli
@@ -451,13 +604,6 @@ jobs:
pattern: latest-yml-* pattern: latest-yml-*
path: /tmp/latest-yml path: /tmp/latest-yml
- 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: Cache apt packages (AUR) - name: Cache apt packages (AUR)
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -486,5 +632,3 @@ jobs:
GH_REPO: ${{ needs.version.outputs.repo }} GH_REPO: ${{ needs.version.outputs.repo }}
NPM_CONFIG_PROVENANCE: false NPM_CONFIG_PROVENANCE: false
LATEST_YML_DIR: /tmp/latest-yml LATEST_YML_DIR: /tmp/latest-yml
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
-5
View File
@@ -68,11 +68,6 @@ jobs:
env: env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }} OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
- name: Run HttpApi exerciser gates
if: runner.os == 'Linux'
working-directory: packages/opencode
run: bun run test:httpapi
- name: Publish unit reports - name: Publish unit reports
if: always() if: always()
uses: mikepenz/action-junit-report@v6 uses: mikepenz/action-junit-report@v6
+116
View File
@@ -0,0 +1,116 @@
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 vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
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;
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) {
// 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}`);
return;
}
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing issue.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
labels: ['Vouched'],
});
core.info(`Added vouched label to issue #${issueNumber} from ${author}`);
+114
View File
@@ -0,0 +1,114 @@
name: vouch-check-pr
on:
pull_request_target:
types: [opened]
permissions:
contents: read
issues: write
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 vouched and denounced users
const vouched = new Set();
const denounced = new Map();
for (const line of content.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const isDenounced = trimmed.startsWith('-');
const rest = isDenounced ? trimmed.slice(1).trim() : trimmed;
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;
if (isDenounced) {
denounced.set(username.toLowerCase(), reason);
continue;
}
vouched.add(username.toLowerCase());
}
// Check if the author is denounced
const reason = denounced.get(author.toLowerCase());
if (reason !== undefined) {
// 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}`);
return;
}
// Author is positively vouched — add label
if (!vouched.has(author.toLowerCase())) {
core.info(`User ${author} is not denounced or vouched. Allowing PR.`);
return;
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: prNumber,
labels: ['Vouched'],
});
core.info(`Added vouched label to PR #${prNumber} from ${author}`);
@@ -0,0 +1,38 @@
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 }}
roles: admin,maintain,write
env:
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
-1
View File
@@ -3,7 +3,6 @@ node_modules
.worktrees .worktrees
.sst .sst
.env .env
.env.local
.idea .idea
.vscode .vscode
.codex .codex
-5
View File
@@ -1,5 +0,0 @@
# Fake secret-looking strings used by HTTP recorder redaction tests.
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:69
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:92
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:generic-api-key:146
afa57acfda894e0ebf3c637dd710310b705c0a2f:packages/http-recorder/test/record-replay.test.ts:gcp-api-key:71
+112 -15
View File
@@ -1,7 +1,7 @@
--- ---
mode: primary mode: primary
hidden: true hidden: true
model: opencode/gpt-5.4-nano model: opencode/minimax-m2.5
color: "#44BA81" color: "#44BA81"
tools: tools:
"*": false "*": false
@@ -14,30 +14,127 @@ Use your github-triage tool to triage issues.
This file is the source of truth for ownership/routing rules. This file is the source of truth for ownership/routing rules.
Assign issues by choosing the team with the strongest overlap. The github-triage tool will assign a random member from that team. ## Labels
Do not add labels to issues. Only assign an owner. ### windows
When calling github-triage, pass one of these team values: tui, desktop_web, core, inference, windows. Use for any issue that mentions Windows (the OS). Be sure they are saying that they are on Windows.
## Teams - Use if they mention WSL too
### TUI #### perf
Terminal UI issues, including rendering, keybindings, scrolling, terminal compatibility, SSH behavior, crashes in the TUI, and low-level TUI performance. Performance-related issues:
### Desktop / Web - Slow performance
- High RAM usage
- High CPU usage
Desktop application and browser-based app issues, including `opencode web`, desktop-specific UI behavior, packaging, and web view problems. **Only** add if it's likely a RAM or CPU issue. **Do not** add for LLM slowness.
### Core #### desktop
Core opencode server and harness issues, including sqlite, snapshots, memory, API behavior, agent context construction, tool execution, provider integrations, model behavior, documentation, and larger architectural features. Desktop app issues:
### Inference - `opencode web` command
- The desktop app itself
OpenCode Zen, OpenCode Go, and billing issues. **Only** add if it's specifically about the Desktop application or `opencode web` view. **Do not** add for terminal, TUI, or general opencode issues.
### Windows #### nix
Windows-specific issues, including native Windows behavior, WSL interactions, path handling, shell compatibility, and installation or runtime problems that only happen on Windows. **Only** add if the issue explicitly mentions nix.
If the issue does not mention nix, do not add nix.
If the issue mentions nix, assign to `rekram1-node`.
#### zen
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
#### core
Use for core server issues in `packages/opencode/`, excluding `packages/opencode/src/cli/cmd/tui/`.
Examples:
- LSP server behavior
- Harness behavior (agent + tools)
- Feature requests for server behavior
- Agent context construction
- API endpoints
- Provider integration issues
- New, broken, or poor-quality models
#### acp
If the issue mentions acp support, assign acp label.
#### docs
Add if the issue requests better documentation or docs updates.
#### opentui
TUI issues potentially caused by our underlying TUI library:
- Keybindings not working
- Scroll speed issues (too fast/slow/laggy)
- Screen flickering
- Crashes with opentui in the log
**Do not** add for general TUI bugs.
When assigning to people here are the following rules:
Desktop / Web:
Use for desktop-labeled issues only.
- adamdotdevin
- iamdavidhill
- Brendonovich
- nexxeln
Zen:
ONLY assign if the issue will have the "zen" label.
- fwang
- MrMushrooooom
TUI (`packages/opencode/src/cli/cmd/tui/...`):
- thdxr for TUI UX/UI product decisions and interaction flow
- kommander for OpenTUI engine issues: rendering artifacts, keybind handling, terminal compatibility, SSH behavior, and low-level perf bottlenecks
- rekram1-node for TUI bugs that are not clearly OpenTUI engine issues
Core (`packages/opencode/...`, excluding TUI subtree):
- thdxr for sqlite/snapshot/memory bugs and larger architectural core features
- jlongster for opencode server + API feature work (tool currently remaps jlongster -> thdxr until assignable)
- rekram1-node for harness issues, provider issues, and other bug-squashing
For core bugs that do not clearly map, either thdxr or rekram1-node is acceptable.
Docs:
- R44VC0RP
Windows:
- Hona (assign any issue that mentions Windows or is likely Windows-specific)
Determinism rules:
- If title + body does not contain "zen", do not add the "zen" label
- If "nix" label is added but title + body does not mention nix/nixos, the tool will drop "nix"
- If title + body mentions nix/nixos, assign to `rekram1-node`
- If "desktop" label is added, the tool will override assignee and randomly pick one Desktop / Web owner
In all other cases, choose the team/section with the most overlap with the issue and assign a member from that team at random.
ACP:
- rekram1-node (assign any acp issues to rekram1-node)
+1 -4
View File
@@ -18,12 +18,9 @@ Do not use `git log` or author metadata when deciding attribution.
Rules: Rules:
- Write the final file with release sections in this order: - Write the final file with sections in this order:
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions` `## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry - Only include sections that have at least one notable entry
- Within each release section, keep bug fixes grouped under `### Bugfixes`
- Keep other notable entries under `### Improvements` when a section has bug fixes too
- Omit empty subsections
- Keep one bullet per commit you keep - Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing - Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter - Start each bullet with a capital letter
+1 -1
View File
@@ -3,7 +3,7 @@
"provider": {}, "provider": {},
"permission": { "permission": {
"edit": { "edit": {
"packages/opencode/migration/*": "ask", "packages/opencode/migration/*": "deny",
}, },
}, },
"mcp": {}, "mcp": {},
+285 -367
View File
@@ -1,62 +1,35 @@
/** @jsxImportSource @opentui/solid */ /** @jsxImportSource @opentui/solid */
import { useTerminalDimensions, type JSX } from "@opentui/solid" import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useBindings, useKeymapSelector } from "@opentui/keymap/solid" import { RGBA, VignetteEffect } from "@opentui/core"
import { RGBA, VignetteEffect, type KeyEvent, type Renderable } from "@opentui/core" import type {
import { createBindingLookup, type BindingConfig } from "@opentui/keymap/extras" TuiKeybindSet,
import type { TuiPlugin, TuiPluginApi, TuiPluginMeta, TuiPluginModule, TuiSlotPlugin } from "@opencode-ai/plugin/tui" TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"] const tabs = ["overview", "counter", "help"]
const command = { const bind = {
modal: "smoke_modal", modal: "ctrl+shift+m",
screen: "smoke_screen", screen: "ctrl+shift+o",
alert: "smoke_alert", home: "escape,ctrl+h",
confirm: "smoke_confirm", left: "left,h",
prompt: "smoke_prompt", right: "right,l",
select: "smoke_select", up: "up,k",
host: "smoke_host", down: "down,j",
home: "smoke_home", alert: "a",
toast: "smoke_toast", confirm: "c",
dialog_close: "smoke_dialog_close", prompt: "p",
local_push: "smoke_local_push", select: "s",
local_pop: "smoke_local_pop", modal_accept: "enter,return",
screen_home: "smoke_screen_home", modal_close: "escape",
screen_left: "smoke_screen_left", dialog_close: "escape",
screen_right: "smoke_screen_right", local: "x",
screen_up: "smoke_screen_up", local_push: "enter,return",
screen_down: "smoke_screen_down", local_close: "q,backspace",
screen_modal: "smoke_screen_modal", host: "z",
screen_local: "smoke_screen_local",
screen_host: "smoke_screen_host",
screen_alert: "smoke_screen_alert",
screen_confirm: "smoke_screen_confirm",
screen_prompt: "smoke_screen_prompt",
screen_select: "smoke_screen_select",
modal_accept: "smoke_modal_accept",
modal_close: "smoke_modal_close",
}
type SmokeBindings = BindingConfig<Renderable, KeyEvent>
const defaultKeymap = {
[command.modal]: "ctrl+shift+m",
[command.screen]: "ctrl+shift+o",
[command.dialog_close]: "escape",
[command.local_push]: "enter,return",
[command.local_pop]: "escape,q,backspace",
[command.screen_home]: "escape,ctrl+h",
[command.screen_left]: "left,h",
[command.screen_right]: "right,l",
[command.screen_up]: "up,k",
[command.screen_down]: "down,j",
[command.screen_modal]: "ctrl+shift+m",
[command.screen_local]: "x",
[command.screen_host]: "z",
[command.screen_alert]: "a",
[command.screen_confirm]: "c",
[command.screen_prompt]: "p",
[command.screen_select]: "s",
[command.modal_accept]: "enter,return",
[command.modal_close]: "escape",
} }
const pick = (value: unknown, fallback: string) => { const pick = (value: unknown, fallback: string) => {
@@ -70,14 +43,16 @@ const num = (value: unknown, fallback: number) => {
return value return value
} }
const record = (value: unknown): value is Record<string, unknown> => const rec = (value: unknown) => {
!!value && typeof value === "object" && !Array.isArray(value) if (!value || typeof value !== "object" || Array.isArray(value)) return
return Object.fromEntries(Object.entries(value))
}
type Cfg = { type Cfg = {
label: string label: string
route: string route: string
vignette: number vignette: number
keybinds: SmokeBindings | undefined keybinds: Record<string, unknown> | undefined
} }
type Route = { type Route = {
@@ -99,7 +74,7 @@ const cfg = (options: Record<string, unknown> | undefined) => {
label: pick(options?.label, "smoke"), label: pick(options?.label, "smoke"),
route: pick(options?.route, "workspace-smoke"), route: pick(options?.route, "workspace-smoke"),
vignette: Math.max(0, num(options?.vignette, 0.35)), vignette: Math.max(0, num(options?.vignette, 0.35)),
keybinds: record(options?.keybinds) ? (options.keybinds as SmokeBindings) : undefined, keybinds: rec(options?.keybinds),
} }
} }
@@ -110,12 +85,7 @@ const names = (input: Cfg) => {
} }
} }
function createKeys(input: SmokeBindings | undefined) { type Keys = TuiKeybindSet
return createBindingLookup({ ...defaultKeymap, ...input })
}
type Keys = ReturnType<typeof createKeys>
const ui = { const ui = {
panel: "#1d1d1d", panel: "#1d1d1d",
border: "#4a4a4a", border: "#4a4a4a",
@@ -322,174 +292,125 @@ const Screen = (props: {
} }
const pop = (base?: State) => { const pop = (base?: State) => {
const next = base ?? current(props.api, props.route) const next = base ?? current(props.api, props.route)
set(Math.max(0, next.local - 1), next) const local = Math.max(0, next.local - 1)
set(local, next)
} }
const show = () => { const show = () => {
setTimeout(() => { setTimeout(() => {
open() open()
}, 0) }, 0)
} }
const screenActive = () => props.api.route.current.name === props.route.screen useKeyboard((evt) => {
if (props.api.route.current.name !== props.route.screen) return
const next = current(props.api, props.route)
if (props.api.ui.dialog.open) {
if (props.keys.match("dialog_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.ui.dialog.clear()
return
}
return
}
useBindings(() => ({ if (next.local > 0) {
enabled: () => screenActive() && props.api.ui.dialog.open, if (evt.name === "escape" || props.keys.match("local_close", evt)) {
commands: [ evt.preventDefault()
{ evt.stopPropagation()
name: command.dialog_close, pop(next)
run() { return
props.api.ui.dialog.clear() }
},
},
],
bindings: props.keys.gather("smoke.dialog", [command.dialog_close]),
}))
useBindings(() => ({ if (props.keys.match("local_push", evt)) {
enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local > 0, evt.preventDefault()
commands: [ evt.stopPropagation()
{ push(next)
name: command.local_push, return
run() { }
push(current(props.api, props.route)) return
}, }
},
{
name: command.local_pop,
run() {
pop(current(props.api, props.route))
},
},
],
bindings: props.keys.gather("smoke.local", [command.local_push, command.local_pop]),
}))
useBindings(() => ({ if (props.keys.match("home", evt)) {
enabled: () => screenActive() && !props.api.ui.dialog.open && current(props.api, props.route).local === 0, evt.preventDefault()
commands: [ evt.stopPropagation()
{ props.api.route.navigate("home")
name: command.screen_home, return
run() { }
props.api.route.navigate("home")
},
},
{
name: command.screen_left,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
},
},
{
name: command.screen_right,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
},
},
{
name: command.screen_up,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
},
},
{
name: command.screen_down,
run() {
const next = current(props.api, props.route)
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
},
},
{
name: command.screen_modal,
run() {
props.api.route.navigate(props.route.modal, current(props.api, props.route))
},
},
{
name: command.screen_local,
run() {
open()
},
},
{
name: command.screen_host,
run() {
host(props.api, props.input, skin)
},
},
{
name: command.screen_alert,
run() {
warn(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_confirm,
run() {
check(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_prompt,
run() {
entry(props.api, props.route, current(props.api, props.route))
},
},
{
name: command.screen_select,
run() {
picker(props.api, props.route, current(props.api, props.route))
},
},
],
bindings: props.keys.gather("smoke.screen", [
command.screen_home,
command.screen_left,
command.screen_right,
command.screen_up,
command.screen_down,
command.screen_modal,
command.screen_local,
command.screen_host,
command.screen_alert,
command.screen_confirm,
command.screen_prompt,
command.screen_select,
]),
}))
const shortcuts = useKeymapSelector((keymap) => {
const bindings = keymap.getCommandBindings({
visibility: "registered",
commands: [
command.screen_home,
command.screen_up,
command.screen_down,
command.screen_modal,
command.screen_alert,
command.screen_confirm,
command.screen_prompt,
command.screen_select,
command.screen_local,
command.screen_host,
command.local_push,
command.local_pop,
],
})
return { if (props.keys.match("left", evt)) {
screen_home: props.api.keys.formatBindings(bindings.get(command.screen_home)) ?? "", evt.preventDefault()
screen_up: props.api.keys.formatBindings(bindings.get(command.screen_up)) ?? "", evt.stopPropagation()
screen_down: props.api.keys.formatBindings(bindings.get(command.screen_down)) ?? "", props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab - 1 + tabs.length) % tabs.length })
screen_modal: props.api.keys.formatBindings(bindings.get(command.screen_modal)) ?? "", return
screen_alert: props.api.keys.formatBindings(bindings.get(command.screen_alert)) ?? "", }
screen_confirm: props.api.keys.formatBindings(bindings.get(command.screen_confirm)) ?? "",
screen_prompt: props.api.keys.formatBindings(bindings.get(command.screen_prompt)) ?? "", if (props.keys.match("right", evt)) {
screen_select: props.api.keys.formatBindings(bindings.get(command.screen_select)) ?? "", evt.preventDefault()
screen_local: props.api.keys.formatBindings(bindings.get(command.screen_local)) ?? "", evt.stopPropagation()
screen_host: props.api.keys.formatBindings(bindings.get(command.screen_host)) ?? "", props.api.route.navigate(props.route.screen, { ...next, tab: (next.tab + 1) % tabs.length })
local_push: props.api.keys.formatBindings(bindings.get(command.local_push)) ?? "", return
local_pop: props.api.keys.formatBindings(bindings.get(command.local_pop)) ?? "", }
if (props.keys.match("up", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count + 1 })
return
}
if (props.keys.match("down", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.screen, { ...next, count: next.count - 1 })
return
}
if (props.keys.match("modal", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate(props.route.modal, next)
return
}
if (props.keys.match("local", evt)) {
evt.preventDefault()
evt.stopPropagation()
open()
return
}
if (props.keys.match("host", evt)) {
evt.preventDefault()
evt.stopPropagation()
host(props.api, props.input, skin)
return
}
if (props.keys.match("alert", evt)) {
evt.preventDefault()
evt.stopPropagation()
warn(props.api, props.route, next)
return
}
if (props.keys.match("confirm", evt)) {
evt.preventDefault()
evt.stopPropagation()
check(props.api, props.route, next)
return
}
if (props.keys.match("prompt", evt)) {
evt.preventDefault()
evt.stopPropagation()
entry(props.api, props.route, next)
return
}
if (props.keys.match("select", evt)) {
evt.preventDefault()
evt.stopPropagation()
picker(props.api, props.route, next)
} }
}) })
@@ -509,7 +430,7 @@ const Screen = (props: {
<b>{props.input.label} screen</b> <b>{props.input.label} screen</b>
<span style={{ fg: skin.muted }}> plugin route</span> <span style={{ fg: skin.muted }}> plugin route</span>
</text> </text>
<text fg={skin.muted}>{shortcuts().screen_home} home</text> <text fg={skin.muted}>{props.keys.print("home")} home</text>
</box> </box>
<box flexDirection="row" gap={1} paddingBottom={1}> <box flexDirection="row" gap={1} paddingBottom={1}>
@@ -556,7 +477,7 @@ const Screen = (props: {
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text fg={skin.text}>Counter: {value.count}</text> <text fg={skin.text}>Counter: {value.count}</text>
<text fg={skin.muted}> <text fg={skin.muted}>
{shortcuts().screen_up} / {shortcuts().screen_down} change value {props.keys.print("up")} / {props.keys.print("down")} change value
</text> </text>
</box> </box>
) : null} ) : null}
@@ -564,16 +485,17 @@ const Screen = (props: {
{value.tab === 2 ? ( {value.tab === 2 ? (
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<text fg={skin.muted}> <text fg={skin.muted}>
{shortcuts().screen_modal} modal | {shortcuts().screen_alert} alert | {shortcuts().screen_confirm}{" "} {props.keys.print("modal")} modal | {props.keys.print("alert")} alert | {props.keys.print("confirm")}{" "}
confirm | {shortcuts().screen_prompt} prompt | {shortcuts().screen_select} select confirm | {props.keys.print("prompt")} prompt | {props.keys.print("select")} select
</text> </text>
<text fg={skin.muted}> <text fg={skin.muted}>
{shortcuts().screen_local} local stack | {shortcuts().screen_host} host stack {props.keys.print("local")} local stack | {props.keys.print("host")} host stack
</text> </text>
<text fg={skin.muted}> <text fg={skin.muted}>
local open: {shortcuts().local_push} push nested · {shortcuts().local_pop} close local open: {props.keys.print("local_push")} push nested · esc or {props.keys.print("local_close")}{" "}
close
</text> </text>
<text fg={skin.muted}>{shortcuts().screen_home} returns home</text> <text fg={skin.muted}>{props.keys.print("home")} returns home</text>
</box> </box>
) : null} ) : null}
</box> </box>
@@ -626,7 +548,7 @@ const Screen = (props: {
</text> </text>
<text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text> <text fg={skin.muted}>Plugin-owned stack depth: {value.local}</text>
<text fg={skin.muted}> <text fg={skin.muted}>
{shortcuts().local_push} push nested · {shortcuts().local_pop} pop/close {props.keys.print("local_push")} push nested · {props.keys.print("local_close")} pop/close
</text> </text>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<Btn txt="push" run={push} skin={skin} on /> <Btn txt="push" run={push} skin={skin} on />
@@ -649,35 +571,20 @@ const Modal = (props: {
const value = parse(props.params) const value = parse(props.params)
const skin = tone(props.api) const skin = tone(props.api)
useBindings(() => ({ useKeyboard((evt) => {
enabled: () => props.api.route.current.name === props.route.modal, if (props.api.route.current.name !== props.route.modal) return
commands: [
{
name: command.modal_accept,
run() {
props.api.route.navigate(props.route.screen, { ...parse(props.params), source: "modal" })
},
},
{
name: command.modal_close,
run() {
props.api.route.navigate("home")
},
},
],
bindings: props.keys.gather("smoke.modal", [command.modal_accept, command.modal_close]),
}))
const shortcuts = useKeymapSelector((keymap) => {
const bindings = keymap.getCommandBindings({
visibility: "registered",
commands: [command.modal, command.screen, command.modal_accept, command.modal_close],
})
return { if (props.keys.match("modal_accept", evt)) {
modal: props.api.keys.formatBindings(bindings.get(command.modal)) ?? "", evt.preventDefault()
screen: props.api.keys.formatBindings(bindings.get(command.screen)) ?? "", evt.stopPropagation()
modal_accept: props.api.keys.formatBindings(bindings.get(command.modal_accept)) ?? "", props.api.route.navigate(props.route.screen, { ...value, source: "modal" })
modal_close: props.api.keys.formatBindings(bindings.get(command.modal_close)) ?? "", return
}
if (props.keys.match("modal_close", evt)) {
evt.preventDefault()
evt.stopPropagation()
props.api.route.navigate("home")
} }
}) })
@@ -688,10 +595,10 @@ const Modal = (props: {
<text fg={skin.text}> <text fg={skin.text}>
<b>{props.input.label} modal</b> <b>{props.input.label} modal</b>
</text> </text>
<text fg={skin.muted}>{shortcuts().modal} modal command</text> <text fg={skin.muted}>{props.keys.print("modal")} modal command</text>
<text fg={skin.muted}>{shortcuts().screen} screen command</text> <text fg={skin.muted}>{props.keys.print("screen")} screen command</text>
<text fg={skin.muted}> <text fg={skin.muted}>
{shortcuts().modal_accept} opens screen · {shortcuts().modal_close} closes {props.keys.print("modal_accept")} opens screen · {props.keys.print("modal_close")} closes
</text> </text>
<box flexDirection="row" gap={1}> <box flexDirection="row" gap={1}>
<Btn <Btn
@@ -744,8 +651,25 @@ const home = (api: TuiPluginApi, input: Cfg) => ({
}, },
home_prompt(ctx, value) { home_prompt(ctx, value) {
const skin = look(ctx.theme.current) const skin = look(ctx.theme.current)
const Prompt = api.ui.Prompt type Prompt = (props: {
const Slot = api.ui.Slot workspaceID?: string
visible?: boolean
disabled?: boolean
onSubmit?: () => void
hint?: JSX.Element
right?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
type Slot = (
props: { name: string; mode?: unknown; children?: JSX.Element } & Record<string, unknown>,
) => JSX.Element | null
const ui = api.ui as TuiPluginApi["ui"] & { Prompt: Prompt; Slot: Slot }
const Prompt = ui.Prompt
const Slot = ui.Slot
const normal = [ const normal = [
`[SMOKE] route check for ${input.label}`, `[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override", "[SMOKE] confirm home_prompt slot override",
@@ -867,115 +791,109 @@ const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => { const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
const route = names(input) const route = names(input)
api.keymap.registerLayer({ api.command.register(() => [
commands: [ {
{ title: `${input.label} modal`,
name: command.modal, value: "plugin.smoke.modal",
title: `${input.label} modal`, keybind: keys.get("modal"),
category: "Plugin", category: "Plugin",
namespace: "palette", slash: {
slashName: "smoke", name: "smoke",
run() {
api.route.navigate(route.modal, { source: "command" })
},
}, },
{ onSelect: () => {
name: command.screen, api.route.navigate(route.modal, { source: "command" })
title: `${input.label} screen`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-screen",
run() {
api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
},
}, },
{ },
name: command.alert, {
title: `${input.label} alert dialog`, title: `${input.label} screen`,
category: "Plugin", value: "plugin.smoke.screen",
namespace: "palette", keybind: keys.get("screen"),
slashName: "smoke-alert", category: "Plugin",
run() { slash: {
warn(api, route, current(api, route)) name: "smoke-screen",
},
}, },
{ onSelect: () => {
name: command.confirm, api.route.navigate(route.screen, { source: "command", tab: 0, count: 0 })
title: `${input.label} confirm dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-confirm",
run() {
check(api, route, current(api, route))
},
}, },
{ },
name: command.prompt, {
title: `${input.label} prompt dialog`, title: `${input.label} alert dialog`,
category: "Plugin", value: "plugin.smoke.alert",
namespace: "palette", category: "Plugin",
slashName: "smoke-prompt", slash: {
run() { name: "smoke-alert",
entry(api, route, current(api, route))
},
}, },
{ onSelect: () => {
name: command.select, warn(api, route, current(api, route))
title: `${input.label} select dialog`,
category: "Plugin",
namespace: "palette",
slashName: "smoke-select",
run() {
picker(api, route, current(api, route))
},
}, },
{ },
name: command.host, {
title: `${input.label} host overlay`, title: `${input.label} confirm dialog`,
category: "Plugin", value: "plugin.smoke.confirm",
namespace: "palette", category: "Plugin",
slashName: "smoke-host", slash: {
run() { name: "smoke-confirm",
host(api, input, tone(api))
},
}, },
{ onSelect: () => {
name: command.home, check(api, route, current(api, route))
title: `${input.label} go home`,
category: "Plugin",
namespace: "palette",
enabled: () => api.route.current.name !== "home",
run() {
api.route.navigate("home")
},
}, },
{ },
name: command.toast, {
title: `${input.label} toast`, title: `${input.label} prompt dialog`,
category: "Plugin", value: "plugin.smoke.prompt",
namespace: "palette", category: "Plugin",
run() { slash: {
api.ui.toast({ name: "smoke-prompt",
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
}, },
], onSelect: () => {
bindings: keys.gather("smoke.global", [ entry(api, route, current(api, route))
command.modal, },
command.screen, },
command.alert, {
command.confirm, title: `${input.label} select dialog`,
command.prompt, value: "plugin.smoke.select",
command.select, category: "Plugin",
command.host, slash: {
command.home, name: "smoke-select",
command.toast, },
]), onSelect: () => {
}) picker(api, route, current(api, route))
},
},
{
title: `${input.label} host overlay`,
value: "plugin.smoke.host",
category: "Plugin",
slash: {
name: "smoke-host",
},
onSelect: () => {
host(api, input, tone(api))
},
},
{
title: `${input.label} go home`,
value: "plugin.smoke.home",
category: "Plugin",
enabled: api.route.current.name !== "home",
onSelect: () => {
api.route.navigate("home")
},
},
{
title: `${input.label} toast`,
value: "plugin.smoke.toast",
category: "Plugin",
onSelect: () => {
api.ui.toast({
variant: "info",
title: "Smoke",
message: "Plugin toast works",
duration: 2000,
})
},
},
])
} }
const tui: TuiPlugin = async (api, options, meta) => { const tui: TuiPlugin = async (api, options, meta) => {
@@ -984,9 +902,9 @@ const tui: TuiPlugin = async (api, options, meta) => {
await api.theme.install("./smoke-theme.json") await api.theme.install("./smoke-theme.json")
api.theme.set("smoke-theme") api.theme.set("smoke-theme")
const value = cfg(options) const value = cfg(options ?? undefined)
const route = names(value) const route = names(value)
const keys = createKeys(value.keybinds) const keys = api.keybind.create(bind, value.keybinds)
const fx = new VignetteEffect(value.vignette) const fx = new VignetteEffect(value.vignette)
const post = fx.apply.bind(fx) const post = fx.apply.bind(fx)
api.renderer.addPostProcessFn(post) api.renderer.addPostProcessFn(post)
-8
View File
@@ -28,11 +28,3 @@ Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3
- In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior. - In tests, prefer the repo's existing Effect test helpers and live tests for filesystem, git, child process, locks, or timing behavior.
- Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types. - Do not introduce `any`, non-null assertions, unchecked casts, or older Effect APIs just to satisfy types.
- Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first. - Do not answer from memory. Verify against `.opencode/references/effect-smol` or nearby code first.
## Testing Patterns
- Use `testEffect(...)` from `packages/opencode/test/lib/effect.ts` for tests that exercise Effect services, layers, runtime context, scoped resources, or platform integrations.
- Use `it.live(...)` for filesystem, git repositories, HTTP servers, sockets, child processes, locks, real time, and other live platform behavior.
- Run tests from package directories such as `packages/opencode`; never run package tests from the repo root.
- Prefer explicit test layers over ad hoc managed runtimes. Keep dependency provisioning visible in the test file.
- Use scoped fixtures and finalizers for resources that must be cleaned up, including temporary directories, flags, databases, fibers, servers, and global state.
+68 -12
View File
@@ -1,14 +1,16 @@
/// <reference path="../env.d.ts" /> /// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin" import { tool } from "@opencode-ai/plugin"
const TEAM = { const TEAM = {
tui: ["kommander", "simonklee"], desktop: ["adamdotdevin", "iamdavidhill", "Brendonovich", "nexxeln"],
desktop_web: ["Hona", "Brendonovich"], zen: ["fwang", "MrMushrooooom"],
core: ["jlongster", "rekram1-node", "nexxeln", "kitlangton"], tui: ["kommander", "rekram1-node", "simonklee"],
inference: ["fwang", "MrMushrooooom"], core: ["kitlangton", "rekram1-node", "jlongster"],
docs: ["R44VC0RP"],
windows: ["Hona"], windows: ["Hona"],
} as const } as const
const ASSIGNEES = [...new Set(Object.values(TEAM).flat())]
function pick<T>(items: readonly T[]) { function pick<T>(items: readonly T[]) {
return items[Math.floor(Math.random() * items.length)]! return items[Math.floor(Math.random() * items.length)]!
} }
@@ -36,25 +38,79 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
} }
export default tool({ export default tool({
description: `Use this tool to assign a GitHub issue. description: `Use this tool to assign and/or label a GitHub issue.
Provide the team that should own the issue. This tool picks a random assignee from that team and does not apply labels.`, Choose labels and assignee using the current triage policy and ownership rules.
Pick the most fitting labels for the issue and assign one owner.
If unsure, choose the team/section with the most overlap with the issue and assign a member from that team at random.`,
args: { args: {
team: tool.schema assignee: tool.schema
.enum(Object.keys(TEAM) as [keyof typeof TEAM, ...(keyof typeof TEAM)[]]) .enum(ASSIGNEES as [string, ...string[]])
.describe("The owning team"), .describe("The username of the assignee")
.default("rekram1-node"),
labels: tool.schema
.array(tool.schema.enum(["nix", "opentui", "perf", "web", "desktop", "zen", "docs", "windows", "core"]))
.describe("The labels(s) to add to the issue")
.default([]),
}, },
async execute(args) { async execute(args) {
const issue = getIssueNumber() const issue = getIssueNumber()
const owner = "anomalyco" const owner = "anomalyco"
const repo = "opencode" const repo = "opencode"
const assignee = pick(TEAM[args.team])
const results: string[] = []
let labels = [...new Set(args.labels.map((x) => (x === "desktop" ? "web" : x)))]
const web = labels.includes("web")
const text = `${process.env.ISSUE_TITLE ?? ""}\n${process.env.ISSUE_BODY ?? ""}`.toLowerCase()
const zen = /\bzen\b/.test(text) || text.includes("opencode black")
const nix = /\bnix(os)?\b/.test(text)
if (labels.includes("nix") && !nix) {
labels = labels.filter((x) => x !== "nix")
results.push("Dropped label: nix (issue does not mention nix)")
}
const assignee = nix ? "rekram1-node" : web ? pick(TEAM.desktop) : args.assignee
if (labels.includes("zen") && !zen) {
throw new Error("Only add the zen label when issue title/body contains 'zen'")
}
if (web && !nix && !(TEAM.desktop as readonly string[]).includes(assignee)) {
throw new Error("Web issues must be assigned to adamdotdevin, iamdavidhill, Brendonovich, or nexxeln")
}
if ((TEAM.zen as readonly string[]).includes(assignee) && !labels.includes("zen")) {
throw new Error("Only zen issues should be assigned to fwang or MrMushrooooom")
}
if (assignee === "Hona" && !labels.includes("windows")) {
throw new Error("Only windows issues should be assigned to Hona")
}
if (assignee === "R44VC0RP" && !labels.includes("docs")) {
throw new Error("Only docs issues should be assigned to R44VC0RP")
}
if (assignee === "kommander" && !labels.includes("opentui")) {
throw new Error("Only opentui issues should be assigned to kommander")
}
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, { await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/assignees`, {
method: "POST", method: "POST",
body: JSON.stringify({ assignees: [assignee] }), body: JSON.stringify({ assignees: [assignee] }),
}) })
results.push(`Assigned @${assignee} to issue #${issue}`)
return `Assigned @${assignee} from ${args.team} to issue #${issue}` if (labels.length > 0) {
await githubFetch(`/repos/${owner}/${repo}/issues/${issue}/labels`, {
method: "POST",
body: JSON.stringify({ labels }),
})
results.push(`Added labels: ${labels.join(", ")}`)
}
return results.join("\n")
}, },
}) })
+4 -5
View File
@@ -7,11 +7,10 @@
"enabled": false, "enabled": false,
"label": "workspace", "label": "workspace",
"keybinds": { "keybinds": {
"smoke_modal": "ctrl+alt+m", "modal": "ctrl+alt+m",
"smoke_screen": "ctrl+alt+o", "screen": "ctrl+alt+o",
"smoke_screen_home": "escape,ctrl+shift+h", "home": "escape,ctrl+shift+h",
"smoke_screen_modal": "ctrl+alt+m", "dialog_close": "escape,q"
"smoke_dialog_close": "escape,q"
} }
} }
] ]
-1
View File
@@ -9,7 +9,6 @@
### General Principles ### General Principles
- Keep things in one function unless composable or reusable - Keep things in one function unless composable or reusable
- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller.
- Avoid `try`/`catch` where possible - Avoid `try`/`catch` where possible
- Avoid using the `any` type - Avoid using the `any` type
- Use Bun APIs when possible, like `Bun.file()` - Use Bun APIs when possible, like `Bun.file()`
+18 -6
View File
@@ -73,7 +73,7 @@ Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
- `packages/opencode`: OpenCode core business logic & server. - `packages/opencode`: OpenCode core business logic & server.
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui) - `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
- `packages/app`: The shared web UI components, written in SolidJS - `packages/app`: The shared web UI components, written in SolidJS
- `packages/desktop`: The native desktop app, built with Electron (wraps `packages/app`) - `packages/desktop`: The native desktop app, built with Tauri (wraps `packages/app`)
- `packages/plugin`: Source for `@opencode-ai/plugin` - `packages/plugin`: Source for `@opencode-ai/plugin`
### Understanding bun dev vs opencode ### Understanding bun dev vs opencode
@@ -123,21 +123,33 @@ This starts a local dev server at http://localhost:5173 (or similar port shown i
### Running the Desktop App ### Running the Desktop App
The desktop app is an Electron application that wraps the web UI. The desktop app is a native Tauri application that wraps the web UI.
To run the desktop app in development: To run the native desktop app:
```bash
bun run --cwd packages/desktop tauri dev
```
This starts the web dev server on http://localhost:1420 and opens the native window.
If you only want the web dev server (no native shell):
```bash ```bash
bun run --cwd packages/desktop dev bun run --cwd packages/desktop dev
``` ```
To create a production build and package the app: To create a production `dist/` and build the native app bundle:
```bash ```bash
bun run --cwd packages/desktop build bun run --cwd packages/desktop tauri build
bun run --cwd packages/desktop package
``` ```
This runs `bun run --cwd packages/desktop build` automatically via Tauris `beforeBuildCommand`.
> [!NOTE]
> Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
> [!NOTE] > [!NOTE]
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files. > If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # او github:anomalyco/opencode لاحدث
يتوفر OpenCode ايضا كتطبيق سطح مكتب. قم بالتنزيل مباشرة من [صفحة الاصدارات](https://github.com/anomalyco/opencode/releases) او من [opencode.ai/download](https://opencode.ai/download). يتوفر OpenCode ايضا كتطبيق سطح مكتب. قم بالتنزيل مباشرة من [صفحة الاصدارات](https://github.com/anomalyco/opencode/releases) او من [opencode.ai/download](https://opencode.ai/download).
| المنصة | التنزيل | | المنصة | التنزيل |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb` او `.rpm` او AppImage | | Linux | `.deb` او `.rpm` او AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev
OpenCode ডেস্কটপ অ্যাপ্লিকেশন হিসেবেও উপলব্ধ। সরাসরি [রিলিজ পেজ](https://github.com/anomalyco/opencode/releases) অথবা [opencode.ai/download](https://opencode.ai/download) থেকে ডাউনলোড করুন। OpenCode ডেস্কটপ অ্যাপ্লিকেশন হিসেবেও উপলব্ধ। সরাসরি [রিলিজ পেজ](https://github.com/anomalyco/opencode/releases) অথবা [opencode.ai/download](https://opencode.ai/download) থেকে ডাউনলোড করুন।
| প্ল্যাটফর্ম | ডাউনলোড | | প্ল্যাটফর্ম | ডাউনলোড |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, or `.AppImage` | | Linux | `.deb`, `.rpm`, or AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # ou github:anomalyco/opencode para a branch
O OpenCode também está disponível como aplicativo desktop. Baixe diretamente pela [página de releases](https://github.com/anomalyco/opencode/releases) ou em [opencode.ai/download](https://opencode.ai/download). O OpenCode também está disponível como aplicativo desktop. Baixe diretamente pela [página de releases](https://github.com/anomalyco/opencode/releases) ou em [opencode.ai/download](https://opencode.ai/download).
| Plataforma | Download | | Plataforma | Download |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` ou AppImage | | Linux | `.deb`, `.rpm` ou AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # ili github:anomalyco/opencode za najnoviji
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). 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 | | Platforma | Preuzimanje |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, ili AppImage | | Linux | `.deb`, `.rpm`, ili AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste
OpenCode findes også som desktop-app. Download direkte fra [releases-siden](https://github.com/anomalyco/opencode/releases) eller [opencode.ai/download](https://opencode.ai/download). OpenCode findes også som desktop-app. Download direkte fra [releases-siden](https://github.com/anomalyco/opencode/releases) eller [opencode.ai/download](https://opencode.ai/download).
| Platform | Download | | Platform | Download |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, eller AppImage | | Linux | `.deb`, `.rpm`, eller AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # oder github:anomalyco/opencode für den neu
OpenCode ist auch als Desktop-Anwendung verfügbar. Lade sie direkt von der [Releases-Seite](https://github.com/anomalyco/opencode/releases) oder [opencode.ai/download](https://opencode.ai/download) herunter. OpenCode ist auch als Desktop-Anwendung verfügbar. Lade sie direkt von der [Releases-Seite](https://github.com/anomalyco/opencode/releases) oder [opencode.ai/download](https://opencode.ai/download) herunter.
| Plattform | Download | | Plattform | Download |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` oder AppImage | | Linux | `.deb`, `.rpm` oder AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # o github:anomalyco/opencode para la rama de
OpenCode también está disponible como aplicación de escritorio. Descárgala directamente desde la [página de releases](https://github.com/anomalyco/opencode/releases) o desde [opencode.ai/download](https://opencode.ai/download). OpenCode también está disponible como aplicación de escritorio. Descárgala directamente desde la [página de releases](https://github.com/anomalyco/opencode/releases) o desde [opencode.ai/download](https://opencode.ai/download).
| Plataforma | Descarga | | Plataforma | Descarga |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, o AppImage | | Linux | `.deb`, `.rpm`, o AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # ou github:anomalyco/opencode pour la branch
OpenCode est aussi disponible en application de bureau. Téléchargez-la directement depuis la [page des releases](https://github.com/anomalyco/opencode/releases) ou [opencode.ai/download](https://opencode.ai/download). OpenCode est aussi disponible en application de bureau. Téléchargez-la directement depuis la [page des releases](https://github.com/anomalyco/opencode/releases) ou [opencode.ai/download](https://opencode.ai/download).
| Plateforme | Téléchargement | | Plateforme | Téléchargement |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, ou AppImage | | Linux | `.deb`, `.rpm`, ou AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # ή github:anomalyco/opencode με βάση
Το OpenCode είναι επίσης διαθέσιμο ως εφαρμογή. Κατέβασε το απευθείας από τη [σελίδα εκδόσεων](https://github.com/anomalyco/opencode/releases) ή το [opencode.ai/download](https://opencode.ai/download). Το OpenCode είναι επίσης διαθέσιμο ως εφαρμογή. Κατέβασε το απευθείας από τη [σελίδα εκδόσεων](https://github.com/anomalyco/opencode/releases) ή το [opencode.ai/download](https://opencode.ai/download).
| Πλατφόρμα | Λήψη | | Πλατφόρμα | Λήψη |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, ή AppImage | | Linux | `.deb`, `.rpm`, ή AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # oppure github:anomalyco/opencode per lul
OpenCode è disponibile anche come applicazione desktop. Puoi scaricarla direttamente dalla [pagina delle release](https://github.com/anomalyco/opencode/releases) oppure da [opencode.ai/download](https://opencode.ai/download). OpenCode è disponibile anche come applicazione desktop. Puoi scaricarla direttamente dalla [pagina delle release](https://github.com/anomalyco/opencode/releases) oppure da [opencode.ai/download](https://opencode.ai/download).
| Piattaforma | Download | | Piattaforma | Download |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, oppure AppImage | | Linux | `.deb`, `.rpm`, oppure AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # または github:anomalyco/opencode で最
OpenCode はデスクトップアプリとしても利用できます。[releases page](https://github.com/anomalyco/opencode/releases) から直接ダウンロードするか、[opencode.ai/download](https://opencode.ai/download) を利用してください。 OpenCode はデスクトップアプリとしても利用できます。[releases page](https://github.com/anomalyco/opencode/releases) から直接ダウンロードするか、[opencode.ai/download](https://opencode.ai/download) を利用してください。
| プラットフォーム | ダウンロード | | プラットフォーム | ダウンロード |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb``.rpm`、または AppImage | | Linux | `.deb``.rpm`、または AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # 또는 github:anomalyco/opencode 로 최신
OpenCode 는 데스크톱 앱으로도 제공됩니다. [releases page](https://github.com/anomalyco/opencode/releases) 에서 직접 다운로드하거나 [opencode.ai/download](https://opencode.ai/download) 를 이용하세요. OpenCode 는 데스크톱 앱으로도 제공됩니다. [releases page](https://github.com/anomalyco/opencode/releases) 에서 직접 다운로드하거나 [opencode.ai/download](https://opencode.ai/download) 를 이용하세요.
| 플랫폼 | 다운로드 | | 플랫폼 | 다운로드 |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, 또는 AppImage | | Linux | `.deb`, `.rpm`, 또는 AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+7 -7
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev
OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/anomalyco/opencode/releases) or [opencode.ai/download](https://opencode.ai/download). OpenCode is also available as a desktop application. Download directly from the [releases page](https://github.com/anomalyco/opencode/releases) or [opencode.ai/download](https://opencode.ai/download).
| Platform | Download | | Platform | Download |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, or `.AppImage` | | Linux | `.deb`, `.rpm`, or AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
@@ -132,7 +132,7 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
- 100% open source - 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. - 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.
- Built-in opt-in LSP support - 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 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 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.
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # eller github:anomalyco/opencode for nyeste
OpenCode er også tilgjengelig som en desktop-app. Last ned direkte fra [releases-siden](https://github.com/anomalyco/opencode/releases) eller [opencode.ai/download](https://opencode.ai/download). OpenCode er også tilgjengelig som en desktop-app. Last ned direkte fra [releases-siden](https://github.com/anomalyco/opencode/releases) eller [opencode.ai/download](https://opencode.ai/download).
| Plattform | Nedlasting | | Plattform | Nedlasting |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` eller AppImage | | Linux | `.deb`, `.rpm` eller AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # lub github:anomalyco/opencode dla najnowsze
OpenCode jest także dostępny jako aplikacja desktopowa. Pobierz ją bezpośrednio ze strony [releases](https://github.com/anomalyco/opencode/releases) lub z [opencode.ai/download](https://opencode.ai/download). OpenCode jest także dostępny jako aplikacja desktopowa. Pobierz ją bezpośrednio ze strony [releases](https://github.com/anomalyco/opencode/releases) lub z [opencode.ai/download](https://opencode.ai/download).
| Platforma | Pobieranie | | Platforma | Pobieranie |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` lub AppImage | | Linux | `.deb`, `.rpm` lub AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # или github:anomalyco/opencode для с
OpenCode также доступен как десктопное приложение. Скачайте его со [страницы релизов](https://github.com/anomalyco/opencode/releases) или с [opencode.ai/download](https://opencode.ai/download). OpenCode также доступен как десктопное приложение. Скачайте его со [страницы релизов](https://github.com/anomalyco/opencode/releases) или с [opencode.ai/download](https://opencode.ai/download).
| Платформа | Загрузка | | Платформа | Загрузка |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` или AppImage | | Linux | `.deb`, `.rpm` или AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # หรือ github:anomalyco/opencode ส
OpenCode มีให้ใช้งานเป็นแอปพลิเคชันเดสก์ท็อป ดาวน์โหลดโดยตรงจาก [หน้ารุ่น](https://github.com/anomalyco/opencode/releases) หรือ [opencode.ai/download](https://opencode.ai/download) OpenCode มีให้ใช้งานเป็นแอปพลิเคชันเดสก์ท็อป ดาวน์โหลดโดยตรงจาก [หน้ารุ่น](https://github.com/anomalyco/opencode/releases) หรือ [opencode.ai/download](https://opencode.ai/download)
| แพลตฟอร์ม | ดาวน์โหลด | | แพลตฟอร์ม | ดาวน์โหลด |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, หรือ AppImage | | Linux | `.deb`, `.rpm`, หรือ AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # veya en güncel geliştirme dalı için git
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. 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 | | Platform | İndirme |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` veya AppImage | | Linux | `.deb`, `.rpm` veya AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # або github:anomalyco/opencode для н
OpenCode також доступний як десктопний застосунок. Завантажуйте напряму зі [сторінки релізів](https://github.com/anomalyco/opencode/releases) або [opencode.ai/download](https://opencode.ai/download). OpenCode також доступний як десктопний застосунок. Завантажуйте напряму зі [сторінки релізів](https://github.com/anomalyco/opencode/releases) або [opencode.ai/download](https://opencode.ai/download).
| Платформа | Завантаження | | Платформа | Завантаження |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` або AppImage | | Linux | `.deb`, `.rpm` або AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # hoặc github:anomalyco/opencode cho nhánh
OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download). OpenCode cũng có sẵn dưới dạng ứng dụng desktop. Tải trực tiếp từ [trang releases](https://github.com/anomalyco/opencode/releases) hoặc [opencode.ai/download](https://opencode.ai/download).
| Nền tảng | Tải xuống | | Nền tảng | Tải xuống |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, hoặc AppImage | | Linux | `.deb`, `.rpm`, hoặc AppImage |
```bash ```bash
# macOS (Homebrew) # macOS (Homebrew)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最
OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下载。 OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下载。
| 平台 | 下载文件 | | 平台 | 下载文件 |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb``.rpm` 或 AppImage | | Linux | `.deb``.rpm` 或 AppImage |
```bash ```bash
# macOS (Homebrew Cask) # macOS (Homebrew Cask)
+6 -6
View File
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取
OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。 OpenCode 也提供桌面版應用程式。您可以直接從 [發佈頁面 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下載。
| 平台 | 下載連結 | | 平台 | 下載連結 |
| --------------------- | ---------------------------------- | | --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-mac-arm64.dmg` | | macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-mac-x64.dmg` | | macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` | | Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm`, 或 AppImage | | Linux | `.deb`, `.rpm`, 或 AppImage |
```bash ```bash
# macOS (Homebrew Cask) # macOS (Homebrew Cask)
+151 -397
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -30,7 +30,6 @@ export const api = new sst.cloudflare.Worker("Api", {
transform: { transform: {
worker: (args) => { worker: (args) => {
args.logpush = true args.logpush = true
if ($app.stage === "vimtor") return
args.bindings = $resolve(args.bindings).apply((bindings) => [ args.bindings = $resolve(args.bindings).apply((bindings) => [
...bindings, ...bindings,
{ {
-5
View File
@@ -1,6 +1,5 @@
import { domain } from "./stage" import { domain } from "./stage"
import { EMAILOCTOPUS_API_KEY } from "./app" import { EMAILOCTOPUS_API_KEY } from "./app"
import { SECRET } from "./secret"
//////////////// ////////////////
// DATABASE // DATABASE
@@ -222,7 +221,6 @@ const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", { const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
properties: { value: stripeWebhook.secret }, properties: { value: stripeWebhook.secret },
}) })
const gatewayKv = new sst.cloudflare.Kv("GatewayKv") const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
//////////////// ////////////////
@@ -232,7 +230,6 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
const bucket = new sst.cloudflare.Bucket("ZenData") const bucket = new sst.cloudflare.Bucket("ZenData")
const bucketNew = new sst.cloudflare.Bucket("ZenDataNew") const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID") 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") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
@@ -254,8 +251,6 @@ new sst.cloudflare.x.SolidStart("Console", {
database, database,
AUTH_API_URL, AUTH_API_URL,
STRIPE_WEBHOOK_SECRET, STRIPE_WEBHOOK_SECRET,
DISCORD_INCIDENT_WEBHOOK_URL,
SECRET.HoneycombWebhookSecret,
STRIPE_SECRET_KEY, STRIPE_SECRET_KEY,
EMAILOCTOPUS_API_KEY, EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID, AWS_SES_ACCESS_KEY_ID,
-202
View File
@@ -1,202 +0,0 @@
import { SECRET } from "./secret"
import { domain } from "./stage"
const webhookRecipient = new honeycomb.WebhookRecipient("DiscordAlerts", {
name: $app.stage === "production" ? "Discord Alerts" : `Discord Alerts (${$app.stage})`,
url: `https://${domain}/honeycomb/webhook`,
secret: SECRET.HoneycombWebhookSecret.result,
templates: [
{
type: "trigger",
body: `{
"url": {{ .Result.URL | quote }},
"type": {{ .Vars.type | quote }},
"name": {{ .Name | quote }},
"status": {{ .Alert.Status | quote }},
"isTest": {{ .Alert.IsTest }},
"groups": {{ .Result.GroupsTriggered | toJson }}
}`,
},
],
variables: [
{
name: "type",
},
],
})
const modelHttpErrorsQuery = (product: "go" | "zen") => {
const filters = [
{ column: "model", op: "exists" },
{ column: "event_type", op: "=", value: "completions" },
{ column: "user_agent", op: "contains", value: "opencode" },
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
]
return honeycomb.getQuerySpecificationOutput({
breakdowns: ["model"],
calculatedFields: [
{
name: "is_failed_http_status",
expression:
product === "go"
? `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401")), NOT(EQUALS($status, "429"))), 1, 0)`
: `IF(AND(GTE($status, "400"), NOT(EQUALS($status, "401"))), 1, 0)`,
},
],
calculations: [
{ op: "COUNT", name: "TOTAL", filterCombination: "AND", filters },
{ op: "SUM", name: "FAILED", column: "is_failed_http_status", filterCombination: "AND", filters },
],
formulas: [{ name: "ERROR", expression: "IF(GTE($TOTAL, 100), DIV($FAILED, $TOTAL), 0)" }],
timeRange: 900,
}).json
}
const providerHttpErrorsQuery = (product: "go" | "zen") => {
const filters = [
{ column: "provider", op: "exists" },
{ column: "user_agent", op: "contains", value: "opencode" },
{ column: "isGoTier", op: "=", value: product === "go" ? "true" : "false" },
]
return honeycomb.getQuerySpecificationOutput({
breakdowns: ["provider"],
calculatedFields: [
{
name: "is_success_http_status",
expression: `IF(AND(GTE($status, "200"), LT($status, "400")), 1, 0)`,
},
{
name: "is_failed_provider_http_status",
expression: `IF(GT($llm.error.code, "400"), 1, 0)`,
},
],
calculations: [
{
op: "SUM",
name: "SUCCESS",
column: "is_success_http_status",
filterCombination: "AND",
filters: [...filters, { column: "event_type", op: "=", value: "completions" }],
},
{
op: "SUM",
name: "FAILED",
column: "is_failed_provider_http_status",
filterCombination: "AND",
filters: [...filters, { column: "event_type", op: "=", value: "llm.error" }],
},
],
formulas: [
{ name: "ERROR", expression: "IF(GTE(SUM($SUCCESS, $FAILED), 50), DIV($FAILED, SUM($SUCCESS, $FAILED)), 0)" },
],
timeRange: 900,
}).json
}
const description = "Managed by SST (Don't edit in Honeycomb UI)"
new honeycomb.Trigger("IncreasedModelHttpErrorsGo", {
name: "Increased Model HTTP Errors [Go]",
description,
queryJson: modelHttpErrorsQuery("go"),
alertType: "on_change",
frequency: 300,
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [{ name: "type", value: "model_http_errors" }],
},
],
},
],
})
new honeycomb.Trigger("IncreasedModelHttpErrorsZen", {
name: "Increased Model HTTP Errors [Zen]",
description,
queryJson: modelHttpErrorsQuery("zen"),
alertType: "on_change",
frequency: 300,
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [{ name: "type", value: "model_http_errors" }],
},
],
},
],
})
new honeycomb.Trigger("IncreasedProviderHttpErrorsGo", {
name: "Increased Provider HTTP Errors [Go]",
description,
queryJson: providerHttpErrorsQuery("go"),
alertType: "on_change",
frequency: 300,
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [{ name: "type", value: "provider_http_errors" }],
},
],
},
],
})
new honeycomb.Trigger("IncreasedProviderHttpErrorsZen", {
name: "Increased Provider HTTP Errors [Zen]",
description,
queryJson: providerHttpErrorsQuery("zen"),
alertType: "on_change",
frequency: 300,
thresholds: [{ op: ">=", value: 0.7, exceededLimit: 1 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [{ name: "type", value: "provider_http_errors" }],
},
],
},
],
})
new honeycomb.Trigger("IncreasedFreeTierRequests", {
name: "Increased Free Tier Requests",
description,
queryJson: honeycomb.getQuerySpecificationOutput({
calculations: [{ op: "COUNT" }],
filters: [
{ column: "event_type", op: "=", value: "completions" },
{ column: "user_agent", op: "contains", value: "opencode" },
{ column: "isFreeTier", op: "=", value: "true" },
],
timeRange: 3600,
}).json,
alertType: "on_change",
frequency: 900,
thresholds: [{ op: ">=", value: 50, exceededLimit: 1 }],
baselineDetails: [{ type: "percentage", offsetMinutes: 1440 }],
recipients: [
{
id: webhookRecipient.id,
notificationDetails: [
{
variables: [{ name: "type", value: "custom" }],
},
],
},
],
})
-7
View File
@@ -1,11 +1,4 @@
sst.Linkable.wrap(random.RandomPassword, (resource) => ({
properties: {
value: resource.result,
},
}))
export const SECRET = { export const SECRET = {
R2AccessKey: new sst.Secret("R2AccessKey", "unknown"), R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
R2SecretKey: new sst.Secret("R2SecretKey", "unknown"), R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
} }
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-baGxh+hk/rPhg0xI/OdMDz6dPwncgercYNBdTPnLX9o=", "x86_64-linux": "sha256-h2T/LnUnISZZDn9ZQkZ/A59P+6+QdfOlrgl4RXK/vgM=",
"aarch64-linux": "sha256-VTWKq679B3Q4ZnAoQzC4VSCYA09wWecNJ+JajvjNB1U=", "aarch64-linux": "sha256-+DRohG1ZEB/2LtZU90GWoqJkeyu/sW8A8oKT3f/TtQ0=",
"aarch64-darwin": "sha256-orf2zIBMTiiQrt/6qCzE+o0oKhv6u8zXF9DH1Bo3lbo=", "aarch64-darwin": "sha256-k4nsk/WduuxY8HgjRuqzGT9EjEo7V/2mAzBTYee0fZ0=",
"x86_64-darwin": "sha256-1MZC1fadRoY4lhkmjlcUQTLYH9Q8pDI1bxd5f94f1xU=" "x86_64-darwin": "sha256-3dSvfN2+5lXwOx57x8NSIWbEZ1fp6+1T6bJpAuUNPyk="
} }
} }
+1
View File
@@ -55,6 +55,7 @@ stdenvNoCC.mkDerivation {
--filter './packages/opencode' \ --filter './packages/opencode' \
--filter './packages/desktop' \ --filter './packages/desktop' \
--filter './packages/app' \ --filter './packages/app' \
--filter './packages/shared' \
--frozen-lockfile \ --frozen-lockfile \
--ignore-scripts \ --ignore-scripts \
--no-progress --no-progress
+5 -10
View File
@@ -7,13 +7,12 @@
"packageManager": "bun@1.3.13", "packageManager": "bun@1.3.13",
"scripts": { "scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop dev", "dev:desktop": "bun --cwd packages/desktop-electron dev",
"dev:web": "bun --cwd packages/app dev", "dev:web": "bun --cwd packages/app dev",
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
"dev:storybook": "bun --cwd packages/storybook storybook", "dev:storybook": "bun --cwd packages/storybook storybook",
"lint": "oxlint", "lint": "oxlint",
"typecheck": "bun turbo typecheck", "typecheck": "bun turbo typecheck",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"postinstall": "bun run --cwd packages/opencode fix-node-pty", "postinstall": "bun run --cwd packages/opencode fix-node-pty",
"prepare": "husky", "prepare": "husky",
"random": "echo 'Random script'", "random": "echo 'Random script'",
@@ -35,13 +34,12 @@
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0", "@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2", "@hono/zod-validator": "0.4.2",
"@opentui/core": "0.2.6", "@opentui/core": "0.1.105",
"@opentui/keymap": "0.2.6", "@opentui/solid": "0.1.105",
"@opentui/solid": "0.2.6",
"ulid": "3.0.1", "ulid": "3.0.1",
"@kobalte/core": "0.13.11", "@kobalte/core": "0.13.11",
"@types/luxon": "3.7.1", "@types/luxon": "3.7.1",
"@types/node": "24.12.2", "@types/node": "22.13.9",
"@types/semver": "7.7.1", "@types/semver": "7.7.1",
"@tsconfig/node22": "22.0.2", "@tsconfig/node22": "22.0.2",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
@@ -55,7 +53,7 @@
"dompurify": "3.3.1", "dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4", "drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4", "drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.59", "effect": "4.0.0-beta.57",
"ai": "6.0.168", "ai": "6.0.168",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"hono": "4.10.7", "hono": "4.10.7",
@@ -79,8 +77,6 @@
"@solidjs/meta": "0.29.4", "@solidjs/meta": "0.29.4",
"@solidjs/router": "0.15.4", "@solidjs/router": "0.15.4",
"@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020", "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10", "solid-js": "1.9.10",
"vite-plugin-solid": "2.11.10", "vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.10" "@lydell/node-pty": "1.2.0-beta.10"
@@ -133,7 +129,6 @@
}, },
"patchedDependencies": { "patchedDependencies": {
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch" "solid-js@1.9.10": "patches/solid-js@1.9.10.patch"
} }
+1 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.14.46", "version": "1.14.28",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
@@ -27,7 +27,6 @@
"devDependencies": { "devDependencies": {
"@happy-dom/global-registrator": "20.0.11", "@happy-dom/global-registrator": "20.0.11",
"@playwright/test": "catalog:", "@playwright/test": "catalog:",
"@sentry/vite-plugin": "catalog:",
"@tailwindcss/vite": "catalog:", "@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9", "@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:", "@types/bun": "catalog:",
@@ -41,7 +40,6 @@
}, },
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@sentry/solid": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/core": "workspace:*", "@opencode-ai/core": "workspace:*",
+6 -14
View File
@@ -1,5 +1,4 @@
import "@/index.css" import "@/index.css"
import * as Sentry from "@sentry/solid"
import { I18nProvider } from "@opencode-ai/ui/context" import { I18nProvider } from "@opencode-ai/ui/context"
import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file" import { FileComponentProvider } from "@opencode-ai/ui/context/file"
@@ -149,19 +148,12 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
> >
<LanguageProvider locale={props.locale}> <LanguageProvider locale={props.locale}>
<UiI18nBridge> <UiI18nBridge>
<ErrorBoundary <ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
fallback={(error) => { <DialogProvider>
Sentry.captureException(error) <MarkedProvider>
return <ErrorPage error={error} /> <FileComponentProvider component={File}>{props.children}</FileComponentProvider>
}} </MarkedProvider>
> </DialogProvider>
<QueryProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</QueryProvider>
</ErrorBoundary> </ErrorBoundary>
</UiI18nBridge> </UiI18nBridge>
</LanguageProvider> </LanguageProvider>
@@ -6,7 +6,7 @@ import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list" import { List } from "@opencode-ai/ui/list"
import { Switch } from "@opencode-ai/ui/switch" import { Switch } from "@opencode-ai/ui/switch"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { mcpQueryKey } from "@/context/global-sync" import { loadMcpQuery } from "@/context/global-sync"
const statusLabels = { const statusLabels = {
connected: "mcp.status.connected", connected: "mcp.status.connected",
@@ -32,7 +32,7 @@ export const DialogSelectMcp: Component = () => {
if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name }) if (sync.data.mcp[name]?.status === "connected") await sdk.client.mcp.disconnect({ name })
else await sdk.client.mcp.connect({ name }) else await sdk.client.mcp.connect({ name })
}, },
onSuccess: () => queryClient.refetchQueries({ queryKey: mcpQueryKey(sync.directory) }), onSuccess: () => queryClient.refetchQueries({ queryKey: loadMcpQuery(sync.directory).queryKey }),
})) }))
const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length)
+1 -7
View File
@@ -16,7 +16,6 @@ import {
} from "@/context/prompt" } from "@/context/prompt"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useGlobalSDK } from "@/context/global-sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
@@ -103,7 +102,6 @@ const NON_EMPTY_TEXT = /[^\s\u200B]/
export const PromptInput: Component<PromptInputProps> = (props) => { export const PromptInput: Component<PromptInputProps> = (props) => {
const sdk = useSDK() const sdk = useSDK()
const globalSDK = useGlobalSDK()
const sync = useSync() const sync = useSync()
const local = useLocal() const local = useLocal()
@@ -1255,11 +1253,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({ const [agentsQuery, globalProvidersQuery, providersQuery] = useQueries(() => ({
queries: [ queries: [loadAgentsQuery(sdk.directory), loadProvidersQuery(null), loadProvidersQuery(sdk.directory)],
loadAgentsQuery(sdk.directory, sdk.client),
loadProvidersQuery(null, globalSDK.client),
loadProvidersQuery(sdk.directory, sdk.client),
],
})) }))
const agentsLoading = () => agentsQuery.isLoading const agentsLoading = () => agentsQuery.isLoading
@@ -329,7 +329,6 @@ export const SettingsGeneral: Component = () => {
label={(o) => o.label} label={(o) => o.label}
onSelect={(option) => { onSelect={(option) => {
if (!option) return if (!option) return
if (option.value === currentShell()) return
globalSync.updateConfig({ shell: option.value }) globalSync.updateConfig({ shell: option.value })
}} }}
variant="secondary" variant="secondary"
@@ -15,7 +15,7 @@ import { useSDK } from "@/context/sdk"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
import { mcpQueryKey } from "@/context/global-sync" import { loadMcpQuery } from "@/context/global-sync"
const pollMs = 10_000 const pollMs = 10_000
@@ -145,7 +145,7 @@ const useMcpToggleMutation = () => {
const status = sync.data.mcp[name] const status = sync.data.mcp[name]
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name })) await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
}, },
onSuccess: () => queryClient.refetchQueries({ queryKey: mcpQueryKey(sync.directory) }), onSuccess: () => queryClient.refetchQueries({ queryKey: loadMcpQuery(sync.directory).queryKey }),
onError: (err) => { onError: (err) => {
showToast({ showToast({
variant: "error", variant: "error",
+22 -44
View File
@@ -15,7 +15,6 @@ import { terminalFontFamily, useSettings } from "@/context/settings"
import type { LocalPTY } from "@/context/terminal" import type { LocalPTY } from "@/context/terminal"
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
import { terminalWriter } from "@/utils/terminal-writer" import { terminalWriter } from "@/utils/terminal-writer"
import { terminalWebSocketURL } from "@/utils/terminal-websocket-url"
const TOGGLE_TERMINAL_ID = "terminal.toggle" const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -68,6 +67,13 @@ const debugTerminal = (...values: unknown[]) => {
console.debug("[terminal]", ...values) console.debug("[terminal]", ...values)
} }
const errorName = (err: unknown) => {
if (!err || typeof err !== "object") return
if (!("name" in err)) return
const errorName = err.name
return typeof errorName === "string" ? errorName : undefined
}
const useTerminalUiBindings = (input: { const useTerminalUiBindings = (input: {
container: HTMLDivElement container: HTMLDivElement
term: Term term: Term
@@ -472,34 +478,14 @@ export const Terminal = (props: TerminalProps) => {
const gone = () => const gone = () =>
client.pty client.pty
.get({ ptyID: id }, { throwOnError: false }) .get({ ptyID: id })
.then((result) => result.response.status === 404) .then(() => false)
.catch((err) => { .catch((err) => {
if (errorName(err) === "NotFoundError") return true
debugTerminal("failed to inspect terminal session", err) debugTerminal("failed to inspect terminal session", err)
return false return false
}) })
const connectToken = async () => {
const result = await client.pty
.connectToken(
{ ptyID: id, directory },
{
throwOnError: false,
headers: { "x-opencode-ticket": "1" },
},
)
.catch((err: unknown) => {
if (err instanceof Error && err.message.includes("Request is not supported")) return
throw err
})
if (!result) return
if (result.response.status === 200 && result.data?.ticket) return result.data.ticket
if (result.response.status === 404 || result.response.status === 405) return
if (result.response.status === 403)
throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.")
throw new Error(`PTY connect ticket failed with ${result.response.status}`)
}
const retry = (err: unknown) => { const retry = (err: unknown) => {
if (disposed) return if (disposed) return
if (reconn !== undefined) return if (reconn !== undefined) return
@@ -519,30 +505,22 @@ export const Terminal = (props: TerminalProps) => {
}, ms) }, ms)
} }
const open = async () => { const open = () => {
if (disposed) return if (disposed) return
drop?.() drop?.()
const ticket = await connectToken().catch((err) => { const next = new URL(url + `/pty/${id}/connect`)
fail(err) next.searchParams.set("directory", directory)
return undefined next.searchParams.set("cursor", String(seek))
}) next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
if (once.value) return if (!sameOrigin && password) {
if (disposed) return next.searchParams.set("auth_token", btoa(`${username}:${password}`))
// For same-origin requests, let the browser reuse the page's existing auth.
next.username = username
next.password = password
}
const socket = new WebSocket( const socket = new WebSocket(next)
terminalWebSocketURL({
url,
id,
directory,
cursor: seek,
ticket,
sameOrigin,
username,
password,
authToken: server.current?.type === "http" ? server.current.authToken : false,
}),
)
socket.binaryType = "arraybuffer" socket.binaryType = "arraybuffer"
ws = socket ws = socket
+132 -147
View File
@@ -35,9 +35,6 @@ type TauriApi = {
const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__ const tauriApi = () => (window as unknown as { __TAURI__?: TauriApi }).__TAURI__
const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.() const currentDesktopWindow = () => tauriApi()?.window?.getCurrentWindow?.()
const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.() const currentThemeWindow = () => tauriApi()?.webviewWindow?.getCurrentWebviewWindow?.()
const titlebarHeight = 40
const minTitlebarZoom = 0.25
const windowsControlsBaseWidth = 138 // 3 native Windows caption buttons at 46px each.
export function Titlebar() { export function Titlebar() {
const layout = useLayout() const layout = useLayout()
@@ -54,14 +51,7 @@ export function Titlebar() {
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const web = createMemo(() => platform.platform === "web") const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1 const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom()) const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined)
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
if (mac()) return `${titlebarHeight / zoom()}px`
if (windows()) return `${titlebarHeight / Math.min(titlebarZoom(), 1)}px`
return undefined
}
const windowsControlsWidth = () => `${windowsControlsBaseWidth / Math.max(titlebarZoom(), 1)}px`
const [history, setHistory] = createStore({ const [history, setHistory] = createStore({
stack: [] as string[], stack: [] as string[],
@@ -175,161 +165,156 @@ export function Titlebar() {
return ( return (
<header <header
class="h-10 shrink-0 bg-background-base relative overflow-hidden" class="h-10 shrink-0 bg-background-base relative grid grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ "min-height": minHeight() }} style={{ "min-height": minHeight() }}
data-tauri-drag-region data-tauri-drag-region
onMouseDown={drag} onMouseDown={drag}
onDblClick={maximize} onDblClick={maximize}
> >
<div <div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center" classList={{
style={{ zoom: counterZoom() }} "flex items-center min-w-0": true,
"pl-2": !mac(),
}}
> >
<div <Show when={mac()}>
classList={{ <div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
"flex items-center min-w-0": true, <div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
"pl-2": !mac(), <IconButton
}} icon="menu"
> variant="ghost"
<Show when={mac()}> class="titlebar-icon rounded-md"
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} /> onClick={layout.mobileSidebar.toggle}
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center"> aria-label={language.t("sidebar.menu.toggle")}
<IconButton aria-expanded={layout.mobileSidebar.opened()}
icon="menu" />
variant="ghost" </div>
class="titlebar-icon rounded-md" </Show>
onClick={layout.mobileSidebar.toggle} <Show when={!mac()}>
aria-label={language.t("sidebar.menu.toggle")} <div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
aria-expanded={layout.mobileSidebar.opened()} <IconButton
/> icon="menu"
</div> variant="ghost"
</Show> class="titlebar-icon rounded-md"
<Show when={!mac()}> onClick={layout.mobileSidebar.toggle}
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center"> aria-label={language.t("sidebar.menu.toggle")}
<IconButton aria-expanded={layout.mobileSidebar.opened()}
icon="menu" />
variant="ghost" </div>
class="titlebar-icon rounded-md" </Show>
onClick={layout.mobileSidebar.toggle} <div class="flex items-center gap-1 shrink-0">
aria-label={language.t("sidebar.menu.toggle")} <TooltipKeybind
aria-expanded={layout.mobileSidebar.opened()} class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
/> placement="bottom"
</div> title={language.t("command.sidebar.toggle")}
</Show> keybind={command.keybind("sidebar.toggle")}
<div class="flex items-center gap-1 shrink-0"> >
<TooltipKeybind <Button
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"} variant="ghost"
placement="bottom" class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
title={language.t("command.sidebar.toggle")} onClick={layout.sidebar.toggle}
keybind={command.keybind("sidebar.toggle")} aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
> >
<Button <Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
variant="ghost" </Button>
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border" </TooltipKeybind>
onClick={layout.sidebar.toggle} <div class="hidden xl:flex items-center shrink-0">
aria-label={language.t("command.sidebar.toggle")} <Show when={params.dir}>
aria-expanded={layout.sidebar.opened()} <div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
> >
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div <div
class="flex items-center shrink-0 w-8 mr-1" class="transition-opacity"
aria-hidden={layout.sidebar.opened() ? "true" : undefined} classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
> >
<div <TooltipKeybind
class="transition-opacity" placement="bottom"
classList={{ title={language.t("command.session.new")}
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(), keybind={command.keybind("session.new")}
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(), openDelay={2000}
}}
> >
<TooltipKeybind <Button
placement="bottom" variant="ghost"
title={language.t("command.session.new")} icon={creating() ? "new-session-active" : "new-session"}
keybind={command.keybind("session.new")} class="titlebar-icon w-8 h-6 p-0 box-border"
openDelay={2000} disabled={layout.sidebar.opened()}
> tabIndex={layout.sidebar.opened() ? -1 : undefined}
<Button onClick={() => {
variant="ghost" if (!params.dir) return
icon={creating() ? "new-session-active" : "new-session"} navigate(`/${params.dir}/session`)
class="titlebar-icon w-8 h-6 p-0 box-border" }}
disabled={layout.sidebar.opened()} aria-label={language.t("command.session.new")}
tabIndex={layout.sidebar.opened() ? -1 : undefined} aria-current={creating() ? "page" : undefined}
onClick={() => { />
if (!params.dir) return </TooltipKeybind>
navigate(`/${params.dir}/session`) </div>
}} </div>
aria-label={language.t("command.session.new")} </Show>
aria-current={creating() ? "page" : undefined} <div
/> class="flex items-center shrink-0"
</TooltipKeybind> classList={{
</div> "-translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div> </div>
</Show> </Show>
<div <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
class="flex items-center shrink-0" {["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
classList={{ <div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
"-translate-x-[36px]": layout.sidebar.opened() && !!params.dir, {import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
"duration-180 ease-out": !layout.sidebar.opened(), </div>
"duration-180 ease-in": layout.sidebar.opened(), )}
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={2000}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
{["beta", "dev"].includes(import.meta.env.VITE_OPENCODE_CHANNEL) && (
<div class="bg-icon-interactive-base text-[#FFF] font-medium px-2 rounded-sm uppercase font-mono">
{import.meta.env.VITE_OPENCODE_CHANNEL.toUpperCase()}
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none"> <div class="min-w-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" /> <div id="opencode-titlebar-center" class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full" />
</div> </div>
<div <div
classList={{ classList={{
"flex items-center min-w-0 justify-end": true, "flex items-center min-w-0 justify-end": true,
"pr-2": !windows(), "pr-2": !windows(),
}} }}
data-tauri-drag-region data-tauri-drag-region
onMouseDown={drag} onMouseDown={drag}
> >
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" /> <div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}> <Show when={windows()}>
{!tauriApi() && <div class="shrink-0" style={{ width: windowsControlsWidth() }} />} {!tauriApi() && <div class="w-36 shrink-0" />}
<div data-tauri-decorum-tb class="flex flex-row" /> <div data-tauri-decorum-tb class="flex flex-row" />
</Show> </Show>
</div>
</div> </div>
</header> </header>
) )
+39 -55
View File
@@ -20,6 +20,7 @@ import {
clearProviderRev, clearProviderRev,
loadGlobalConfigQuery, loadGlobalConfigQuery,
loadPathQuery, loadPathQuery,
loadProjectsQuery,
loadProvidersQuery, loadProvidersQuery,
} from "./global-sync/bootstrap" } from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store" import { createChildStoreManager } from "./global-sync/child-store"
@@ -30,9 +31,8 @@ import { trimSessions } from "./global-sync/session-trim"
import type { ProjectMeta } from "./global-sync/types" import type { ProjectMeta } from "./global-sync/types"
import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" import { queryOptions, skipToken, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createRefreshQueue } from "./global-sync/queue" import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
type GlobalStore = { type GlobalStore = {
ready: boolean ready: boolean
@@ -48,22 +48,19 @@ type GlobalStore = {
reload: undefined | "pending" | "complete" reload: undefined | "pending" | "complete"
} }
export const loadSessionsQueryKey = (directory: string) => [directory, "loadSessions"] as const export const loadSessionsQuery = (directory: string) =>
queryOptions<null>({ queryKey: [directory, "loadSessions"], queryFn: skipToken })
export const mcpQueryKey = (directory: string) => [directory, "mcp"] as const export const loadMcpQuery = (directory: string, sdk?: OpencodeClient) =>
export const loadMcpQuery = (directory: string, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: mcpQueryKey(directory), queryKey: [directory, "mcp"],
queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), queryFn: sdk ? () => sdk.mcp.status().then((r) => r.data ?? {}) : skipToken,
}) })
export const lspQueryKey = (directory: string) => [directory, "lsp"] as const export const loadLspQuery = (directory: string, sdk?: OpencodeClient) =>
export const loadLspQuery = (directory: string, sdk: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: lspQueryKey(directory), queryKey: [directory, "lsp"],
queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), queryFn: sdk ? () => sdk.lsp.status().then((r) => r.data ?? []) : skipToken,
}) })
function createGlobalSync() { function createGlobalSync() {
@@ -78,11 +75,7 @@ function createGlobalSync() {
const sessionMeta = new Map<string, { limit: number }>() const sessionMeta = new Map<string, { limit: number }>()
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
queries: [ queries: [loadGlobalConfigQuery(), loadProvidersQuery(null), loadPathQuery(null), loadProjectsQuery()],
loadGlobalConfigQuery(globalSDK.client),
loadProvidersQuery(null, globalSDK.client),
loadPathQuery(null, globalSDK.client),
],
})) }))
const [globalStore, setGlobalStore] = createStore<GlobalStore>({ const [globalStore, setGlobalStore] = createStore<GlobalStore>({
@@ -176,20 +169,18 @@ function createGlobalSync() {
const queue = createRefreshQueue({ const queue = createRefreshQueue({
paused, paused,
key: directoryKey,
bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }), bootstrap: () => queryClient.fetchQuery({ queryKey: ["bootstrap"] }),
bootstrapInstance, bootstrapInstance,
}) })
const sdkFor = (directory: string) => { const sdkFor = (directory: string) => {
const key = directoryKey(directory) const cached = sdkCache.get(directory)
const cached = sdkCache.get(key)
if (cached) return cached if (cached) return cached
const sdk = globalSDK.createClient({ const sdk = globalSDK.createClient({
directory, directory,
throwOnError: true, throwOnError: true,
}) })
sdkCache.set(key, sdk) sdkCache.set(directory, sdk)
return sdk return sdk
} }
@@ -201,28 +192,23 @@ function createGlobalSync() {
void bootstrapInstance(directory) void bootstrapInstance(directory)
}, },
onDispose: (directory) => { onDispose: (directory) => {
const key = directoryKey(directory) queue.clear(directory)
queue.clear(key) sessionMeta.delete(directory)
sessionMeta.delete(key) sdkCache.delete(directory)
sdkCache.delete(key) clearProviderRev(directory)
clearProviderRev(key) clearSessionPrefetchDirectory(directory)
clearSessionPrefetchDirectory(key)
}, },
translate: language.t, translate: language.t,
getSdk: sdkFor, getSdk: sdkFor,
global: {
provider: globalStore.provider,
},
}) })
async function loadSessions(directory: string) { async function loadSessions(directory: string) {
const key = directoryKey(directory) const pending = sessionLoads.get(directory)
const pending = sessionLoads.get(key)
if (pending) return pending if (pending) return pending
children.pin(key) children.pin(directory)
const [store, setStore] = children.child(directory, { bootstrap: false }) const [store, setStore] = children.child(directory, { bootstrap: false })
const meta = sessionMeta.get(key) const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) { if (meta && meta.limit >= store.limit) {
const next = trimSessions(store.session, { const next = trimSessions(store.session, {
limit: store.limit, limit: store.limit,
@@ -232,14 +218,14 @@ function createGlobalSync() {
setStore("session", reconcile(next, { key: "id" })) setStore("session", reconcile(next, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo) cleanupDroppedSessionCaches(store, setStore, next, setSessionTodo)
} }
children.unpin(key) children.unpin(directory)
return return
} }
const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT) const limit = Math.max(store.limit + SESSION_RECENT_LIMIT, SESSION_RECENT_LIMIT)
const promise = queryClient const promise = queryClient
.fetchQuery({ .fetchQuery({
queryKey: loadSessionsQueryKey(key), ...loadSessionsQuery(directory),
queryFn: () => queryFn: () =>
loadRootSessionsWithFallback({ loadRootSessionsWithFallback({
directory, directory,
@@ -269,7 +255,7 @@ function createGlobalSync() {
setStore("session", reconcile(sessions, { key: "id" })) setStore("session", reconcile(sessions, { key: "id" }))
cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo) cleanupDroppedSessionCaches(store, setStore, sessions, setSessionTodo)
}) })
sessionMeta.set(key, { limit }) sessionMeta.set(directory, { limit })
}) })
.catch((err) => { .catch((err) => {
console.error("Failed to load sessions", err) console.error("Failed to load sessions", err)
@@ -284,24 +270,23 @@ function createGlobalSync() {
}) })
.then(() => {}) .then(() => {})
sessionLoads.set(key, promise) sessionLoads.set(directory, promise)
void promise.finally(() => { void promise.finally(() => {
sessionLoads.delete(key) sessionLoads.delete(directory)
children.unpin(key) children.unpin(directory)
}) })
return promise return promise
} }
async function bootstrapInstance(directory: string) { async function bootstrapInstance(directory: string) {
const key = directoryKey(directory) if (!directory) return
if (!key) return const pending = booting.get(directory)
const pending = booting.get(key)
if (pending) return pending if (pending) return pending
children.pin(key) children.pin(directory)
const promise = Promise.resolve().then(async () => { const promise = Promise.resolve().then(async () => {
const child = children.ensureChild(directory) const child = children.ensureChild(directory)
const cache = children.vcsCache.get(key) const cache = children.vcsCache.get(directory)
if (!cache) return if (!cache) return
const sdk = sdkFor(directory) const sdk = sdkFor(directory)
await bootstrapDirectory({ await bootstrapDirectory({
@@ -322,17 +307,16 @@ function createGlobalSync() {
}) })
}) })
booting.set(key, promise) booting.set(directory, promise)
void promise.finally(() => { void promise.finally(() => {
booting.delete(key) booting.delete(directory)
children.unpin(key) children.unpin(directory)
}) })
return promise return promise
} }
const unsub = globalSDK.event.listen((e) => { const unsub = globalSDK.event.listen((e) => {
const directory = e.name const directory = e.name
const key = directoryKey(directory)
const event = e.details const event = e.details
const recent = bootingRoot || Date.now() - bootedAt < 1500 const recent = bootingRoot || Date.now() - bootedAt < 1500
@@ -355,9 +339,9 @@ function createGlobalSync() {
return return
} }
const existing = children.children[key] const existing = children.children[directory]
if (!existing) return if (!existing) return
children.mark(key) children.mark(directory)
const [store, setStore] = existing const [store, setStore] = existing
applyDirectoryEvent({ applyDirectoryEvent({
event, event,
@@ -366,9 +350,9 @@ function createGlobalSync() {
setStore, setStore,
push: queue.push, push: queue.push,
setSessionTodo, setSessionTodo,
vcsCache: children.vcsCache.get(key), vcsCache: children.vcsCache.get(directory),
loadLsp: () => { loadLsp: () => {
void queryClient.fetchQuery(loadLspQuery(key, sdkFor(directory))) void queryClient.fetchQuery(loadLspQuery(directory, sdkFor(directory)))
}, },
}) })
}) })
@@ -379,7 +363,7 @@ function createGlobalSync() {
}) })
onCleanup(() => { onCleanup(() => {
for (const directory of Object.keys(children.children)) { for (const directory of Object.keys(children.children)) {
children.disposeDirectory(directoryKey(directory)) children.disposeDirectory(directory)
} }
}) })
@@ -18,7 +18,7 @@ import { reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { State, VcsCache } from "./types" import type { State, VcsCache } from "./types"
import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" import { cmp, normalizeAgentList, normalizeProviderList } from "./utils"
import { formatServerError } from "@/utils/server-errors" import { formatServerError } from "@/utils/server-errors"
import { QueryClient, queryOptions } from "@tanstack/solid-query" import { QueryClient, queryOptions, skipToken } from "@tanstack/solid-query"
import { loadMcpQuery } from "../global-sync" import { loadMcpQuery } from "../global-sync"
type GlobalStore = { type GlobalStore = {
@@ -83,25 +83,44 @@ function showErrors(input: {
}) })
} }
export const loadGlobalConfigQuery = (sdk: OpencodeClient) => export const loadGlobalConfigQuery = (
sdk?: OpencodeClient,
transform?: (x: Awaited<ReturnType<OpencodeClient["global"]["config"]["get"]>>) => void,
) =>
queryOptions({ queryOptions({
queryKey: ["config"], queryKey: ["config"],
queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), queryFn: sdk
? () =>
retry(() =>
sdk.global.config.get().then((x) => {
transform?.(x)
return x.data!
}),
)
: skipToken,
}) })
export const loadProjectsQuery = (sdk: OpencodeClient) => export const loadProjectsQuery = (
sdk?: OpencodeClient,
transform?: (x: Awaited<ReturnType<OpencodeClient["project"]["list"]>>["data"]) => void,
) =>
queryOptions({ queryOptions({
queryKey: ["project"], queryKey: ["project"],
queryFn: () => queryFn: sdk
retry(() => ? () =>
sdk.project.list().then((x) => { retry(() =>
return (x.data ?? []) sdk.project
.filter((p) => !!p?.id) .list()
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) .then((x) => {
.slice() return (x.data ?? [])
.sort((a, b) => cmp(a.id, b.id)) .filter((p) => !!p?.id)
}), .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
), .slice()
.sort((a, b) => cmp(a.id, b.id))
})
.then(transform),
)
: skipToken,
}) })
export async function bootstrapGlobal(input: { export async function bootstrapGlobal(input: {
@@ -117,9 +136,9 @@ export async function bootstrapGlobal(input: {
() => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)), () => input.queryClient.fetchQuery(loadProvidersQuery(null, input.globalSDK)),
() => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)), () => input.queryClient.fetchQuery(loadPathQuery(null, input.globalSDK)),
() => () =>
input.queryClient input.queryClient.fetchQuery(
.fetchQuery(loadProjectsQuery(input.globalSDK)) loadProjectsQuery(input.globalSDK, (data) => input.setGlobalStore("project", data ?? [])),
.then((data) => input.setGlobalStore("project", data)), ),
] ]
await runAll(slow) await runAll(slow)
// showErrors({ // showErrors({
@@ -178,22 +197,46 @@ function warmSessions(input: {
).then(() => undefined) ).then(() => undefined)
} }
export const loadProvidersQuery = (directory: string | null, sdk: OpencodeClient) => export const loadProvidersQuery = (directory: string | null, sdk?: OpencodeClient) =>
queryOptions({ queryOptions({
queryKey: [directory, "providers"], queryKey: [directory, "providers"],
queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))), queryFn: sdk ? () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))) : skipToken,
}) })
export const loadAgentsQuery = (directory: string | null, sdk: OpencodeClient) => export const loadAgentsQuery = (
directory: string | null,
sdk?: OpencodeClient,
transform?: (x: Awaited<ReturnType<OpencodeClient["app"]["agents"]>>) => void,
) =>
queryOptions({ queryOptions({
queryKey: [directory, "agents"], queryKey: [directory, "agents"],
queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))), queryFn: sdk
? () =>
retry(() =>
sdk.app.agents().then((x) => {
transform?.(x)
return x.data!
}),
)
: skipToken,
}) })
export const loadPathQuery = (directory: string | null, sdk: OpencodeClient) => export const loadPathQuery = (
directory: string | null,
sdk?: OpencodeClient,
transform?: (x: Awaited<ReturnType<OpencodeClient["path"]["get"]>>) => void,
) =>
queryOptions<Path>({ queryOptions<Path>({
queryKey: [directory, "path"], queryKey: [directory, "path"],
queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), queryFn: sdk
? () =>
retry(() =>
sdk.path.get().then(async (x) => {
transform?.(x)
return x.data!
}),
)
: skipToken,
}) })
export async function bootstrapDirectory(input: { export async function bootstrapDirectory(input: {
@@ -217,6 +260,9 @@ export async function bootstrapDirectory(input: {
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
if (seededProject) input.setStore("project", seededProject) if (seededProject) input.setStore("project", seededProject)
if (seededPath) input.setStore("path", seededPath) if (seededPath) input.setStore("path", seededPath)
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
input.setStore("provider", input.global.provider)
}
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
input.setStore("config", reconcile(input.global.config, { merge: false })) input.setStore("config", reconcile(input.global.config, { merge: false }))
} }
@@ -228,9 +274,9 @@ export async function bootstrapDirectory(input: {
const slow = [ const slow = [
() => Promise.resolve(input.loadSessions(input.directory)), () => Promise.resolve(input.loadSessions(input.directory)),
() => () =>
input.queryClient input.queryClient.ensureQueryData(
.ensureQueryData(loadAgentsQuery(input.directory, input.sdk)) loadAgentsQuery(input.directory, input.sdk, (x) => input.setStore("agent", normalizeAgentList(x.data))),
.then((data) => input.setStore("agent", data)), ),
() => () =>
retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
@@ -238,10 +284,12 @@ export async function bootstrapDirectory(input: {
(() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))),
!seededPath && !seededPath &&
(() => (() =>
input.queryClient.ensureQueryData(loadPathQuery(input.directory, input.sdk)).then((data) => { input.queryClient.ensureQueryData(
const next = projectID(data.directory ?? input.directory, input.global.project) loadPathQuery(input.directory, input.sdk, (x) => {
if (next) input.setStore("project", next) const next = projectID(x.data?.directory ?? input.directory, input.global.project)
})), if (next) input.setStore("project", next)
}),
)),
() => () =>
retry(() => retry(() =>
input.sdk.vcs.get().then((x) => { input.sdk.vcs.get().then((x) => {
@@ -23,7 +23,6 @@ describe("createChildStoreManager", () => {
onDispose() {}, onDispose() {},
translate: (key) => key, translate: (key) => key,
getSdk: () => null!, getSdk: () => null!,
global: { provider: null! },
}) })
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
@@ -1,7 +1,7 @@
import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js" import { createRoot, getOwner, onCleanup, runWithOwner, type Owner } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store" import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import type { OpencodeClient, ProviderListResponse, VcsInfo } from "@opencode-ai/sdk/v2/client" import type { OpencodeClient, VcsInfo } from "@opencode-ai/sdk/v2/client"
import { import {
DIR_IDLE_TTL_MS, DIR_IDLE_TTL_MS,
MAX_DIR_STORES, MAX_DIR_STORES,
@@ -17,7 +17,6 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQueries } from "@tanstack/solid-query" import { useQueries } from "@tanstack/solid-query"
import { loadPathQuery, loadProvidersQuery } from "./bootstrap" import { loadPathQuery, loadProvidersQuery } from "./bootstrap"
import { loadLspQuery, loadMcpQuery } from "../global-sync" import { loadLspQuery, loadMcpQuery } from "../global-sync"
import { directoryKey, type DirectoryKey } from "./utils"
export function createChildStoreManager(input: { export function createChildStoreManager(input: {
owner: Owner owner: Owner
@@ -27,9 +26,6 @@ export function createChildStoreManager(input: {
onDispose: (directory: string) => void onDispose: (directory: string) => void
translate: (key: string, vars?: Record<string, string | number>) => string translate: (key: string, vars?: Record<string, string | number>) => string
getSdk: (directory: string) => OpencodeClient getSdk: (directory: string) => OpencodeClient
global: {
provider: ProviderListResponse
}
}) { }) {
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {} const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
const vcsCache = new Map<string, VcsCache>() const vcsCache = new Map<string, VcsCache>()
@@ -40,37 +36,30 @@ export function createChildStoreManager(input: {
const ownerPins = new WeakMap<object, Set<string>>() const ownerPins = new WeakMap<object, Set<string>>()
const disposers = new Map<string, () => void>() const disposers = new Map<string, () => void>()
const markKey = (key: DirectoryKey) => {
if (!key) return
lifecycle.set(key, { lastAccessAt: Date.now() })
runEviction(key)
}
const mark = (directory: string) => { const mark = (directory: string) => {
const key = directoryKey(directory) if (!directory) return
markKey(key) lifecycle.set(directory, { lastAccessAt: Date.now() })
runEviction(directory)
} }
const pin = (directory: string) => { const pin = (directory: string) => {
const key = directoryKey(directory) if (!directory) return
if (!key) return pins.set(directory, (pins.get(directory) ?? 0) + 1)
pins.set(key, (pins.get(key) ?? 0) + 1) mark(directory)
markKey(key)
} }
const unpin = (directory: string) => { const unpin = (directory: string) => {
const key = directoryKey(directory) if (!directory) return
if (!key) return const next = (pins.get(directory) ?? 0) - 1
const next = (pins.get(key) ?? 0) - 1
if (next > 0) { if (next > 0) {
pins.set(key, next) pins.set(directory, next)
return return
} }
pins.delete(key) pins.delete(directory)
runEviction() runEviction()
} }
const pinned = (directory: string) => (pins.get(directoryKey(directory)) ?? 0) > 0 const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
const pinForOwner = (directory: string) => { const pinForOwner = (directory: string) => {
const current = getOwner() const current = getOwner()
@@ -92,31 +81,30 @@ export function createChildStoreManager(input: {
}) })
} }
function disposeDirectory(directory: DirectoryKey) { function disposeDirectory(directory: string) {
const key = directory
if ( if (
!canDisposeDirectory({ !canDisposeDirectory({
directory: key, directory,
hasStore: !!children[key], hasStore: !!children[directory],
pinned: pinned(key), pinned: pinned(directory),
booting: input.isBooting(key), booting: input.isBooting(directory),
loadingSessions: input.isLoadingSessions(key), loadingSessions: input.isLoadingSessions(directory),
}) })
) { ) {
return false return false
} }
vcsCache.delete(key) vcsCache.delete(directory)
metaCache.delete(key) metaCache.delete(directory)
iconCache.delete(key) iconCache.delete(directory)
lifecycle.delete(key) lifecycle.delete(directory)
const dispose = disposers.get(key) const dispose = disposers.get(directory)
if (dispose) { if (dispose) {
dispose() dispose()
disposers.delete(key) disposers.delete(directory)
} }
delete children[key] delete children[directory]
input.onDispose(key) input.onDispose(directory)
return true return true
} }
@@ -133,14 +121,13 @@ export function createChildStoreManager(input: {
}).filter((directory) => directory !== skip) }).filter((directory) => directory !== skip)
if (list.length === 0) return if (list.length === 0) return
for (const directory of list) { for (const directory of list) {
if (!disposeDirectory(directoryKey(directory))) continue if (!disposeDirectory(directory)) continue
} }
} }
function ensureChild(directory: string) { function ensureChild(directory: string) {
const key = directoryKey(directory) if (!directory) console.error("No directory provided")
if (!key) console.error("No directory provided") if (!children[directory]) {
if (!children[key]) {
const vcs = runWithOwner(input.owner, () => const vcs = runWithOwner(input.owner, () =>
persisted( persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]), Persist.workspace(directory, "vcs", ["vcs.v1"]),
@@ -149,7 +136,7 @@ export function createChildStoreManager(input: {
) )
if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
const vcsStore = vcs[0] const vcsStore = vcs[0]
vcsCache.set(key, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] })
const meta = runWithOwner(input.owner, () => const meta = runWithOwner(input.owner, () =>
persisted( persisted(
@@ -158,7 +145,7 @@ export function createChildStoreManager(input: {
), ),
) )
if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed")) if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed"))
metaCache.set(key, { store: meta[0], setStore: meta[1], ready: meta[3] }) metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] })
const icon = runWithOwner(input.owner, () => const icon = runWithOwner(input.owner, () =>
persisted( persisted(
@@ -167,7 +154,7 @@ export function createChildStoreManager(input: {
), ),
) )
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] }) iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
const init = () => const init = () =>
createRoot((dispose) => { createRoot((dispose) => {
@@ -178,10 +165,10 @@ export function createChildStoreManager(input: {
const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({ const [pathQuery, mcpQuery, lspQuery, providerQuery] = useQueries(() => ({
queries: [ queries: [
loadPathQuery(key, sdk), loadPathQuery(directory, sdk),
loadMcpQuery(key, sdk), loadMcpQuery(directory, sdk),
loadLspQuery(key, sdk), loadLspQuery(directory, sdk),
loadProvidersQuery(key, sdk), loadProvidersQuery(directory, sdk),
], ],
})) }))
@@ -190,15 +177,9 @@ export function createChildStoreManager(input: {
projectMeta: initialMeta, projectMeta: initialMeta,
icon: initialIcon, icon: initialIcon,
get provider_ready() { get provider_ready() {
return !providerQuery.isLoading return providerQuery.isLoading
},
get provider() {
const EMPTY = { all: [], connected: [], default: {} }
if (providerQuery.isLoading) return EMPTY
if (providerQuery.data?.all.length === 0 && input.global.provider.all.length > 0)
return input.global.provider
return providerQuery.data ?? EMPTY
}, },
provider: { all: [], connected: [], default: {} },
config: {}, config: {},
get path() { get path() {
if (pathQuery.isLoading || !pathQuery.data) if (pathQuery.isLoading || !pathQuery.data)
@@ -216,13 +197,13 @@ export function createChildStoreManager(input: {
permission: {}, permission: {},
question: {}, question: {},
get mcp_ready() { get mcp_ready() {
return !mcpQuery.isLoading return mcpQuery.isLoading
}, },
get mcp() { get mcp() {
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {}) return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
}, },
get lsp_ready() { get lsp_ready() {
return !lspQuery.isLoading return lspQuery.isLoading
}, },
get lsp() { get lsp() {
return lspQuery.isLoading ? [] : (lspQuery.data ?? []) return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
@@ -232,13 +213,13 @@ export function createChildStoreManager(input: {
message: {}, message: {},
part: {}, part: {},
}) })
children[key] = child children[directory] = child
disposers.set(key, dispose) disposers.set(directory, dispose)
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => { const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
if (!(init instanceof Promise)) return if (!(init instanceof Promise)) return
void init.then(() => { void init.then(() => {
if (children[key] !== child) return if (children[directory] !== child) return
run() run()
}) })
} }
@@ -262,16 +243,15 @@ export function createChildStoreManager(input: {
runWithOwner(input.owner, init) runWithOwner(input.owner, init)
} }
markKey(key) mark(directory)
const childStore = children[key] const childStore = children[directory]
if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed")) if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed"))
return childStore return childStore
} }
function child(directory: string, options: ChildOptions = {}) { function child(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory)
const childStore = ensureChild(directory) const childStore = ensureChild(directory)
pinForOwner(key) pinForOwner(directory)
const shouldBootstrap = options.bootstrap ?? true const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") { if (shouldBootstrap && childStore[0].status === "loading") {
input.onBootstrap(directory) input.onBootstrap(directory)
@@ -280,7 +260,6 @@ export function createChildStoreManager(input: {
} }
function peek(directory: string, options: ChildOptions = {}) { function peek(directory: string, options: ChildOptions = {}) {
const key = directoryKey(directory)
const childStore = ensureChild(directory) const childStore = ensureChild(directory)
const shouldBootstrap = options.bootstrap ?? true const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") { if (shouldBootstrap && childStore[0].status === "loading") {
@@ -290,9 +269,8 @@ export function createChildStoreManager(input: {
} }
function projectMeta(directory: string, patch: ProjectMeta) { function projectMeta(directory: string, patch: ProjectMeta) {
const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory) const [store, setStore] = ensureChild(directory)
const cached = metaCache.get(key) const cached = metaCache.get(directory)
if (!cached) return if (!cached) return
const previous = store.projectMeta ?? {} const previous = store.projectMeta ?? {}
const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon const icon = patch.icon ? { ...previous.icon, ...patch.icon } : previous.icon
@@ -308,9 +286,8 @@ export function createChildStoreManager(input: {
} }
function projectIcon(directory: string, value: string | undefined) { function projectIcon(directory: string, value: string | undefined) {
const key = directoryKey(directory)
const [store, setStore] = ensureChild(directory) const [store, setStore] = ensureChild(directory)
const cached = iconCache.get(key) const cached = iconCache.get(directory)
if (!cached) return if (!cached) return
if (store.icon === value) return if (store.icon === value) return
cached.setStore("value", value) cached.setStore("value", value)
@@ -1,46 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createRefreshQueue } from "./queue"
import { directoryKey } from "./utils"
const tick = () => new Promise((resolve) => setTimeout(resolve, 10))
describe("createRefreshQueue", () => {
test("clears queued directories by normalized key", async () => {
const calls: string[] = []
const queue = createRefreshQueue({
paused: () => false,
key: directoryKey,
bootstrap: async () => {},
bootstrapInstance: (directory) => {
calls.push(directory)
},
})
queue.push("C:\\tmp\\demo")
queue.clear("C:/tmp/demo")
await tick()
expect(calls).toEqual([])
queue.dispose()
})
test("passes the original directory to bootstrapInstance", async () => {
const calls: string[] = []
const queue = createRefreshQueue({
paused: () => false,
key: directoryKey,
bootstrap: async () => {},
bootstrapInstance: (directory) => {
calls.push(directory)
},
})
queue.push("C:\\tmp\\demo")
await tick()
expect(calls).toEqual(["C:\\tmp\\demo"])
queue.dispose()
})
})
@@ -2,25 +2,22 @@ type QueueInput = {
paused: () => boolean paused: () => boolean
bootstrap: () => Promise<void> bootstrap: () => Promise<void>
bootstrapInstance: (directory: string) => Promise<void> | void bootstrapInstance: (directory: string) => Promise<void> | void
key?: (directory: string) => string
} }
export function createRefreshQueue(input: QueueInput) { export function createRefreshQueue(input: QueueInput) {
const queued = new Map<string, string>() const queued = new Set<string>()
let root = false let root = false
let running = false let running = false
let timer: ReturnType<typeof setTimeout> | undefined let timer: ReturnType<typeof setTimeout> | undefined
const key = input.key ?? ((directory: string) => directory)
const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0)) const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
const take = (count: number) => { const take = (count: number) => {
if (queued.size === 0) return [] as string[] if (queued.size === 0) return [] as string[]
const items: string[] = [] const items: string[] = []
for (const [id, directory] of queued) { for (const item of queued) {
queued.delete(id) queued.delete(item)
items.push(directory) items.push(item)
if (items.length >= count) break if (items.length >= count) break
} }
return items return items
@@ -36,7 +33,7 @@ export function createRefreshQueue(input: QueueInput) {
const push = (directory: string) => { const push = (directory: string) => {
if (!directory) return if (!directory) return
queued.set(key(directory), directory) queued.add(directory)
if (input.paused()) return if (input.paused()) return
schedule() schedule()
} }
@@ -76,7 +73,7 @@ export function createRefreshQueue(input: QueueInput) {
push, push,
refresh, refresh,
clear(directory: string) { clear(directory: string) {
queued.delete(key(directory)) queued.delete(directory)
}, },
dispose() { dispose() {
if (!timer) return if (!timer) return
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test" import { describe, expect, test } from "bun:test"
import type { Agent } from "@opencode-ai/sdk/v2/client" import type { Agent } from "@opencode-ai/sdk/v2/client"
import { directoryKey, normalizeAgentList } from "./utils" import { normalizeAgentList } from "./utils"
const agent = (name = "build") => const agent = (name = "build") =>
({ ({
@@ -33,20 +33,3 @@ describe("normalizeAgentList", () => {
expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")])
}) })
}) })
describe("directoryKey", () => {
test("normalizes slashes", () => {
expect(String(directoryKey("C:\\Repos\\sst\\opencode"))).toBe("C:/Repos/sst/opencode")
expect(String(directoryKey("C:/Repos/sst/opencode"))).toBe("C:/Repos/sst/opencode")
})
test("preserves backslashes in posix paths", () => {
expect(String(directoryKey("/tmp/foo\\bar"))).toBe("/tmp/foo\\bar")
})
test("trims trailing slashes without breaking roots", () => {
expect(String(directoryKey("C:/Repos/sst/opencode/"))).toBe("C:/Repos/sst/opencode")
expect(String(directoryKey("C:/"))).toBe("C:/")
expect(String(directoryKey("/"))).toBe("/")
})
})
@@ -1,5 +1,4 @@
import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client"
export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key"
export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+1 -8
View File
@@ -391,14 +391,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
? globalSync.data.project.find((x) => x.id === projectID) ? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree) : globalSync.data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon). return { ...metadata, ...project }
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = { ...metadata, ...project }
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
return base
} }
const roots = createMemo(() => { const roots = createMemo(() => {
+1 -1
View File
@@ -382,7 +382,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setSaved("session", session, { setSaved("session", session, {
agent: msg.agent, agent: msg.agent,
model: msg.model, model: msg.model,
variant: msg.model?.variant ?? null, variant: msg.model.variant ?? null,
}) })
}, },
}, },
-53
View File
@@ -1,53 +0,0 @@
import { describe, expect, test } from "bun:test"
import { resolveServerList, ServerConnection } from "./server"
describe("resolveServerList", () => {
test("lets startup auth_token credentials override a persisted same-url server", () => {
const list = resolveServerList({
stored: [{ url: "https://server.example.test" }],
props: [
{
type: "http",
authToken: true,
http: {
url: "https://server.example.test",
username: "opencode",
password: "secret",
},
},
],
})
expect(list).toHaveLength(1)
expect(list[0]?.type).toBe("http")
expect(list[0]?.http).toEqual({
url: "https://server.example.test",
username: "opencode",
password: "secret",
})
expect(list[0]?.type === "http" ? list[0].authToken : false).toBe(true)
expect(ServerConnection.key(list[0]!) as string).toBe("https://server.example.test")
})
test("keeps persisted credentials when startup has no auth_token", () => {
const list = resolveServerList({
stored: [
{
url: "https://server.example.test",
username: "opencode",
password: "saved",
},
],
props: [{ type: "http", http: { url: "https://server.example.test" } }],
})
expect(list).toHaveLength(1)
expect(list[0]?.type).toBe("http")
expect(list[0]?.http).toEqual({
url: "https://server.example.test",
username: "opencode",
password: "saved",
})
expect(list[0]?.type === "http" ? list[0].authToken : true).toBeUndefined()
})
})
+21 -30
View File
@@ -33,33 +33,6 @@ function isLocalHost(url: string) {
if (host === "localhost" || host === "127.0.0.1") return "local" if (host === "localhost" || host === "127.0.0.1") return "local"
} }
export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
}): Array<ServerConnection.Any> {
const servers = [
...input.stored.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
...(input.props ?? []),
]
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>()
for (const value of servers) {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
const key = ServerConnection.key(conn)
if (deduped.has(key) && conn.type === "http" && !conn.authToken) continue
deduped.set(key, conn)
}
return [...deduped.values()]
}
export namespace ServerConnection { export namespace ServerConnection {
type Base = { displayName?: string } type Base = { displayName?: string }
@@ -73,7 +46,6 @@ export namespace ServerConnection {
export type Http = { export type Http = {
type: "http" type: "http"
http: HttpBase http: HttpBase
authToken?: boolean
} & Base } & Base
export type Sidecar = { export type Sidecar = {
@@ -141,7 +113,26 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url) const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => { const allServers = createMemo((): Array<ServerConnection.Any> => {
return resolveServerList({ stored: store.list, props: props.servers }) const servers = [
...(props.servers ?? []),
...store.list.map((value) =>
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: value,
),
]
const deduped = new Map(
servers.map((value) => {
const conn: ServerConnection.Any = "type" in value ? value : { type: "http", http: value }
return [ServerConnection.key(conn), conn]
}),
)
return [...deduped.values()]
}) })
const [state, setState] = createStore({ const [state, setState] = createStore({
@@ -183,7 +174,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
function add(input: ServerConnection.Http) { function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url) const url_ = normalizeServerUrl(input.http.url)
if (!url_) return if (!url_) return
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } } const conn = { ...input, http: { ...input.http, url: url_ } }
return batch(() => { return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_) const existing = store.list.findIndex((x) => url(x) === url_)
if (existing !== -1) { if (existing !== -1) {
+1 -44
View File
@@ -1,9 +1,6 @@
import { beforeAll, describe, expect, mock, test } from "bun:test" import { beforeAll, describe, expect, mock, test } from "bun:test"
type ServerKey = Parameters<typeof import("./terminal").getTerminalServerScope>[1] let getWorkspaceTerminalCacheKey: (dir: string) => string
let getWorkspaceTerminalCacheKey: (dir: string, scope?: string) => string
let getTerminalServerScope: typeof import("./terminal").getTerminalServerScope
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[] let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown let migrateTerminalState: (value: unknown) => unknown
@@ -20,7 +17,6 @@ beforeAll(async () => {
})) }))
const mod = await import("./terminal") const mod = await import("./terminal")
getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey getWorkspaceTerminalCacheKey = mod.getWorkspaceTerminalCacheKey
getTerminalServerScope = mod.getTerminalServerScope
getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys getLegacyTerminalStorageKeys = mod.getLegacyTerminalStorageKeys
migrateTerminalState = mod.migrateTerminalState migrateTerminalState = mod.migrateTerminalState
}) })
@@ -29,45 +25,6 @@ describe("getWorkspaceTerminalCacheKey", () => {
test("uses workspace-only directory cache key", () => { test("uses workspace-only directory cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__") expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
}) })
test("can include a server scope", () => {
expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("wsl:Debian:/repo:__workspace__")
})
})
describe("getTerminalServerScope", () => {
test("preserves local server keys", () => {
expect(
getTerminalServerScope(
{ type: "sidecar", variant: "base", http: { url: "http://127.0.0.1:4096" } },
"sidecar" as ServerKey,
),
).toBeUndefined()
expect(
getTerminalServerScope(
{ type: "http", http: { url: "http://localhost:4096" } },
"http://localhost:4096" as ServerKey,
),
).toBeUndefined()
expect(
getTerminalServerScope({ type: "http", http: { url: "http://[::1]:4096" } }, "http://[::1]:4096" as ServerKey),
).toBeUndefined()
})
test("scopes non-local server keys", () => {
expect(
getTerminalServerScope(
{ type: "sidecar", variant: "wsl", distro: "Debian", http: { url: "http://127.0.0.1:4096" } },
"wsl:Debian" as ServerKey,
),
).toBe("wsl:Debian" as ServerKey)
expect(
getTerminalServerScope(
{ type: "http", http: { url: "https://example.com" } },
"https://example.com" as ServerKey,
),
).toBe("https://example.com" as ServerKey)
})
}) })
describe("getLegacyTerminalStorageKeys", () => { describe("getLegacyTerminalStorageKeys", () => {
+15 -47
View File
@@ -4,7 +4,6 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk" import { useSDK } from "./sdk"
import type { Platform } from "./platform" import type { Platform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { defaultTitle, titleNumber } from "./terminal-title" import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist" import { Persist, persisted, removePersisted } from "@/utils/persist"
@@ -83,31 +82,10 @@ export function migrateTerminalState(value: unknown) {
} }
} }
export function getWorkspaceTerminalCacheKey(dir: string, scope?: string) { export function getWorkspaceTerminalCacheKey(dir: string) {
if (scope) return `${scope}:${dir}:${WORKSPACE_KEY}`
return `${dir}:${WORKSPACE_KEY}` return `${dir}:${WORKSPACE_KEY}`
} }
export function getTerminalServerScope(conn: ServerConnection.Any | undefined, key: ServerConnection.Key) {
if (!conn) return
if (conn.type === "sidecar" && conn.variant === "base") return
if (conn.type === "http") {
try {
const url = new URL(conn.http.url)
if (
url.hostname === "localhost" ||
url.hostname === "127.0.0.1" ||
url.hostname === "::1" ||
url.hostname === "[::1]"
)
return
} catch {
return key
}
}
return key
}
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) { export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
if (!legacySessionID) return [`${dir}/terminal.v1`] if (!legacySessionID) return [`${dir}/terminal.v1`]
return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`] return [`${dir}/terminal/${legacySessionID}.v1`, `${dir}/terminal.v1`]
@@ -132,16 +110,15 @@ const trimTerminal = (pty: LocalPTY) => {
} }
} }
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform, scope?: string) { export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
const key = getWorkspaceTerminalCacheKey(dir, scope) const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) { for (const cache of caches) {
const entry = cache.get(key) const entry = cache.get(key)
entry?.value.clear() entry?.value.clear()
} }
void removePersisted(Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal"), platform) void removePersisted(Persist.workspace(dir, "terminal"), platform)
if (scope) return
const legacy = new Set(getLegacyTerminalStorageKeys(dir)) const legacy = new Set(getLegacyTerminalStorageKeys(dir))
for (const id of sessionIDs ?? []) { for (const id of sessionIDs ?? []) {
for (const key of getLegacyTerminalStorageKeys(dir, id)) { for (const key of getLegacyTerminalStorageKeys(dir, id)) {
@@ -153,17 +130,12 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
} }
} }
function createWorkspaceTerminalSession( function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
sdk: ReturnType<typeof useSDK>, const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
dir: string,
legacySessionID?: string,
scope?: string,
) {
const legacy = scope ? [] : getLegacyTerminalStorageKeys(dir, legacySessionID)
const [store, setStore, _, ready] = persisted( const [store, setStore, _, ready] = persisted(
{ {
...Persist.workspace(dir, scope ? `terminal:${scope}` : "terminal", legacy), ...Persist.workspace(dir, "terminal", legacy),
migrate: migrateTerminalState, migrate: migrateTerminalState,
}, },
createStore<{ createStore<{
@@ -385,12 +357,8 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false, gate: false,
init: () => { init: () => {
const sdk = useSDK() const sdk = useSDK()
const server = useServer()
const params = useParams() const params = useParams()
const cache = new Map<string, TerminalCacheEntry>() const cache = new Map<string, TerminalCacheEntry>()
const scope = createMemo(() => {
return getTerminalServerScope(server.current, server.key)
})
caches.add(cache) caches.add(cache)
onCleanup(() => caches.delete(cache)) onCleanup(() => caches.delete(cache))
@@ -414,9 +382,9 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
} }
} }
const loadWorkspace = (dir: string, legacySessionID: string | undefined, serverScope: string | undefined) => { const loadWorkspace = (dir: string, legacySessionID?: string) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory. // Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
const key = getWorkspaceTerminalCacheKey(dir, serverScope) const key = getWorkspaceTerminalCacheKey(dir)
const existing = cache.get(key) const existing = cache.get(key)
if (existing) { if (existing) {
cache.delete(key) cache.delete(key)
@@ -425,7 +393,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
} }
const entry = createRoot((dispose) => ({ const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID, serverScope), value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
dispose, dispose,
})) }))
@@ -434,16 +402,16 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value return entry.value
} }
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope())) const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
createEffect( createEffect(
on( on(
() => ({ dir: params.dir, id: params.id, scope: scope() }), () => ({ dir: params.dir, id: params.id }),
(next, prev) => { (next, prev) => {
if (!prev?.dir) return if (!prev?.dir) return
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id && next.scope === prev.scope) return if (next.dir === prev.dir && next.id) return
loadWorkspace(prev.dir, prev.id, prev.scope).trimAll() loadWorkspace(prev.dir, prev.id).trimAll()
}, },
{ defer: true }, { defer: true },
), ),
+1 -38
View File
@@ -1,13 +1,11 @@
// @refresh reload // @refresh reload
import * as Sentry from "@sentry/solid"
import { render } from "solid-js/web" import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app" import { AppBaseProviders, AppInterface } from "@/app"
import { type Platform, PlatformProvider } from "@/context/platform" import { type Platform, PlatformProvider } from "@/context/platform"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh" import { dict as zh } from "@/i18n/zh"
import { handleNotificationClick } from "@/utils/notification-click" import { handleNotificationClick } from "@/utils/notification-click"
import { authFromToken } from "@/utils/server"
import pkg from "../package.json" import pkg from "../package.json"
import { ServerConnection } from "./context/server" import { ServerConnection } from "./context/server"
@@ -112,13 +110,6 @@ const getDefaultUrl = () => {
return getCurrentUrl() return getCurrentUrl()
} }
const clearAuthToken = () => {
const params = new URLSearchParams(location.search)
if (!params.has("auth_token")) return
params.delete("auth_token")
history.replaceState(null, "", location.pathname + (params.size ? `?${params}` : "") + location.hash)
}
const platform: Platform = { const platform: Platform = {
platform: "web", platform: "web",
version: pkg.version, version: pkg.version,
@@ -134,36 +125,8 @@ const platform: Platform = {
setDefaultServer: writeDefaultServerUrl, setDefaultServer: writeDefaultServerUrl,
} }
if (import.meta.env.VITE_SENTRY_DSN) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.VITE_SENTRY_ENVIRONMENT ?? import.meta.env.MODE,
release: import.meta.env.VITE_SENTRY_RELEASE ?? `web@${pkg.version}`,
initialScope: {
tags: {
platform: "web",
},
},
integrations: (integrations) => {
return integrations.filter(
(i) =>
i.name !== "Breadcrumbs" && !(import.meta.env.OPENCODE_CHANNEL === "prod" && i.name === "GlobalHandlers"),
)
},
})
}
if (root instanceof HTMLElement) { if (root instanceof HTMLElement) {
const auth = authFromToken(new URLSearchParams(location.search).get("auth_token")) const server: ServerConnection.Http = { type: "http", http: { url: getCurrentUrl() } }
clearAuthToken()
const server: ServerConnection.Http = {
type: "http",
authToken: !!auth,
http: {
url: getCurrentUrl(),
...auth,
},
}
render( render(
() => ( () => (
<PlatformProvider value={platform}> <PlatformProvider value={platform}>
-4
View File
@@ -2,10 +2,6 @@ interface ImportMetaEnv {
readonly VITE_OPENCODE_SERVER_HOST: string readonly VITE_OPENCODE_SERVER_HOST: string
readonly VITE_OPENCODE_SERVER_PORT: string readonly VITE_OPENCODE_SERVER_PORT: string
readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod" readonly VITE_OPENCODE_CHANNEL?: "dev" | "beta" | "prod"
readonly VITE_SENTRY_DSN?: string
readonly VITE_SENTRY_ENVIRONMENT?: string
readonly VITE_SENTRY_RELEASE?: string
} }
interface ImportMeta { interface ImportMeta {
+2 -2
View File
@@ -402,8 +402,6 @@ export const dict = {
"error.page.description": "حدث خطأ أثناء تحميل التطبيق.", "error.page.description": "حدث خطأ أثناء تحميل التطبيق.",
"error.page.details.label": "تفاصيل الخطأ", "error.page.details.label": "تفاصيل الخطأ",
"error.page.action.restart": "إعادة تشغيل", "error.page.action.restart": "إعادة تشغيل",
"error.page.action.report": "الإبلاغ عن الخطأ",
"error.page.action.reported": "تم الإبلاغ عن الخطأ",
"error.page.action.checking": "جارٍ التحقق...", "error.page.action.checking": "جارٍ التحقق...",
"error.page.action.checkUpdates": "التحقق من وجود تحديثات", "error.page.action.checkUpdates": "التحقق من وجود تحديثات",
"error.page.action.updateTo": "تحديث إلى {{version}}", "error.page.action.updateTo": "تحديث إلى {{version}}",
@@ -723,6 +721,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "جلب محتوى من عنوان URL", "settings.permissions.tool.webfetch.description": "جلب محتوى من عنوان URL",
"settings.permissions.tool.websearch.title": "بحث الويب", "settings.permissions.tool.websearch.title": "بحث الويب",
"settings.permissions.tool.websearch.description": "البحث في الويب", "settings.permissions.tool.websearch.description": "البحث في الويب",
"settings.permissions.tool.codesearch.title": "بحث الكود",
"settings.permissions.tool.codesearch.description": "البحث عن كود على الويب",
"settings.permissions.tool.external_directory.title": "دليل خارجي", "settings.permissions.tool.external_directory.title": "دليل خارجي",
"settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع", "settings.permissions.tool.external_directory.description": "الوصول إلى الملفات خارج دليل المشروع",
"settings.permissions.tool.doom_loop.title": "حلقة الموت", "settings.permissions.tool.doom_loop.title": "حلقة الموت",
+2 -2
View File
@@ -403,8 +403,6 @@ export const dict = {
"error.page.description": "Ocorreu um erro ao carregar a aplicação.", "error.page.description": "Ocorreu um erro ao carregar a aplicação.",
"error.page.details.label": "Detalhes do Erro", "error.page.details.label": "Detalhes do Erro",
"error.page.action.restart": "Reiniciar", "error.page.action.restart": "Reiniciar",
"error.page.action.report": "Reportar erro",
"error.page.action.reported": "Erro reportado",
"error.page.action.checking": "Verificando...", "error.page.action.checking": "Verificando...",
"error.page.action.checkUpdates": "Verificar atualizações", "error.page.action.checkUpdates": "Verificar atualizações",
"error.page.action.updateTo": "Atualizar para {{version}}", "error.page.action.updateTo": "Atualizar para {{version}}",
@@ -734,6 +732,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Buscar conteúdo de uma URL", "settings.permissions.tool.webfetch.description": "Buscar conteúdo de uma URL",
"settings.permissions.tool.websearch.title": "Pesquisa Web", "settings.permissions.tool.websearch.title": "Pesquisa Web",
"settings.permissions.tool.websearch.description": "Pesquisar na web", "settings.permissions.tool.websearch.description": "Pesquisar na web",
"settings.permissions.tool.codesearch.title": "Pesquisa de Código",
"settings.permissions.tool.codesearch.description": "Pesquisar código na web",
"settings.permissions.tool.external_directory.title": "Diretório Externo", "settings.permissions.tool.external_directory.title": "Diretório Externo",
"settings.permissions.tool.external_directory.description": "Acessar arquivos fora do diretório do projeto", "settings.permissions.tool.external_directory.description": "Acessar arquivos fora do diretório do projeto",
"settings.permissions.tool.doom_loop.title": "Loop Infinito", "settings.permissions.tool.doom_loop.title": "Loop Infinito",
+2 -2
View File
@@ -449,8 +449,6 @@ export const dict = {
"error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.", "error.page.description": "Došlo je do greške prilikom učitavanja aplikacije.",
"error.page.details.label": "Detalji greške", "error.page.details.label": "Detalji greške",
"error.page.action.restart": "Restartuj", "error.page.action.restart": "Restartuj",
"error.page.action.report": "Prijavi grešku",
"error.page.action.reported": "Greška prijavljena",
"error.page.action.checking": "Provjera...", "error.page.action.checking": "Provjera...",
"error.page.action.checkUpdates": "Provjeri ažuriranja", "error.page.action.checkUpdates": "Provjeri ažuriranja",
"error.page.action.updateTo": "Ažuriraj na {{version}}", "error.page.action.updateTo": "Ažuriraj na {{version}}",
@@ -808,6 +806,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Preuzmi sadržaj sa URL-a", "settings.permissions.tool.webfetch.description": "Preuzmi sadržaj sa URL-a",
"settings.permissions.tool.websearch.title": "Web pretraga", "settings.permissions.tool.websearch.title": "Web pretraga",
"settings.permissions.tool.websearch.description": "Pretražuj web", "settings.permissions.tool.websearch.description": "Pretražuj web",
"settings.permissions.tool.codesearch.title": "Pretraga koda",
"settings.permissions.tool.codesearch.description": "Pretraži kod na webu",
"settings.permissions.tool.external_directory.title": "Vanjski direktorij", "settings.permissions.tool.external_directory.title": "Vanjski direktorij",
"settings.permissions.tool.external_directory.description": "Pristup datotekama izvan direktorija projekta", "settings.permissions.tool.external_directory.description": "Pristup datotekama izvan direktorija projekta",
"settings.permissions.tool.doom_loop.title": "Beskonačna petlja", "settings.permissions.tool.doom_loop.title": "Beskonačna petlja",
+2 -2
View File
@@ -446,8 +446,6 @@ export const dict = {
"error.page.description": "Der opstod en fejl under indlæsning af applikationen.", "error.page.description": "Der opstod en fejl under indlæsning af applikationen.",
"error.page.details.label": "Fejldetaljer", "error.page.details.label": "Fejldetaljer",
"error.page.action.restart": "Genstart", "error.page.action.restart": "Genstart",
"error.page.action.report": "Rapportér fejl",
"error.page.action.reported": "Fejl rapporteret",
"error.page.action.checking": "Tjekker...", "error.page.action.checking": "Tjekker...",
"error.page.action.checkUpdates": "Tjek for opdateringer", "error.page.action.checkUpdates": "Tjek for opdateringer",
"error.page.action.updateTo": "Opdater til {{version}}", "error.page.action.updateTo": "Opdater til {{version}}",
@@ -802,6 +800,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Hent indhold fra en URL", "settings.permissions.tool.webfetch.description": "Hent indhold fra en URL",
"settings.permissions.tool.websearch.title": "Websøgning", "settings.permissions.tool.websearch.title": "Websøgning",
"settings.permissions.tool.websearch.description": "Søg på nettet", "settings.permissions.tool.websearch.description": "Søg på nettet",
"settings.permissions.tool.codesearch.title": "Kodesøgning",
"settings.permissions.tool.codesearch.description": "Søg kode på nettet",
"settings.permissions.tool.external_directory.title": "Ekstern mappe", "settings.permissions.tool.external_directory.title": "Ekstern mappe",
"settings.permissions.tool.external_directory.description": "Få adgang til filer uden for projektmappen", "settings.permissions.tool.external_directory.description": "Få adgang til filer uden for projektmappen",
"settings.permissions.tool.doom_loop.title": "Doom Loop", "settings.permissions.tool.doom_loop.title": "Doom Loop",
+2 -2
View File
@@ -410,8 +410,6 @@ export const dict = {
"error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.", "error.page.description": "Beim Laden der Anwendung ist ein Fehler aufgetreten.",
"error.page.details.label": "Fehlerdetails", "error.page.details.label": "Fehlerdetails",
"error.page.action.restart": "Neustart", "error.page.action.restart": "Neustart",
"error.page.action.report": "Fehler melden",
"error.page.action.reported": "Fehler gemeldet",
"error.page.action.checking": "Prüfen...", "error.page.action.checking": "Prüfen...",
"error.page.action.checkUpdates": "Nach Updates suchen", "error.page.action.checkUpdates": "Nach Updates suchen",
"error.page.action.updateTo": "Auf {{version}} aktualisieren", "error.page.action.updateTo": "Auf {{version}} aktualisieren",
@@ -745,6 +743,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Inhalt von einer URL abrufen", "settings.permissions.tool.webfetch.description": "Inhalt von einer URL abrufen",
"settings.permissions.tool.websearch.title": "Web-Suche", "settings.permissions.tool.websearch.title": "Web-Suche",
"settings.permissions.tool.websearch.description": "Das Web durchsuchen", "settings.permissions.tool.websearch.description": "Das Web durchsuchen",
"settings.permissions.tool.codesearch.title": "Code-Suche",
"settings.permissions.tool.codesearch.description": "Code im Web durchsuchen",
"settings.permissions.tool.external_directory.title": "Externes Verzeichnis", "settings.permissions.tool.external_directory.title": "Externes Verzeichnis",
"settings.permissions.tool.external_directory.description": "Zugriff auf Dateien außerhalb des Projektverzeichnisses", "settings.permissions.tool.external_directory.description": "Zugriff auf Dateien außerhalb des Projektverzeichnisses",
"settings.permissions.tool.doom_loop.title": "Doom Loop", "settings.permissions.tool.doom_loop.title": "Doom Loop",
+2 -2
View File
@@ -465,8 +465,6 @@ export const dict = {
"error.page.description": "An error occurred while loading the application.", "error.page.description": "An error occurred while loading the application.",
"error.page.details.label": "Error Details", "error.page.details.label": "Error Details",
"error.page.action.restart": "Restart", "error.page.action.restart": "Restart",
"error.page.action.report": "Report Error",
"error.page.action.reported": "Error Reported",
"error.page.action.checking": "Checking...", "error.page.action.checking": "Checking...",
"error.page.action.checkUpdates": "Check for updates", "error.page.action.checkUpdates": "Check for updates",
"error.page.action.updateTo": "Update to {{version}}", "error.page.action.updateTo": "Update to {{version}}",
@@ -922,6 +920,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Fetch content from a URL", "settings.permissions.tool.webfetch.description": "Fetch content from a URL",
"settings.permissions.tool.websearch.title": "Web Search", "settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Search the web", "settings.permissions.tool.websearch.description": "Search the web",
"settings.permissions.tool.codesearch.title": "Code Search",
"settings.permissions.tool.codesearch.description": "Search code on the web",
"settings.permissions.tool.external_directory.title": "External Directory", "settings.permissions.tool.external_directory.title": "External Directory",
"settings.permissions.tool.external_directory.description": "Access files outside the project directory", "settings.permissions.tool.external_directory.description": "Access files outside the project directory",
"settings.permissions.tool.doom_loop.title": "Doom Loop", "settings.permissions.tool.doom_loop.title": "Doom Loop",
+2 -2
View File
@@ -449,8 +449,6 @@ export const dict = {
"error.page.description": "Ocurrió un error al cargar la aplicación.", "error.page.description": "Ocurrió un error al cargar la aplicación.",
"error.page.details.label": "Detalles del error", "error.page.details.label": "Detalles del error",
"error.page.action.restart": "Reiniciar", "error.page.action.restart": "Reiniciar",
"error.page.action.report": "Informar error",
"error.page.action.reported": "Error informado",
"error.page.action.checking": "Comprobando...", "error.page.action.checking": "Comprobando...",
"error.page.action.checkUpdates": "Buscar actualizaciones", "error.page.action.checkUpdates": "Buscar actualizaciones",
"error.page.action.updateTo": "Actualizar a {{version}}", "error.page.action.updateTo": "Actualizar a {{version}}",
@@ -815,6 +813,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Obtener contenido de una URL", "settings.permissions.tool.webfetch.description": "Obtener contenido de una URL",
"settings.permissions.tool.websearch.title": "Búsqueda Web", "settings.permissions.tool.websearch.title": "Búsqueda Web",
"settings.permissions.tool.websearch.description": "Buscar en la web", "settings.permissions.tool.websearch.description": "Buscar en la web",
"settings.permissions.tool.codesearch.title": "Búsqueda de Código",
"settings.permissions.tool.codesearch.description": "Buscar código en la web",
"settings.permissions.tool.external_directory.title": "Directorio Externo", "settings.permissions.tool.external_directory.title": "Directorio Externo",
"settings.permissions.tool.external_directory.description": "Acceder a archivos fuera del directorio del proyecto", "settings.permissions.tool.external_directory.description": "Acceder a archivos fuera del directorio del proyecto",
"settings.permissions.tool.doom_loop.title": "Bucle Infinito", "settings.permissions.tool.doom_loop.title": "Bucle Infinito",
+2 -2
View File
@@ -406,8 +406,6 @@ export const dict = {
"error.page.description": "Une erreur s'est produite lors du chargement de l'application.", "error.page.description": "Une erreur s'est produite lors du chargement de l'application.",
"error.page.details.label": "Détails de l'erreur", "error.page.details.label": "Détails de l'erreur",
"error.page.action.restart": "Redémarrer", "error.page.action.restart": "Redémarrer",
"error.page.action.report": "Signaler l'erreur",
"error.page.action.reported": "Erreur signalée",
"error.page.action.checking": "Vérification...", "error.page.action.checking": "Vérification...",
"error.page.action.checkUpdates": "Vérifier les mises à jour", "error.page.action.checkUpdates": "Vérifier les mises à jour",
"error.page.action.updateTo": "Mettre à jour vers {{version}}", "error.page.action.updateTo": "Mettre à jour vers {{version}}",
@@ -743,6 +741,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Récupérer le contenu d'une URL", "settings.permissions.tool.webfetch.description": "Récupérer le contenu d'une URL",
"settings.permissions.tool.websearch.title": "Recherche Web", "settings.permissions.tool.websearch.title": "Recherche Web",
"settings.permissions.tool.websearch.description": "Rechercher sur le web", "settings.permissions.tool.websearch.description": "Rechercher sur le web",
"settings.permissions.tool.codesearch.title": "Recherche de code",
"settings.permissions.tool.codesearch.description": "Rechercher du code sur le web",
"settings.permissions.tool.external_directory.title": "Répertoire externe", "settings.permissions.tool.external_directory.title": "Répertoire externe",
"settings.permissions.tool.external_directory.description": "Accéder aux fichiers en dehors du répertoire du projet", "settings.permissions.tool.external_directory.description": "Accéder aux fichiers en dehors du répertoire du projet",
"settings.permissions.tool.doom_loop.title": "Boucle infernale", "settings.permissions.tool.doom_loop.title": "Boucle infernale",
+2 -2
View File
@@ -402,8 +402,6 @@ export const dict = {
"error.page.description": "アプリケーションの読み込み中にエラーが発生しました。", "error.page.description": "アプリケーションの読み込み中にエラーが発生しました。",
"error.page.details.label": "エラー詳細", "error.page.details.label": "エラー詳細",
"error.page.action.restart": "再起動", "error.page.action.restart": "再起動",
"error.page.action.report": "エラーを報告",
"error.page.action.reported": "エラーを報告しました",
"error.page.action.checking": "確認中...", "error.page.action.checking": "確認中...",
"error.page.action.checkUpdates": "アップデートを確認", "error.page.action.checkUpdates": "アップデートを確認",
"error.page.action.updateTo": "{{version}}にアップデート", "error.page.action.updateTo": "{{version}}にアップデート",
@@ -729,6 +727,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "URLからコンテンツを取得", "settings.permissions.tool.webfetch.description": "URLからコンテンツを取得",
"settings.permissions.tool.websearch.title": "Web検索", "settings.permissions.tool.websearch.title": "Web検索",
"settings.permissions.tool.websearch.description": "ウェブを検索", "settings.permissions.tool.websearch.description": "ウェブを検索",
"settings.permissions.tool.codesearch.title": "コード検索",
"settings.permissions.tool.codesearch.description": "ウェブ上のコードを検索",
"settings.permissions.tool.external_directory.title": "外部ディレクトリ", "settings.permissions.tool.external_directory.title": "外部ディレクトリ",
"settings.permissions.tool.external_directory.description": "プロジェクトディレクトリ外のファイルへのアクセス", "settings.permissions.tool.external_directory.description": "プロジェクトディレクトリ外のファイルへのアクセス",
"settings.permissions.tool.doom_loop.title": "無限ループ", "settings.permissions.tool.doom_loop.title": "無限ループ",
+2 -2
View File
@@ -401,8 +401,6 @@ export const dict = {
"error.page.description": "애플리케이션을 로드하는 동안 오류가 발생했습니다.", "error.page.description": "애플리케이션을 로드하는 동안 오류가 발생했습니다.",
"error.page.details.label": "오류 세부 정보", "error.page.details.label": "오류 세부 정보",
"error.page.action.restart": "다시 시작", "error.page.action.restart": "다시 시작",
"error.page.action.report": "오류 신고",
"error.page.action.reported": "오류가 신고됨",
"error.page.action.checking": "확인 중...", "error.page.action.checking": "확인 중...",
"error.page.action.checkUpdates": "업데이트 확인", "error.page.action.checkUpdates": "업데이트 확인",
"error.page.action.updateTo": "{{version}} 버전으로 업데이트", "error.page.action.updateTo": "{{version}} 버전으로 업데이트",
@@ -724,6 +722,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "URL에서 콘텐츠 가져오기", "settings.permissions.tool.webfetch.description": "URL에서 콘텐츠 가져오기",
"settings.permissions.tool.websearch.title": "웹 검색", "settings.permissions.tool.websearch.title": "웹 검색",
"settings.permissions.tool.websearch.description": "웹 검색", "settings.permissions.tool.websearch.description": "웹 검색",
"settings.permissions.tool.codesearch.title": "코드 검색",
"settings.permissions.tool.codesearch.description": "웹에서 코드 검색",
"settings.permissions.tool.external_directory.title": "외부 디렉터리", "settings.permissions.tool.external_directory.title": "외부 디렉터리",
"settings.permissions.tool.external_directory.description": "프로젝트 디렉터리 외부의 파일에 액세스", "settings.permissions.tool.external_directory.description": "프로젝트 디렉터리 외부의 파일에 액세스",
"settings.permissions.tool.doom_loop.title": "무한 반복", "settings.permissions.tool.doom_loop.title": "무한 반복",
+2 -2
View File
@@ -450,8 +450,6 @@ export const dict = {
"error.page.description": "Det oppstod en feil under lasting av applikasjonen.", "error.page.description": "Det oppstod en feil under lasting av applikasjonen.",
"error.page.details.label": "Feildetaljer", "error.page.details.label": "Feildetaljer",
"error.page.action.restart": "Start på nytt", "error.page.action.restart": "Start på nytt",
"error.page.action.report": "Rapporter feil",
"error.page.action.reported": "Feil rapportert",
"error.page.action.checking": "Sjekker...", "error.page.action.checking": "Sjekker...",
"error.page.action.checkUpdates": "Se etter oppdateringer", "error.page.action.checkUpdates": "Se etter oppdateringer",
"error.page.action.updateTo": "Oppdater til {{version}}", "error.page.action.updateTo": "Oppdater til {{version}}",
@@ -809,6 +807,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Hent innhold fra en URL", "settings.permissions.tool.webfetch.description": "Hent innhold fra en URL",
"settings.permissions.tool.websearch.title": "Websøk", "settings.permissions.tool.websearch.title": "Websøk",
"settings.permissions.tool.websearch.description": "Søk på nettet", "settings.permissions.tool.websearch.description": "Søk på nettet",
"settings.permissions.tool.codesearch.title": "Kodesøk",
"settings.permissions.tool.codesearch.description": "Søk etter kode på nettet",
"settings.permissions.tool.external_directory.title": "Ekstern mappe", "settings.permissions.tool.external_directory.title": "Ekstern mappe",
"settings.permissions.tool.external_directory.description": "Få tilgang til filer utenfor prosjektmappen", "settings.permissions.tool.external_directory.description": "Få tilgang til filer utenfor prosjektmappen",
"settings.permissions.tool.doom_loop.title": "Doom Loop", "settings.permissions.tool.doom_loop.title": "Doom Loop",
+2 -2
View File
@@ -403,8 +403,6 @@ export const dict = {
"error.page.description": "Wystąpił błąd podczas ładowania aplikacji.", "error.page.description": "Wystąpił błąd podczas ładowania aplikacji.",
"error.page.details.label": "Szczegóły błędu", "error.page.details.label": "Szczegóły błędu",
"error.page.action.restart": "Restartuj", "error.page.action.restart": "Restartuj",
"error.page.action.report": "Zgłoś błąd",
"error.page.action.reported": "Błąd zgłoszony",
"error.page.action.checking": "Sprawdzanie...", "error.page.action.checking": "Sprawdzanie...",
"error.page.action.checkUpdates": "Sprawdź aktualizacje", "error.page.action.checkUpdates": "Sprawdź aktualizacje",
"error.page.action.updateTo": "Zaktualizuj do {{version}}", "error.page.action.updateTo": "Zaktualizuj do {{version}}",
@@ -731,6 +729,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Pobieranie zawartości z adresu URL", "settings.permissions.tool.webfetch.description": "Pobieranie zawartości z adresu URL",
"settings.permissions.tool.websearch.title": "Wyszukiwanie w sieci", "settings.permissions.tool.websearch.title": "Wyszukiwanie w sieci",
"settings.permissions.tool.websearch.description": "Przeszukiwanie sieci", "settings.permissions.tool.websearch.description": "Przeszukiwanie sieci",
"settings.permissions.tool.codesearch.title": "Wyszukiwanie kodu",
"settings.permissions.tool.codesearch.description": "Przeszukiwanie kodu w sieci",
"settings.permissions.tool.external_directory.title": "Katalog zewnętrzny", "settings.permissions.tool.external_directory.title": "Katalog zewnętrzny",
"settings.permissions.tool.external_directory.description": "Dostęp do plików poza katalogiem projektu", "settings.permissions.tool.external_directory.description": "Dostęp do plików poza katalogiem projektu",
"settings.permissions.tool.doom_loop.title": "Zapętlenie", "settings.permissions.tool.doom_loop.title": "Zapętlenie",
+2 -2
View File
@@ -448,8 +448,6 @@ export const dict = {
"error.page.description": "Произошла ошибка при загрузке приложения.", "error.page.description": "Произошла ошибка при загрузке приложения.",
"error.page.details.label": "Детали ошибки", "error.page.details.label": "Детали ошибки",
"error.page.action.restart": "Перезапустить", "error.page.action.restart": "Перезапустить",
"error.page.action.report": "Сообщить об ошибке",
"error.page.action.reported": "Об ошибке сообщено",
"error.page.action.checking": "Проверка...", "error.page.action.checking": "Проверка...",
"error.page.action.checkUpdates": "Проверить обновления", "error.page.action.checkUpdates": "Проверить обновления",
"error.page.action.updateTo": "Обновить до {{version}}", "error.page.action.updateTo": "Обновить до {{version}}",
@@ -810,6 +808,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Получение контента по URL", "settings.permissions.tool.webfetch.description": "Получение контента по URL",
"settings.permissions.tool.websearch.title": "Web Search", "settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "Поиск в интернете", "settings.permissions.tool.websearch.description": "Поиск в интернете",
"settings.permissions.tool.codesearch.title": "Code Search",
"settings.permissions.tool.codesearch.description": "Поиск кода в интернете",
"settings.permissions.tool.external_directory.title": "Внешняя директория", "settings.permissions.tool.external_directory.title": "Внешняя директория",
"settings.permissions.tool.external_directory.description": "Доступ к файлам вне директории проекта", "settings.permissions.tool.external_directory.description": "Доступ к файлам вне директории проекта",
"settings.permissions.tool.doom_loop.title": "Doom Loop", "settings.permissions.tool.doom_loop.title": "Doom Loop",
+2 -2
View File
@@ -447,8 +447,6 @@ export const dict = {
"error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน", "error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน",
"error.page.details.label": "รายละเอียดข้อผิดพลาด", "error.page.details.label": "รายละเอียดข้อผิดพลาด",
"error.page.action.restart": "รีสตาร์ท", "error.page.action.restart": "รีสตาร์ท",
"error.page.action.report": "รายงานข้อผิดพลาด",
"error.page.action.reported": "รายงานข้อผิดพลาดแล้ว",
"error.page.action.checking": "กำลังตรวจสอบ...", "error.page.action.checking": "กำลังตรวจสอบ...",
"error.page.action.checkUpdates": "ตรวจสอบการอัปเดต", "error.page.action.checkUpdates": "ตรวจสอบการอัปเดต",
"error.page.action.updateTo": "อัปเดตเป็น {{version}}", "error.page.action.updateTo": "อัปเดตเป็น {{version}}",
@@ -798,6 +796,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "ดึงเนื้อหาจาก URL", "settings.permissions.tool.webfetch.description": "ดึงเนื้อหาจาก URL",
"settings.permissions.tool.websearch.title": "ค้นหาเว็บ", "settings.permissions.tool.websearch.title": "ค้นหาเว็บ",
"settings.permissions.tool.websearch.description": "ค้นหาบนเว็บ", "settings.permissions.tool.websearch.description": "ค้นหาบนเว็บ",
"settings.permissions.tool.codesearch.title": "ค้นหาโค้ด",
"settings.permissions.tool.codesearch.description": "ค้นหาโค้ดบนเว็บ",
"settings.permissions.tool.external_directory.title": "ไดเรกทอรีภายนอก", "settings.permissions.tool.external_directory.title": "ไดเรกทอรีภายนอก",
"settings.permissions.tool.external_directory.description": "เข้าถึงไฟล์นอกไดเรกทอรีโปรเจกต์", "settings.permissions.tool.external_directory.description": "เข้าถึงไฟล์นอกไดเรกทอรีโปรเจกต์",
"settings.permissions.tool.doom_loop.title": "Doom Loop", "settings.permissions.tool.doom_loop.title": "Doom Loop",
+2 -2
View File
@@ -452,8 +452,6 @@ export const dict = {
"error.page.description": "Uygulama yüklenirken bir hata oluştu.", "error.page.description": "Uygulama yüklenirken bir hata oluştu.",
"error.page.details.label": "Hata Detayları", "error.page.details.label": "Hata Detayları",
"error.page.action.restart": "Yeniden Başlat", "error.page.action.restart": "Yeniden Başlat",
"error.page.action.report": "Hatayı Bildir",
"error.page.action.reported": "Hata Bildirildi",
"error.page.action.checking": "Kontrol ediliyor...", "error.page.action.checking": "Kontrol ediliyor...",
"error.page.action.checkUpdates": "Güncellemeleri kontrol et", "error.page.action.checkUpdates": "Güncellemeleri kontrol et",
"error.page.action.updateTo": "{{version}} sürümüne güncelle", "error.page.action.updateTo": "{{version}} sürümüne güncelle",
@@ -818,6 +816,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "Bir URL'den içerik getir", "settings.permissions.tool.webfetch.description": "Bir URL'den içerik getir",
"settings.permissions.tool.websearch.title": "Web Ara", "settings.permissions.tool.websearch.title": "Web Ara",
"settings.permissions.tool.websearch.description": "Web'de ara", "settings.permissions.tool.websearch.description": "Web'de ara",
"settings.permissions.tool.codesearch.title": "Kod Ara",
"settings.permissions.tool.codesearch.description": "Web'de kod ara",
"settings.permissions.tool.external_directory.title": "Harici Dizin", "settings.permissions.tool.external_directory.title": "Harici Dizin",
"settings.permissions.tool.external_directory.description": "Proje dizini dışındaki dosyalara eriş", "settings.permissions.tool.external_directory.description": "Proje dizini dışındaki dosyalara eriş",
"settings.permissions.tool.doom_loop.title": "Sonsuz Döngü", "settings.permissions.tool.doom_loop.title": "Sonsuz Döngü",
+2 -2
View File
@@ -452,8 +452,6 @@ export const dict = {
"error.page.description": "加载应用程序时发生错误。", "error.page.description": "加载应用程序时发生错误。",
"error.page.details.label": "错误详情", "error.page.details.label": "错误详情",
"error.page.action.restart": "重启", "error.page.action.restart": "重启",
"error.page.action.report": "上报错误",
"error.page.action.reported": "错误已上报",
"error.page.action.checking": "检查中...", "error.page.action.checking": "检查中...",
"error.page.action.checkUpdates": "检查更新", "error.page.action.checkUpdates": "检查更新",
"error.page.action.updateTo": "更新到 {{version}}", "error.page.action.updateTo": "更新到 {{version}}",
@@ -795,6 +793,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "从 URL 获取内容", "settings.permissions.tool.webfetch.description": "从 URL 获取内容",
"settings.permissions.tool.websearch.title": "网页搜索", "settings.permissions.tool.websearch.title": "网页搜索",
"settings.permissions.tool.websearch.description": "搜索网页", "settings.permissions.tool.websearch.description": "搜索网页",
"settings.permissions.tool.codesearch.title": "代码搜索",
"settings.permissions.tool.codesearch.description": "在网上搜索代码",
"settings.permissions.tool.external_directory.title": "外部目录", "settings.permissions.tool.external_directory.title": "外部目录",
"settings.permissions.tool.external_directory.description": "访问项目目录之外的文件", "settings.permissions.tool.external_directory.description": "访问项目目录之外的文件",
"settings.permissions.tool.doom_loop.title": "死循环", "settings.permissions.tool.doom_loop.title": "死循环",
+2 -2
View File
@@ -445,8 +445,6 @@ export const dict = {
"error.page.description": "載入應用程式時發生錯誤。", "error.page.description": "載入應用程式時發生錯誤。",
"error.page.details.label": "錯誤詳情", "error.page.details.label": "錯誤詳情",
"error.page.action.restart": "重新啟動", "error.page.action.restart": "重新啟動",
"error.page.action.report": "回報錯誤",
"error.page.action.reported": "已回報錯誤",
"error.page.action.checking": "檢查中...", "error.page.action.checking": "檢查中...",
"error.page.action.checkUpdates": "檢查更新", "error.page.action.checkUpdates": "檢查更新",
"error.page.action.updateTo": "更新到 {{version}}", "error.page.action.updateTo": "更新到 {{version}}",
@@ -791,6 +789,8 @@ export const dict = {
"settings.permissions.tool.webfetch.description": "從 URL 取得內容", "settings.permissions.tool.webfetch.description": "從 URL 取得內容",
"settings.permissions.tool.websearch.title": "Web Search", "settings.permissions.tool.websearch.title": "Web Search",
"settings.permissions.tool.websearch.description": "搜尋網頁", "settings.permissions.tool.websearch.description": "搜尋網頁",
"settings.permissions.tool.codesearch.title": "Code Search",
"settings.permissions.tool.codesearch.description": "在網路上搜尋程式碼",
"settings.permissions.tool.external_directory.title": "外部目錄", "settings.permissions.tool.external_directory.title": "外部目錄",
"settings.permissions.tool.external_directory.description": "存取專案目錄之外的檔案", "settings.permissions.tool.external_directory.description": "存取專案目錄之外的檔案",
"settings.permissions.tool.doom_loop.title": "Doom Loop", "settings.permissions.tool.doom_loop.title": "Doom Loop",
+2 -20
View File
@@ -1,8 +1,7 @@
import { TextField } from "@opencode-ai/ui/text-field" import { TextField } from "@opencode-ai/ui/text-field"
import * as Sentry from "@sentry/solid"
import { Logo } from "@opencode-ai/ui/logo" import { Logo } from "@opencode-ai/ui/logo"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Component, createSignal, Show } from "solid-js" import { Component, Show } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -271,27 +270,10 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
label={language.t("error.page.details.label")} label={language.t("error.page.details.label")}
hideLabel hideLabel
/> />
<div class="flex flex-row items-center justify-center gap-3 flex-wrap max-w-64"> <div class="flex items-center gap-3">
<Button size="large" onClick={platform.restart}> <Button size="large" onClick={platform.restart}>
{language.t("error.page.action.restart")} {language.t("error.page.action.restart")}
</Button> </Button>
<Show when={Sentry.isEnabled}>
{(_) => {
const [reported, setReported] = createSignal(false)
return (
<Button
size="large"
disabled={reported()}
onClick={() => {
Sentry.captureException(props.error)
setReported(true)
}}
>
{language.t(reported() ? "error.page.action.reported" : "error.page.action.report")}
</Button>
)
}}
</Show>
<Show when={platform.checkUpdate}> <Show when={platform.checkUpdate}>
<Show <Show
when={store.version} when={store.version}
+40 -35
View File
@@ -35,7 +35,7 @@ import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers" import { useProviders } from "@/hooks/use-providers"
import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSDK } from "@/context/global-sdk"
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal" import { clearWorkspaceTerminals } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import { import {
clearSessionPrefetchInflight, clearSessionPrefetchInflight,
@@ -64,13 +64,13 @@ import { DebugBar } from "@/components/debug-bar"
import { Titlebar } from "@/components/titlebar" import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language" import { useLanguage, type Locale } from "@/context/language"
import { pathKey } from "@/utils/path-key"
import { import {
displayName, displayName,
effectiveWorkspaceOrder, effectiveWorkspaceOrder,
errorMessage, errorMessage,
latestRootSession, latestRootSession,
sortedRootSessions, sortedRootSessions,
workspaceKey,
} from "./layout/helpers" } from "./layout/helpers"
import { import {
collectNewSessionDeepLinks, collectNewSessionDeepLinks,
@@ -164,7 +164,7 @@ export default function Layout(props: ParentProps) {
const editor = createInlineEditorController() const editor = createInlineEditorController()
const setBusy = (directory: string, value: boolean) => { const setBusy = (directory: string, value: boolean) => {
const key = pathKey(directory) const key = workspaceKey(directory)
if (value) { if (value) {
setState("busyWorkspaces", key, true) setState("busyWorkspaces", key, true)
return return
@@ -176,7 +176,7 @@ export default function Layout(props: ParentProps) {
}), }),
) )
} }
const isBusy = (directory: string) => !!state.busyWorkspaces[pathKey(directory)] const isBusy = (directory: string) => !!state.busyWorkspaces[workspaceKey(directory)]
const navLeave = { current: undefined as number | undefined } const navLeave = { current: undefined as number | undefined }
const sortNow = () => state.sortNow const sortNow = () => state.sortNow
let sizet: number | undefined let sizet: number | undefined
@@ -497,8 +497,8 @@ export default function Layout(props: ParentProps) {
} }
const currentSession = params.id const currentSession = params.id
if (pathKey(directory) === pathKey(currentDir()) && props.sessionID === currentSession) return if (workspaceKey(directory) === workspaceKey(currentDir()) && props.sessionID === currentSession) return
if (pathKey(directory) === pathKey(currentDir()) && session?.parentID === currentSession) return if (workspaceKey(directory) === workspaceKey(currentDir()) && session?.parentID === currentSession) return
dismissSessionAlert(sessionKey) dismissSessionAlert(sessionKey)
@@ -556,14 +556,14 @@ export default function Layout(props: ParentProps) {
const currentProject = createMemo(() => { const currentProject = createMemo(() => {
const directory = currentDir() const directory = currentDir()
if (!directory) return if (!directory) return
const key = pathKey(directory) const key = workspaceKey(directory)
const projects = layout.projects.list() const projects = layout.projects.list()
const sandbox = projects.find((p) => p.sandboxes?.some((item) => pathKey(item) === key)) const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key))
if (sandbox) return sandbox if (sandbox) return sandbox
const direct = projects.find((p) => pathKey(p.worktree) === key) const direct = projects.find((p) => workspaceKey(p.worktree) === key)
if (direct) return direct if (direct) return direct
const [child] = globalSync.child(directory, { bootstrap: false }) const [child] = globalSync.child(directory, { bootstrap: false })
@@ -596,7 +596,7 @@ export default function Layout(props: ParentProps) {
}) })
const workspaceName = (directory: string, projectId?: string, branch?: string) => { const workspaceName = (directory: string, projectId?: string, branch?: string) => {
const key = pathKey(directory) const key = workspaceKey(directory)
const direct = store.workspaceName[key] ?? store.workspaceName[directory] const direct = store.workspaceName[key] ?? store.workspaceName[directory]
if (direct) return direct if (direct) return direct
if (!projectId) return if (!projectId) return
@@ -605,7 +605,7 @@ export default function Layout(props: ParentProps) {
} }
const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => { const setWorkspaceName = (directory: string, next: string, projectId?: string, branch?: string) => {
const key = pathKey(directory) const key = workspaceKey(directory)
setStore("workspaceName", key, next) setStore("workspaceName", key, next)
if (!projectId) return if (!projectId) return
if (!branch) return if (!branch) return
@@ -633,7 +633,7 @@ export default function Layout(props: ParentProps) {
const activeDir = currentDir() const activeDir = currentDir()
return workspaceIds(project).filter((directory) => { return workspaceIds(project).filter((directory) => {
const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree
const active = pathKey(directory) === pathKey(activeDir) const active = workspaceKey(directory) === workspaceKey(activeDir)
return expanded || active return expanded || active
}) })
}) })
@@ -644,9 +644,10 @@ export default function Layout(props: ParentProps) {
const projects = layout.projects.list() const projects = layout.projects.list()
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) { for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
if (!expanded) continue if (!expanded) continue
const key = pathKey(directory) const key = workspaceKey(directory)
const project = projects.find( const project = projects.find(
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key), (item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
) )
if (!project) continue if (!project) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
@@ -699,7 +700,7 @@ export default function Layout(props: ParentProps) {
seen: lru, seen: lru,
keep: sessionID, keep: sessionID,
limit: PREFETCH_MAX_SESSIONS_PER_DIR, limit: PREFETCH_MAX_SESSIONS_PER_DIR,
preserve: params.id && pathKey(directory) === pathKey(currentDir()) ? [params.id] : undefined, preserve: params.id && workspaceKey(directory) === workspaceKey(currentDir()) ? [params.id] : undefined,
}) })
} }
@@ -1220,14 +1221,17 @@ export default function Layout(props: ParentProps) {
} }
function projectRoot(directory: string) { function projectRoot(directory: string) {
const key = pathKey(directory) const key = workspaceKey(directory)
const project = layout.projects const project = layout.projects
.list() .list()
.find((item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key)) .find(
(item) =>
workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
)
if (project) return project.worktree if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find( const known = Object.entries(store.workspaceOrder).find(
([root, dirs]) => pathKey(root) === key || dirs.some((item) => pathKey(item) === key), ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key),
) )
if (known) return known[0] if (known) return known[0]
@@ -1279,7 +1283,7 @@ export default function Layout(props: ParentProps) {
: [root] : [root]
const canOpen = (value: string | undefined) => { const canOpen = (value: string | undefined) => {
if (!value) return false if (!value) return false
return dirs.some((item) => pathKey(item) === pathKey(value)) return dirs.some((item) => workspaceKey(item) === workspaceKey(value))
} }
const refreshDirs = async (target?: string) => { const refreshDirs = async (target?: string) => {
if (!target || target === root || canOpen(target)) return canOpen(target) if (!target || target === root || canOpen(target)) return canOpen(target)
@@ -1405,9 +1409,9 @@ export default function Layout(props: ParentProps) {
function closeProject(directory: string) { function closeProject(directory: string) {
const list = layout.projects.list() const list = layout.projects.list()
const key = pathKey(directory) const key = workspaceKey(directory)
const index = list.findIndex((x) => pathKey(x.worktree) === key) const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
const active = pathKey(currentProject()?.worktree ?? "") === key const active = workspaceKey(currentProject()?.worktree ?? "") === key
if (index === -1) return if (index === -1) return
const next = list[index + 1] const next = list[index + 1]
@@ -1481,8 +1485,8 @@ export default function Layout(props: ParentProps) {
if (directory === root) return if (directory === root) return
const current = currentDir() const current = currentDir()
const currentKey = pathKey(current) const currentKey = workspaceKey(current)
const deletedKey = pathKey(directory) const deletedKey = workspaceKey(directory)
const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey) const shouldLeave = leaveDeletedWorkspace || (!!params.dir && currentKey === deletedKey)
if (!leaveDeletedWorkspace && shouldLeave) { if (!leaveDeletedWorkspace && shouldLeave) {
navigateWithSidebarReset(`/${base64Encode(root)}/session`) navigateWithSidebarReset(`/${base64Encode(root)}/session`)
@@ -1505,7 +1509,7 @@ export default function Layout(props: ParentProps) {
if (!result) return if (!result) return
if (pathKey(store.lastProjectSession[root]?.directory ?? "") === pathKey(directory)) { if (workspaceKey(store.lastProjectSession[root]?.directory ?? "") === workspaceKey(directory)) {
clearLastProjectSession(root) clearLastProjectSession(root)
} }
@@ -1525,12 +1529,12 @@ export default function Layout(props: ParentProps) {
if (shouldLeave) return if (shouldLeave) return
const nextCurrent = currentDir() const nextCurrent = currentDir()
const nextKey = pathKey(nextCurrent) const nextKey = workspaceKey(nextCurrent)
const project = layout.projects.list().find((item) => item.worktree === root) const project = layout.projects.list().find((item) => item.worktree === root)
const dirs = project const dirs = project
? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root]) ? effectiveWorkspaceOrder(root, [root, ...(project.sandboxes ?? [])], store.workspaceOrder[root])
: [root] : [root]
const valid = dirs.some((item) => pathKey(item) === nextKey) const valid = dirs.some((item) => workspaceKey(item) === nextKey)
if (params.dir && projectRoot(nextCurrent) === root && !valid) { if (params.dir && projectRoot(nextCurrent) === root && !valid) {
navigateWithSidebarReset(`/${base64Encode(root)}/session`) navigateWithSidebarReset(`/${base64Encode(root)}/session`)
@@ -1557,7 +1561,6 @@ export default function Layout(props: ParentProps) {
directory, directory,
sessions.map((s) => s.id), sessions.map((s) => s.id),
platform, platform,
getTerminalServerScope(server.current, server.key),
) )
await globalSDK.client.instance.dispose({ directory }).catch(() => undefined) await globalSDK.client.instance.dispose({ directory }).catch(() => undefined)
@@ -1637,7 +1640,7 @@ export default function Layout(props: ParentProps) {
}) })
const handleDelete = () => { const handleDelete = () => {
const leaveDeletedWorkspace = !!params.dir && pathKey(currentDir()) === pathKey(props.directory) const leaveDeletedWorkspace = !!params.dir && workspaceKey(currentDir()) === workspaceKey(props.directory)
if (leaveDeletedWorkspace) { if (leaveDeletedWorkspace) {
navigateWithSidebarReset(`/${base64Encode(props.root)}/session`) navigateWithSidebarReset(`/${base64Encode(props.root)}/session`)
} }
@@ -1864,9 +1867,11 @@ export default function Layout(props: ParentProps) {
const local = project.worktree const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])] const dirs = [local, ...(project.sandboxes ?? [])]
const active = currentProject() const active = currentProject()
const directory = pathKey(active?.worktree ?? "") === pathKey(project.worktree) ? currentDir() : undefined const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined
const extra = const extra =
directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory)) directory &&
workspaceKey(directory) !== workspaceKey(local) &&
!dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
? directory ? directory
: undefined : undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
@@ -1911,7 +1916,7 @@ export default function Layout(props: ParentProps) {
setStore( setStore(
"workspaceOrder", "workspaceOrder",
project.worktree, project.worktree,
result.filter((directory) => pathKey(directory) !== pathKey(project.worktree)), result.filter((directory) => workspaceKey(directory) !== workspaceKey(project.worktree)),
) )
} }
@@ -1937,8 +1942,8 @@ export default function Layout(props: ParentProps) {
setWorkspaceName(created.directory, created.branch, project.id, created.branch) setWorkspaceName(created.directory, created.branch, project.id, created.branch)
const local = project.worktree const local = project.worktree
const key = pathKey(created.directory) const key = workspaceKey(created.directory)
const root = pathKey(local) const root = workspaceKey(local)
setBusy(created.directory, true) setBusy(created.directory, true)
WorktreeState.pending(created.directory) WorktreeState.pending(created.directory)
@@ -1949,7 +1954,7 @@ export default function Layout(props: ParentProps) {
setStore("workspaceOrder", project.worktree, (prev) => { setStore("workspaceOrder", project.worktree, (prev) => {
const existing = prev ?? [] const existing = prev ?? []
const next = existing.filter((item) => { const next = existing.filter((item) => {
const id = pathKey(item) const id = workspaceKey(item)
return id !== root && id !== key return id !== root && id !== key
}) })
return [created.directory, ...next] return [created.directory, ...next]
@@ -14,8 +14,8 @@ import {
errorMessage, errorMessage,
hasProjectPermissions, hasProjectPermissions,
latestRootSession, latestRootSession,
workspaceKey,
} from "./helpers" } from "./helpers"
import { pathKey } from "@/utils/path-key"
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) => const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({ ({
@@ -104,16 +104,16 @@ describe("layout deep links", () => {
describe("layout workspace helpers", () => { describe("layout workspace helpers", () => {
test("normalizes trailing slash in workspace key", () => { test("normalizes trailing slash in workspace key", () => {
expect(String(pathKey("/tmp/demo///"))).toBe("/tmp/demo") expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
expect(String(pathKey("C:\\tmp\\demo\\\\"))).toBe("C:/tmp/demo") expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo")
}) })
test("preserves posix and drive roots in workspace key", () => { test("preserves posix and drive roots in workspace key", () => {
expect(String(pathKey("/"))).toBe("/") expect(workspaceKey("/")).toBe("/")
expect(String(pathKey("///"))).toBe("/") expect(workspaceKey("///")).toBe("/")
expect(String(pathKey("C:\\"))).toBe("C:/") expect(workspaceKey("C:\\")).toBe("C:/")
expect(String(pathKey("C://"))).toBe("C:/") expect(workspaceKey("C://")).toBe("C:/")
expect(String(pathKey("C:///"))).toBe("C:/") expect(workspaceKey("C:///")).toBe("C:/")
}) })
test("keeps local first while preserving known order", () => { test("keeps local first while preserving known order", () => {
+12 -5
View File
@@ -1,12 +1,19 @@
import { getFilename } from "@opencode-ai/core/util/path" import { getFilename } from "@opencode-ai/core/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client" import { type Session } from "@opencode-ai/sdk/v2/client"
import { pathKey } from "@/utils/path-key"
type SessionStore = { type SessionStore = {
session?: Session[] session?: Session[]
path: { directory: string } path: { directory: string }
} }
export const workspaceKey = (directory: string) => {
const value = directory.replaceAll("\\", "/")
const drive = value.match(/^([A-Za-z]:)\/+$/)
if (drive) return `${drive[1]}/`
if (/^\/+$/i.test(value)) return "/"
return value.replace(/\/+$/, "")
}
function sortSessions(now: number) { function sortSessions(now: number) {
const oneMinuteAgo = now - 60 * 1000 const oneMinuteAgo = now - 60 * 1000
return (a: Session, b: Session) => { return (a: Session, b: Session) => {
@@ -22,7 +29,7 @@ function sortSessions(now: number) {
} }
const isRootVisibleSession = (session: Session, directory: string) => const isRootVisibleSession = (session: Session, directory: string) =>
pathKey(session.directory) === pathKey(directory) && !session.parentID && !session.time?.archived workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
export const roots = (store: SessionStore) => export const roots = (store: SessionStore) =>
(store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory)) (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
@@ -65,11 +72,11 @@ export const errorMessage = (err: unknown, fallback: string) => {
} }
export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => { export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted?: string[]) => {
const root = pathKey(local) const root = workspaceKey(local)
const live = new Map<string, string>() const live = new Map<string, string>()
for (const dir of dirs) { for (const dir of dirs) {
const key = pathKey(dir) const key = workspaceKey(dir)
if (key === root) continue if (key === root) continue
if (!live.has(key)) live.set(key, dir) if (!live.has(key)) live.set(key, dir)
} }
@@ -78,7 +85,7 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted
const result = [local] const result = [local]
for (const dir of persisted) { for (const dir of persisted) {
const key = pathKey(dir) const key = workspaceKey(dir)
if (key === root) continue if (key === root) continue
const match = live.get(key) const match = live.get(key)
if (!match) continue if (!match) continue
@@ -20,18 +20,12 @@ import { childSessionOnPath, hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) { export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
if (id === OPENCODE_PROJECT_ID) return "https://opencode.ai/favicon.svg" return id === OPENCODE_PROJECT_ID
if (icon?.override) return icon?.override ? "https://opencode.ai/favicon.svg"
if (icon?.color) return undefined : (icon?.override ?? (icon?.color ? undefined : icon?.url))
return icon?.url
} }
export const ProjectIcon = (props: { export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
project: LocalProject
class?: string
notify?: boolean
working?: boolean
}): JSX.Element => {
const globalSync = useGlobalSync() const globalSync = useGlobalSync()
const notification = useNotification() const notification = useNotification()
const permission = usePermission() const permission = usePermission()
@@ -70,11 +64,6 @@ export const ProjectIcon = (props: {
}} }}
/> />
</Show> </Show>
<Show when={props.working}>
<div class="absolute bottom-px right-px size-3 rounded-full bg-background-base z-10 flex items-center justify-center">
<Spinner class="size-[9px]" />
</div>
</Show>
</div> </div>
) )
} }

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