Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
457a4b26fe |
@@ -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,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
|
||||||
|
|||||||
@@ -11,6 +11,5 @@ MrMushrooooom
|
|||||||
nexxeln
|
nexxeln
|
||||||
R44VC0RP
|
R44VC0RP
|
||||||
rekram1-node
|
rekram1-node
|
||||||
|
RhysSullivan
|
||||||
thdxr
|
thdxr
|
||||||
simonklee
|
|
||||||
vimtor
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# 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
|
||||||
|
-danieljoshuanazareth
|
||||||
|
-danieljoshuanazareth
|
||||||
|
edemaine
|
||||||
|
-florianleibert
|
||||||
|
fwang
|
||||||
|
iamdavidhill
|
||||||
|
jayair
|
||||||
|
kitlangton
|
||||||
|
kommander
|
||||||
|
-opencode2026
|
||||||
|
-opencodeengineer bot that spams issues
|
||||||
|
r44vc0rp
|
||||||
|
rekram1-node
|
||||||
|
-robinmordasiewicz
|
||||||
|
simonklee
|
||||||
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
|
thdxr
|
||||||
|
-toastythebot
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
name: "Setup Bun"
|
name: "Setup Bun"
|
||||||
description: "Setup Bun with caching and install dependencies"
|
description: "Setup Bun with caching and install dependencies"
|
||||||
inputs:
|
|
||||||
install-flags:
|
|
||||||
description: "Additional flags to pass to 'bun install'"
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
runs:
|
runs:
|
||||||
using: "composite"
|
using: "composite"
|
||||||
steps:
|
steps:
|
||||||
@@ -51,8 +46,8 @@ runs:
|
|||||||
# e.g. ./patches/ for standard-openapi
|
# e.g. ./patches/ for standard-openapi
|
||||||
# https://github.com/oven-sh/bun/issues/28147
|
# https://github.com/oven-sh/bun/issues/28147
|
||||||
if [ "$RUNNER_OS" = "Windows" ]; then
|
if [ "$RUNNER_OS" = "Windows" ]; then
|
||||||
bun install --linker hoisted ${{ inputs.install-flags }}
|
bun install --linker hoisted
|
||||||
else
|
else
|
||||||
bun install ${{ inputs.install-flags }}
|
bun install
|
||||||
fi
|
fi
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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 }}
|
|
||||||
|
|||||||
+197
-57
@@ -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
|
||||||
@@ -226,14 +402,12 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
settings:
|
settings:
|
||||||
- host: macos-26-intel
|
- host: macos-latest
|
||||||
target: x86_64-apple-darwin
|
target: x86_64-apple-darwin
|
||||||
platform_flag: --mac --x64
|
platform_flag: --mac --x64
|
||||||
bun_install_flags: --os=darwin --cpu=x64
|
- host: macos-latest
|
||||||
- host: macos-26
|
|
||||||
target: aarch64-apple-darwin
|
target: aarch64-apple-darwin
|
||||||
platform_flag: --mac --arm64
|
platform_flag: --mac --arm64
|
||||||
bun_install_flags: --os=darwin --cpu=arm64
|
|
||||||
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
|
||||||
- host: "windows-2025"
|
- host: "windows-2025"
|
||||||
target: aarch64-pc-windows-msvc
|
target: aarch64-pc-windows-msvc
|
||||||
@@ -263,8 +437,6 @@ jobs:
|
|||||||
run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
|
run: echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
|
||||||
|
|
||||||
- uses: ./.github/actions/setup-bun
|
- uses: ./.github/actions/setup-bun
|
||||||
with:
|
|
||||||
install-flags: ${{ matrix.settings.bun_install_flags }}
|
|
||||||
|
|
||||||
- name: Azure login
|
- name: Azure login
|
||||||
if: runner.os == 'Windows'
|
if: runner.os == 'Windows'
|
||||||
@@ -304,7 +476,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 +487,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 +508,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 +531,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 +572,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 +600,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 +628,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 }}
|
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ jobs:
|
|||||||
|
|
||||||
- name: Check PR guidelines compliance
|
- name: Check PR guidelines compliance
|
||||||
env:
|
env:
|
||||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
OPENCODE_PERMISSION: '{ "bash": { "*": "deny", "gh*": "allow", "gh pr review*": "deny" } }'
|
OPENCODE_PERMISSION: '{ "bash": { "*": "deny", "gh*": "allow", "gh pr review*": "deny" } }'
|
||||||
PR_TITLE: ${{ steps.pr-details.outputs.title }}
|
PR_TITLE: ${{ steps.pr-details.outputs.title }}
|
||||||
run: |
|
run: |
|
||||||
PR_BODY=$(jq -r .body pr_data.json)
|
PR_BODY=$(jq -r .body pr_data.json)
|
||||||
opencode run -m opencode/gpt-5.5 --variant medium "A new pull request has been created: '${PR_TITLE}'
|
opencode run -m anthropic/claude-opus-4-5 "A new pull request has been created: '${PR_TITLE}'
|
||||||
|
|
||||||
<pr-number>
|
<pr-number>
|
||||||
${{ steps.pr-number.outputs.number }}
|
${{ steps.pr-number.outputs.number }}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -126,7 +121,7 @@ jobs:
|
|||||||
- name: Read Playwright version
|
- name: Read Playwright version
|
||||||
id: playwright-version
|
id: playwright-version
|
||||||
run: |
|
run: |
|
||||||
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
|
version=$(node -e 'console.log(require("./packages/app/package.json").devDependencies["@playwright/test"])')
|
||||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Cache Playwright browsers
|
- name: Cache Playwright browsers
|
||||||
|
|||||||
@@ -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}`);
|
||||||
@@ -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 }}
|
||||||
@@ -3,7 +3,6 @@ node_modules
|
|||||||
.worktrees
|
.worktrees
|
||||||
.sst
|
.sst
|
||||||
.env
|
.env
|
||||||
.env.local
|
|
||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
.codex
|
.codex
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -3,5 +3,4 @@ plans
|
|||||||
package.json
|
package.json
|
||||||
bun.lock
|
bun.lock
|
||||||
.gitignore
|
.gitignore
|
||||||
package-lock.json
|
package-lock.json
|
||||||
references/
|
|
||||||
@@ -0,0 +1,900 @@
|
|||||||
|
---
|
||||||
|
description: Translate content for a specified locale while preserving technical terms
|
||||||
|
mode: subagent
|
||||||
|
model: opencode/gpt-5.4
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a professional translator and localization specialist.
|
||||||
|
|
||||||
|
Translate the user's content into the requested target locale (language + region, e.g. fr-FR, de-DE).
|
||||||
|
|
||||||
|
Requirements:
|
||||||
|
|
||||||
|
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
|
||||||
|
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
|
||||||
|
- Also preserve every term listed in the Do-Not-Translate glossary below.
|
||||||
|
- Also apply locale-specific guidance from `.opencode/glossary/<locale>.md` when available (for example, `zh-cn.md`).
|
||||||
|
- Do not modify fenced code blocks.
|
||||||
|
- Output ONLY the translation (no commentary).
|
||||||
|
|
||||||
|
If the target locale is missing, ask the user to provide it.
|
||||||
|
If no locale-specific glossary exists, use the global glossary only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Locale-Specific Glossaries
|
||||||
|
|
||||||
|
When a locale glossary exists, use it to:
|
||||||
|
|
||||||
|
- Apply preferred wording for recurring UI/docs terms in that locale
|
||||||
|
- Preserve locale-specific do-not-translate terms and casing decisions
|
||||||
|
- Prefer natural phrasing over literal translation when the locale file calls it out
|
||||||
|
- If the repo uses a locale alias slug, apply that file too (for example, `pt-BR` maps to `br.md` in this repo)
|
||||||
|
|
||||||
|
Locale guidance does not override code/command preservation rules or the global Do-Not-Translate glossary below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Do-Not-Translate Terms (OpenCode Docs)
|
||||||
|
|
||||||
|
Generated from: `packages/web/src/content/docs/*.mdx` (default English docs)
|
||||||
|
Generated on: 2026-02-10
|
||||||
|
|
||||||
|
Use this as a translation QA checklist / glossary. Preserve listed terms exactly (spelling, casing, punctuation).
|
||||||
|
|
||||||
|
General rules (verbatim, even if not listed below):
|
||||||
|
|
||||||
|
- Anything inside inline code (single backticks) or fenced code blocks (triple backticks)
|
||||||
|
- MDX/JS code in docs: `import ... from "..."`, component tags, identifiers
|
||||||
|
- CLI commands, flags, config keys/values, file paths, URLs/domains, and env vars
|
||||||
|
|
||||||
|
## Proper nouns and product names
|
||||||
|
|
||||||
|
Additional (not reliably captured via link text):
|
||||||
|
|
||||||
|
```text
|
||||||
|
Astro
|
||||||
|
Bun
|
||||||
|
Chocolatey
|
||||||
|
Cursor
|
||||||
|
Docker
|
||||||
|
Git
|
||||||
|
GitHub Actions
|
||||||
|
GitLab CI
|
||||||
|
GNOME Terminal
|
||||||
|
Homebrew
|
||||||
|
Mise
|
||||||
|
Neovim
|
||||||
|
Node.js
|
||||||
|
npm
|
||||||
|
Obsidian
|
||||||
|
opencode
|
||||||
|
opencode-ai
|
||||||
|
Paru
|
||||||
|
pnpm
|
||||||
|
ripgrep
|
||||||
|
Scoop
|
||||||
|
SST
|
||||||
|
Starlight
|
||||||
|
Visual Studio Code
|
||||||
|
VS Code
|
||||||
|
VSCodium
|
||||||
|
Windsurf
|
||||||
|
Windows Terminal
|
||||||
|
Yarn
|
||||||
|
Zellij
|
||||||
|
Zed
|
||||||
|
anomalyco
|
||||||
|
```
|
||||||
|
|
||||||
|
Extracted from link labels in the English docs (review and prune as desired):
|
||||||
|
|
||||||
|
```text
|
||||||
|
@openspoon/subtask2
|
||||||
|
302.AI console
|
||||||
|
ACP progress report
|
||||||
|
Agent Client Protocol
|
||||||
|
Agent Skills
|
||||||
|
Agentic
|
||||||
|
AGENTS.md
|
||||||
|
AI SDK
|
||||||
|
Alacritty
|
||||||
|
Anthropic
|
||||||
|
Anthropic's Data Policies
|
||||||
|
Atom One
|
||||||
|
Avante.nvim
|
||||||
|
Ayu
|
||||||
|
Azure AI Foundry
|
||||||
|
Azure portal
|
||||||
|
Baseten
|
||||||
|
built-in GITHUB_TOKEN
|
||||||
|
Bun.$
|
||||||
|
Catppuccin
|
||||||
|
Cerebras console
|
||||||
|
ChatGPT Plus or Pro
|
||||||
|
Cloudflare dashboard
|
||||||
|
CodeCompanion.nvim
|
||||||
|
CodeNomad
|
||||||
|
Configuring Adapters: Environment Variables
|
||||||
|
Context7 MCP server
|
||||||
|
Cortecs console
|
||||||
|
Deep Infra dashboard
|
||||||
|
DeepSeek console
|
||||||
|
Duo Agent Platform
|
||||||
|
Everforest
|
||||||
|
Fireworks AI console
|
||||||
|
Firmware dashboard
|
||||||
|
Ghostty
|
||||||
|
GitLab CLI agents docs
|
||||||
|
GitLab docs
|
||||||
|
GitLab User Settings > Access Tokens
|
||||||
|
Granular Rules (Object Syntax)
|
||||||
|
Grep by Vercel
|
||||||
|
Groq console
|
||||||
|
Gruvbox
|
||||||
|
Helicone
|
||||||
|
Helicone documentation
|
||||||
|
Helicone Header Directory
|
||||||
|
Helicone's Model Directory
|
||||||
|
Hugging Face Inference Providers
|
||||||
|
Hugging Face settings
|
||||||
|
install WSL
|
||||||
|
IO.NET console
|
||||||
|
JetBrains IDE
|
||||||
|
Kanagawa
|
||||||
|
Kitty
|
||||||
|
MiniMax API Console
|
||||||
|
Models.dev
|
||||||
|
Moonshot AI console
|
||||||
|
Nebius Token Factory console
|
||||||
|
Nord
|
||||||
|
OAuth
|
||||||
|
Ollama integration docs
|
||||||
|
OpenAI's Data Policies
|
||||||
|
OpenChamber
|
||||||
|
OpenCode
|
||||||
|
OpenCode config
|
||||||
|
OpenCode Config
|
||||||
|
OpenCode TUI with the opencode theme
|
||||||
|
OpenCode Web - Active Session
|
||||||
|
OpenCode Web - New Session
|
||||||
|
OpenCode Web - See Servers
|
||||||
|
OpenCode Zen
|
||||||
|
OpenCode-Obsidian
|
||||||
|
OpenRouter dashboard
|
||||||
|
OpenWork
|
||||||
|
OVHcloud panel
|
||||||
|
Pro+ subscription
|
||||||
|
SAP BTP Cockpit
|
||||||
|
Scaleway Console IAM settings
|
||||||
|
Scaleway Generative APIs
|
||||||
|
SDK documentation
|
||||||
|
Sentry MCP server
|
||||||
|
shell API
|
||||||
|
Together AI console
|
||||||
|
Tokyonight
|
||||||
|
Unified Billing
|
||||||
|
Venice AI console
|
||||||
|
Vercel dashboard
|
||||||
|
WezTerm
|
||||||
|
Windows Subsystem for Linux (WSL)
|
||||||
|
WSL
|
||||||
|
WSL (Windows Subsystem for Linux)
|
||||||
|
WSL extension
|
||||||
|
xAI console
|
||||||
|
Z.AI API console
|
||||||
|
Zed
|
||||||
|
ZenMux dashboard
|
||||||
|
Zod
|
||||||
|
```
|
||||||
|
|
||||||
|
## Acronyms and initialisms
|
||||||
|
|
||||||
|
```text
|
||||||
|
ACP
|
||||||
|
AGENTS
|
||||||
|
AI
|
||||||
|
AI21
|
||||||
|
ANSI
|
||||||
|
API
|
||||||
|
AST
|
||||||
|
AWS
|
||||||
|
BTP
|
||||||
|
CD
|
||||||
|
CDN
|
||||||
|
CI
|
||||||
|
CLI
|
||||||
|
CMD
|
||||||
|
CORS
|
||||||
|
DEBUG
|
||||||
|
EKS
|
||||||
|
ERROR
|
||||||
|
FAQ
|
||||||
|
GLM
|
||||||
|
GNOME
|
||||||
|
GPT
|
||||||
|
HTML
|
||||||
|
HTTP
|
||||||
|
HTTPS
|
||||||
|
IAM
|
||||||
|
ID
|
||||||
|
IDE
|
||||||
|
INFO
|
||||||
|
IO
|
||||||
|
IP
|
||||||
|
IRSA
|
||||||
|
JS
|
||||||
|
JSON
|
||||||
|
JSONC
|
||||||
|
K2
|
||||||
|
LLM
|
||||||
|
LM
|
||||||
|
LSP
|
||||||
|
M2
|
||||||
|
MCP
|
||||||
|
MR
|
||||||
|
NET
|
||||||
|
NPM
|
||||||
|
NTLM
|
||||||
|
OIDC
|
||||||
|
OS
|
||||||
|
PAT
|
||||||
|
PATH
|
||||||
|
PHP
|
||||||
|
PR
|
||||||
|
PTY
|
||||||
|
README
|
||||||
|
RFC
|
||||||
|
RPC
|
||||||
|
SAP
|
||||||
|
SDK
|
||||||
|
SKILL
|
||||||
|
SSE
|
||||||
|
SSO
|
||||||
|
TS
|
||||||
|
TTY
|
||||||
|
TUI
|
||||||
|
UI
|
||||||
|
URL
|
||||||
|
US
|
||||||
|
UX
|
||||||
|
VCS
|
||||||
|
VPC
|
||||||
|
VPN
|
||||||
|
VS
|
||||||
|
WARN
|
||||||
|
WSL
|
||||||
|
X11
|
||||||
|
YAML
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code identifiers used in prose (CamelCase, mixedCase)
|
||||||
|
|
||||||
|
```text
|
||||||
|
apiKey
|
||||||
|
AppleScript
|
||||||
|
AssistantMessage
|
||||||
|
baseURL
|
||||||
|
BurntSushi
|
||||||
|
ChatGPT
|
||||||
|
ClangFormat
|
||||||
|
CodeCompanion
|
||||||
|
CodeNomad
|
||||||
|
DeepSeek
|
||||||
|
DefaultV2
|
||||||
|
FileContent
|
||||||
|
FileDiff
|
||||||
|
FileNode
|
||||||
|
fineGrained
|
||||||
|
FormatterStatus
|
||||||
|
GitHub
|
||||||
|
GitLab
|
||||||
|
iTerm2
|
||||||
|
JavaScript
|
||||||
|
JetBrains
|
||||||
|
macOS
|
||||||
|
mDNS
|
||||||
|
MiniMax
|
||||||
|
NeuralNomadsAI
|
||||||
|
NickvanDyke
|
||||||
|
NoeFabris
|
||||||
|
OpenAI
|
||||||
|
OpenAPI
|
||||||
|
OpenChamber
|
||||||
|
OpenCode
|
||||||
|
OpenRouter
|
||||||
|
OpenTUI
|
||||||
|
OpenWork
|
||||||
|
ownUserPermissions
|
||||||
|
PowerShell
|
||||||
|
ProviderAuthAuthorization
|
||||||
|
ProviderAuthMethod
|
||||||
|
ProviderInitError
|
||||||
|
SessionStatus
|
||||||
|
TabItem
|
||||||
|
tokenType
|
||||||
|
ToolIDs
|
||||||
|
ToolList
|
||||||
|
TypeScript
|
||||||
|
typesUrl
|
||||||
|
UserMessage
|
||||||
|
VcsInfo
|
||||||
|
WebView2
|
||||||
|
WezTerm
|
||||||
|
xAI
|
||||||
|
ZenMux
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenCode CLI commands (as shown in docs)
|
||||||
|
|
||||||
|
```text
|
||||||
|
opencode
|
||||||
|
opencode [project]
|
||||||
|
opencode /path/to/project
|
||||||
|
opencode acp
|
||||||
|
opencode agent [command]
|
||||||
|
opencode agent create
|
||||||
|
opencode agent list
|
||||||
|
opencode attach [url]
|
||||||
|
opencode attach http://10.20.30.40:4096
|
||||||
|
opencode attach http://localhost:4096
|
||||||
|
opencode auth [command]
|
||||||
|
opencode auth list
|
||||||
|
opencode auth login
|
||||||
|
opencode auth logout
|
||||||
|
opencode auth ls
|
||||||
|
opencode export [sessionID]
|
||||||
|
opencode github [command]
|
||||||
|
opencode github install
|
||||||
|
opencode github run
|
||||||
|
opencode import <file>
|
||||||
|
opencode import https://opncd.ai/s/abc123
|
||||||
|
opencode import session.json
|
||||||
|
opencode mcp [command]
|
||||||
|
opencode mcp add
|
||||||
|
opencode mcp auth [name]
|
||||||
|
opencode mcp auth list
|
||||||
|
opencode mcp auth ls
|
||||||
|
opencode mcp auth my-oauth-server
|
||||||
|
opencode mcp auth sentry
|
||||||
|
opencode mcp debug <name>
|
||||||
|
opencode mcp debug my-oauth-server
|
||||||
|
opencode mcp list
|
||||||
|
opencode mcp logout [name]
|
||||||
|
opencode mcp logout my-oauth-server
|
||||||
|
opencode mcp ls
|
||||||
|
opencode models --refresh
|
||||||
|
opencode models [provider]
|
||||||
|
opencode models anthropic
|
||||||
|
opencode run [message..]
|
||||||
|
opencode run Explain the use of context in Go
|
||||||
|
opencode serve
|
||||||
|
opencode serve --cors http://localhost:5173 --cors https://app.example.com
|
||||||
|
opencode serve --hostname 0.0.0.0 --port 4096
|
||||||
|
opencode serve [--port <number>] [--hostname <string>] [--cors <origin>]
|
||||||
|
opencode session [command]
|
||||||
|
opencode session list
|
||||||
|
opencode session delete <sessionID>
|
||||||
|
opencode stats
|
||||||
|
opencode uninstall
|
||||||
|
opencode upgrade
|
||||||
|
opencode upgrade [target]
|
||||||
|
opencode upgrade v0.1.48
|
||||||
|
opencode web
|
||||||
|
opencode web --cors https://example.com
|
||||||
|
opencode web --hostname 0.0.0.0
|
||||||
|
opencode web --mdns
|
||||||
|
opencode web --mdns --mdns-domain myproject.local
|
||||||
|
opencode web --port 4096
|
||||||
|
opencode web --port 4096 --hostname 0.0.0.0
|
||||||
|
opencode.server.close()
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slash commands and routes
|
||||||
|
|
||||||
|
```text
|
||||||
|
/agent
|
||||||
|
/auth/:id
|
||||||
|
/clear
|
||||||
|
/command
|
||||||
|
/config
|
||||||
|
/config/providers
|
||||||
|
/connect
|
||||||
|
/continue
|
||||||
|
/doc
|
||||||
|
/editor
|
||||||
|
/event
|
||||||
|
/experimental/tool?provider=<p>&model=<m>
|
||||||
|
/experimental/tool/ids
|
||||||
|
/export
|
||||||
|
/file?path=<path>
|
||||||
|
/file/content?path=<p>
|
||||||
|
/file/status
|
||||||
|
/find?pattern=<pat>
|
||||||
|
/find/file
|
||||||
|
/find/file?query=<q>
|
||||||
|
/find/symbol?query=<q>
|
||||||
|
/formatter
|
||||||
|
/global/event
|
||||||
|
/global/health
|
||||||
|
/help
|
||||||
|
/init
|
||||||
|
/instance/dispose
|
||||||
|
/log
|
||||||
|
/lsp
|
||||||
|
/mcp
|
||||||
|
/mnt/
|
||||||
|
/mnt/c/
|
||||||
|
/mnt/d/
|
||||||
|
/models
|
||||||
|
/oc
|
||||||
|
/opencode
|
||||||
|
/path
|
||||||
|
/project
|
||||||
|
/project/current
|
||||||
|
/provider
|
||||||
|
/provider/{id}/oauth/authorize
|
||||||
|
/provider/{id}/oauth/callback
|
||||||
|
/provider/auth
|
||||||
|
/q
|
||||||
|
/quit
|
||||||
|
/redo
|
||||||
|
/resume
|
||||||
|
/session
|
||||||
|
/session/:id
|
||||||
|
/session/:id/abort
|
||||||
|
/session/:id/children
|
||||||
|
/session/:id/command
|
||||||
|
/session/:id/diff
|
||||||
|
/session/:id/fork
|
||||||
|
/session/:id/init
|
||||||
|
/session/:id/message
|
||||||
|
/session/:id/message/:messageID
|
||||||
|
/session/:id/permissions/:permissionID
|
||||||
|
/session/:id/prompt_async
|
||||||
|
/session/:id/revert
|
||||||
|
/session/:id/share
|
||||||
|
/session/:id/shell
|
||||||
|
/session/:id/summarize
|
||||||
|
/session/:id/todo
|
||||||
|
/session/:id/unrevert
|
||||||
|
/session/status
|
||||||
|
/share
|
||||||
|
/summarize
|
||||||
|
/theme
|
||||||
|
/tui
|
||||||
|
/tui/append-prompt
|
||||||
|
/tui/clear-prompt
|
||||||
|
/tui/control/next
|
||||||
|
/tui/control/response
|
||||||
|
/tui/execute-command
|
||||||
|
/tui/open-help
|
||||||
|
/tui/open-models
|
||||||
|
/tui/open-sessions
|
||||||
|
/tui/open-themes
|
||||||
|
/tui/show-toast
|
||||||
|
/tui/submit-prompt
|
||||||
|
/undo
|
||||||
|
/Users/username
|
||||||
|
/Users/username/projects/*
|
||||||
|
/vcs
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI flags and short options
|
||||||
|
|
||||||
|
```text
|
||||||
|
--agent
|
||||||
|
--attach
|
||||||
|
--command
|
||||||
|
--continue
|
||||||
|
--cors
|
||||||
|
--cwd
|
||||||
|
--days
|
||||||
|
--dir
|
||||||
|
--dry-run
|
||||||
|
--event
|
||||||
|
--file
|
||||||
|
--force
|
||||||
|
--fork
|
||||||
|
--format
|
||||||
|
--help
|
||||||
|
--hostname
|
||||||
|
--hostname 0.0.0.0
|
||||||
|
--keep-config
|
||||||
|
--keep-data
|
||||||
|
--log-level
|
||||||
|
--max-count
|
||||||
|
--mdns
|
||||||
|
--mdns-domain
|
||||||
|
--method
|
||||||
|
--model
|
||||||
|
--models
|
||||||
|
--port
|
||||||
|
--print-logs
|
||||||
|
--project
|
||||||
|
--prompt
|
||||||
|
--refresh
|
||||||
|
--session
|
||||||
|
--share
|
||||||
|
--title
|
||||||
|
--token
|
||||||
|
--tools
|
||||||
|
--verbose
|
||||||
|
--version
|
||||||
|
--wait
|
||||||
|
|
||||||
|
-c
|
||||||
|
-d
|
||||||
|
-f
|
||||||
|
-h
|
||||||
|
-m
|
||||||
|
-n
|
||||||
|
-s
|
||||||
|
-v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment variables
|
||||||
|
|
||||||
|
```text
|
||||||
|
AI_API_URL
|
||||||
|
AI_FLOW_CONTEXT
|
||||||
|
AI_FLOW_EVENT
|
||||||
|
AI_FLOW_INPUT
|
||||||
|
AICORE_DEPLOYMENT_ID
|
||||||
|
AICORE_RESOURCE_GROUP
|
||||||
|
AICORE_SERVICE_KEY
|
||||||
|
ANTHROPIC_API_KEY
|
||||||
|
AWS_ACCESS_KEY_ID
|
||||||
|
AWS_BEARER_TOKEN_BEDROCK
|
||||||
|
AWS_PROFILE
|
||||||
|
AWS_REGION
|
||||||
|
AWS_ROLE_ARN
|
||||||
|
AWS_SECRET_ACCESS_KEY
|
||||||
|
AWS_WEB_IDENTITY_TOKEN_FILE
|
||||||
|
AZURE_COGNITIVE_SERVICES_RESOURCE_NAME
|
||||||
|
AZURE_RESOURCE_NAME
|
||||||
|
CI_PROJECT_DIR
|
||||||
|
CI_SERVER_FQDN
|
||||||
|
CI_WORKLOAD_REF
|
||||||
|
CLOUDFLARE_ACCOUNT_ID
|
||||||
|
CLOUDFLARE_API_TOKEN
|
||||||
|
CLOUDFLARE_GATEWAY_ID
|
||||||
|
CONTEXT7_API_KEY
|
||||||
|
GITHUB_TOKEN
|
||||||
|
GITLAB_AI_GATEWAY_URL
|
||||||
|
GITLAB_HOST
|
||||||
|
GITLAB_INSTANCE_URL
|
||||||
|
GITLAB_OAUTH_CLIENT_ID
|
||||||
|
GITLAB_TOKEN
|
||||||
|
GITLAB_TOKEN_OPENCODE
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS
|
||||||
|
GOOGLE_CLOUD_PROJECT
|
||||||
|
HTTP_PROXY
|
||||||
|
HTTPS_PROXY
|
||||||
|
K2_
|
||||||
|
MY_API_KEY
|
||||||
|
MY_ENV_VAR
|
||||||
|
MY_MCP_CLIENT_ID
|
||||||
|
MY_MCP_CLIENT_SECRET
|
||||||
|
NO_PROXY
|
||||||
|
NODE_ENV
|
||||||
|
NODE_EXTRA_CA_CERTS
|
||||||
|
NPM_AUTH_TOKEN
|
||||||
|
OC_ALLOW_WAYLAND
|
||||||
|
OPENCODE_API_KEY
|
||||||
|
OPENCODE_AUTH_JSON
|
||||||
|
OPENCODE_AUTO_SHARE
|
||||||
|
OPENCODE_CLIENT
|
||||||
|
OPENCODE_CONFIG
|
||||||
|
OPENCODE_CONFIG_CONTENT
|
||||||
|
OPENCODE_CONFIG_DIR
|
||||||
|
OPENCODE_DISABLE_AUTOCOMPACT
|
||||||
|
OPENCODE_DISABLE_AUTOUPDATE
|
||||||
|
OPENCODE_DISABLE_CLAUDE_CODE
|
||||||
|
OPENCODE_DISABLE_CLAUDE_CODE_PROMPT
|
||||||
|
OPENCODE_DISABLE_CLAUDE_CODE_SKILLS
|
||||||
|
OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||||
|
OPENCODE_DISABLE_FILETIME_CHECK
|
||||||
|
OPENCODE_DISABLE_LSP_DOWNLOAD
|
||||||
|
OPENCODE_DISABLE_MODELS_FETCH
|
||||||
|
OPENCODE_DISABLE_PRUNE
|
||||||
|
OPENCODE_DISABLE_TERMINAL_TITLE
|
||||||
|
OPENCODE_ENABLE_EXA
|
||||||
|
OPENCODE_ENABLE_EXPERIMENTAL_MODELS
|
||||||
|
OPENCODE_EXPERIMENTAL
|
||||||
|
OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||||
|
OPENCODE_EXPERIMENTAL_EXA
|
||||||
|
OPENCODE_EXPERIMENTAL_FILEWATCHER
|
||||||
|
OPENCODE_EXPERIMENTAL_ICON_DISCOVERY
|
||||||
|
OPENCODE_EXPERIMENTAL_LSP_TOOL
|
||||||
|
OPENCODE_EXPERIMENTAL_LSP_TY
|
||||||
|
OPENCODE_EXPERIMENTAL_MARKDOWN
|
||||||
|
OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX
|
||||||
|
OPENCODE_EXPERIMENTAL_OXFMT
|
||||||
|
OPENCODE_EXPERIMENTAL_PLAN_MODE
|
||||||
|
OPENCODE_ENABLE_QUESTION_TOOL
|
||||||
|
OPENCODE_FAKE_VCS
|
||||||
|
OPENCODE_GIT_BASH_PATH
|
||||||
|
OPENCODE_MODEL
|
||||||
|
OPENCODE_MODELS_URL
|
||||||
|
OPENCODE_PERMISSION
|
||||||
|
OPENCODE_PORT
|
||||||
|
OPENCODE_SERVER_PASSWORD
|
||||||
|
OPENCODE_SERVER_USERNAME
|
||||||
|
PROJECT_ROOT
|
||||||
|
RESOURCE_NAME
|
||||||
|
RUST_LOG
|
||||||
|
VARIABLE_NAME
|
||||||
|
VERTEX_LOCATION
|
||||||
|
XDG_CONFIG_HOME
|
||||||
|
```
|
||||||
|
|
||||||
|
## Package/module identifiers
|
||||||
|
|
||||||
|
```text
|
||||||
|
../../../config.mjs
|
||||||
|
@astrojs/starlight/components
|
||||||
|
@opencode-ai/plugin
|
||||||
|
@opencode-ai/sdk
|
||||||
|
path
|
||||||
|
shescape
|
||||||
|
zod
|
||||||
|
|
||||||
|
@
|
||||||
|
@ai-sdk/anthropic
|
||||||
|
@ai-sdk/cerebras
|
||||||
|
@ai-sdk/google
|
||||||
|
@ai-sdk/openai
|
||||||
|
@ai-sdk/openai-compatible
|
||||||
|
@File#L37-42
|
||||||
|
@modelcontextprotocol/server-everything
|
||||||
|
@opencode
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub owner/repo slugs referenced in docs
|
||||||
|
|
||||||
|
```text
|
||||||
|
24601/opencode-zellij-namer
|
||||||
|
angristan/opencode-wakatime
|
||||||
|
anomalyco/opencode
|
||||||
|
apps/opencode-agent
|
||||||
|
athal7/opencode-devcontainers
|
||||||
|
awesome-opencode/awesome-opencode
|
||||||
|
backnotprop/plannotator
|
||||||
|
ben-vargas/ai-sdk-provider-opencode-sdk
|
||||||
|
btriapitsyn/openchamber
|
||||||
|
BurntSushi/ripgrep
|
||||||
|
Cluster444/agentic
|
||||||
|
code-yeongyu/oh-my-opencode
|
||||||
|
darrenhinde/opencode-agents
|
||||||
|
different-ai/opencode-scheduler
|
||||||
|
different-ai/openwork
|
||||||
|
features/copilot
|
||||||
|
folke/tokyonight.nvim
|
||||||
|
franlol/opencode-md-table-formatter
|
||||||
|
ggml-org/llama.cpp
|
||||||
|
ghoulr/opencode-websearch-cited.git
|
||||||
|
H2Shami/opencode-helicone-session
|
||||||
|
hosenur/portal
|
||||||
|
jamesmurdza/daytona
|
||||||
|
jenslys/opencode-gemini-auth
|
||||||
|
JRedeker/opencode-morph-fast-apply
|
||||||
|
JRedeker/opencode-shell-strategy
|
||||||
|
kdcokenny/ocx
|
||||||
|
kdcokenny/opencode-background-agents
|
||||||
|
kdcokenny/opencode-notify
|
||||||
|
kdcokenny/opencode-workspace
|
||||||
|
kdcokenny/opencode-worktree
|
||||||
|
login/device
|
||||||
|
mohak34/opencode-notifier
|
||||||
|
morhetz/gruvbox
|
||||||
|
mtymek/opencode-obsidian
|
||||||
|
NeuralNomadsAI/CodeNomad
|
||||||
|
nick-vi/opencode-type-inject
|
||||||
|
NickvanDyke/opencode.nvim
|
||||||
|
NoeFabris/opencode-antigravity-auth
|
||||||
|
nordtheme/nord
|
||||||
|
numman-ali/opencode-openai-codex-auth
|
||||||
|
olimorris/codecompanion.nvim
|
||||||
|
panta82/opencode-notificator
|
||||||
|
rebelot/kanagawa.nvim
|
||||||
|
remorses/kimaki
|
||||||
|
sainnhe/everforest
|
||||||
|
shekohex/opencode-google-antigravity-auth
|
||||||
|
shekohex/opencode-pty.git
|
||||||
|
spoons-and-mirrors/subtask2
|
||||||
|
sudo-tee/opencode.nvim
|
||||||
|
supermemoryai/opencode-supermemory
|
||||||
|
Tarquinen/opencode-dynamic-context-pruning
|
||||||
|
Th3Whit3Wolf/one-nvim
|
||||||
|
upstash/context7
|
||||||
|
vtemian/micode
|
||||||
|
vtemian/octto
|
||||||
|
yetone/avante.nvim
|
||||||
|
zenobi-us/opencode-plugin-template
|
||||||
|
zenobi-us/opencode-skillful
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paths, filenames, globs, and URLs
|
||||||
|
|
||||||
|
```text
|
||||||
|
./.opencode/themes/*.json
|
||||||
|
./<project-slug>/storage/
|
||||||
|
./config/#custom-directory
|
||||||
|
./global/storage/
|
||||||
|
.agents/skills/*/SKILL.md
|
||||||
|
.agents/skills/<name>/SKILL.md
|
||||||
|
.clang-format
|
||||||
|
.claude
|
||||||
|
.claude/skills
|
||||||
|
.claude/skills/*/SKILL.md
|
||||||
|
.claude/skills/<name>/SKILL.md
|
||||||
|
.env
|
||||||
|
.github/workflows/opencode.yml
|
||||||
|
.gitignore
|
||||||
|
.gitlab-ci.yml
|
||||||
|
.ignore
|
||||||
|
.NET SDK
|
||||||
|
.npmrc
|
||||||
|
.ocamlformat
|
||||||
|
.opencode
|
||||||
|
.opencode/
|
||||||
|
.opencode/agents/
|
||||||
|
.opencode/commands/
|
||||||
|
.opencode/commands/test.md
|
||||||
|
.opencode/modes/
|
||||||
|
.opencode/plans/*.md
|
||||||
|
.opencode/plugins/
|
||||||
|
.opencode/skills/<name>/SKILL.md
|
||||||
|
.opencode/skills/git-release/SKILL.md
|
||||||
|
.opencode/tools/
|
||||||
|
.well-known/opencode
|
||||||
|
{ type: "raw" \| "patch", content: string }
|
||||||
|
{file:path/to/file}
|
||||||
|
**/*.js
|
||||||
|
%USERPROFILE%/intelephense/license.txt
|
||||||
|
%USERPROFILE%\.cache\opencode
|
||||||
|
%USERPROFILE%\.config\opencode\opencode.jsonc
|
||||||
|
%USERPROFILE%\.config\opencode\plugins
|
||||||
|
%USERPROFILE%\.local\share\opencode
|
||||||
|
%USERPROFILE%\.local\share\opencode\log
|
||||||
|
<project-root>/.opencode/themes/*.json
|
||||||
|
<providerId>/<modelId>
|
||||||
|
<your-project>/.opencode/plugins/
|
||||||
|
~
|
||||||
|
~/...
|
||||||
|
~/.agents/skills/*/SKILL.md
|
||||||
|
~/.agents/skills/<name>/SKILL.md
|
||||||
|
~/.aws/credentials
|
||||||
|
~/.bashrc
|
||||||
|
~/.cache/opencode
|
||||||
|
~/.cache/opencode/node_modules/
|
||||||
|
~/.claude/CLAUDE.md
|
||||||
|
~/.claude/skills/
|
||||||
|
~/.claude/skills/*/SKILL.md
|
||||||
|
~/.claude/skills/<name>/SKILL.md
|
||||||
|
~/.config/opencode
|
||||||
|
~/.config/opencode/AGENTS.md
|
||||||
|
~/.config/opencode/agents/
|
||||||
|
~/.config/opencode/commands/
|
||||||
|
~/.config/opencode/modes/
|
||||||
|
~/.config/opencode/opencode.json
|
||||||
|
~/.config/opencode/opencode.jsonc
|
||||||
|
~/.config/opencode/plugins/
|
||||||
|
~/.config/opencode/skills/*/SKILL.md
|
||||||
|
~/.config/opencode/skills/<name>/SKILL.md
|
||||||
|
~/.config/opencode/themes/*.json
|
||||||
|
~/.config/opencode/tools/
|
||||||
|
~/.config/zed/settings.json
|
||||||
|
~/.local/share
|
||||||
|
~/.local/share/opencode/
|
||||||
|
~/.local/share/opencode/auth.json
|
||||||
|
~/.local/share/opencode/log/
|
||||||
|
~/.local/share/opencode/mcp-auth.json
|
||||||
|
~/.local/share/opencode/opencode.jsonc
|
||||||
|
~/.npmrc
|
||||||
|
~/.zshrc
|
||||||
|
~/code/
|
||||||
|
~/Library/Application Support
|
||||||
|
~/projects/*
|
||||||
|
~/projects/personal/
|
||||||
|
${config.github}/blob/dev/packages/sdk/js/src/gen/types.gen.ts
|
||||||
|
$HOME/intelephense/license.txt
|
||||||
|
$HOME/projects/*
|
||||||
|
$XDG_CONFIG_HOME/opencode/themes/*.json
|
||||||
|
agent/
|
||||||
|
agents/
|
||||||
|
build/
|
||||||
|
commands/
|
||||||
|
dist/
|
||||||
|
http://<wsl-ip>:4096
|
||||||
|
http://127.0.0.1:8080/callback
|
||||||
|
http://localhost:<port>
|
||||||
|
http://localhost:4096
|
||||||
|
http://localhost:4096/doc
|
||||||
|
https://app.example.com
|
||||||
|
https://AZURE_COGNITIVE_SERVICES_RESOURCE_NAME.cognitiveservices.azure.com/
|
||||||
|
https://opencode.ai/zen/v1/chat/completions
|
||||||
|
https://opencode.ai/zen/v1/messages
|
||||||
|
https://opencode.ai/zen/v1/models/gemini-3-flash
|
||||||
|
https://opencode.ai/zen/v1/models/gemini-3-pro
|
||||||
|
https://opencode.ai/zen/v1/responses
|
||||||
|
https://RESOURCE_NAME.openai.azure.com/
|
||||||
|
laravel/pint
|
||||||
|
log/
|
||||||
|
model: "anthropic/claude-sonnet-4-5"
|
||||||
|
modes/
|
||||||
|
node_modules/
|
||||||
|
openai/gpt-4.1
|
||||||
|
opencode.ai/config.json
|
||||||
|
opencode/<model-id>
|
||||||
|
opencode/gpt-5.1-codex
|
||||||
|
opencode/gpt-5.2-codex
|
||||||
|
opencode/kimi-k2
|
||||||
|
openrouter/google/gemini-2.5-flash
|
||||||
|
opncd.ai/s/<share-id>
|
||||||
|
packages/*/AGENTS.md
|
||||||
|
plugins/
|
||||||
|
project/
|
||||||
|
provider_id/model_id
|
||||||
|
provider/model
|
||||||
|
provider/model-id
|
||||||
|
rm -rf ~/.cache/opencode
|
||||||
|
skills/
|
||||||
|
skills/*/SKILL.md
|
||||||
|
src/**/*.ts
|
||||||
|
themes/
|
||||||
|
tools/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Keybind strings
|
||||||
|
|
||||||
|
```text
|
||||||
|
alt+b
|
||||||
|
Alt+Ctrl+K
|
||||||
|
alt+d
|
||||||
|
alt+f
|
||||||
|
Cmd+Esc
|
||||||
|
Cmd+Option+K
|
||||||
|
Cmd+Shift+Esc
|
||||||
|
Cmd+Shift+G
|
||||||
|
Cmd+Shift+P
|
||||||
|
ctrl+a
|
||||||
|
ctrl+b
|
||||||
|
ctrl+d
|
||||||
|
ctrl+e
|
||||||
|
Ctrl+Esc
|
||||||
|
ctrl+f
|
||||||
|
ctrl+g
|
||||||
|
ctrl+k
|
||||||
|
Ctrl+Shift+Esc
|
||||||
|
Ctrl+Shift+P
|
||||||
|
ctrl+t
|
||||||
|
ctrl+u
|
||||||
|
ctrl+w
|
||||||
|
ctrl+x
|
||||||
|
DELETE
|
||||||
|
Shift+Enter
|
||||||
|
WIN+R
|
||||||
|
```
|
||||||
|
|
||||||
|
## Model ID strings referenced
|
||||||
|
|
||||||
|
```text
|
||||||
|
{env:OPENCODE_MODEL}
|
||||||
|
anthropic/claude-3-5-sonnet-20241022
|
||||||
|
anthropic/claude-haiku-4-20250514
|
||||||
|
anthropic/claude-haiku-4-5
|
||||||
|
anthropic/claude-sonnet-4-20250514
|
||||||
|
anthropic/claude-sonnet-4-5
|
||||||
|
gitlab/duo-chat-haiku-4-5
|
||||||
|
lmstudio/google/gemma-3n-e4b
|
||||||
|
openai/gpt-4.1
|
||||||
|
openai/gpt-5
|
||||||
|
opencode/gpt-5.1-codex
|
||||||
|
opencode/gpt-5.2-codex
|
||||||
|
opencode/kimi-k2
|
||||||
|
openrouter/google/gemini-2.5-flash
|
||||||
|
```
|
||||||
+112
-15
@@ -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)
|
||||||
|
|||||||
@@ -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,14 +0,0 @@
|
|||||||
---
|
|
||||||
description: translate English to other languages
|
|
||||||
model: opencode/claude-opus-4-7
|
|
||||||
---
|
|
||||||
|
|
||||||
run git diff and translate changed english doc and UI copy files to other international languages. Translate all languages in parallel to save time.
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
|
|
||||||
- Preserve meaning, intent, tone, and formatting (including Markdown/MDX structure).
|
|
||||||
- Preserve all technical terms and artifacts exactly: product/company names, API names, identifiers, code, commands/flags, file paths, URLs, versions, error messages, config keys/values, and anything inside inline code or code blocks.
|
|
||||||
- Also preserve every term listed in the Do-Not-Translate glossary below.
|
|
||||||
- Also apply locale-specific guidance from `.opencode/glossary/<locale>.md` when available (for example, `zh-cn.md`).
|
|
||||||
- Do not modify fenced code blocks.
|
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://opencode.ai/config.json",
|
||||||
"provider": {},
|
"provider": {
|
||||||
|
"opencode": {
|
||||||
|
"options": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"edit": {
|
"edit": {
|
||||||
"packages/opencode/migration/*": "ask",
|
"packages/opencode/migration/*": "deny",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"mcp": {},
|
"mcp": {},
|
||||||
|
|||||||
+285
-367
@@ -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)
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
---
|
|
||||||
name: effect
|
|
||||||
description: Work with Effect v4 / effect-smol TypeScript code in this repo
|
|
||||||
---
|
|
||||||
|
|
||||||
# Effect
|
|
||||||
|
|
||||||
This codebase uses Effect for typed, composable TypeScript services, schemas, and workflows.
|
|
||||||
|
|
||||||
## Source Of Truth
|
|
||||||
|
|
||||||
Use the current Effect v4 / effect-smol source, not memory or older Effect v2/v3 examples.
|
|
||||||
|
|
||||||
1. If `.opencode/references/effect-smol` is missing, clone `https://github.com/Effect-TS/effect-smol` there. Do this in the project, not in the skill folder.
|
|
||||||
2. Search `.opencode/references/effect-smol` for exact APIs, examples, tests, and naming patterns before answering or implementing Effect-specific code.
|
|
||||||
3. Also inspect existing repo code for local house style before introducing new patterns.
|
|
||||||
4. Prefer answers and implementations backed by specific source files or nearby repo examples.
|
|
||||||
|
|
||||||
## Guidelines
|
|
||||||
|
|
||||||
- Prefer current Effect v4 APIs and project-local patterns over old blog posts, examples, or package-memory guesses.
|
|
||||||
- Use `Effect.gen(function* () { ... })` for multi-step workflows.
|
|
||||||
- Use `Effect.fn("Name")` or `Effect.fnUntraced(...)` for named effects when adding reusable service methods or important workflows.
|
|
||||||
- Prefer Effect `Schema` for API and domain data shapes. Use branded schemas for IDs and `Schema.TaggedErrorClass` for typed domain errors when modeling new error surfaces.
|
|
||||||
- Keep HTTP handlers thin: decode input, read request context, call services, and map transport errors. Put business rules in services.
|
|
||||||
- In Effect service code, prefer Effect-aware platform abstractions and dependencies over ad hoc promises where the surrounding code already does so.
|
|
||||||
- Keep layer composition explicit. Avoid broad hidden provisioning that makes missing dependencies hard to see.
|
|
||||||
- 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 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.
|
|
||||||
@@ -116,8 +116,8 @@
|
|||||||
"light": "nord5"
|
"light": "nord5"
|
||||||
},
|
},
|
||||||
"diffLineNumber": {
|
"diffLineNumber": {
|
||||||
"dark": "#abafb7",
|
"dark": "nord2",
|
||||||
"light": "textMuted"
|
"light": "nord4"
|
||||||
},
|
},
|
||||||
"diffAddedLineNumberBg": {
|
"diffAddedLineNumberBg": {
|
||||||
"dark": "#3B4252",
|
"dark": "#3B4252",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
|||||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||||
Accept: "application/vnd.github+json",
|
Accept: "application/vnd.github+json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
|
...options.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -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: ["thdxr", "kommander", "rekram1-node"],
|
||||||
inference: ["fwang", "MrMushrooooom"],
|
core: ["thdxr", "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)]!
|
||||||
}
|
}
|
||||||
@@ -26,7 +28,7 @@ async function githubFetch(endpoint: string, options: RequestInit = {}) {
|
|||||||
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
|
||||||
Accept: "application/vnd.github+json",
|
Accept: "application/vnd.github+json",
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(options.headers instanceof Headers ? Object.fromEntries(options.headers.entries()) : options.headers),
|
...options.headers,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -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
@@ -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,51 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://raw.githubusercontent.com/nicolo-ribaudo/oxc-project.github.io/refs/heads/json-schema/src/public/.oxlintrc.schema.json",
|
|
||||||
"options": {
|
|
||||||
"typeAware": true
|
|
||||||
},
|
|
||||||
"categories": {
|
|
||||||
"suspicious": "warn"
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"typescript/no-base-to-string": "warn",
|
|
||||||
// Effect uses `function*` with Effect.gen/Effect.fnUntraced that don't always yield
|
|
||||||
"require-yield": "off",
|
|
||||||
// SolidJS uses `let ref: T | undefined` for JSX ref bindings assigned at runtime
|
|
||||||
"no-unassigned-vars": "off",
|
|
||||||
// SolidJS tracks reactive deps by reading properties inside createEffect
|
|
||||||
"no-unused-expressions": "off",
|
|
||||||
// Intentional control char matching (ANSI escapes, null byte sanitization)
|
|
||||||
"no-control-regex": "off",
|
|
||||||
// SST and plugin tools require triple-slash references
|
|
||||||
"triple-slash-reference": "off",
|
|
||||||
|
|
||||||
// Suspicious category: suppress noisy rules
|
|
||||||
// Effect's nested function* closures inherently shadow outer scope
|
|
||||||
"no-shadow": "off",
|
|
||||||
// Namespace-heavy codebase makes this too noisy
|
|
||||||
"unicorn/consistent-function-scoping": "off",
|
|
||||||
// Opinionated — .sort()/.reverse() mutation is fine in this codebase
|
|
||||||
"unicorn/no-array-sort": "off",
|
|
||||||
"unicorn/no-array-reverse": "off",
|
|
||||||
// Not relevant — this isn't a DOM event handler codebase
|
|
||||||
"unicorn/prefer-add-event-listener": "off",
|
|
||||||
// Bundler handles module resolution
|
|
||||||
"unicorn/require-module-specifiers": "off",
|
|
||||||
// postMessage target origin not relevant for this codebase
|
|
||||||
"unicorn/require-post-message-target-origin": "off",
|
|
||||||
// Side-effectful constructors are intentional in some places
|
|
||||||
"no-new": "off",
|
|
||||||
|
|
||||||
// Type-aware: catch unhandled promises
|
|
||||||
"typescript/no-floating-promises": "warn",
|
|
||||||
// Warn when spreading non-plain objects (Headers, class instances, etc.)
|
|
||||||
"typescript/no-misused-spread": "warn"
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"typeAware": true
|
|
||||||
},
|
|
||||||
"options": {
|
|
||||||
"typeAware": true
|
|
||||||
},
|
|
||||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/.build", "**/.sst", "**/*.d.ts", "**/sdk.gen.ts"]
|
|
||||||
}
|
|
||||||
@@ -11,10 +11,35 @@
|
|||||||
- Keep things in one function unless composable or reusable
|
- Keep things in one function unless composable or reusable
|
||||||
- Avoid `try`/`catch` where possible
|
- Avoid `try`/`catch` where possible
|
||||||
- Avoid using the `any` type
|
- Avoid using the `any` type
|
||||||
|
- Prefer single word variable names where possible
|
||||||
- Use Bun APIs when possible, like `Bun.file()`
|
- Use Bun APIs when possible, like `Bun.file()`
|
||||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||||
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
||||||
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
Prefer single word names for variables and functions. Only use multiple words if necessary.
|
||||||
|
|
||||||
|
### Naming Enforcement (Read This)
|
||||||
|
|
||||||
|
THIS RULE IS MANDATORY FOR AGENT WRITTEN CODE.
|
||||||
|
|
||||||
|
- Use single word names by default for new locals, params, and helper functions.
|
||||||
|
- Multi-word names are allowed only when a single word would be unclear or ambiguous.
|
||||||
|
- Do not introduce new camelCase compounds when a short single-word alternative is clear.
|
||||||
|
- Before finishing edits, review touched lines and shorten newly introduced identifiers where possible.
|
||||||
|
- Good short names to prefer: `pid`, `cfg`, `err`, `opts`, `dir`, `root`, `child`, `state`, `timeout`.
|
||||||
|
- Examples to avoid unless truly required: `inputPID`, `existingClient`, `connectTimeout`, `workerPath`.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Good
|
||||||
|
const foo = 1
|
||||||
|
function journal(dir: string) {}
|
||||||
|
|
||||||
|
// Bad
|
||||||
|
const fooBar = 1
|
||||||
|
function prepareJournal(dir: string) {}
|
||||||
|
```
|
||||||
|
|
||||||
Reduce total variable count by inlining when a value is only used once.
|
Reduce total variable count by inlining when a value is only used once.
|
||||||
|
|
||||||
|
|||||||
+18
-6
@@ -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 Tauri’s `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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -68,12 +68,12 @@ nix run nixpkgs#opencode # oppure github:anomalyco/opencode per l’ul
|
|||||||
|
|
||||||
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
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
|||||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
|||||||
"nodes": {
|
"nodes": {
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1776683584,
|
"lastModified": 1773909469,
|
||||||
"narHash": "sha256-NuTLMrr10Tng72hurYG8jYQ4XKK8wnpJmOGcPiis96g=",
|
"narHash": "sha256-vglVrLfHjFIzIdV9A27Ugul6rh3I1qHbbitGW7dk420=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "9dd5558b06dbdacbf635a3dd36dce1b1a7ee3a89",
|
"rev": "7149c06513f335be57f26fcbbbe34afda923882b",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
|||||||
+6
-6
@@ -281,7 +281,7 @@ async function assertOpencodeConnected() {
|
|||||||
})
|
})
|
||||||
connected = true
|
connected = true
|
||||||
break
|
break
|
||||||
} catch {}
|
} catch (e) {}
|
||||||
await sleep(300)
|
await sleep(300)
|
||||||
} while (retry++ < 30)
|
} while (retry++ < 30)
|
||||||
|
|
||||||
@@ -513,7 +513,7 @@ async function subscribeSessionEvents() {
|
|||||||
const decoder = new TextDecoder()
|
const decoder = new TextDecoder()
|
||||||
|
|
||||||
let text = ""
|
let text = ""
|
||||||
void (async () => {
|
;(async () => {
|
||||||
while (true) {
|
while (true) {
|
||||||
try {
|
try {
|
||||||
const { done, value } = await reader.read()
|
const { done, value } = await reader.read()
|
||||||
@@ -542,7 +542,7 @@ async function subscribeSessionEvents() {
|
|||||||
? JSON.stringify(part.state.input)
|
? JSON.stringify(part.state.input)
|
||||||
: "Unknown"
|
: "Unknown"
|
||||||
console.log()
|
console.log()
|
||||||
console.log(`${color}|`, `\x1b[0m\x1b[2m ${tool.padEnd(7, " ")}`, "", `\x1b[0m${title}`)
|
console.log(color + `|`, "\x1b[0m\x1b[2m" + ` ${tool.padEnd(7, " ")}`, "", "\x1b[0m" + title)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
@@ -561,7 +561,7 @@ async function subscribeSessionEvents() {
|
|||||||
if (evt.properties.info.id !== session.id) continue
|
if (evt.properties.info.id !== session.id) continue
|
||||||
session = evt.properties.info
|
session = evt.properties.info
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
// Ignore parse errors
|
// Ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -576,7 +576,7 @@ async function subscribeSessionEvents() {
|
|||||||
async function summarize(response: string) {
|
async function summarize(response: string) {
|
||||||
try {
|
try {
|
||||||
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
|
||||||
} catch {
|
} catch (e) {
|
||||||
if (isScheduleEvent()) {
|
if (isScheduleEvent()) {
|
||||||
return "Scheduled task changes"
|
return "Scheduled task changes"
|
||||||
}
|
}
|
||||||
@@ -776,7 +776,7 @@ async function assertPermissions() {
|
|||||||
console.log(` permission: ${permission}`)
|
console.log(` permission: ${permission}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to check permissions: ${error}`)
|
console.error(`Failed to check permissions: ${error}`)
|
||||||
throw new Error(`Failed to check permissions for user ${actor}: ${error}`, { cause: error })
|
throw new Error(`Failed to check permissions for user ${actor}: ${error}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
|
if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
|
||||||
|
|||||||
@@ -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,
|
||||||
{
|
{
|
||||||
|
|||||||
+1
-29
@@ -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
|
||||||
@@ -116,27 +115,6 @@ const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100
|
|||||||
appliesToProducts: [zenLiteProduct.id],
|
appliesToProducts: [zenLiteProduct.id],
|
||||||
duration: "once",
|
duration: "once",
|
||||||
})
|
})
|
||||||
const zenLiteCouponThreeMonths100 = new stripe.Coupon("ZenLiteCoupon3Months100", {
|
|
||||||
name: "3 months 100% off",
|
|
||||||
percentOff: 100,
|
|
||||||
appliesToProducts: [zenLiteProduct.id],
|
|
||||||
duration: "repeating",
|
|
||||||
durationInMonths: 3,
|
|
||||||
})
|
|
||||||
const zenLiteCouponSixMonths100 = new stripe.Coupon("ZenLiteCoupon6Months100", {
|
|
||||||
name: "6 months 100% off",
|
|
||||||
percentOff: 100,
|
|
||||||
appliesToProducts: [zenLiteProduct.id],
|
|
||||||
duration: "repeating",
|
|
||||||
durationInMonths: 6,
|
|
||||||
})
|
|
||||||
const zenLiteCouponTwelveMonths100 = new stripe.Coupon("ZenLiteCoupon12Months100", {
|
|
||||||
name: "12 months 100% off",
|
|
||||||
percentOff: 100,
|
|
||||||
appliesToProducts: [zenLiteProduct.id],
|
|
||||||
duration: "repeating",
|
|
||||||
durationInMonths: 12,
|
|
||||||
})
|
|
||||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||||
product: zenLiteProduct.id,
|
product: zenLiteProduct.id,
|
||||||
currency: "usd",
|
currency: "usd",
|
||||||
@@ -153,9 +131,6 @@ const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
|||||||
priceInr: 92900,
|
priceInr: 92900,
|
||||||
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||||
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
|
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
|
||||||
threeMonths100Coupon: zenLiteCouponThreeMonths100.id,
|
|
||||||
sixMonths100Coupon: zenLiteCouponSixMonths100.id,
|
|
||||||
twelveMonths100Coupon: zenLiteCouponTwelveMonths100.id,
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -222,7 +197,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 +206,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 +227,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,
|
||||||
@@ -265,6 +236,7 @@ new sst.cloudflare.x.SolidStart("Console", {
|
|||||||
SALESFORCE_INSTANCE_URL,
|
SALESFORCE_INSTANCE_URL,
|
||||||
ZEN_BLACK_PRICE,
|
ZEN_BLACK_PRICE,
|
||||||
ZEN_LITE_PRICE,
|
ZEN_LITE_PRICE,
|
||||||
|
new sst.Secret("ZEN_LITE_COUPON_FIRST_MONTH_100_INVITEES"),
|
||||||
new sst.Secret("ZEN_LIMITS"),
|
new sst.Secret("ZEN_LIMITS"),
|
||||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||||
...ZEN_MODELS,
|
...ZEN_MODELS,
|
||||||
|
|||||||
+2
-2
@@ -1,9 +1,9 @@
|
|||||||
import { SECRET } from "./secret"
|
import { SECRET } from "./secret"
|
||||||
import { shortDomain } from "./stage"
|
import { domain, shortDomain } from "./stage"
|
||||||
|
|
||||||
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
|
const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
|
||||||
|
|
||||||
new sst.cloudflare.x.SolidStart("Teams", {
|
const teams = new sst.cloudflare.x.SolidStart("Teams", {
|
||||||
domain: shortDomain,
|
domain: shortDomain,
|
||||||
path: "packages/enterprise",
|
path: "packages/enterprise",
|
||||||
buildCommand: "bun run build:cloudflare",
|
buildCommand: "bun run build:cloudflare",
|
||||||
|
|||||||
@@ -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" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
@@ -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
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-LTo0ohJN5hBOubqFLVL45unVEIwBDkACNVv64k2nkq4=",
|
"x86_64-linux": "sha256-g29OM3dy+sZ3ioTs8zjQOK1N+KnNr9ptP9xtdPcdr64=",
|
||||||
"aarch64-linux": "sha256-oYKY2UJRWG2fhufW4aGujX/Poou93023ZF2Fu7oyYOw=",
|
"aarch64-linux": "sha256-Iu91KwDcV5omkf4Ngny1aYpyCkPLjuoWOVUDOJUhW1k=",
|
||||||
"aarch64-darwin": "sha256-618c9vqKN5I+no1nzylctAiWvqw7Bsa+bzSTNwXmSQA=",
|
"aarch64-darwin": "sha256-bk3G6m+Yo60Ea3Kyglc37QZf5Vm7MLMFcxemjc7HnL0=",
|
||||||
"x86_64-darwin": "sha256-1ro3/gH0FC0TWXwWT+k675xR396GE98HpnBEeuD4t6k="
|
"x86_64-darwin": "sha256-y3hooQw13Z3Cu0KFfXYdpkTEeKTyuKd+a/jsXHQLdqA="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -64,7 +64,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
|
|||||||
[
|
[
|
||||||
ripgrep
|
ripgrep
|
||||||
]
|
]
|
||||||
# bun runs sysctl to detect if running on rosetta2
|
# bun runs sysctl to detect if dunning on rosetta2
|
||||||
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
|
++ lib.optional stdenvNoCC.hostPlatform.isDarwin sysctl
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-22
@@ -4,16 +4,14 @@
|
|||||||
"description": "AI-powered development tool",
|
"description": "AI-powered development tool",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "bun@1.3.13",
|
"packageManager": "bun@1.3.11",
|
||||||
"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 tauri 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",
|
|
||||||
"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'",
|
||||||
@@ -28,35 +26,29 @@
|
|||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
"catalog": {
|
"catalog": {
|
||||||
"@effect/opentelemetry": "4.0.0-beta.57",
|
"@effect/platform-node": "4.0.0-beta.46",
|
||||||
"@effect/platform-node": "4.0.0-beta.57",
|
"@types/bun": "1.3.11",
|
||||||
"@npmcli/arborist": "9.4.0",
|
|
||||||
"@types/bun": "1.3.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/keymap": "0.2.6",
|
|
||||||
"@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",
|
||||||
"@cloudflare/workers-types": "4.20251008.0",
|
"@cloudflare/workers-types": "4.20251008.0",
|
||||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||||
"@pierre/diffs": "1.1.0-beta.18",
|
"@pierre/diffs": "1.1.0-beta.18",
|
||||||
"opentui-spinner": "0.0.6",
|
|
||||||
"@solid-primitives/storage": "4.3.3",
|
"@solid-primitives/storage": "4.3.3",
|
||||||
"@tailwindcss/vite": "4.1.11",
|
"@tailwindcss/vite": "4.1.11",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"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.46",
|
||||||
"ai": "6.0.168",
|
"ai": "6.0.158",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
@@ -65,8 +57,7 @@
|
|||||||
"marked": "17.0.1",
|
"marked": "17.0.1",
|
||||||
"marked-shiki": "1.2.1",
|
"marked-shiki": "1.2.1",
|
||||||
"remend": "1.3.0",
|
"remend": "1.3.0",
|
||||||
"@playwright/test": "1.59.1",
|
"@playwright/test": "1.51.0",
|
||||||
"semver": "7.7.4",
|
|
||||||
"typescript": "5.8.2",
|
"typescript": "5.8.2",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"zod": "4.1.8",
|
"zod": "4.1.8",
|
||||||
@@ -79,8 +70,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"
|
||||||
@@ -93,8 +82,6 @@
|
|||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"glob": "13.0.5",
|
"glob": "13.0.5",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"oxlint": "1.60.0",
|
|
||||||
"oxlint-tsgolint": "0.21.0",
|
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"semver": "^7.6.0",
|
"semver": "^7.6.0",
|
||||||
"sst": "3.18.10",
|
"sst": "3.18.10",
|
||||||
@@ -132,7 +119,6 @@
|
|||||||
"@types/node": "catalog:"
|
"@types/node": "catalog:"
|
||||||
},
|
},
|
||||||
"patchedDependencies": {
|
"patchedDependencies": {
|
||||||
"@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,11 @@ Your app is ready to be deployed!
|
|||||||
|
|
||||||
## E2E Testing
|
## E2E Testing
|
||||||
|
|
||||||
Playwright starts the Vite dev server automatically via `webServer`, and UI tests expect an opencode backend at `localhost:4096` by default.
|
Playwright starts the Vite dev server automatically via `webServer`, and UI tests need an opencode backend (defaults to `localhost:4096`).
|
||||||
|
Use the local runner to create a temp sandbox, seed data, and run the tests.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bunx playwright install chromium
|
bunx playwright install
|
||||||
bun run test:e2e:local
|
bun run test:e2e:local
|
||||||
bun run test:e2e:local -- --grep "settings"
|
bun run test:e2e:local -- --grep "settings"
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# E2E Testing Guide
|
||||||
|
|
||||||
|
## Build/Lint/Test Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all e2e tests
|
||||||
|
bun test:e2e
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
bun test:e2e -- app/home.spec.ts
|
||||||
|
|
||||||
|
# Run single test by title
|
||||||
|
bun test:e2e -- -g "home renders and shows core entrypoints"
|
||||||
|
|
||||||
|
# Run tests with UI mode (for debugging)
|
||||||
|
bun test:e2e:ui
|
||||||
|
|
||||||
|
# Run tests locally with full server setup
|
||||||
|
bun test:e2e:local
|
||||||
|
|
||||||
|
# View test report
|
||||||
|
bun test:e2e:report
|
||||||
|
|
||||||
|
# Typecheck
|
||||||
|
bun typecheck
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
All tests live in `packages/app/e2e/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
e2e/
|
||||||
|
├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk)
|
||||||
|
├── actions.ts # Reusable action helpers
|
||||||
|
├── selectors.ts # DOM selectors
|
||||||
|
├── utils.ts # Utilities (serverUrl, modKey, path helpers)
|
||||||
|
└── [feature]/
|
||||||
|
└── *.spec.ts # Test files
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Patterns
|
||||||
|
|
||||||
|
### Basic Test Structure
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
|
test("test description", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await gotoSession() // or gotoSession(sessionID)
|
||||||
|
|
||||||
|
// Your test code
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Fixtures
|
||||||
|
|
||||||
|
- `page` - Playwright page
|
||||||
|
- `llm` - Mock LLM server for queuing responses (`text`, `tool`, `toolMatch`, `textMatch`, etc.)
|
||||||
|
- `project` - Golden-path project fixture (call `project.open()` first, then use `project.sdk`, `project.prompt(...)`, `project.gotoSession(...)`, `project.trackSession(...)`)
|
||||||
|
- `sdk` - OpenCode SDK client for API calls (worker-scoped, shared directory)
|
||||||
|
- `gotoSession(sessionID?)` - Navigate to session (worker-scoped, shared directory)
|
||||||
|
|
||||||
|
### Helper Functions
|
||||||
|
|
||||||
|
**Actions** (`actions.ts`):
|
||||||
|
|
||||||
|
- `openPalette(page)` - Open command palette
|
||||||
|
- `openSettings(page)` - Open settings dialog
|
||||||
|
- `closeDialog(page, dialog)` - Close any dialog
|
||||||
|
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
|
||||||
|
- `waitTerminalReady(page, { term? })` - Wait for a mounted terminal to connect and finish rendering output
|
||||||
|
- `runTerminal(page, { cmd, token, term?, timeout? })` - Type into the terminal via the browser and wait for rendered output
|
||||||
|
- `withSession(sdk, title, callback)` - Create temp session
|
||||||
|
- `sessionIDFromUrl(url)` - Read session ID from URL
|
||||||
|
- `slugFromUrl(url)` - Read workspace slug from URL
|
||||||
|
- `waitSlug(page, skip?)` - Wait for resolved workspace slug
|
||||||
|
- `clickListItem(container, filter)` - Click list item by key/text
|
||||||
|
|
||||||
|
**Selectors** (`selectors.ts`):
|
||||||
|
|
||||||
|
- `promptSelector` - Prompt input
|
||||||
|
- `terminalSelector` - Terminal panel
|
||||||
|
- `sessionItemSelector(id)` - Session in sidebar
|
||||||
|
- `listItemSelector` - Generic list items
|
||||||
|
|
||||||
|
**Utils** (`utils.ts`):
|
||||||
|
|
||||||
|
- `modKey` - Meta (Mac) or Control (Linux/Win)
|
||||||
|
- `serverUrl` - Backend server URL
|
||||||
|
- `sessionPath(dir, id?)` - Build session URL
|
||||||
|
|
||||||
|
## Code Style Guidelines
|
||||||
|
|
||||||
|
### Imports
|
||||||
|
|
||||||
|
Always import from `../fixtures`, not `@playwright/test`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
import { test, expect } from "@playwright/test"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Naming Conventions
|
||||||
|
|
||||||
|
- Test files: `feature-name.spec.ts`
|
||||||
|
- Test names: lowercase, descriptive: `"sidebar can be toggled"`
|
||||||
|
- Variables: camelCase
|
||||||
|
- Constants: SCREAMING_SNAKE_CASE
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Tests should clean up after themselves. Prefer fixture-managed cleanup:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, "test session", async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
// Test code...
|
||||||
|
}) // Auto-deletes session
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- Prefer the `project` fixture for tests that need a dedicated project with LLM mocking — call `project.open()` then use `project.prompt(...)`, `project.trackSession(...)`, etc.
|
||||||
|
- Use `withSession(sdk, title, callback)` for lightweight temp sessions on the shared worker directory
|
||||||
|
- Call `project.trackSession(sessionID, directory?)` and `project.trackDirectory(directory)` for any resources created outside the fixture so teardown can clean them up
|
||||||
|
- Avoid calling `sdk.session.delete(...)` directly
|
||||||
|
|
||||||
|
### Timeouts
|
||||||
|
|
||||||
|
Default: 60s per test, 10s per assertion. Override when needed:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
test.setTimeout(120_000) // For long LLM operations
|
||||||
|
test("slow test", async () => {
|
||||||
|
await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Selectors
|
||||||
|
|
||||||
|
Use `data-component`, `data-action`, or semantic roles:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ Good
|
||||||
|
await page.locator('[data-component="prompt-input"]').click()
|
||||||
|
await page.getByRole("button", { name: "Open settings" }).click()
|
||||||
|
|
||||||
|
// ❌ Bad
|
||||||
|
await page.locator(".css-class-name").click()
|
||||||
|
await page.locator("#id-name").click()
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard Shortcuts
|
||||||
|
|
||||||
|
Use `modKey` for cross-platform compatibility:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
|
||||||
|
await page.keyboard.press(`${modKey}+Comma`) // Open settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Terminal Tests
|
||||||
|
|
||||||
|
- In terminal tests, type through the browser. Do not write to the PTY through the SDK.
|
||||||
|
- Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`.
|
||||||
|
- These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles.
|
||||||
|
- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters.
|
||||||
|
- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts.
|
||||||
|
- Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks.
|
||||||
|
|
||||||
|
### Wait on state
|
||||||
|
|
||||||
|
- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass
|
||||||
|
- Avoid race-prone flows that assume work is finished after an action
|
||||||
|
- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers
|
||||||
|
- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state
|
||||||
|
- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops
|
||||||
|
- Do not treat a visible element as proof that the app will route the next action to it
|
||||||
|
- When fixing a flake, validate with `--repeat-each` and multiple workers when practical
|
||||||
|
|
||||||
|
### Add hooks
|
||||||
|
|
||||||
|
- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks
|
||||||
|
- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts`
|
||||||
|
- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony
|
||||||
|
- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI
|
||||||
|
- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable
|
||||||
|
- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states
|
||||||
|
|
||||||
|
### Prefer helpers
|
||||||
|
|
||||||
|
- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise
|
||||||
|
- Use direct locators when the interaction is simple and a helper would not add clarity
|
||||||
|
- Prefer helpers that both perform an action and verify the app consumed it
|
||||||
|
- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state
|
||||||
|
- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions
|
||||||
|
|
||||||
|
## Writing New Tests
|
||||||
|
|
||||||
|
1. Choose appropriate folder or create new one
|
||||||
|
2. Import from `../fixtures`
|
||||||
|
3. Use helper functions from `../actions` and `../selectors`
|
||||||
|
4. When validating routing, use shared helpers from `../actions`. Workspace URL slugs can be canonicalized on Windows, so assert against canonical or resolved workspace slugs.
|
||||||
|
5. Clean up any created resources
|
||||||
|
6. Use specific selectors (avoid CSS classes)
|
||||||
|
7. Test one feature per test file
|
||||||
|
|
||||||
|
## Local Development
|
||||||
|
|
||||||
|
For UI debugging, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun test:e2e:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
This opens Playwright's interactive UI for step-through debugging.
|
||||||
@@ -0,0 +1,949 @@
|
|||||||
|
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
|
||||||
|
import { expect, type Locator, type Page } from "@playwright/test"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { execSync } from "node:child_process"
|
||||||
|
import { terminalAttr, type E2EWindow } from "../src/testing/terminal"
|
||||||
|
import { createSdk, modKey, resolveDirectory, serverUrl } from "./utils"
|
||||||
|
import {
|
||||||
|
dropdownMenuContentSelector,
|
||||||
|
projectSwitchSelector,
|
||||||
|
projectMenuTriggerSelector,
|
||||||
|
projectCloseMenuSelector,
|
||||||
|
projectWorkspacesToggleSelector,
|
||||||
|
titlebarRightSelector,
|
||||||
|
popoverBodySelector,
|
||||||
|
listItemSelector,
|
||||||
|
listItemKeySelector,
|
||||||
|
listItemKeyStartsWithSelector,
|
||||||
|
promptSelector,
|
||||||
|
terminalSelector,
|
||||||
|
workspaceItemSelector,
|
||||||
|
workspaceMenuTriggerSelector,
|
||||||
|
} from "./selectors"
|
||||||
|
|
||||||
|
const phase = new WeakMap<Page, "test" | "cleanup">()
|
||||||
|
|
||||||
|
export function setHealthPhase(page: Page, value: "test" | "cleanup") {
|
||||||
|
phase.set(page, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healthPhase(page: Page) {
|
||||||
|
return phase.get(page) ?? "test"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function defocus(page: Page) {
|
||||||
|
await page
|
||||||
|
.evaluate(() => {
|
||||||
|
const el = document.activeElement
|
||||||
|
if (el instanceof HTMLElement) el.blur()
|
||||||
|
})
|
||||||
|
.catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalID(term: Locator) {
|
||||||
|
const id = await term.getAttribute(terminalAttr)
|
||||||
|
if (id) return id
|
||||||
|
throw new Error(`Active terminal missing ${terminalAttr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function terminalConnects(page: Page, input?: { term?: Locator }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(term)
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function disconnectTerminal(page: Page, input?: { term?: Locator }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(term)
|
||||||
|
await page.evaluate((id) => {
|
||||||
|
;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.()
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalReady(page: Page, term?: Locator) {
|
||||||
|
const next = term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(next)
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||||
|
return !!state?.connected && (state.settled ?? 0) > 0
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalFocusIdle(page: Page, term?: Locator) {
|
||||||
|
const next = term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(next)
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]
|
||||||
|
return (state?.focusing ?? 0) === 0
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function terminalHas(page: Page, input: { term?: Locator; token: string }) {
|
||||||
|
const next = input.term ?? page.locator(terminalSelector).first()
|
||||||
|
const id = await terminalID(next)
|
||||||
|
return page.evaluate(
|
||||||
|
(input) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[input.id]
|
||||||
|
return state?.rendered.includes(input.token) ?? false
|
||||||
|
},
|
||||||
|
{ id, token: input.token },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSlashActive(page: Page, id: string) {
|
||||||
|
return page.evaluate((id) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||||
|
if (state?.popover !== "slash") return false
|
||||||
|
if (!state.slash.ids.includes(id)) return false
|
||||||
|
return state.slash.active === id
|
||||||
|
}, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSlashSelects(page: Page) {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSlashSelected(page: Page, input: { id: string; count: number }) {
|
||||||
|
return page.evaluate((input) => {
|
||||||
|
const state = (window as E2EWindow).__opencode_e2e?.prompt?.current
|
||||||
|
if (!state) return false
|
||||||
|
return state.selected === input.id && state.selects >= input.count
|
||||||
|
}, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const timeout = input?.timeout ?? 10_000
|
||||||
|
await expect(term).toBeVisible()
|
||||||
|
await expect(term.locator("textarea")).toHaveCount(1)
|
||||||
|
await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) {
|
||||||
|
const term = input?.term ?? page.locator(terminalSelector).first()
|
||||||
|
const timeout = input?.timeout ?? 10_000
|
||||||
|
await waitTerminalReady(page, { term, timeout })
|
||||||
|
await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function showPromptSlash(
|
||||||
|
page: Page,
|
||||||
|
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||||
|
) {
|
||||||
|
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||||
|
const timeout = input.timeout ?? 10_000
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await prompt.click().catch(() => false)
|
||||||
|
await prompt.fill(input.text).catch(() => false)
|
||||||
|
return promptSlashActive(page, input.id).catch(() => false)
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPromptSlash(
|
||||||
|
page: Page,
|
||||||
|
input: { id: string; text: string; prompt?: Locator; timeout?: number },
|
||||||
|
) {
|
||||||
|
const prompt = input.prompt ?? page.locator(promptSelector)
|
||||||
|
const timeout = input.timeout ?? 10_000
|
||||||
|
const count = await promptSlashSelects(page)
|
||||||
|
await showPromptSlash(page, input)
|
||||||
|
await prompt.press("Enter")
|
||||||
|
await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) {
|
||||||
|
const term = input.term ?? page.locator(terminalSelector).first()
|
||||||
|
const timeout = input.timeout ?? 10_000
|
||||||
|
await waitTerminalReady(page, { term, timeout })
|
||||||
|
const textarea = term.locator("textarea")
|
||||||
|
await term.click()
|
||||||
|
await expect(textarea).toBeFocused()
|
||||||
|
await page.keyboard.type(input.cmd)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await expect.poll(() => terminalHas(page, { term, token: input.token }), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openPalette(page: Page, key = "K") {
|
||||||
|
await defocus(page)
|
||||||
|
await page.keyboard.press(`${modKey}+${key}`)
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDialog(page: Page, dialog: Locator) {
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
const closed = await dialog
|
||||||
|
.waitFor({ state: "detached", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (closed) return
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
const closedSecond = await dialog
|
||||||
|
.waitFor({ state: "detached", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (closedSecond) return
|
||||||
|
|
||||||
|
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isSidebarClosed(page: Page) {
|
||||||
|
const button = await waitSidebarButton(page, "isSidebarClosed")
|
||||||
|
return (await button.getAttribute("aria-expanded")) !== "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function errorBoundaryText(page: Page) {
|
||||||
|
const title = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||||
|
if (!(await title.isVisible().catch(() => false))) return
|
||||||
|
|
||||||
|
const description = await page
|
||||||
|
.getByText(/an error occurred while loading the application\./i)
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
.catch(() => "")
|
||||||
|
const detail = await page
|
||||||
|
.getByRole("textbox", { name: /error details/i })
|
||||||
|
.first()
|
||||||
|
.inputValue()
|
||||||
|
.catch(async () =>
|
||||||
|
(
|
||||||
|
(await page
|
||||||
|
.getByRole("textbox", { name: /error details/i })
|
||||||
|
.first()
|
||||||
|
.textContent()
|
||||||
|
.catch(() => "")) ?? ""
|
||||||
|
).trim(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return [title ? "Error boundary" : "", description ?? "", detail ?? ""].filter(Boolean).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertHealthy(page: Page, context: string) {
|
||||||
|
const text = await errorBoundaryText(page)
|
||||||
|
if (!text) return
|
||||||
|
console.log(`[e2e:error-boundary][${context}]\n${text}`)
|
||||||
|
throw new Error(`Error boundary during ${context}\n${text}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitSidebarButton(page: Page, context: string) {
|
||||||
|
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
|
||||||
|
const boundary = page.getByRole("heading", { name: /something went wrong/i }).first()
|
||||||
|
await button.or(boundary).first().waitFor({ state: "visible", timeout: 10_000 })
|
||||||
|
await assertHealthy(page, context)
|
||||||
|
return button
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function toggleSidebar(page: Page) {
|
||||||
|
await defocus(page)
|
||||||
|
await page.keyboard.press(`${modKey}+B`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSidebar(page: Page) {
|
||||||
|
if (!(await isSidebarClosed(page))) return
|
||||||
|
|
||||||
|
const button = await waitSidebarButton(page, "openSidebar")
|
||||||
|
await button.click()
|
||||||
|
|
||||||
|
const opened = await expect(button)
|
||||||
|
.toHaveAttribute("aria-expanded", "true", { timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (opened) return
|
||||||
|
|
||||||
|
await toggleSidebar(page)
|
||||||
|
await expect(button).toHaveAttribute("aria-expanded", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeSidebar(page: Page) {
|
||||||
|
if (await isSidebarClosed(page)) return
|
||||||
|
|
||||||
|
const button = await waitSidebarButton(page, "closeSidebar")
|
||||||
|
await button.click()
|
||||||
|
|
||||||
|
const closed = await expect(button)
|
||||||
|
.toHaveAttribute("aria-expanded", "false", { timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (closed) return
|
||||||
|
|
||||||
|
await toggleSidebar(page)
|
||||||
|
await expect(button).toHaveAttribute("aria-expanded", "false")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSettings(page: Page) {
|
||||||
|
await assertHealthy(page, "openSettings")
|
||||||
|
await defocus(page)
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
|
||||||
|
|
||||||
|
const opened = await dialog
|
||||||
|
.waitFor({ state: "visible", timeout: 3000 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (opened) return dialog
|
||||||
|
|
||||||
|
await assertHealthy(page, "openSettings")
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Settings" }).first().click()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTestProject(input?: { serverUrl?: string }) {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
|
||||||
|
const id = `e2e-${path.basename(root)}`
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(root, "README.md"), `# e2e\n\n${id}\n`)
|
||||||
|
|
||||||
|
execSync("git init", { cwd: root, stdio: "ignore" })
|
||||||
|
await fs.writeFile(path.join(root, ".git", "opencode"), id)
|
||||||
|
execSync("git config core.fsmonitor false", { cwd: root, stdio: "ignore" })
|
||||||
|
execSync("git config commit.gpgsign false", { cwd: root, stdio: "ignore" })
|
||||||
|
execSync("git add -A", { cwd: root, stdio: "ignore" })
|
||||||
|
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
|
||||||
|
cwd: root,
|
||||||
|
stdio: "ignore",
|
||||||
|
})
|
||||||
|
|
||||||
|
return resolveDirectory(root, input?.serverUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupTestProject(directory: string) {
|
||||||
|
try {
|
||||||
|
execSync("git fsmonitor--daemon stop", { cwd: directory, stdio: "ignore" })
|
||||||
|
} catch {}
|
||||||
|
await fs.rm(directory, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function slugFromUrl(url: string) {
|
||||||
|
return /\/([^/]+)\/session(?:[/?#]|$)/.exec(url)?.[1] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeSession(page: Page) {
|
||||||
|
return page
|
||||||
|
.evaluate(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
const current = win.__opencode_e2e?.model?.current
|
||||||
|
if (!current) return null
|
||||||
|
return { dir: current.dir, sessionID: current.sessionID }
|
||||||
|
})
|
||||||
|
.catch(() => null as { dir?: string; sessionID?: string } | null)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSlug(page: Page, skip: string[] = []) {
|
||||||
|
let prev = ""
|
||||||
|
let next = ""
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await assertHealthy(page, "waitSlug")
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
if (skip.includes(slug)) return ""
|
||||||
|
if (slug !== prev) {
|
||||||
|
prev = slug
|
||||||
|
next = ""
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
next = slug
|
||||||
|
return slug
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveSlug(slug: string, input?: { serverUrl?: string }) {
|
||||||
|
const directory = base64Decode(slug)
|
||||||
|
if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
|
||||||
|
const resolved = await resolveDirectory(directory, input?.serverUrl)
|
||||||
|
return { directory: resolved, slug: base64Encode(resolved), raw: slug }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitDir(page: Page, directory: string, input?: { serverUrl?: string }) {
|
||||||
|
const target = await resolveDirectory(directory, input?.serverUrl)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await assertHealthy(page, "waitDir")
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return ""
|
||||||
|
return resolveSlug(slug, input)
|
||||||
|
.then((item) => item.directory)
|
||||||
|
.catch(() => "")
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(target)
|
||||||
|
return { directory: target, slug: base64Encode(target) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSession(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
sessionID?: string
|
||||||
|
serverUrl?: string
|
||||||
|
allowAnySession?: boolean
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const target = await resolveDirectory(input.directory, input.serverUrl)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await assertHealthy(page, "waitSession")
|
||||||
|
const slug = slugFromUrl(page.url())
|
||||||
|
if (!slug) return false
|
||||||
|
const resolved = await resolveSlug(slug, { serverUrl: input.serverUrl }).catch(() => undefined)
|
||||||
|
if (!resolved || resolved.directory !== target) return false
|
||||||
|
const current = sessionIDFromUrl(page.url())
|
||||||
|
if (input.sessionID && current !== input.sessionID) return false
|
||||||
|
if (!input.sessionID && !input.allowAnySession && current) return false
|
||||||
|
|
||||||
|
const state = await probeSession(page)
|
||||||
|
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||||
|
if (!input.sessionID && !input.allowAnySession && state?.sessionID) return false
|
||||||
|
if (state?.dir) {
|
||||||
|
const dir = await resolveDirectory(state.dir, input.serverUrl).catch(() => state.dir ?? "")
|
||||||
|
if (dir !== target) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return page
|
||||||
|
.locator(promptSelector)
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.catch(() => false)
|
||||||
|
},
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
return { directory: target, slug: base64Encode(target) }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSessionSaved(directory: string, sessionID: string, timeout = 30_000, serverUrl?: string) {
|
||||||
|
const sdk = createSdk(directory, serverUrl)
|
||||||
|
const target = await resolveDirectory(directory, serverUrl)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const data = await sdk.session
|
||||||
|
.get({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!data?.directory) return ""
|
||||||
|
return resolveDirectory(data.directory, serverUrl).catch(() => data.directory)
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(target)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const items = await sdk.session
|
||||||
|
.messages({ sessionID, limit: 20 })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return items.some((item) => item.info.role === "user")
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionIDFromUrl(url: string) {
|
||||||
|
const match = /\/session\/([^/?#]+)/.exec(url)
|
||||||
|
return match?.[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hoverSessionItem(page: Page, sessionID: string) {
|
||||||
|
const sessionEl = page.locator(`[data-session-id="${sessionID}"]`).last()
|
||||||
|
await expect(sessionEl).toBeVisible()
|
||||||
|
await sessionEl.hover()
|
||||||
|
return sessionEl
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSessionMoreMenu(page: Page, sessionID: string) {
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
|
||||||
|
|
||||||
|
const scroller = page.locator(".scroll-view__viewport").first()
|
||||||
|
await expect(scroller).toBeVisible()
|
||||||
|
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const menu = page
|
||||||
|
.locator(dropdownMenuContentSelector)
|
||||||
|
.filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
|
||||||
|
.filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
|
||||||
|
.filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
|
||||||
|
.first()
|
||||||
|
|
||||||
|
const opened = await menu
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (opened) return menu
|
||||||
|
|
||||||
|
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||||
|
await expect(menuTrigger).toBeVisible()
|
||||||
|
await menuTrigger.click()
|
||||||
|
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
|
||||||
|
const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click({ force: options?.force })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmDialog(page: Page, buttonName: string | RegExp) {
|
||||||
|
const dialog = page.getByRole("dialog").first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
|
||||||
|
await expect(button).toBeVisible()
|
||||||
|
await button.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openSharePopover(page: Page) {
|
||||||
|
const scroller = page.locator(".scroll-view__viewport").first()
|
||||||
|
await expect(scroller).toBeVisible()
|
||||||
|
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
|
||||||
|
await expect(menuTrigger).toBeVisible({ timeout: 30_000 })
|
||||||
|
|
||||||
|
const popoverBody = page
|
||||||
|
.locator('[data-component="popover-content"]')
|
||||||
|
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
|
||||||
|
.first()
|
||||||
|
|
||||||
|
const opened = await popoverBody
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await menuTrigger.click()
|
||||||
|
await clickMenuItem(menu, /share/i)
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
await expect(popoverBody).toBeVisible({ timeout: 30_000 })
|
||||||
|
}
|
||||||
|
return { rightSection: scroller, popoverBody }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clickListItem(
|
||||||
|
container: Locator | Page,
|
||||||
|
filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
|
||||||
|
): Promise<Locator> {
|
||||||
|
let item: Locator
|
||||||
|
|
||||||
|
if (typeof filter === "string" || filter instanceof RegExp) {
|
||||||
|
item = container.locator(listItemSelector).filter({ hasText: filter }).first()
|
||||||
|
} else if (filter.keyStartsWith) {
|
||||||
|
item = container.locator(listItemKeyStartsWithSelector(filter.keyStartsWith)).first()
|
||||||
|
} else if (filter.key) {
|
||||||
|
item = container.locator(listItemKeySelector(filter.key)).first()
|
||||||
|
} else if (filter.text) {
|
||||||
|
item = container.locator(listItemSelector).filter({ hasText: filter.text }).first()
|
||||||
|
} else {
|
||||||
|
throw new Error("Invalid filter provided to clickListItem")
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
async function status(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const data = await sdk.session
|
||||||
|
.status()
|
||||||
|
.then((x) => x.data ?? {})
|
||||||
|
.catch(() => undefined)
|
||||||
|
return data?.[sessionID]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stable(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 10_000) {
|
||||||
|
let prev = ""
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const info = await sdk.session
|
||||||
|
.get({ sessionID })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!info) return true
|
||||||
|
const next = `${info.title}:${info.time.updated ?? info.time.created}`
|
||||||
|
if (next !== prev) {
|
||||||
|
prev = next
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
{ timeout },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitSessionIdle(sdk: ReturnType<typeof createSdk>, sessionID: string, timeout = 30_000) {
|
||||||
|
await expect.poll(() => status(sdk, sessionID).then((x) => !x || x.type === "idle"), { timeout }).toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupSession(input: {
|
||||||
|
sessionID: string
|
||||||
|
directory?: string
|
||||||
|
sdk?: ReturnType<typeof createSdk>
|
||||||
|
serverUrl?: string
|
||||||
|
}) {
|
||||||
|
const sdk = input.sdk ?? (input.directory ? createSdk(input.directory, input.serverUrl) : undefined)
|
||||||
|
if (!sdk) throw new Error("cleanupSession requires sdk or directory")
|
||||||
|
await waitSessionIdle(sdk, input.sessionID, 5_000).catch(() => undefined)
|
||||||
|
const current = await status(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
if (current && current.type !== "idle") {
|
||||||
|
await sdk.session.abort({ sessionID: input.sessionID }).catch(() => undefined)
|
||||||
|
await waitSessionIdle(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
}
|
||||||
|
await stable(sdk, input.sessionID).catch(() => undefined)
|
||||||
|
await sdk.session.delete({ sessionID: input.sessionID }).catch(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withSession<T>(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
title: string,
|
||||||
|
callback: (session: { id: string; title: string }) => Promise<T>,
|
||||||
|
): Promise<T> {
|
||||||
|
const session = await sdk.session.create({ title }).then((r) => r.data)
|
||||||
|
if (!session?.id) throw new Error("Session create did not return an id")
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await callback(session)
|
||||||
|
} finally {
|
||||||
|
await cleanupSession({ sdk, sessionID: session.id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seedSystem = [
|
||||||
|
"You are seeding deterministic e2e UI state.",
|
||||||
|
"Follow the user's instruction exactly.",
|
||||||
|
"When asked to call a tool, call exactly that tool exactly once with the exact JSON input.",
|
||||||
|
"Do not call any extra tools.",
|
||||||
|
].join(" ")
|
||||||
|
|
||||||
|
const wait = async <T>(input: { probe: () => Promise<T | undefined>; timeout?: number }) => {
|
||||||
|
const timeout = input.timeout ?? 30_000
|
||||||
|
const end = Date.now() + timeout
|
||||||
|
while (Date.now() < end) {
|
||||||
|
const value = await input.probe()
|
||||||
|
if (value !== undefined) return value
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const seed = async <T>(input: {
|
||||||
|
sessionID: string
|
||||||
|
prompt: string
|
||||||
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
probe: () => Promise<T | undefined>
|
||||||
|
timeout?: number
|
||||||
|
attempts?: number
|
||||||
|
}) => {
|
||||||
|
for (let i = 0; i < (input.attempts ?? 2); i++) {
|
||||||
|
await input.sdk.session.promptAsync({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: "build",
|
||||||
|
system: seedSystem,
|
||||||
|
parts: [{ type: "text", text: input.prompt }],
|
||||||
|
})
|
||||||
|
const value = await wait({ probe: input.probe, timeout: input.timeout })
|
||||||
|
if (value !== undefined) return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSessionQuestion(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
questions: Array<{
|
||||||
|
header: string
|
||||||
|
question: string
|
||||||
|
options: Array<{ label: string; description: string }>
|
||||||
|
multiple?: boolean
|
||||||
|
custom?: boolean
|
||||||
|
}>
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const first = input.questions[0]
|
||||||
|
if (!first) throw new Error("Question seed requires at least one question")
|
||||||
|
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one question tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({ questions: input.questions })}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
"After calling the tool, wait for the user response.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 30_000,
|
||||||
|
probe: async () => {
|
||||||
|
const list = await sdk.question.list().then((x) => x.data ?? [])
|
||||||
|
return list.find((item) => item.sessionID === input.sessionID && item.questions[0]?.header === first.header)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding question request")
|
||||||
|
return { id: result.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedSessionTask(
|
||||||
|
sdk: ReturnType<typeof createSdk>,
|
||||||
|
input: {
|
||||||
|
sessionID: string
|
||||||
|
description: string
|
||||||
|
prompt: string
|
||||||
|
subagentType?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const text = [
|
||||||
|
"Your only valid response is one task tool call.",
|
||||||
|
`Use this JSON input: ${JSON.stringify({
|
||||||
|
description: input.description,
|
||||||
|
prompt: input.prompt,
|
||||||
|
subagent_type: input.subagentType ?? "general",
|
||||||
|
})}`,
|
||||||
|
"Do not output plain text.",
|
||||||
|
"Wait for the task to start and return the child session id.",
|
||||||
|
].join("\n")
|
||||||
|
|
||||||
|
const result = await seed({
|
||||||
|
sdk,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
prompt: text,
|
||||||
|
timeout: 90_000,
|
||||||
|
probe: async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID: input.sessionID, limit: 50 }).then((x) => x.data ?? [])
|
||||||
|
const part = messages
|
||||||
|
.flatMap((message) => message.parts)
|
||||||
|
.find((part) => {
|
||||||
|
if (part.type !== "tool" || part.tool !== "task") return false
|
||||||
|
if (!("state" in part) || !part.state || typeof part.state !== "object") return false
|
||||||
|
if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false
|
||||||
|
if (!("description" in part.state.input) || part.state.input.description !== input.description) return false
|
||||||
|
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object")
|
||||||
|
return false
|
||||||
|
if (!("sessionId" in part.state.metadata)) return false
|
||||||
|
return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return
|
||||||
|
if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return
|
||||||
|
if (!("sessionId" in part.state.metadata)) return
|
||||||
|
const id = part.state.metadata.sessionId
|
||||||
|
if (typeof id !== "string" || !id) return
|
||||||
|
const child = await sdk.session
|
||||||
|
.get({ sessionID: id })
|
||||||
|
.then((x) => x.data)
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (!child?.id) return
|
||||||
|
return { sessionID: id }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!result) throw new Error("Timed out seeding task tool")
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearSessionDockSeed(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const [questions, permissions] = await Promise.all([
|
||||||
|
sdk.question.list().then((x) => x.data ?? []),
|
||||||
|
sdk.permission.list().then((x) => x.data ?? []),
|
||||||
|
])
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
...questions
|
||||||
|
.filter((item) => item.sessionID === sessionID)
|
||||||
|
.map((item) => sdk.question.reject({ requestID: item.id }).catch(() => undefined)),
|
||||||
|
...permissions
|
||||||
|
.filter((item) => item.sessionID === sessionID)
|
||||||
|
.map((item) => sdk.permission.reply({ requestID: item.id, reply: "reject" }).catch(() => undefined)),
|
||||||
|
])
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openStatusPopover(page: Page) {
|
||||||
|
await defocus(page)
|
||||||
|
|
||||||
|
const rightSection = page.locator(titlebarRightSelector)
|
||||||
|
const trigger = rightSection.getByRole("button", { name: /status/i }).first()
|
||||||
|
|
||||||
|
const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
|
||||||
|
|
||||||
|
const opened = await popoverBody
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (!opened) {
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
await expect(popoverBody).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rightSection, popoverBody }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openProjectMenu(page: Page, projectSlug: string) {
|
||||||
|
await openSidebar(page)
|
||||||
|
const item = page.locator(projectSwitchSelector(projectSlug)).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.hover()
|
||||||
|
|
||||||
|
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
|
||||||
|
await expect(trigger).toHaveCount(1)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
|
||||||
|
const menu = page
|
||||||
|
.locator(dropdownMenuContentSelector)
|
||||||
|
.filter({ has: page.locator(projectCloseMenuSelector(projectSlug)) })
|
||||||
|
.first()
|
||||||
|
const close = menu.locator(projectCloseMenuSelector(projectSlug)).first()
|
||||||
|
|
||||||
|
const clicked = await trigger
|
||||||
|
.click({ force: true, timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (clicked) {
|
||||||
|
const opened = await menu
|
||||||
|
.waitFor({ state: "visible", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (opened) {
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await trigger.focus()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const opened = await menu
|
||||||
|
.waitFor({ state: "visible", timeout: 1500 })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if (opened) {
|
||||||
|
await expect(close).toBeVisible()
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to open project menu: ${projectSlug}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
|
||||||
|
const current = () =>
|
||||||
|
page
|
||||||
|
.getByRole("button", { name: "New workspace" })
|
||||||
|
.first()
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
|
||||||
|
if ((await current()) === enabled) return
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
if ((await current()) === enabled) return
|
||||||
|
}
|
||||||
|
|
||||||
|
const flip = async (timeout?: number) => {
|
||||||
|
const menu = await openProjectMenu(page, projectSlug)
|
||||||
|
const toggle = menu.locator(projectWorkspacesToggleSelector(projectSlug)).first()
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await expect(toggle).toBeEnabled({ timeout: 30_000 })
|
||||||
|
const clicked = await toggle
|
||||||
|
.click({ force: true, timeout })
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (clicked) return
|
||||||
|
await toggle.focus()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const timeout of [1500, undefined, undefined]) {
|
||||||
|
if ((await current()) === enabled) break
|
||||||
|
await flip(timeout)
|
||||||
|
.then(() => undefined)
|
||||||
|
.catch(() => undefined)
|
||||||
|
const matched = await expect
|
||||||
|
.poll(current, { timeout: 5_000 })
|
||||||
|
.toBe(enabled)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (matched) break
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((await current()) !== enabled) {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const expected = enabled ? "New workspace" : "New session"
|
||||||
|
await expect.poll(current, { timeout: 60_000 }).toBe(enabled)
|
||||||
|
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible({ timeout: 30_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
|
||||||
|
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.hover()
|
||||||
|
|
||||||
|
const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator(dropdownMenuContentSelector).first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assistantText(sdk: ReturnType<typeof createSdk>, sessionID: string) {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages
|
||||||
|
.filter((m) => m.info.role === "assistant")
|
||||||
|
.flatMap((m) => m.parts)
|
||||||
|
.filter((p) => p.type === "text")
|
||||||
|
.map((p) => p.text)
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { serverNamePattern } from "../utils"
|
||||||
|
|
||||||
|
test("home renders and shows core entrypoints", async ({ page }) => {
|
||||||
|
await page.goto("/")
|
||||||
|
const nav = page.locator('[data-component="sidebar-nav-desktop"]')
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible()
|
||||||
|
await expect(nav.getByText("No projects open")).toBeVisible()
|
||||||
|
await expect(nav.getByText("Open a project to get started")).toBeVisible()
|
||||||
|
await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("server picker dialog opens from home", async ({ page }) => {
|
||||||
|
await page.goto("/")
|
||||||
|
|
||||||
|
const trigger = page.getByRole("button", { name: serverNamePattern })
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { dirPath } from "../utils"
|
||||||
|
|
||||||
|
test("project route redirects to /session", async ({ page, directory, slug }) => {
|
||||||
|
await page.goto(dirPath(directory))
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { closeDialog, openPalette } from "../actions"
|
||||||
|
|
||||||
|
test("search palette opens and closes", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openPalette(page)
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("search palette also opens with cmd+p", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const dialog = await openPalette(page, "P")
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { serverNamePattern, serverUrls } from "../utils"
|
||||||
|
import { closeDialog, clickMenuItem } from "../actions"
|
||||||
|
|
||||||
|
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
|
||||||
|
|
||||||
|
test("can set a default server on web", async ({ page, gotoSession }) => {
|
||||||
|
await page.addInitScript((key: string) => {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(key)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, DEFAULT_SERVER_URL_KEY)
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const status = page.getByRole("button", { name: "Status" })
|
||||||
|
await expect(status).toBeVisible()
|
||||||
|
const popover = page.locator('[data-component="popover-content"]').filter({ hasText: "Manage servers" })
|
||||||
|
|
||||||
|
const ensurePopoverOpen = async () => {
|
||||||
|
if (await popover.isVisible()) return
|
||||||
|
await status.click()
|
||||||
|
await expect(popover).toBeVisible()
|
||||||
|
}
|
||||||
|
|
||||||
|
await ensurePopoverOpen()
|
||||||
|
await popover.getByRole("button", { name: "Manage servers" }).click()
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await expect(dialog.getByText(serverNamePattern).first()).toBeVisible()
|
||||||
|
|
||||||
|
const menuTrigger = dialog.locator('[data-slot="dropdown-menu-trigger"]').first()
|
||||||
|
await expect(menuTrigger).toBeVisible()
|
||||||
|
await menuTrigger.click({ force: true })
|
||||||
|
|
||||||
|
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
|
||||||
|
await expect(menu).toBeVisible()
|
||||||
|
await clickMenuItem(menu, /set as default/i)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () =>
|
||||||
|
serverUrls.includes((await page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)) ?? ""),
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
await expect(dialog.getByText("Default", { exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
|
||||||
|
await ensurePopoverOpen()
|
||||||
|
|
||||||
|
const serverRow = popover.locator("button").filter({ hasText: serverNamePattern }).first()
|
||||||
|
await expect(serverRow).toBeVisible()
|
||||||
|
await expect(serverRow.getByText("Default", { exact: true })).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
|
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
|
||||||
|
const title = `e2e smoke ${Date.now()}`
|
||||||
|
|
||||||
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("hello from e2e")
|
||||||
|
await expect(prompt).toContainText("hello from e2e")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { defocus, openSidebar, withSession } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const stamp = Date.now()
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
|
||||||
|
await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
|
||||||
|
await gotoSession(one.id)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
|
await expect(link).toBeVisible()
|
||||||
|
await link.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
const back = page.getByRole("button", { name: "Back" })
|
||||||
|
const forward = page.getByRole("button", { name: "Forward" })
|
||||||
|
|
||||||
|
await expect(back).toBeVisible()
|
||||||
|
await expect(back).toBeEnabled()
|
||||||
|
await back.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await expect(forward).toBeVisible()
|
||||||
|
await expect(forward).toBeEnabled()
|
||||||
|
await forward.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("titlebar forward is cleared after branching history from sidebar", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const stamp = Date.now()
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e titlebar history a ${stamp}`, async (a) => {
|
||||||
|
await withSession(sdk, `e2e titlebar history b ${stamp}`, async (b) => {
|
||||||
|
await withSession(sdk, `e2e titlebar history c ${stamp}`, async (c) => {
|
||||||
|
await gotoSession(a.id)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const second = page.locator(`[data-session-id="${b.id}"] a`).first()
|
||||||
|
await expect(second).toBeVisible()
|
||||||
|
await second.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${b.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
const back = page.getByRole("button", { name: "Back" })
|
||||||
|
const forward = page.getByRole("button", { name: "Forward" })
|
||||||
|
|
||||||
|
await expect(back).toBeVisible()
|
||||||
|
await expect(back).toBeEnabled()
|
||||||
|
await back.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${a.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const third = page.locator(`[data-session-id="${c.id}"] a`).first()
|
||||||
|
await expect(third).toBeVisible()
|
||||||
|
await third.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${c.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await expect(forward).toBeVisible()
|
||||||
|
await expect(forward).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("keyboard shortcuts navigate titlebar history", async ({ page, slug, sdk, gotoSession }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const stamp = Date.now()
|
||||||
|
|
||||||
|
await withSession(sdk, `e2e titlebar shortcuts 1 ${stamp}`, async (one) => {
|
||||||
|
await withSession(sdk, `e2e titlebar shortcuts 2 ${stamp}`, async (two) => {
|
||||||
|
await gotoSession(one.id)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
|
||||||
|
await expect(link).toBeVisible()
|
||||||
|
await link.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await defocus(page)
|
||||||
|
await page.keyboard.press(`${modKey}+[`)
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
|
||||||
|
await defocus(page)
|
||||||
|
await page.keyboard.press(`${modKey}+]`)
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
|
||||||
|
await expect(page.locator(promptSelector)).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { spawn } from "node:child_process"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
import net from "node:net"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { fileURLToPath } from "node:url"
|
||||||
|
|
||||||
|
type Handle = {
|
||||||
|
url: string
|
||||||
|
stop: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function freePort() {
|
||||||
|
return new Promise<number>((resolve, reject) => {
|
||||||
|
const server = net.createServer()
|
||||||
|
server.once("error", reject)
|
||||||
|
server.listen(0, () => {
|
||||||
|
const address = server.address()
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
server.close(() => reject(new Error("Failed to acquire a free port")))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) reject(err)
|
||||||
|
else resolve(address.port)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForHealth(url: string, probe = "/global/health") {
|
||||||
|
const end = Date.now() + 120_000
|
||||||
|
let last = ""
|
||||||
|
while (Date.now() < end) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}${probe}`)
|
||||||
|
if (res.ok) return
|
||||||
|
last = `status ${res.status}`
|
||||||
|
} catch (err) {
|
||||||
|
last = err instanceof Error ? err.message : String(err)
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 250))
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function done(proc: ReturnType<typeof spawn>) {
|
||||||
|
return proc.exitCode !== null || proc.signalCode !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
|
||||||
|
if (done(proc)) return
|
||||||
|
await Promise.race([
|
||||||
|
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
|
||||||
|
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOG_CAP = 100
|
||||||
|
|
||||||
|
function cap(input: string[]) {
|
||||||
|
if (input.length > LOG_CAP) input.splice(0, input.length - LOG_CAP)
|
||||||
|
}
|
||||||
|
|
||||||
|
function tail(input: string[]) {
|
||||||
|
return input.slice(-40).join("")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startBackend(label: string, input?: { llmUrl?: string }): Promise<Handle> {
|
||||||
|
const port = await freePort()
|
||||||
|
const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), `opencode-e2e-${label}-`))
|
||||||
|
const appDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
|
||||||
|
const repoDir = path.resolve(appDir, "../..")
|
||||||
|
const opencodeDir = path.join(repoDir, "packages", "opencode")
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
|
||||||
|
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||||
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
|
||||||
|
OPENCODE_TEST_HOME: path.join(sandbox, "home"),
|
||||||
|
XDG_DATA_HOME: path.join(sandbox, "share"),
|
||||||
|
XDG_CACHE_HOME: path.join(sandbox, "cache"),
|
||||||
|
XDG_CONFIG_HOME: path.join(sandbox, "config"),
|
||||||
|
XDG_STATE_HOME: path.join(sandbox, "state"),
|
||||||
|
OPENCODE_CLIENT: "app",
|
||||||
|
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||||
|
OPENCODE_E2E_LLM_URL: input?.llmUrl,
|
||||||
|
} satisfies Record<string, string | undefined>
|
||||||
|
const out: string[] = []
|
||||||
|
const err: string[] = []
|
||||||
|
const proc = spawn(
|
||||||
|
"bun",
|
||||||
|
["run", "--conditions=browser", "./src/index.ts", "serve", "--port", String(port), "--hostname", "127.0.0.1"],
|
||||||
|
{
|
||||||
|
cwd: opencodeDir,
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
proc.stdout?.on("data", (chunk) => {
|
||||||
|
out.push(String(chunk))
|
||||||
|
cap(out)
|
||||||
|
})
|
||||||
|
proc.stderr?.on("data", (chunk) => {
|
||||||
|
err.push(String(chunk))
|
||||||
|
cap(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = `http://127.0.0.1:${port}`
|
||||||
|
try {
|
||||||
|
await waitForHealth(url)
|
||||||
|
} catch (error) {
|
||||||
|
proc.kill("SIGTERM")
|
||||||
|
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
throw new Error(
|
||||||
|
[
|
||||||
|
`Failed to start isolated e2e backend for ${label}`,
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
tail(out),
|
||||||
|
tail(err),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("\n"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
async stop() {
|
||||||
|
if (!done(proc)) {
|
||||||
|
proc.kill("SIGTERM")
|
||||||
|
await waitExit(proc)
|
||||||
|
}
|
||||||
|
if (!done(proc)) {
|
||||||
|
proc.kill("SIGKILL")
|
||||||
|
await waitExit(proc)
|
||||||
|
}
|
||||||
|
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("ctrl+l focuses the prompt", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator("main").click({ position: { x: 5, y: 5 } })
|
||||||
|
await expect(prompt).not.toBeFocused()
|
||||||
|
|
||||||
|
await page.keyboard.press("Control+L")
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
const expanded = async (el: { getAttribute: (name: string) => Promise<string | null> }) => {
|
||||||
|
const value = await el.getAttribute("aria-expanded")
|
||||||
|
if (value !== "true" && value !== "false") throw new Error(`Expected aria-expanded to be true|false, got: ${value}`)
|
||||||
|
return value === "true"
|
||||||
|
}
|
||||||
|
|
||||||
|
test("review panel can be toggled via keybind", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const reviewPanel = page.locator("#review-panel")
|
||||||
|
|
||||||
|
const treeToggle = page.getByRole("button", { name: "Toggle file tree" }).first()
|
||||||
|
await expect(treeToggle).toBeVisible()
|
||||||
|
if (await expanded(treeToggle)) await treeToggle.click()
|
||||||
|
await expect(treeToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
|
const reviewToggle = page.getByRole("button", { name: "Toggle review" }).first()
|
||||||
|
await expect(reviewToggle).toBeVisible()
|
||||||
|
if (await expanded(reviewToggle)) await reviewToggle.click()
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(reviewPanel).toHaveAttribute("aria-hidden", "false")
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+Shift+R`)
|
||||||
|
await expect(reviewToggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
await expect(reviewPanel).toHaveAttribute("aria-hidden", "true")
|
||||||
|
})
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
test("mod+w closes the active file tab", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
await expect(page.locator('[data-slash-id="file.open"]').first()).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await dialog.getByRole("textbox").first().fill("package.json")
|
||||||
|
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||||
|
await expect(item).toBeVisible({ timeout: 30_000 })
|
||||||
|
await item.click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: "package.json" }).first()
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
await page.keyboard.press(`${modKey}+W`)
|
||||||
|
await expect(page.getByRole("tab", { name: "package.json" })).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const input = dialog.getByRole("textbox").first()
|
||||||
|
await input.fill("package.json")
|
||||||
|
|
||||||
|
const item = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
|
||||||
|
await expect(item).toBeVisible({ timeout: 30_000 })
|
||||||
|
await item.click()
|
||||||
|
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
|
await expect(tabs.locator('[data-slot="tabs-trigger"]').first()).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
|
||||||
|
test("file tree can expand folders and open a file", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const toggle = page.getByRole("button", { name: "Toggle file tree" })
|
||||||
|
const panel = page.locator("#file-tree-panel")
|
||||||
|
const treeTabs = panel.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]')
|
||||||
|
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
if ((await toggle.getAttribute("aria-expanded")) !== "true") await toggle.click()
|
||||||
|
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(panel).toBeVisible()
|
||||||
|
await expect(treeTabs).toBeVisible()
|
||||||
|
|
||||||
|
const allTab = treeTabs.getByRole("tab", { name: /^all files$/i })
|
||||||
|
await expect(allTab).toBeVisible()
|
||||||
|
await allTab.click()
|
||||||
|
await expect(allTab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
const tree = treeTabs.locator('[data-slot="tabs-content"]:not([hidden])')
|
||||||
|
await expect(tree).toBeVisible()
|
||||||
|
|
||||||
|
const expand = async (name: string) => {
|
||||||
|
const folder = tree.getByRole("button", { name, exact: true }).first()
|
||||||
|
await expect(folder).toBeVisible()
|
||||||
|
await expect(folder).toHaveAttribute("aria-expanded", /true|false/)
|
||||||
|
if ((await folder.getAttribute("aria-expanded")) === "false") await folder.click()
|
||||||
|
await expect(folder).toHaveAttribute("aria-expanded", "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
await expand("packages")
|
||||||
|
await expand("app")
|
||||||
|
await expand("src")
|
||||||
|
await expand("components")
|
||||||
|
|
||||||
|
const file = tree.getByRole("button", { name: "file-tree.tsx", exact: true }).first()
|
||||||
|
await expect(file).toBeVisible()
|
||||||
|
await file.click()
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: "file-tree.tsx" })
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
await toggle.click()
|
||||||
|
await expect(toggle).toHaveAttribute("aria-expanded", "false")
|
||||||
|
|
||||||
|
await toggle.click()
|
||||||
|
await expect(toggle).toHaveAttribute("aria-expanded", "true")
|
||||||
|
await expect(allTab).toHaveAttribute("aria-selected", "true")
|
||||||
|
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
await expect(viewer).toContainText("export default function FileTree")
|
||||||
|
})
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { modKey } from "../utils"
|
||||||
|
|
||||||
|
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const input = dialog.getByRole("textbox").first()
|
||||||
|
await input.fill("package.json")
|
||||||
|
|
||||||
|
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||||
|
let index = -1
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||||
|
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||||
|
return index >= 0
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const item = items.nth(index)
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: "package.json" })
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
await expect(viewer.getByText(/"name"\s*:\s*"@opencode-ai\/app"/)).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cmd+f opens text viewer search while prompt is focused", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const input = dialog.getByRole("textbox").first()
|
||||||
|
await input.fill("package.json")
|
||||||
|
|
||||||
|
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||||
|
let index = -1
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||||
|
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||||
|
return index >= 0
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const item = items.nth(index)
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: "package.json" })
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.press(`${modKey}+f`)
|
||||||
|
|
||||||
|
const findInput = page.getByPlaceholder("Find")
|
||||||
|
await expect(findInput).toBeVisible()
|
||||||
|
await expect(findInput).toBeFocused()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cmd+f opens text viewer search while prompt is not focused", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]').first()
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const input = dialog.getByRole("textbox").first()
|
||||||
|
await input.fill("package.json")
|
||||||
|
|
||||||
|
const items = dialog.locator('[data-slot="list-item"][data-key^="file:"]')
|
||||||
|
let index = -1
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const keys = await items.evaluateAll((nodes) => nodes.map((node) => node.getAttribute("data-key") ?? ""))
|
||||||
|
index = keys.findIndex((key) => /packages[\\/]+app[\\/]+package\.json$/i.test(key.replace(/^file:/, "")))
|
||||||
|
return index >= 0
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const item = items.nth(index)
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click()
|
||||||
|
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
const tab = page.getByRole("tab", { name: "package.json" })
|
||||||
|
await expect(tab).toBeVisible()
|
||||||
|
await tab.click()
|
||||||
|
|
||||||
|
const viewer = page.locator('[data-component="file"][data-mode="text"]').first()
|
||||||
|
await expect(viewer).toBeVisible()
|
||||||
|
|
||||||
|
await viewer.click()
|
||||||
|
await page.keyboard.press(`${modKey}+f`)
|
||||||
|
|
||||||
|
const findInput = page.getByPlaceholder("Find")
|
||||||
|
await expect(findInput).toBeVisible()
|
||||||
|
await expect(findInput).toBeFocused()
|
||||||
|
})
|
||||||
@@ -0,0 +1,604 @@
|
|||||||
|
import { test as base, expect, type Page } from "@playwright/test"
|
||||||
|
import { ManagedRuntime } from "effect"
|
||||||
|
import type { E2EWindow } from "../src/testing/terminal"
|
||||||
|
import type { Item, Reply, Usage } from "../../opencode/test/lib/llm-server"
|
||||||
|
import { TestLLMServer } from "../../opencode/test/lib/llm-server"
|
||||||
|
import { startBackend } from "./backend"
|
||||||
|
import {
|
||||||
|
healthPhase,
|
||||||
|
cleanupSession,
|
||||||
|
cleanupTestProject,
|
||||||
|
createTestProject,
|
||||||
|
setHealthPhase,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
waitSession,
|
||||||
|
waitSessionIdle,
|
||||||
|
waitSessionSaved,
|
||||||
|
waitSlug,
|
||||||
|
} from "./actions"
|
||||||
|
import { promptSelector } from "./selectors"
|
||||||
|
import { createSdk, dirSlug, getWorktree, serverUrl, sessionPath } from "./utils"
|
||||||
|
|
||||||
|
type LLMFixture = {
|
||||||
|
url: string
|
||||||
|
push: (...input: (Item | Reply)[]) => Promise<void>
|
||||||
|
pushMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
...input: (Item | Reply)[]
|
||||||
|
) => Promise<void>
|
||||||
|
textMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
value: string,
|
||||||
|
opts?: { usage?: Usage },
|
||||||
|
) => Promise<void>
|
||||||
|
toolMatch: (
|
||||||
|
match: (hit: { url: URL; body: Record<string, unknown> }) => boolean,
|
||||||
|
name: string,
|
||||||
|
input: unknown,
|
||||||
|
) => Promise<void>
|
||||||
|
text: (value: string, opts?: { usage?: Usage }) => Promise<void>
|
||||||
|
tool: (name: string, input: unknown) => Promise<void>
|
||||||
|
toolHang: (name: string, input: unknown) => Promise<void>
|
||||||
|
reason: (value: string, opts?: { text?: string; usage?: Usage }) => Promise<void>
|
||||||
|
fail: (message?: unknown) => Promise<void>
|
||||||
|
error: (status: number, body: unknown) => Promise<void>
|
||||||
|
hang: () => Promise<void>
|
||||||
|
hold: (value: string, wait: PromiseLike<unknown>) => Promise<void>
|
||||||
|
hits: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||||
|
calls: () => Promise<number>
|
||||||
|
wait: (count: number) => Promise<void>
|
||||||
|
inputs: () => Promise<Record<string, unknown>[]>
|
||||||
|
pending: () => Promise<number>
|
||||||
|
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
|
||||||
|
}
|
||||||
|
|
||||||
|
type LLMWorker = LLMFixture & {
|
||||||
|
reset: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssistantFixture = {
|
||||||
|
reply: LLMFixture["text"]
|
||||||
|
tool: LLMFixture["tool"]
|
||||||
|
toolHang: LLMFixture["toolHang"]
|
||||||
|
reason: LLMFixture["reason"]
|
||||||
|
fail: LLMFixture["fail"]
|
||||||
|
error: LLMFixture["error"]
|
||||||
|
hang: LLMFixture["hang"]
|
||||||
|
hold: LLMFixture["hold"]
|
||||||
|
calls: LLMFixture["calls"]
|
||||||
|
pending: LLMFixture["pending"]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const settingsKey = "settings.v3"
|
||||||
|
|
||||||
|
const seedModel = (() => {
|
||||||
|
const [providerID = "opencode", modelID = "big-pickle"] = (
|
||||||
|
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
|
||||||
|
).split("/")
|
||||||
|
return {
|
||||||
|
providerID: providerID || "opencode",
|
||||||
|
modelID: modelID || "big-pickle",
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
function clean(value: string | null) {
|
||||||
|
return (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function visit(page: Page, url: string) {
|
||||||
|
let err: unknown
|
||||||
|
for (const _ of [0, 1, 2]) {
|
||||||
|
try {
|
||||||
|
await page.goto(url)
|
||||||
|
return
|
||||||
|
} catch (cause) {
|
||||||
|
err = cause
|
||||||
|
if (!String(cause).includes("ERR_CONNECTION_REFUSED")) throw cause
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
|
||||||
|
async function promptSend(page: Page) {
|
||||||
|
return page
|
||||||
|
.evaluate(() => {
|
||||||
|
const win = window as E2EWindow
|
||||||
|
const sent = win.__opencode_e2e?.prompt?.sent
|
||||||
|
return {
|
||||||
|
started: sent?.started ?? 0,
|
||||||
|
count: sent?.count ?? 0,
|
||||||
|
sessionID: sent?.sessionID,
|
||||||
|
directory: sent?.directory,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => ({ started: 0, count: 0, sessionID: undefined, directory: undefined }))
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectHandle = {
|
||||||
|
directory: string
|
||||||
|
slug: string
|
||||||
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
|
trackSession: (sessionID: string, directory?: string) => void
|
||||||
|
trackDirectory: (directory: string) => void
|
||||||
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectOptions = {
|
||||||
|
extra?: string[]
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
setup?: (directory: string) => Promise<void>
|
||||||
|
beforeGoto?: (project: { directory: string; sdk: ReturnType<typeof createSdk> }) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectFixture = ProjectHandle & {
|
||||||
|
open: (options?: ProjectOptions) => Promise<void>
|
||||||
|
prompt: (text: string) => Promise<string>
|
||||||
|
user: (text: string) => Promise<string>
|
||||||
|
shell: (cmd: string) => Promise<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestFixtures = {
|
||||||
|
llm: LLMFixture
|
||||||
|
assistant: AssistantFixture
|
||||||
|
project: ProjectFixture
|
||||||
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
type WorkerFixtures = {
|
||||||
|
_llm: LLMWorker
|
||||||
|
backend: {
|
||||||
|
url: string
|
||||||
|
sdk: (directory?: string) => ReturnType<typeof createSdk>
|
||||||
|
}
|
||||||
|
directory: string
|
||||||
|
slug: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const test = base.extend<TestFixtures, WorkerFixtures>({
|
||||||
|
_llm: [
|
||||||
|
async ({}, use) => {
|
||||||
|
const rt = ManagedRuntime.make(TestLLMServer.layer)
|
||||||
|
try {
|
||||||
|
const svc = await rt.runPromise(TestLLMServer.asEffect())
|
||||||
|
await use({
|
||||||
|
url: svc.url,
|
||||||
|
push: (...input) => rt.runPromise(svc.push(...input)),
|
||||||
|
pushMatch: (match, ...input) => rt.runPromise(svc.pushMatch(match, ...input)),
|
||||||
|
textMatch: (match, value, opts) => rt.runPromise(svc.textMatch(match, value, opts)),
|
||||||
|
toolMatch: (match, name, input) => rt.runPromise(svc.toolMatch(match, name, input)),
|
||||||
|
text: (value, opts) => rt.runPromise(svc.text(value, opts)),
|
||||||
|
tool: (name, input) => rt.runPromise(svc.tool(name, input)),
|
||||||
|
toolHang: (name, input) => rt.runPromise(svc.toolHang(name, input)),
|
||||||
|
reason: (value, opts) => rt.runPromise(svc.reason(value, opts)),
|
||||||
|
fail: (message) => rt.runPromise(svc.fail(message)),
|
||||||
|
error: (status, body) => rt.runPromise(svc.error(status, body)),
|
||||||
|
hang: () => rt.runPromise(svc.hang),
|
||||||
|
hold: (value, wait) => rt.runPromise(svc.hold(value, wait)),
|
||||||
|
reset: () => rt.runPromise(svc.reset),
|
||||||
|
hits: () => rt.runPromise(svc.hits),
|
||||||
|
calls: () => rt.runPromise(svc.calls),
|
||||||
|
wait: (count) => rt.runPromise(svc.wait(count)),
|
||||||
|
inputs: () => rt.runPromise(svc.inputs),
|
||||||
|
pending: () => rt.runPromise(svc.pending),
|
||||||
|
misses: () => rt.runPromise(svc.misses),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
|
backend: [
|
||||||
|
async ({ _llm }, use, workerInfo) => {
|
||||||
|
const handle = await startBackend(`w${workerInfo.workerIndex}`, { llmUrl: _llm.url })
|
||||||
|
try {
|
||||||
|
await use({
|
||||||
|
url: handle.url,
|
||||||
|
sdk: (directory?: string) => createSdk(directory, handle.url),
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
await handle.stop()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
|
llm: async ({ _llm }, use) => {
|
||||||
|
await _llm.reset()
|
||||||
|
await use({
|
||||||
|
url: _llm.url,
|
||||||
|
push: _llm.push,
|
||||||
|
pushMatch: _llm.pushMatch,
|
||||||
|
textMatch: _llm.textMatch,
|
||||||
|
toolMatch: _llm.toolMatch,
|
||||||
|
text: _llm.text,
|
||||||
|
tool: _llm.tool,
|
||||||
|
toolHang: _llm.toolHang,
|
||||||
|
reason: _llm.reason,
|
||||||
|
fail: _llm.fail,
|
||||||
|
error: _llm.error,
|
||||||
|
hang: _llm.hang,
|
||||||
|
hold: _llm.hold,
|
||||||
|
hits: _llm.hits,
|
||||||
|
calls: _llm.calls,
|
||||||
|
wait: _llm.wait,
|
||||||
|
inputs: _llm.inputs,
|
||||||
|
pending: _llm.pending,
|
||||||
|
misses: _llm.misses,
|
||||||
|
})
|
||||||
|
const pending = await _llm.pending()
|
||||||
|
if (pending > 0) {
|
||||||
|
throw new Error(`TestLLMServer still has ${pending} queued response(s) after the test finished`)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
assistant: async ({ llm }, use) => {
|
||||||
|
await use({
|
||||||
|
reply: llm.text,
|
||||||
|
tool: llm.tool,
|
||||||
|
toolHang: llm.toolHang,
|
||||||
|
reason: llm.reason,
|
||||||
|
fail: llm.fail,
|
||||||
|
error: llm.error,
|
||||||
|
hang: llm.hang,
|
||||||
|
hold: llm.hold,
|
||||||
|
calls: llm.calls,
|
||||||
|
pending: llm.pending,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
let boundary: string | undefined
|
||||||
|
setHealthPhase(page, "test")
|
||||||
|
const consoleHandler = (msg: { text(): string }) => {
|
||||||
|
const text = msg.text()
|
||||||
|
if (!text.includes("[e2e:error-boundary]")) return
|
||||||
|
if (healthPhase(page) === "cleanup") {
|
||||||
|
console.warn(`[e2e:error-boundary][cleanup-warning]\n${text}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
boundary ||= text
|
||||||
|
console.log(text)
|
||||||
|
}
|
||||||
|
const pageErrorHandler = (err: Error) => {
|
||||||
|
console.log(`[e2e:pageerror] ${err.stack || err.message}`)
|
||||||
|
}
|
||||||
|
page.on("console", consoleHandler)
|
||||||
|
page.on("pageerror", pageErrorHandler)
|
||||||
|
await use(page)
|
||||||
|
page.off("console", consoleHandler)
|
||||||
|
page.off("pageerror", pageErrorHandler)
|
||||||
|
if (boundary) throw new Error(boundary)
|
||||||
|
},
|
||||||
|
directory: [
|
||||||
|
async ({ backend }, use) => {
|
||||||
|
await use(await getWorktree(backend.url))
|
||||||
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
|
slug: [
|
||||||
|
async ({ directory }, use) => {
|
||||||
|
await use(dirSlug(directory))
|
||||||
|
},
|
||||||
|
{ scope: "worker" },
|
||||||
|
],
|
||||||
|
sdk: async ({ directory, backend }, use) => {
|
||||||
|
await use(backend.sdk(directory))
|
||||||
|
},
|
||||||
|
gotoSession: async ({ page, directory, backend }, use) => {
|
||||||
|
await seedStorage(page, { directory, serverUrl: backend.url })
|
||||||
|
|
||||||
|
const gotoSession = async (sessionID?: string) => {
|
||||||
|
await visit(page, sessionPath(directory, sessionID))
|
||||||
|
await waitSession(page, {
|
||||||
|
directory,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
allowAnySession: !sessionID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await use(gotoSession)
|
||||||
|
},
|
||||||
|
project: async ({ page, llm, backend }, use) => {
|
||||||
|
const item = makeProject(page, llm, backend)
|
||||||
|
try {
|
||||||
|
await use(item.project)
|
||||||
|
} finally {
|
||||||
|
await item.cleanup()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function makeProject(
|
||||||
|
page: Page,
|
||||||
|
llm: LLMFixture,
|
||||||
|
backend: { url: string; sdk: (directory?: string) => ReturnType<typeof createSdk> },
|
||||||
|
) {
|
||||||
|
let state:
|
||||||
|
| {
|
||||||
|
directory: string
|
||||||
|
slug: string
|
||||||
|
sdk: ReturnType<typeof createSdk>
|
||||||
|
sessions: Map<string, string>
|
||||||
|
dirs: Set<string>
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
|
||||||
|
const need = () => {
|
||||||
|
if (state) return state
|
||||||
|
throw new Error("project.open() must be called first")
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackSession = (sessionID: string, directory?: string) => {
|
||||||
|
const cur = need()
|
||||||
|
cur.sessions.set(sessionID, directory ?? cur.directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const trackDirectory = (directory: string) => {
|
||||||
|
const cur = need()
|
||||||
|
if (directory !== cur.directory) cur.dirs.add(directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
const gotoSession = async (sessionID?: string) => {
|
||||||
|
const cur = need()
|
||||||
|
await visit(page, sessionPath(cur.directory, sessionID))
|
||||||
|
await waitSession(page, {
|
||||||
|
directory: cur.directory,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
allowAnySession: !sessionID,
|
||||||
|
})
|
||||||
|
const current = sessionIDFromUrl(page.url())
|
||||||
|
if (current) trackSession(current)
|
||||||
|
}
|
||||||
|
|
||||||
|
const open = async (options?: ProjectOptions) => {
|
||||||
|
if (state) return
|
||||||
|
const directory = await createTestProject({ serverUrl: backend.url })
|
||||||
|
const sdk = backend.sdk(directory)
|
||||||
|
await options?.setup?.(directory)
|
||||||
|
await seedStorage(page, {
|
||||||
|
directory,
|
||||||
|
extra: options?.extra,
|
||||||
|
model: options?.model,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
state = {
|
||||||
|
directory,
|
||||||
|
slug: "",
|
||||||
|
sdk,
|
||||||
|
sessions: new Map(),
|
||||||
|
dirs: new Set(),
|
||||||
|
}
|
||||||
|
await options?.beforeGoto?.({ directory, sdk })
|
||||||
|
await gotoSession()
|
||||||
|
need().slug = await waitSlug(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
const send = async (text: string, input: { noReply: boolean; shell: boolean }) => {
|
||||||
|
if (input.noReply) {
|
||||||
|
const cur = need()
|
||||||
|
const state = await page.evaluate(() => {
|
||||||
|
const model = (window as E2EWindow).__opencode_e2e?.model?.current
|
||||||
|
if (!model) return null
|
||||||
|
return {
|
||||||
|
dir: model.dir,
|
||||||
|
sessionID: model.sessionID,
|
||||||
|
agent: model.agent,
|
||||||
|
model: model.model ? { providerID: model.model.providerID, modelID: model.model.modelID } : undefined,
|
||||||
|
variant: model.variant ?? undefined,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const dir = state?.dir ?? cur.directory
|
||||||
|
const sdk = backend.sdk(dir)
|
||||||
|
const sessionID = state?.sessionID
|
||||||
|
? state.sessionID
|
||||||
|
: await sdk.session.create({ directory: dir, title: "E2E Session" }).then((res) => {
|
||||||
|
if (!res.data?.id) throw new Error("Failed to create no-reply session")
|
||||||
|
return res.data.id
|
||||||
|
})
|
||||||
|
await sdk.session.prompt({
|
||||||
|
sessionID,
|
||||||
|
agent: state?.agent,
|
||||||
|
model: state?.model,
|
||||||
|
variant: state?.variant,
|
||||||
|
noReply: true,
|
||||||
|
parts: [{ type: "text", text }],
|
||||||
|
})
|
||||||
|
await visit(page, sessionPath(dir, sessionID))
|
||||||
|
const active = await waitSession(page, {
|
||||||
|
directory: dir,
|
||||||
|
sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
trackSession(sessionID, active.directory)
|
||||||
|
await waitSessionSaved(active.directory, sessionID, 90_000, backend.url)
|
||||||
|
return sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
const prev = await promptSend(page)
|
||||||
|
if (!input.noReply && !input.shell && (await llm.pending()) === 0) {
|
||||||
|
await llm.text("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector).first()
|
||||||
|
const submit = async () => {
|
||||||
|
await expect(prompt).toBeVisible()
|
||||||
|
await prompt.click()
|
||||||
|
if (input.shell) {
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
|
}
|
||||||
|
await page.keyboard.type(text)
|
||||||
|
await expect.poll(async () => clean(await prompt.textContent())).toBe(text)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
const started = await expect
|
||||||
|
.poll(async () => (await promptSend(page)).started, { timeout: 5_000 })
|
||||||
|
.toBeGreaterThan(prev.started)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
if (started) return
|
||||||
|
const send = page.getByRole("button", { name: "Send" }).first()
|
||||||
|
const enabled = await send
|
||||||
|
.isEnabled()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (enabled) {
|
||||||
|
await send.click()
|
||||||
|
} else {
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
}
|
||||||
|
await expect.poll(async () => (await promptSend(page)).started, { timeout: 5_000 }).toBeGreaterThan(prev.started)
|
||||||
|
}
|
||||||
|
|
||||||
|
await submit()
|
||||||
|
|
||||||
|
let next: { sessionID: string; directory: string } | undefined
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const sent = await promptSend(page)
|
||||||
|
if (sent.count <= prev.count) return ""
|
||||||
|
if (!sent.sessionID || !sent.directory) return ""
|
||||||
|
next = { sessionID: sent.sessionID, directory: sent.directory }
|
||||||
|
return sent.sessionID
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.not.toBe("")
|
||||||
|
|
||||||
|
if (!next) throw new Error("Failed to observe prompt submission in e2e prompt probe")
|
||||||
|
const active = await waitSession(page, {
|
||||||
|
directory: next.directory,
|
||||||
|
sessionID: next.sessionID,
|
||||||
|
serverUrl: backend.url,
|
||||||
|
})
|
||||||
|
trackSession(next.sessionID, active.directory)
|
||||||
|
if (!input.shell) {
|
||||||
|
await waitSessionSaved(active.directory, next.sessionID, 90_000, backend.url)
|
||||||
|
}
|
||||||
|
await waitSessionIdle(backend.sdk(active.directory), next.sessionID, 90_000).catch(() => undefined)
|
||||||
|
return next.sessionID
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = async (text: string) => {
|
||||||
|
return send(text, { noReply: false, shell: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = async (text: string) => {
|
||||||
|
return send(text, { noReply: true, shell: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
const shell = async (cmd: string) => {
|
||||||
|
return send(cmd, { noReply: false, shell: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const cleanup = async () => {
|
||||||
|
const cur = state
|
||||||
|
if (!cur) return
|
||||||
|
setHealthPhase(page, "cleanup")
|
||||||
|
await Promise.allSettled(
|
||||||
|
Array.from(cur.sessions, ([sessionID, directory]) =>
|
||||||
|
cleanupSession({ sessionID, directory, serverUrl: backend.url }),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
await Promise.allSettled(Array.from(cur.dirs, (directory) => cleanupTestProject(directory)))
|
||||||
|
await cleanupTestProject(cur.directory)
|
||||||
|
state = undefined
|
||||||
|
setHealthPhase(page, "test")
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
project: {
|
||||||
|
open,
|
||||||
|
prompt,
|
||||||
|
user,
|
||||||
|
shell,
|
||||||
|
gotoSession,
|
||||||
|
trackSession,
|
||||||
|
trackDirectory,
|
||||||
|
get directory() {
|
||||||
|
return need().directory
|
||||||
|
},
|
||||||
|
get slug() {
|
||||||
|
return need().slug
|
||||||
|
},
|
||||||
|
get sdk() {
|
||||||
|
return need().sdk
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cleanup,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedStorage(
|
||||||
|
page: Page,
|
||||||
|
input: {
|
||||||
|
directory: string
|
||||||
|
extra?: string[]
|
||||||
|
model?: { providerID: string; modelID: string }
|
||||||
|
serverUrl?: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const origin = input.serverUrl ?? serverUrl
|
||||||
|
await page.addInitScript(
|
||||||
|
(args: {
|
||||||
|
directory: string
|
||||||
|
serverUrl: string
|
||||||
|
extra: string[]
|
||||||
|
model: { providerID: string; modelID: string }
|
||||||
|
}) => {
|
||||||
|
const key = "opencode.global.dat:server"
|
||||||
|
const raw = localStorage.getItem(key)
|
||||||
|
const parsed = (() => {
|
||||||
|
if (!raw) return undefined
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw) as unknown
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
|
const store = parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : {}
|
||||||
|
const list = Array.isArray(store.list) ? store.list : []
|
||||||
|
const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {}
|
||||||
|
const projects = store.projects && typeof store.projects === "object" ? store.projects : {}
|
||||||
|
const next = { ...(projects as Record<string, unknown>) }
|
||||||
|
const nextList = list.includes(args.serverUrl) ? list : [args.serverUrl, ...list]
|
||||||
|
|
||||||
|
const add = (origin: string, directory: string) => {
|
||||||
|
const current = next[origin]
|
||||||
|
const items = Array.isArray(current) ? current : []
|
||||||
|
const existing = items.filter(
|
||||||
|
(p): p is { worktree: string; expanded?: boolean } =>
|
||||||
|
!!p &&
|
||||||
|
typeof p === "object" &&
|
||||||
|
"worktree" in p &&
|
||||||
|
typeof (p as { worktree?: unknown }).worktree === "string",
|
||||||
|
)
|
||||||
|
if (existing.some((p) => p.worktree === directory)) return
|
||||||
|
next[origin] = [{ worktree: directory, expanded: true }, ...existing]
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const directory of [args.directory, ...args.extra]) {
|
||||||
|
add("local", directory)
|
||||||
|
add(args.serverUrl, directory)
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(key, JSON.stringify({ list: nextList, projects: next, lastProject }))
|
||||||
|
localStorage.setItem("opencode.settings.dat:defaultServerUrl", args.serverUrl)
|
||||||
|
|
||||||
|
const win = window as E2EWindow
|
||||||
|
win.__opencode_e2e = {
|
||||||
|
...win.__opencode_e2e,
|
||||||
|
model: { enabled: true },
|
||||||
|
prompt: { enabled: true },
|
||||||
|
terminal: { enabled: true, terminals: {} },
|
||||||
|
}
|
||||||
|
localStorage.setItem("opencode.global.dat:model", JSON.stringify({ recent: [args.model], user: [], variant: {} }))
|
||||||
|
},
|
||||||
|
{ directory: input.directory, serverUrl: origin, extra: input.extra ?? [], model: input.model ?? seedModel },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { expect }
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { clickListItem } from "../actions"
|
||||||
|
|
||||||
|
test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/model")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="model.choose"]')
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
const input = dialog.getByRole("textbox").first()
|
||||||
|
|
||||||
|
const selected = dialog.locator('[data-slot="list-item"][data-selected="true"]').first()
|
||||||
|
await expect(selected).toBeVisible()
|
||||||
|
|
||||||
|
const other = dialog.locator('[data-slot="list-item"]:not([data-selected="true"])').first()
|
||||||
|
const target = (await other.count()) > 0 ? other : selected
|
||||||
|
|
||||||
|
const key = await target.getAttribute("data-key")
|
||||||
|
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||||
|
|
||||||
|
const model = key.split(":").slice(1).join(":")
|
||||||
|
|
||||||
|
await input.fill(model)
|
||||||
|
|
||||||
|
await clickListItem(dialog, { key })
|
||||||
|
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/model")
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialogAgain = page.getByRole("dialog")
|
||||||
|
await expect(dialogAgain).toBeVisible()
|
||||||
|
await expect(dialogAgain.locator(`[data-slot="list-item"][data-key="${key}"][data-selected="true"]`)).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { closeDialog, openSettings, clickListItem } from "../actions"
|
||||||
|
|
||||||
|
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/model")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="model.choose"]')
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const picker = page.getByRole("dialog")
|
||||||
|
await expect(picker).toBeVisible()
|
||||||
|
|
||||||
|
const target = picker.locator('[data-slot="list-item"]').first()
|
||||||
|
await expect(target).toBeVisible()
|
||||||
|
|
||||||
|
const key = await target.getAttribute("data-key")
|
||||||
|
if (!key) throw new Error("Failed to resolve model key from list item")
|
||||||
|
|
||||||
|
const name = (await target.locator("span").first().innerText()).trim()
|
||||||
|
if (!name) throw new Error("Failed to resolve model name from list item")
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(picker).toHaveCount(0)
|
||||||
|
|
||||||
|
const settings = await openSettings(page)
|
||||||
|
|
||||||
|
await settings.getByRole("tab", { name: "Models" }).click()
|
||||||
|
const search = settings.getByPlaceholder("Search models")
|
||||||
|
await expect(search).toBeVisible()
|
||||||
|
await search.fill(name)
|
||||||
|
|
||||||
|
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
|
||||||
|
const input = toggle.locator('[data-slot="switch-input"]')
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||||
|
await toggle.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", "false")
|
||||||
|
|
||||||
|
await closeDialog(page, settings)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/model")
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const pickerAgain = page.getByRole("dialog")
|
||||||
|
await expect(pickerAgain).toBeVisible()
|
||||||
|
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
|
||||||
|
|
||||||
|
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(pickerAgain).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { clickMenuItem, openProjectMenu, openSidebar } from "../actions"
|
||||||
|
|
||||||
|
test("dialog edit project updates name and startup script", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
await project.open()
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const open = async () => {
|
||||||
|
const menu = await openProjectMenu(page, project.slug)
|
||||||
|
await clickMenuItem(menu, /^Edit$/i, { force: true })
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
|
||||||
|
return dialog
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = `e2e project ${Date.now()}`
|
||||||
|
const startup = `echo e2e_${Date.now()}`
|
||||||
|
|
||||||
|
const dialog = await open()
|
||||||
|
|
||||||
|
const nameInput = dialog.getByLabel("Name")
|
||||||
|
await nameInput.fill(name)
|
||||||
|
|
||||||
|
const startupInput = dialog.getByLabel("Workspace startup script")
|
||||||
|
await startupInput.fill(startup)
|
||||||
|
|
||||||
|
await dialog.getByRole("button", { name: "Save" }).click()
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
const reopened = await open()
|
||||||
|
const value = await reopened.getByLabel("Name").inputValue()
|
||||||
|
const next = await reopened.getByLabel("Workspace startup script").inputValue()
|
||||||
|
await reopened.getByRole("button", { name: "Cancel" }).click()
|
||||||
|
await expect(reopened).toHaveCount(0)
|
||||||
|
return `${value}\n${next}`
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(`${name}\n${startup}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem, openProjectMenu } from "../actions"
|
||||||
|
import { projectSwitchSelector } from "../selectors"
|
||||||
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
|
test("closing active project navigates to another open project", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await project.open({ extra: [other] })
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
|
const menu = await openProjectMenu(page, otherSlug)
|
||||||
|
await clickMenuItem(menu, /^Close$/i, { force: true })
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
() => {
|
||||||
|
const pathname = new URL(page.url()).pathname
|
||||||
|
if (new RegExp(`^/${project.slug}/session(?:/[^/]+)?/?$`).test(pathname)) return "project"
|
||||||
|
if (pathname === "/") return "home"
|
||||||
|
return ""
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toMatch(/^(project|home)$/)
|
||||||
|
|
||||||
|
await expect(page).not.toHaveURL(new RegExp(`/${otherSlug}/session(?:[/?#]|$)`))
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
return await page.locator(projectSwitchSelector(otherSlug)).count()
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import {
|
||||||
|
defocus,
|
||||||
|
createTestProject,
|
||||||
|
cleanupTestProject,
|
||||||
|
openSidebar,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
waitSession,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
|
import { projectSwitchSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
|
import { dirSlug, resolveDirectory } from "../utils"
|
||||||
|
|
||||||
|
test("can switch between projects from sidebar", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await project.open({ extra: [other] })
|
||||||
|
await defocus(page)
|
||||||
|
|
||||||
|
const currentSlug = dirSlug(project.directory)
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
|
||||||
|
|
||||||
|
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
|
||||||
|
await expect(currentButton).toBeVisible()
|
||||||
|
await currentButton.click()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("switching back to a project opens the latest workspace session", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const other = await createTestProject()
|
||||||
|
const otherSlug = dirSlug(other)
|
||||||
|
try {
|
||||||
|
await project.open({ extra: [other] })
|
||||||
|
await defocus(page)
|
||||||
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
|
const raw = await waitSlug(page, [project.slug])
|
||||||
|
const dir = base64Decode(raw)
|
||||||
|
if (!dir) throw new Error(`Failed to decode workspace slug: ${raw}`)
|
||||||
|
const space = await resolveDirectory(dir)
|
||||||
|
const next = dirSlug(space)
|
||||||
|
project.trackDirectory(space)
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const item = page.locator(`${workspaceItemSelector(next)}, ${workspaceItemSelector(raw)}`).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.hover()
|
||||||
|
|
||||||
|
const btn = page.locator(`${workspaceNewSessionSelector(next)}, ${workspaceNewSessionSelector(raw)}`).first()
|
||||||
|
await expect(btn).toBeVisible()
|
||||||
|
await btn.click({ force: true })
|
||||||
|
|
||||||
|
await waitSession(page, { directory: space })
|
||||||
|
|
||||||
|
const created = await project.user("test")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/${next}/session/${created}(?:[/?#]|$)`))
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
|
||||||
|
await expect(otherButton).toBeVisible()
|
||||||
|
await otherButton.click({ force: true })
|
||||||
|
await waitSession(page, { directory: other })
|
||||||
|
|
||||||
|
const rootButton = page.locator(projectSwitchSelector(project.slug)).first()
|
||||||
|
await expect(rootButton).toBeVisible()
|
||||||
|
await rootButton.click({ force: true })
|
||||||
|
|
||||||
|
await waitSession(page, { directory: space, sessionID: created })
|
||||||
|
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(other)
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import {
|
||||||
|
openSidebar,
|
||||||
|
resolveSlug,
|
||||||
|
sessionIDFromUrl,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
waitDir,
|
||||||
|
waitSession,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
|
import { workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
|
||||||
|
|
||||||
|
function item(space: { slug: string; raw: string }) {
|
||||||
|
return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function button(space: { slug: string; raw: string }) {
|
||||||
|
return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.locator(item(space)).first()).toBeVisible({ timeout: 60_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createWorkspace(page: Page, root: string, seen: string[]) {
|
||||||
|
await openSidebar(page)
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
|
||||||
|
const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
|
||||||
|
await waitDir(page, next.directory)
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
|
||||||
|
await waitWorkspaceReady(page, space)
|
||||||
|
|
||||||
|
const row = page.locator(item(space)).first()
|
||||||
|
await row.hover()
|
||||||
|
|
||||||
|
const next = page.locator(button(space)).first()
|
||||||
|
await expect(next).toBeVisible()
|
||||||
|
await next.click({ force: true })
|
||||||
|
|
||||||
|
await waitSession(page, { directory: space.directory })
|
||||||
|
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe("")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSessionFromWorkspace(
|
||||||
|
project: Parameters<typeof test>[0]["project"],
|
||||||
|
page: Page,
|
||||||
|
space: { slug: string; raw: string; directory: string },
|
||||||
|
text: string,
|
||||||
|
) {
|
||||||
|
await openWorkspaceNewSession(page, space)
|
||||||
|
return project.user(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("new sessions from sidebar workspace actions stay in selected workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
await project.open()
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
|
const first = await createWorkspace(page, project.slug, [])
|
||||||
|
project.trackDirectory(first.directory)
|
||||||
|
await waitWorkspaceReady(page, first)
|
||||||
|
|
||||||
|
const second = await createWorkspace(page, project.slug, [first.slug])
|
||||||
|
project.trackDirectory(second.directory)
|
||||||
|
await waitWorkspaceReady(page, second)
|
||||||
|
|
||||||
|
await createSessionFromWorkspace(project, page, first, `workspace one ${Date.now()}`)
|
||||||
|
await createSessionFromWorkspace(project, page, second, `workspace two ${Date.now()}`)
|
||||||
|
await createSessionFromWorkspace(project, page, first, `workspace one again ${Date.now()}`)
|
||||||
|
})
|
||||||
@@ -0,0 +1,368 @@
|
|||||||
|
import fs from "node:fs/promises"
|
||||||
|
import os from "node:os"
|
||||||
|
import path from "node:path"
|
||||||
|
import { base64Decode } from "@opencode-ai/util/encode"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
|
||||||
|
test.describe.configure({ mode: "serial" })
|
||||||
|
import {
|
||||||
|
cleanupTestProject,
|
||||||
|
clickMenuItem,
|
||||||
|
confirmDialog,
|
||||||
|
openSidebar,
|
||||||
|
openWorkspaceMenu,
|
||||||
|
resolveSlug,
|
||||||
|
setWorkspacesEnabled,
|
||||||
|
slugFromUrl,
|
||||||
|
waitDir,
|
||||||
|
waitSlug,
|
||||||
|
} from "../actions"
|
||||||
|
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
|
||||||
|
import { dirSlug } from "../utils"
|
||||||
|
|
||||||
|
async function setupWorkspaceTest(page: Page, project: { slug: string; trackDirectory: (directory: string) => void }) {
|
||||||
|
const rootSlug = project.slug
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
const next = await resolveSlug(await waitSlug(page, [rootSlug]))
|
||||||
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
return { rootSlug, slug: next.slug, directory: next.directory }
|
||||||
|
}
|
||||||
|
|
||||||
|
test("can enable and disable workspaces from project menu", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
|
||||||
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
await expect(page.locator(workspaceItemSelector(project.slug)).first()).toBeVisible()
|
||||||
|
|
||||||
|
await setWorkspacesEnabled(page, project.slug, false)
|
||||||
|
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
|
||||||
|
await expect(page.locator(workspaceItemSelector(project.slug))).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can create a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, project.slug, true)
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
const next = await resolveSlug(await waitSlug(page, [project.slug]))
|
||||||
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(next.slug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("non-git projects keep workspace mode disabled", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
|
||||||
|
const nonGit = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-nongit-"))
|
||||||
|
const nonGitSlug = dirSlug(nonGit)
|
||||||
|
|
||||||
|
await fs.writeFile(path.join(nonGit, "README.md"), "# e2e nongit\n")
|
||||||
|
|
||||||
|
try {
|
||||||
|
await project.open({ extra: [nonGit] })
|
||||||
|
await page.goto(`/${nonGitSlug}/session`)
|
||||||
|
|
||||||
|
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
|
||||||
|
|
||||||
|
const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
|
||||||
|
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
|
||||||
|
await expect(page.getByRole("button", { name: "Create Git repository" })).toBeVisible()
|
||||||
|
} finally {
|
||||||
|
await cleanupTestProject(nonGit)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can rename a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const { slug } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
const rename = `e2e workspace ${Date.now()}`
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Rename$/i, { force: true })
|
||||||
|
|
||||||
|
await expect(menu).toHaveCount(0)
|
||||||
|
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
const input = item.locator(inlineInputSelector).first()
|
||||||
|
const shown = await input
|
||||||
|
.isVisible()
|
||||||
|
.then((x) => x)
|
||||||
|
.catch(() => false)
|
||||||
|
if (!shown) {
|
||||||
|
const retry = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(retry, /^Rename$/i, { force: true })
|
||||||
|
await expect(retry).toHaveCount(0)
|
||||||
|
}
|
||||||
|
await expect(input).toBeVisible()
|
||||||
|
await input.fill(rename)
|
||||||
|
await input.press("Enter")
|
||||||
|
await expect(item).toContainText(rename)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can reset a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
|
||||||
|
|
||||||
|
const readme = path.join(createdDir, "README.md")
|
||||||
|
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
|
||||||
|
const original = await fs.readFile(readme, "utf8")
|
||||||
|
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
|
||||||
|
await fs.writeFile(readme, dirty, "utf8")
|
||||||
|
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await fs
|
||||||
|
.stat(extra)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const files = await project.sdk.file
|
||||||
|
.status({ directory: createdDir })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return files.length
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Reset$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Reset workspace$/i)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const files = await project.sdk.file
|
||||||
|
.status({ directory: createdDir })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [])
|
||||||
|
return files.length
|
||||||
|
},
|
||||||
|
{ timeout: 120_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
|
|
||||||
|
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 120_000 }).toBe(original)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await fs
|
||||||
|
.stat(extra)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
})
|
||||||
|
.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can reorder workspaces by drag and drop", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
const rootSlug = project.slug
|
||||||
|
|
||||||
|
const listSlugs = async () => {
|
||||||
|
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
|
||||||
|
const slugs = await nodes.evaluateAll((els) => {
|
||||||
|
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
|
||||||
|
})
|
||||||
|
return slugs
|
||||||
|
}
|
||||||
|
|
||||||
|
const waitReady = async (slug: string) => {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const item = page.locator(workspaceItemSelector(slug)).first()
|
||||||
|
try {
|
||||||
|
await item.hover({ timeout: 500 })
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = async (from: string, to: string) => {
|
||||||
|
const src = page.locator(workspaceItemSelector(from)).first()
|
||||||
|
const dst = page.locator(workspaceItemSelector(to)).first()
|
||||||
|
|
||||||
|
const a = await src.boundingBox()
|
||||||
|
const b = await dst.boundingBox()
|
||||||
|
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
|
||||||
|
|
||||||
|
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
|
||||||
|
await page.mouse.up()
|
||||||
|
}
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
|
||||||
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
|
const workspaces = [] as { directory: string; slug: string }[]
|
||||||
|
for (const _ of [0, 1]) {
|
||||||
|
const prev = slugFromUrl(page.url())
|
||||||
|
await page.getByRole("button", { name: "New workspace" }).first().click()
|
||||||
|
const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
|
||||||
|
await waitDir(page, next.directory)
|
||||||
|
project.trackDirectory(next.directory)
|
||||||
|
workspaces.push(next)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
|
||||||
|
|
||||||
|
const a = workspaces[0].slug
|
||||||
|
const b = workspaces[1].slug
|
||||||
|
|
||||||
|
await waitReady(a)
|
||||||
|
await waitReady(b)
|
||||||
|
|
||||||
|
const list = async () => {
|
||||||
|
const slugs = await listSlugs()
|
||||||
|
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const slugs = await list()
|
||||||
|
return slugs.length === 2
|
||||||
|
})
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const before = await list()
|
||||||
|
const from = before[1]
|
||||||
|
const to = before[0]
|
||||||
|
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
|
||||||
|
|
||||||
|
await drag(from, to)
|
||||||
|
|
||||||
|
await expect.poll(async () => await list()).toEqual([from, to])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("can delete a workspace", async ({ page, project }) => {
|
||||||
|
await page.setViewportSize({ width: 1400, height: 800 })
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const rootSlug = project.slug
|
||||||
|
await openSidebar(page)
|
||||||
|
await setWorkspacesEnabled(page, rootSlug, true)
|
||||||
|
|
||||||
|
const created = await project.sdk.worktree.create({ directory: project.directory }).then((res) => res.data)
|
||||||
|
if (!created?.directory) throw new Error("Failed to create workspace for delete test")
|
||||||
|
|
||||||
|
const directory = created.directory
|
||||||
|
const slug = dirSlug(directory)
|
||||||
|
project.trackDirectory(directory)
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible({ timeout: 60_000 })
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await project.sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBe(true)
|
||||||
|
|
||||||
|
const menu = await openWorkspaceMenu(page, slug)
|
||||||
|
await clickMenuItem(menu, /^Delete$/i, { force: true })
|
||||||
|
await confirmDialog(page, /^Delete workspace$/i)
|
||||||
|
|
||||||
|
await expect.poll(() => base64Decode(slugFromUrl(page.url()))).toBe(project.directory)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const worktrees = await project.sdk.worktree
|
||||||
|
.list()
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
.catch(() => [] as string[])
|
||||||
|
return worktrees.includes(directory)
|
||||||
|
},
|
||||||
|
{ timeout: 60_000 },
|
||||||
|
)
|
||||||
|
.toBe(false)
|
||||||
|
|
||||||
|
await openSidebar(page)
|
||||||
|
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0, { timeout: 60_000 })
|
||||||
|
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
|
||||||
|
})
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { withSession } from "../actions"
|
||||||
|
|
||||||
|
function contextButton(page: Page) {
|
||||||
|
return page
|
||||||
|
.locator('[data-component="button"]')
|
||||||
|
.filter({ has: page.locator('[data-component="progress-circle"]').first() })
|
||||||
|
.first()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedContextSession(input: { sessionID: string; sdk: Parameters<typeof withSession>[0] }) {
|
||||||
|
await input.sdk.session.promptAsync({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
noReply: true,
|
||||||
|
parts: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "seed context",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
const messages = await input.sdk.session
|
||||||
|
.messages({ sessionID: input.sessionID, limit: 1 })
|
||||||
|
.then((r) => r.data ?? [])
|
||||||
|
return messages.length
|
||||||
|
})
|
||||||
|
.toBeGreaterThan(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
|
||||||
|
const title = `e2e smoke context ${Date.now()}`
|
||||||
|
|
||||||
|
await withSession(sdk, title, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
const trigger = contextButton(page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
|
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("context panel can be closed from the context tab close action", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e context toggle ${Date.now()}`, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
|
||||||
|
const trigger = contextButton(page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
|
||||||
|
const context = tabs.getByRole("tab", { name: "Context" })
|
||||||
|
await expect(context).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "Close tab" }).first().click()
|
||||||
|
await expect(context).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("context panel can open file picker from context actions", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e context tabs ${Date.now()}`, async (session) => {
|
||||||
|
await seedContextSession({ sessionID: session.id, sdk })
|
||||||
|
await gotoSession(session.id)
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
|
||||||
|
const trigger = contextButton(page)
|
||||||
|
await expect(trigger).toBeVisible()
|
||||||
|
await trigger.click()
|
||||||
|
|
||||||
|
await expect(page.getByRole("tab", { name: "Context" })).toBeVisible()
|
||||||
|
await page.getByRole("button", { name: "Open file" }).first().click()
|
||||||
|
|
||||||
|
const dialog = page
|
||||||
|
.getByRole("dialog")
|
||||||
|
.filter({ has: page.getByPlaceholder(/search files/i) })
|
||||||
|
.first()
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
type Hit = { body: Record<string, unknown> }
|
||||||
|
|
||||||
|
export function bodyText(hit: Hit) {
|
||||||
|
return JSON.stringify(hit.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Match requests whose body contains the exact serialized tool input.
|
||||||
|
* The seed prompts embed JSON.stringify(input) in the prompt text, which
|
||||||
|
* gets escaped again inside the JSON body — so we double-escape to match.
|
||||||
|
*/
|
||||||
|
export function inputMatch(input: unknown) {
|
||||||
|
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
|
||||||
|
return (hit: Hit) => bodyText(hit).includes(escaped)
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { assistantText, withSession } from "../actions"
|
||||||
|
|
||||||
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
|
||||||
|
// Regression test for Issue #12453: the synchronous POST /message endpoint holds
|
||||||
|
// the connection open while the agent works, causing "Failed to fetch" over
|
||||||
|
// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately.
|
||||||
|
test("prompt succeeds when sync message endpoint is unreachable", async ({ page, project, assistant }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
// Simulate Tailscale/VPN killing the long-lived sync connection
|
||||||
|
await page.route("**/session/*/message", (route) => route.abort("connectionfailed"))
|
||||||
|
|
||||||
|
const token = `E2E_ASYNC_${Date.now()}`
|
||||||
|
await project.open()
|
||||||
|
await assistant.reply(token)
|
||||||
|
const sessionID = await project.prompt(`Reply with exactly: ${token}`)
|
||||||
|
|
||||||
|
await expect.poll(() => assistant.calls()).toBeGreaterThanOrEqual(1)
|
||||||
|
await expect.poll(() => assistantText(project.sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("failed prompt send restores the composer input", async ({ page, sdk, gotoSession }) => {
|
||||||
|
await withSession(sdk, `e2e prompt failure ${Date.now()}`, async (session) => {
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
const value = `restore ${Date.now()}`
|
||||||
|
|
||||||
|
await page.route(`**/session/${session.id}/prompt_async`, (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
status: 500,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify({ message: "e2e prompt failure" }),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await gotoSession(session.id)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(value)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
await expect.poll(async () => text(await prompt.textContent())).toBe(value)
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID: session.id, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
return messages.length
|
||||||
|
},
|
||||||
|
{ timeout: 15_000 },
|
||||||
|
)
|
||||||
|
.toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("dropping text/plain file: uri inserts a file pill", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
|
||||||
|
const path = process.platform === "win32" ? "C:\\opencode-e2e-drop.txt" : "/tmp/opencode-e2e-drop.txt"
|
||||||
|
const dt = await page.evaluateHandle((text) => {
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
dt.setData("text/plain", text)
|
||||||
|
return dt
|
||||||
|
}, `file:${path}`)
|
||||||
|
|
||||||
|
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
|
||||||
|
|
||||||
|
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
|
||||||
|
await expect(pill).toBeVisible()
|
||||||
|
await expect(pill).toHaveAttribute("data-path", path)
|
||||||
|
})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("dropping an image file adds an attachment", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
|
||||||
|
const png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO3+4uQAAAAASUVORK5CYII="
|
||||||
|
const dt = await page.evaluateHandle((b64) => {
|
||||||
|
const dt = new DataTransfer()
|
||||||
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))
|
||||||
|
const file = new File([bytes], "drop.png", { type: "image/png" })
|
||||||
|
dt.items.add(file)
|
||||||
|
return dt
|
||||||
|
}, png)
|
||||||
|
|
||||||
|
await page.dispatchEvent("body", "drop", { dataTransfer: dt })
|
||||||
|
|
||||||
|
const img = page.locator('img[alt="drop.png"]').first()
|
||||||
|
await expect(img).toBeVisible()
|
||||||
|
|
||||||
|
const remove = page.getByRole("button", { name: "Remove attachment" }).first()
|
||||||
|
await expect(remove).toBeVisible()
|
||||||
|
|
||||||
|
await img.hover()
|
||||||
|
await remove.click()
|
||||||
|
await expect(page.locator('img[alt="drop.png"]')).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import type { Locator, Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptAgentSelector, promptModelSelector, promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
type Probe = {
|
||||||
|
agent?: string
|
||||||
|
model?: { providerID: string; modelID: string; name?: string }
|
||||||
|
models?: Array<{ providerID: string; modelID: string; name: string }>
|
||||||
|
agents?: Array<{ name: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probe(page: Page): Promise<Probe | null> {
|
||||||
|
return page.evaluate(() => {
|
||||||
|
const win = window as Window & {
|
||||||
|
__opencode_e2e?: {
|
||||||
|
model?: {
|
||||||
|
current?: Probe
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return win.__opencode_e2e?.model?.current ?? null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function state(page: Page) {
|
||||||
|
const value = await probe(page)
|
||||||
|
if (!value) throw new Error("Failed to resolve model selection probe")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ready(page: Page) {
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
await prompt.pressSequentially("focus")
|
||||||
|
return prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
async function body(prompt: Locator) {
|
||||||
|
return prompt.evaluate((el) => (el as HTMLElement).innerText)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("agent select returns focus to the prompt", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = await ready(page)
|
||||||
|
|
||||||
|
const info = await state(page)
|
||||||
|
const next = info.agents?.map((item) => item.name).find((name) => name !== info.agent)
|
||||||
|
test.skip(!next, "only one agent available")
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
await page.locator(`${promptAgentSelector} [data-slot="select-select-trigger"]`).first().click()
|
||||||
|
|
||||||
|
const item = page.locator('[data-slot="select-select-item"]').filter({ hasText: next }).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click({ force: true })
|
||||||
|
|
||||||
|
await expect(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()).toHaveText(
|
||||||
|
next,
|
||||||
|
)
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
await prompt.pressSequentially(" agent")
|
||||||
|
await expect.poll(() => body(prompt)).toContain("focus agent")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("model select returns focus to the prompt", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = await ready(page)
|
||||||
|
|
||||||
|
const info = await state(page)
|
||||||
|
const key = info.model ? `${info.model.providerID}:${info.model.modelID}` : null
|
||||||
|
const next = info.models?.find((item) => `${item.providerID}:${item.modelID}` !== key)
|
||||||
|
test.skip(!next, "only one model available")
|
||||||
|
if (!next) return
|
||||||
|
|
||||||
|
await page.locator(`${promptModelSelector} [data-action="prompt-model"]`).first().click()
|
||||||
|
|
||||||
|
const item = page.locator(`[data-slot="list-item"][data-key="${next.providerID}:${next.modelID}"]`).first()
|
||||||
|
await expect(item).toBeVisible()
|
||||||
|
await item.click({ force: true })
|
||||||
|
|
||||||
|
await expect(page.locator(`${promptModelSelector} [data-action="prompt-model"] span`).first()).toHaveText(next.name)
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
await prompt.pressSequentially(" model")
|
||||||
|
await expect.poll(() => body(prompt)).toContain("focus model")
|
||||||
|
})
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import type { Page } from "@playwright/test"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { assistantText } from "../actions"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
import { createSdk } from "../utils"
|
||||||
|
|
||||||
|
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
|
||||||
|
type Sdk = ReturnType<typeof createSdk>
|
||||||
|
|
||||||
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
|
if (!part || typeof part !== "object") return false
|
||||||
|
if (!("type" in part) || part.type !== "tool") return false
|
||||||
|
if (!("tool" in part) || part.tool !== "bash") return false
|
||||||
|
return "state" in part
|
||||||
|
}
|
||||||
|
|
||||||
|
async function wait(page: Page, value: string) {
|
||||||
|
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reply(sdk: Sdk, sessionID: string, token: string) {
|
||||||
|
await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? [])
|
||||||
|
const part = messages
|
||||||
|
.filter((item) => item.info.role === "assistant")
|
||||||
|
.flatMap((item) => item.parts)
|
||||||
|
.filter(isBash)
|
||||||
|
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||||
|
|
||||||
|
if (!part || part.state.status !== "completed") return
|
||||||
|
return typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toContain(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("prompt history restores unsent draft with arrow navigation", async ({ page, project, assistant }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
const firstToken = `E2E_HISTORY_ONE_${Date.now()}`
|
||||||
|
const secondToken = `E2E_HISTORY_TWO_${Date.now()}`
|
||||||
|
const first = `Reply with exactly: ${firstToken}`
|
||||||
|
const second = `Reply with exactly: ${secondToken}`
|
||||||
|
const draft = `draft ${Date.now()}`
|
||||||
|
|
||||||
|
await project.open()
|
||||||
|
await assistant.reply(firstToken)
|
||||||
|
const sessionID = await project.prompt(first)
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(project.sdk, sessionID, firstToken)
|
||||||
|
|
||||||
|
await assistant.reply(secondToken)
|
||||||
|
await project.prompt(second)
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(project.sdk, sessionID, secondToken)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(draft)
|
||||||
|
await wait(page, draft)
|
||||||
|
|
||||||
|
await prompt.fill("")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, first)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, "")
|
||||||
|
})
|
||||||
|
|
||||||
|
test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
|
||||||
|
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
|
||||||
|
const normalToken = `E2E_NORMAL_${Date.now()}`
|
||||||
|
const first = `echo ${firstToken}`
|
||||||
|
const second = `echo ${secondToken}`
|
||||||
|
const normal = `Reply with exactly: ${normalToken}`
|
||||||
|
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.type(first)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
|
||||||
|
const sessionID = sessionIDFromUrl(page.url())!
|
||||||
|
await shell(sdk, sessionID, first, firstToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.type(second)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await shell(sdk, sessionID, second, secondToken)
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, first)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, second)
|
||||||
|
|
||||||
|
await page.keyboard.press("ArrowDown")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await wait(page, "")
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type(normal)
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
await wait(page, "")
|
||||||
|
await reply(sdk, sessionID, normalToken)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.press("ArrowUp")
|
||||||
|
await wait(page, normal)
|
||||||
|
})
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
const sep = process.platform === "win32" ? "\\" : "/"
|
||||||
|
const file = ["packages", "app", "package.json"].join(sep)
|
||||||
|
const filePattern = /packages[\\/]+app[\\/]+\s*package\.json/
|
||||||
|
|
||||||
|
await page.keyboard.type(`@${file}`)
|
||||||
|
|
||||||
|
const suggestion = page.getByRole("button", { name: filePattern }).first()
|
||||||
|
await expect(suggestion).toBeVisible()
|
||||||
|
await suggestion.hover()
|
||||||
|
|
||||||
|
await page.keyboard.press("Tab")
|
||||||
|
|
||||||
|
const pill = page.locator(`${promptSelector} [data-type="file"]`).first()
|
||||||
|
await expect(pill).toBeVisible()
|
||||||
|
await expect(pill).toHaveAttribute("data-path", filePattern)
|
||||||
|
|
||||||
|
await page.keyboard.type(" ok")
|
||||||
|
await expect(page.locator(promptSelector)).toContainText("ok")
|
||||||
|
})
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("shift+enter inserts a newline without submitting", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector)
|
||||||
|
await prompt.focus()
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
|
||||||
|
await prompt.pressSequentially("line one")
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
|
||||||
|
await prompt.press("Shift+Enter")
|
||||||
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
await expect(prompt).toBeFocused()
|
||||||
|
|
||||||
|
await prompt.pressSequentially("line two")
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(/\/session\/?$/)
|
||||||
|
await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
|
||||||
|
})
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { ToolPart } from "@opencode-ai/sdk/v2/client"
|
||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { closeDialog, openSettings, withSession } from "../actions"
|
||||||
|
import { promptModelSelector, promptSelector, promptVariantSelector } from "../selectors"
|
||||||
|
|
||||||
|
const isBash = (part: unknown): part is ToolPart => {
|
||||||
|
if (!part || typeof part !== "object") return false
|
||||||
|
if (!("type" in part) || part.type !== "tool") return false
|
||||||
|
if (!("tool" in part) || part.tool !== "bash") return false
|
||||||
|
return "state" in part
|
||||||
|
}
|
||||||
|
|
||||||
|
test("shell mode runs a command in the project directory", async ({ page, project }) => {
|
||||||
|
test.setTimeout(120_000)
|
||||||
|
|
||||||
|
await project.open()
|
||||||
|
const cmd = process.platform === "win32" ? "dir" : "command ls"
|
||||||
|
|
||||||
|
await withSession(project.sdk, `e2e shell ${Date.now()}`, async (session) => {
|
||||||
|
project.trackSession(session.id)
|
||||||
|
await project.gotoSession(session.id)
|
||||||
|
const dialog = await openSettings(page)
|
||||||
|
const toggle = dialog.locator('[data-action="settings-auto-accept-permissions"]').first()
|
||||||
|
const input = toggle.locator('[data-slot="switch-input"]').first()
|
||||||
|
await expect(toggle).toBeVisible()
|
||||||
|
if ((await input.getAttribute("aria-checked")) !== "true") {
|
||||||
|
await toggle.locator('[data-slot="switch-control"]').click()
|
||||||
|
await expect(input).toHaveAttribute("aria-checked", "true")
|
||||||
|
}
|
||||||
|
await closeDialog(page, dialog)
|
||||||
|
await project.shell(cmd)
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => {
|
||||||
|
const list = await project.sdk.session
|
||||||
|
.messages({ sessionID: session.id, limit: 50 })
|
||||||
|
.then((x) => x.data ?? [])
|
||||||
|
const msg = list.findLast(
|
||||||
|
(item) => item.info.role === "assistant" && "path" in item.info && item.info.path.cwd === project.directory,
|
||||||
|
)
|
||||||
|
if (!msg) return
|
||||||
|
|
||||||
|
const part = msg.parts
|
||||||
|
.filter(isBash)
|
||||||
|
.find((item) => item.state.input?.command === cmd && item.state.status === "completed")
|
||||||
|
|
||||||
|
if (!part || part.state.status !== "completed") return
|
||||||
|
const output =
|
||||||
|
typeof part.state.metadata?.output === "string" ? part.state.metadata.output : part.state.output
|
||||||
|
if (!output.includes("README.md")) return
|
||||||
|
|
||||||
|
return { cwd: project.directory, output }
|
||||||
|
},
|
||||||
|
{ timeout: 90_000 },
|
||||||
|
)
|
||||||
|
.toEqual(expect.objectContaining({ cwd: project.directory, output: expect.stringContaining("README.md") }))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("shell mode unmounts model and variant controls", async ({ page, project }) => {
|
||||||
|
await project.open()
|
||||||
|
|
||||||
|
const prompt = page.locator(promptSelector).first()
|
||||||
|
await expect(page.locator(promptModelSelector)).toHaveCount(1)
|
||||||
|
await expect(page.locator(promptVariantSelector)).toHaveCount(1)
|
||||||
|
|
||||||
|
await prompt.click()
|
||||||
|
await page.keyboard.type("!")
|
||||||
|
|
||||||
|
await expect(prompt).toHaveAttribute("aria-label", /enter shell command/i)
|
||||||
|
await expect(page.locator(promptModelSelector)).toHaveCount(0)
|
||||||
|
await expect(page.locator(promptVariantSelector)).toHaveCount(0)
|
||||||
|
})
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { test, expect } from "../fixtures"
|
||||||
|
import { promptSelector } from "../selectors"
|
||||||
|
|
||||||
|
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
|
||||||
|
await gotoSession()
|
||||||
|
|
||||||
|
await page.locator(promptSelector).click()
|
||||||
|
await page.keyboard.type("/open")
|
||||||
|
|
||||||
|
const command = page.locator('[data-slash-id="file.open"]')
|
||||||
|
await expect(command).toBeVisible()
|
||||||
|
await command.hover()
|
||||||
|
|
||||||
|
await page.keyboard.press("Enter")
|
||||||
|
|
||||||
|
const dialog = page.getByRole("dialog")
|
||||||
|
await expect(dialog).toBeVisible()
|
||||||
|
await expect(dialog.getByRole("textbox").first()).toBeVisible()
|
||||||
|
|
||||||
|
await page.keyboard.press("Escape")
|
||||||
|
await expect(dialog).toHaveCount(0)
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user