Compare commits

..

2 Commits

Author SHA1 Message Date
Aiden Cline
5e782b8a34 test: add unit test 2026-01-30 16:04:07 -06:00
Aiden Cline
c64ace2a07 fix: adjust resolve parts so that when messages with multiple @ references occur, the tool calls are properly ordered 2026-01-30 15:55:00 -06:00
400 changed files with 8070 additions and 24846 deletions

1
.github/CODEOWNERS vendored
View File

@@ -1,5 +1,4 @@
# web + desktop packages
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
packages/desktop/src-tauri/ @brendonovich
packages/desktop/ @adamdotdevin

View File

@@ -23,7 +23,6 @@ runs:
with:
app-id: ${{ inputs.opencode-app-id }}
private-key: ${{ inputs.opencode-app-secret }}
owner: ${{ github.repository_owner }}
- name: Configure git user
run: |

View File

@@ -1,12 +1,17 @@
name: beta
on:
workflow_dispatch:
schedule:
- cron: "0 * * * *"
push:
branches: [dev]
pull_request:
types: [opened, synchronize, labeled, unlabeled]
jobs:
sync:
if: |
github.event_name == 'push' ||
(github.event_name == 'pull_request' &&
contains(github.event.pull_request.labels.*.name, 'contributor'))
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write

View File

@@ -18,7 +18,6 @@ permissions:
jobs:
close-stale-prs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Close inactive PRs
uses: actions/github-script@v8
@@ -26,210 +25,59 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const DAYS_INACTIVE = 60
const MAX_RETRIES = 3
// Adaptive delay: fast for small batches, slower for large to respect
// GitHub's 80 content-generating requests/minute limit
const SMALL_BATCH_THRESHOLD = 10
const SMALL_BATCH_DELAY_MS = 1000 // 1s for daily operations (≤10 PRs)
const LARGE_BATCH_DELAY_MS = 2000 // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
const startTime = Date.now()
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
const { owner, repo } = context.repo
const dryRun = context.payload.inputs?.dryRun === "true"
const stalePrs = []
core.info(`Dry run mode: ${dryRun}`)
core.info(`Cutoff date: ${cutoff.toISOString()}`)
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
async function withRetry(fn, description = 'API call') {
let lastError
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
const result = await fn()
return result
} catch (error) {
lastError = error
const isRateLimited = error.status === 403 &&
(error.message?.includes('rate limit') || error.message?.includes('secondary'))
if (!isRateLimited) {
throw error
}
// Parse retry-after header, default to 60 seconds
const retryAfter = error.response?.headers?.['retry-after']
? parseInt(error.response.headers['retry-after'])
: 60
// Exponential backoff: retryAfter * 2^attempt
const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
await sleep(backoffMs)
}
}
core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
throw lastError
}
const query = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
pullRequests(first: 100, states: OPEN, after: $cursor) {
pageInfo {
hasNextPage
endCursor
}
nodes {
number
title
author {
login
}
createdAt
commits(last: 1) {
nodes {
commit {
committedDate
}
}
}
comments(last: 1) {
nodes {
createdAt
}
}
reviews(last: 1) {
nodes {
createdAt
}
}
}
}
}
}
`
const allPrs = []
let cursor = null
let hasNextPage = true
let pageCount = 0
while (hasNextPage) {
pageCount++
core.info(`Fetching page ${pageCount} of open PRs...`)
const result = await withRetry(
() => github.graphql(query, { owner, repo, cursor }),
`GraphQL page ${pageCount}`
)
allPrs.push(...result.repository.pullRequests.nodes)
hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
cursor = result.repository.pullRequests.pageInfo.endCursor
core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
// Delay between pagination requests (use small batch delay for reads)
if (hasNextPage) {
await sleep(SMALL_BATCH_DELAY_MS)
}
}
core.info(`Found ${allPrs.length} open pull requests`)
const stalePrs = allPrs.filter((pr) => {
const dates = [
new Date(pr.createdAt),
pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
].filter((d) => d !== null)
const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
if (!lastActivity || lastActivity > cutoff) {
core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
return false
}
core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
return true
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: "open",
per_page: 100,
sort: "updated",
direction: "asc",
})
for (const pr of prs) {
const lastUpdated = new Date(pr.updated_at)
if (lastUpdated > cutoff) {
core.info(`PR ${pr.number} is fresh`)
continue
}
stalePrs.push(pr)
}
if (!stalePrs.length) {
core.info("No stale pull requests found.")
return
}
core.info(`Found ${stalePrs.length} stale pull requests`)
// ============================================
// Close stale PRs
// ============================================
const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
? LARGE_BATCH_DELAY_MS
: SMALL_BATCH_DELAY_MS
core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
let closedCount = 0
let skippedCount = 0
for (const pr of stalePrs) {
const issue_number = pr.number
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
if (dryRun) {
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`)
continue
}
try {
// Add comment
await withRetry(
() => github.rest.issues.createComment({
owner,
repo,
issue_number,
body: closeComment,
}),
`Comment on PR #${issue_number}`
)
await github.rest.issues.createComment({
owner,
repo,
issue_number,
body: closeComment,
})
// Close PR
await withRetry(
() => github.rest.pulls.update({
owner,
repo,
pull_number: issue_number,
state: "closed",
}),
`Close PR #${issue_number}`
)
await github.rest.pulls.update({
owner,
repo,
pull_number: issue_number,
state: "closed",
})
closedCount++
core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
// Delay before processing next PR
await sleep(requestDelayMs)
} catch (error) {
skippedCount++
core.error(`Failed to close PR #${issue_number}: ${error.message}`)
}
core.info(`Closed PR #${issue_number} from ${pr.user.login}`)
}
const elapsed = Math.round((Date.now() - startTime) / 1000)
core.info(`\n========== Summary ==========`)
core.info(`Total open PRs found: ${allPrs.length}`)
core.info(`Stale PRs identified: ${stalePrs.length}`)
core.info(`PRs closed: ${closedCount}`)
core.info(`PRs skipped (errors): ${skippedCount}`)
core.info(`Elapsed time: ${elapsed}s`)
core.info(`=============================`)

View File

@@ -21,15 +21,6 @@ jobs:
with:
node-version: "24"
# Workaround for Pulumi version conflict:
# GitHub runners have Pulumi 3.212.0+ pre-installed, which removed the -root flag
# from pulumi-language-nodejs (see https://github.com/pulumi/pulumi/pull/21065).
# SST 3.17.x uses Pulumi SDK 3.210.0 which still passes -root, causing a conflict.
# Removing the system language plugin forces SST to use its bundled compatible version.
# TODO: Remove when sst supports Pulumi >3.210.0
- name: Fix Pulumi version conflict
run: sudo rm -f /usr/local/bin/pulumi-language-nodejs
- run: bun sst deploy --stage=${{ github.ref_name }}
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

View File

@@ -6,7 +6,13 @@ permissions:
on:
workflow_dispatch:
push:
branches: [dev]
paths:
- "bun.lock"
- "package.json"
- "packages/*/package.json"
- "flake.lock"
- ".github/workflows/nix-hashes.yml"
pull_request:
paths:
- "bun.lock"
- "package.json"
@@ -15,131 +21,120 @@ on:
- ".github/workflows/nix-hashes.yml"
jobs:
# Native runners required: bun install cross-compilation flags (--os/--cpu)
# do not produce byte-identical node_modules as native installs.
compute-hash:
strategy:
fail-fast: false
matrix:
include:
- system: x86_64-linux
runner: blacksmith-4vcpu-ubuntu-2404
- system: aarch64-linux
runner: blacksmith-4vcpu-ubuntu-2404-arm
- system: x86_64-darwin
runner: macos-15-intel
- system: aarch64-darwin
runner: macos-latest
runs-on: ${{ matrix.runner }}
nix-hashes:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
TITLE: node_modules hashes
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Compute node_modules hash
id: hash
env:
SYSTEM: ${{ matrix.system }}
run: |
set -euo pipefail
BUILD_LOG=$(mktemp)
trap 'rm -f "$BUILD_LOG"' EXIT
# Build with fakeHash to trigger hash mismatch and reveal correct hash
nix build ".#packages.${SYSTEM}.node_modules_updater" --no-link 2>&1 | tee "$BUILD_LOG" || true
# Extract hash from build log with portability
HASH="$(grep -oE 'sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | tail -n1 || true)"
if [ -z "$HASH" ]; then
echo "::error::Failed to compute hash for ${SYSTEM}"
cat "$BUILD_LOG"
exit 1
fi
echo "$HASH" > hash.txt
echo "Computed hash for ${SYSTEM}: $HASH"
- name: Upload hash
uses: actions/upload-artifact@v4
with:
name: hash-${{ matrix.system }}
path: hash.txt
retention-days: 1
update-hashes:
needs: compute-hash
if: github.event_name != 'pull_request'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: false
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
ref: ${{ github.ref_name }}
ref: ${{ github.head_ref || github.ref_name }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Pull latest changes
env:
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git pull --rebase --autostash origin "$BRANCH"
- name: Download hash artifacts
uses: actions/download-artifact@v4
with:
path: hashes
pattern: hash-*
- name: Update hashes.json
- name: Compute all node_modules hashes
run: |
set -euo pipefail
HASH_FILE="nix/hashes.json"
SYSTEMS="x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin"
[ -f "$HASH_FILE" ] || echo '{"nodeModules":{}}' > "$HASH_FILE"
if [ ! -f "$HASH_FILE" ]; then
mkdir -p "$(dirname "$HASH_FILE")"
echo '{"nodeModules":{}}' > "$HASH_FILE"
fi
for SYSTEM in x86_64-linux aarch64-linux x86_64-darwin aarch64-darwin; do
FILE="hashes/hash-${SYSTEM}/hash.txt"
if [ -f "$FILE" ]; then
HASH="$(tr -d '[:space:]' < "$FILE")"
echo "${SYSTEM}: ${HASH}"
jq --arg sys "$SYSTEM" --arg h "$HASH" '.nodeModules[$sys] = $h' "$HASH_FILE" > tmp.json
mv tmp.json "$HASH_FILE"
else
echo "::warning::Missing hash for ${SYSTEM}"
for SYSTEM in $SYSTEMS; do
echo "Computing hash for ${SYSTEM}..."
BUILD_LOG=$(mktemp)
trap 'rm -f "$BUILD_LOG"' EXIT
# The updater derivations use fakeHash, so they will fail and reveal the correct hash
UPDATER_ATTR=".#packages.x86_64-linux.${SYSTEM}_node_modules"
nix build "$UPDATER_ATTR" --no-link 2>&1 | tee "$BUILD_LOG" || true
CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
if [ -z "$CORRECT_HASH" ]; then
CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
fi
if [ -z "$CORRECT_HASH" ]; then
echo "Failed to determine correct node_modules hash for ${SYSTEM}."
cat "$BUILD_LOG"
exit 1
fi
echo " ${SYSTEM}: ${CORRECT_HASH}"
jq --arg sys "$SYSTEM" --arg h "$CORRECT_HASH" \
'.nodeModules[$sys] = $h' "$HASH_FILE" > "${HASH_FILE}.tmp"
mv "${HASH_FILE}.tmp" "$HASH_FILE"
done
echo "All hashes computed:"
cat "$HASH_FILE"
- name: Commit changes
- name: Commit ${{ env.TITLE }} changes
env:
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
set -euo pipefail
HASH_FILE="nix/hashes.json"
echo "Checking for changes..."
if [ -z "$(git status --short -- "$HASH_FILE")" ]; then
echo "No changes to commit"
echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
echo "Status: no changes" >> "$GITHUB_STEP_SUMMARY"
summarize() {
local status="$1"
{
echo "### Nix $TITLE"
echo ""
echo "- ref: ${GITHUB_REF_NAME}"
echo "- status: ${status}"
} >> "$GITHUB_STEP_SUMMARY"
if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
}
FILES=("$HASH_FILE")
STATUS="$(git status --short -- "${FILES[@]}" || true)"
if [ -z "$STATUS" ]; then
echo "No changes detected."
summarize "no changes"
exit 0
fi
git add "$HASH_FILE"
echo "Changes detected:"
echo "$STATUS"
git add "${FILES[@]}"
git commit -m "chore: update nix node_modules hashes"
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
git push origin HEAD:"$GITHUB_REF_NAME"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git pull --rebase --autostash origin "$BRANCH"
git push origin HEAD:"$BRANCH"
echo "Changes pushed successfully"
echo "### Nix hashes" >> "$GITHUB_STEP_SUMMARY"
echo "Status: committed $(git rev-parse --short HEAD)" >> "$GITHUB_STEP_SUMMARY"
summarize "committed $(git rev-parse --short HEAD)"

View File

@@ -103,7 +103,7 @@ jobs:
target: x86_64-pc-windows-msvc
- host: blacksmith-4vcpu-ubuntu-2404
target: x86_64-unknown-linux-gnu
- host: blacksmith-8vcpu-ubuntu-2404-arm
- host: blacksmith-4vcpu-ubuntu-2404-arm
target: aarch64-unknown-linux-gnu
runs-on: ${{ matrix.settings.host }}
steps:

View File

@@ -1,9 +1,6 @@
name: test
on:
push:
branches:
- dev
pull_request:
workflow_dispatch:
jobs:

View File

@@ -1,8 +1,6 @@
name: typecheck
on:
push:
branches: [dev]
pull_request:
branches: [dev]
workflow_dispatch:

1
.gitignore vendored
View File

@@ -5,7 +5,6 @@ node_modules
.env
.idea
.vscode
.codex
*~
playground
tmp

View File

@@ -1,6 +1,6 @@
---
description: git commit and push
model: opencode/kimi-k2.5
model: opencode/glm-4.7
subtask: true
---

View File

@@ -1,5 +1,6 @@
{
"$schema": "https://opencode.ai/config.json",
// "plugin": ["opencode-openai-codex-auth"],
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
@@ -8,7 +9,12 @@
"options": {},
},
},
"mcp": {},
"mcp": {
"context7": {
"type": "remote",
"url": "https://mcp.context7.com/mcp",
},
},
"tools": {
"github-triage": false,
"github-pr-search": false,

View File

@@ -1,2 +1 @@
sst-env.d.ts
desktop/src/bindings.ts
sst-env.d.ts

101
AGENTS.md
View File

@@ -5,107 +5,78 @@
## Style Guide
### General Principles
- Keep things in one function unless composable or reusable
- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context
- Avoid `try`/`catch` where possible
- Avoid using the `any` type
- Prefer single word variable names where possible
- Use Bun APIs when possible, like `Bun.file()`
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
### Naming
### Avoid let statements
Prefer single word names for variables and functions. Only use multiple words if necessary.
We don't like `let` statements, especially combined with if/else statements.
Prefer `const`.
Good:
```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.
```ts
// Good
const journal = await Bun.file(path.join(dir, "journal.json")).json()
// Bad
const journalPath = path.join(dir, "journal.json")
const journal = await Bun.file(journalPath).json()
```
### Destructuring
Avoid unnecessary destructuring. Use dot notation to preserve context.
```ts
// Good
obj.a
obj.b
// Bad
const { a, b } = obj
```
### Variables
Prefer `const` over `let`. Use ternaries or early returns instead of reassignment.
```ts
// Good
const foo = condition ? 1 : 2
```
// Bad
Bad:
```ts
let foo
if (condition) foo = 1
else foo = 2
```
### Control Flow
### Avoid else statements
Avoid `else` statements. Prefer early returns.
Prefer early returns or using an `iife` to avoid else statements.
Good:
```ts
// Good
function foo() {
if (condition) return 1
return 2
}
```
// Bad
Bad:
```ts
function foo() {
if (condition) return 1
else return 2
}
```
### Schema Definitions (Drizzle)
### Prefer single word naming
Use snake_case for field names so column names don't need to be redefined as strings.
Try your best to find a single word name for your variables, functions, etc.
Only use multiple words if you cannot.
Good:
```ts
// Good
const table = sqliteTable("session", {
id: text().primaryKey(),
project_id: text().notNull(),
created_at: integer().notNull(),
})
const foo = 1
const bar = 2
const baz = 3
```
// Bad
const table = sqliteTable("session", {
id: text("id").primaryKey(),
projectID: text("project_id").notNull(),
createdAt: integer("created_at").notNull(),
})
Bad:
```ts
const fooBar = 1
const barBaz = 2
const bazFoo = 3
```
## Testing
- Avoid mocks as much as possible
- Test actual implementation, do not duplicate logic into tests
You MUST avoid using `mocks` as much as possible.
Tests MUST test actual implementation, do not duplicate logic into a test.

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -30,8 +30,7 @@
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.th.md">ไทย</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -82,7 +81,7 @@ The install script respects the following priority order for the installation pa
1. `$OPENCODE_INSTALL_DIR` - Custom installation directory
2. `$XDG_BIN_DIR` - XDG Base Directory Specification compliant path
3. `$HOME/bin` - Standard user binary directory (if it exists or can be created)
3. `$HOME/bin` - Standard user binary directory (if exists or can be created)
4. `$HOME/.opencode/bin` - Default fallback
```bash
@@ -95,20 +94,20 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
OpenCode includes two built-in agents you can switch between with the `Tab` key.
- **build** - Default, full-access agent for development work
- **build** - Default, full access agent for development work
- **plan** - Read-only agent for analysis and code exploration
- Denies file edits by default
- Asks permission before running bash commands
- Ideal for exploring unfamiliar codebases or planning changes
Also included is a **general** subagent for complex searches and multistep tasks.
Also, included is a **general** subagent for complex searches and multistep tasks.
This is used internally and can be invoked using `@general` in messages.
Learn more about [agents](https://opencode.ai/docs/agents).
### Documentation
For more info on how to configure OpenCode, [**head over to our docs**](https://opencode.ai/docs).
For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs).
### Contributing
@@ -116,7 +115,7 @@ If you're interested in contributing to OpenCode, please read our [contributing
### Building on OpenCode
If you are working on a project that's related to OpenCode and is using "opencode" as part of its name, for example "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
If you are working on a project that's related to OpenCode and is using "opencode" as a part of its name; for example, "opencode-dashboard" or "opencode-mobile", please add a note to your README to clarify that it is not built by the OpenCode team and is not affiliated with us in any way.
### FAQ
@@ -125,10 +124,10 @@ If you are working on a project that's related to OpenCode and is using "opencod
It's very similar to Claude Code in terms of capability. Here are the key differences:
- 100% open source
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen), OpenCode can be used with Claude, OpenAI, Google, or even local models. As models evolve, the gaps between them will close and pricing will drop, so being provider-agnostic is important.
- Out-of-the-box LSP support
- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
- Out of the box LSP support
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This, for example, can allow OpenCode to run on your computer while you 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 can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
---

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -30,8 +30,7 @@
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.th.md">ไทย</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -1,135 +0,0 @@
<p align="center">
<a href="https://opencode.ai">
<picture>
<source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
<source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
<img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
</picture>
</a>
</p>
<p align="center">Açık kaynaklı yapay zeka kodlama asistanı.</p>
<p align="center">
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
</p>
<p align="center">
<a href="README.md">English</a> |
<a href="README.zh.md">简体中文</a> |
<a href="README.zht.md">繁體中文</a> |
<a href="README.ko.md">한국어</a> |
<a href="README.de.md">Deutsch</a> |
<a href="README.es.md">Español</a> |
<a href="README.fr.md">Français</a> |
<a href="README.it.md">Italiano</a> |
<a href="README.da.md">Dansk</a> |
<a href="README.ja.md">日本語</a> |
<a href="README.pl.md">Polski</a> |
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### Kurulum
```bash
# YOLO
curl -fsSL https://opencode.ai/install | bash
# Paket yöneticileri
npm i -g opencode-ai@latest # veya bun/pnpm/yarn
scoop install opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
paru -S opencode-bin # Arch Linux
mise use -g opencode # Tüm işletim sistemleri
nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
```
> [!TIP]
> Kurulumdan önce 0.1.x'ten eski sürümleri kaldırın.
### Masaüstü Uygulaması (BETA)
OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz.
| Platform | İndirme |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb`, `.rpm` veya AppImage |
```bash
# macOS (Homebrew)
brew install --cask opencode-desktop
# Windows (Scoop)
scoop bucket add extras; scoop install extras/opencode-desktop
```
#### Kurulum Dizini (Installation Directory)
Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder:
1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini
2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol
3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa)
4. `$HOME/.opencode/bin` - Varsayılan yedek konum
```bash
# Örnekler
OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
```
### Ajanlar
OpenCode, `Tab` tuşuyla aralarında geçiş yapabileceğiniz iki yerleşik (built-in) ajan içerir.
- **build** - Varsayılan, geliştirme çalışmaları için tam erişimli ajan
- **plan** - Analiz ve kod keşfi için salt okunur ajan
- Varsayılan olarak dosya düzenlemelerini reddeder
- Bash komutlarını çalıştırmadan önce izin ister
- Tanımadığınız kod tabanlarını keşfetmek veya değişiklikleri planlamak için ideal
Ayrıca, karmaşık aramalar ve çok adımlı görevler için bir **genel** alt ajan bulunmaktadır.
Bu dahili olarak kullanılır ve mesajlarda `@general` ile çağrılabilir.
[Ajanlar](https://opencode.ai/docs/agents) hakkında daha fazla bilgi edinin.
### Dokümantasyon
OpenCode'u nasıl yapılandıracağınız hakkında daha fazla bilgi için [**dokümantasyonumuza göz atın**](https://opencode.ai/docs).
### Katkıda Bulunma
OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermeden önce [katkıda bulunma dokümanlarımızı](./CONTRIBUTING.md) okuyun.
### OpenCode Üzerine Geliştirme
OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
### SSS
#### Bu Claude Code'dan nasıl farklı?
Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
- %100 açık kaynak
- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
- Kurulum gerektirmeyen hazır LSP desteği
- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
---
**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

View File

@@ -29,9 +29,7 @@
<a href="README.ru.md">Русский</a> |
<a href="README.ar.md">العربية</a> |
<a href="README.no.md">Norsk</a> |
<a href="README.br.md">Português (Brasil)</a> |
<a href="README.th.md">ไทย</a> |
<a href="README.tr.md">Türkçe</a>
<a href="README.br.md">Português (Brasil)</a>
</p>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -126,7 +124,7 @@ OpenCode 內建了兩種 Agent您可以使用 `Tab` 鍵快速切換。
- 100% 開源。
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
- 內建 LSP (語言伺服器協定) 支援。
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造我們將不斷挑戰終端機介面的極限。
- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造我們將不斷挑戰終端機介面的極限。
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
---

239
bun.lock
View File

@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -44,7 +44,7 @@
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "0.4.0",
"ghostty-web": "0.3.0",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -213,7 +213,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -242,7 +242,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -258,36 +258,35 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.52",
"version": "1.1.46",
"bin": {
"opencode": "./bin/opencode",
},
"dependencies": {
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.14.1",
"@ai-sdk/amazon-bedrock": "3.0.74",
"@ai-sdk/anthropic": "2.0.58",
"@agentclientprotocol/sdk": "0.13.0",
"@ai-sdk/amazon-bedrock": "3.0.73",
"@ai-sdk/anthropic": "2.0.57",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.36",
"@ai-sdk/cerebras": "1.0.34",
"@ai-sdk/cohere": "2.0.22",
"@ai-sdk/deepinfra": "1.0.33",
"@ai-sdk/gateway": "2.0.30",
"@ai-sdk/deepinfra": "1.0.31",
"@ai-sdk/gateway": "2.0.25",
"@ai-sdk/google": "2.0.52",
"@ai-sdk/google-vertex": "3.0.98",
"@ai-sdk/google-vertex": "3.0.97",
"@ai-sdk/groq": "2.0.34",
"@ai-sdk/mistral": "2.0.27",
"@ai-sdk/openai": "2.0.89",
"@ai-sdk/openai-compatible": "1.0.32",
"@ai-sdk/openai-compatible": "1.0.30",
"@ai-sdk/perplexity": "2.0.23",
"@ai-sdk/provider": "2.0.1",
"@ai-sdk/provider-utils": "3.0.20",
"@ai-sdk/togetherai": "1.0.34",
"@ai-sdk/vercel": "1.0.33",
"@ai-sdk/togetherai": "1.0.31",
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.4.0",
"@gitlab/opencode-gitlab-auth": "1.3.2",
"@gitlab/gitlab-ai-provider": "3.3.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
@@ -298,9 +297,9 @@
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.77",
"@opentui/solid": "0.1.77",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.75",
"@opentui/solid": "0.1.75",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -308,9 +307,8 @@
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.8",
"bun-pty": "0.4.4",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
@@ -364,7 +362,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -384,7 +382,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.52",
"version": "1.1.46",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -395,7 +393,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -408,7 +406,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -450,7 +448,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"zod": "catalog:",
},
@@ -461,7 +459,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.52",
"version": "1.1.46",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -496,6 +494,9 @@
"web-tree-sitter",
"tree-sitter-bash",
],
"patchedDependencies": {
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch",
},
"overrides": {
"@types/bun": "catalog:",
"@types/node": "catalog:",
@@ -520,7 +521,7 @@
"@types/node": "22.13.9",
"@types/semver": "7.7.1",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "5.0.124",
"ai": "5.0.119",
"diff": "8.0.2",
"dompurify": "3.3.1",
"fuzzysort": "3.1.0",
@@ -556,33 +557,25 @@
"@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="],
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.13.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.73", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EAAGJ/dfbAZaqIhK3w52hq6cftSLZwXdC6uHKh8Cls1T0N4MxS6ykDf54UyFO3bZWkQxR+Mdw1B3qireGOxtJQ=="],
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="],
"@ai-sdk/azure": ["@ai-sdk/azure@2.0.91", "", { "dependencies": { "@ai-sdk/openai": "2.0.89", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9tznVSs6LGQNKKxb8pKd7CkBV9yk+a/ENpFicHCj2CmBUKefxzwJ9JbUqrlK3VF6dGZw3LXq0dWxt7/Yekaj1w=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zoJYL33+ieyd86FSP0Whm86D79d1lKPR7wUzh1SZ1oTxwYmsGyvIrmMf2Ll0JA9Ds2Es6qik4VaFCrjwGYRTIQ=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XOK0dJsAGoPYi/lfR4KFBi8xhvJ46oCpAxUD6FmJAuJ4eh0qlj5zDt+myvzM8gvN7S6K7zHD+mdWlOPKGQT8Vg=="],
"@ai-sdk/cohere": ["@ai-sdk/cohere@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yJ9kP5cEDJwo8qpITq5TQFD8YNfNtW+HbyvWwrKMbFzmiMvIZuk95HIaFXE7PCTuZsqMA05yYu+qX/vQ3rNKjA=="],
"@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-87qFcYNvDF/89hB//MQjYTb3tlsAfmgeZrZ34RESeBTZpSgs0EzYOMqPMwFTHUNp4wteoifikDJbaS/9Da8cfw=="],
"@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="],
"@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="],
"@ai-sdk/elevenlabs": ["@ai-sdk/elevenlabs@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4d5EKu0OW7Gf5WFpGo4ixn0iWEwA+GpteqUjEznWGmi7qdLE5zdkbRik5B1HrDDiw5P90yO51xBex/Fp50JcVA=="],
"@ai-sdk/fireworks": ["@ai-sdk/fireworks@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-WWOz5Kj+5fVe94h7WeReqjUOVtAquDE2kM575FUc8CsVxH2tRfA5cLa8nu3bknSezsKt3i67YM6mvCRxiXCkWA=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-5Nrkj8B4MzkkOfjjA+Cs5pamkbkK4lI11bx80QV7TFcen/hWA8wEC+UVzwuM5H2zpekoNMjvl6GonHnR62XIZw=="],
"@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Rq+FX55ne7lMiqai7NcvvDZj4HLsr+hg77WayqmySqc6zhw3tIOLxd4Ty6OpwNj0C0bVMi3iCl2zvJIEirh9XA=="],
"@ai-sdk/google": ["@ai-sdk/google@2.0.52", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2XUnGi3f7TV4ujoAhA+Fg3idUoG/+Y2xjCRg70a1/m0DH1KSQqYaCboJ1C19y6ZHGdf5KNT20eJdswP6TvrY2g=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.98", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-uuv0RHkdJ5vTzeH1+iuBlv7GAjRcOPd2jiqtGLz6IKOUDH+PRQoE3ExrvOysVnKuhhTBMqvawkktDhMDQE6sVQ=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.97", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s4tI7Z15i6FlbtCvS4SBRal8wRfkOXJzKxlS6cU4mJW/QfUfoVy4b22836NVNJwDvkG/HkDSfzwm/X8mn46MhA=="],
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.34", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wfCYkVgmVjxNA32T57KbLabVnv9aFUflJ4urJ7eWgTwbnmGQHElCTu+rJ3ydxkXSqxOkXPwMOttDm7XNrvPjmg=="],
@@ -598,9 +591,9 @@
"@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jjJmJms6kdEc4nC3MDGFJfhV8F1ifY4nolV2dbnT7BM4ab+Wkskc0GwCsJ7G7WdRMk7xDbFh4he3DPL8KJ/cyA=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RlYubjStoZQxna4Ng91Vvo8YskvL7lW9zj68IwZfCnaDBSAp1u6Nhc5BR4ZtKnY6PA3XEtu4bATIQl7yiiQ+Lw=="],
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Qwjm+HdwKasu7L9bDUryBMGKDMscIEzMUkjw/33uGdJpktzyNW13YaNIObOZ2HkskqDMIQJSd4Ao2BBT8fEYLw=="],
"@ai-sdk/vercel": ["@ai-sdk/vercel@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ggvwAMt/KsbqcdR6ILQrjwrRONLV/8aG6rOLbjcOGvV0Ai+WdZRRKQj5nOeQ06PvwVQtKdkp7S4IinpXIhCiHg=="],
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.51", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-AI3le03qiegkZvn9hpnpDwez49lOvQLj4QUBT8H41SMbrdTYOxn3ktTwrsSu90cNDdzKGMvoH0u2GHju1EdnCg=="],
@@ -918,22 +911,8 @@
"@expressive-code/plugin-text-markers": ["@expressive-code/plugin-text-markers@0.41.6", "", { "dependencies": { "@expressive-code/core": "^0.41.6" } }, "sha512-PBFa1wGyYzRExMDzBmAWC6/kdfG1oLn4pLpBeTfIRrALPjcGA/59HP3e7q9J0Smk4pC7U+lWkA2LHR8FYV8U7Q=="],
"@fastify/ajv-compiler": ["@fastify/ajv-compiler@4.0.5", "", { "dependencies": { "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0" } }, "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A=="],
"@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="],
"@fastify/error": ["@fastify/error@4.2.0", "", {}, "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ=="],
"@fastify/fast-json-stringify-compiler": ["@fastify/fast-json-stringify-compiler@5.0.3", "", { "dependencies": { "fast-json-stringify": "^6.0.0" } }, "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ=="],
"@fastify/forwarded": ["@fastify/forwarded@3.0.1", "", {}, "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw=="],
"@fastify/merge-json-schemas": ["@fastify/merge-json-schemas@0.2.1", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A=="],
"@fastify/proxy-addr": ["@fastify/proxy-addr@5.1.0", "", { "dependencies": { "@fastify/forwarded": "^3.0.0", "ipaddr.js": "^2.1.0" } }, "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw=="],
"@fastify/rate-limit": ["@fastify/rate-limit@10.3.0", "", { "dependencies": { "@lukeed/ms": "^2.0.2", "fastify-plugin": "^5.0.0", "toad-cache": "^3.7.0" } }, "sha512-eIGkG9XKQs0nyynatApA3EVrojHOuq4l6fhB4eeCk4PIOeadvOJz9/4w3vGI44Go17uaXOWEcPkaD8kuKm7g6Q=="],
"@floating-ui/core": ["@floating-ui/core@1.7.4", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.5", "", { "dependencies": { "@floating-ui/core": "^1.7.4", "@floating-ui/utils": "^0.2.10" } }, "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg=="],
@@ -946,9 +925,7 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="],
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.2", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-pvGrC+aDVLY8bRCC/fZaG/Qihvt2r4by5xbTo5JTSz9O7yIcR6xG2d9Wkuu4bcXFz674z2C+i5bUk+J/RSdBpg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -1142,8 +1119,6 @@
"@leichtgewicht/ip-codec": ["@leichtgewicht/ip-codec@2.0.5", "", {}, "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw=="],
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
"@mdx-js/mdx": ["@mdx-js/mdx@3.1.1", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdx": "^2.0.0", "acorn": "^8.0.0", "collapse-white-space": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "estree-util-scope": "^1.0.0", "estree-walker": "^3.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "markdown-extensions": "^2.0.0", "recma-build-jsx": "^1.0.0", "recma-jsx": "^1.0.0", "recma-stringify": "^1.0.0", "rehype-recma": "^1.0.0", "remark-mdx": "^3.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "source-map": "^0.7.0", "unified": "^11.0.0", "unist-util-position-from-estree": "^2.0.0", "unist-util-stringify-position": "^4.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ=="],
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
@@ -1246,27 +1221,27 @@
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.4", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" } }, "sha512-xrSQPUIH8n9zuyYZR0XK7Ba0h2KsjJcMkxnwaYfmv13pKs3sDkjPzVPPhlhzqBGddHb5cFEwJ9VFuFeDcxCDSw=="],
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@1.5.2", "", { "dependencies": { "@openrouter/sdk": "^0.1.27" }, "peerDependencies": { "@toon-format/toon": "^2.0.0", "ai": "^5.0.0", "zod": "^3.24.1 || ^v4" }, "optionalPeers": ["@toon-format/toon"] }, "sha512-3Th0vmJ9pjnwcPc2H1f59Mb0LFvwaREZAScfOQIpUxAHjZ7ZawVKDP27qgsteZPmMYqccNMy4r4Y3kgUnNcKAg=="],
"@openrouter/sdk": ["@openrouter/sdk@0.1.27", "", { "dependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-RH//L10bSmc81q25zAZudiI4kNkLgxF2E+WU42vghp3N6TEvZ6F0jK7uT3tOxkEn91gzmMw9YVmDENy7SJsajQ=="],
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.77", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="],
"@opentui/core": ["@opentui/core@0.1.75", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.75", "@opentui/core-darwin-x64": "0.1.75", "@opentui/core-linux-arm64": "0.1.75", "@opentui/core-linux-x64": "0.1.75", "@opentui/core-win32-arm64": "0.1.75", "@opentui/core-win32-x64": "0.1.75", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-8ARRZxSG+BXkJmEVtM2DQ4se7DAF1ZCKD07d+AklgTr2mxCzmdxxPbOwRzboSQ6FM7qGuTVPVbV4O2W9DpUmoA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.77", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.75", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gGaGZjkFpqcXJk6321JzhRl66pM2VxBlI470L8W4DQUW4S6iDT1R9L7awSzGB4Cn9toUl7DTV8BemaXZYXV4SA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.77", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.75", "", { "os": "darwin", "cpu": "x64" }, "sha512-tPlvqQI0whZ76amHydpJs5kN+QeWAIcFbI8RAtlAo9baj2EbxTDC+JGwgb9Fnt0/YQx831humbtaNDhV2Jt1bw=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.77", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.75", "", { "os": "linux", "cpu": "arm64" }, "sha512-nVxIQ4Hqf84uBergDpWiVzU6pzpjy6tqBHRQpySxZ2flkJ/U6/aMEizVrQ1jcgIdxZtvqWDETZhzxhG0yDx+cw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.77", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.75", "", { "os": "linux", "cpu": "x64" }, "sha512-1CnApef4kxA+ORyLfbuCLgZfEjp4wr3HjFnt7FAfOb73kIZH82cb7JYixeqRyy9eOcKfKqxLmBYy3o8IDkc4Rg=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.77", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.75", "", { "os": "win32", "cpu": "arm64" }, "sha512-j0UB95nmkYGNzmOrs6GqaddO1S90R0YC6IhbKnbKBdjchFPNVLz9JpexAs6MBDXPZwdKAywMxtwG2h3aTJtxng=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.77", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.75", "", { "os": "win32", "cpu": "x64" }, "sha512-ESpVZVGewe3JkB2TwrG3VRbkxT909iPdtvgNT7xTCIYH2VB4jqZomJfvERPTE0tvqAZJm19mHECzJFI8asSJgQ=="],
"@opentui/solid": ["@opentui/solid@0.1.77", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="],
"@opentui/solid": ["@opentui/solid@0.1.75", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.75", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-WjKsZIfrm29znfRlcD9w3uUn/+uvoy2MmeoDwTvg1YOa0OjCTCmjZ43L9imp0m9S4HmVU8ma6o2bR4COzcyDdg=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1384,8 +1359,6 @@
"@pierre/diffs": ["@pierre/diffs@1.0.2", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-RkFSDD5X/U+8QjyilPViYGJfmJNWXR17zTL8zw48+DcVC1Ujbh6I1edyuRnFfgRzpft05x2DSCkz2cjoIAxPvQ=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@planetscale/database": ["@planetscale/database@1.19.0", "", {}, "sha512-Tv4jcFUFAFjOWrGSio49H6R2ijALv0ZzVBfJKIdm+kl9X046Fh4LLawrF9OMsglVbK6ukqMJsUCeucGAFTBcMA=="],
@@ -1936,7 +1909,7 @@
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="],
"@vercel/oidc": ["@vercel/oidc@3.0.5", "", {}, "sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
@@ -1962,8 +1935,6 @@
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="],
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
@@ -1976,9 +1947,7 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@5.0.124", "", { "dependencies": { "@ai-sdk/gateway": "2.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Li6Jw9F9qsvFJXZPBfxj38ddP2iURCnMs96f9Q3OeQzrDVcl1hvtwSEAuxA/qmfh6SDV2ERqFUOFzigvr0697g=="],
"ai-gateway-provider": ["ai-gateway-provider@2.3.1", "", { "dependencies": { "@ai-sdk/provider": "^2.0.0", "@ai-sdk/provider-utils": "^3.0.19", "ai": "^5.0.116" }, "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^3.0.71", "@ai-sdk/anthropic": "^2.0.56", "@ai-sdk/azure": "^2.0.90", "@ai-sdk/cerebras": "^1.0.33", "@ai-sdk/cohere": "^2.0.21", "@ai-sdk/deepgram": "^1.0.21", "@ai-sdk/deepseek": "^1.0.32", "@ai-sdk/elevenlabs": "^1.0.21", "@ai-sdk/fireworks": "^1.0.30", "@ai-sdk/google": "^2.0.51", "@ai-sdk/google-vertex": "3.0.90", "@ai-sdk/groq": "^2.0.33", "@ai-sdk/mistral": "^2.0.26", "@ai-sdk/openai": "^2.0.88", "@ai-sdk/perplexity": "^2.0.22", "@ai-sdk/xai": "^2.0.42", "@openrouter/ai-sdk-provider": "^1.5.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^1.0.29" } }, "sha512-PqI6TVNEDNwr7kOhy7XUGnA8XJB1SpeA9aLqGjr0CyWkKgH+y+ofPm8MZGZ74DOwVejDF+POZq0Qs9jKEKUeYg=="],
"ai": ["ai@5.0.119", "", { "dependencies": { "@ai-sdk/gateway": "2.0.25", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HUOwhc17fl2SZTJGZyA/99aNu706qKfXaUBCy9vgZiXBwrxg2eTzn2BCz7kmYDsfx6Fg2ACBy2icm41bsDXCTw=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
@@ -2040,14 +2009,10 @@
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"autoprefixer": ["autoprefixer@10.4.23", "", { "dependencies": { "browserslist": "^4.28.1", "caniuse-lite": "^1.0.30001760", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA=="],
"available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="],
"avvio": ["avvio@9.1.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-fYASnYi600CsH/j9EQov7lECAniYiBFiiAtBNuZYLA2leLe9qOvZzqYHFjtIj6gD2VMoMLP14834LFWvr4IfDw=="],
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
"aws-sdk": ["aws-sdk@2.1692.0", "", { "dependencies": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", "xml2js": "0.6.2" } }, "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw=="],
@@ -2132,7 +2097,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
@@ -2490,26 +2455,16 @@
"fast-content-type-parse": ["fast-content-type-parse@3.0.0", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
"fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-json-stringify": ["fast-json-stringify@6.2.0", "", { "dependencies": { "@fastify/merge-json-schemas": "^0.2.0", "ajv": "^8.12.0", "ajv-formats": "^3.0.1", "fast-uri": "^3.0.0", "json-schema-ref-resolver": "^3.0.0", "rfdc": "^1.2.0" } }, "sha512-Eaf/KNIDwHkzfyeQFNfLXJnQ7cl1XQI3+zRqmPlvtkMigbXnAcasTrvJQmquBSxKfFGeRA6PFog8t+hFmpDoWw=="],
"fast-querystring": ["fast-querystring@1.1.2", "", { "dependencies": { "fast-decode-uri-component": "^1.0.1" } }, "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-xml-parser": ["fast-xml-parser@4.4.1", "", { "dependencies": { "strnum": "^1.0.5" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-xkjOecfnKGkSsOwtZ5Pz7Us/T6mrbPQrq0nh+aCO5V9nk5NLWmasAHumTKjiPJPWANe+kAZ84Jc8ooJkzZ88Sw=="],
"fastify": ["fastify@5.7.4", "", { "dependencies": { "@fastify/ajv-compiler": "^4.0.5", "@fastify/error": "^4.0.0", "@fastify/fast-json-stringify-compiler": "^5.0.0", "@fastify/proxy-addr": "^5.0.0", "abstract-logging": "^2.0.1", "avvio": "^9.0.0", "fast-json-stringify": "^6.0.0", "find-my-way": "^9.0.0", "light-my-request": "^6.0.0", "pino": "^10.1.0", "process-warning": "^5.0.0", "rfdc": "^1.3.1", "secure-json-parse": "^4.0.0", "semver": "^7.6.0", "toad-cache": "^3.7.0" } }, "sha512-e6l5NsRdaEP8rdD8VR0ErJASeyaRbzXYpmkrpr2SuvuMq6Si3lvsaVy5C+7gLanEkvjpMDzBXWE5HPeb/hgTxA=="],
"fastify-plugin": ["fastify-plugin@5.1.0", "", {}, "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw=="],
"fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -2524,8 +2479,6 @@
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
"find-my-way": ["find-my-way@9.4.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-5Ye4vHsypZRYtS01ob/iwHzGRUDELlsoCftI/OZFhcLs1M0tkGPcXldE80TAZC5yYuJMBPJQQ43UHlqbJWiX2w=="],
"find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
@@ -2602,7 +2555,7 @@
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="],
"ghostty-web": ["ghostty-web@0.3.0", "", {}, "sha512-SAdSHWYF20GMZUB0n8kh1N6Z4ljMnuUqT8iTB2n5FAPswEV10MejEpLlhW/769GL5+BQa1NYwEg9y/XCckV5+A=="],
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
@@ -2892,8 +2845,6 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"json-schema-ref-resolver": ["json-schema-ref-resolver@3.0.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
@@ -2930,8 +2881,6 @@
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"light-my-request": ["light-my-request@6.6.0", "", { "dependencies": { "cookie": "^1.0.1", "process-warning": "^4.0.0", "set-cookie-parser": "^2.6.0" } }, "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A=="],
"lightningcss": ["lightningcss@1.30.1", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.30.1", "lightningcss-darwin-x64": "1.30.1", "lightningcss-freebsd-x64": "1.30.1", "lightningcss-linux-arm-gnueabihf": "1.30.1", "lightningcss-linux-arm64-gnu": "1.30.1", "lightningcss-linux-arm64-musl": "1.30.1", "lightningcss-linux-x64-gnu": "1.30.1", "lightningcss-linux-x64-musl": "1.30.1", "lightningcss-win32-arm64-msvc": "1.30.1", "lightningcss-win32-x64-msvc": "1.30.1" } }, "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg=="],
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ=="],
@@ -3234,8 +3183,6 @@
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@@ -3342,12 +3289,6 @@
"pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="],
"pino": ["pino@10.3.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^3.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^4.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-0GNPNzHXBKw6U/InGe79A3Crzyk9bcSyObF9/Gfo9DLEf5qj5RF50RSjsu0W1rZ6ZqRGdzDFCRBQvi9/rSGPtA=="],
"pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="],
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
"pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
@@ -3400,8 +3341,6 @@
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"promise.allsettled": ["promise.allsettled@1.0.7", "", { "dependencies": { "array.prototype.map": "^1.0.5", "call-bind": "^1.0.2", "define-properties": "^1.2.0", "es-abstract": "^1.22.1", "get-intrinsic": "^1.2.1", "iterate-value": "^1.0.2" } }, "sha512-hezvKvQQmsFkOdrZfYxUxkyxl8mgFQeT259Ajj9PXdbg9VzBCWrItOev72JyWxkCD5VSSqAeHmlN3tWx4DlmsA=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
@@ -3424,8 +3363,6 @@
"queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
"radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
@@ -3460,8 +3397,6 @@
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="],
"recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="],
@@ -3528,8 +3463,6 @@
"restructure": ["restructure@3.0.2", "", {}, "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw=="],
"ret": ["ret@0.5.0", "", {}, "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw=="],
"retext": ["retext@9.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "retext-latin": "^4.0.0", "retext-stringify": "^4.0.0", "unified": "^11.0.0" } }, "sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA=="],
"retext-latin": ["retext-latin@4.0.0", "", { "dependencies": { "@types/nlcst": "^2.0.0", "parse-latin": "^7.0.0", "unified": "^11.0.0" } }, "sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA=="],
@@ -3542,8 +3475,6 @@
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"rollup": ["rollup@4.57.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.0", "@rollup/rollup-android-arm64": "4.57.0", "@rollup/rollup-darwin-arm64": "4.57.0", "@rollup/rollup-darwin-x64": "4.57.0", "@rollup/rollup-freebsd-arm64": "4.57.0", "@rollup/rollup-freebsd-x64": "4.57.0", "@rollup/rollup-linux-arm-gnueabihf": "4.57.0", "@rollup/rollup-linux-arm-musleabihf": "4.57.0", "@rollup/rollup-linux-arm64-gnu": "4.57.0", "@rollup/rollup-linux-arm64-musl": "4.57.0", "@rollup/rollup-linux-loong64-gnu": "4.57.0", "@rollup/rollup-linux-loong64-musl": "4.57.0", "@rollup/rollup-linux-ppc64-gnu": "4.57.0", "@rollup/rollup-linux-ppc64-musl": "4.57.0", "@rollup/rollup-linux-riscv64-gnu": "4.57.0", "@rollup/rollup-linux-riscv64-musl": "4.57.0", "@rollup/rollup-linux-s390x-gnu": "4.57.0", "@rollup/rollup-linux-x64-gnu": "4.57.0", "@rollup/rollup-linux-x64-musl": "4.57.0", "@rollup/rollup-openbsd-x64": "4.57.0", "@rollup/rollup-openharmony-arm64": "4.57.0", "@rollup/rollup-win32-arm64-msvc": "4.57.0", "@rollup/rollup-win32-ia32-msvc": "4.57.0", "@rollup/rollup-win32-x64-gnu": "4.57.0", "@rollup/rollup-win32-x64-msvc": "4.57.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA=="],
@@ -3566,10 +3497,6 @@
"safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="],
"safe-regex2": ["safe-regex2@5.0.0", "", { "dependencies": { "ret": "~0.5.0" } }, "sha512-YwJwe5a51WlK7KbOJREPdjNrpViQBI3p4T50lfwPuDhZnE3XGVTlGvi+aolc5+RvxDD6bnUmjVsU9n1eboLUYw=="],
"safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"sax": ["sax@1.2.1", "", {}, "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA=="],
@@ -3578,8 +3505,6 @@
"section-matter": ["section-matter@1.0.0", "", { "dependencies": { "extend-shallow": "^2.0.1", "kind-of": "^6.0.0" } }, "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA=="],
"secure-json-parse": ["secure-json-parse@4.1.0", "", {}, "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA=="],
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
@@ -3594,8 +3519,6 @@
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
"set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
"set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
@@ -3658,8 +3581,6 @@
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
"sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -3668,8 +3589,6 @@
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"sprintf-js": ["sprintf-js@1.0.3", "", {}, "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g=="],
"sqlstring": ["sqlstring@2.3.3", "", {}, "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg=="],
@@ -3772,8 +3691,6 @@
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
"three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="],
"thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="],
@@ -4058,9 +3975,7 @@
"@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
@@ -4068,13 +3983,11 @@
"@ai-sdk/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
@@ -4084,9 +3997,9 @@
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"@ai-sdk/vercel/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
@@ -4156,8 +4069,6 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
"@gitlab/gitlab-ai-provider/openai": ["openai@6.17.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-NHRpPEUPzAvFOAFs9+9pC6+HCw/iWsYsKCMPXH5Kw7BpMxqd8g/A07/1o7Gx2TWtCnzevVRyKMRFqyiHyAlqcA=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
@@ -4360,16 +4271,6 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="],
"ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"ai-gateway-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@2.0.56", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FGlqwWc3tAYqDHE8r8hQGQLcMiPUwgz90oU2QygUH930OWtCLapFkSu114DgVaIN/qoM1DUX+inv0Ee74Fgp5g=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
@@ -4458,8 +4359,6 @@
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"light-my-request/process-warning": ["process-warning@4.0.1", "", {}, "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q=="],
"lightningcss/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
@@ -4482,11 +4381,11 @@
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
"opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
@@ -4904,14 +4803,6 @@
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="],
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"ansi-align/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],

View File

@@ -42,15 +42,28 @@
desktop = pkgs.callPackage ./nix/desktop.nix {
inherit opencode;
};
# nixpkgs cpu naming to bun cpu naming
cpuMap = { x86_64 = "x64"; aarch64 = "arm64"; };
# matrix of node_modules builds - these will always fail due to fakeHash usage
# but allow computation of the correct hash from any build machine for any cpu/os
# see the update-nix-hashes workflow for usage
moduleUpdaters = pkgs.lib.listToAttrs (
pkgs.lib.concatMap (cpu:
map (os: {
name = "${cpu}-${os}_node_modules";
value = node_modules.override {
bunCpu = cpuMap.${cpu};
bunOs = os;
hash = pkgs.lib.fakeHash;
};
}) [ "linux" "darwin" ]
) [ "x86_64" "aarch64" ]
);
in
{
default = opencode;
inherit opencode desktop;
# Updater derivation with fakeHash - build fails and reveals correct hash
node_modules_updater = node_modules.override {
hash = pkgs.lib.fakeHash;
};
}
} // moduleUpdaters
);
};
}

View File

@@ -45,7 +45,8 @@ rustPlatform.buildRustPackage (finalAttrs: {
rustc
jq
makeWrapper
] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
]
++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
buildInputs = lib.optionals stdenv.isLinux [
dbus
@@ -60,7 +61,6 @@ rustPlatform.buildRustPackage (finalAttrs: {
gst_all_1.gstreamer
gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-good
gst_all_1.gst-plugins-bad
];
strictDeps = true;
@@ -97,4 +97,4 @@ rustPlatform.buildRustPackage (finalAttrs: {
mainProgram = "opencode-desktop";
inherit (opencode.meta) platforms;
};
})
})

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-ufEpxjmlJeft9tI+WxxO+Zbh1pdAaLOURCDBpoQqR0w=",
"aarch64-linux": "sha256-z3K6W5oYZNUdV0rjoAZjvNQcifM5bXamLIrD+ZvJ4kA=",
"aarch64-darwin": "sha256-+QikplmNhxGF2Nd4L1BG/xyl+24GVhDYMTtK6xCKy/s=",
"x86_64-darwin": "sha256-hAcrCT2X02ymwgj/0BAmD2gF66ylGYzbfcqPta/LVEU="
"x86_64-linux": "sha256-gUWzUsk81miIrjg0fZQmsIQG4pZYmEHgzN6BaXI+lfc=",
"aarch64-linux": "sha256-gwEG75ha/ojTO2iAObTmLTtEkXIXJ7BThzfI5CqlJh8=",
"aarch64-darwin": "sha256-20RGG2GkUItCzD67gDdoSLfexttM8abS//FKO9bfjoM=",
"x86_64-darwin": "sha256-i2VawFuR1UbjPVYoybU6aJDJfFo0tcvtl1aM31Y2mTQ="
}
}

View File

@@ -2,6 +2,8 @@
lib,
stdenvNoCC,
bun,
bunCpu ? if stdenvNoCC.hostPlatform.isAarch64 then "arm64" else "x64",
bunOs ? if stdenvNoCC.hostPlatform.isLinux then "linux" else "darwin",
rev ? "dirty",
hash ?
(lib.pipe ./hashes.json [
@@ -14,9 +16,6 @@ let
builtins.readFile
builtins.fromJSON
];
platform = stdenvNoCC.hostPlatform;
bunCpu = if platform.isAarch64 then "arm64" else "x64";
bunOs = if platform.isLinux then "linux" else "darwin";
in
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
@@ -40,22 +39,23 @@ stdenvNoCC.mkDerivation {
"SOCKS_SERVER"
];
nativeBuildInputs = [ bun ];
nativeBuildInputs = [
bun
];
dontConfigure = true;
buildPhase = ''
runHook preBuild
export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--cpu="${bunCpu}" \
--os="${bunOs}" \
--filter '!./' \
--filter './packages/opencode' \
--filter './packages/desktop' \
--frozen-lockfile \
--ignore-scripts \
--no-progress
--no-progress \
--linker=isolated
bun --bun ${./scripts/canonicalize-node-modules.ts}
bun --bun ${./scripts/normalize-bun-binaries.ts}
runHook postBuild
@@ -63,8 +63,10 @@ stdenvNoCC.mkDerivation {
installPhase = ''
runHook preInstall
mkdir -p $out
find . -type d -name node_modules -exec cp -R --parents {} $out \;
runHook postInstall
'';

View File

@@ -7,8 +7,6 @@
"packageManager": "bun@1.3.5",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop tauri dev",
"dev:web": "bun --cwd packages/app dev",
"typecheck": "bun turbo typecheck",
"prepare": "husky",
"random": "echo 'Random script'",
@@ -40,7 +38,7 @@
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"ai": "5.0.124",
"ai": "5.0.119",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
@@ -100,5 +98,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:"
},
"patchedDependencies": {}
"patchedDependencies": {
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
}
}

View File

@@ -1,176 +0,0 @@
# E2E Testing Guide
## Build/Lint/Test Commands
```bash
# Run all e2e tests
bun test:e2e
# Run specific test file
bun test:e2e -- app/home.spec.ts
# Run single test by title
bun test:e2e -- -g "home renders and shows core entrypoints"
# Run tests with UI mode (for debugging)
bun test:e2e:ui
# Run tests locally with full server setup
bun test:e2e:local
# View test report
bun test:e2e:report
# Typecheck
bun typecheck
```
## Test Structure
All tests live in `packages/app/e2e/`:
```
e2e/
├── fixtures.ts # Test fixtures (test, expect, gotoSession, sdk)
├── actions.ts # Reusable action helpers
├── selectors.ts # DOM selectors
├── utils.ts # Utilities (serverUrl, modKey, path helpers)
└── [feature]/
└── *.spec.ts # Test files
```
## Test Patterns
### Basic Test Structure
```typescript
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
test("test description", async ({ page, sdk, gotoSession }) => {
await gotoSession() // or gotoSession(sessionID)
// Your test code
await expect(page.locator(promptSelector)).toBeVisible()
})
```
### Using Fixtures
- `page` - Playwright page
- `sdk` - OpenCode SDK client for API calls
- `gotoSession(sessionID?)` - Navigate to session
### Helper Functions
**Actions** (`actions.ts`):
- `openPalette(page)` - Open command palette
- `openSettings(page)` - Open settings dialog
- `closeDialog(page, dialog)` - Close any dialog
- `openSidebar(page)` / `closeSidebar(page)` - Toggle sidebar
- `withSession(sdk, title, callback)` - Create temp session
- `clickListItem(container, filter)` - Click list item by key/text
**Selectors** (`selectors.ts`):
- `promptSelector` - Prompt input
- `terminalSelector` - Terminal panel
- `sessionItemSelector(id)` - Session in sidebar
- `listItemSelector` - Generic list items
**Utils** (`utils.ts`):
- `modKey` - Meta (Mac) or Control (Linux/Win)
- `serverUrl` - Backend server URL
- `sessionPath(dir, id?)` - Build session URL
## Code Style Guidelines
### Imports
Always import from `../fixtures`, not `@playwright/test`:
```typescript
// ✅ Good
import { test, expect } from "../fixtures"
// ❌ Bad
import { test, expect } from "@playwright/test"
```
### Naming Conventions
- Test files: `feature-name.spec.ts`
- Test names: lowercase, descriptive: `"sidebar can be toggled"`
- Variables: camelCase
- Constants: SCREAMING_SNAKE_CASE
### Error Handling
Tests should clean up after themselves:
```typescript
test("test with cleanup", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "test session", async (session) => {
await gotoSession(session.id)
// Test code...
}) // Auto-deletes session
})
```
### Timeouts
Default: 60s per test, 10s per assertion. Override when needed:
```typescript
test.setTimeout(120_000) // For long LLM operations
test("slow test", async () => {
await expect.poll(() => check(), { timeout: 90_000 }).toBe(true)
})
```
### Selectors
Use `data-component`, `data-action`, or semantic roles:
```typescript
// ✅ Good
await page.locator('[data-component="prompt-input"]').click()
await page.getByRole("button", { name: "Open settings" }).click()
// ❌ Bad
await page.locator(".css-class-name").click()
await page.locator("#id-name").click()
```
### Keyboard Shortcuts
Use `modKey` for cross-platform compatibility:
```typescript
import { modKey } from "../utils"
await page.keyboard.press(`${modKey}+B`) // Toggle sidebar
await page.keyboard.press(`${modKey}+Comma`) // Open settings
```
## Writing New Tests
1. Choose appropriate folder or create new one
2. Import from `../fixtures`
3. Use helper functions from `../actions` and `../selectors`
4. Clean up any created resources
5. Use specific selectors (avoid CSS classes)
6. Test one feature per test file
## Local Development
For UI debugging, use:
```bash
bun test:e2e:ui
```
This opens Playwright's interactive UI for step-through debugging.

View File

@@ -1,421 +0,0 @@
import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { execSync } from "node:child_process"
import { modKey, serverUrl } from "./utils"
import {
sessionItemSelector,
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
projectMenuTriggerSelector,
projectWorkspacesToggleSelector,
titlebarRightSelector,
popoverBodySelector,
listItemSelector,
listItemKeySelector,
listItemKeyStartsWithSelector,
workspaceItemSelector,
workspaceMenuTriggerSelector,
} from "./selectors"
import type { createSdk } from "./utils"
export async function defocus(page: Page) {
await page
.evaluate(() => {
const el = document.activeElement
if (el instanceof HTMLElement) el.blur()
})
.catch(() => undefined)
}
export async function openPalette(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
return dialog
}
export async function closeDialog(page: Page, dialog: Locator) {
await page.keyboard.press("Escape")
const closed = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closed) return
await page.keyboard.press("Escape")
const closedSecond = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closedSecond) return
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
await expect(dialog).toHaveCount(0)
}
export async function isSidebarClosed(page: Page) {
const main = page.locator("main")
const classes = (await main.getAttribute("class")) ?? ""
return classes.includes("xl:border-l")
}
export async function toggleSidebar(page: Page) {
await defocus(page)
await page.keyboard.press(`${modKey}+B`)
}
export async function openSidebar(page: Page) {
if (!(await isSidebarClosed(page))) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
if (visible) await button.click()
if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const opened = await expect(main)
.not.toHaveClass(/xl:border-l/, { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) return
await toggleSidebar(page)
await expect(main).not.toHaveClass(/xl:border-l/)
}
export async function closeSidebar(page: Page) {
if (await isSidebarClosed(page)) return
const button = page.getByRole("button", { name: /toggle sidebar/i }).first()
const visible = await button
.isVisible()
.then((x) => x)
.catch(() => false)
if (visible) await button.click()
if (!visible) await toggleSidebar(page)
const main = page.locator("main")
const closed = await expect(main)
.toHaveClass(/xl:border-l/, { timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closed) return
await toggleSidebar(page)
await expect(main).toHaveClass(/xl:border-l/)
}
export async function openSettings(page: Page) {
await defocus(page)
const dialog = page.getByRole("dialog")
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
const opened = await dialog
.waitFor({ state: "visible", timeout: 3000 })
.then(() => true)
.catch(() => false)
if (opened) return dialog
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
return dialog
}
export async function seedProjects(page: Page, input: { directory: string; extra?: string[] }) {
await page.addInitScript(
(args: { directory: string; serverUrl: string; extra: string[] }) => {
const key = "opencode.global.dat:server"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
try {
return JSON.parse(raw) as unknown
} catch {
return undefined
}
})()
const store = parsed && typeof parsed === "object" ? (parsed as Record<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 nextProjects = { ...(projects as Record<string, unknown>) }
const add = (origin: string, directory: string) => {
const current = nextProjects[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)
if (existing.some((p) => p.worktree === directory)) return
nextProjects[origin] = [{ worktree: directory, expanded: true }, ...existing]
}
const directories = [args.directory, ...args.extra]
for (const directory of directories) {
add("local", directory)
add(args.serverUrl, directory)
}
localStorage.setItem(
key,
JSON.stringify({
list,
projects: nextProjects,
lastProject,
}),
)
},
{ directory: input.directory, serverUrl, extra: input.extra ?? [] },
)
}
export async function createTestProject() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-project-"))
await fs.writeFile(path.join(root, "README.md"), "# e2e\n")
execSync("git init", { cwd: root, stdio: "ignore" })
execSync("git add -A", { cwd: root, stdio: "ignore" })
execSync('git -c user.name="e2e" -c user.email="e2e@example.com" commit -m "init" --allow-empty', {
cwd: root,
stdio: "ignore",
})
return root
}
export async function cleanupTestProject(directory: string) {
await fs.rm(directory, { recursive: true, force: true }).catch(() => undefined)
}
export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
}
export async function hoverSessionItem(page: Page, sessionID: string) {
const sessionEl = page.locator(sessionItemSelector(sessionID)).first()
await expect(sessionEl).toBeVisible()
await sessionEl.hover()
return sessionEl
}
export async function openSessionMoreMenu(page: Page, sessionID: string) {
await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
const scroller = page.locator(".session-scroller").first()
await expect(scroller).toBeVisible()
await expect(scroller.getByRole("heading", { level: 1 }).first()).toBeVisible({ timeout: 30_000 })
const menu = page
.locator(dropdownMenuContentSelector)
.filter({ has: page.getByRole("menuitem", { name: /rename/i }) })
.filter({ has: page.getByRole("menuitem", { name: /archive/i }) })
.filter({ has: page.getByRole("menuitem", { name: /delete/i }) })
.first()
const opened = await menu
.isVisible()
.then((x) => x)
.catch(() => false)
if (opened) return menu
const menuTrigger = scroller.getByRole("button", { name: /more options/i }).first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click()
await expect(menu).toBeVisible()
return menu
}
export async function clickMenuItem(menu: Locator, itemName: string | RegExp, options?: { force?: boolean }) {
const item = menu.getByRole("menuitem").filter({ hasText: itemName }).first()
await expect(item).toBeVisible()
await item.click({ force: options?.force })
}
export async function confirmDialog(page: Page, buttonName: string | RegExp) {
const dialog = page.getByRole("dialog").first()
await expect(dialog).toBeVisible()
const button = dialog.getByRole("button").filter({ hasText: buttonName }).first()
await expect(button).toBeVisible()
await button.click()
}
export async function openSharePopover(page: Page) {
const rightSection = page.locator(titlebarRightSelector)
const shareButton = rightSection.getByRole("button", { name: "Share" }).first()
await expect(shareButton).toBeVisible()
const popoverBody = page
.locator(popoverBodySelector)
.filter({ has: page.getByRole("button", { name: /^(Publish|Unpublish)$/ }) })
.first()
const opened = await popoverBody
.isVisible()
.then((x) => x)
.catch(() => false)
if (!opened) {
await shareButton.click()
await expect(popoverBody).toBeVisible()
}
return { rightSection, popoverBody }
}
export async function clickPopoverButton(page: Page, buttonName: string | RegExp) {
const button = page.getByRole("button").filter({ hasText: buttonName }).first()
await expect(button).toBeVisible()
await button.click()
}
export async function clickListItem(
container: Locator | Page,
filter: string | RegExp | { key?: string; text?: string | RegExp; keyStartsWith?: string },
): Promise<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
}
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 sdk.session.delete({ sessionID: session.id }).catch(() => undefined)
}
}
export async function openStatusPopover(page: Page) {
await defocus(page)
const rightSection = page.locator(titlebarRightSelector)
const trigger = rightSection.getByRole("button", { name: /status/i }).first()
const popoverBody = page.locator(popoverBodySelector).filter({ has: page.locator('[data-component="tabs"]') })
const opened = await popoverBody
.isVisible()
.then((x) => x)
.catch(() => false)
if (!opened) {
await expect(trigger).toBeVisible()
await trigger.click()
await expect(popoverBody).toBeVisible()
}
return { rightSection, popoverBody }
}
export async function openProjectMenu(page: Page, projectSlug: string) {
const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
await expect(trigger).toHaveCount(1)
await trigger.focus()
await page.keyboard.press("Enter")
const menu = page.locator(dropdownMenuContentSelector).first()
const opened = await menu
.waitFor({ state: "visible", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (opened) {
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
return menu
}
await trigger.click({ force: true })
await expect(menu).toBeVisible()
const viewport = page.viewportSize()
const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
const y = viewport ? Math.max(viewport.height - 5, 0) : 800
await page.mouse.move(x, y)
return menu
}
export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
const current = await page
.getByRole("button", { name: "New workspace" })
.first()
.isVisible()
.then((x) => x)
.catch(() => false)
if (current === enabled) return
await openProjectMenu(page, projectSlug)
const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
await expect(toggle).toBeVisible()
await toggle.click({ force: true })
const expected = enabled ? "New workspace" : "New session"
await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
}
export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
await expect(item).toBeVisible()
await item.hover()
const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
const menu = page.locator(dropdownMenuContentSelector).first()
await expect(menu).toBeVisible()
return menu
}

View File

@@ -1,6 +1,5 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { dirPath } from "../utils"
import { dirPath, promptSelector } from "../utils"
test("project route redirects to /session", async ({ page, directory, slug }) => {
await page.goto(dirPath(directory))

View File

@@ -1,10 +1,14 @@
import { test, expect } from "../fixtures"
import { openPalette } from "../actions"
import { modKey } from "../utils"
test("search palette opens and closes", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page)
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("textbox").first()).toBeVisible()
await page.keyboard.press("Escape")
await expect(dialog).toHaveCount(0)

View File

@@ -1,6 +1,5 @@
import { test, expect } from "../fixtures"
import { serverName, serverUrl } from "../utils"
import { clickListItem, closeDialog, clickMenuItem } from "../actions"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
@@ -34,18 +33,31 @@ test("can set a default server on web", async ({ page, gotoSession }) => {
const row = dialog.locator('[data-slot="list-item"]').filter({ hasText: serverName }).first()
await expect(row).toBeVisible()
const menuTrigger = row.locator('[data-slot="dropdown-menu-trigger"]').first()
await expect(menuTrigger).toBeVisible()
await menuTrigger.click({ force: true })
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible()
await clickMenuItem(menu, /set as default/i)
const menu = row.locator('[data-component="icon-button"]').last()
await menu.click()
await page.getByRole("menuitem", { name: "Set as default" }).click()
await expect.poll(() => page.evaluate((key) => localStorage.getItem(key), DEFAULT_SERVER_URL_KEY)).toBe(serverUrl)
await expect(row.getByText("Default", { exact: true })).toBeVisible()
await closeDialog(page, dialog)
await page.keyboard.press("Escape")
const closed = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (!closed) {
await page.keyboard.press("Escape")
const closedSecond = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (!closedSecond) {
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
await expect(dialog).toHaveCount(0)
}
}
await ensurePopoverOpen()

View File

@@ -1,16 +1,21 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
import { promptSelector } from "../utils"
test("can open an existing session and type into the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke ${Date.now()}`
const created = await sdk.session.create({ title }).then((r) => r.data)
await withSession(sdk, title, async (session) => {
await gotoSession(session.id)
if (!created?.id) throw new Error("Session create did not return an id")
const sessionID = created.id
try {
await gotoSession(sessionID)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("hello from e2e")
await expect(prompt).toContainText("hello from e2e")
})
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
})

View File

@@ -1,42 +1,52 @@
import { test, expect } from "../fixtures"
import { openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
import { modKey, promptSelector } from "../utils"
test("titlebar back/forward navigates between sessions", async ({ page, slug, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e titlebar history 1 ${stamp}` }).then((r) => r.data)
const two = await sdk.session.create({ title: `e2e titlebar history 2 ${stamp}` }).then((r) => r.data)
await withSession(sdk, `e2e titlebar history 1 ${stamp}`, async (one) => {
await withSession(sdk, `e2e titlebar history 2 ${stamp}`, async (two) => {
await gotoSession(one.id)
if (!one?.id) throw new Error("Session create did not return an id")
if (!two?.id) throw new Error("Session create did not return an id")
await openSidebar(page)
try {
await gotoSession(one.id)
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
const main = page.locator("main")
const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
if (collapsed) {
await page.keyboard.press(`${modKey}+B`)
await expect(main).not.toHaveClass(/xl:border-l/)
}
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
const link = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(link).toBeVisible()
await link.scrollIntoViewIfNeeded()
await link.click()
const back = page.getByRole("button", { name: "Back" })
const forward = page.getByRole("button", { name: "Forward" })
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(back).toBeVisible()
await expect(back).toBeEnabled()
await back.click()
const back = page.getByRole("button", { name: "Back" })
const forward = page.getByRole("button", { name: "Forward" })
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(back).toBeVisible()
await expect(back).toBeEnabled()
await back.click()
await expect(forward).toBeVisible()
await expect(forward).toBeEnabled()
await forward.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${one.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
})
})
await expect(forward).toBeVisible()
await expect(forward).toBeEnabled()
await forward.click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
} finally {
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
}
})

View File

@@ -1,15 +1,20 @@
import { test, expect } from "../fixtures"
import { openPalette, clickListItem } from "../actions"
import { modKey } from "../utils"
test("can open a file tab from the search palette", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openPalette(page)
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill("package.json")
await clickListItem(dialog, { keyStartsWith: "file:" })
const fileItem = dialog.locator('[data-slot="list-item"][data-key^="file:"]').first()
await expect(fileItem).toBeVisible()
await fileItem.click()
await expect(dialog).toHaveCount(0)

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { openPalette, clickListItem } from "../actions"
import { modKey } from "../utils"
test("smoke file viewer renders real file content", async ({ page, gotoSession }) => {
await gotoSession()
@@ -7,12 +7,21 @@ test("smoke file viewer renders real file content", async ({ page, gotoSession }
const sep = process.platform === "win32" ? "\\" : "/"
const file = ["packages", "app", "package.json"].join(sep)
const dialog = await openPalette(page)
await page.keyboard.press(`${modKey}+P`)
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
const input = dialog.getByRole("textbox").first()
await input.fill(file)
await clickListItem(dialog, { text: /packages.*app.*package.json/ })
const fileItem = dialog
.locator(
'[data-slot="list-item"][data-key^="file:"][data-key*="packages"][data-key*="app"][data-key$="package.json"]',
)
.first()
await expect(fileItem).toBeVisible()
await fileItem.click()
await expect(dialog).toHaveCount(0)

View File

@@ -1,21 +1,9 @@
import { test as base, expect, type Page } from "@playwright/test"
import { cleanupTestProject, createTestProject, seedProjects } from "./actions"
import { promptSelector } from "./selectors"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
export const settingsKey = "settings.v3"
import { test as base, expect } from "@playwright/test"
import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils"
type TestFixtures = {
sdk: ReturnType<typeof createSdk>
gotoSession: (sessionID?: string) => Promise<void>
withProject: <T>(
callback: (project: {
directory: string
slug: string
gotoSession: (sessionID?: string) => Promise<void>
}) => Promise<T>,
options?: { extra?: string[] },
) => Promise<T>
}
type WorkerFixtures = {
@@ -41,7 +29,54 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
await use(createSdk(directory))
},
gotoSession: async ({ page, directory }, use) => {
await seedStorage(page, { directory })
await page.addInitScript(
(input: { directory: string; serverUrl: string }) => {
const key = "opencode.global.dat:server"
const raw = localStorage.getItem(key)
const parsed = (() => {
if (!raw) return undefined
try {
return JSON.parse(raw) as unknown
} catch {
return undefined
}
})()
const store = parsed && typeof parsed === "object" ? (parsed as Record<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 nextProjects = { ...(projects as Record<string, unknown>) }
const add = (origin: string) => {
const current = nextProjects[origin]
const items = Array.isArray(current) ? current : []
const existing = items.filter(
(p): p is { worktree: string; expanded?: boolean } =>
!!p &&
typeof p === "object" &&
"worktree" in p &&
typeof (p as { worktree?: unknown }).worktree === "string",
)
if (existing.some((p) => p.worktree === input.directory)) return
nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing]
}
add("local")
add(input.serverUrl)
localStorage.setItem(
key,
JSON.stringify({
list,
projects: nextProjects,
lastProject,
}),
)
},
{ directory, serverUrl },
)
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
@@ -49,39 +84,6 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
}
await use(gotoSession)
},
withProject: async ({ page }, use) => {
await use(async (callback, options) => {
const directory = await createTestProject()
const slug = dirSlug(directory)
await seedStorage(page, { directory, extra: options?.extra })
const gotoSession = async (sessionID?: string) => {
await page.goto(sessionPath(directory, sessionID))
await expect(page.locator(promptSelector)).toBeVisible()
}
try {
await gotoSession()
return await callback({ directory, slug, gotoSession })
} finally {
await cleanupTestProject(directory)
}
})
},
})
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
await seedProjects(page, input)
await page.addInitScript(() => {
localStorage.setItem(
"opencode.global.dat:model",
JSON.stringify({
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
user: [],
variant: {},
}),
)
})
}
export { expect }

View File

@@ -1,6 +1,5 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { clickListItem } from "../actions"
import { promptSelector } from "../utils"
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
await gotoSession()
@@ -33,7 +32,9 @@ test("smoke model selection updates prompt footer", async ({ page, gotoSession }
await input.fill(model)
await clickListItem(dialog, { key })
const item = dialog.locator(`[data-slot="list-item"][data-key="${key}"]`)
await expect(item).toBeVisible()
await item.click()
await expect(dialog).toHaveCount(0)

View File

@@ -1,6 +1,5 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { closeDialog, openSettings, clickListItem } from "../actions"
import { modKey, promptSelector } from "../utils"
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
await gotoSession()
@@ -28,7 +27,18 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
const settings = page.getByRole("dialog")
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
const opened = await settings
.waitFor({ state: "visible", timeout: 3000 })
.then(() => true)
.catch(() => false)
if (!opened) {
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(settings).toBeVisible()
}
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
@@ -42,7 +52,22 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await closeDialog(page, settings)
await page.keyboard.press("Escape")
const closed = await settings
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (!closed) {
await page.keyboard.press("Escape")
const closedSecond = await settings
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (!closedSecond) {
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
await expect(settings).toHaveCount(0)
}
}
await page.locator(promptSelector).click()
await page.keyboard.type("/model")

View File

@@ -1,53 +0,0 @@
import { test, expect } from "../fixtures"
import { openSidebar } from "../actions"
test("dialog edit project updates name and startup script", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async () => {
await openSidebar(page)
const open = async () => {
const header = page.locator(".group\\/project").first()
await header.hover()
const trigger = header.getByRole("button", { name: "More options" }).first()
await expect(trigger).toBeVisible()
await trigger.click({ force: true })
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible()
const editItem = menu.getByRole("menuitem", { name: "Edit" }).first()
await expect(editItem).toBeVisible()
await editItem.click({ force: true })
const dialog = page.getByRole("dialog")
await expect(dialog).toBeVisible()
await expect(dialog.getByRole("heading", { level: 2 })).toHaveText("Edit project")
return dialog
}
const name = `e2e project ${Date.now()}`
const startup = `echo e2e_${Date.now()}`
const dialog = await open()
const nameInput = dialog.getByLabel("Name")
await nameInput.fill(name)
const startupInput = dialog.getByLabel("Workspace startup script")
await startupInput.fill(startup)
await dialog.getByRole("button", { name: "Save" }).click()
await expect(dialog).toHaveCount(0)
const header = page.locator(".group\\/project").first()
await expect(header).toContainText(name)
const reopened = await open()
await expect(reopened.getByLabel("Name")).toHaveValue(name)
await expect(reopened.getByLabel("Workspace startup script")).toHaveValue(startup)
await reopened.getByRole("button", { name: "Cancel" }).click()
await expect(reopened).toHaveCount(0)
})
})

View File

@@ -1,74 +0,0 @@
import { test, expect } from "../fixtures"
import { createTestProject, cleanupTestProject, openSidebar, clickMenuItem } from "../actions"
import { projectCloseHoverSelector, projectCloseMenuSelector, projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("can close a project via hover card close button", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async () => {
await openSidebar(page)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.hover()
const close = page.locator(projectCloseHoverSelector(otherSlug)).first()
await expect(close).toBeVisible()
await close.click()
await expect(otherButton).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})
test("can close a project via project header more options menu", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherName = other.split("/").pop() ?? other
const otherSlug = dirSlug(other)
try {
await withProject(
async () => {
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 header = page
.locator(".group\\/project")
.filter({ has: page.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`) })
.first()
await expect(header).toContainText(otherName)
const trigger = header.locator(`[data-action="project-menu"][data-project="${otherSlug}"]`).first()
await expect(trigger).toHaveCount(1)
await trigger.focus()
await page.keyboard.press("Enter")
const menu = page.locator('[data-component="dropdown-menu-content"]').first()
await expect(menu).toBeVisible({ timeout: 10_000 })
await clickMenuItem(menu, /^Close$/i, { force: true })
await expect(otherButton).toHaveCount(0)
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})

View File

@@ -1,35 +0,0 @@
import { test, expect } from "../fixtures"
import { defocus, createTestProject, cleanupTestProject } from "../actions"
import { projectSwitchSelector } from "../selectors"
import { dirSlug } from "../utils"
test("can switch between projects from sidebar", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
const other = await createTestProject()
const otherSlug = dirSlug(other)
try {
await withProject(
async ({ directory }) => {
await defocus(page)
const currentSlug = dirSlug(directory)
const otherButton = page.locator(projectSwitchSelector(otherSlug)).first()
await expect(otherButton).toBeVisible()
await otherButton.click()
await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`))
const currentButton = page.locator(projectSwitchSelector(currentSlug)).first()
await expect(currentButton).toBeVisible()
await currentButton.click()
await expect(page).toHaveURL(new RegExp(`/${currentSlug}/session`))
},
{ extra: [other] },
)
} finally {
await cleanupTestProject(other)
}
})

View File

@@ -1,333 +0,0 @@
import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises"
import path from "node:path"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
test.describe.configure({ mode: "serial" })
import {
cleanupTestProject,
clickMenuItem,
confirmDialog,
openSidebar,
openWorkspaceMenu,
setWorkspacesEnabled,
} from "../actions"
import { inlineInputSelector, workspaceItemSelector } from "../selectors"
function slugFromUrl(url: string) {
return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
}
async function setupWorkspaceTest(page: Page, project: { slug: string }) {
const rootSlug = project.slug
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
return { rootSlug, slug, directory: dir }
}
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await openSidebar(page)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
await setWorkspacesEnabled(page, slug, false)
await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
})
})
test("can create a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug }) => {
await openSidebar(page)
await setWorkspacesEnabled(page, slug, true)
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const currentSlug = slugFromUrl(page.url())
return currentSlug.length > 0 && currentSlug !== slug
},
{ timeout: 45_000 },
)
.toBe(true)
const workspaceSlug = slugFromUrl(page.url())
const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
await cleanupTestProject(workspaceDir)
})
})
test("can rename a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const { slug } = await setupWorkspaceTest(page, project)
const rename = `e2e workspace ${Date.now()}`
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Rename$/i, { force: true })
await expect(menu).toHaveCount(0)
const item = page.locator(workspaceItemSelector(slug)).first()
await expect(item).toBeVisible()
const input = item.locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await input.fill(rename)
await input.press("Enter")
await expect(item).toContainText(rename)
})
})
test("can reset a workspace", async ({ page, sdk, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const { slug, directory: createdDir } = await setupWorkspaceTest(page, project)
const readme = path.join(createdDir, "README.md")
const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
const original = await fs.readFile(readme, "utf8")
const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
await fs.writeFile(readme, dirty, "utf8")
await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(true)
await expect
.poll(async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
})
.toBeGreaterThan(0)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Reset$/i, { force: true })
await confirmDialog(page, /^Reset workspace$/i)
await expect
.poll(
async () => {
const files = await sdk.file
.status({ directory: createdDir })
.then((r) => r.data ?? [])
.catch(() => [])
return files.length
},
{ timeout: 60_000 },
)
.toBe(0)
await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
await expect
.poll(async () => {
return await fs
.stat(extra)
.then(() => true)
.catch(() => false)
})
.toBe(false)
})
})
test("can delete a workspace", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async (project) => {
const { rootSlug, slug } = await setupWorkspaceTest(page, project)
const menu = await openWorkspaceMenu(page, slug)
await clickMenuItem(menu, /^Delete$/i, { force: true })
await confirmDialog(page, /^Delete workspace$/i)
await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
})
})
test("can reorder workspaces by drag and drop", async ({ page, withProject }) => {
await page.setViewportSize({ width: 1400, height: 800 })
await withProject(async ({ slug: rootSlug }) => {
const workspaces = [] as { directory: string; slug: string }[]
const listSlugs = async () => {
const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
const slugs = await nodes.evaluateAll((els) => {
return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
})
return slugs
}
const waitReady = async (slug: string) => {
await expect
.poll(
async () => {
const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
} catch {
return false
}
},
{ timeout: 60_000 },
)
.toBe(true)
}
const drag = async (from: string, to: string) => {
const src = page.locator(workspaceItemSelector(from)).first()
const dst = page.locator(workspaceItemSelector(to)).first()
await src.scrollIntoViewIfNeeded()
await dst.scrollIntoViewIfNeeded()
const a = await src.boundingBox()
const b = await dst.boundingBox()
if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
await page.mouse.down()
await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
await page.mouse.up()
}
try {
await openSidebar(page)
await setWorkspacesEnabled(page, rootSlug, true)
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
await expect
.poll(
() => {
const slug = slugFromUrl(page.url())
return slug.length > 0 && slug !== rootSlug && slug !== prev
},
{ timeout: 45_000 },
)
.toBe(true)
const slug = slugFromUrl(page.url())
const dir = base64Decode(slug)
workspaces.push({ slug, directory: dir })
await openSidebar(page)
}
if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
const a = workspaces[0].slug
const b = workspaces[1].slug
await waitReady(a)
await waitReady(b)
const list = async () => {
const slugs = await listSlugs()
return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
}
await expect
.poll(async () => {
const slugs = await list()
return slugs.length === 2
})
.toBe(true)
const before = await list()
const from = before[1]
const to = before[0]
if (!from || !to) throw new Error("Failed to resolve initial workspace order")
await drag(from, to)
await expect.poll(async () => await list()).toEqual([from, to])
} finally {
await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
}
})
})

View File

@@ -1,13 +1,16 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { withSession } from "../actions"
import { promptSelector } from "../utils"
test("context panel can be opened from the prompt", async ({ page, sdk, gotoSession }) => {
const title = `e2e smoke context ${Date.now()}`
const created = await sdk.session.create({ title }).then((r) => r.data)
await withSession(sdk, title, async (session) => {
if (!created?.id) throw new Error("Session create did not return an id")
const sessionID = created.id
try {
await sdk.session.promptAsync({
sessionID: session.id,
sessionID,
noReply: true,
parts: [
{
@@ -19,12 +22,12 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
await expect
.poll(async () => {
const messages = await sdk.session.messages({ sessionID: session.id, limit: 1 }).then((r) => r.data ?? [])
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
})
.toBeGreaterThan(0)
await gotoSession(session.id)
await gotoSession(sessionID)
const contextButton = page
.locator('[data-component="button"]')
@@ -36,5 +39,7 @@ test("context panel can be opened from the prompt", async ({ page, sdk, gotoSess
const tabs = page.locator('[data-component="tabs"][data-variant="normal"]')
await expect(tabs.getByRole("tab", { name: "Context" })).toBeVisible()
})
} finally {
await sdk.session.delete({ sessionID }).catch(() => undefined)
}
})

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { promptSelector } from "../utils"
test("smoke @mention inserts file pill token", async ({ page, gotoSession }) => {
await gotoSession()

View File

@@ -1,5 +1,5 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { promptSelector } from "../utils"
test("smoke /open opens file picker dialog", async ({ page, gotoSession }) => {
await gotoSession()

View File

@@ -1,6 +1,10 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { sessionIDFromUrl, withSession } from "../actions"
import { promptSelector } from "../utils"
function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
}
test("can send a prompt and receive a reply", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000)

View File

@@ -1,57 +0,0 @@
export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
export const settingsThemeSelector = '[data-action="settings-theme"]'
export const settingsFontSelector = '[data-action="settings-font"]'
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]'
export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]'
export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]'
export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]'
export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]'
export const sidebarNavSelector = '[data-component="sidebar-nav-desktop"]'
export const projectSwitchSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-switch"][data-project="${slug}"]`
export const projectCloseHoverSelector = (slug: string) => `[data-action="project-close-hover"][data-project="${slug}"]`
export const projectMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="project-menu"][data-project="${slug}"]`
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
export const projectWorkspacesToggleSelector = (slug: string) =>
`[data-action="project-workspaces-toggle"][data-project="${slug}"]`
export const titlebarRightSelector = "#opencode-titlebar-right"
export const popoverBodySelector = '[data-slot="popover-body"]'
export const dropdownMenuTriggerSelector = '[data-slot="dropdown-menu-trigger"]'
export const dropdownMenuContentSelector = '[data-component="dropdown-menu-content"]'
export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
export const workspaceItemSelector = (slug: string) =>
`${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
export const workspaceMenuTriggerSelector = (slug: string) =>
`${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
export const listItemSelector = '[data-slot="list-item"]'
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
export const listItemKeySelector = (key: string) => `${listItemSelector}[data-key="${key}"]`
export const keybindButtonSelector = (id: string) => `[data-keybind-id="${id}"]`

View File

@@ -1,157 +0,0 @@
import { test, expect } from "../fixtures"
import {
openSidebar,
openSessionMoreMenu,
clickMenuItem,
confirmDialog,
openSharePopover,
withSession,
} from "../actions"
import { sessionItemSelector, inlineInputSelector } from "../selectors"
const shareDisabled = process.env.OPENCODE_DISABLE_SHARE === "true" || process.env.OPENCODE_DISABLE_SHARE === "1"
type Sdk = Parameters<typeof withSession>[0]
async function seedMessage(sdk: Sdk, sessionID: string) {
await sdk.session.promptAsync({
sessionID,
noReply: true,
parts: [{ type: "text", text: "e2e seed" }],
})
await expect
.poll(
async () => {
const messages = await sdk.session.messages({ sessionID, limit: 1 }).then((r) => r.data ?? [])
return messages.length
},
{ timeout: 30_000 },
)
.toBeGreaterThan(0)
}
test("session can be renamed via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const originalTitle = `e2e rename test ${stamp}`
const newTitle = `e2e renamed ${stamp}`
await withSession(sdk, originalTitle, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /rename/i)
const input = page.locator(".session-scroller").locator(inlineInputSelector).first()
await expect(input).toBeVisible()
await input.fill(newTitle)
await input.press("Enter")
await expect(page.getByRole("heading", { level: 1 }).first()).toContainText(newTitle)
})
})
test("session can be archived via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const title = `e2e archive test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /archive/i)
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.time?.archived
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
test("session can be deleted via header menu", async ({ page, sdk, gotoSession }) => {
const stamp = Date.now()
const title = `e2e delete test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id)
await clickMenuItem(menu, /delete/i)
await confirmDialog(page, /delete/i)
await expect
.poll(
async () => {
const data = await sdk.session
.get({ sessionID: session.id })
.then((r) => r.data)
.catch(() => undefined)
return data?.id
},
{ timeout: 30_000 },
)
.toBeUndefined()
await openSidebar(page)
await expect(page.locator(sessionItemSelector(session.id))).toHaveCount(0)
})
})
test("session can be shared and unshared via header button", async ({ page, sdk, gotoSession }) => {
test.skip(shareDisabled, "Share is disabled in this environment (OPENCODE_DISABLE_SHARE).")
const stamp = Date.now()
const title = `e2e share test ${stamp}`
await withSession(sdk, title, async (session) => {
await seedMessage(sdk, session.id)
await gotoSession(session.id)
const { rightSection, popoverBody } = await openSharePopover(page)
await popoverBody.getByRole("button", { name: "Publish" }).first().click()
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.not.toBeUndefined()
const copyButton = rightSection.locator('button[aria-label="Copy link"]').first()
await expect(copyButton).toBeVisible({ timeout: 30_000 })
const sharedPopover = await openSharePopover(page)
const unpublish = sharedPopover.popoverBody.getByRole("button", { name: "Unpublish" }).first()
await expect(unpublish).toBeVisible({ timeout: 30_000 })
await unpublish.click()
await expect
.poll(
async () => {
const data = await sdk.session.get({ sessionID: session.id }).then((r) => r.data)
return data?.share?.url || undefined
},
{ timeout: 30_000 },
)
.toBeUndefined()
await expect(copyButton).not.toBeVisible({ timeout: 30_000 })
const unsharedPopover = await openSharePopover(page)
await expect(unsharedPopover.popoverBody.getByRole("button", { name: "Publish" }).first()).toBeVisible({
timeout: 30_000,
})
})
})

View File

@@ -1,317 +0,0 @@
import { test, expect } from "../fixtures"
import { openSettings, closeDialog, withSession } from "../actions"
import { keybindButtonSelector } from "../selectors"
import { modKey } from "../utils"
test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyH`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("H")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("mod+shift+h")
await closeDialog(page, dialog)
const main = page.locator("main")
const initialClasses = (await main.getAttribute("class")) ?? ""
const initiallyClosed = initialClasses.includes("xl:border-l")
await page.keyboard.press(`${modKey}+Shift+H`)
await page.waitForTimeout(100)
const afterToggleClasses = (await main.getAttribute("class")) ?? ""
const afterToggleClosed = afterToggleClasses.includes("xl:border-l")
expect(afterToggleClosed).toBe(!initiallyClosed)
await page.keyboard.press(`${modKey}+Shift+H`)
await page.waitForTimeout(100)
const finalClasses = (await main.getAttribute("class")) ?? ""
const finalClosed = finalClasses.includes("xl:border-l")
expect(finalClosed).toBe(initiallyClosed)
})
test("resetting all keybinds to defaults works", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "sidebar.toggle": "mod+shift+x" } }))
})
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const customKeybind = await keybindButton.textContent()
expect(customKeybind).toContain("X")
const resetButton = dialog.getByRole("button", { name: "Reset to defaults" })
await expect(resetButton).toBeVisible()
await expect(resetButton).toBeEnabled()
await resetButton.click()
await page.waitForTimeout(100)
const restoredKeybind = await keybindButton.textContent()
expect(restoredKeybind).toContain("B")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBeUndefined()
await closeDialog(page, dialog)
})
test("clearing a keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("sidebar.toggle"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("B")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press("Delete")
await page.waitForTimeout(100)
const clearedKeybind = await keybindButton.textContent()
expect(clearedKeybind).toMatch(/unassigned|press/i)
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["sidebar.toggle"]).toBe("none")
await closeDialog(page, dialog)
await page.keyboard.press(`${modKey}+B`)
await page.waitForTimeout(100)
const stillOnSession = page.url().includes("/session")
expect(stillOnSession).toBe(true)
})
test("changing settings open keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("settings.open"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain(",")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Slash`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("/")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["settings.open"]).toBe("mod+/")
await closeDialog(page, dialog)
const settingsDialog = page.getByRole("dialog")
await expect(settingsDialog).toHaveCount(0)
await page.keyboard.press(`${modKey}+Slash`)
await page.waitForTimeout(100)
await expect(settingsDialog).toBeVisible()
await closeDialog(page, settingsDialog)
})
test("changing new session keybind works", async ({ page, sdk, gotoSession }) => {
await withSession(sdk, "test session for keybind", async (session) => {
await gotoSession(session.id)
const initialUrl = page.url()
expect(initialUrl).toContain(`/session/${session.id}`)
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("session.new"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyN`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("N")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["session.new"]).toBe("mod+shift+n")
await closeDialog(page, dialog)
await page.keyboard.press(`${modKey}+Shift+N`)
await page.waitForTimeout(200)
const newUrl = page.url()
expect(newUrl).toMatch(/\/session\/?$/)
expect(newUrl).not.toContain(session.id)
})
})
test("changing file open keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("file.open"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyF`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("F")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["file.open"]).toBe("mod+shift+f")
await closeDialog(page, dialog)
const filePickerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder(/search files/i) })
await expect(filePickerDialog).toHaveCount(0)
await page.keyboard.press(`${modKey}+Shift+F`)
await page.waitForTimeout(100)
await expect(filePickerDialog).toBeVisible()
await page.keyboard.press("Escape")
await expect(filePickerDialog).toHaveCount(0)
})
test("changing terminal toggle keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("terminal.toggle"))
await expect(keybindButton).toBeVisible()
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+KeyY`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("Y")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["terminal.toggle"]).toBe("mod+y")
await closeDialog(page, dialog)
await page.keyboard.press(`${modKey}+Y`)
await page.waitForTimeout(100)
const pageStable = await page.evaluate(() => document.readyState === "complete")
expect(pageStable).toBe(true)
})
test("changing command palette keybind works", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
const keybindButton = dialog.locator(keybindButtonSelector("command.palette"))
await expect(keybindButton).toBeVisible()
const initialKeybind = await keybindButton.textContent()
expect(initialKeybind).toContain("P")
await keybindButton.click()
await expect(keybindButton).toHaveText(/press/i)
await page.keyboard.press(`${modKey}+Shift+KeyK`)
await page.waitForTimeout(100)
const newKeybind = await keybindButton.textContent()
expect(newKeybind).toContain("K")
const stored = await page.evaluate(() => {
const raw = localStorage.getItem("settings.v3")
return raw ? JSON.parse(raw) : null
})
expect(stored?.keybinds?.["command.palette"]).toBe("mod+shift+k")
await closeDialog(page, dialog)
const palette = page.getByRole("dialog").filter({ has: page.getByRole("textbox").first() })
await expect(palette).toHaveCount(0)
await page.keyboard.press(`${modKey}+Shift+K`)
await page.waitForTimeout(100)
await expect(palette).toBeVisible()
await expect(palette.getByRole("textbox").first()).toBeVisible()
await page.keyboard.press("Escape")
await expect(palette).toHaveCount(0)
})

View File

@@ -0,0 +1,39 @@
import { test, expect } from "../fixtures"
import { modKey, settingsLanguageSelectSelector } from "../utils"
test("smoke changing language updates settings labels", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
})
await gotoSession()
const dialog = page.getByRole("dialog")
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
const opened = await dialog
.waitFor({ state: "visible", timeout: 3000 })
.then(() => true)
.catch(() => false)
if (!opened) {
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
}
const heading = dialog.getByRole("heading", { level: 2 })
await expect(heading).toHaveText("General")
const select = dialog.locator(settingsLanguageSelectSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
await expect(heading).toHaveText("Allgemein")
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
await expect(heading).toHaveText("General")
})

View File

@@ -1,122 +0,0 @@
import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
import { closeDialog, openSettings } from "../actions"
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const picker = page.getByRole("dialog")
await expect(picker).toBeVisible()
const target = picker.locator('[data-slot="list-item"]').first()
await expect(target).toBeVisible()
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
if (!name) throw new Error("Failed to resolve model name from list item")
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
await expect(search).toBeVisible()
await search.fill(name)
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
const input = toggle.locator('[data-slot="switch-input"]')
await expect(toggle).toBeVisible()
await expect(input).toHaveAttribute("aria-checked", "true")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const pickerAgain = page.getByRole("dialog")
await expect(pickerAgain).toBeVisible()
await expect(pickerAgain.locator('[data-slot="list-item"]').first()).toBeVisible()
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toHaveCount(0)
await page.keyboard.press("Escape")
await expect(pickerAgain).toHaveCount(0)
})
test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
await gotoSession()
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
const command = page.locator('[data-slash-id="model.choose"]')
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const picker = page.getByRole("dialog")
await expect(picker).toBeVisible()
const target = picker.locator('[data-slot="list-item"]').first()
await expect(target).toBeVisible()
const key = await target.getAttribute("data-key")
if (!key) throw new Error("Failed to resolve model key from list item")
const name = (await target.locator("span").first().innerText()).trim()
if (!name) throw new Error("Failed to resolve model name from list item")
await page.keyboard.press("Escape")
await expect(picker).toHaveCount(0)
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Models" }).click()
const search = settings.getByPlaceholder("Search models")
await expect(search).toBeVisible()
await search.fill(name)
const toggle = settings.locator('[data-component="switch"]').filter({ hasText: name }).first()
const input = toggle.locator('[data-slot="switch-input"]')
await expect(toggle).toBeVisible()
await expect(input).toHaveAttribute("aria-checked", "true")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "false")
await toggle.locator('[data-slot="switch-control"]').click()
await expect(input).toHaveAttribute("aria-checked", "true")
await closeDialog(page, settings)
await page.locator(promptSelector).click()
await page.keyboard.type("/model")
await expect(command).toBeVisible()
await command.hover()
await page.keyboard.press("Enter")
const pickerAgain = page.getByRole("dialog")
await expect(pickerAgain).toBeVisible()
await expect(pickerAgain.locator(`[data-slot="list-item"][data-key="${key}"]`)).toBeVisible()
await page.keyboard.press("Escape")
await expect(pickerAgain).toHaveCount(0)
})

View File

@@ -1,136 +1,56 @@
import { test, expect } from "../fixtures"
import { closeDialog, openSettings } from "../actions"
import { modKey, promptSelector } from "../utils"
test("custom provider form can be filled and validates input", async ({ page, gotoSession }) => {
test("smoke providers settings opens provider selector", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const dialog = page.getByRole("dialog")
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await expect(customProviderSection).toBeVisible()
await page.keyboard.press(`${modKey}+Comma`).catch(() => undefined)
const connectButton = customProviderSection.getByRole("button", { name: "Connect" })
await connectButton.click()
const opened = await dialog
.waitFor({ state: "visible", timeout: 3000 })
.then(() => true)
.catch(() => false)
if (!opened) {
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
}
await dialog.getByRole("tab", { name: "Providers" }).click()
await expect(dialog.getByText("Connected providers", { exact: true })).toBeVisible()
await expect(dialog.getByText("Popular providers", { exact: true })).toBeVisible()
await dialog.getByRole("button", { name: "Show more providers" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByPlaceholder("Search providers") })
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("test-provider")
await providerDialog.getByLabel("Display name").fill("Test Provider")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/fake")
await providerDialog.getByLabel("API key").fill("fake-key")
await providerDialog.getByPlaceholder("model-id").first().fill("test-model")
await providerDialog.getByPlaceholder("Display Name").first().fill("Test Model")
await expect(providerDialog.getByRole("textbox", { name: "Provider ID" })).toHaveValue("test-provider")
await expect(providerDialog.getByRole("textbox", { name: "Display name" })).toHaveValue("Test Provider")
await expect(providerDialog.getByRole("textbox", { name: "Base URL" })).toHaveValue("http://localhost:9999/fake")
await expect(providerDialog.getByRole("textbox", { name: "API key" })).toHaveValue("fake-key")
await expect(providerDialog.getByPlaceholder("model-id").first()).toHaveValue("test-model")
await expect(providerDialog.getByPlaceholder("Display Name").first()).toHaveValue("Test Model")
await expect(providerDialog.getByPlaceholder("Search providers")).toBeVisible()
await expect(providerDialog.locator('[data-slot="list-item"]').first()).toBeVisible()
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await expect(page.locator(promptSelector)).toBeVisible()
await closeDialog(page, settings)
})
test("custom provider form shows validation errors", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("invalid provider id")
await providerDialog.getByLabel("Base URL").fill("not-a-url")
await providerDialog.getByRole("button", { name: /submit|save/i }).click()
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /lowercase/i })).toBeVisible()
await expect(providerDialog.locator('[data-slot="input-error"]').filter({ hasText: /http/i })).toBeVisible()
const stillOpen = await dialog.isVisible().catch(() => false)
if (!stillOpen) return
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
})
test("custom provider form can add and remove models", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("multi-model-test")
await providerDialog.getByLabel("Display name").fill("Multi Model Test")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/multi")
await providerDialog.getByPlaceholder("model-id").first().fill("model-1")
await providerDialog.getByPlaceholder("Display Name").first().fill("Model 1")
const idInputsBefore = await providerDialog.getByPlaceholder("model-id").count()
await providerDialog.getByRole("button", { name: "Add model" }).click()
const idInputsAfter = await providerDialog.getByPlaceholder("model-id").count()
expect(idInputsAfter).toBe(idInputsBefore + 1)
await providerDialog.getByPlaceholder("model-id").nth(1).fill("model-2")
await providerDialog.getByPlaceholder("Display Name").nth(1).fill("Model 2")
await expect(providerDialog.getByPlaceholder("model-id").nth(1)).toHaveValue("model-2")
await expect(providerDialog.getByPlaceholder("Display Name").nth(1)).toHaveValue("Model 2")
const closed = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closed) return
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
const closedSecond = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
if (closedSecond) return
await closeDialog(page, settings)
})
test("custom provider form can add and remove headers", async ({ page, gotoSession }) => {
await gotoSession()
const settings = await openSettings(page)
await settings.getByRole("tab", { name: "Providers" }).click()
const customProviderSection = settings.locator('[data-component="custom-provider-section"]')
await customProviderSection.getByRole("button", { name: "Connect" }).click()
const providerDialog = page.getByRole("dialog").filter({ has: page.getByText("Custom provider") })
await expect(providerDialog).toBeVisible()
await providerDialog.getByLabel("Provider ID").fill("header-test")
await providerDialog.getByLabel("Display name").fill("Header Test")
await providerDialog.getByLabel("Base URL").fill("http://localhost:9999/headers")
await providerDialog.getByPlaceholder("model-id").first().fill("model-x")
await providerDialog.getByPlaceholder("Display Name").first().fill("Model X")
const headerInputsBefore = await providerDialog.getByPlaceholder("Header-Name").count()
await providerDialog.getByRole("button", { name: "Add header" }).click()
const headerInputsAfter = await providerDialog.getByPlaceholder("Header-Name").count()
expect(headerInputsAfter).toBe(headerInputsBefore + 1)
await providerDialog.getByPlaceholder("Header-Name").first().fill("Authorization")
await providerDialog.getByPlaceholder("value").first().fill("Bearer token123")
await expect(providerDialog.getByPlaceholder("Header-Name").first()).toHaveValue("Authorization")
await expect(providerDialog.getByPlaceholder("value").first()).toHaveValue("Bearer token123")
await page.keyboard.press("Escape")
await expect(providerDialog).toHaveCount(0)
await closeDialog(page, settings)
await page.locator('[data-component="dialog-overlay"]').click({ position: { x: 5, y: 5 } })
await expect(dialog).toHaveCount(0)
})

View File

@@ -1,292 +1,44 @@
import { test, expect, settingsKey } from "../fixtures"
import { closeDialog, openSettings } from "../actions"
import {
settingsColorSchemeSelector,
settingsFontSelector,
settingsLanguageSelectSelector,
settingsNotificationsAgentSelector,
settingsNotificationsErrorsSelector,
settingsNotificationsPermissionsSelector,
settingsReleaseNotesSelector,
settingsSoundsAgentSelector,
settingsThemeSelector,
settingsUpdatesStartupSelector,
} from "../selectors"
import { test, expect } from "../fixtures"
import { modKey } from "../utils"
test("smoke settings dialog opens, switches tabs, closes", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(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) {
await page.getByRole("button", { name: "Settings" }).first().click()
await expect(dialog).toBeVisible()
}
await dialog.getByRole("tab", { name: "Shortcuts" }).click()
await expect(dialog.getByRole("button", { name: "Reset to defaults" })).toBeVisible()
await expect(dialog.getByPlaceholder("Search shortcuts")).toBeVisible()
await closeDialog(page, dialog)
})
test("changing language updates settings labels", async ({ page, gotoSession }) => {
await page.addInitScript(() => {
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: "en" }))
})
await gotoSession()
const dialog = await openSettings(page)
const heading = dialog.getByRole("heading", { level: 2 })
await expect(heading).toHaveText("General")
const select = dialog.locator(settingsLanguageSelectSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Deutsch" }).click()
await expect(heading).toHaveText("Allgemein")
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "English" }).click()
await expect(heading).toHaveText("General")
})
test("changing color scheme persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsColorSchemeSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
const colorScheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-color-scheme")
})
expect(colorScheme).toBe("dark")
await select.locator('[data-slot="select-select-trigger"]').click()
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Light" }).click()
const lightColorScheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-color-scheme")
})
expect(lightColorScheme).toBe("light")
})
test("changing theme persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsThemeSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
const count = await items.count()
expect(count).toBeGreaterThan(1)
const firstTheme = await items.nth(1).locator('[data-slot="select-select-item-label"]').textContent()
expect(firstTheme).toBeTruthy()
await items.nth(1).click()
await page.keyboard.press("Escape")
const storedThemeId = await page.evaluate(() => {
return localStorage.getItem("opencode-theme-id")
})
const closed = await dialog
.waitFor({ state: "detached", timeout: 1500 })
.then(() => true)
.catch(() => false)
expect(storedThemeId).not.toBeNull()
expect(storedThemeId).not.toBe("oc-1")
if (closed) return
const dataTheme = await page.evaluate(() => {
return document.documentElement.getAttribute("data-theme")
})
expect(dataTheme).toBe(storedThemeId)
})
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsFontSelector)
await expect(select).toBeVisible()
const initialFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
})
expect(initialFontFamily).toContain("IBM Plex Mono")
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
await items.nth(2).click()
await page.waitForTimeout(100)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
const newFontFamily = await page.evaluate(() => {
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
})
expect(newFontFamily).not.toBe(initialFontFamily)
})
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsAgentSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.agent).toBe(false)
})
test("toggling notification permissions switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsPermissionsSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.permissions).toBe(false)
})
test("toggling notification errors switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsNotificationsErrorsSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(false)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(true)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.notifications?.errors).toBe(true)
})
test("changing sound agent selection persists in localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const select = dialog.locator(settingsSoundsAgentSelector)
await expect(select).toBeVisible()
await select.locator('[data-slot="select-select-trigger"]').click()
const items = page.locator('[data-slot="select-select-item"]')
await items.nth(2).click()
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.sounds?.agent).not.toBe("staplebops-01")
})
test("toggling updates startup switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsUpdatesStartupSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const isDisabled = await toggleInput.evaluate((el: HTMLInputElement) => el.disabled)
if (isDisabled) {
test.skip()
return
}
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.updates?.startup).toBe(false)
})
test("toggling release notes switch updates localStorage", async ({ page, gotoSession }) => {
await gotoSession()
const dialog = await openSettings(page)
const switchContainer = dialog.locator(settingsReleaseNotesSelector)
await expect(switchContainer).toBeVisible()
const toggleInput = switchContainer.locator('[data-slot="switch-input"]')
const initialState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(initialState).toBe(true)
await switchContainer.locator('[data-slot="switch-control"]').click()
await page.waitForTimeout(100)
const newState = await toggleInput.evaluate((el: HTMLInputElement) => el.checked)
expect(newState).toBe(false)
const stored = await page.evaluate((key) => {
const raw = localStorage.getItem(key)
return raw ? JSON.parse(raw) : null
}, settingsKey)
expect(stored?.general?.releaseNotes).toBe(false)
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)
})

View File

@@ -1,6 +1,5 @@
import { test, expect } from "../fixtures"
import { openSidebar, withSession } from "../actions"
import { promptSelector } from "../selectors"
import { modKey, promptSelector } from "../utils"
test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
@@ -14,7 +13,12 @@ test("sidebar session links navigate to the selected session", async ({ page, sl
try {
await gotoSession(one.id)
await openSidebar(page)
const main = page.locator("main")
const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l")
if (collapsed) {
await page.keyboard.press(`${modKey}+B`)
await expect(main).not.toHaveClass(/xl:border-l/)
}
const target = page.locator(`[data-session-id="${two.id}"] a`).first()
await expect(target).toBeVisible()

View File

@@ -1,14 +1,21 @@
import { test, expect } from "../fixtures"
import { openSidebar, toggleSidebar } from "../actions"
import { modKey } from "../utils"
test("sidebar can be collapsed and expanded", async ({ page, gotoSession }) => {
await gotoSession()
await openSidebar(page)
const main = page.locator("main")
const closedClass = /xl:border-l/
const isClosed = await main.evaluate((node) => node.className.includes("xl:border-l"))
await toggleSidebar(page)
await expect(page.locator("main")).toHaveClass(/xl:border-l/)
if (isClosed) {
await page.keyboard.press(`${modKey}+B`)
await expect(main).not.toHaveClass(closedClass)
}
await toggleSidebar(page)
await expect(page.locator("main")).not.toHaveClass(/xl:border-l/)
await page.keyboard.press(`${modKey}+B`)
await expect(main).toHaveClass(closedClass)
await page.keyboard.press(`${modKey}+B`)
await expect(main).not.toHaveClass(closedClass)
})

View File

@@ -1,94 +0,0 @@
import { test, expect } from "../fixtures"
import { openStatusPopover } from "../actions"
test("status popover opens and shows tabs", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody.getByRole("tab", { name: /servers/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /mcp/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /lsp/i })).toBeVisible()
await expect(popoverBody.getByRole("tab", { name: /plugins/i })).toBeVisible()
await page.keyboard.press("Escape")
await expect(popoverBody).toHaveCount(0)
})
test("status popover servers tab shows current server", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const serversTab = popoverBody.getByRole("tab", { name: /servers/i })
await expect(serversTab).toHaveAttribute("aria-selected", "true")
const serverList = popoverBody.locator('[role="tabpanel"]').first()
await expect(serverList.locator("button").first()).toBeVisible()
})
test("status popover can switch to mcp tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const mcpTab = popoverBody.getByRole("tab", { name: /mcp/i })
await mcpTab.click()
const ariaSelected = await mcpTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const mcpContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(mcpContent).toBeVisible()
})
test("status popover can switch to lsp tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const lspTab = popoverBody.getByRole("tab", { name: /lsp/i })
await lspTab.click()
const ariaSelected = await lspTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const lspContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(lspContent).toBeVisible()
})
test("status popover can switch to plugins tab", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
const pluginsTab = popoverBody.getByRole("tab", { name: /plugins/i })
await pluginsTab.click()
const ariaSelected = await pluginsTab.getAttribute("aria-selected")
expect(ariaSelected).toBe("true")
const pluginsContent = popoverBody.locator('[role="tabpanel"]:visible').first()
await expect(pluginsContent).toBeVisible()
})
test("status popover closes on escape", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody).toBeVisible()
await page.keyboard.press("Escape")
await expect(popoverBody).toHaveCount(0)
})
test("status popover closes when clicking outside", async ({ page, gotoSession }) => {
await gotoSession()
const { popoverBody } = await openStatusPopover(page)
await expect(popoverBody).toBeVisible()
await page.getByRole("main").click({ position: { x: 5, y: 5 } })
await expect(popoverBody).toHaveCount(0)
})

View File

@@ -1,6 +1,5 @@
import { test, expect } from "../fixtures"
import { promptSelector, terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
import { promptSelector, terminalSelector, terminalToggleKey } from "../utils"
test("smoke terminal mounts and can create a second tab", async ({ page, gotoSession }) => {
await gotoSession()

View File

@@ -1,6 +1,5 @@
import { test, expect } from "../fixtures"
import { terminalSelector } from "../selectors"
import { terminalToggleKey } from "../utils"
import { terminalSelector, terminalToggleKey } from "../utils"
test("terminal panel can be toggled", async ({ page, gotoSession }) => {
await gotoSession()

View File

@@ -1,5 +1,5 @@
import { test, expect } from "./fixtures"
import { modelVariantCycleSelector } from "./selectors"
import { modelVariantCycleSelector } from "./utils"
test("smoke model variant cycle updates label", async ({ page, gotoSession }) => {
await gotoSession()

View File

@@ -2,7 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "bun"]
"types": ["node"]
},
"include": ["./**/*.ts"]
}

View File

@@ -10,6 +10,12 @@ export const serverName = `${serverHost}:${serverPort}`
export const modKey = process.platform === "darwin" ? "Meta" : "Control"
export const terminalToggleKey = "Control+Backquote"
export const promptSelector = '[data-component="prompt-input"]'
export const terminalSelector = '[data-component="terminal"]'
export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]'
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
export function createSdk(directory?: string) {
return createOpencodeClient({ baseUrl: serverUrl, directory, throwOnError: true })
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.52",
"version": "1.1.46",
"description": "",
"type": "module",
"exports": {
@@ -54,7 +54,7 @@
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "0.4.0",
"ghostty-web": "0.3.0",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",

View File

@@ -58,7 +58,7 @@ const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-"))
const serverEnv = {
...process.env,
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
OPENCODE_DISABLE_SHARE: "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",

View File

@@ -241,19 +241,19 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _rowEnd(row: number, isLastRow: boolean): void {
let rowSeparator = ""
const nextLine = isLastRow ? undefined : this._buffer.getLine(row + 1)
const wrapped = !!nextLine?.isWrapped
if (this._nullCellCount > 0 && wrapped) {
if (this._nullCellCount > 0) {
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
this._nullCellCount = 0
if (!isLastRow) {
const nextLine = this._buffer.getLine(row + 1)
if (!isLastRow && !wrapped) {
rowSeparator = "\r\n"
this._lastCursorRow = row + 1
this._lastCursorCol = 0
if (!nextLine?.isWrapped) {
rowSeparator = "\r\n"
this._lastCursorRow = row + 1
this._lastCursorCol = 0
}
}
this._allRows[this._rowIndex] = this._currentRow
@@ -389,7 +389,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
const styleChanged = sgrSeq.length > 0
const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
if (styleChanged) {
if (this._nullCellCount > 0) {
@@ -442,24 +442,12 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
}
if (excludeFinalCursorPosition) return content
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
const cursorCol = this._buffer.cursorX + 1
content += `\u001b[${cursorRow};${cursorCol}H`
const line = this._buffer.getLine(absoluteCursorRow)
const cell = line?.getCell(this._buffer.cursorX)
const style = (() => {
if (!cell) return this._buffer.getNullCell()
if (cell.getWidth() !== 0) return cell
if (this._buffer.cursorX > 0) return line?.getCell(this._buffer.cursorX - 1) ?? cell
return cell
})()
const sgrSeq = this._diffStyle(style, this._cursorStyle)
if (sgrSeq.length) content += `\u001b[${sgrSeq.join(";")}m`
if (!excludeFinalCursorPosition) {
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
const cursorCol = this._buffer.cursorX + 1
content += `\u001b[${cursorRow};${cursorCol}H`
}
return content
}

View File

@@ -158,22 +158,22 @@ export function DialogEditProject(props: { project: LocalProject }) {
</Show>
</div>
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
class="absolute inset-0 size-16 bg-black/60 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": store.iconHover && !store.iconUrl,
"opacity-0": !(store.iconHover && !store.iconUrl),
}}
>
<Icon name="cloud-upload" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
<Icon name="cloud-upload" size="large" class="text-icon-invert-base" />
</div>
<div
class="absolute inset-0 size-16 bg-surface-raised-stronger-non-alpha/90 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
class="absolute inset-0 size-16 bg-black/60 rounded-[6px] z-10 pointer-events-none flex items-center justify-center transition-opacity"
classList={{
"opacity-100": store.iconHover && !!store.iconUrl,
"opacity-0": !(store.iconHover && !!store.iconUrl),
}}
>
<Icon name="trash" size="large" class="text-icon-on-interactive-base drop-shadow-sm" />
<Icon name="trash" size="large" class="text-icon-invert-base" />
</div>
</div>
<input id="icon-upload" type="file" accept="image/*" class="hidden" onChange={handleInputChange} />

View File

@@ -4,11 +4,10 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import fuzzysort from "fuzzysort"
import { createMemo, createResource, createSignal } from "solid-js"
import { createMemo } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import type { ListRef } from "@opencode-ai/ui/list"
interface DialogSelectDirectoryProps {
title?: string
@@ -16,47 +15,18 @@ interface DialogSelectDirectoryProps {
onSelect: (result: string | string[] | null) => void
}
type Row = {
absolute: string
search: string
}
export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const sync = useGlobalSync()
const sdk = useGlobalSDK()
const dialog = useDialog()
const language = useLanguage()
const [filter, setFilter] = createSignal("")
const home = createMemo(() => sync.data.path.home)
let list: ListRef | undefined
const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
const [fallbackPath] = createResource(
() => (missingBase() ? true : undefined),
async () => {
return sdk.client.path
.get()
.then((x) => x.data)
.catch(() => undefined)
},
{ initialValue: undefined },
)
const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
const start = createMemo(
() => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
)
const start = createMemo(() => sync.data.path.home || sync.data.path.directory)
const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
const clean = (value: string) => {
const first = (value ?? "").split(/\r?\n/)[0] ?? ""
return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
}
function normalize(input: string) {
const v = input.replaceAll("\\", "/")
if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
@@ -94,67 +64,24 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
return ""
}
function parentOf(input: string) {
const v = trimTrailing(input)
if (v === "/") return v
if (v === "//") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
return v.slice(0, i)
}
function modeOf(input: string) {
const raw = normalizeDriveRoot(input.trim())
if (!raw) return "relative" as const
if (raw.startsWith("~")) return "tilde" as const
if (rootOf(raw)) return "absolute" as const
return "relative" as const
}
function display(path: string, input: string) {
function display(path: string) {
const full = trimTrailing(path)
if (modeOf(input) === "absolute") return full
return tildeOf(full) || full
}
function tildeOf(absolute: string) {
const full = trimTrailing(absolute)
const h = home()
if (!h) return ""
if (!h) return full
const hn = trimTrailing(h)
const lc = full.toLowerCase()
const hc = hn.toLowerCase()
if (lc === hc) return "~"
if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
return ""
return full
}
function row(absolute: string): Row {
const full = trimTrailing(absolute)
const tilde = tildeOf(full)
const withSlash = (value: string) => {
if (!value) return ""
if (value.endsWith("/")) return value
return value + "/"
}
const search = Array.from(
new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
).join("\n")
return { absolute: full, search }
}
function scoped(value: string) {
function scoped(filter: string) {
const base = start()
if (!base) return
const raw = normalizeDriveRoot(value)
const raw = normalizeDriveRoot(filter.trim())
if (!raw) return { directory: trimTrailing(base), path: "" }
const h = home()
@@ -195,25 +122,21 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}
const directories = async (filter: string) => {
const value = clean(filter)
const scopedInput = scoped(value)
if (!scopedInput) return [] as string[]
const input = scoped(filter)
if (!input) return [] as string[]
const raw = normalizeDriveRoot(value)
const raw = normalizeDriveRoot(filter.trim())
const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
const query = normalizeDriveRoot(scopedInput.path)
const query = normalizeDriveRoot(input.path)
const find = () =>
sdk.client.find
.files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
if (!isPath) {
const results = await sdk.client.find
.files({ directory: input.directory, query, type: "directory", limit: 50 })
.then((x) => x.data ?? [])
.catch(() => [])
if (!isPath) {
const results = await find()
return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
return results.map((rel) => join(input.directory, rel)).slice(0, 50)
}
const segments = query.replace(/^\/+/, "").split("/")
@@ -222,10 +145,17 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
const cap = 12
const branch = 4
let paths = [scopedInput.directory]
let paths = [input.directory]
for (const part of head) {
if (part === "..") {
paths = paths.map(parentOf)
paths = paths.map((p) => {
const v = trimTrailing(p)
if (v === "/") return v
if (/^[A-Za-z]:\/$/.test(v)) return v
const i = v.lastIndexOf("/")
if (i <= 0) return "/"
return v.slice(0, i)
})
continue
}
@@ -235,27 +165,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
}
const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
const deduped = Array.from(new Set(out))
const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
const expand = !raw.endsWith("/")
if (!expand || !tail) {
const items = base ? Array.from(new Set([base, ...deduped])) : deduped
return items.slice(0, 50)
}
const needle = tail.toLowerCase()
const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
const target = exact[0]
if (!target) return deduped.slice(0, 50)
const children = await match(target, "", 30)
const items = Array.from(new Set([...deduped, ...children]))
return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
}
const items = async (value: string) => {
const results = await directories(value)
return results.map(row)
return Array.from(new Set(out)).slice(0, 50)
}
function resolve(absolute: string) {
@@ -269,52 +179,24 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
emptyMessage={language.t("dialog.directory.empty")}
loadingMessage={language.t("common.loading")}
items={items}
key={(x) => x.absolute}
filterKeys={["search"]}
ref={(r) => (list = r)}
onFilter={(value) => setFilter(clean(value))}
onKeyEvent={(e, item) => {
if (e.key !== "Tab") return
if (e.shiftKey) return
if (!item) return
e.preventDefault()
e.stopPropagation()
const value = display(item.absolute, filter())
list?.setFilter(value.endsWith("/") ? value : value + "/")
}}
items={directories}
key={(x) => x}
onSelect={(path) => {
if (!path) return
resolve(path.absolute)
resolve(path)
}}
>
{(item) => {
const path = display(item.absolute, filter())
if (path === "~") {
return (
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-strong whitespace-nowrap">~</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>
)
}
{(absolute) => {
const path = display(absolute)
return (
<div class="w-full flex items-center justify-between rounded-md">
<div class="flex items-center gap-x-3 grow min-w-0">
<FileIcon node={{ path: item.absolute, type: "directory" }} class="shrink-0 size-4" />
<FileIcon node={{ path: absolute, type: "directory" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular min-w-0">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(path)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
<span class="text-text-weak whitespace-nowrap">/</span>
</div>
</div>
</div>

View File

@@ -1,22 +1,17 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { Keybind } from "@opencode-ai/ui/keybind"
import { List } from "@opencode-ai/ui/list"
import { base64Encode } from "@opencode-ai/util/encode"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useNavigate, useParams } from "@solidjs/router"
import { createMemo, createSignal, Match, onCleanup, Show, Switch } from "solid-js"
import { useParams } from "@solidjs/router"
import { createMemo, createSignal, onCleanup, Show } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { decode64 } from "@/utils/base64"
type EntryType = "command" | "file" | "session"
type EntryType = "command" | "file"
type Entry = {
id: string
@@ -27,9 +22,6 @@ type Entry = {
category: string
option?: CommandOption
path?: string
directory?: string
sessionID?: string
archived?: number
}
type DialogSelectFileMode = "all" | "files"
@@ -41,13 +33,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const file = useFile()
const dialog = useDialog()
const params = useParams()
const navigate = useNavigate()
const globalSDK = useGlobalSDK()
const globalSync = useGlobalSync()
const filesOnly = () => props.mode === "files"
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const state = { cleanup: undefined as (() => void) | void, committed: false }
const [grouped, setGrouped] = createSignal(false)
const common = [
@@ -85,52 +73,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
path,
})
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const workspaces = createMemo(() => {
const directory = projectDirectory()
const current = project()
if (!current) return directory ? [directory] : []
const dirs = [current.worktree, ...(current.sandboxes ?? [])]
if (directory && !dirs.includes(directory)) return [...dirs, directory]
return dirs
})
const homedir = createMemo(() => globalSync.data.path.home)
const label = (directory: string) => {
const current = project()
const kind =
current && directory === current.worktree
? language.t("workspace.type.local")
: language.t("workspace.type.sandbox")
const [store] = globalSync.child(directory, { bootstrap: false })
const home = homedir()
const path = home ? directory.replace(home, "~") : directory
const name = store.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name || path}`
}
const sessionItem = (input: {
directory: string
id: string
title: string
description: string
archived?: number
}): Entry => ({
id: `session:${input.directory}:${input.id}`,
type: "session",
title: input.title,
description: input.description,
category: language.t("command.category.session"),
directory: input.directory,
sessionID: input.id,
archived: input.archived,
})
const list = createMemo(() => allowed().map(commandItem))
const picks = createMemo(() => {
@@ -180,68 +122,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return out
}
const sessionToken = { value: 0 }
let sessionInflight: Promise<Entry[]> | undefined
let sessionAll: Entry[] | undefined
const sessions = (text: string) => {
const query = text.trim()
if (!query) {
sessionToken.value += 1
sessionInflight = undefined
sessionAll = undefined
return [] as Entry[]
}
if (sessionAll) return sessionAll
if (sessionInflight) return sessionInflight
const current = sessionToken.value
const dirs = workspaces()
if (dirs.length === 0) return [] as Entry[]
sessionInflight = Promise.all(
dirs.map((directory) => {
const description = label(directory)
return globalSDK.client.session
.list({ directory, roots: true })
.then((x) =>
(x.data ?? [])
.filter((s) => !!s?.id)
.map((s) => ({
id: s.id,
title: s.title ?? language.t("command.session.new"),
description,
directory,
archived: s.time?.archived,
})),
)
.catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[])
}),
)
.then((results) => {
if (sessionToken.value !== current) return [] as Entry[]
const seen = new Set<string>()
const next = results
.flat()
.filter((item) => {
const key = `${item.directory}:${item.id}`
if (seen.has(key)) return false
seen.add(key)
return true
})
.map(sessionItem)
sessionAll = next
return next
})
.catch(() => [] as Entry[])
.finally(() => {
sessionInflight = undefined
})
return sessionInflight
}
const items = async (text: string) => {
const query = text.trim()
setGrouped(query.length > 0)
@@ -266,10 +146,9 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const files = await file.searchFiles(query)
return files.map(fileItem)
}
const [files, nextSessions] = await Promise.all([file.searchFiles(query), Promise.resolve(sessions(query))])
const files = await file.searchFiles(query)
const entries = files.map(fileItem)
return [...list(), ...nextSessions, ...entries]
return [...list(), ...entries]
}
const handleMove = (item: Entry | undefined) => {
@@ -283,7 +162,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
const value = file.tab(path)
tabs().open(value)
file.load(path)
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
props.onOpenFile?.(path)
@@ -300,12 +178,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
return
}
if (item.type === "session") {
if (!item.directory || !item.sessionID) return
navigate(`/${base64Encode(item.directory)}/session/${item.sessionID}`)
return
}
if (!item.path) return
open(item.path)
}
@@ -330,12 +202,13 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
groupBy={grouped() ? (item) => item.category : () => ""}
groupBy={(item) => item.category}
onMove={handleMove}
onSelect={handleSelect}
>
{(item) => (
<Switch
<Show
when={item.type === "command"}
fallback={
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0">
@@ -350,43 +223,18 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
</div>
}
>
<Match when={item.type === "command"}>
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
<Show when={item.description}>
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
</Show>
</div>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
<div class="w-full flex items-center justify-between gap-4">
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
<Show when={item.description}>
<span class="text-14-regular text-text-weak truncate">{item.description}</span>
</Show>
</div>
</Match>
<Match when={item.type === "session"}>
<div class="w-full flex items-center justify-between rounded-md pl-1">
<div class="flex items-center gap-x-3 grow min-w-0">
<Icon name="bubble-5" size="small" class="shrink-0 text-icon-weak" />
<div class="flex items-center gap-2 min-w-0">
<span
class="text-14-regular text-text-strong truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.title}
</span>
<Show when={item.description}>
<span
class="text-14-regular text-text-weak truncate"
classList={{ "opacity-70": !!item.archived }}
>
{item.description}
</span>
</Show>
</div>
</div>
</div>
</Match>
</Switch>
<Show when={item.keybind}>
<Keybind class="rounded-[4px]">{formatKeybind(item.keybind ?? "")}</Keybind>
</Show>
</div>
</Show>
)}
</List>
</Dialog>

View File

@@ -54,6 +54,7 @@ const ModelList: Component<{
class="w-full"
placement="right-start"
gutter={12}
forceMount={false}
value={
<ModelTooltip
model={item}
@@ -89,7 +90,7 @@ const ModelList: Component<{
export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
provider?: string
children?: JSX.Element
children?: JSX.Element | ((open: boolean) => JSX.Element)
triggerAs?: T
triggerProps?: ComponentProps<T>
}) {
@@ -181,12 +182,13 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
as={props.triggerAs ?? "div"}
{...(props.triggerProps as any)}
>
{props.children}
{typeof props.children === "function" ? props.children(store.open) : props.children}
</Kobalte.Trigger>
<Kobalte.Portal>
<Kobalte.Content
class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
data-component="model-popover-content"
ref={(el) => setStore("content", el)}
class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
onEscapeKeyDown={(event) => {
setStore("dismiss", "escape")
setStore("open", false)
@@ -213,7 +215,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
class="p-1"
action={
<div class="flex items-center gap-1">
<Tooltip placement="top" value={language.t("command.provider.connect")}>
<Tooltip placement="top" forceMount={false} value={language.t("command.provider.connect")}>
<IconButton
icon="plus-small"
variant="ghost"
@@ -223,7 +225,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
onClick={handleConnectProvider}
/>
</Tooltip>
<Tooltip placement="top" value={language.t("dialog.model.manage")}>
<Tooltip placement="top" forceMount={false} value={language.t("dialog.model.manage")}>
<IconButton
icon="sliders"
variant="ghost"

View File

@@ -130,57 +130,10 @@ export default function FileTree(props: {
const nodes = file.tree.children(props.path)
const current = filter()
if (!current) return nodes
const parent = (path: string) => {
const idx = path.lastIndexOf("/")
if (idx === -1) return ""
return path.slice(0, idx)
}
const leaf = (path: string) => {
const idx = path.lastIndexOf("/")
return idx === -1 ? path : path.slice(idx + 1)
}
const out = nodes.filter((node) => {
return nodes.filter((node) => {
if (node.type === "file") return current.files.has(node.path)
return current.dirs.has(node.path)
})
const seen = new Set(out.map((node) => node.path))
for (const dir of current.dirs) {
if (parent(dir) !== props.path) continue
if (seen.has(dir)) continue
out.push({
name: leaf(dir),
path: dir,
absolute: dir,
type: "directory",
ignored: false,
})
seen.add(dir)
}
for (const item of current.files) {
if (parent(item) !== props.path) continue
if (seen.has(item)) continue
out.push({
name: leaf(item),
path: item,
absolute: item,
type: "file",
ignored: false,
})
seen.add(item)
}
return out.toSorted((a, b) => {
if (a.type !== b.type) {
return a.type === "directory" ? -1 : 1
}
return a.name.localeCompare(b.name)
})
})
const Node = (
@@ -241,7 +194,7 @@ export default function FileTree(props: {
: kind === "del"
? "color: var(--icon-diff-delete-base)"
: kind === "mix"
? "color: var(--icon-warning-active)"
? "color: var(--icon-diff-modified-base)"
: undefined
return (
<span
@@ -268,7 +221,7 @@ export default function FileTree(props: {
? "color: var(--icon-diff-add-base)"
: kind === "del"
? "color: var(--icon-diff-delete-base)"
: "color: var(--icon-warning-active)"
: "color: var(--icon-diff-modified-base)"
return (
<span class="shrink-0 w-4 text-center text-12-medium" style={color}>
@@ -283,7 +236,7 @@ export default function FileTree(props: {
? "background-color: var(--icon-diff-add-base)"
: kind === "del"
? "background-color: var(--icon-diff-delete-base)"
: "background-color: var(--icon-warning-active)"
: "background-color: var(--icon-diff-modified-base)"
return <div class="shrink-0 size-1.5 mr-1.5 rounded-full" style={color} />
}
@@ -321,6 +274,7 @@ export default function FileTree(props: {
return (
<Tooltip
forceMount={false}
openDelay={2000}
placement="bottom-start"
class="w-full"

View File

@@ -32,7 +32,9 @@ import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
import { Button } from "@opencode-ai/ui/button"
import { CycleLabel } from "@opencode-ai/ui/cycle-label"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
@@ -42,6 +44,7 @@ import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
@@ -112,7 +115,6 @@ interface SlashCommand {
description?: string
keybind?: string
type: "builtin" | "custom"
source?: "command" | "mcp" | "skill"
}
export const PromptInput: Component<PromptInputProps> = (props) => {
@@ -172,7 +174,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const commentInReview = (path: string) => {
const sessionID = params.id
@@ -191,14 +192,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
if (wantsReview) {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("changes")
requestAnimationFrame(() => comments.setFocus(focus))
return
}
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
const tab = files.tab(item.path)
@@ -521,7 +520,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
title: cmd.name,
description: cmd.description,
type: "custom" as const,
source: cmd.source,
}))
return [...custom, ...builtin]
@@ -1135,7 +1133,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const images = imageAttachments().slice()
const mode = store.mode
if (text.trim().length === 0 && images.length === 0 && commentCount() === 0) {
if (text.trim().length === 0 && images.length === 0) {
if (working()) abort()
return
}
@@ -1223,10 +1221,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
return undefined
})
if (session) {
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
if (!session) return
@@ -1260,7 +1255,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput()
client.session
.shell({
sessionID: session.id,
sessionID: session?.id || "",
agent,
model,
command: text,
@@ -1283,7 +1278,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
clearInput()
client.session
.command({
sessionID: session.id,
sessionID: session?.id || "",
command: commandName,
arguments: args.join(" "),
agent,
@@ -1439,13 +1434,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const optimisticParts = requestParts.map((part) => ({
...part,
sessionID: session.id,
sessionID: session?.id || "",
messageID,
})) as unknown as Part[]
const optimisticMessage: Message = {
id: messageID,
sessionID: session.id,
sessionID: session?.id || "",
role: "user",
time: { created: Date.now() },
agent,
@@ -1456,9 +1451,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (sessionDirectory === projectDirectory) {
sync.set(
produce((draft) => {
const messages = draft.message[session.id]
const messages = draft.message[session?.id || ""]
if (!messages) {
draft.message[session.id] = [optimisticMessage]
draft.message[session?.id || ""] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
@@ -1466,7 +1461,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draft.part[messageID] = optimisticParts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
.sort((a, b) => a.id.localeCompare(b.id))
}),
)
return
@@ -1474,9 +1469,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1](
produce((draft) => {
const messages = draft.message[session.id]
const messages = draft.message[session?.id || ""]
if (!messages) {
draft.message[session.id] = [optimisticMessage]
draft.message[session?.id || ""] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
@@ -1484,7 +1479,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
draft.part[messageID] = optimisticParts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
.sort((a, b) => a.id.localeCompare(b.id))
}),
)
}
@@ -1493,7 +1488,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (sessionDirectory === projectDirectory) {
sync.set(
produce((draft) => {
const messages = draft.message[session.id]
const messages = draft.message[session?.id || ""]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
@@ -1506,7 +1501,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
globalSync.child(sessionDirectory)[1](
produce((draft) => {
const messages = draft.message[session.id]
const messages = draft.message[session?.id || ""]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
@@ -1527,15 +1522,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const worktree = WorktreeState.get(sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "busy" })
if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session?.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session?.id, { type: "idle" })
}
removeOptimisticMessage()
for (const item of commentItems) {
@@ -1552,7 +1547,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
restoreInput()
}
pending.set(session.id, { abort: controller, cleanup })
pending.set(session?.id || "", { abort: controller, cleanup })
const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
if (controller.signal.aborted) {
@@ -1580,7 +1575,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
pending.delete(session.id)
pending.delete(session?.id || "")
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
@@ -1590,7 +1585,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.prompt({
sessionID: session.id,
sessionID: session?.id || "",
agent,
model,
messageID,
@@ -1600,9 +1595,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
void send().catch((err) => {
pending.delete(session.id)
if (sessionDirectory === projectDirectory) {
sync.set("session_status", session.id, { type: "idle" })
pending.delete(session?.id || "")
if (sessionDirectory === projectDirectory && session?.id) {
sync.set("session_status", session?.id, { type: "idle" })
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
@@ -1624,6 +1619,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
}
const currrentModelVariant = createMemo(() => {
const modelVariant = local.model.variant.current() ?? ""
return modelVariant === "xhigh"
? "xHigh"
: modelVariant.length > 0
? modelVariant[0].toUpperCase() + modelVariant.slice(1)
: "Default"
})
const reasoningPercentage = createMemo(() => {
const variants = local.model.variant.list()
const current = local.model.variant.current()
const totalEntries = variants.length + 1
if (totalEntries <= 2 || current === "Default") {
return 0
}
const currentIndex = current ? variants.indexOf(current) + 1 : 0
return ((currentIndex + 1) / totalEntries) * 100
}, [local.model.variant])
return (
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popover}>
@@ -1676,7 +1693,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</>
}
>
<Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
<Icon name="brain" size="normal" class="text-icon-info-active shrink-0" />
<span class="text-14-regular text-text-strong whitespace-nowrap">
@{(item as { type: "agent"; name: string }).name}
</span>
@@ -1709,13 +1726,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Show>
</div>
<div class="flex items-center gap-2 shrink-0">
<Show when={cmd.type === "custom" && cmd.source !== "command"}>
<Show when={cmd.type === "custom"}>
<span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
{cmd.source === "skill"
? language.t("prompt.slash.badge.skill")
: cmd.source === "mcp"
? language.t("prompt.slash.badge.mcp")
: language.t("prompt.slash.badge.custom")}
{language.t("prompt.slash.badge.custom")}
</span>
</Show>
<Show when={command.keybind(cmd.id)}>
@@ -1741,9 +1754,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
>
<Show when={store.dragging}>
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
<div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 mr-1 pointer-events-none">
<div class="flex flex-col items-center gap-2 text-text-weak">
<Icon name="photo" class="size-8" />
<Icon name="photo" size={18} class="text-icon-base stroke-1.5" />
<span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
</div>
</div>
@@ -1782,7 +1795,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
>
<div class="flex items-center gap-1.5">
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
<FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-7" />
<div class="flex items-center text-11-regular min-w-0 font-medium">
<span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
<Show when={item.selection}>
@@ -1799,7 +1812,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
type="button"
icon="close-small"
variant="ghost"
class="ml-auto size-3.5 opacity-0 group-hover:opacity-100 transition-all"
class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID)
@@ -1829,7 +1842,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
when={attachment.mime.startsWith("image/")}
fallback={
<div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
<Icon name="folder" class="size-6 text-text-weak" />
<Icon name="folder" size="normal" class="size-6 text-text-base" />
</div>
}
>
@@ -1902,8 +1915,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</div>
</Show>
</div>
<div class="relative p-3 flex items-center justify-between gap-2">
<div class="flex items-center gap-2 min-w-0 flex-1">
<div class="relative p-3 flex items-center justify-between">
<div class="flex items-center justify-start gap-2">
<Switch>
<Match when={store.mode === "shell"}>
<div class="flex items-center gap-2 px-2 h-6">
@@ -1915,7 +1928,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Match when={store.mode === "normal"}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.agent.cycle")}
keybind={command.keybind("agent.cycle")}
>
@@ -1923,8 +1935,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
options={local.agent.list().map((agent) => agent.name)}
current={local.agent.current()?.name ?? ""}
onSelect={local.agent.set}
class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`}
valueClass="truncate"
class="capitalize"
variant="ghost"
/>
</TooltipKeybind>
@@ -1933,68 +1944,64 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
fallback={
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<Button
as="div"
variant="ghost"
class="px-2 min-w-0 max-w-[240px]"
onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
class="px-2"
onClick={() => dialog.render(<DialogSelectModelUnpaid />, "select-model")}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
<MorphChevron expanded={dialog.isActive("select-model")} />
</Button>
</TooltipKeybind>
}
>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
<ModelSelectorPopover
triggerAs={Button}
triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[240px]" }}
>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
<span class="truncate">
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
</span>
<Icon name="chevron-down" size="small" class="shrink-0" />
<ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }}>
{(open) => (
<>
<Show when={local.model.current()?.provider?.id}>
<ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
</Show>
{local.model.current()?.name ?? language.t("dialog.model.select.title")}
<MorphChevron expanded={open} class="text-text-weak" />
</>
)}
</ModelSelectorPopover>
</TooltipKeybind>
</Show>
<Show when={local.model.variant.list().length > 0}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.model.variant.cycle")}
keybind={command.keybind("model.variant.cycle")}
>
<Button
data-action="model-variant-cycle"
variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
class="text-text-strong text-12-regular"
onClick={() => local.model.variant.cycle()}
>
{local.model.variant.current() ?? language.t("common.default")}
<Show when={local.model.variant.list().length > 1}>
<ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
</Show>
<CycleLabel value={currrentModelVariant()} />
</Button>
</TooltipKeybind>
</Show>
<Show when={permission.permissionsEnabled() && params.id}>
<TooltipKeybind
placement="top"
gutter={8}
title={language.t("command.permissions.autoaccept.enable")}
keybind={command.keybind("permissions.autoaccept")}
>
@@ -2002,7 +2009,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
"_hidden group-hover/prompt-input:flex items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
@@ -2024,7 +2031,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</Match>
</Switch>
</div>
<div class="flex items-center gap-1 shrink-0">
<div class="flex items-center gap-1 absolute right-3 bottom-3">
<input
ref={fileInputRef}
type="file"
@@ -2036,18 +2043,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
e.currentTarget.value = ""
}}
/>
<div class="flex items-center gap-1 mr-1">
<div class="flex items-center gap-1.5 mr-1.5">
<SessionContextUsage />
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
<Button
type="button"
variant="ghost"
class="size-6 px-1"
size="small"
class="px-1"
onClick={() => fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")}
>
<Icon name="photo" class="size-4.5" />
<Icon name="photo" class="size-6 text-icon-base" />
</Button>
</Tooltip>
</Show>
@@ -2066,7 +2074,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<Match when={true}>
<div class="flex items-center gap-2">
<span>{language.t("prompt.action.send")}</span>
<Icon name="enter" size="small" class="text-icon-base" />
<Icon name="enter" size="normal" class="text-icon-base" />
</div>
</Match>
</Switch>
@@ -2074,10 +2082,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<IconButton
type="submit"
disabled={!prompt.dirty() && !working() && commentCount() === 0}
disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-6 w-4.5"
class="h-6 w-5.5"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
</Tooltip>

View File

@@ -1,295 +0,0 @@
import { For, Show, createMemo, type Component } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
export const QuestionDock: Component<{ request: QuestionRequest }> = (props) => {
const sdk = useSDK()
const language = useLanguage()
const questions = createMemo(() => props.request.questions)
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
const [store, setStore] = createStore({
tab: 0,
answers: [] as QuestionAnswer[],
custom: [] as string[],
editing: false,
sending: false,
})
const question = createMemo(() => questions()[store.tab])
const confirm = createMemo(() => !single() && store.tab === questions().length)
const options = createMemo(() => question()?.options ?? [])
const input = createMemo(() => store.custom[store.tab] ?? "")
const multi = createMemo(() => question()?.multiple === true)
const customPicked = createMemo(() => {
const value = input()
if (!value) return false
return store.answers[store.tab]?.includes(value) ?? false
})
const fail = (err: unknown) => {
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
}
const reply = (answers: QuestionAnswer[]) => {
if (store.sending) return
setStore("sending", true)
sdk.client.question
.reply({ requestID: props.request.id, answers })
.catch(fail)
.finally(() => setStore("sending", false))
}
const reject = () => {
if (store.sending) return
setStore("sending", true)
sdk.client.question
.reject({ requestID: props.request.id })
.catch(fail)
.finally(() => setStore("sending", false))
}
const submit = () => {
reply(questions().map((_, i) => store.answers[i] ?? []))
}
const pick = (answer: string, custom: boolean = false) => {
const answers = [...store.answers]
answers[store.tab] = [answer]
setStore("answers", answers)
if (custom) {
const inputs = [...store.custom]
inputs[store.tab] = answer
setStore("custom", inputs)
}
if (single()) {
reply([[answer]])
return
}
setStore("tab", store.tab + 1)
}
const toggle = (answer: string) => {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
const index = next.indexOf(answer)
if (index === -1) next.push(answer)
if (index !== -1) next.splice(index, 1)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
}
const selectTab = (index: number) => {
setStore("tab", index)
setStore("editing", false)
}
const selectOption = (optIndex: number) => {
if (store.sending) return
if (optIndex === options().length) {
setStore("editing", true)
return
}
const opt = options()[optIndex]
if (!opt) return
if (multi()) {
toggle(opt.label)
return
}
pick(opt.label)
}
const handleCustomSubmit = (e: Event) => {
e.preventDefault()
if (store.sending) return
const value = input().trim()
if (!value) {
setStore("editing", false)
return
}
if (multi()) {
const existing = store.answers[store.tab] ?? []
const next = [...existing]
if (!next.includes(value)) next.push(value)
const answers = [...store.answers]
answers[store.tab] = next
setStore("answers", answers)
setStore("editing", false)
return
}
pick(value, true)
setStore("editing", false)
}
return (
<div data-component="question-prompt">
<Show when={!single()}>
<div data-slot="question-tabs">
<For each={questions()}>
{(q, index) => {
const active = () => index() === store.tab
const answered = () => (store.answers[index()]?.length ?? 0) > 0
return (
<button
data-slot="question-tab"
data-active={active()}
data-answered={answered()}
disabled={store.sending}
onClick={() => selectTab(index())}
>
{q.header}
</button>
)
}}
</For>
<button
data-slot="question-tab"
data-active={confirm()}
disabled={store.sending}
onClick={() => selectTab(questions().length)}
>
{language.t("ui.common.confirm")}
</button>
</div>
</Show>
<Show when={!confirm()}>
<div data-slot="question-content">
<div data-slot="question-text">
{question()?.question}
{multi() ? " " + language.t("ui.question.multiHint") : ""}
</div>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
disabled={store.sending}
onClick={() => selectOption(i())}
>
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
<Show when={picked()}>
<Icon name="check-small" size="normal" />
</Show>
</button>
)
}}
</For>
<button
data-slot="question-option"
data-picked={customPicked()}
disabled={store.sending}
onClick={() => selectOption(options().length)}
>
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<Show when={!store.editing && input()}>
<span data-slot="option-description">{input()}</span>
</Show>
<Show when={customPicked()}>
<Icon name="check-small" size="normal" />
</Show>
</button>
<Show when={store.editing}>
<form data-slot="custom-input-form" onSubmit={handleCustomSubmit}>
<input
ref={(el) => setTimeout(() => el.focus(), 0)}
type="text"
data-slot="custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
value={input()}
disabled={store.sending}
onInput={(e) => {
const inputs = [...store.custom]
inputs[store.tab] = e.currentTarget.value
setStore("custom", inputs)
}}
/>
<Button type="submit" variant="primary" size="small" disabled={store.sending}>
{multi() ? language.t("ui.common.add") : language.t("ui.common.submit")}
</Button>
<Button
type="button"
variant="ghost"
size="small"
disabled={store.sending}
onClick={() => setStore("editing", false)}
>
{language.t("ui.common.cancel")}
</Button>
</form>
</Show>
</div>
</div>
</Show>
<Show when={confirm()}>
<div data-slot="question-review">
<div data-slot="review-title">{language.t("ui.messagePart.review.title")}</div>
<For each={questions()}>
{(q, index) => {
const value = () => store.answers[index()]?.join(", ") ?? ""
const answered = () => Boolean(value())
return (
<div data-slot="review-item">
<span data-slot="review-label">{q.question}</span>
<span data-slot="review-value" data-answered={answered()}>
{answered() ? value() : language.t("ui.question.review.notAnswered")}
</span>
</div>
)
}}
</For>
</div>
</Show>
<div data-slot="question-actions">
<Button variant="ghost" size="small" onClick={reject} disabled={store.sending}>
{language.t("ui.common.dismiss")}
</Button>
<Show when={!single()}>
<Show when={confirm()}>
<Button variant="primary" size="small" onClick={submit} disabled={store.sending}>
{language.t("ui.common.submit")}
</Button>
</Show>
<Show when={!confirm() && multi()}>
<Button
variant="secondary"
size="small"
onClick={() => selectTab(store.tab + 1)}
disabled={store.sending || (store.answers[store.tab]?.length ?? 0) === 0}
>
{language.t("ui.common.next")}
</Button>
</Show>
</Show>
</div>
</div>
)
}

View File

@@ -23,7 +23,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const variant = createMemo(() => props.variant ?? "button")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const usd = createMemo(
@@ -58,7 +57,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => {
if (!params.id) return
if (!view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.open()
layout.fileTree.setTab("all")
tabs().open("context")
@@ -66,8 +64,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
}
const circle = () => (
<div class="flex items-center justify-center">
<ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
<div class="text-icon-base">
<ProgressCircle size={18} percentage={context()?.percentage ?? 0} />
</div>
)
@@ -103,7 +101,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<Button
type="button"
variant="ghost"
class="size-6"
class="size-7 text-icon-base"
onClick={openContext}
aria-label={language.t("context.usage.view")}
>

View File

@@ -6,23 +6,18 @@ import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path"
import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { Keybind } from "@opencode-ai/ui/keybind"
import { showToast } from "@opencode-ai/ui/toast"
import { StatusPopover } from "../status-popover"
export function SessionHeader() {
@@ -30,7 +25,6 @@ export function SessionHeader() {
const layout = useLayout()
const params = useParams()
const command = useCommand()
const server = useServer()
const sync = useSync()
const platform = usePlatform()
const language = useLanguage()
@@ -54,117 +48,6 @@ export function SessionHeader() {
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey))
const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"xcode",
"android-studio",
"powershell",
] as const
type OpenApp = (typeof OPEN_APPS)[number]
const os = createMemo<"macos" | "windows" | "linux" | "unknown">(() => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
})
const options = createMemo(() => {
if (os() === "macos") {
return [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "Visual Studio Code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" },
{ id: "antigravity", label: "Antigravity", icon: "antigravity", openWith: "Antigravity" },
{ id: "finder", label: "Finder", icon: "finder" },
{ id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" },
{ id: "android-studio", label: "Android Studio", icon: "android-studio", openWith: "Android Studio" },
] as const
}
if (os() === "windows") {
return [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "finder", label: "File Explorer", icon: "finder" },
{ id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" },
] as const
}
return [
{ id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "Zed", icon: "zed", openWith: "zed" },
{ id: "finder", label: "File Manager", icon: "finder" },
] as const
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
createEffect(() => {
if (platform.platform !== "desktop") return
const value = prefs.app
if (options().some((o) => o.id === value)) return
setPrefs("app", options()[0]?.id ?? "finder")
})
const openDir = (app: OpenApp) => {
const directory = projectDirectory()
if (!directory) return
if (!canOpen()) return
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
}
const copyPath = () => {
const directory = projectDirectory()
if (!directory) return
navigator.clipboard
.writeText(directory)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: directory,
})
})
.catch((err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
}
const [state, setState] = createStore({
share: false,
unshare: false,
@@ -267,76 +150,6 @@ export function SessionHeader() {
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-3">
<Show when={projectDirectory()}>
<Show
when={canOpen()}
fallback={
<Button
variant="ghost"
class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">{language.t("session.header.open.copyPath")}</span>
</Button>
}
>
<div class="flex items-center">
<Button
variant="ghost"
class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none rounded-r-none"
onClick={() => openDir(current().id)}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<AppIcon id={current().icon} class="size-5" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.action", { app: current().label })}
</span>
</Button>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
class="rounded-sm h-[24px] w-auto px-1.5 border-none shadow-none rounded-l-none data-[expanded]:bg-surface-raised-base-active"
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content placement="bottom-end" gutter={6}>
<DropdownMenu.Group>
<DropdownMenu.GroupLabel>{language.t("session.header.openIn")}</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
value={prefs.app}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
setPrefs("app", value as OpenApp)
}}
>
{options().map((o) => (
<DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
<AppIcon id={o.icon} class="size-5" />
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={copyPath}>
<Icon name="copy" size="small" class="text-icon-weak" />
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</Show>
</Show>
<StatusPopover />
<Show when={showShare()}>
<div class="flex items-center">
@@ -354,7 +167,7 @@ export function SessionHeader() {
triggerAs={Button}
triggerProps={{
variant: "secondary",
class: "rounded-sm h-[24px] px-3",
class: "rounded-sm w-[60px] h-[24px]",
classList: { "rounded-r-none": shareUrl() !== undefined },
style: { scale: 1 },
}}
@@ -470,57 +283,27 @@ export function SessionHeader() {
<TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
class="group/file-tree-toggle size-6 p-0"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-expanded={layout.fileTree.opened()}
aria-controls="review-panel"
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-right"}
class="group-hover/review-toggle:hidden"
name={layout.fileTree.opened() ? "layout-right-full" : "layout-right"}
class="group-hover/file-tree-toggle:hidden"
/>
<Icon
size="small"
name="layout-right-partial"
class="hidden group-hover/review-toggle:inline-block"
class="hidden group-hover/file-tree-toggle:inline-block"
/>
<Icon
size="small"
name={view().reviewPanel.opened() ? "layout-right" : "layout-right-full"}
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<div class="hidden md:block shrink-0">
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="group/file-tree-toggle size-6 p-0"
onClick={() => {
const opening = !layout.fileTree.opened()
if (opening && !view().reviewPanel.opened()) view().reviewPanel.open()
layout.fileTree.toggle()
}}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name="bullet-list"
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
name={layout.fileTree.opened() ? "layout-right" : "layout-right-full"}
class="hidden group-active/file-tree-toggle:inline-block"
/>
</div>
</Button>

View File

@@ -3,12 +3,11 @@ import type { JSX } from "solid-js"
import { createSortable } from "@thisbeyond/solid-dnd"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Tabs } from "@opencode-ai/ui/tabs"
import { getFilename } from "@opencode-ai/util/path"
import { useFile } from "@/context/file"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
return (
@@ -28,7 +27,6 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
const file = useFile()
const language = useLanguage()
const command = useCommand()
const sortable = createSortable(props.tab)
const path = createMemo(() => file.pathFromTab(props.tab))
return (
@@ -38,11 +36,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
<Tabs.Trigger
value={props.tab}
closeButton={
<TooltipKeybind
title={language.t("common.closeTab")}
keybind={command.keybind("tab.close")}
placement="bottom"
>
<Tooltip value={language.t("common.closeTab")} placement="bottom">
<IconButton
icon="close-small"
variant="ghost"
@@ -50,7 +44,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
onClick={() => props.onTabClose(props.tab)}
aria-label={language.t("common.closeTab")}
/>
</TooltipKeybind>
</Tooltip>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}

View File

@@ -10,6 +10,7 @@ import { usePlatform } from "@/context/platform"
import { useSettings, monoFontFamily } from "@/context/settings"
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
import { Link } from "./link"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
let demoSoundState = {
cleanup: undefined as (() => void) | undefined,
@@ -130,7 +131,12 @@ export const SettingsGeneral: Component = () => {
const soundOptions = [...SOUND_OPTIONS]
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-1 pt-6 pb-8">
<h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
@@ -165,7 +171,6 @@ export const SettingsGeneral: Component = () => {
description={language.t("settings.general.row.appearance.description")}
>
<Select
data-action="settings-color-scheme"
options={colorSchemeOptions()}
current={colorSchemeOptions().find((o) => o.value === theme.colorScheme())}
value={(o) => o.value}
@@ -192,7 +197,6 @@ export const SettingsGeneral: Component = () => {
}
>
<Select
data-action="settings-theme"
options={themeOptions()}
current={themeOptions().find((o) => o.id === theme.themeId())}
value={(o) => o.id}
@@ -217,7 +221,6 @@ export const SettingsGeneral: Component = () => {
description={language.t("settings.general.row.font.description")}
>
<Select
data-action="settings-font"
options={fontOptionsList}
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
value={(o) => o.value}
@@ -247,36 +250,30 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.general.notifications.agent.title")}
description={language.t("settings.general.notifications.agent.description")}
>
<div data-action="settings-notifications-agent">
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</div>
<Switch
checked={settings.notifications.agent()}
onChange={(checked) => settings.notifications.setAgent(checked)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.permissions.title")}
description={language.t("settings.general.notifications.permissions.description")}
>
<div data-action="settings-notifications-permissions">
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</div>
<Switch
checked={settings.notifications.permissions()}
onChange={(checked) => settings.notifications.setPermissions(checked)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.notifications.errors.title")}
description={language.t("settings.general.notifications.errors.description")}
>
<div data-action="settings-notifications-errors">
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</div>
<Switch
checked={settings.notifications.errors()}
onChange={(checked) => settings.notifications.setErrors(checked)}
/>
</SettingsRow>
</div>
</div>
@@ -291,7 +288,6 @@ export const SettingsGeneral: Component = () => {
description={language.t("settings.general.sounds.agent.description")}
>
<Select
data-action="settings-sounds-agent"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.agent())}
value={(o) => o.id}
@@ -316,7 +312,6 @@ export const SettingsGeneral: Component = () => {
description={language.t("settings.general.sounds.permissions.description")}
>
<Select
data-action="settings-sounds-permissions"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.permissions())}
value={(o) => o.id}
@@ -341,7 +336,6 @@ export const SettingsGeneral: Component = () => {
description={language.t("settings.general.sounds.errors.description")}
>
<Select
data-action="settings-sounds-errors"
options={soundOptions}
current={soundOptions.find((o) => o.id === settings.sounds.errors())}
value={(o) => o.id}
@@ -372,25 +366,21 @@ export const SettingsGeneral: Component = () => {
title={language.t("settings.updates.row.startup.title")}
description={language.t("settings.updates.row.startup.description")}
>
<div data-action="settings-updates-startup">
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</div>
<Switch
checked={settings.updates.startup()}
disabled={!platform.checkUpdate}
onChange={(checked) => settings.updates.setStartup(checked)}
/>
</SettingsRow>
<SettingsRow
title={language.t("settings.general.row.releaseNotes.title")}
description={language.t("settings.general.row.releaseNotes.description")}
>
<div data-action="settings-release-notes">
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</div>
<Switch
checked={settings.general.releaseNotes()}
onChange={(checked) => settings.general.setReleaseNotes(checked)}
/>
</SettingsRow>
<SettingsRow
@@ -411,7 +401,7 @@ export const SettingsGeneral: Component = () => {
</div>
</div>
</div>
</div>
</ScrollFade>
)
}

View File

@@ -9,6 +9,7 @@ import fuzzysort from "fuzzysort"
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
const PALETTE_ID = "command.palette"
@@ -352,7 +353,12 @@ export const SettingsKeybinds: Component = () => {
})
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<ScrollFade
direction="vertical"
fadeStartSize={0}
fadeEndSize={16}
class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
>
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
@@ -396,7 +402,6 @@ export const SettingsKeybinds: Component = () => {
<span class="text-14-regular text-text-strong">{title(id)}</span>
<button
type="button"
data-keybind-id={id}
classList={{
"h-8 px-3 rounded-md text-12-regular": true,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
@@ -430,6 +435,6 @@ export const SettingsKeybinds: Component = () => {
</div>
</Show>
</div>
</div>
</ScrollFade>
)
}

View File

@@ -123,7 +123,7 @@ export const SettingsProviders: Component = () => {
</div>
<div class="flex flex-col gap-8 max-w-[720px]">
<div class="flex flex-col gap-1" data-component="connected-providers-section">
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.providers.section.connected")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<Show
@@ -225,12 +225,9 @@ export const SettingsProviders: Component = () => {
)}
</For>
<div
class="flex items-center justify-between gap-4 min-h-16 border-b border-border-weak-base last:border-none flex-wrap py-3"
data-component="custom-provider-section"
>
<div class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none">
<div class="flex flex-col min-w-0">
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
<div class="flex items-center gap-x-3">
<ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
<span class="text-14-medium text-text-strong">Custom provider</span>
<Tag>{language.t("settings.providers.tag.custom")}</Tag>

View File

@@ -1,6 +1,5 @@
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { usePlatform } from "@/context/platform"
import { useSDK } from "@/context/sdk"
import { monoFontFamily, useSettings } from "@/context/settings"
import { SerializeAddon } from "@/addons/serialize"
@@ -53,7 +52,6 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
}
export const Terminal = (props: TerminalProps) => {
const platform = usePlatform()
const sdk = useSDK()
const settings = useSettings()
const theme = useTheme()
@@ -70,7 +68,6 @@ export const Terminal = (props: TerminalProps) => {
let handleTextareaBlur: () => void
let disposed = false
const cleanups: VoidFunction[] = []
let tail = local.pty.tail ?? ""
const cleanup = () => {
if (!cleanups.length) return
@@ -138,22 +135,6 @@ export const Terminal = (props: TerminalProps) => {
focusTerminal()
}
const handleLinkClick = (event: MouseEvent) => {
if (!event.shiftKey && !event.ctrlKey && !event.metaKey) return
if (event.altKey) return
if (event.button !== 0) return
const t = term
if (!t) return
const link = (t as unknown as { currentHoveredLink?: { text: string } }).currentHoveredLink
if (!link?.text) return
event.preventDefault()
event.stopImmediatePropagation()
platform.openLink(link.text)
}
onMount(() => {
const run = async () => {
const loaded = await loadGhostty()
@@ -165,7 +146,6 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
@@ -186,7 +166,6 @@ export const Terminal = (props: TerminalProps) => {
fontSize: 14,
fontFamily: monoFontFamily(settings.appearance.font()),
allowTransparency: true,
convertEol: true,
theme: terminalColors(),
scrollback: 10_000,
ghostty: g,
@@ -257,13 +236,9 @@ export const Terminal = (props: TerminalProps) => {
serializeAddon = serializer
t.open(container)
container.addEventListener("pointerdown", handlePointerDown)
cleanups.push(() => container.removeEventListener("pointerdown", handlePointerDown))
container.addEventListener("click", handleLinkClick, { capture: true })
cleanups.push(() => container.removeEventListener("click", handleLinkClick, { capture: true }))
handleTextareaFocus = () => {
t.options.cursorBlink = true
}
@@ -278,11 +253,15 @@ export const Terminal = (props: TerminalProps) => {
focusTerminal()
fit.fit()
if (local.pty.buffer) {
if (local.pty.rows && local.pty.cols) {
t.resize(local.pty.cols, local.pty.rows)
}
t.write(local.pty.buffer, () => {
if (local.pty.scrollY) t.scrollToLine(local.pty.scrollY)
if (local.pty.scrollY) {
t.scrollToLine(local.pty.scrollY)
}
fitAddon.fit()
})
}
@@ -320,19 +299,6 @@ export const Terminal = (props: TerminalProps) => {
// console.log("Scroll position:", ydisp)
// })
const limit = 16_384
const seed = tail
let sync = !!seed
const overlap = (data: string) => {
if (!seed) return 0
const max = Math.min(seed.length, data.length)
for (let i = max; i > 0; i--) {
if (seed.slice(-i) === data.slice(0, i)) return i
}
return 0
}
const handleOpen = () => {
local.onConnect?.()
sdk.client.pty
@@ -349,25 +315,7 @@ export const Terminal = (props: TerminalProps) => {
cleanups.push(() => socket.removeEventListener("open", handleOpen))
const handleMessage = (event: MessageEvent) => {
const data = typeof event.data === "string" ? event.data : ""
if (!data) return
const next = (() => {
if (!sync) return data
const n = overlap(data)
if (!n) {
sync = false
return data
}
const trimmed = data.slice(n)
if (trimmed) sync = false
return trimmed
})()
if (!next) return
t.write(next)
tail = next.length >= limit ? next.slice(-limit) : (tail + next).slice(-limit)
t.write(event.data)
}
socket.addEventListener("message", handleMessage)
cleanups.push(() => socket.removeEventListener("message", handleMessage))
@@ -421,7 +369,6 @@ export const Terminal = (props: TerminalProps) => {
props.onCleanup({
...local.pty,
buffer,
tail,
rows: t.rows,
cols: t.cols,
scrollY: t.getViewportY(),

View File

@@ -24,8 +24,6 @@ export function Titlebar() {
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const web = createMemo(() => platform.platform === "web")
const zoom = () => platform.webviewZoom?.() ?? 1
const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined)
const [history, setHistory] = createStore({
stack: [] as string[],
@@ -136,7 +134,7 @@ export function Titlebar() {
return (
<header
class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
style={{ "min-height": minHeight() }}
data-tauri-drag-region
>
<div
classList={{
@@ -144,9 +142,10 @@ export function Titlebar() {
"pl-2": !mac(),
}}
onMouseDown={drag}
data-tauri-drag-region
>
<Show when={mac()}>
<div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
@@ -220,10 +219,13 @@ export function Titlebar() {
</Tooltip>
</div>
</div>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" data-tauri-drag-region />
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center">
<div
class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center"
data-tauri-drag-region
>
<div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
</div>
@@ -233,8 +235,9 @@ export function Titlebar() {
"pr-6": !windows(),
}}
onMouseDown={drag}
data-tauri-drag-region
>
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" />
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" data-tauri-drag-region />
<Show when={windows()}>
<div class="w-6 shrink-0" />
<div data-tauri-decorum-tb class="flex flex-row" />

View File

@@ -70,14 +70,6 @@ function createCommentSession(dir: string, id: string | undefined) {
setFocus((current) => (current?.id === id ? null : current))
}
const clear = () => {
batch(() => {
setStore("comments", {})
setFocus(null)
setActive(null)
})
}
const all = createMemo(() => {
const files = Object.keys(store.comments)
const items = files.flatMap((file) => store.comments[file] ?? [])
@@ -90,7 +82,6 @@ function createCommentSession(dir: string, id: string | undefined) {
all,
add,
remove,
clear,
focus: createMemo(() => state.focus),
setFocus,
clearFocus: () => setFocus(null),
@@ -153,7 +144,6 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
all: () => session().all(),
add: (input: Omit<LineComment, "id" | "time">) => session().add(input),
remove: (file: string, id: string) => session().remove(file, id),
clear: () => session().clear(),
focus: () => session().focus(),
setFocus: (focus: CommentFocus | null) => session().setFocus(focus),
clearFocus: () => session().clearFocus(),

View File

@@ -1,5 +1,5 @@
import { batch, createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent, FileNode } from "@opencode-ai/sdk/v2"
import { showToast } from "@opencode-ai/ui/toast"
@@ -277,8 +277,10 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const scope = createMemo(() => sdk.directory)
const directory = createMemo(() => sync.data.path.directory)
function normalize(input: string) {
const root = scope()
const root = directory()
const prefix = root.endsWith("/") ? root : root + "/"
let path = unquoteGitPath(stripQueryAndHash(stripFileProtocol(input)))
@@ -369,13 +371,9 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
inflight.clear()
treeInflight.clear()
contentLru.clear()
batch(() => {
setStore("file", reconcile({}))
setTree("node", reconcile({}))
setTree("dir", reconcile({}))
setTree("dir", "", { expanded: true })
})
setStore("file", {})
setTree("node", {})
setTree("dir", { "": { expanded: true } })
})
const viewCache = new Map<string, ViewCacheEntry>()
@@ -416,7 +414,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return entry.value
}
const view = createMemo(() => loadView(scope(), params.id))
const view = createMemo(() => loadView(params.dir!, params.id))
function ensure(path: string) {
if (!path) return

View File

@@ -119,8 +119,6 @@ type ChildOptions = {
bootstrap?: boolean
}
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
return {
...input,
@@ -299,7 +297,7 @@ function createGlobalSync() {
const aUpdated = sessionUpdatedAt(a)
const bUpdated = sessionUpdatedAt(b)
if (aUpdated !== bUpdated) return bUpdated - aUpdated
return cmp(a.id, b.id)
return a.id.localeCompare(b.id)
}
function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
@@ -327,7 +325,7 @@ function createGlobalSync() {
const all = input
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => cmp(a.id, b.id))
.sort((a, b) => a.id.localeCompare(b.id))
const roots = all.filter((s) => !s.parentID)
const children = all.filter((s) => !!s.parentID)
@@ -344,7 +342,7 @@ function createGlobalSync() {
return sessionUpdatedAt(s) > cutoff
})
return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id))
}
function ensureChild(directory: string) {
@@ -459,7 +457,7 @@ function createGlobalSync() {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.sort((a, b) => cmp(a.id, b.id))
.sort((a, b) => a.id.localeCompare(b.id))
// Read the current limit at resolve-time so callers that bump the limit while
// a request is in-flight still get the expanded result.
@@ -561,7 +559,7 @@ function createGlobalSync() {
"permission",
sessionID,
reconcile(
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@@ -590,7 +588,7 @@ function createGlobalSync() {
"question",
sessionID,
reconcile(
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@@ -988,7 +986,7 @@ function createGlobalSync() {
.filter((p) => !!p?.id)
.filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
.slice()
.sort((a, b) => cmp(a.id, b.id))
.sort((a, b) => a.id.localeCompare(b.id))
setGlobalStore("project", projects)
}),
),

View File

@@ -18,7 +18,6 @@ import { dict as ar } from "@/i18n/ar"
import { dict as no } from "@/i18n/no"
import { dict as br } from "@/i18n/br"
import { dict as th } from "@/i18n/th"
import { dict as bs } from "@/i18n/bs"
import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
@@ -34,7 +33,6 @@ import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
import { dict as uiBs } from "@opencode-ai/ui/i18n/bs"
export type Locale =
| "en"
@@ -52,7 +50,6 @@ export type Locale =
| "no"
| "br"
| "th"
| "bs"
type RawDictionary = typeof en & typeof uiEn
type Dictionary = i18n.Flatten<RawDictionary>
@@ -69,7 +66,6 @@ const LOCALES: readonly Locale[] = [
"ja",
"pl",
"ru",
"bs",
"ar",
"no",
"br",
@@ -103,7 +99,6 @@ function detectLocale(): Locale {
return "no"
if (language.toLowerCase().startsWith("pt")) return "br"
if (language.toLowerCase().startsWith("th")) return "th"
if (language.toLowerCase().startsWith("bs")) return "bs"
}
return "en"
@@ -134,7 +129,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
if (store.locale === "no") return "no"
if (store.locale === "br") return "br"
if (store.locale === "th") return "th"
if (store.locale === "bs") return "bs"
return "en"
})
@@ -160,7 +154,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) }
if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) }
if (locale() === "th") return { ...base, ...i18n.flatten({ ...th, ...uiTh }) }
if (locale() === "bs") return { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }
return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }
})
@@ -182,7 +175,6 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
no: "language.no",
br: "language.br",
th: "language.th",
bs: "language.bs",
}
const label = (value: Locale) => t(labelKey[value])

View File

@@ -33,14 +33,6 @@ type SessionTabs = {
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
pendingMessage?: string
pendingMessageAt?: number
}
type TabHandoff = {
dir: string
id: string
at: number
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
@@ -71,7 +63,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})()
const review = value.review
const fileTree = value.fileTree
const migratedFileTree = (() => {
if (!isRecord(fileTree)) return fileTree
@@ -86,22 +77,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})()
const migratedReview = (() => {
if (!isRecord(review)) return review
if (typeof review.panelOpened === "boolean") return review
const opened = isRecord(fileTree) && typeof fileTree.opened === "boolean" ? fileTree.opened : true
return {
...review,
panelOpened: opened,
}
})()
if (migratedSidebar === sidebar && migratedReview === review && migratedFileTree === fileTree) return value
if (migratedSidebar === sidebar && migratedFileTree === fileTree) return value
return {
...value,
sidebar: migratedSidebar,
review: migratedReview,
fileTree: migratedFileTree,
}
}
@@ -122,7 +101,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
review: {
diffStyle: "split" as ReviewDiffStyle,
panelOpened: true,
},
fileTree: {
opened: true,
@@ -137,14 +115,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>,
handoff: {
tabs: undefined as TabHandoff | undefined,
},
}),
)
const MAX_SESSION_KEYS = 50
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>()
@@ -437,16 +411,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return {
ready,
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { dir, id, at: Date.now() })
},
clearTabs() {
if (!store.handoff?.tabs) return
setStore("handoff", "tabs", undefined)
},
},
projects: {
list,
open(directory: string) {
@@ -504,7 +468,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) {
setStore("review", { diffStyle, panelOpened: true })
setStore("review", { diffStyle })
return
}
setStore("review", "diffStyle", diffStyle)
@@ -572,49 +536,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("mobileSidebar", "opened", (x) => !x)
},
},
pendingMessage: {
set(sessionKey: string, messageID: string) {
const at = Date.now()
touch(sessionKey)
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, {
scroll: {},
pendingMessage: messageID,
pendingMessageAt: at,
})
prune(meta.active ?? sessionKey)
return
}
setStore(
"sessionView",
sessionKey,
produce((draft) => {
draft.pendingMessage = messageID
draft.pendingMessageAt = at
}),
)
},
consume(sessionKey: string) {
const current = store.sessionView[sessionKey]
const message = current?.pendingMessage
const at = current?.pendingMessageAt
if (!message || !at) return
setStore(
"sessionView",
sessionKey,
produce((draft) => {
delete draft.pendingMessage
delete draft.pendingMessageAt
}),
)
if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
return message
},
},
view(sessionKey: string | Accessor<string>) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey
@@ -634,7 +555,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const s = createMemo(() => store.sessionView[key()] ?? { scroll: {} })
const terminalOpened = createMemo(() => store.terminal?.opened ?? false)
const reviewPanelOpened = createMemo(() => store.review?.panelOpened ?? true)
function setTerminalOpened(next: boolean) {
const current = store.terminal
@@ -648,18 +568,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("terminal", "opened", next)
}
function setReviewPanelOpened(next: boolean) {
const current = store.review
if (!current) {
setStore("review", { diffStyle: "split" as ReviewDiffStyle, panelOpened: next })
return
}
const value = current.panelOpened ?? true
if (value === next) return
setStore("review", "panelOpened", next)
}
return {
scroll(tab: string) {
return scroll.scroll(key(), tab)
@@ -679,18 +587,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setTerminalOpened(!terminalOpened())
},
},
reviewPanel: {
opened: reviewPanelOpened,
open() {
setReviewPanelOpened(true)
},
close() {
setReviewPanelOpened(false)
},
toggle() {
setReviewPanelOpened(!reviewPanelOpened())
},
},
review: {
open: createMemo(() => s().reviewOpen),
setOpen(open: string[]) {
@@ -728,10 +624,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const tabs = createMemo(() => store.sessionTabs[key()] ?? { all: [] })
return {
tabs,
active: createMemo(() => tabs().active),
active: createMemo(() => (tabs().active === "review" ? undefined : tabs().active)),
all: createMemo(() => tabs().all.filter((tab) => tab !== "review")),
setActive(tab: string | undefined) {
const session = key()
if (tab === "review") return
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: [], active: tab })
} else {
@@ -748,18 +645,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
async open(tab: string) {
if (tab === "review") return
const session = key()
const current = store.sessionTabs[session] ?? { all: [] }
if (tab === "review") {
if (!store.sessionTabs[session]) {
setStore("sessionTabs", session, { all: current.all.filter((x) => x !== "review"), active: tab })
return
}
setStore("sessionTabs", session, "active", tab)
return
}
if (tab === "context") {
const all = [tab, ...current.all.filter((x) => x !== tab)]
if (!store.sessionTabs[session]) {
@@ -792,22 +681,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const current = store.sessionTabs[session]
if (!current) return
if (tab === "review") {
if (current.active !== tab) return
setStore("sessionTabs", session, "active", current.all[0])
return
}
const all = current.all.filter((x) => x !== tab)
if (current.active !== tab) {
setStore("sessionTabs", session, "all", all)
return
}
const index = current.all.findIndex((f) => f === tab)
const next = current.all[index - 1] ?? current.all[index + 1] ?? all[0]
batch(() => {
setStore("sessionTabs", session, "all", all)
if (current.active !== tab) return
const index = current.all.findIndex((f) => f === tab)
const next = all[index - 1] ?? all[0]
setStore("sessionTabs", session, "active", next)
})
},

View File

@@ -1,6 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
export type Platform = {
/** Platform discriminator */
@@ -15,9 +14,6 @@ export type Platform = {
/** Open a URL in the default browser */
openLink(url: string): void
/** Open a local path in a local app (desktop only) */
openPath?(path: string, app?: string): Promise<void>
/** Restart the app */
restart(): Promise<void>
@@ -59,9 +55,6 @@ export type Platform = {
/** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
parseMarkdown?(markdown: string): Promise<string>
/** Webview zoom level (desktop only) */
webviewZoom?: Accessor<number>
}
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({

View File

@@ -1,17 +1,17 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { createEffect, createMemo, onCleanup, type Accessor } from "solid-js"
import { createEffect, createMemo, onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
name: "SDK",
init: (props: { directory: Accessor<string> }) => {
init: (props: { directory: string }) => {
const platform = usePlatform()
const globalSDK = useGlobalSDK()
const directory = createMemo(props.directory)
const directory = createMemo(() => props.directory)
const client = createMemo(() =>
createOpencodeClient({
baseUrl: globalSDK.url,

View File

@@ -9,8 +9,6 @@ import type { Message, Part } from "@opencode-ai/sdk/v2/client"
const keyFor = (directory: string, id: string) => `${directory}\n${id}`
const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
export const { use: useSync, provider: SyncProvider } = createSimpleContext({
name: "Sync",
init: () => {
@@ -61,7 +59,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.sort((a, b) => cmp(a.id, b.id))
.sort((a, b) => a.id.localeCompare(b.id))
batch(() => {
input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
@@ -71,7 +69,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
"part",
message.info.id,
reconcile(
message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
@@ -131,7 +129,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const result = Binary.search(messages, input.messageID, (m) => m.id)
messages.splice(result.index, 0, message)
}
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id))
}),
)
},
@@ -273,7 +271,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
await client.session.list().then((x) => {
const sessions = (x.data ?? [])
.filter((s) => !!s?.id)
.sort((a, b) => cmp(a.id, b.id))
.sort((a, b) => a.id.localeCompare(b.id))
.slice(0, store.limit)
setStore("session", reconcile(sessions, { key: "id" }))
})

View File

@@ -13,7 +13,6 @@ export type LocalPTY = {
cols?: number
buffer?: string
scrollY?: number
tail?: string
}
const WORKSPACE_KEY = "__workspace__"

View File

@@ -28,8 +28,6 @@ export const dict = {
"command.settings.open": "فتح الإعدادات",
"command.session.previous": "الجلسة السابقة",
"command.session.next": "الجلسة التالية",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "أرشفة الجلسة",
"command.palette": "لوحة الأوامر",
@@ -44,6 +42,7 @@ export const dict = {
"command.session.new": "جلسة جديدة",
"command.file.open": "فتح ملف",
"command.file.open.description": "البحث في الملفات والأوامر",
"command.context.addSelection": "إضافة التحديد إلى السياق",
"command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي",
"command.terminal.toggle": "تبديل المحطة الطرفية",
@@ -69,7 +68,6 @@ export const dict = {
"command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
"command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
"command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
"command.workspace.toggle": "تبديل مساحات العمل",
"command.session.undo": "تراجع",
"command.session.undo.description": "تراجع عن الرسالة الأخيرة",
"command.session.redo": "إعادة",
@@ -83,7 +81,7 @@ export const dict = {
"command.session.unshare": "إلغاء مشاركة الجلسة",
"command.session.unshare.description": "إيقاف مشاركة هذه الجلسة",
"palette.search.placeholder": "البحث في الملفات والأوامر والجلسات",
"palette.search.placeholder": "البحث في الملفات والأوامر",
"palette.empty": "لا توجد نتائج",
"palette.group.commands": "الأوامر",
"palette.group.files": "الملفات",
@@ -212,8 +210,6 @@ export const dict = {
"prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
"prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
"prompt.slash.badge.custom": "مخصص",
"prompt.slash.badge.skill": "مهارة",
"prompt.slash.badge.mcp": "mcp",
"prompt.context.active": "نشط",
"prompt.context.includeActiveFile": "تضمين الملف النشط",
"prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
@@ -321,6 +317,22 @@ export const dict = {
"context.usage.clickToView": "انقر لعرض السياق",
"context.usage.view": "عرض استخدام السياق",
"language.en": "English",
"language.zh": "简体中文",
"language.zht": "繁體中文",
"language.ko": "한국어",
"language.de": "Deutsch",
"language.es": "Español",
"language.fr": "Français",
"language.da": "Dansk",
"language.ja": "日本語",
"language.pl": "Polski",
"language.ru": "Русский",
"language.ar": "العربية",
"language.no": "Norsk",
"language.br": "Português (Brasil)",
"language.th": "ไทย",
"toast.language.title": "لغة",
"toast.language.description": "تم التبديل إلى {{language}}",
@@ -332,11 +344,6 @@ export const dict = {
"toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا",
"toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة",
"toast.workspace.enabled.title": "تم تمكين مساحات العمل",
"toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي",
"toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
"toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي",
"toast.model.none.title": "لم يتم تحديد نموذج",
"toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة",
@@ -425,7 +432,6 @@ export const dict = {
"session.review.noChanges": "لا توجد تغييرات",
"session.files.selectToOpen": "اختر ملفًا لفتحه",
"session.files.all": "كل الملفات",
"session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
"session.messages.renderEarlier": "عرض الرسائل السابقة",
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
"session.messages.loadEarlier": "تحميل الرسائل السابقة",

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