Compare commits

..

1 Commits

Author SHA1 Message Date
Aiden Cline d2311bbf81 core: ephemerally wrap queued user messages with reminder to stay on track 2026-01-02 22:40:05 -06:00
498 changed files with 9547 additions and 82988 deletions
-4
View File
@@ -1,4 +0,0 @@
# web + desktop packages
packages/app/ @adamdotdevin
packages/tauri/ @adamdotdevin
packages/desktop/ @adamdotdevin
-8
View File
@@ -11,14 +11,6 @@ body:
validations:
required: true
- type: input
id: plugins
attributes:
label: Plugins
description: What plugins are you using?
validations:
required: false
- type: input
id: opencode-version
attributes:
-3
View File
@@ -1,3 +0,0 @@
### What does this PR do?
### How did you verify your code works?
+2 -2
View File
@@ -28,8 +28,8 @@ jobs:
OPENCODE_PERMISSION: |
{
"bash": {
"*": "deny",
"gh issue*": "allow"
"gh issue*": "allow",
"*": "deny"
},
"webfetch": "deny"
}
-65
View File
@@ -1,65 +0,0 @@
name: Duplicate PR Check
on:
pull_request_target:
types: [opened]
jobs:
check-duplicates:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Install dependencies
run: bun install
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Build prompt
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
{
echo "Check for duplicate PRs related to this new PR:"
echo ""
echo "CURRENT_PR_NUMBER: $PR_NUMBER"
echo ""
echo "Title: $(gh pr view "$PR_NUMBER" --json title --jq .title)"
echo ""
echo "Description:"
gh pr view "$PR_NUMBER" --json body --jq .body
} > pr_info.txt
- name: Check for duplicate PRs
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
COMMENT=$(bun script/duplicate-pr.ts -f pr_info.txt "Check the attached file for PR details and search for duplicates")
gh pr comment "$PR_NUMBER" --body "_The following comment was made by an LLM, it may be inaccurate:_
$COMMENT"
-44
View File
@@ -1,44 +0,0 @@
name: nix desktop
on:
push:
branches: [dev]
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
pull_request:
paths:
- "flake.nix"
- "flake.lock"
- "nix/**"
- "packages/app/**"
- "packages/desktop/**"
workflow_dispatch:
jobs:
build-desktop:
strategy:
fail-fast: false
matrix:
os:
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-ubuntu-2404-arm
- macos-15
- macos-latest
runs-on: ${{ matrix.os }}
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Build desktop via flake
run: |
set -euo pipefail
nix --version
nix build .#desktop -L
-139
View File
@@ -1,139 +0,0 @@
name: PR Standards
on:
pull_request_target:
types: [opened, edited, synchronize]
jobs:
check-standards:
if: |
github.event.pull_request.user.login != 'actions-user' &&
github.event.pull_request.user.login != 'opencode' &&
github.event.pull_request.user.login != 'rekram1-node' &&
github.event.pull_request.user.login != 'thdxr' &&
github.event.pull_request.user.login != 'kommander' &&
github.event.pull_request.user.login != 'jayair' &&
github.event.pull_request.user.login != 'fwang' &&
github.event.pull_request.user.login != 'adamdotdevin' &&
github.event.pull_request.user.login != 'iamdavidhill' &&
github.event.pull_request.user.login != 'opencode-agent[bot]'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Check PR standards
uses: actions/github-script@v7
with:
script: |
const pr = context.payload.pull_request;
const title = pr.title;
async function addLabel(label) {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
labels: [label]
});
}
async function removeLabel(label) {
try {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
name: label
});
} catch (e) {
// Label wasn't present, ignore
}
}
async function comment(marker, body) {
const markerText = `<!-- pr-standards:${marker} -->`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number
});
const existing = comments.find(c => c.body.includes(markerText));
if (existing) return;
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body: markerText + '\n' + body
});
}
// Step 1: Check title format
// Matches: feat:, feat(scope):, feat (scope):, etc.
const titlePattern = /^(feat|fix|docs|chore|refactor|test)\s*(\([a-zA-Z0-9-]+\))?\s*:/;
const hasValidTitle = titlePattern.test(title);
if (!hasValidTitle) {
await addLabel('needs:title');
await comment('title', `Hey! Your PR title \`${title}\` doesn't follow conventional commit format.
Please update it to start with one of:
- \`feat:\` or \`feat(scope):\` new feature
- \`fix:\` or \`fix(scope):\` bug fix
- \`docs:\` or \`docs(scope):\` documentation changes
- \`chore:\` or \`chore(scope):\` maintenance tasks
- \`refactor:\` or \`refactor(scope):\` code refactoring
- \`test:\` or \`test(scope):\` adding or updating tests
Where \`scope\` is the package name (e.g., \`app\`, \`desktop\`, \`opencode\`).
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#pr-titles) for details.`);
return;
}
await removeLabel('needs:title');
// Step 2: Check for linked issue (skip for docs/refactor PRs)
const skipIssueCheck = /^(docs|refactor)\s*(\([a-zA-Z0-9-]+\))?\s*:/.test(title);
if (skipIssueCheck) {
await removeLabel('needs:issue');
console.log('Skipping issue check for docs/refactor PR');
return;
}
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 1) {
totalCount
}
}
}
}
`;
const result = await github.graphql(query, {
owner: context.repo.owner,
repo: context.repo.repo,
number: pr.number
});
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
if (linkedIssues === 0) {
await addLabel('needs:issue');
await comment('issue', `Thanks for your contribution!
This PR doesn't have a linked issue. All PRs must reference an existing issue.
Please:
1. Open an issue describing the bug/feature (if one doesn't exist)
2. Add \`Fixes #<number>\` or \`Closes #<number>\` to this PR description
See [CONTRIBUTING.md](../blob/dev/CONTRIBUTING.md#issue-first-policy) for details.`);
return;
}
await removeLabel('needs:issue');
console.log('PR meets all standards');
+14 -18
View File
@@ -31,7 +31,7 @@ permissions:
jobs:
publish:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'sst/opencode'
steps:
- uses: actions/checkout@v3
with:
@@ -92,7 +92,7 @@ jobs:
publish-tauri:
needs: publish
continue-on-error: false
continue-on-error: true
strategy:
fail-fast: false
matrix:
@@ -177,22 +177,8 @@ jobs:
cargo tauri --version
- name: Build and upload artifacts
uses: Wandalen/wretry.action@v3
timeout-minutes: 60
with:
attempt_limit: 3
attempt_delay: 10000
action: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
with: |
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.publish.outputs.release }}
tagName: ${{ needs.publish.outputs.tag }}
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
releaseDraft: true
timeout-minutes: 20
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
@@ -204,6 +190,16 @@ jobs:
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
with:
projectPath: packages/desktop
uploadWorkflowArtifacts: true
tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
updaterJsonPreferNsis: true
releaseId: ${{ needs.publish.outputs.release }}
tagName: ${{ needs.publish.outputs.tag }}
releaseAssetNamePattern: opencode-desktop-[platform]-[arch][ext]
releaseDraft: true
publish-release:
needs:
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: '{ "bash": { "*": "deny", "gh*": "allow", "gh pr review*": "deny" } }'
OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "gh pr review*": "deny", "*": "deny" } }'
PR_TITLE: ${{ steps.pr-details.outputs.title }}
run: |
PR_BODY=$(jq -r .body pr_data.json)
+1 -1
View File
@@ -9,7 +9,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
stats:
if: github.repository == 'anomalyco/opencode'
if: github.repository == 'sst/opencode'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
+15 -105
View File
@@ -17,11 +17,11 @@ on:
- "packages/*/package.json"
jobs:
update-flake:
update:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: blacksmith-4vcpu-ubuntu-2404
env:
TITLE: flake.lock
SYSTEM: x86_64-linux
steps:
- name: Checkout repository
@@ -33,129 +33,39 @@ jobs:
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
uses: DeterminateSystems/nix-installer-action@v20
- name: Configure git
run: |
git config --global user.email "action@github.com"
git config --global user.name "Github Action"
- name: Update ${{ env.TITLE }}
- name: Update flake.lock
run: |
set -euo pipefail
echo "📦 Updating $TITLE..."
echo "📦 Updating flake.lock..."
nix flake update
echo "✅ $TITLE updated successfully"
echo "✅ flake.lock updated successfully"
- name: Commit ${{ env.TITLE }} changes
env:
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
- name: Update node_modules hash
run: |
set -euo pipefail
echo "🔍 Checking for changes in tracked files..."
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=(flake.lock flake.nix)
STATUS="$(git status --short -- "${FILES[@]}" || true)"
if [ -z "$STATUS" ]; then
echo "✅ No changes detected."
summarize "no changes"
exit 0
fi
echo "📝 Changes detected:"
echo "$STATUS"
echo "🔗 Staging files..."
git add "${FILES[@]}"
echo "💾 Committing changes..."
git commit -m "Update $TITLE"
echo "✅ Changes committed"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
echo "🌳 Pulling latest from branch: $BRANCH"
git pull --rebase origin "$BRANCH"
echo "🚀 Pushing changes to branch: $BRANCH"
git push origin HEAD:"$BRANCH"
echo "✅ Changes pushed successfully"
summarize "committed $(git rev-parse --short HEAD)"
update-node-modules-hash:
needs: update-flake
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
strategy:
fail-fast: false
matrix:
include:
- system: x86_64-linux
host: blacksmith-4vcpu-ubuntu-2404
- system: aarch64-linux
host: blacksmith-4vcpu-ubuntu-2404-arm
- system: x86_64-darwin
host: macos-15-intel
- system: aarch64-darwin
host: macos-latest
runs-on: ${{ matrix.host }}
env:
SYSTEM: ${{ matrix.system }}
TITLE: node_modules hash (${{ matrix.system }})
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
ref: ${{ github.head_ref || github.ref_name }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
- name: Setup Nix
uses: nixbuild/nix-quick-install-action@v34
- name: Configure git
run: |
git config --global user.email "action@github.com"
git config --global user.name "Github Action"
- name: Pull latest changes
env:
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
git pull origin "$BRANCH"
- name: Update ${{ env.TITLE }}
run: |
set -euo pipefail
echo "🔄 Updating $TITLE..."
echo "🔄 Updating node_modules hash..."
nix/scripts/update-hashes.sh
echo "✅ $TITLE updated successfully"
echo "✅ node_modules hash updated successfully"
- name: Commit ${{ env.TITLE }} changes
- name: Commit hash changes
env:
TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
run: |
set -euo pipefail
echo "🔍 Checking for changes in tracked files..."
echo "🔍 Checking for changes in tracked Nix files..."
summarize() {
local status="$1"
{
echo "### Nix $TITLE"
echo "### Nix Hash Update"
echo ""
echo "- ref: ${GITHUB_REF_NAME}"
echo "- status: ${status}"
@@ -166,10 +76,10 @@ jobs:
echo "" >> "$GITHUB_STEP_SUMMARY"
}
FILES=(nix/hashes.json)
FILES=(flake.lock flake.nix nix/node-modules.nix nix/hashes.json)
STATUS="$(git status --short -- "${FILES[@]}" || true)"
if [ -z "$STATUS" ]; then
echo "✅ No changes detected."
echo "✅ No changes detected. Hashes are already up to date."
summarize "no changes"
exit 0
fi
@@ -179,7 +89,7 @@ jobs:
echo "🔗 Staging files..."
git add "${FILES[@]}"
echo "💾 Committing changes..."
git commit -m "Update $TITLE"
git commit -m "Update Nix flake.lock and hashes"
echo "✅ Changes committed"
BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
-1
View File
@@ -24,4 +24,3 @@ target
# Local dev files
opencode-dev
logs/
*.bun-build
-26
View File
@@ -1,26 +0,0 @@
---
mode: primary
hidden: true
model: opencode/claude-haiku-4-5
color: "#E67E22"
tools:
"*": false
"github-pr-search": true
---
You are a duplicate PR detection agent. When a PR is opened, your job is to search for potentially duplicate or related open PRs.
Use the github-pr-search tool to search for PRs that might be addressing the same issue or feature.
IMPORTANT: The input will contain a line `CURRENT_PR_NUMBER: NNNN`. This is the current PR number, you should not mark that the current PR as a duplicate of itself.
Search using keywords from the PR title and description. Try multiple searches with different relevant terms.
If you find potential duplicates:
- List them with their titles and URLs
- Briefly explain why they might be related
If no duplicates are found, say so clearly. BUT ONLY SAY "No duplicate PRs found" (don't say anything else if no dups)
Keep your response concise and actionable.
+2 -2
View File
@@ -45,9 +45,9 @@ Desktop app issues:
#### zen
**Only** add if the issue mentions "zen" or "opencode zen" or "opencode black".
**Only** add if the issue mentions "zen" or "opencode zen". Zen is our gateway for coding models. **Do not** add for other gateways or inference providers.
If the issue doesn't have "zen" or "opencode black" in it then don't add zen label
If the issue doesn't have "zen" in it then don't add zen label
#### docs
-24
View File
@@ -1,24 +0,0 @@
---
description: "Bump AI sdk dependencies minor / patch versions only"
---
Please read @package.json and @packages/opencode/package.json.
Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes).
I want a report of every dependency and the version that can be upgraded to.
What would be even better is if you can give me links to the changelog for each dependency, or at least some reference info so I can see what bugs were fixed or new features were added.
Consider using subagents for each dep to save your context window.
Here is a short list of some deps (please be comprehensive tho):
- "ai"
- "@ai-sdk/openai"
- "@ai-sdk/anthropic"
- "@openrouter/ai-sdk-provider"
- etc, etc
DO NOT upgrade the dependencies yet, just make a list of all dependencies and their versions that can be upgraded to minor or patch versions only.
Write up your findings to ai-sdk-updates.md
+1 -1
View File
@@ -3,7 +3,7 @@ description: "find issue(s) on github"
model: opencode/claude-haiku-4-5
---
Search through existing issues in anomalyco/opencode using the gh cli to find issues matching this query:
Search through existing issues in sst/opencode using the gh cli to find issues matching this query:
$ARGUMENTS
+5 -1
View File
@@ -10,6 +10,11 @@
"options": {},
},
},
"permission": {
"bash": {
"ls foo": "ask",
},
},
"mcp": {
"context7": {
"type": "remote",
@@ -18,6 +23,5 @@
},
"tools": {
"github-triage": false,
"github-pr-search": false,
},
}
-57
View File
@@ -1,57 +0,0 @@
/// <reference path="../env.d.ts" />
import { tool } from "@opencode-ai/plugin"
import DESCRIPTION from "./github-pr-search.txt"
async function githubFetch(endpoint: string, options: RequestInit = {}) {
const response = await fetch(`https://api.github.com${endpoint}`, {
...options,
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
...options.headers,
},
})
if (!response.ok) {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
interface PR {
title: string
html_url: string
}
export default tool({
description: DESCRIPTION,
args: {
query: tool.schema.string().describe("Search query for PR titles and descriptions"),
limit: tool.schema.number().describe("Maximum number of results to return").default(10),
offset: tool.schema.number().describe("Number of results to skip for pagination").default(0),
},
async execute(args) {
const owner = "anomalyco"
const repo = "opencode"
const page = Math.floor(args.offset / args.limit) + 1
const searchQuery = encodeURIComponent(`${args.query} repo:${owner}/${repo} type:pr state:open`)
const result = await githubFetch(
`/search/issues?q=${searchQuery}&per_page=${args.limit}&page=${page}&sort=updated&order=desc`,
)
if (result.total_count === 0) {
return `No PRs found matching "${args.query}"`
}
const prs = result.items as PR[]
if (prs.length === 0) {
return `No other PRs found matching "${args.query}"`
}
const formatted = prs.map((pr) => `${pr.title}\n${pr.html_url}`).join("\n\n")
return `Found ${result.total_count} PRs (showing ${prs.length}):\n\n${formatted}`
},
})
-10
View File
@@ -1,10 +0,0 @@
Use this tool to search GitHub pull requests by title and description.
This tool searches PRs in the sst/opencode repository and returns LLM-friendly results including:
- PR number and title
- Author
- State (open/closed/merged)
- Labels
- Description snippet
Use the query parameter to search for keywords that might appear in PR titles or descriptions.
+1 -1
View File
@@ -40,7 +40,7 @@ export default tool({
async execute(args) {
const issue = getIssueNumber()
// const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
const owner = "anomalyco"
const owner = "sst"
const repo = "opencode"
const results: string[] = []
+3 -3
View File
@@ -1,4 +1,4 @@
- To test opencode in `packages/opencode`, run `bun dev`.
- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`.
- To test opencode in the `packages/opencode` directory you can run `bun dev`
- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.
- The default branch in this repo is `dev`.
- the default branch in this repo is `dev`
+4 -97
View File
@@ -67,49 +67,8 @@ Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
- Core pieces:
- `packages/opencode`: OpenCode core business logic & server.
- `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)
- `packages/app`: The shared web UI components, written in SolidJS
- `packages/desktop`: The native desktop app, built with Tauri (wraps `packages/app`)
- `packages/plugin`: Source for `@opencode-ai/plugin`
### Running the Web App
To test UI changes during development, run the web app:
```bash
bun run --cwd packages/app dev
```
This starts a local dev server at http://localhost:5173 (or similar port shown in output). Most UI changes can be tested here.
### Running the Desktop App
The desktop app is a native Tauri application that wraps the web UI.
To run the native desktop app:
```bash
bun run --cwd packages/desktop tauri dev
```
This starts the web dev server on http://localhost:1420 and opens the native window.
If you only want the web dev server (no native shell):
```bash
bun run --cwd packages/desktop dev
```
To create a production `dist/` and build the native app bundle:
```bash
bun run --cwd packages/desktop tauri build
```
This runs `bun run --cwd packages/desktop build` automatically via Tauris `beforeBuildCommand`.
> [!NOTE]
> Running the desktop app requires additional Tauri dependencies (Rust toolchain, platform-specific libraries). See the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for setup instructions.
> [!NOTE]
> If you make changes to the API or SDK (e.g. `packages/opencode/src/server/server.ts`), run `./script/generate.ts` to regenerate the SDK and related files.
@@ -149,63 +108,11 @@ With that said, you may want to try these methods, as they might work for you.
## Pull Request Expectations
### Issue First Policy
**All PRs must reference an existing issue.** Before opening a PR, open an issue describing the bug or feature. This helps maintainers triage and prevents duplicate work. PRs without a linked issue may be closed without review.
- Use `Fixes #123` or `Closes #123` in your PR description to link the issue
- For small fixes, a brief issue is fine - just enough context for maintainers to understand the problem
### General Requirements
- Keep pull requests small and focused
- Try to keep pull requests small and focused.
- Link relevant issue(s) in the description
- Explain the issue and why your change fixes it
- Before adding new functionality, ensure it doesn't already exist elsewhere in the codebase
### UI Changes
If your PR includes UI changes, please include screenshots or videos showing the before and after. This helps maintainers review faster and gives you quicker feedback.
### Logic Changes
For non-UI changes (bug fixes, new features, refactors), explain **how you verified it works**:
- What did you test?
- How can a reviewer reproduce/confirm the fix?
### No AI-Generated Walls of Text
Long, AI-generated PR descriptions and issues are not acceptable and may be ignored. Respect the maintainers' time:
- Write short, focused descriptions
- Explain what changed and why in your own words
- If you can't explain it briefly, your PR might be too large
### PR Titles
PR titles should follow conventional commit standards:
- `feat:` new feature or functionality
- `fix:` bug fix
- `docs:` documentation or README changes
- `chore:` maintenance tasks, dependency updates, etc.
- `refactor:` code refactoring without changing behavior
- `test:` adding or updating tests
You can optionally include a scope to indicate which package is affected:
- `feat(app):` feature in the app package
- `fix(desktop):` bug fix in the desktop package
- `chore(opencode):` maintenance in the opencode package
Examples:
- `docs: update contributing guidelines`
- `fix: resolve crash on startup`
- `feat: add dark mode support`
- `feat(app): add dark mode support`
- `fix(desktop): resolve crash on startup`
- `chore: bump dependency versions`
- Avoid having verbose LLM generated PR descriptions
- Before adding new functions or functionality, ensure that such behavior doesn't already exist elsewhere in the codebase.
### Style Preferences
+7 -3
View File
@@ -28,8 +28,7 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # or bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
brew install opencode # macOS and Linux (official brew formula, updated less)
brew install opencode # macOS and Linux
paru -S opencode-bin # Arch Linux
mise use -g opencode # Any OS
nix run nixpkgs#opencode # or github:anomalyco/opencode for latest dev branch
@@ -71,7 +70,8 @@ XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
### Agents
OpenCode includes two built-in agents you can switch between with the `Tab` key.
OpenCode includes two built-in agents you can switch between,
you can switch between these using the `Tab` key.
- **build** - Default, full access agent for development work
- **plan** - Read-only agent for analysis and code exploration
@@ -108,6 +108,10 @@ It's very similar to Claude Code in terms of capability. Here are the key differ
- A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
- A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
#### What's the other repo?
The other confusingly named repo has no relation to this one. You can [read the story behind it here](https://x.com/thdxr/status/1933561254481666466).
---
**Join our community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
-116
View File
@@ -1,116 +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">开源的 AI Coding Agent。</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>
[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
---
### 安装
```bash
# 直接安装 (YOLO)
curl -fsSL https://opencode.ai/install | bash
# 软件包管理器
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
brew install opencode # macOS 和 Linux(官方 brew formula,更新频率较低)
paru -S opencode-bin # Arch Linux
mise use -g opencode # 任意系统
nix run nixpkgs#opencode # 或用 github:anomalyco/opencode 获取最新 dev 分支
```
> [!TIP]
> 安装前请先移除 0.1.x 之前的旧版本。
### 桌面应用程序 (BETA)
OpenCode 也提供桌面版应用。可直接从 [发布页 (releases page)](https://github.com/anomalyco/opencode/releases) 或 [opencode.ai/download](https://opencode.ai/download) 下载。
| 平台 | 下载文件 |
| --------------------- | ------------------------------------- |
| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
| Windows | `opencode-desktop-windows-x64.exe` |
| Linux | `.deb``.rpm` 或 AppImage |
```bash
# macOS (Homebrew Cask)
brew install --cask opencode-desktop
```
#### 安装目录
安装脚本按照以下优先级决定安装路径:
1. `$OPENCODE_INSTALL_DIR` - 自定义安装目录
2. `$XDG_BIN_DIR` - 符合 XDG 基础目录规范的路径
3. `$HOME/bin` - 如果存在或可创建的用户二进制目录
4. `$HOME/.opencode/bin` - 默认备用路径
```bash
# 示例
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
```
### Agents
OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
- **build** - 默认模式,具备完整权限,适合开发工作
- **plan** - 只读模式,适合代码分析与探索
- 默认拒绝修改文件
- 运行 bash 命令前会询问
- 便于探索未知代码库或规划改动
另外还包含一个 **general** 子 Agent,用于复杂搜索和多步任务,内部使用,也可在消息中输入 `@general` 调用。
了解更多 [Agents](https://opencode.ai/docs/agents) 相关信息。
### 文档
更多配置说明请查看我们的 [**官方文档**](https://opencode.ai/docs)。
### 参与贡献
如有兴趣贡献代码,请在提交 PR 前阅读 [贡献指南 (Contributing Docs)](./CONTRIBUTING.md)。
### 基于 OpenCode 进行开发
如果你在项目名中使用了 “opencode”(如 “opencode-dashboard” 或 “opencode-mobile”),请在 README 里注明该项目不是 OpenCode 团队官方开发,且不存在隶属关系。
### 常见问题 (FAQ)
#### 这和 Claude Code 有什么不同?
功能上很相似,关键差异:
- 100% 开源。
- 不绑定特定提供商。推荐使用 [OpenCode Zen](https://opencode.ai/zen) 的模型,但也可搭配 Claude、OpenAI、Google 甚至本地模型。模型迭代会缩小差异、降低成本,因此保持 provider-agnostic 很重要。
- 内置 LSP 支持。
- 聚焦终端界面 (TUI)。OpenCode 由 Neovim 爱好者和 [terminal.shop](https://terminal.shop) 的创建者打造,会持续探索终端的极限。
- 客户端/服务器架构。可在本机运行,同时用移动设备远程驱动。TUI 只是众多潜在客户端之一。
#### 另一个同名的仓库是什么?
另一个名字相近的仓库与本项目无关。[点击这里了解背后故事](https://x.com/thdxr/status/1933561254481666466)。
---
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
+1 -2
View File
@@ -28,8 +28,7 @@ curl -fsSL https://opencode.ai/install | bash
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
scoop bucket add extras; scoop install extras/opencode # Windows
choco install opencode # Windows
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
brew install opencode # macOS 與 Linux(官方 brew formula,更新頻率較低)
brew install opencode # macOS 與 Linux
paru -S opencode-bin # Arch Linux
mise use -g github:anomalyco/opencode # 任何作業系統
nix run nixpkgs#opencode # 或使用 github:anomalyco/opencode 以取得最新開發分支
-40
View File
@@ -1,40 +0,0 @@
# Security
## Threat Model
### Overview
OpenCode is an AI-powered coding assistant that runs locally on your machine. It provides an agent system with access to powerful tools including shell execution, file operations, and web access.
### No Sandbox
OpenCode does **not** sandbox the agent. The permission system exists as a UX feature to help users stay aware of what actions the agent is taking - it prompts for confirmation before executing commands, writing files, etc. However, it is not designed to provide security isolation.
If you need true isolation, run OpenCode inside a Docker container or VM.
### Server Mode
Server mode is opt-in only. When enabled, set `OPENCODE_SERVER_PASSWORD` to require HTTP Basic Auth. Without this, the server runs unauthenticated (with a warning). It is the end user's responsibility to secure the server - any functionality it provides is not a vulnerability.
### Out of Scope
| Category | Rationale |
| ------------------------------- | ----------------------------------------------------------------------- |
| **Server access when opted-in** | If you enable server mode, API access is expected behavior |
| **Sandbox escapes** | The permission system is not a sandbox (see above) |
| **LLM provider data handling** | Data sent to your configured LLM provider is governed by their policies |
| **MCP server behavior** | External MCP servers you configure are outside our trust boundary |
---
# Reporting Security Issues
We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/anomalyco/opencode/security/advisories/new) tab.
The team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
## Escalation
If you do not receive an acknowledgement of your report within 6 business days, you may send an email to security@anoma.ly
+189 -201
View File
@@ -1,203 +1,191 @@
# Download Stats
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | -------------------- | ------------------- | -------------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) |
| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) |
| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) |
| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) |
| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) |
| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) |
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) |
| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) |
| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) |
| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) |
| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) |
| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) |
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) |
| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) |
| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) |
| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) |
| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) |
| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) |
| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) |
| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) |
| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) |
| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) |
| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) |
| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) |
| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) |
| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) |
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
| 2026-01-03 | 1,618,065 (+54,591) | 1,331,914 (+10,955) | 2,949,979 (+65,546) |
| 2026-01-04 | 1,672,656 (+39,702) | 1,339,883 (+7,969) | 3,012,539 (+62,560) |
| 2026-01-05 | 1,738,171 (+65,515) | 1,353,043 (+13,160) | 3,091,214 (+78,675) |
| 2026-01-06 | 1,960,988 (+222,817) | 1,377,377 (+24,334) | 3,338,365 (+247,151) |
| 2026-01-07 | 2,123,239 (+162,251) | 1,398,648 (+21,271) | 3,521,887 (+183,522) |
| 2026-01-08 | 2,272,630 (+149,391) | 1,432,480 (+33,832) | 3,705,110 (+183,223) |
| 2026-01-09 | 2,443,565 (+170,935) | 1,469,451 (+36,971) | 3,913,016 (+207,906) |
| 2026-01-10 | 2,632,023 (+188,458) | 1,503,670 (+34,219) | 4,135,693 (+222,677) |
| 2026-01-11 | 2,836,394 (+204,371) | 1,530,479 (+26,809) | 4,366,873 (+231,180) |
| 2026-01-12 | 3,053,594 (+217,200) | 1,553,671 (+23,192) | 4,607,265 (+240,392) |
| 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) |
| 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) |
| Date | GitHub Downloads | npm Downloads | Total |
| ---------- | ------------------- | ------------------- | ------------------- |
| 2025-06-29 | 18,789 (+0) | 39,420 (+0) | 58,209 (+0) |
| 2025-06-30 | 20,127 (+1,338) | 41,059 (+1,639) | 61,186 (+2,977) |
| 2025-07-01 | 22,108 (+1,981) | 43,745 (+2,686) | 65,853 (+4,667) |
| 2025-07-02 | 24,814 (+2,706) | 46,168 (+2,423) | 70,982 (+5,129) |
| 2025-07-03 | 27,834 (+3,020) | 49,955 (+3,787) | 77,789 (+6,807) |
| 2025-07-04 | 30,608 (+2,774) | 54,758 (+4,803) | 85,366 (+7,577) |
| 2025-07-05 | 32,524 (+1,916) | 58,371 (+3,613) | 90,895 (+5,529) |
| 2025-07-06 | 33,766 (+1,242) | 59,694 (+1,323) | 93,460 (+2,565) |
| 2025-07-08 | 38,052 (+4,286) | 64,468 (+4,774) | 102,520 (+9,060) |
| 2025-07-09 | 40,924 (+2,872) | 67,935 (+3,467) | 108,859 (+6,339) |
| 2025-07-10 | 43,796 (+2,872) | 71,402 (+3,467) | 115,198 (+6,339) |
| 2025-07-11 | 46,982 (+3,186) | 77,462 (+6,060) | 124,444 (+9,246) |
| 2025-07-12 | 49,302 (+2,320) | 82,177 (+4,715) | 131,479 (+7,035) |
| 2025-07-13 | 50,803 (+1,501) | 86,394 (+4,217) | 137,197 (+5,718) |
| 2025-07-14 | 53,283 (+2,480) | 87,860 (+1,466) | 141,143 (+3,946) |
| 2025-07-15 | 57,590 (+4,307) | 91,036 (+3,176) | 148,626 (+7,483) |
| 2025-07-16 | 62,313 (+4,723) | 95,258 (+4,222) | 157,571 (+8,945) |
| 2025-07-17 | 66,684 (+4,371) | 100,048 (+4,790) | 166,732 (+9,161) |
| 2025-07-18 | 70,379 (+3,695) | 102,587 (+2,539) | 172,966 (+6,234) |
| 2025-07-19 | 73,497 (+3,117) | 105,904 (+3,317) | 179,401 (+6,434) |
| 2025-07-20 | 76,453 (+2,956) | 109,044 (+3,140) | 185,497 (+6,096) |
| 2025-07-21 | 80,197 (+3,744) | 113,537 (+4,493) | 193,734 (+8,237) |
| 2025-07-22 | 84,251 (+4,054) | 118,073 (+4,536) | 202,324 (+8,590) |
| 2025-07-23 | 88,589 (+4,338) | 121,436 (+3,363) | 210,025 (+7,701) |
| 2025-07-24 | 92,469 (+3,880) | 124,091 (+2,655) | 216,560 (+6,535) |
| 2025-07-25 | 96,417 (+3,948) | 126,985 (+2,894) | 223,402 (+6,842) |
| 2025-07-26 | 100,646 (+4,229) | 131,411 (+4,426) | 232,057 (+8,655) |
| 2025-07-27 | 102,644 (+1,998) | 134,736 (+3,325) | 237,380 (+5,323) |
| 2025-07-28 | 105,446 (+2,802) | 136,016 (+1,280) | 241,462 (+4,082) |
| 2025-07-29 | 108,998 (+3,552) | 137,542 (+1,526) | 246,540 (+5,078) |
| 2025-07-30 | 113,544 (+4,546) | 140,317 (+2,775) | 253,861 (+7,321) |
| 2025-07-31 | 118,339 (+4,795) | 143,344 (+3,027) | 261,683 (+7,822) |
| 2025-08-01 | 123,539 (+5,200) | 146,680 (+3,336) | 270,219 (+8,536) |
| 2025-08-02 | 127,864 (+4,325) | 149,236 (+2,556) | 277,100 (+6,881) |
| 2025-08-03 | 131,397 (+3,533) | 150,451 (+1,215) | 281,848 (+4,748) |
| 2025-08-04 | 136,266 (+4,869) | 153,260 (+2,809) | 289,526 (+7,678) |
| 2025-08-05 | 141,596 (+5,330) | 155,752 (+2,492) | 297,348 (+7,822) |
| 2025-08-06 | 147,067 (+5,471) | 158,309 (+2,557) | 305,376 (+8,028) |
| 2025-08-07 | 152,591 (+5,524) | 160,889 (+2,580) | 313,480 (+8,104) |
| 2025-08-08 | 158,187 (+5,596) | 163,448 (+2,559) | 321,635 (+8,155) |
| 2025-08-09 | 162,770 (+4,583) | 165,721 (+2,273) | 328,491 (+6,856) |
| 2025-08-10 | 165,695 (+2,925) | 167,109 (+1,388) | 332,804 (+4,313) |
| 2025-08-11 | 169,297 (+3,602) | 167,953 (+844) | 337,250 (+4,446) |
| 2025-08-12 | 176,307 (+7,010) | 171,876 (+3,923) | 348,183 (+10,933) |
| 2025-08-13 | 182,997 (+6,690) | 177,182 (+5,306) | 360,179 (+11,996) |
| 2025-08-14 | 189,063 (+6,066) | 179,741 (+2,559) | 368,804 (+8,625) |
| 2025-08-15 | 193,608 (+4,545) | 181,792 (+2,051) | 375,400 (+6,596) |
| 2025-08-16 | 198,118 (+4,510) | 184,558 (+2,766) | 382,676 (+7,276) |
| 2025-08-17 | 201,299 (+3,181) | 186,269 (+1,711) | 387,568 (+4,892) |
| 2025-08-18 | 204,559 (+3,260) | 187,399 (+1,130) | 391,958 (+4,390) |
| 2025-08-19 | 209,814 (+5,255) | 189,668 (+2,269) | 399,482 (+7,524) |
| 2025-08-20 | 214,497 (+4,683) | 191,481 (+1,813) | 405,978 (+6,496) |
| 2025-08-21 | 220,465 (+5,968) | 194,784 (+3,303) | 415,249 (+9,271) |
| 2025-08-22 | 225,899 (+5,434) | 197,204 (+2,420) | 423,103 (+7,854) |
| 2025-08-23 | 229,005 (+3,106) | 199,238 (+2,034) | 428,243 (+5,140) |
| 2025-08-24 | 232,098 (+3,093) | 201,157 (+1,919) | 433,255 (+5,012) |
| 2025-08-25 | 236,607 (+4,509) | 202,650 (+1,493) | 439,257 (+6,002) |
| 2025-08-26 | 242,783 (+6,176) | 205,242 (+2,592) | 448,025 (+8,768) |
| 2025-08-27 | 248,409 (+5,626) | 205,242 (+0) | 453,651 (+5,626) |
| 2025-08-28 | 252,796 (+4,387) | 205,242 (+0) | 458,038 (+4,387) |
| 2025-08-29 | 256,045 (+3,249) | 211,075 (+5,833) | 467,120 (+9,082) |
| 2025-08-30 | 258,863 (+2,818) | 212,397 (+1,322) | 471,260 (+4,140) |
| 2025-08-31 | 262,004 (+3,141) | 213,944 (+1,547) | 475,948 (+4,688) |
| 2025-09-01 | 265,359 (+3,355) | 215,115 (+1,171) | 480,474 (+4,526) |
| 2025-09-02 | 270,483 (+5,124) | 217,075 (+1,960) | 487,558 (+7,084) |
| 2025-09-03 | 274,793 (+4,310) | 219,755 (+2,680) | 494,548 (+6,990) |
| 2025-09-04 | 280,430 (+5,637) | 222,103 (+2,348) | 502,533 (+7,985) |
| 2025-09-05 | 283,769 (+3,339) | 223,793 (+1,690) | 507,562 (+5,029) |
| 2025-09-06 | 286,245 (+2,476) | 225,036 (+1,243) | 511,281 (+3,719) |
| 2025-09-07 | 288,623 (+2,378) | 225,866 (+830) | 514,489 (+3,208) |
| 2025-09-08 | 293,341 (+4,718) | 227,073 (+1,207) | 520,414 (+5,925) |
| 2025-09-09 | 300,036 (+6,695) | 229,788 (+2,715) | 529,824 (+9,410) |
| 2025-09-10 | 307,287 (+7,251) | 233,435 (+3,647) | 540,722 (+10,898) |
| 2025-09-11 | 314,083 (+6,796) | 237,356 (+3,921) | 551,439 (+10,717) |
| 2025-09-12 | 321,046 (+6,963) | 240,728 (+3,372) | 561,774 (+10,335) |
| 2025-09-13 | 324,894 (+3,848) | 245,539 (+4,811) | 570,433 (+8,659) |
| 2025-09-14 | 328,876 (+3,982) | 248,245 (+2,706) | 577,121 (+6,688) |
| 2025-09-15 | 334,201 (+5,325) | 250,983 (+2,738) | 585,184 (+8,063) |
| 2025-09-16 | 342,609 (+8,408) | 255,264 (+4,281) | 597,873 (+12,689) |
| 2025-09-17 | 351,117 (+8,508) | 260,970 (+5,706) | 612,087 (+14,214) |
| 2025-09-18 | 358,717 (+7,600) | 266,922 (+5,952) | 625,639 (+13,552) |
| 2025-09-19 | 365,401 (+6,684) | 271,859 (+4,937) | 637,260 (+11,621) |
| 2025-09-20 | 372,092 (+6,691) | 276,917 (+5,058) | 649,009 (+11,749) |
| 2025-09-21 | 377,079 (+4,987) | 280,261 (+3,344) | 657,340 (+8,331) |
| 2025-09-22 | 382,492 (+5,413) | 284,009 (+3,748) | 666,501 (+9,161) |
| 2025-09-23 | 387,008 (+4,516) | 289,129 (+5,120) | 676,137 (+9,636) |
| 2025-09-24 | 393,325 (+6,317) | 294,927 (+5,798) | 688,252 (+12,115) |
| 2025-09-25 | 398,879 (+5,554) | 301,663 (+6,736) | 700,542 (+12,290) |
| 2025-09-26 | 404,334 (+5,455) | 306,713 (+5,050) | 711,047 (+10,505) |
| 2025-09-27 | 411,618 (+7,284) | 317,763 (+11,050) | 729,381 (+18,334) |
| 2025-09-28 | 414,910 (+3,292) | 322,522 (+4,759) | 737,432 (+8,051) |
| 2025-09-29 | 419,919 (+5,009) | 328,033 (+5,511) | 747,952 (+10,520) |
| 2025-09-30 | 427,991 (+8,072) | 336,472 (+8,439) | 764,463 (+16,511) |
| 2025-10-01 | 433,591 (+5,600) | 341,742 (+5,270) | 775,333 (+10,870) |
| 2025-10-02 | 440,852 (+7,261) | 348,099 (+6,357) | 788,951 (+13,618) |
| 2025-10-03 | 446,829 (+5,977) | 359,937 (+11,838) | 806,766 (+17,815) |
| 2025-10-04 | 452,561 (+5,732) | 370,386 (+10,449) | 822,947 (+16,181) |
| 2025-10-05 | 455,559 (+2,998) | 374,745 (+4,359) | 830,304 (+7,357) |
| 2025-10-06 | 460,927 (+5,368) | 379,489 (+4,744) | 840,416 (+10,112) |
| 2025-10-07 | 467,336 (+6,409) | 385,438 (+5,949) | 852,774 (+12,358) |
| 2025-10-08 | 474,643 (+7,307) | 394,139 (+8,701) | 868,782 (+16,008) |
| 2025-10-09 | 479,203 (+4,560) | 400,526 (+6,387) | 879,729 (+10,947) |
| 2025-10-10 | 484,374 (+5,171) | 406,015 (+5,489) | 890,389 (+10,660) |
| 2025-10-11 | 488,427 (+4,053) | 414,699 (+8,684) | 903,126 (+12,737) |
| 2025-10-12 | 492,125 (+3,698) | 418,745 (+4,046) | 910,870 (+7,744) |
| 2025-10-14 | 505,130 (+13,005) | 429,286 (+10,541) | 934,416 (+23,546) |
| 2025-10-15 | 512,717 (+7,587) | 439,290 (+10,004) | 952,007 (+17,591) |
| 2025-10-16 | 517,719 (+5,002) | 447,137 (+7,847) | 964,856 (+12,849) |
| 2025-10-17 | 526,239 (+8,520) | 457,467 (+10,330) | 983,706 (+18,850) |
| 2025-10-18 | 531,564 (+5,325) | 465,272 (+7,805) | 996,836 (+13,130) |
| 2025-10-19 | 536,209 (+4,645) | 469,078 (+3,806) | 1,005,287 (+8,451) |
| 2025-10-20 | 541,264 (+5,055) | 472,952 (+3,874) | 1,014,216 (+8,929) |
| 2025-10-21 | 548,721 (+7,457) | 479,703 (+6,751) | 1,028,424 (+14,208) |
| 2025-10-22 | 557,949 (+9,228) | 491,395 (+11,692) | 1,049,344 (+20,920) |
| 2025-10-23 | 564,716 (+6,767) | 498,736 (+7,341) | 1,063,452 (+14,108) |
| 2025-10-24 | 572,692 (+7,976) | 506,905 (+8,169) | 1,079,597 (+16,145) |
| 2025-10-25 | 578,927 (+6,235) | 516,129 (+9,224) | 1,095,056 (+15,459) |
| 2025-10-26 | 584,409 (+5,482) | 521,179 (+5,050) | 1,105,588 (+10,532) |
| 2025-10-27 | 589,999 (+5,590) | 526,001 (+4,822) | 1,116,000 (+10,412) |
| 2025-10-28 | 595,776 (+5,777) | 532,438 (+6,437) | 1,128,214 (+12,214) |
| 2025-10-29 | 606,259 (+10,483) | 542,064 (+9,626) | 1,148,323 (+20,109) |
| 2025-10-30 | 613,746 (+7,487) | 542,064 (+0) | 1,155,810 (+7,487) |
| 2025-10-30 | 617,846 (+4,100) | 555,026 (+12,962) | 1,172,872 (+17,062) |
| 2025-10-31 | 626,612 (+8,766) | 564,579 (+9,553) | 1,191,191 (+18,319) |
| 2025-11-01 | 636,100 (+9,488) | 581,806 (+17,227) | 1,217,906 (+26,715) |
| 2025-11-02 | 644,067 (+7,967) | 590,004 (+8,198) | 1,234,071 (+16,165) |
| 2025-11-03 | 653,130 (+9,063) | 597,139 (+7,135) | 1,250,269 (+16,198) |
| 2025-11-04 | 663,912 (+10,782) | 608,056 (+10,917) | 1,271,968 (+21,699) |
| 2025-11-05 | 675,074 (+11,162) | 619,690 (+11,634) | 1,294,764 (+22,796) |
| 2025-11-06 | 686,252 (+11,178) | 630,885 (+11,195) | 1,317,137 (+22,373) |
| 2025-11-07 | 696,646 (+10,394) | 642,146 (+11,261) | 1,338,792 (+21,655) |
| 2025-11-08 | 706,035 (+9,389) | 653,489 (+11,343) | 1,359,524 (+20,732) |
| 2025-11-09 | 713,462 (+7,427) | 660,459 (+6,970) | 1,373,921 (+14,397) |
| 2025-11-10 | 722,288 (+8,826) | 668,225 (+7,766) | 1,390,513 (+16,592) |
| 2025-11-11 | 729,769 (+7,481) | 677,501 (+9,276) | 1,407,270 (+16,757) |
| 2025-11-12 | 740,180 (+10,411) | 686,454 (+8,953) | 1,426,634 (+19,364) |
| 2025-11-13 | 749,905 (+9,725) | 696,157 (+9,703) | 1,446,062 (+19,428) |
| 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080) | 1,465,165 (+19,103) |
| 2025-11-15 | 765,955 (+6,027) | 712,870 (+7,633) | 1,478,825 (+13,660) |
| 2025-11-16 | 771,069 (+5,114) | 716,596 (+3,726) | 1,487,665 (+8,840) |
| 2025-11-17 | 780,161 (+9,092) | 723,339 (+6,743) | 1,503,500 (+15,835) |
| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205) | 1,524,107 (+20,607) |
| 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
| 2025-11-23 | 846,609 (+9,340) | 795,069 (+14,073) | 1,641,678 (+23,413) |
| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964) | 1,660,766 (+19,088) |
| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) |
| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) |
| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) |
| 2025-11-28 | 901,741 (+7,781) | 856,482 (+10,302) | 1,758,223 (+18,083) |
| 2025-11-29 | 908,689 (+6,948) | 863,361 (+6,879) | 1,772,050 (+13,827) |
| 2025-11-30 | 916,116 (+7,427) | 870,194 (+6,833) | 1,786,310 (+14,260) |
| 2025-12-01 | 925,898 (+9,782) | 876,500 (+6,306) | 1,802,398 (+16,088) |
| 2025-12-02 | 939,250 (+13,352) | 890,919 (+14,419) | 1,830,169 (+27,771) |
| 2025-12-03 | 952,249 (+12,999) | 903,713 (+12,794) | 1,855,962 (+25,793) |
| 2025-12-04 | 965,611 (+13,362) | 916,471 (+12,758) | 1,882,082 (+26,120) |
| 2025-12-05 | 977,996 (+12,385) | 930,616 (+14,145) | 1,908,612 (+26,530) |
| 2025-12-06 | 987,884 (+9,888) | 943,773 (+13,157) | 1,931,657 (+23,045) |
| 2025-12-07 | 994,046 (+6,162) | 951,425 (+7,652) | 1,945,471 (+13,814) |
| 2025-12-08 | 1,000,898 (+6,852) | 957,149 (+5,724) | 1,958,047 (+12,576) |
| 2025-12-09 | 1,011,488 (+10,590) | 973,922 (+16,773) | 1,985,410 (+27,363) |
| 2025-12-10 | 1,025,891 (+14,403) | 991,708 (+17,786) | 2,017,599 (+32,189) |
| 2025-12-11 | 1,045,110 (+19,219) | 1,010,559 (+18,851) | 2,055,669 (+38,070) |
| 2025-12-12 | 1,061,340 (+16,230) | 1,030,838 (+20,279) | 2,092,178 (+36,509) |
| 2025-12-13 | 1,073,561 (+12,221) | 1,044,608 (+13,770) | 2,118,169 (+25,991) |
| 2025-12-14 | 1,082,042 (+8,481) | 1,052,425 (+7,817) | 2,134,467 (+16,298) |
| 2025-12-15 | 1,093,632 (+11,590) | 1,059,078 (+6,653) | 2,152,710 (+18,243) |
| 2025-12-16 | 1,120,477 (+26,845) | 1,078,022 (+18,944) | 2,198,499 (+45,789) |
| 2025-12-17 | 1,151,067 (+30,590) | 1,097,661 (+19,639) | 2,248,728 (+50,229) |
| 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
| 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
| 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |
| 2025-12-23 | 1,286,548 (+24,026) | 1,186,439 (+17,318) | 2,472,987 (+41,344) |
| 2025-12-24 | 1,309,323 (+22,775) | 1,203,767 (+17,328) | 2,513,090 (+40,103) |
| 2025-12-25 | 1,333,032 (+23,709) | 1,217,283 (+13,516) | 2,550,315 (+37,225) |
| 2025-12-26 | 1,352,411 (+19,379) | 1,227,615 (+10,332) | 2,580,026 (+29,711) |
| 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
| 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454) | 2,636,078 (+26,071) |
| 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |
| 2026-01-02 | 1,563,474 (+54,591) | 1,320,959 (+11,085) | 2,884,433 (+65,676) |
+10 -69
View File
@@ -1,71 +1,12 @@
## Style Guide
- 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()`
# Avoid let statements
We don't like `let` statements, especially combined with if/else statements.
Prefer `const`.
Good:
```ts
const foo = condition ? 1 : 2
```
Bad:
```ts
let foo
if (condition) foo = 1
else foo = 2
```
# Avoid else statements
Prefer early returns or using an `iife` to avoid else statements.
Good:
```ts
function foo() {
if (condition) return 1
return 2
}
```
Bad:
```ts
function foo() {
if (condition) return 1
else return 2
}
```
# Prefer single word naming
Try your best to find a single word name for your variables, functions, etc.
Only use multiple words if you cannot.
Good:
```ts
const foo = 1
const bar = 2
const baz = 3
```
Bad:
```ts
const fooBar = 1
const barBaz = 2
const bazFoo = 3
```
- Try to keep things in one function unless composable or reusable
- DO NOT do unnecessary destructuring of variables
- DO NOT use `else` statements unless necessary
- DO NOT use `try`/`catch` if it can be avoided
- AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type
- AVOID `let` statements
- PREFER single word variable names where possible
- Use as many bun apis as possible like Bun.file()
+105 -187
View File
@@ -22,7 +22,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -70,7 +70,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -81,30 +81,24 @@
"@opencode-ai/console-mail": "workspace:*",
"@opencode-ai/console-resource": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@smithy/eventstream-codec": "4.2.7",
"@smithy/util-utf8": "4.2.0",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@stripe/stripe-js": "8.6.1",
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"solid-list": "0.3.0",
"solid-stripe": "0.8.1",
"vite": "catalog:",
"zod": "catalog:",
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"@webgpu/types": "0.1.54",
"typescript": "catalog:",
"wrangler": "4.50.0",
},
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -131,7 +125,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@ai-sdk/anthropic": "2.0.0",
"@ai-sdk/openai": "2.0.2",
@@ -155,7 +149,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -179,10 +173,9 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@solid-primitives/storage": "catalog:",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "~2",
@@ -208,7 +201,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -237,7 +230,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -253,7 +246,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.23",
"version": "1.0.224",
"bin": {
"opencode": "./bin/opencode",
},
@@ -261,30 +254,29 @@
"@actions/core": "1.11.1",
"@actions/github": "6.0.1",
"@agentclientprotocol/sdk": "0.5.1",
"@ai-sdk/amazon-bedrock": "3.0.73",
"@ai-sdk/anthropic": "2.0.57",
"@ai-sdk/azure": "2.0.91",
"@ai-sdk/cerebras": "1.0.34",
"@ai-sdk/cohere": "2.0.22",
"@ai-sdk/deepinfra": "1.0.31",
"@ai-sdk/gateway": "2.0.25",
"@ai-sdk/google": "2.0.52",
"@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.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.31",
"@ai-sdk/amazon-bedrock": "3.0.57",
"@ai-sdk/anthropic": "2.0.56",
"@ai-sdk/azure": "2.0.82",
"@ai-sdk/cerebras": "1.0.33",
"@ai-sdk/cohere": "2.0.21",
"@ai-sdk/deepinfra": "1.0.30",
"@ai-sdk/gateway": "2.0.23",
"@ai-sdk/google": "2.0.49",
"@ai-sdk/google-vertex": "3.0.81",
"@ai-sdk/groq": "2.0.33",
"@ai-sdk/mistral": "2.0.26",
"@ai-sdk/openai": "2.0.71",
"@ai-sdk/openai-compatible": "1.0.29",
"@ai-sdk/perplexity": "2.0.22",
"@ai-sdk/provider": "2.0.0",
"@ai-sdk/provider-utils": "3.0.19",
"@ai-sdk/togetherai": "1.0.30",
"@ai-sdk/vercel": "1.0.31",
"@ai-sdk/xai": "2.0.51",
"@ai-sdk/xai": "2.0.42",
"@clack/prompts": "1.0.0-alpha.1",
"@gitlab/gitlab-ai-provider": "3.1.1",
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.25.2",
"@modelcontextprotocol/sdk": "1.15.1",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -293,12 +285,11 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "1.5.2",
"@opentui/core": "0.1.73",
"@opentui/solid": "0.1.73",
"@opentui/core": "0.1.67",
"@opentui/solid": "0.1.67",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/scheduled": "1.5.2",
"@standard-schema/spec": "1.0.0",
"@zip.js/zip.js": "2.7.62",
"ai": "catalog:",
@@ -357,7 +348,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -377,7 +368,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.23",
"version": "1.0.224",
"devDependencies": {
"@hey-api/openapi-ts": "0.88.1",
"@tsconfig/node22": "catalog:",
@@ -388,7 +379,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -401,7 +392,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -409,11 +400,9 @@
"@pierre/diffs": "catalog:",
"@shikijs/transformers": "3.9.2",
"@solid-primitives/bounds": "0.1.3",
"@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@typescript/native-preview": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"luxon": "catalog:",
@@ -441,7 +430,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"zod": "catalog:",
},
@@ -452,7 +441,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.23",
"version": "1.0.224",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -508,13 +497,12 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
"@types/bun": "1.3.5",
"@types/bun": "1.3.4",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"ai": "5.0.119",
"ai": "5.0.97",
"diff": "8.0.2",
"dompurify": "3.3.1",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -550,52 +538,48 @@
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.5.1", "", { "dependencies": { "zod": "^3.0.0" } }, "sha512-9bq2TgjhLBSUSC5jE04MEe+Hqw8YePzKghhYZ9QcjOyonY3q2oJfX6GoSO83hURpEnsqEPIrex6VZN3+61fBJg=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.73", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-EAAGJ/dfbAZaqIhK3w52hq6cftSLZwXdC6uHKh8Cls1T0N4MxS6ykDf54UyFO3bZWkQxR+Mdw1B3qireGOxtJQ=="],
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.57", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.45", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@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-mOUSLe+RgZzx0rtL1p9QXmSd/08z1EkBR+vQ1ydpd1t5P0Nx2kB8afiukEgM8nuDvmO9eYQlp7VTy1n5ffPs2g=="],
"@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/azure": ["@ai-sdk/azure@2.0.82", "", { "dependencies": { "@ai-sdk/openai": "2.0.80", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Bpab51ETBB4adZC1xGMYsryL/CB8j1sA+t5aDqhRv3t3WRLTxhaBDcFKtQTIuxiEQTFosz9Q2xQqdfBvQm5jHw=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.34", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XOK0dJsAGoPYi/lfR4KFBi8xhvJ46oCpAxUD6FmJAuJ4eh0qlj5zDt+myvzM8gvN7S6K7zHD+mdWlOPKGQT8Vg=="],
"@ai-sdk/cerebras": ["@ai-sdk/cerebras@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2gSSS/7kunIwMdC4td5oWsUAzoLw84ccGpz6wQbxVnrb1iWnrEnKa5tRBduaP6IXpzLWsu8wME3+dQhZy+gT7w=="],
"@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/cohere": ["@ai-sdk/cohere@2.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZjaZFvJlc5XOPi3QwTLEFZbHIgTJc6YGvxz+8zIMGVZi/hdynR8/f/C1A9x6mhzmBtAqi/dZ2h11oouAQH5z4g=="],
"@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.30", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XK8oRZFApzo6xnS5C+FhWUUkB2itA5Nfon3pU9dJVM0goViq8GwdleZTBRqhu4DE4KJURo5DGWpJr2hfV54cEg=="],
"@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/gateway": ["@ai-sdk/gateway@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-qmX7afPRszUqG5hryHF3UN8ITPIRSGmDW6VYCmByzjoUkgm3MekzSx2hMV1wr0P+llDeuXb378SjqUfpvWJulg=="],
"@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": ["@ai-sdk/google@2.0.49", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-efwKk4mOV0SpumUaQskeYABk37FJPmEYwoDJQEjyLRmGSjtHRe9P5Cwof5ffLvaFav2IaJpBGEz98pyTs7oNWA=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.97", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.57", "@ai-sdk/google": "2.0.52", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-s4tI7Z15i6FlbtCvS4SBRal8wRfkOXJzKxlS6cU4mJW/QfUfoVy4b22836NVNJwDvkG/HkDSfzwm/X8mn46MhA=="],
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.81", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.50", "@ai-sdk/google": "2.0.44", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18", "google-auth-library": "^9.15.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-yrl5Ug0Mqwo9ya45oxczgy2RWgpEA/XQQCSFYP+3NZMQ4yA3Iim1vkOjVCsGaZZ8rjVk395abi1ZMZV0/6rqVA=="],
"@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=="],
"@ai-sdk/groq": ["@ai-sdk/groq@2.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FWGl7xNr88NBveao3y9EcVWYUt9ABPrwLFY7pIutSNgaTf32vgvyhREobaMrLU4Scr5G/2tlNqOPZ5wkYMaZig=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.27", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gaptHgaXjMw3+eA0Q4FABcsj5nQNP6EpFaGUR+Pj5WJy7Kn6mApl975/x57224MfeJIShNpt8wFKK3tvh5ewKg=="],
"@ai-sdk/mistral": ["@ai-sdk/mistral@2.0.26", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-jxDB++4WI1wEx5ONNBI+VbkmYJOYIuS8UQY13/83UGRaiW7oB/WHiH4ETe6KzbKpQPB3XruwTJQjUMsMfKyTXA=="],
"@ai-sdk/openai": ["@ai-sdk/openai@2.0.2", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-D4zYz2uR90aooKQvX1XnS00Z7PkbrcY+snUvPfm5bCabTG7bzLrVtD56nJ5bSaZG8lmuOMfXpyiEEArYLyWPpw=="],
"@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.1", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-luHVcU+yKzwv3ekKgbP3v+elUVxb2Rt+8c6w9qi7g2NYG2/pEL21oIrnaEnc6UtTZLLZX9EFBcpq2N1FQKDIMw=="],
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@2.0.23", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-aiaRvnc6mhQZKhTTSXPCjPH8Iqr5D/PfCN1hgVP/3RGTBbJtsd9HemIBSABeSdAKbsMH/PwJxgnqH75HEamcBA=="],
"@ai-sdk/perplexity": ["@ai-sdk/perplexity@2.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-zwzcnk08R2J3mZcQPn4Ifl4wYGrvANR7jsBB0hCTUSbb+Rx3ybpikSWiGuXQXxdiRc1I5MWXgj70m+bZaLPvHw=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
"@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@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/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=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.31", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.30", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RlYubjStoZQxna4Ng91Vvo8YskvL7lW9zj68IwZfCnaDBSAp1u6Nhc5BR4ZtKnY6PA3XEtu4bATIQl7yiiQ+Lw=="],
"@ai-sdk/togetherai": ["@ai-sdk/togetherai@1.0.30", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9bxQbIXnWSN4bNismrza3NvIo+ui/Y3pj3UN6e9vCszCWFCN45RgISi4oDe10RqmzaJ/X8cfO/Tem+K8MT3wGQ=="],
"@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=="],
"@ai-sdk/xai": ["@ai-sdk/xai@2.0.42", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.29", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-wlwO4yRoZ/d+ca29vN8SDzxus7POdnL7GBTyRdSrt6icUF0hooLesauC8qRUC4aLxtqvMEc1YHtJOU7ZnLWbTQ=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="],
"@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="],
"@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="],
"@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="],
@@ -916,10 +900,6 @@
"@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.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-7AtFrCflq2NzC99bj7YaqbQDCZyaScM1+L4ujllV5syiRTFE239Uhnd/yEkPXa7sUAnNRfN3CWusCkQ2zK/q9g=="],
"@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=="],
"@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="],
"@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="],
@@ -928,8 +908,6 @@
"@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.88.1", "", { "dependencies": { "@hey-api/codegen-core": "^0.3.3", "@hey-api/json-schema-ref-parser": "1.2.2", "ansi-colors": "4.1.3", "c12": "3.3.2", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.2" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-x/nDTupOnV9VuSeNIiJpgIpc915GHduhyseJeMTnI0JMsXaObmpa0rgPr3ASVEYMLgpvqozIEG1RTOOnal6zLQ=="],
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
"@hono/standard-validator": ["@hono/standard-validator@0.1.5", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-EIyZPPwkyLn6XKwFj5NBEWHXhXbgmnVh2ceIFo5GO7gKI9WmzTjPDKnppQB0KrqKeAkq3kpoW4SIbu5X1dgx3w=="],
"@hono/zod-validator": ["@hono/zod-validator@0.4.2", "", { "peerDependencies": { "hono": ">=3.9.0", "zod": "^3.19.1" } }, "sha512-1rrlBg+EpDPhzOV4hT9pxr5+xDVmKuz6YJl+la7VCwK6ass5ldyKm5fD+umJdV2zhHD6jROoCCv8NbTwyfhT0g=="],
@@ -1114,7 +1092,7 @@
"@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.2", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.15.1", "", { "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-W/XlN9c528yYn+9MQkVjxiTPgPxoxt+oczfjHBDsJx0+59+O7B75Zhsp0B16Xbwbz8ANISDajh6+V7nIcPMc5w=="],
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
@@ -1218,21 +1196,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.73", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.73", "@opentui/core-darwin-x64": "0.1.73", "@opentui/core-linux-arm64": "0.1.73", "@opentui/core-linux-x64": "0.1.73", "@opentui/core-win32-arm64": "0.1.73", "@opentui/core-win32-x64": "0.1.73", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-1OqLlArzUh3QjrYXGro5WKNgoCcacGJaaFvwOHg5lAOoSigFQRiqEUEEJLbSo3pyV8u7XEdC3M0rOP6K+oThzw=="],
"@opentui/core": ["@opentui/core@0.1.67", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.67", "@opentui/core-darwin-x64": "0.1.67", "@opentui/core-linux-arm64": "0.1.67", "@opentui/core-linux-x64": "0.1.67", "@opentui/core-win32-arm64": "0.1.67", "@opentui/core-win32-x64": "0.1.67", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-zmfyA10QUbzT6ohacPoHmGiYzuJrDSCfQWRWrKtao0BrHj9bii73qWy3V/eR4ibVueoRREwxJs5GlBOSvK6IoA=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.73", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Xnc8S6kGIVcdwqqTq6jk50UVe1QtOXp+B0v4iH85iNW1Ljf198OoA7RcVA+edFb6o01PVwnhIIPtpkB/A4710w=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.67", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LtOcTlFD+kO7neItmkiF77H8cnjTYzBOZe8JQGwRSt9aaCke3UzMvLxmQnj4BP/kPC3hi9V6NRnFdptz0sJZIQ=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.73", "", { "os": "darwin", "cpu": "x64" }, "sha512-RlgxQxu+kxsCZzeXRnpYrqbrpxbG8M/lnDf4sTPWmhXUiuDvY5BdB4YiBY5bv8eNdJ1j9HiMLtx6ZxElEviidA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.67", "", { "os": "darwin", "cpu": "x64" }, "sha512-9i+awVWgpEVqZhFLaLq8usNGyCiyT5QxMLy6eH7JmRic79S34u23HfxiniGRtdYh3aqpm9SbLzo60v0nRIUkCA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.73", "", { "os": "linux", "cpu": "arm64" }, "sha512-9I88BdZMB3qtDPtDzFTg1EEt6sAGFSpOEmIIMB3MhqZqoq9+WSEyJZxM0/kff5vt4RJnqG7vz4fKMVRwNrUPGA=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.67", "", { "os": "linux", "cpu": "arm64" }, "sha512-WLjnTM3Ig//SRo0FUZYZJ5TITVbR6dKDVg6axU2D+sMoUzJMBP/Xo04q/TvZ3wP764Yca9l7oVMKWDxHlygyjQ=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.73", "", { "os": "linux", "cpu": "x64" }, "sha512-50cGZkCh/i3nzijsjUnkmtWJtnJ6l9WpdIwSJsO2Id7nZdzupT1b6AkgGZdOgNl23MHXpAitmb+MhEAjAimCRA=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.67", "", { "os": "linux", "cpu": "x64" }, "sha512-5UbZ/TqWi/DAmHIZL4NvhdpgTwglszRiddkRiQ8cT0IbnE4lutd4XxWUWcLKwsNT1YJv32TtcGWkuthluLiriQ=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.73", "", { "os": "win32", "cpu": "arm64" }, "sha512-mFiEeoiim5cmi6qu8CDfeecl9ivuMilfby/GnqTsr9G8e52qfT6nWF2m9Nevh9ebhXK+D/VnVhJIbObc0WIchA=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.67", "", { "os": "win32", "cpu": "arm64" }, "sha512-KNam5rObhN8/U9+GVVuvtAlGXp3MfdMHnw4W2P6YH7xp8HTsLvABUT91SJEyJ/ktVe9e1itLDG2fDHSoA5NbUg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.73", "", { "os": "win32", "cpu": "x64" }, "sha512-vzWHUi2vgwImuyxl+hlmK0aeCbnwozeuicIcHJE0orPOwp2PAKyR9WO330szAvfIO5ZPbNkjWfh6xIYnASM0lQ=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.67", "", { "os": "win32", "cpu": "x64" }, "sha512-740lkOw42zLNh9YfahXjCwV2DS/amH2uMDh3tCADDCLckrMhemIhqArXDiMlalDxDqYspoaZCpBsFVsG9dMS6A=="],
"@opentui/solid": ["@opentui/solid@0.1.73", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.73", "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-FBSTiuWl+hHqFxmrJfC93cbJ0PJ4QoFbvRFuD6Gzrea5rH+G7BidjyI8YZuCcNnriDuIYaXTJdvBqe15lgKR1A=="],
"@opentui/solid": ["@opentui/solid@0.1.67", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.67", "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-dVNq0+PJIdNb63D0T7vcbyVF/ZvLCihGvivTU50zDOzd0Sk5prbrIfpG8+DjMErFubXfdZQvdy/PqFdtw0rjtQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -1524,7 +1502,7 @@
"@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.5", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.5", "@smithy/property-provider": "^4.2.5", "@smithy/types": "^4.9.0", "@smithy/url-parser": "^4.2.5", "tslib": "^2.6.2" } }, "sha512-BZwotjoZWn9+36nimwm/OLIcVe+KYRwzMjfhd4QT7QxPm9WY0HiOV8t/Wlh+HVUif0SBVV7ksq8//hPaBC/okQ=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.7", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.11.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-DrpkEoM3j9cBBWhufqBwnbbn+3nf1N9FP6xuVJ+e220jbactKuQgaZwjwP5CP1t+O94brm2JgVMD2atMGX3xIQ=="],
"@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA=="],
"@smithy/eventstream-serde-browser": ["@smithy/eventstream-serde-browser@4.2.5", "", { "dependencies": { "@smithy/eventstream-serde-universal": "^4.2.5", "@smithy/types": "^4.9.0", "tslib": "^2.6.2" } }, "sha512-HohfmCQZjppVnKX2PnXlf47CW3j92Ki6T/vkAT2DhBR47e89pen3s4fIa7otGTtrVxmj7q+IhH0RnC5kpR8wtw=="],
@@ -1614,8 +1592,6 @@
"@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="],
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
"@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="],
"@solid-primitives/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
@@ -1640,8 +1616,6 @@
"@solid-primitives/rootless": ["@solid-primitives/rootless@1.5.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9HULb0QAzL2r47CCad0M+NKFtQ+LrGGNHZfteX/ThdGvKIg2o2GYhBooZubTCd/RTu2l2+Nw4s+dEfiDGvdrrQ=="],
"@solid-primitives/scheduled": ["@solid-primitives/scheduled@1.5.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-/j2igE0xyNaHhj6kMfcUQn5rAVSTLbAX+CDEBm25hSNBmNiHLu2lM7Usj2kJJ5j36D67bE8wR1hBNA8hjtvsQA=="],
"@solid-primitives/scroll": ["@solid-primitives/scroll@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-Ejq/Z7zKo/6eIEFr1bFLzXFxiGBCMLuqCM8QB8urr3YdPzjSETFLzYRWUyRiDWaBQN0F7k0SY6S7ig5nWOP7vg=="],
"@solid-primitives/static-store": ["@solid-primitives/static-store@0.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-ReK+5O38lJ7fT+L6mUFvUr6igFwHBESZF+2Ug842s7fvlVeBdIVEdTCErygff6w7uR6+jrr7J8jQo+cYrEq4Iw=="],
@@ -1668,8 +1642,6 @@
"@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="],
"@stripe/stripe-js": ["@stripe/stripe-js@8.6.1", "", {}, "sha512-UJ05U2062XDgydbUcETH1AoRQLNhigQ2KmDn1BG8sC3xfzu6JKg95Qt6YozdzFpxl1Npii/02m2LEWFt1RYjVA=="],
"@swc/helpers": ["@swc/helpers@0.5.17", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.11", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.30.1", "magic-string": "^0.30.17", "source-map-js": "^1.2.1", "tailwindcss": "4.1.11" } }, "sha512-yzhzuGRmv5QyU9qLNg4GTlYI6STedBWRE7NjxP45CsFYYq9taI0zJXZBMqIC/c8fViNLhmrbpSFS57EoxUmD6Q=="],
@@ -1776,7 +1748,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
"@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="],
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
@@ -1848,8 +1820,6 @@
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
"@types/tsscmp": ["@types/tsscmp@1.0.2", "", {}, "sha512-cy7BRSU8GYYgxjcx0Py+8lo5MthuDhlyu076KUcYzVNXL23luYgRHkMG2fIFEc6neckeh/ntP82mw+U4QjZq+g=="],
"@types/tunnel": ["@types/tunnel@0.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-sOUTGn6h1SfQ+gbgqC364jLFBw2lnFqkgF3q0WovEHRLMrVD1sd5aufqi/aJObLekJO+Aq5z646U4Oxy6shXMA=="],
@@ -1906,7 +1876,7 @@
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"@webgpu/types": ["@webgpu/types@0.1.54", "", {}, "sha512-81oaalC8LFrXjhsczomEQ0u3jG+TqE6V9QHLA8GNZq/Rnot0KDugu3LhSYSlie8tSdooAN1Hov05asrUUp9qgg=="],
"@webgpu/types": ["@webgpu/types@0.1.66", "", {}, "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA=="],
"@zip.js/zip.js": ["@zip.js/zip.js@2.7.62", "", {}, "sha512-OaLvZ8j4gCkLn048ypkZu29KX30r8/OfFF2w4Jo5WXFr+J04J+lzJ5TKZBVgFXhlvSkqNFQdfnY1Q8TMTCyBVA=="],
@@ -1926,11 +1896,9 @@
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
"ai": ["ai@5.0.119", "", { "dependencies": { "@ai-sdk/gateway": "2.0.25", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HUOwhc17fl2SZTJGZyA/99aNu706qKfXaUBCy9vgZiXBwrxg2eTzn2BCz7kmYDsfx6Fg2ACBy2icm41bsDXCTw=="],
"ai": ["ai@5.0.97", "", { "dependencies": { "@ai-sdk/gateway": "2.0.12", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8zBx0b/owis4eJI2tAlV8a1Rv0BANmLxontcAelkLNwEHhgfgXeKpDkhNB6OgV+BJSwboIUDkgd9312DdJnCOQ=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-align": ["ansi-align@3.0.1", "", { "dependencies": { "string-width": "^4.1.0" } }, "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w=="],
@@ -2078,7 +2046,7 @@
"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=="],
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
"bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="],
@@ -2228,8 +2196,6 @@
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
"data-view-buffer": ["data-view-buffer@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ=="],
"data-view-byte-length": ["data-view-byte-length@1.0.2", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-data-view": "^1.0.2" } }, "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ=="],
@@ -2302,8 +2268,6 @@
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"dompurify": ["dompurify@3.3.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dot-case": ["dot-case@3.0.4", "", { "dependencies": { "no-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w=="],
@@ -2336,10 +2300,6 @@
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="],
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
@@ -2442,7 +2402,7 @@
"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-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"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=="],
@@ -2450,8 +2410,6 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -2482,8 +2440,6 @@
"formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="],
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
@@ -2506,9 +2462,9 @@
"fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="],
"gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
"gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="],
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
"gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="],
"gel": ["gel@2.2.0", "", { "dependencies": { "@petamoriken/float16": "^3.8.7", "debug": "^4.3.4", "env-paths": "^3.0.0", "semver": "^7.6.2", "shell-quote": "^1.8.1", "which": "^4.0.0" }, "bin": { "gel": "dist/cli.mjs" } }, "sha512-q0ma7z2swmoamHQusey8ayo8+ilVdzDt4WTxSPzq/yRqvucWRfymRVMvNgmSC0XK7eNjjEZEcplxpgaNojKdmQ=="],
@@ -2554,21 +2510,17 @@
"globby": ["globby@11.0.4", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.1.1", "ignore": "^5.1.4", "merge2": "^1.3.0", "slash": "^3.0.0" } }, "sha512-9O4MVG9ioZJ08ffbcyVYyLOJLk5JQ688pJ4eMGLpdWLHq/Wr1D9BlriLQyL0E+jbkuePVZXYFj47QM/v093wHg=="],
"google-auth-library": ["google-auth-library@10.5.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.0.0", "gcp-metadata": "^8.0.0", "google-logging-utils": "^1.0.0", "gtoken": "^8.0.0", "jws": "^4.0.0" } }, "sha512-7ABviyMOlX5hIVD60YOfHw4/CxOfBhyduaYB+wbFWCWoni4N7SLcV46hrVRktuBbZjFC9ONyqamZITN7q3n32w=="],
"google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
"google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="],
"graphql-request": ["graphql-request@6.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "cross-fetch": "^3.1.5" }, "peerDependencies": { "graphql": "14 - 16" } }, "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw=="],
"gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="],
"gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="],
"gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
"h3": ["h3@2.0.1-rc.4", "", { "dependencies": { "rou3": "^0.7.8", "srvx": "^0.9.1" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"] }, "sha512-vZq8pEUp6THsXKXrUXX44eOqfChic2wVQ1GlSzQCBr7DeFBkfIZAo2WyNND4GSv54TAa0E4LYIK73WSPdgKUgw=="],
@@ -2794,8 +2746,6 @@
"isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="],
"isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="],
"iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="],
"iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="],
@@ -2828,11 +2778,7 @@
"json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
"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=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
@@ -3106,8 +3052,6 @@
"named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="],
"nanoevents": ["nanoevents@7.0.1", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
@@ -3436,8 +3380,6 @@
"remeda": ["remeda@2.26.0", "", { "dependencies": { "type-fest": "^4.41.0" } }, "sha512-lmNNwtaC6Co4m0WTTNoZ/JlpjEqAjPZO0+czC9YVRQUpkbS4x8Hmh+Mn9HPfJfiXqUQ5IXXgSXSOB2pBKAytdA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
"resolve": ["resolve@1.22.11", "", { "dependencies": { "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ=="],
@@ -3462,8 +3404,6 @@
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rimraf": ["rimraf@5.0.10", "", { "dependencies": { "glob": "^10.3.7" }, "bin": { "rimraf": "dist/esm/bin.mjs" } }, "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ=="],
"rollup": ["rollup@4.53.3", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.53.3", "@rollup/rollup-android-arm64": "4.53.3", "@rollup/rollup-darwin-arm64": "4.53.3", "@rollup/rollup-darwin-x64": "4.53.3", "@rollup/rollup-freebsd-arm64": "4.53.3", "@rollup/rollup-freebsd-x64": "4.53.3", "@rollup/rollup-linux-arm-gnueabihf": "4.53.3", "@rollup/rollup-linux-arm-musleabihf": "4.53.3", "@rollup/rollup-linux-arm64-gnu": "4.53.3", "@rollup/rollup-linux-arm64-musl": "4.53.3", "@rollup/rollup-linux-loong64-gnu": "4.53.3", "@rollup/rollup-linux-ppc64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-gnu": "4.53.3", "@rollup/rollup-linux-riscv64-musl": "4.53.3", "@rollup/rollup-linux-s390x-gnu": "4.53.3", "@rollup/rollup-linux-x64-gnu": "4.53.3", "@rollup/rollup-linux-x64-musl": "4.53.3", "@rollup/rollup-openharmony-arm64": "4.53.3", "@rollup/rollup-win32-arm64-msvc": "4.53.3", "@rollup/rollup-win32-ia32-msvc": "4.53.3", "@rollup/rollup-win32-x64-gnu": "4.53.3", "@rollup/rollup-win32-x64-msvc": "4.53.3", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA=="],
"rou3": ["rou3@0.7.10", "", {}, "sha512-aoFj6f7MJZ5muJ+Of79nrhs9N3oLGqi2VEMe94Zbkjb6Wupha46EuoYgpWSOZlXww3bbd8ojgXTAA2mzimX5Ww=="],
@@ -3550,10 +3490,6 @@
"smol-toml": ["smol-toml@1.5.2", "", {}, "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ=="],
"socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="],
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
@@ -3564,8 +3500,6 @@
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
"source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="],
@@ -3720,8 +3654,6 @@
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="],
"tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="],
@@ -3822,6 +3754,8 @@
"update-browserslist-db": ["update-browserslist-db@1.1.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"url": ["url@0.10.3", "", { "dependencies": { "punycode": "1.3.2", "querystring": "0.2.0" } }, "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ=="],
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
@@ -3914,8 +3848,6 @@
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
"xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
@@ -3966,35 +3898,35 @@
"@agentclientprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.45", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Ipv62vavDCmrV/oE/lXehL9FzwQuZOnnlhPEftWizx464Wb6lvnBTJx8uhmEYruFSzOWTI95Z33ncZ4tA8E6RQ=="],
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA=="],
"@ai-sdk/anthropic/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "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-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"@ai-sdk/anthropic/@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/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/azure/@ai-sdk/openai": ["@ai-sdk/openai@2.0.80", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tNHuraF11db+8xJEDBoU9E3vMcpnHFKRhnLQ3DQX2LnEzfPB9DksZ8rE+yVuDN1WRW9cm2OWAhgHFgVKs7ICuw=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@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/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="],
"@ai-sdk/openai/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "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-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="],
"@ai-sdk/openai/@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/openai-compatible/@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="],
"@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"@ai-sdk/togetherai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@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=="],
"@ai-sdk/vercel/@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="],
"@ai-sdk/vercel/@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/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"@astrojs/cloudflare/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="],
@@ -4068,8 +4000,6 @@
"@expressive-code/plugin-shiki/shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="],
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
@@ -4124,11 +4054,9 @@
"@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
"@modelcontextprotocol/sdk/jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@modelcontextprotocol/sdk/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.7", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="],
@@ -4240,10 +4168,6 @@
"@slack/web-api/p-queue": ["p-queue@6.6.2", "", { "dependencies": { "eventemitter3": "^4.0.4", "p-timeout": "^3.2.0" } }, "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ=="],
"@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.11.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-mlrmL0DRDVe3mNrjTcVcZEgkFmufITfUAPBEA+AHYiIeYyJebso/He1qLbP3PssRe22KUzLRpQSdBPbXdgZ2VA=="],
"@smithy/eventstream-serde-universal/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.5", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.9.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-Ogt4Zi9hEbIP17oQMd68qYOHUzmH47UkK7q7Gl55iIm9oKt27MUGrC5JfpMroeHjdkOliOA4Qt3NQ1xMq/nrlA=="],
"@solidjs/start/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"@solidjs/start/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
@@ -4268,6 +4192,10 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.12", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17", "@vercel/oidc": "3.0.5" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W+cB1sOWvPcz9qiIsNtD+HxUrBUva2vWv2K1EFukuImX+HA0uZx3EyyOjhYQ9gtf/teqEG80M6OvJ7xx/VLV2A=="],
"ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "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-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"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=="],
@@ -4300,8 +4228,6 @@
"body-parser/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
"bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.66", "", {}, "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA=="],
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
@@ -4318,8 +4244,6 @@
"editorconfig/minimatch": ["minimatch@9.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w=="],
"engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="],
"es-get-iterator/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
"esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
@@ -4336,13 +4260,13 @@
"express/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="],
"fetch-blob/web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
"finalhandler/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
"form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"gaxios/node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
"gaxios/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"gaxios/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
"glob/minimatch": ["minimatch@10.1.1", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ=="],
@@ -4384,11 +4308,11 @@
"nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.57", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DREpYqW2pylgaj69gZ+K8u92bo9DaMgFdictYnY+IwYeY3bawQ4zI7l/o1VkDsBDljAx8iYz5lPURwVZNu+Xpg=="],
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.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=="],
"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": ["@ai-sdk/openai@2.0.71", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.17" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tg+gj+R0z/On9P4V7hy7/7o04cQPjKGayMCL3gzWD/aNGjAKkhEnaocuNDidSnghizt8g2zJn16cAuAolnW+qQ=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.30", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-thubwhRtv9uicAxSWwNpinM7hiL/0CkhL/ymPaHuKvI494J7HIzn8KQZQ2ymRz284WTIZnI7VMyyejxW4RMM6w=="],
"opencode/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.29", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cZUppWzxjfpNaH1oVZ6U8yDLKKsdGbC9X0Pex8cG9CXhKWSoVLLnW1rKr6tu9jDISK5okjBIW/O1ZzfnbUrtEw=="],
"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=="],
@@ -4426,8 +4350,6 @@
"readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
"rimraf/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"router/path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"safe-array-concat/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="],
@@ -4478,6 +4400,8 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
@@ -4990,6 +4914,8 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"opencode/@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.17", "", { "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-TR3Gs4I3Tym4Ll+EPdzRdvo/rc8Js6c4nVhFLuvGLX/Y4V9ZcQMa/HTiYsHEgmYrf1zVi6Q145UEZUfleOwOjw=="],
"opencontrol/@modelcontextprotocol/sdk/express": ["express@5.1.0", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.0", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA=="],
"opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="],
@@ -5006,12 +4932,6 @@
"readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"rimraf/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"rimraf/glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"rimraf/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
@@ -5194,8 +5114,6 @@
"pkg-up/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
"rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"tw-to-css/tailwindcss/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"tw-to-css/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1768395095,
"narHash": "sha256-ZhuYJbwbZT32QA95tSkXd9zXHcdZj90EzHpEXBMabaw=",
"lastModified": 1767273430,
"narHash": "sha256-kDpoFwQ8GLrPiS3KL+sAwreXrph2KhdXuJzo5+vSLoo=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "13868c071cc73a5e9f610c47d7bb08e5da64fdd5",
"rev": "76eec3925eb9bbe193934987d3285473dbcfad50",
"type": "github"
},
"original": {
+7 -39
View File
@@ -27,28 +27,11 @@
"aarch64-darwin" = "bun-darwin-arm64";
"x86_64-darwin" = "bun-darwin-x64";
};
# Parse "bun-{os}-{cpu}" to {os, cpu}
parseBunTarget =
target:
let
parts = lib.splitString "-" target;
in
{
os = builtins.elemAt parts 1;
cpu = builtins.elemAt parts 2;
};
defaultNodeModules = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
hashesFile = "${./nix}/hashes.json";
hashesData =
if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { };
# Lookup hash: supports per-system ({system: hash}) or legacy single hash
nodeModulesHashFor =
system:
if builtins.isAttrs hashesData.nodeModules then
hashesData.nodeModules.${system}
else
hashesData.nodeModules;
nodeModulesHash = hashesData.nodeModules or defaultNodeModules;
modelsDev = forEachSystem (
system:
let
@@ -80,16 +63,13 @@
system:
let
pkgs = pkgsFor system;
bunPlatform = parseBunTarget bunTarget.${system};
mkNodeModules = pkgs.callPackage ./nix/node-modules.nix {
hash = nodeModulesHashFor system;
bunCpu = bunPlatform.cpu;
bunOs = bunPlatform.os;
hash = nodeModulesHash;
};
mkOpencode = pkgs.callPackage ./nix/opencode.nix { };
mkDesktop = pkgs.callPackage ./nix/desktop.nix { };
opencodePkg = mkOpencode {
mkPackage = pkgs.callPackage ./nix/opencode.nix { };
in
{
default = mkPackage {
inherit (packageJson) version;
src = ./.;
scripts = ./nix/scripts;
@@ -97,18 +77,6 @@
modelsDev = "${modelsDev.${system}}/dist/_api.json";
inherit mkNodeModules;
};
desktopPkg = mkDesktop {
inherit (packageJson) version;
src = ./.;
scripts = ./nix/scripts;
mkNodeModules = mkNodeModules;
opencode = opencodePkg;
};
in
{
default = opencodePkg;
desktop = desktopPkg;
}
);
+5 -6
View File
@@ -81,13 +81,12 @@ This will walk you through installing the GitHub app, creating the workflow, and
permissions:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
persist-credentials: false
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run opencode
- name: Run opencode
uses: anomalyco/opencode/github@latest
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
-1
View File
@@ -3,7 +3,6 @@
"module": "index.ts",
"type": "module",
"private": true,
"license": "MIT",
"devDependencies": {
"@types/bun": "catalog:"
},
-21
View File
@@ -76,7 +76,6 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
"checkout.session.completed",
"checkout.session.expired",
"charge.refunded",
"invoice.payment_succeeded",
"customer.created",
"customer.deleted",
"customer.updated",
@@ -98,19 +97,6 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
],
})
const zenProduct = new stripe.Product("ZenBlack", {
name: "OpenCode Black",
})
const zenPrice = new stripe.Price("ZenBlackPrice", {
product: zenProduct.id,
unitAmount: 20000,
currency: "usd",
recurring: {
interval: "month",
intervalCount: 1,
},
})
const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS1"),
new sst.Secret("ZEN_MODELS2"),
@@ -118,12 +104,8 @@ const ZEN_MODELS = [
new sst.Secret("ZEN_MODELS4"),
new sst.Secret("ZEN_MODELS5"),
new sst.Secret("ZEN_MODELS6"),
new sst.Secret("ZEN_MODELS7"),
new sst.Secret("ZEN_MODELS8"),
]
const ZEN_BLACK = new sst.Secret("ZEN_BLACK")
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
properties: { value: auth.url.apply((url) => url!) },
})
@@ -164,8 +146,6 @@ new sst.cloudflare.x.SolidStart("Console", {
EMAILOCTOPUS_API_KEY,
AWS_SES_ACCESS_KEY_ID,
AWS_SES_SECRET_ACCESS_KEY,
ZEN_BLACK,
new sst.Secret("ZEN_SESSION_SECRET"),
...ZEN_MODELS,
...($dev
? [
@@ -179,7 +159,6 @@ new sst.cloudflare.x.SolidStart("Console", {
//VITE_DOCS_URL: web.url.apply((url) => url!),
//VITE_API_URL: gateway.url.apply((url) => url!),
VITE_AUTH_URL: auth.url.apply((url) => url!),
VITE_STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY.value,
},
transform: {
server: {
+119 -146
View File
@@ -16,19 +16,16 @@ Usage: install.sh [options]
Options:
-h, --help Display this help message
-v, --version <version> Install a specific version (e.g., 1.0.180)
-b, --binary <path> Install from a local binary instead of downloading
--no-modify-path Don't modify shell config files (.zshrc, .bashrc, etc.)
Examples:
curl -fsSL https://opencode.ai/install | bash
curl -fsSL https://opencode.ai/install | bash -s -- --version 1.0.180
./install --binary /path/to/opencode
EOF
}
requested_version=${VERSION:-}
no_modify_path=false
binary_path=""
while [[ $# -gt 0 ]]; do
case "$1" in
@@ -45,15 +42,6 @@ while [[ $# -gt 0 ]]; do
exit 1
fi
;;
-b|--binary)
if [[ -n "${2:-}" ]]; then
binary_path="$2"
shift 2
else
echo -e "${RED}Error: --binary requires a path argument${NC}"
exit 1
fi
;;
--no-modify-path)
no_modify_path=true
shift
@@ -65,128 +53,119 @@ while [[ $# -gt 0 ]]; do
esac
done
raw_os=$(uname -s)
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
case "$raw_os" in
Darwin*) os="darwin" ;;
Linux*) os="linux" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
esac
arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
fi
if [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
if [ "$rosetta_flag" = "1" ]; then
arch="arm64"
fi
fi
combo="$os-$arch"
case "$combo" in
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
;;
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
exit 1
;;
esac
archive_ext=".zip"
if [ "$os" = "linux" ]; then
archive_ext=".tar.gz"
fi
is_musl=false
if [ "$os" = "linux" ]; then
if [ -f /etc/alpine-release ]; then
is_musl=true
fi
if command -v ldd >/dev/null 2>&1; then
if ldd --version 2>&1 | grep -qi musl; then
is_musl=true
fi
fi
fi
needs_baseline=false
if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true
fi
fi
if [ "$os" = "darwin" ]; then
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
if [ "$avx2" != "1" ]; then
needs_baseline=true
fi
fi
fi
target="$os-$arch"
if [ "$needs_baseline" = "true" ]; then
target="$target-baseline"
fi
if [ "$is_musl" = "true" ]; then
target="$target-musl"
fi
filename="$APP-$target$archive_ext"
if [ "$os" = "linux" ]; then
if ! command -v tar >/dev/null 2>&1; then
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
exit 1
fi
else
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
fi
fi
INSTALL_DIR=$HOME/.opencode/bin
mkdir -p "$INSTALL_DIR"
# If --binary is provided, skip all download/detection logic
if [ -n "$binary_path" ]; then
if [ ! -f "$binary_path" ]; then
echo -e "${RED}Error: Binary not found at ${binary_path}${NC}"
if [ -z "$requested_version" ]; then
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
exit 1
fi
specific_version="local"
else
raw_os=$(uname -s)
os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
case "$raw_os" in
Darwin*) os="darwin" ;;
Linux*) os="linux" ;;
MINGW*|MSYS*|CYGWIN*) os="windows" ;;
esac
arch=$(uname -m)
if [[ "$arch" == "aarch64" ]]; then
arch="arm64"
fi
if [[ "$arch" == "x86_64" ]]; then
arch="x64"
fi
if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
if [ "$rosetta_flag" = "1" ]; then
arch="arm64"
fi
fi
combo="$os-$arch"
case "$combo" in
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
;;
*)
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version
# Verify the release exists before downloading
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
exit 1
;;
esac
archive_ext=".zip"
if [ "$os" = "linux" ]; then
archive_ext=".tar.gz"
fi
is_musl=false
if [ "$os" = "linux" ]; then
if [ -f /etc/alpine-release ]; then
is_musl=true
fi
if command -v ldd >/dev/null 2>&1; then
if ldd --version 2>&1 | grep -qi musl; then
is_musl=true
fi
fi
fi
needs_baseline=false
if [ "$arch" = "x64" ]; then
if [ "$os" = "linux" ]; then
if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
needs_baseline=true
fi
fi
if [ "$os" = "darwin" ]; then
avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
if [ "$avx2" != "1" ]; then
needs_baseline=true
fi
fi
fi
target="$os-$arch"
if [ "$needs_baseline" = "true" ]; then
target="$target-baseline"
fi
if [ "$is_musl" = "true" ]; then
target="$target-musl"
fi
filename="$APP-$target$archive_ext"
if [ "$os" = "linux" ]; then
if ! command -v tar >/dev/null 2>&1; then
echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
exit 1
fi
else
if ! command -v unzip >/dev/null 2>&1; then
echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
exit 1
fi
fi
if [ -z "$requested_version" ]; then
url="https://github.com/anomalyco/opencode/releases/latest/download/$filename"
specific_version=$(curl -s https://api.github.com/repos/anomalyco/opencode/releases/latest | sed -n 's/.*"tag_name": *"v\([^"]*\)".*/\1/p')
if [[ $? -ne 0 || -z "$specific_version" ]]; then
echo -e "${RED}Failed to fetch version information${NC}"
exit 1
fi
else
# Strip leading 'v' if present
requested_version="${requested_version#v}"
url="https://github.com/anomalyco/opencode/releases/download/v${requested_version}/$filename"
specific_version=$requested_version
# Verify the release exists before downloading
http_status=$(curl -sI -o /dev/null -w "%{http_code}" "https://github.com/anomalyco/opencode/releases/tag/v${requested_version}")
if [ "$http_status" = "404" ]; then
echo -e "${RED}Error: Release v${requested_version} not found${NC}"
echo -e "${MUTED}Available releases: https://github.com/anomalyco/opencode/releases${NC}"
exit 1
fi
fi
fi
@@ -208,8 +187,11 @@ check_version() {
if command -v opencode >/dev/null 2>&1; then
opencode_path=$(which opencode)
## Check the installed version
installed_version=$(opencode --version 2>/dev/null || echo "")
## TODO: check if version is installed
# installed_version=$(opencode version)
installed_version="0.0.1"
installed_version=$(echo $installed_version | awk '{print $2}')
if [[ "$installed_version" != "$specific_version" ]]; then
print_message info "${MUTED}Installed version: ${NC}$installed_version."
@@ -285,11 +267,11 @@ download_with_progress() {
{
local length=0
local bytes=0
while IFS=" " read -r -a line; do
[ "${#line[@]}" -lt 2 ] && continue
local tag="${line[0]} ${line[1]}"
if [ "$tag" = "0000: content-length:" ]; then
length="${line[2]}"
length=$(echo "$length" | tr -d '\r')
@@ -314,7 +296,7 @@ download_and_install() {
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
mkdir -p "$tmp_dir"
if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
# Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
curl -# -L -o "$tmp_dir/$filename" "$url"
@@ -325,24 +307,14 @@ download_and_install() {
else
unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
fi
mv "$tmp_dir/opencode" "$INSTALL_DIR"
chmod 755 "${INSTALL_DIR}/opencode"
rm -rf "$tmp_dir"
}
install_from_binary() {
print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}from: ${NC}$binary_path"
cp "$binary_path" "${INSTALL_DIR}/opencode"
chmod 755 "${INSTALL_DIR}/opencode"
}
if [ -n "$binary_path" ]; then
install_from_binary
else
check_version
download_and_install
fi
check_version
download_and_install
add_to_path() {
@@ -369,7 +341,7 @@ case $current_shell in
config_files="$HOME/.config/fish/config.fish"
;;
zsh)
config_files="${ZDOTDIR:-$HOME}/.zshrc ${ZDOTDIR:-$HOME}/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
config_files="$HOME/.zshrc $HOME/.zshenv $XDG_CONFIG_HOME/zsh/.zshrc $XDG_CONFIG_HOME/zsh/.zshenv"
;;
bash)
config_files="$HOME/.bashrc $HOME/.bash_profile $HOME/.profile $XDG_CONFIG_HOME/bash/.bashrc $XDG_CONFIG_HOME/bash/.bash_profile"
@@ -444,3 +416,4 @@ echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
echo -e ""
echo -e ""
-145
View File
@@ -1,145 +0,0 @@
{
lib,
stdenv,
rustPlatform,
bun,
pkg-config,
dbus ? null,
openssl,
glib ? null,
gtk3 ? null,
libsoup_3 ? null,
webkitgtk_4_1 ? null,
librsvg ? null,
libappindicator-gtk3 ? null,
cargo,
rustc,
makeBinaryWrapper,
nodejs,
jq,
}:
args:
let
scripts = args.scripts;
mkModules =
attrs:
args.mkNodeModules (
attrs
// {
canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
}
);
in
rustPlatform.buildRustPackage rec {
pname = "opencode-desktop";
version = args.version;
src = args.src;
# We need to set the root for cargo, but we also need access to the whole repo.
postUnpack = ''
# Update sourceRoot to point to the tauri app
sourceRoot+=/packages/desktop/src-tauri
'';
cargoLock = {
lockFile = ../packages/desktop/src-tauri/Cargo.lock;
allowBuiltinFetchGit = true;
};
node_modules = mkModules {
version = version;
src = src;
};
nativeBuildInputs = [
pkg-config
bun
makeBinaryWrapper
cargo
rustc
nodejs
jq
];
buildInputs = [
openssl
]
++ lib.optionals stdenv.isLinux [
dbus
glib
gtk3
libsoup_3
webkitgtk_4_1
librsvg
libappindicator-gtk3
];
preBuild = ''
# Restore node_modules
pushd ../../..
# Copy node_modules from the fixed-output derivation
# We use cp -r --no-preserve=mode to ensure we can write to them if needed,
# though we usually just read.
cp -r ${node_modules}/node_modules .
cp -r ${node_modules}/packages .
# Ensure node_modules is writable so patchShebangs can update script headers
chmod -R u+w node_modules
# Ensure workspace packages are writable for tsgo incremental outputs (.tsbuildinfo)
chmod -R u+w packages
# Patch shebangs so scripts can run
patchShebangs node_modules
# Copy sidecar
mkdir -p packages/desktop/src-tauri/sidecars
targetTriple=${stdenv.hostPlatform.rust.rustcTarget}
cp ${args.opencode}/bin/opencode packages/desktop/src-tauri/sidecars/opencode-cli-$targetTriple
# Merge prod config into tauri.conf.json
if ! jq -s '.[0] * .[1]' \
packages/desktop/src-tauri/tauri.conf.json \
packages/desktop/src-tauri/tauri.prod.conf.json \
> packages/desktop/src-tauri/tauri.conf.json.tmp; then
echo "Error: failed to merge tauri.conf.json with tauri.prod.conf.json" >&2
exit 1
fi
mv packages/desktop/src-tauri/tauri.conf.json.tmp packages/desktop/src-tauri/tauri.conf.json
# Build the frontend
cd packages/desktop
# The 'build' script runs 'bun run typecheck && vite build'.
bun run build
popd
'';
# Tauri bundles the assets during the rust build phase (which happens after preBuild).
# It looks for them in the location specified in tauri.conf.json.
postInstall = lib.optionalString stdenv.isLinux ''
# Wrap the binary to ensure it finds the libraries
wrapProgram $out/bin/opencode-desktop \
--prefix LD_LIBRARY_PATH : ${
lib.makeLibraryPath [
gtk3
webkitgtk_4_1
librsvg
glib
libsoup_3
]
}
'';
meta = with lib; {
description = "OpenCode Desktop App";
homepage = "https://opencode.ai";
license = licenses.mit;
maintainers = with maintainers; [ ];
mainProgram = "opencode-desktop";
platforms = platforms.linux ++ platforms.darwin;
};
}
+1 -6
View File
@@ -1,8 +1,3 @@
{
"nodeModules": {
"x86_64-linux": "sha256-qjXrRkNAJsarbUBMiEL18lGkr65w74YvCsFVjrSCQHI=",
"aarch64-linux": "sha256-E6lyYFApS1cw3jE7ISx5QZxDDJ9V3HU0ICYFdY+aIBw=",
"aarch64-darwin": "sha256-7UajHu40n7JKqurU/+CGlitErsVFA2qDneUytI8+/zQ=",
"x86_64-darwin": "sha256-LxBsYdq5AzInQJzF89taXvS2vigew5C5hjaIEH8rTb8="
}
"nodeModules": "sha256-uJDhOieOdMQLORyuOWtgtjLoMnNEQPrDcyij9TX0aTw="
}
+2 -4
View File
@@ -5,8 +5,6 @@
bun,
cacert,
curl,
bunCpu,
bunOs,
}:
args:
stdenvNoCC.mkDerivation {
@@ -31,8 +29,8 @@ stdenvNoCC.mkDerivation {
export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--cpu="${bunCpu}" \
--os="${bunOs}" \
--cpu="*" \
--os="*" \
--frozen-lockfile \
--ignore-scripts \
--no-progress \
+1 -6
View File
@@ -60,12 +60,7 @@ const result = await Bun.build({
compile: {
target,
outfile: "opencode",
autoloadBunfig: false,
autoloadDotenv: false,
//@ts-ignore (bun types aren't up to date)
autoloadTsconfig: true,
autoloadPackageJson: true,
execArgv: ["--user-agent=opencode/" + version, "--use-system-ca", "--"],
execArgv: ["--user-agent=opencode/" + version, '--env-file=""', "--"],
windows: {},
},
})
+3 -10
View File
@@ -10,7 +10,7 @@ HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE}
if [ ! -f "$HASH_FILE" ]; then
cat >"$HASH_FILE" <<EOF
{
"nodeModules": {}
"nodeModules": "$DUMMY"
}
EOF
fi
@@ -33,16 +33,9 @@ trap cleanup EXIT
write_node_modules_hash() {
local value="$1"
local system="${2:-$SYSTEM}"
local temp
temp=$(mktemp)
if jq -e '.nodeModules | type == "object"' "$HASH_FILE" >/dev/null 2>&1; then
jq --arg system "$system" --arg value "$value" '.nodeModules[$system] = $value' "$HASH_FILE" >"$temp"
else
jq --arg system "$system" --arg value "$value" '.nodeModules = {($system): $value}' "$HASH_FILE" >"$temp"
fi
jq --arg value "$value" '.nodeModules = $value' "$HASH_FILE" >"$temp"
mv "$temp" "$HASH_FILE"
}
@@ -111,7 +104,7 @@ fi
write_node_modules_hash "$CORRECT_HASH"
jq -e --arg system "$SYSTEM" --arg hash "$CORRECT_HASH" '.nodeModules[$system] == $hash' "$HASH_FILE" >/dev/null
jq -e --arg hash "$CORRECT_HASH" '.nodeModules == $hash' "$HASH_FILE" >/dev/null
echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH"
+2 -3
View File
@@ -21,7 +21,7 @@
"packages/slack"
],
"catalog": {
"@types/bun": "1.3.5",
"@types/bun": "1.3.4",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
"ulid": "3.0.1",
@@ -36,8 +36,7 @@
"@solid-primitives/storage": "4.3.3",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"dompurify": "3.3.1",
"ai": "5.0.119",
"ai": "5.0.97",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"fuzzysort": "3.1.0",
+1 -1
View File
@@ -1,6 +1,6 @@
## Debugging
- To test the opencode app, use the playwright MCP server, the app is already
- To test the opencode app, use the playwrite mcp server, the app is already
running at http://localhost:3000
- NEVER try to restart the app, or the server process, EVER.
+2 -2
View File
@@ -1,8 +1,8 @@
## Usage
Dependencies for these templates are managed with [pnpm](https://pnpm.io) using `pnpm up -Lri`.
Those templates dependencies are maintained via [pnpm](https://pnpm.io) via `pnpm up -Lri`.
This is the reason you see a `pnpm-lock.yaml`. That said, any package manager will work. This file can safely be removed once you clone a template.
This is the reason you see a `pnpm-lock.yaml`. That being said, any package manager will work. This file can be safely be removed once you clone a template.
```bash
$ npm install # or pnpm install or yarn install
+32 -2
View File
@@ -13,11 +13,41 @@
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
<script id="oc-theme-preload-script" src="/oc-theme-preload.js"></script>
<!-- Theme preload script - applies cached theme to avoid FOUC -->
<script id="oc-theme-preload-script">
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
</script>
</head>
<body class="antialiased overscroll-none text-12-regular overflow-hidden">
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root" class="flex flex-col h-dvh p-px"></div>
<div id="root" class="flex flex-col h-screen"></div>
<script src="/src/entry.tsx" type="module"></script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.23",
"version": "1.0.224",
"description": "",
"type": "module",
"exports": {
-28
View File
@@ -1,28 +0,0 @@
;(function () {
var themeId = localStorage.getItem("opencode-theme-id")
if (!themeId) return
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
var mode = isDark ? "dark" : "light"
document.documentElement.dataset.theme = themeId
document.documentElement.dataset.colorScheme = mode
if (themeId === "oc-1") return
var css = localStorage.getItem("opencode-theme-css-" + themeId + "-" + mode)
if (css) {
var style = document.createElement("style")
style.id = "oc-theme-preload"
style.textContent =
":root{color-scheme:" +
mode +
";--text-mix-blend-mode:" +
(isDark ? "plus-lighter" : "multiply") +
";" +
css +
"}"
document.head.appendChild(style)
}
})()
-47
View File
@@ -242,53 +242,6 @@ describe("SerializeAddon", () => {
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
})
test("serialized output should restore after Terminal.reset()", async () => {
const { term, addon } = createTerminal()
const content = [
"\x1b[1;32m\x1b[0m \x1b[34mcd\x1b[0m /some/path",
"\x1b[1;32m\x1b[0m \x1b[34mls\x1b[0m -la",
"total 42",
].join("\r\n")
await writeAndWait(term, content)
const serialized = addon.serialize()
const { term: term2 } = createTerminal()
terminals.push(term2)
term2.reset()
await writeAndWait(term2, serialized)
expect(term2.buffer.active.getLine(0)?.translateToString(true)).toContain("cd /some/path")
expect(term2.buffer.active.getLine(1)?.translateToString(true)).toContain("ls -la")
expect(term2.buffer.active.getLine(2)?.translateToString(true)).toBe("total 42")
})
test("alternate buffer should round-trip without garbage", async () => {
const { term, addon } = createTerminal(20, 5)
await writeAndWait(term, "normal\r\n")
await writeAndWait(term, "\x1b[?1049h\x1b[HALT")
expect(term.buffer.active.type).toBe("alternate")
const serialized = addon.serialize()
const { term: term2 } = createTerminal(20, 5)
terminals.push(term2)
await writeAndWait(term2, serialized)
expect(term2.buffer.active.type).toBe("alternate")
const line = term2.buffer.active.getLine(0)
expect(line?.translateToString(true)).toBe("ALT")
// Ensure a cell beyond content isn't garbage
const cellCode = line?.getCell(10)?.getCode()
expect(cellCode === 0 || cellCode === 32).toBe(true)
})
test("serialized output written to new terminal should match original colors", async () => {
const { term, addon } = createTerminal(40, 5)
+23 -19
View File
@@ -157,6 +157,23 @@ function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
abstract class BaseSerializeHandler {
constructor(protected readonly _buffer: IBuffer) {}
private _isRealContent(codepoint: number): boolean {
if (codepoint === 0) return false
if (codepoint >= 0xf000) return false
return true
}
private _findLastContentColumn(line: IBufferLine): number {
let lastContent = -1
for (let col = 0; col < line.length; col++) {
const cell = line.getCell(col)
if (cell && this._isRealContent(cell.getCode())) {
lastContent = col
}
}
return lastContent + 1
}
public serialize(range: IBufferRange, excludeFinalCursorPosition?: boolean): string {
let oldCell = this._buffer.getNullCell()
@@ -165,14 +182,14 @@ abstract class BaseSerializeHandler {
const startColumn = range.start.x
const endColumn = range.end.x
this._beforeSerialize(endRow - startRow + 1, startRow, endRow)
this._beforeSerialize(endRow - startRow, startRow, endRow)
for (let row = startRow; row <= endRow; row++) {
const line = this._buffer.getLine(row)
if (line) {
const startLineColumn = row === range.start.y ? startColumn : 0
const endLineColumn = Math.min(endColumn, line.length)
const maxColumn = row === range.end.y ? endColumn : this._findLastContentColumn(line)
const endLineColumn = Math.min(maxColumn, line.length)
for (let col = startLineColumn; col < endLineColumn; col++) {
const c = line.getCell(col)
if (!c) {
@@ -226,13 +243,6 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _beforeSerialize(rows: number, start: number, _end: number): void {
this._allRows = new Array<string>(rows)
this._allRowSeparators = new Array<string>(rows)
this._rowIndex = 0
this._currentRow = ""
this._nullCellCount = 0
this._cursorStyle = this._buffer.getNullCell()
this._lastContentCursorRow = start
this._lastCursorRow = start
this._firstRow = start
@@ -241,11 +251,6 @@ class StringSerializeHandler extends BaseSerializeHandler {
protected _rowEnd(row: number, isLastRow: boolean): void {
let rowSeparator = ""
if (this._nullCellCount > 0) {
this._currentRow += " ".repeat(this._nullCellCount)
this._nullCellCount = 0
}
if (!isLastRow) {
const nextLine = this._buffer.getLine(row + 1)
@@ -383,8 +388,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
}
const codepoint = cell.getCode()
const isInvalidCodepoint = codepoint > 0x10ffff || (codepoint >= 0xd800 && codepoint <= 0xdfff)
const isGarbage = isInvalidCodepoint || (codepoint >= 0xf000 && cell.getWidth() === 1)
const isGarbage = codepoint >= 0xf000
const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
@@ -393,7 +397,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
if (styleChanged) {
if (this._nullCellCount > 0) {
this._currentRow += " ".repeat(this._nullCellCount)
this._currentRow += `\u001b[${this._nullCellCount}C`
this._nullCellCount = 0
}
@@ -413,7 +417,7 @@ class StringSerializeHandler extends BaseSerializeHandler {
this._nullCellCount += cell.getWidth()
} else {
if (this._nullCellCount > 0) {
this._currentRow += " ".repeat(this._nullCellCount)
this._currentRow += `\u001b[${this._nullCellCount}C`
this._nullCellCount = 0
}
+71 -83
View File
@@ -1,5 +1,5 @@
import "@/index.css"
import { ErrorBoundary, Show, lazy, type ParentProps } from "solid-js"
import { ErrorBoundary, Show, type ParentProps } from "solid-js"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
@@ -20,41 +20,30 @@ import { FileProvider } from "@/context/file"
import { NotificationProvider } from "@/context/notification"
import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { CommandProvider } from "@/context/command"
import { Logo } from "@opencode-ai/ui/logo"
import Layout from "@/pages/layout"
import Home from "@/pages/home"
import DirectoryLayout from "@/pages/directory-layout"
import Session from "@/pages/session"
import { ErrorPage } from "./pages/error"
import { iife } from "@opencode-ai/util/iife"
import { Suspense } from "solid-js"
const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () => <div class="size-full flex items-center justify-center text-text-weak">Loading...</div>
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string }
__OPENCODE__?: { updaterEnabled?: boolean; port?: number }
}
}
export function AppBaseProviders(props: ParentProps) {
return (
<MetaProvider>
<Font />
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>{props.children}</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
)
}
const defaultServerUrl = iife(() => {
const param = new URLSearchParams(document.location.search).get("url")
if (param) return param
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return window.location.origin
})
function ServerKey(props: ParentProps) {
const server = useServer()
@@ -65,63 +54,62 @@ function ServerKey(props: ParentProps) {
)
}
export function AppInterface(props: { defaultUrl?: string }) {
const defaultServerUrl = () => {
if (props.defaultUrl) return props.defaultUrl
if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
if (import.meta.env.DEV)
return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
return window.location.origin
}
export function App() {
return (
<ServerProvider defaultUrl={defaultServerUrl()}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(props) => (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)}
>
<Route
path="/"
component={() => (
<Suspense fallback={<Loading />}>
<Home />
</Suspense>
)}
/>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={() => (
<TerminalProvider>
<FileProvider>
<PromptProvider>
<Suspense fallback={<Loading />}>
<Session />
</Suspense>
</PromptProvider>
</FileProvider>
</TerminalProvider>
)}
/>
</Route>
</Router>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ServerProvider>
<MetaProvider>
<Font />
<ThemeProvider>
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
<DialogProvider>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<ServerProvider defaultUrl={defaultServerUrl}>
<ServerKey>
<GlobalSDKProvider>
<GlobalSyncProvider>
<Router
root={(props) => (
<PermissionProvider>
<LayoutProvider>
<NotificationProvider>
<CommandProvider>
<Layout>{props.children}</Layout>
</CommandProvider>
</NotificationProvider>
</LayoutProvider>
</PermissionProvider>
)}
>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id ?? "new"} keyed>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<Session />
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)}
/>
</Route>
</Router>
</GlobalSyncProvider>
</GlobalSDKProvider>
</ServerKey>
</ServerProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</DialogProvider>
</ErrorBoundary>
</ThemeProvider>
</MetaProvider>
)
}
@@ -7,11 +7,15 @@ import { createMemo, createSignal, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobalSDK } from "@/context/global-sdk"
import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
function getFilename(input: string) {
const parts = input.split("/")
return parts[parts.length - 1] || input
}
export function DialogEditProject(props: { project: LocalProject }) {
const dialog = useDialog()
const globalSDK = useGlobalSDK()
@@ -1,99 +0,0 @@
import { Component, createMemo } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useSync } from "@/context/sync"
import { useSDK } from "@/context/sdk"
import { usePrompt } from "@/context/prompt"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { extractPromptFromParts } from "@/utils/prompt"
import type { TextPart as SDKTextPart } from "@opencode-ai/sdk/v2/client"
import { base64Encode } from "@opencode-ai/util/encode"
interface ForkableMessage {
id: string
text: string
time: string
}
function formatTime(date: Date): string {
return date.toLocaleTimeString(undefined, { timeStyle: "short" })
}
export const DialogFork: Component = () => {
const params = useParams()
const navigate = useNavigate()
const sync = useSync()
const sdk = useSDK()
const prompt = usePrompt()
const dialog = useDialog()
const messages = createMemo((): ForkableMessage[] => {
const sessionID = params.id
if (!sessionID) return []
const msgs = sync.data.message[sessionID] ?? []
const result: ForkableMessage[] = []
for (const message of msgs) {
if (message.role !== "user") continue
const parts = sync.data.part[message.id] ?? []
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
if (!textPart) continue
result.push({
id: message.id,
text: textPart.text.replace(/\n/g, " ").slice(0, 200),
time: formatTime(new Date(message.time.created)),
})
}
return result.reverse()
})
const handleSelect = (item: ForkableMessage | undefined) => {
if (!item) return
const sessionID = params.id
if (!sessionID) return
const parts = sync.data.part[item.id] ?? []
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
dialog.close()
sdk.client.session.fork({ sessionID, messageID: item.id }).then((forked) => {
if (!forked.data) return
navigate(`/${base64Encode(sdk.directory)}/session/${forked.data.id}`)
requestAnimationFrame(() => {
prompt.set(restored)
})
})
}
return (
<Dialog title="Fork from message">
<List
class="flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
search={{ placeholder: "Search", autofocus: true }}
emptyMessage="No messages to fork from"
key={(x) => x.id}
items={messages}
filterKeys={["text"]}
onSelect={handleSelect}
>
{(item) => (
<div class="w-full flex items-center gap-2">
<span class="truncate flex-1 min-w-0 text-left" style={{ "font-weight": "400" }}>
{item.text}
</span>
<span class="text-text-weak shrink-0" style={{ "font-weight": "400" }}>
{item.time}
</span>
</div>
)}
</List>
</Dialog>
)
}
@@ -4,175 +4,45 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
import { List } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useParams } from "@solidjs/router"
import { createMemo, createSignal, onCleanup, Show } from "solid-js"
import { formatKeybind, useCommand, type CommandOption } from "@/context/command"
import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout"
import { useFile } from "@/context/file"
type EntryType = "command" | "file"
type Entry = {
id: string
type: EntryType
title: string
description?: string
keybind?: string
category: "Commands" | "Files"
option?: CommandOption
path?: string
}
export function DialogSelectFile() {
const command = useCommand()
const layout = useLayout()
const file = useFile()
const dialog = useDialog()
const params = useParams()
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 = ["session.new", "session.previous", "session.next", "terminal.toggle", "review.toggle"]
const limit = 5
const allowed = createMemo(() =>
command.options.filter(
(option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open",
),
)
const commandItem = (option: CommandOption): Entry => ({
id: "command:" + option.id,
type: "command",
title: option.title,
description: option.description,
keybind: option.keybind,
category: "Commands",
option,
})
const fileItem = (path: string): Entry => ({
id: "file:" + path,
type: "file",
title: path,
category: "Files",
path,
})
const list = createMemo(() => allowed().map(commandItem))
const picks = createMemo(() => {
const all = allowed()
const order = new Map(common.map((id, index) => [id, index]))
const picked = all.filter((option) => order.has(option.id))
const base = picked.length ? picked : all.slice(0, limit)
const sorted = picked.length ? [...base].sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0)) : base
return sorted.map(commandItem)
})
const recent = createMemo(() => {
const all = tabs().all()
const active = tabs().active()
const order = active ? [active, ...all.filter((item) => item !== active)] : all
const seen = new Set<string>()
const items: Entry[] = []
for (const item of order) {
const path = file.pathFromTab(item)
if (!path) continue
if (seen.has(path)) continue
seen.add(path)
items.push(fileItem(path))
}
return items.slice(0, limit)
})
const items = async (filter: string) => {
const query = filter.trim()
setGrouped(query.length > 0)
if (!query) return [...picks(), ...recent()]
const files = await file.searchFiles(query)
const entries = files.map(fileItem)
return [...list(), ...entries]
}
const handleMove = (item: Entry | undefined) => {
state.cleanup?.()
if (!item) return
if (item.type !== "command") return
state.cleanup = item.option?.onHighlight?.()
}
const open = (path: string) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
view().reviewPanel.open()
}
const handleSelect = (item: Entry | undefined) => {
if (!item) return
state.committed = true
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
item.option?.onSelect?.("palette")
return
}
if (!item.path) return
open(item.path)
}
onCleanup(() => {
if (state.committed) return
state.cleanup?.()
})
return (
<Dialog title="Search">
<Dialog title="Select file">
<List
search={{ placeholder: "Search files and commands", autofocus: true }}
emptyMessage="No results found"
items={items}
key={(item) => item.id}
filterKeys={["title", "description", "category"]}
groupBy={(item) => (grouped() ? item.category : "")}
onMove={handleMove}
onSelect={handleSelect}
search={{ placeholder: "Search files", autofocus: true }}
emptyMessage="No files found"
items={file.searchFiles}
key={(x) => x}
onSelect={(path) => {
if (path) {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}
dialog.close()
}}
>
{(item) => (
<Show
when={item.type === "command"}
fallback={
<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.path ?? "", type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(item.path ?? "")}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(item.path ?? "")}</span>
</div>
</div>
{(i) => (
<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: i, type: "file" }} class="shrink-0 size-4" />
<div class="flex items-center text-14-regular">
<span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
{getDirectory(i)}
</span>
<span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
</div>
}
>
<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}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(item.keybind ?? "")}</span>
</Show>
</div>
</Show>
</div>
)}
</List>
</Dialog>
@@ -76,7 +76,7 @@ export const ModelSelectorPopover: Component<{
<Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
<Kobalte.Trigger as="div">{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">
<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">
<Kobalte.Title class="sr-only">Select model</Kobalte.Title>
<ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
</Kobalte.Content>
@@ -1,11 +1,10 @@
import { createResource, createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createEffect, createMemo, onCleanup } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { Button } from "@opencode-ai/ui/button"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
import { usePlatform } from "@/context/platform"
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
@@ -36,8 +35,6 @@ export function DialogSelectServer() {
error: "",
status: {} as Record<string, ServerStatus | undefined>,
})
const [defaultUrl, defaultUrlActions] = createResource(() => platform.getDefaultServerUrl?.())
const isDesktop = platform.platform === "desktop"
const items = createMemo(() => {
const current = server.url
@@ -117,10 +114,6 @@ export function DialogSelectServer() {
select(value, true)
}
async function handleRemove(url: string) {
server.remove(url)
}
return (
<Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
<div class="flex flex-col gap-4 pb-4">
@@ -135,33 +128,20 @@ export function DialogSelectServer() {
}}
>
{(i) => (
<div class="flex items-center gap-2 min-w-0 flex-1 group/item">
<div
class="flex items-center gap-2 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
class="flex items-center gap-2 min-w-0 flex-1"
classList={{ "opacity-50": store.status[i]?.healthy === false }}
>
<div
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span class="truncate">{serverDisplayName(i)}</span>
<span class="text-text-weak">{store.status[i]?.version}</span>
</div>
<Show when={current() !== i && server.list.includes(i)}>
<IconButton
icon="circle-x"
variant="ghost"
class="bg-transparent transition-opacity shrink-0 hover:scale-110"
onClick={(e) => {
e.stopPropagation()
handleRemove(i)
}}
/>
</Show>
classList={{
"size-1.5 rounded-full shrink-0": true,
"bg-icon-success-base": store.status[i]?.healthy === true,
"bg-icon-critical-base": store.status[i]?.healthy === false,
"bg-border-weak-base": store.status[i] === undefined,
}}
/>
<span class="truncate">{serverDisplayName(i)}</span>
<span class="text-text-weak">{store.status[i]?.version}</span>
</div>
)}
</List>
@@ -193,53 +173,6 @@ export function DialogSelectServer() {
</div>
</form>
</div>
<Show when={isDesktop}>
<div class="mt-6 px-3 flex flex-col gap-1.5">
<div class="px-3">
<h3 class="text-14-regular text-text-weak">Default server</h3>
<p class="text-12-regular text-text-weak mt-1">
Connect to this server on app launch instead of starting a local server. Requires restart.
</p>
</div>
<div class="flex items-center gap-2 px-3 py-2">
<Show
when={defaultUrl()}
fallback={
<Show
when={server.url}
fallback={<span class="text-14-regular text-text-weak">No server selected</span>}
>
<Button
variant="secondary"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(server.url)
defaultUrlActions.refetch(server.url)
}}
>
Set current server as default
</Button>
</Show>
}
>
<div class="flex items-center gap-2 flex-1 min-w-0">
<span class="truncate text-14-regular">{serverDisplayName(defaultUrl()!)}</span>
</div>
<Button
variant="ghost"
size="small"
onClick={async () => {
await platform.setDefaultServerUrl?.(null)
defaultUrlActions.refetch()
}}
>
Clear
</Button>
</Show>
</div>
</div>
</Show>
</div>
</Dialog>
)
+421 -394
View File
@@ -1,17 +1,5 @@
import { useFilteredList } from "@opencode-ai/ui/hooks"
import {
createEffect,
on,
Component,
Show,
For,
onMount,
onCleanup,
Switch,
Match,
createMemo,
createSignal,
} from "solid-js"
import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createFocusSignal } from "@solid-primitives/active-element"
import { useLocal } from "@/context/local"
@@ -24,7 +12,6 @@ import {
usePrompt,
ImageAttachmentPart,
AgentPart,
FileAttachmentPart,
} from "@/context/prompt"
import { useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk"
@@ -33,28 +20,19 @@ import { useSync } from "@/context/sync"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import type { IconName } from "@opencode-ai/ui/icons/provider"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { ModelSelectorPopover } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { useProviders } from "@/hooks/use-providers"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { persisted } from "@/utils/persist"
import { Identifier } from "@/utils/id"
import { SessionContextUsage } from "@/components/session-context-usage"
import { usePermission } from "@/context/permission"
import { useGlobalSync } from "@/context/global-sync"
import { usePlatform } from "@/context/platform"
import { createOpencodeClient, type Message, type Part } from "@opencode-ai/sdk/v2/client"
import { Binary } from "@opencode-ai/util/binary"
import { showToast } from "@opencode-ai/ui/toast"
import { base64Encode } from "@opencode-ai/util/encode"
const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -62,8 +40,6 @@ const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
interface PromptInputProps {
class?: string
ref?: (el: HTMLDivElement) => void
newSessionWorktree?: string
onNewSessionWorktreeReset?: () => void
}
const PLACEHOLDERS = [
@@ -107,8 +83,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const globalSync = useGlobalSync()
const platform = usePlatform()
const local = useLocal()
const files = useFile()
const prompt = usePrompt()
@@ -121,7 +95,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
let editorRef!: HTMLDivElement
let fileInputRef!: HTMLInputElement
let scrollRef!: HTMLDivElement
let slashPopoverRef!: HTMLDivElement
const scrollCursorIntoView = () => {
const container = scrollRef
@@ -168,9 +141,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
},
)
const working = createMemo(() => status()?.type !== "idle")
const imageAttachments = createMemo(
() => prompt.current().filter((part) => part.type === "image") as ImageAttachmentPart[],
)
const [store, setStore] = createStore<{
popover: "at" | "slash" | null
@@ -178,21 +148,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
savedPrompt: Prompt | null
placeholder: number
dragging: boolean
imageAttachments: ImageAttachmentPart[]
mode: "normal" | "shell"
applyingHistory: boolean
killBuffer: string
}>({
popover: null,
historyIndex: -1,
savedPrompt: null,
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
dragging: false,
imageAttachments: [],
mode: "normal",
applyingHistory: false,
killBuffer: "",
})
const MAX_HISTORY = 100
const [history, setHistory] = persisted(
Persist.global("prompt-history", ["prompt-history.v1"]),
"prompt-history.v1",
createStore<{
entries: Prompt[]
}>({
@@ -200,7 +174,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}),
)
const [shellHistory, setShellHistory] = persisted(
Persist.global("prompt-history-shell", ["prompt-history-shell.v1"]),
"prompt-history-shell.v1",
createStore<{
entries: Prompt[]
}>({
@@ -251,8 +225,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
const isFocused = createFocusSignal(() => editorRef)
createEffect(() => {
params.id
editorRef.focus()
@@ -263,8 +235,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onCleanup(() => clearInterval(interval))
})
const [composing, setComposing] = createSignal(false)
const isImeComposing = (event: KeyboardEvent) => event.isComposing || composing() || event.keyCode === 229
const isFocused = createFocusSignal(() => editorRef)
const addImageAttachment = async (file: File) => {
if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
@@ -279,16 +250,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
mime: file.type,
dataUrl,
}
const cursorPosition = prompt.cursor() ?? getCursorPosition(editorRef)
prompt.set([...prompt.current(), attachment], cursorPosition)
setStore(
produce((draft) => {
draft.imageAttachments.push(attachment)
}),
)
}
reader.readAsDataURL(file)
}
const removeImageAttachment = (id: string) => {
const current = prompt.current()
const next = current.filter((part) => part.type !== "image" || part.id !== id)
prompt.set(next, prompt.cursor())
setStore(
produce((draft) => {
draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id)
}),
)
}
const handlePaste = async (event: ClipboardEvent) => {
@@ -296,13 +272,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const clipboardData = event.clipboardData
if (!clipboardData) return
event.preventDefault()
event.stopPropagation()
const items = Array.from(clipboardData.items)
const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
if (imageItems.length > 0) {
event.preventDefault()
event.stopPropagation()
for (const item of imageItems) {
const file = item.getAsFile()
if (file) await addImageAttachment(file)
@@ -310,13 +285,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
event.preventDefault()
event.stopPropagation()
const plainText = clipboardData.getData("text/plain") ?? ""
addPart({ type: "text", content: plainText, start: 0, end: 0 })
}
const handleGlobalDragOver = (event: DragEvent) => {
if (dialog.active) return
event.preventDefault()
const hasFiles = event.dataTransfer?.types.includes("Files")
if (hasFiles) {
@@ -325,8 +300,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleGlobalDragLeave = (event: DragEvent) => {
if (dialog.active) return
// relatedTarget is null when leaving the document window
if (!event.relatedTarget) {
setStore("dragging", false)
@@ -334,8 +307,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const handleGlobalDrop = async (event: DragEvent) => {
if (dialog.active) return
event.preventDefault()
setStore("dragging", false)
@@ -350,11 +321,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
onMount(() => {
editorRef.addEventListener("paste", handlePaste)
document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop)
})
onCleanup(() => {
editorRef.removeEventListener("paste", handlePaste)
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
@@ -364,12 +337,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!isFocused()) setStore("popover", null)
})
// Safety: reset composing state on focus change to prevent stuck state
// This handles edge cases where compositionend event may not fire
createEffect(() => {
if (!isFocused()) setComposing(false)
})
type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string }
const agentList = createMemo(() =>
@@ -395,7 +362,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const {
flat: atFlat,
active: atActive,
setActive: setAtActive,
onInput: atOnInput,
onKeyDown: atOnKeyDown,
} = useFilteredList<AtOption>({
@@ -462,10 +428,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const {
flat: slashFlat,
active: slashActive,
setActive: setSlashActive,
onInput: slashOnInput,
onKeyDown: slashOnKeyDown,
refetch: slashRefetch,
} = useFilteredList<SlashCommand>({
items: slashCommands,
key: (x) => x?.id,
@@ -473,79 +437,32 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSelect: handleSlashSelect,
})
const createPill = (part: FileAttachmentPart | AgentPart) => {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", part.type)
if (part.type === "file") pill.setAttribute("data-path", part.path)
if (part.type === "agent") pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
return pill
}
const isNormalizedEditor = () =>
Array.from(editorRef.childNodes).every((node) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ""
if (!text.includes("\u200B")) return true
if (text !== "\u200B") return false
const prev = node.previousSibling
const next = node.nextSibling
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
if (!prevIsBr && !nextIsBr) return false
if (nextIsBr && !prevIsBr && prev) return false
return true
}
if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement
if (el.dataset.type === "file") return true
if (el.dataset.type === "agent") return true
return el.tagName === "BR"
})
const renderEditor = (parts: Prompt) => {
editorRef.innerHTML = ""
for (const part of parts) {
if (part.type === "text") {
editorRef.appendChild(createTextFragment(part.content))
continue
}
if (part.type === "file" || part.type === "agent") {
editorRef.appendChild(createPill(part))
}
}
}
createEffect(
on(
() => sync.data.command,
() => slashRefetch(),
{ defer: true },
),
)
// Auto-scroll active command into view when navigating with keyboard
createEffect(() => {
const activeId = slashActive()
if (!activeId || !slashPopoverRef) return
requestAnimationFrame(() => {
const element = slashPopoverRef.querySelector(`[data-slash-id="${activeId}"]`)
element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
})
})
createEffect(
on(
() => prompt.current(),
(currentParts) => {
const inputParts = currentParts.filter((part) => part.type !== "image") as Prompt
const domParts = parseFromDOM()
if (isNormalizedEditor() && isPromptEqual(inputParts, domParts)) return
const normalized = Array.from(editorRef.childNodes).every((node) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent ?? ""
if (!text.includes("\u200B")) return true
if (text !== "\u200B") return false
const prev = node.previousSibling
const next = node.nextSibling
const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR"
const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR"
if (!prevIsBr && !nextIsBr) return false
if (nextIsBr && !prevIsBr && prev) return false
return true
}
if (node.nodeType !== Node.ELEMENT_NODE) return false
const el = node as HTMLElement
if (el.dataset.type === "file") return true
if (el.dataset.type === "agent") return true
return el.tagName === "BR"
})
if (normalized && isPromptEqual(currentParts, domParts)) return
const selection = window.getSelection()
let cursorPosition: number | null = null
@@ -553,7 +470,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
cursorPosition = getCursorPosition(editorRef)
}
renderEditor(inputParts)
editorRef.innerHTML = ""
currentParts.forEach((part) => {
if (part.type === "text") {
editorRef.appendChild(createTextFragment(part.content))
} else if (part.type === "file") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "file")
pill.setAttribute("data-path", part.path)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
editorRef.appendChild(pill)
} else if (part.type === "agent") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "agent")
pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
editorRef.appendChild(pill)
}
})
if (cursorPosition !== null) {
setCursorPosition(editorRef, cursorPosition)
@@ -644,12 +584,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleInput = () => {
const rawParts = parseFromDOM()
const images = imageAttachments()
const cursorPosition = getCursorPosition(editorRef)
const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
const trimmed = rawText.replace(/\u200B/g, "").trim()
const hasNonText = rawParts.some((part) => part.type !== "text")
const shouldReset = trimmed.length === 0 && !hasNonText && images.length === 0
const shouldReset = trimmed.length === 0 && !hasNonText
if (shouldReset) {
setStore("popover", null)
@@ -688,7 +627,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("savedPrompt", null)
}
prompt.set([...rawParts, ...images], cursorPosition)
prompt.set(rawParts, cursorPosition)
queueScroll()
}
@@ -732,8 +671,40 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const textBeforeCursor = rawText.substring(0, cursorPosition)
const atMatch = textBeforeCursor.match(/@(\S*)$/)
if (part.type === "file" || part.type === "agent") {
const pill = createPill(part)
if (part.type === "file") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "file")
pill.setAttribute("data-path", part.path)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
if (atMatch) {
const start = atMatch.index ?? cursorPosition - atMatch[0].length
setRangeEdge(range, "start", start)
setRangeEdge(range, "end", cursorPosition)
}
range.deleteContents()
range.insertNode(gap)
range.insertNode(pill)
range.setStartAfter(gap)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
} else if (part.type === "agent") {
const pill = document.createElement("span")
pill.textContent = part.content
pill.setAttribute("data-type", "agent")
pill.setAttribute("data-name", part.name)
pill.setAttribute("contenteditable", "false")
pill.style.userSelect = "text"
pill.style.cursor = "default"
const gap = document.createTextNode(" ")
const range = selection.getRangeAt(0)
@@ -779,6 +750,77 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
setStore("popover", null)
}
const setSelectionOffsets = (start: number, end: number) => {
const selection = window.getSelection()
if (!selection) return false
const length = promptLength(prompt.current())
const a = Math.max(0, Math.min(start, length))
const b = Math.max(0, Math.min(end, length))
const rangeStart = Math.min(a, b)
const rangeEnd = Math.max(a, b)
const range = document.createRange()
range.selectNodeContents(editorRef)
const setEdge = (edge: "start" | "end", offset: number) => {
let remaining = offset
const nodes = Array.from(editorRef.childNodes)
for (const node of nodes) {
const length = getNodeLength(node)
const isText = node.nodeType === Node.TEXT_NODE
const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
if (isText && remaining <= length) {
if (edge === "start") range.setStart(node, remaining)
if (edge === "end") range.setEnd(node, remaining)
return
}
if ((isFile || isBreak) && remaining <= length) {
if (edge === "start" && remaining === 0) range.setStartBefore(node)
if (edge === "start" && remaining > 0) range.setStartAfter(node)
if (edge === "end" && remaining === 0) range.setEndBefore(node)
if (edge === "end" && remaining > 0) range.setEndAfter(node)
return
}
remaining -= length
}
const last = editorRef.lastChild
if (!last) {
if (edge === "start") range.setStart(editorRef, 0)
if (edge === "end") range.setEnd(editorRef, 0)
return
}
if (edge === "start") range.setStartAfter(last)
if (edge === "end") range.setEndAfter(last)
}
setEdge("start", rangeStart)
setEdge("end", rangeEnd)
selection.removeAllRanges()
selection.addRange(range)
return true
}
const replaceOffsets = (start: number, end: number, content: string) => {
if (!setSelectionOffsets(start, end)) return false
addPart({ type: "text", content, start: 0, end: 0 })
return true
}
const killText = (start: number, end: number) => {
if (start === end) return
const current = prompt.current()
if (!current.every((part) => part.type === "text")) return
const text = current.map((part) => part.content).join("")
setStore("killBuffer", text.slice(start, end))
}
const abort = () =>
sdk.client.session
.abort({
@@ -791,14 +833,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
.map((p) => ("content" in p ? p.content : ""))
.join("")
.trim()
const hasImages = prompt.some((part) => part.type === "image")
if (!text && !hasImages) return
if (!text) return
const entry = clonePromptParts(prompt)
const currentHistory = mode === "shell" ? shellHistory : history
const setCurrentHistory = mode === "shell" ? setShellHistory : setHistory
const lastEntry = currentHistory.entries[0]
if (lastEntry && isPromptEqual(lastEntry, entry)) return
if (lastEntry) {
const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
if (lastText === text) return
}
setCurrentHistory("entries", (entries) => [entry, ...entries].slice(0, MAX_HISTORY))
}
@@ -887,18 +931,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
}
// Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input
// and should always insert a newline regardless of composition state
if (event.key === "Enter" && event.shiftKey) {
addPart({ type: "text", content: "\n", start: 0, end: 0 })
event.preventDefault()
return
}
if (event.key === "Enter" && isImeComposing(event)) {
return
}
if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
if (store.popover === "at") {
atOnKeyDown(event)
@@ -910,6 +942,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}
const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
const alt = event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey
if (ctrl && event.code === "KeyG") {
if (store.popover) {
@@ -924,6 +957,148 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
if (ctrl || alt) {
const { collapsed, cursorPosition, textLength } = getCaretState()
if (collapsed) {
const current = prompt.current()
const text = current.map((part) => ("content" in part ? part.content : "")).join("")
if (ctrl) {
if (event.code === "KeyA") {
const pos = text.lastIndexOf("\n", cursorPosition - 1) + 1
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyE") {
const next = text.indexOf("\n", cursorPosition)
const pos = next === -1 ? textLength : next
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyB") {
const pos = Math.max(0, cursorPosition - 1)
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyF") {
const pos = Math.min(textLength, cursorPosition + 1)
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyD") {
if (store.mode === "shell" && cursorPosition === 0 && textLength === 0) {
setStore("mode", "normal")
event.preventDefault()
return
}
if (cursorPosition >= textLength) return
replaceOffsets(cursorPosition, cursorPosition + 1, "")
event.preventDefault()
return
}
if (event.code === "KeyK") {
const next = text.indexOf("\n", cursorPosition)
const lineEnd = next === -1 ? textLength : next
const end = lineEnd === cursorPosition && lineEnd < textLength ? lineEnd + 1 : lineEnd
if (end === cursorPosition) return
killText(cursorPosition, end)
replaceOffsets(cursorPosition, end, "")
event.preventDefault()
return
}
if (event.code === "KeyU") {
const start = text.lastIndexOf("\n", cursorPosition - 1) + 1
if (start === cursorPosition) return
killText(start, cursorPosition)
replaceOffsets(start, cursorPosition, "")
event.preventDefault()
return
}
if (event.code === "KeyW") {
let start = cursorPosition
while (start > 0 && /\s/.test(text[start - 1])) start -= 1
while (start > 0 && !/\s/.test(text[start - 1])) start -= 1
if (start === cursorPosition) return
killText(start, cursorPosition)
replaceOffsets(start, cursorPosition, "")
event.preventDefault()
return
}
if (event.code === "KeyY") {
if (!store.killBuffer) return
addPart({ type: "text", content: store.killBuffer, start: 0, end: 0 })
event.preventDefault()
return
}
if (event.code === "KeyT") {
if (!current.every((part) => part.type === "text")) return
if (textLength < 2) return
if (cursorPosition === 0) return
const atEnd = cursorPosition === textLength
const first = atEnd ? cursorPosition - 2 : cursorPosition - 1
const second = atEnd ? cursorPosition - 1 : cursorPosition
if (text[first] === "\n" || text[second] === "\n") return
replaceOffsets(first, second + 1, `${text[second]}${text[first]}`)
event.preventDefault()
return
}
}
if (alt) {
if (event.code === "KeyB") {
let pos = cursorPosition
while (pos > 0 && /\s/.test(text[pos - 1])) pos -= 1
while (pos > 0 && !/\s/.test(text[pos - 1])) pos -= 1
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyF") {
let pos = cursorPosition
while (pos < textLength && /\s/.test(text[pos])) pos += 1
while (pos < textLength && !/\s/.test(text[pos])) pos += 1
setCursorPosition(editorRef, pos)
event.preventDefault()
queueScroll()
return
}
if (event.code === "KeyD") {
let end = cursorPosition
while (end < textLength && /\s/.test(text[end])) end += 1
while (end < textLength && !/\s/.test(text[end])) end += 1
if (end === cursorPosition) return
killText(cursorPosition, end)
replaceOffsets(cursorPosition, end, "")
event.preventDefault()
return
}
}
}
}
if (event.key === "ArrowUp" || event.key === "ArrowDown") {
if (event.altKey || event.ctrlKey || event.metaKey) return
const { collapsed } = getCaretState()
@@ -958,7 +1133,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return
}
// Note: Shift+Enter is handled earlier, before IME check
if (event.key === "Enter" && event.shiftKey) {
addPart({ type: "text", content: "\n", start: 0, end: 0 })
event.preventDefault()
return
}
if (event.key === "Enter" && !event.shiftKey) {
handleSubmit(event)
}
@@ -973,174 +1152,30 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const handleSubmit = async (event: Event) => {
event.preventDefault()
const currentPrompt = prompt.current()
const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
const images = imageAttachments().slice()
const mode = store.mode
if (text.trim().length === 0 && images.length === 0) {
const hasImageAttachments = store.imageAttachments.length > 0
if (text.trim().length === 0 && !hasImageAttachments) {
if (working()) abort()
return
}
const currentModel = local.model.current()
const currentAgent = local.agent.current()
if (!currentModel || !currentAgent) {
showToast({
title: "Select an agent and model",
description: "Choose an agent and model before sending a prompt.",
})
return
}
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return "Request failed"
}
addToHistory(currentPrompt, mode)
addToHistory(currentPrompt, store.mode)
setStore("historyIndex", -1)
setStore("savedPrompt", null)
const projectDirectory = sdk.directory
const isNewSession = !params.id
const worktreeSelection = props.newSessionWorktree ?? "main"
let sessionDirectory = projectDirectory
let client = sdk.client
if (isNewSession) {
if (worktreeSelection === "create") {
const createdWorktree = await client.worktree
.create({ directory: projectDirectory })
.then((x) => x.data)
.catch((err) => {
showToast({
title: "Failed to create worktree",
description: errorMessage(err),
})
return undefined
})
if (!createdWorktree?.directory) {
showToast({
title: "Failed to create worktree",
description: "Request failed",
})
return
}
sessionDirectory = createdWorktree.directory
}
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
sessionDirectory = worktreeSelection
}
if (sessionDirectory !== projectDirectory) {
client = createOpencodeClient({
baseUrl: sdk.url,
fetch: platform.fetch,
directory: sessionDirectory,
throwOnError: true,
})
globalSync.child(sessionDirectory)
}
props.onNewSessionWorktreeReset?.()
let existing = info()
if (!existing) {
const created = await sdk.client.session.create()
existing = created.data ?? undefined
if (existing) navigate(existing.id)
}
if (!existing) return
let session = info()
if (!session && isNewSession) {
session = await client.session.create().then((x) => x.data ?? undefined)
if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
if (!session) return
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const variant = local.model.variant.current()
const clearInput = () => {
prompt.reset()
setStore("mode", "normal")
setStore("popover", null)
}
const restoreInput = () => {
prompt.set(currentPrompt, promptLength(currentPrompt))
setStore("mode", mode)
setStore("popover", null)
requestAnimationFrame(() => {
editorRef.focus()
setCursorPosition(editorRef, promptLength(currentPrompt))
queueScroll()
})
}
if (mode === "shell") {
clearInput()
client.session
.shell({
sessionID: session.id,
agent,
model,
command: text,
})
.catch((err) => {
showToast({
title: "Failed to send shell command",
description: errorMessage(err),
})
restoreInput()
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) {
clearInput()
client.session
.command({
sessionID: session.id,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
parts: images.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
url: attachment.dataUrl,
filename: attachment.filename,
})),
})
.catch((err) => {
showToast({
title: "Failed to send command",
description: errorMessage(err),
})
restoreInput()
})
return
}
}
const toAbsolutePath = (path: string) =>
path.startsWith("/") ? path : (sessionDirectory + "/" + path).replace("//", "/")
const fileAttachments = currentPrompt.filter((part) => part.type === "file") as FileAttachmentPart[]
const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
const fileAttachments = currentPrompt.filter(
(part) => part.type === "file",
) as import("@/context/prompt").FileAttachmentPart[]
const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
const fileAttachmentParts = fileAttachments.map((attachment) => {
@@ -1212,7 +1247,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
addContextFile(item.path, item.selection)
}
const imageAttachmentParts = images.map((attachment) => ({
const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
id: Identifier.ascending("part"),
type: "file" as const,
mime: attachment.mime,
@@ -1220,6 +1255,60 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
filename: attachment.filename,
}))
const isShellMode = store.mode === "shell"
editorRef.innerHTML = ""
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
setStore("imageAttachments", [])
setStore("mode", "normal")
const currentModel = local.model.current()
const currentAgent = local.agent.current()
if (!currentModel || !currentAgent) {
console.warn("No agent or model available for prompt submission")
return
}
const model = {
modelID: currentModel.id,
providerID: currentModel.provider.id,
}
const agent = currentAgent.name
const variant = local.model.variant.current()
if (isShellMode) {
sdk.client.session
.shell({
sessionID: existing.id,
agent,
model,
command: text,
})
.catch((e) => {
console.error("Failed to send shell command", e)
})
return
}
if (text.startsWith("/")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
if (customCommand) {
sdk.client.session
.command({
sessionID: existing.id,
command: commandName,
arguments: args.join(" "),
agent,
model: `${model.providerID}/${model.modelID}`,
variant,
})
.catch((e) => {
console.error("Failed to send command", e)
})
return
}
}
const messageID = Identifier.ascending("message")
const textPart = {
id: Identifier.ascending("part"),
@@ -1233,74 +1322,31 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
...agentAttachmentParts,
...imageAttachmentParts,
]
const optimisticParts = requestParts.map((part) => ({
...part,
sessionID: session.id,
sessionID: existing.id,
messageID,
})) as unknown as Part[]
}))
const optimisticMessage: Message = {
id: messageID,
sessionID: session.id,
role: "user",
time: { created: Date.now() },
sync.session.addOptimisticMessage({
sessionID: existing.id,
messageID,
parts: optimisticParts,
agent,
model,
}
})
const setSyncStore = sessionDirectory === projectDirectory ? sync.set : globalSync.child(sessionDirectory)[1]
const addOptimisticMessage = () => {
setSyncStore(
produce((draft) => {
const messages = draft.message[session.id]
if (!messages) {
draft.message[session.id] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
}
draft.part[messageID] = optimisticParts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
}),
)
}
const removeOptimisticMessage = () => {
setSyncStore(
produce((draft) => {
const messages = draft.message[session.id]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
}
delete draft.part[messageID]
}),
)
}
clearInput()
addOptimisticMessage()
client.session
sdk.client.session
.prompt({
sessionID: session.id,
sessionID: existing.id,
agent,
model,
messageID,
parts: requestParts,
variant,
})
.catch((err) => {
showToast({
title: "Failed to send prompt",
description: errorMessage(err),
})
removeOptimisticMessage()
restoreInput()
.catch((e) => {
console.error("Failed to send prompt", e)
})
}
@@ -1308,13 +1354,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="relative size-full _max-h-[320px] flex flex-col gap-3">
<Show when={store.popover}>
<div
ref={(el) => {
if (store.popover === "slash") slashPopoverRef = el
}}
class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
overflow-auto no-scrollbar flex flex-col p-2 rounded-md
border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
onMouseDown={(e) => e.preventDefault()}
>
<Switch>
<Match when={store.popover === "at"}>
@@ -1330,7 +1372,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"bg-surface-raised-base-hover": atActive() === atKey(item),
}}
onClick={() => handleAtSelect(item)}
onMouseEnter={() => setAtActive(atKey(item))}
>
<Show
when={item.type === "agent"}
@@ -1371,13 +1412,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<For each={slashFlat()}>
{(cmd) => (
<button
data-slash-id={cmd.id}
classList={{
"w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
"bg-surface-raised-base-hover": slashActive() === cmd.id,
}}
onClick={() => handleSlashSelect(cmd)}
onMouseEnter={() => setSlashActive(cmd.id)}
>
<div class="flex items-center gap-2 min-w-0">
<span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
@@ -1481,9 +1520,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</For>
</div>
</Show>
<Show when={imageAttachments().length > 0}>
<Show when={store.imageAttachments.length > 0}>
<div class="flex flex-wrap gap-2 px-3 pt-3">
<For each={imageAttachments()}>
<For each={store.imageAttachments}>
{(attachment) => (
<div class="relative group">
<Show
@@ -1497,10 +1536,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<img
src={attachment.dataUrl}
alt={attachment.filename}
class="size-16 rounded-md object-cover border border-border-base hover:border-border-strong-base transition-colors"
onClick={() =>
dialog.show(() => <ImagePreview src={attachment.dataUrl} alt={attachment.filename} />)
}
class="size-16 rounded-md object-cover border border-border-base"
/>
</Show>
<button
@@ -1527,9 +1563,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
}}
contenteditable="true"
onInput={handleInput}
onPaste={handlePaste}
onCompositionStart={() => setComposing(true)}
onCompositionEnd={() => setComposing(false)}
onKeyDown={handleKeyDown}
classList={{
"select-text": true,
@@ -1539,7 +1572,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
"font-mono!": store.mode === "shell",
}}
/>
<Show when={!prompt.dirty()}>
<Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
<div class="absolute top-0 inset-x-0 px-5 py-3 pr-12 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
{store.mode === "shell"
? "Enter shell command..."
@@ -1572,9 +1605,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
fallback={
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
<Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
<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 ?? "Select model"}
<Icon name="chevron-down" size="small" />
</Button>
@@ -1584,9 +1614,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<ModelSelectorPopover>
<TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
<Button as="div" variant="ghost">
<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 ?? "Select model"}
<Icon name="chevron-down" size="small" />
</Button>
@@ -1601,10 +1628,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<Button
variant="ghost"
class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
class="text-text-base _hidden group-hover/prompt-input:inline-block"
onClick={() => local.model.variant.cycle()}
>
{local.model.variant.current() ?? "Default"}
<span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
</Button>
</TooltipKeybind>
</Show>
@@ -1619,14 +1646,14 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
"_hidden group-hover/prompt-input:flex size-6 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),
"text-text-base": !permission.isAutoAccepting(params.id!),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
}}
>
<Icon
name="chevron-double-right"
size="small"
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!, sdk.directory) }}
classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }}
/>
</Button>
</TooltipKeybind>
@@ -1638,7 +1665,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_FILE_TYPES.join(",")}
accept={ACCEPTED_IMAGE_TYPES.join(",")}
class="hidden"
onChange={(e) => {
const file = e.currentTarget.files?.[0]
@@ -1649,7 +1676,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<div class="flex items-center gap-2">
<SessionContextUsage />
<Show when={store.mode === "normal"}>
<Tooltip placement="top" value="Attach file">
<Tooltip placement="top" value="Attach image">
<Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}>
<Icon name="photo" class="size-4.5" />
</Button>
@@ -1678,7 +1705,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<IconButton
type="submit"
disabled={!prompt.dirty() && !working()}
disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
class="h-6 w-4.5"
@@ -20,7 +20,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 cost = createMemo(() => {
@@ -49,7 +48,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
const openContext = () => {
if (!params.id) return
view().reviewPanel.open()
layout.review.open()
tabs().open("context")
tabs().setActive("context")
}
@@ -305,19 +305,13 @@ export function SessionContextTab(props: SessionContextTabProps) {
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const restoreScroll = (retries = 0) => {
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = props.view()?.scroll("context")
if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
@@ -1,206 +1,212 @@
import { createMemo, createResource, Show } from "solid-js"
import { Portal } from "solid-js/web"
import { useParams } from "@solidjs/router"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useCommand } from "@/context/command"
// import { useServer } from "@/context/server"
// import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useServer } from "@/context/server"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useSync } from "@/context/sync"
import { useGlobalSDK } from "@/context/global-sdk"
import { getFilename } from "@opencode-ai/util/path"
import { base64Decode } from "@opencode-ai/util/encode"
import { base64Encode } from "@opencode-ai/util/encode"
import { iife } from "@opencode-ai/util/iife"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Select } from "@opencode-ai/ui/select"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { SessionLspIndicator } from "@/components/session-lsp-indicator"
import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
import type { Session } from "@opencode-ai/sdk/v2/client"
export function SessionHeader() {
const globalSDK = useGlobalSDK()
const layout = useLayout()
const params = useParams()
const navigate = useNavigate()
const command = useCommand()
// const server = useServer()
// const dialog = useDialog()
const server = useServer()
const dialog = useDialog()
const sync = useSync()
const projectDirectory = createMemo(() => base64Decode(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 name = createMemo(() => {
const current = project()
if (current) return current.name || getFilename(current.worktree)
return getFilename(projectDirectory())
})
const hotkey = createMemo(() => command.keybind("file.open"))
const currentSession = createMemo(() => sync.data.session.find((s) => s.id === params.id))
const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey()))
const branch = createMemo(() => sync.data.vcs?.branch)
const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))
function navigateToProject(directory: string) {
navigate(`/${base64Encode(directory)}`)
}
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session.id}`)
}
return (
<>
<Show when={centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<button
type="button"
class="hidden md:flex w-[320px] h-8 p-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active"
onClick={() => command.trigger("file.open")}
>
<div class="flex items-center gap-2">
<Icon name="magnifying-glass" size="normal" class="icon-base" />
<span class="flex-1 min-w-0 text-14-regular text-text-weak truncate">Search {name()}</span>
</div>
<Show when={hotkey()}>
{(keybind) => (
<span class="shrink-0 flex items-center justify-center h-5 px-2 rounded-[2px] border border-border-weak-base bg-surface-base text-12-medium text-text-weak">
{keybind()}
</span>
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<button
type="button"
class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
onClick={layout.mobileSidebar.toggle}
>
<Icon name="menu" size="small" />
</button>
<div class="px-4 flex items-center justify-between gap-4 w-full">
<div class="flex items-center gap-3 min-w-0">
<div class="flex items-center gap-2 min-w-0">
<div class="hidden xl:flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => project.worktree)}
current={sync.directory}
label={(x) => {
const name = getFilename(x)
const b = x === sync.directory ? branch() : undefined
return b ? `${name}:${b}` : name
}}
onSelect={(x) => (x ? navigateToProject(x) : undefined)}
class="text-14-regular text-text-base"
variant="ghost"
>
{/* @ts-ignore */}
{(i) => (
<div class="flex items-center gap-2">
<Icon name="folder" size="small" />
<div class="text-text-strong">{getFilename(i)}</div>
</div>
)}
</Show>
</button>
</Portal>
)}
</Show>
<Show when={rightMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="flex items-center gap-3">
{/* <div class="hidden md:flex items-center gap-1"> */}
{/* <Button */}
{/* size="small" */}
{/* variant="ghost" */}
{/* onClick={() => { */}
{/* dialog.show(() => <DialogSelectServer />) */}
{/* }} */}
{/* > */}
{/* <div */}
{/* classList={{ */}
{/* "size-1.5 rounded-full": true, */}
{/* "bg-icon-success-base": server.healthy() === true, */}
{/* "bg-icon-critical-base": server.healthy() === false, */}
{/* "bg-border-weak-base": server.healthy() === undefined, */}
{/* }} */}
{/* /> */}
{/* <Icon name="server" size="small" class="text-icon-weak" /> */}
{/* <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span> */}
{/* </Button> */}
{/* <SessionLspIndicator /> */}
{/* <SessionMcpIndicator /> */}
{/* </div> */}
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle size-6 p-0"
onClick={() => view().reviewPanel.toggle()}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={view().reviewPanel.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={view().reviewPanel.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={view().reviewPanel.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle size-6 p-0"
onClick={() => view().terminal.toggle()}
>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: projectDirectory() })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
{ initialValue: "" },
)
return (
<Show when={url.latest}>
{(shareUrl) => <TextField value={shareUrl()} readOnly copyable class="w-72" />}
</Show>
)
})}
</Popover>
</Show>
</Select>
<div class="text-text-weaker">/</div>
</div>
</Portal>
)}
</Show>
</>
<Select
options={sessions()}
current={currentSession()}
placeholder="New session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
variant="ghost"
/>
</div>
<Show when={currentSession()}>
<TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
</TooltipKeybind>
</Show>
</div>
<div class="flex items-center gap-3">
<div class="hidden md:flex items-center gap-1">
<Button
size="small"
variant="ghost"
onClick={() => {
dialog.show(() => <DialogSelectServer />)
}}
>
<div
classList={{
"size-1.5 rounded-full": true,
"bg-icon-success-base": server.healthy() === true,
"bg-icon-critical-base": server.healthy() === false,
"bg-border-weak-base": server.healthy() === undefined,
}}
/>
<Icon name="server" size="small" class="text-icon-weak" />
<span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
</Button>
<SessionLspIndicator />
<SessionMcpIndicator />
</div>
<div class="flex items-center gap-1">
<Show when={currentSession()?.summary?.files}>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle review"
keybind={command.keybind("review.toggle")}
>
<Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.review.opened() ? "layout-right" : "layout-left"}
size="small"
class="group-hover/review-toggle:hidden"
/>
<Icon
name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
size="small"
class="hidden group-hover/review-toggle:inline-block"
/>
<Icon
name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
size="small"
class="hidden group-active/review-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</Show>
<TooltipKeybind
class="hidden md:block shrink-0"
title="Toggle terminal"
keybind={command.keybind("terminal.toggle")}
>
<Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
<div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
class="group-hover/terminal-toggle:hidden"
/>
<Icon
size="small"
name="layout-bottom-partial"
class="hidden group-hover/terminal-toggle:inline-block"
/>
<Icon
size="small"
name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
class="hidden group-active/terminal-toggle:inline-block"
/>
</div>
</Button>
</TooltipKeybind>
</div>
<Show when={shareEnabled() && currentSession()}>
<Popover
title="Share session"
trigger={
<Tooltip class="shrink-0" value="Share session">
<IconButton icon="share" variant="ghost" class="" />
</Tooltip>
}
>
{iife(() => {
const [url] = createResource(
() => currentSession(),
async (session) => {
if (!session) return
let shareURL = session.share?.url
if (!shareURL) {
shareURL = await globalSDK.client.session
.share({ sessionID: session.id, directory: sync.directory })
.then((r) => r.data?.share?.url)
.catch((e) => {
console.error("Failed to share session", e)
return undefined
})
}
return shareURL
},
)
return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
})}
</Popover>
</Show>
</div>
</div>
</header>
)
}
@@ -1,76 +1,22 @@
import { Show, createMemo } from "solid-js"
import { Show } from "solid-js"
import { DateTime } from "luxon"
import { useSync } from "@/context/sync"
import { Icon } from "@opencode-ai/ui/icon"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Select } from "@opencode-ai/ui/select"
const MAIN_WORKTREE = "main"
const CREATE_WORKTREE = "create"
interface NewSessionViewProps {
worktree: string
onWorktreeChange: (value: string) => void
}
export function NewSessionView(props: NewSessionViewProps) {
export function NewSessionView() {
const sync = useSync()
const sandboxes = createMemo(() => sync.project?.sandboxes ?? [])
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
const current = createMemo(() => {
const selection = props.worktree
if (options().includes(selection)) return selection
return MAIN_WORKTREE
})
const projectRoot = createMemo(() => sync.project?.worktree ?? sync.data.path.directory)
const isWorktree = createMemo(() => {
const project = sync.project
if (!project) return false
return sync.data.path.directory !== project.worktree
})
const label = (value: string) => {
if (value === MAIN_WORKTREE) {
if (isWorktree()) return "Main branch"
const branch = sync.data.vcs?.branch
if (branch) return `Main branch (${branch})`
return "Main branch"
}
if (value === CREATE_WORKTREE) return "Create new worktree"
return getFilename(value)
}
return (
<div
class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6"
style={{ "padding-bottom": "calc(var(--prompt-height, 11.25rem) + 64px)" }}
>
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
<div class="text-20-medium text-text-weaker">New session</div>
<div class="flex justify-center items-center gap-3">
<Icon name="folder" size="small" />
<div class="text-12-medium text-text-weak">
{getDirectory(projectRoot())}
<span class="text-text-strong">{getFilename(projectRoot())}</span>
{getDirectory(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
</div>
</div>
<div class="flex justify-center items-center gap-1">
<Icon name="branch" size="small" />
<Select
options={options()}
current={current()}
value={(x) => x}
label={label}
onSelect={(value) => {
props.onWorktreeChange(value ?? MAIN_WORKTREE)
}}
size="normal"
variant="ghost"
class="text-12-medium"
/>
</div>
<Show when={sync.project}>
{(project) => (
<div class="flex justify-center items-center gap-3">
@@ -39,7 +39,6 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
</Tooltip>
}
hideCloseButton
onMiddleClick={() => props.onTabClose(props.tab)}
>
<Show when={path()}>{(p) => <FileVisual path={p()} />}</Show>
</Tabs.Trigger>
+68 -121
View File
@@ -1,9 +1,9 @@
import type { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/terminal"
import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@opencode-ai/ui/theme"
import { resolveThemeVariant, useTheme } from "@opencode-ai/ui/theme"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
@@ -16,7 +16,6 @@ type TerminalColors = {
background: string
foreground: string
cursor: string
selectionBackground: string
}
const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
@@ -24,13 +23,11 @@ const DEFAULT_TERMINAL_COLORS: Record<"light" | "dark", TerminalColors> = {
background: "#fcfcfc",
foreground: "#211e1e",
cursor: "#211e1e",
selectionBackground: withAlpha("#211e1e", 0.2),
},
dark: {
background: "#191515",
foreground: "#d4d4d4",
cursor: "#d4d4d4",
selectionBackground: withAlpha("#d4d4d4", 0.25),
},
}
@@ -39,16 +36,12 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket | undefined
let term: Term | undefined
let ws: WebSocket
let term: Term
let ghostty: Ghostty
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
let handleTextareaFocus: () => void
let handleTextareaBlur: () => void
let reconnect: number | undefined
let disposed = false
const getTerminalColors = (): TerminalColors => {
const mode = theme.mode()
@@ -58,16 +51,12 @@ export const Terminal = (props: TerminalProps) => {
const variant = mode === "dark" ? currentTheme.dark : currentTheme.light
if (!variant?.seeds) return fallback
const resolved = resolveThemeVariant(variant, mode === "dark")
const text = resolved["text-stronger"] ?? fallback.foreground
const text = resolved["text-base"] ?? fallback.foreground
const background = resolved["background-stronger"] ?? fallback.background
const alpha = mode === "dark" ? 0.25 : 0.2
const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor)
const selectionBackground = withAlpha(base, alpha)
return {
background,
foreground: text,
cursor: text,
selectionBackground,
}
}
@@ -82,11 +71,27 @@ export const Terminal = (props: TerminalProps) => {
setOption("theme", colors)
})
const focusTerminal = () => {
const t = term
if (!t) return
t.focus()
setTimeout(() => t.textarea?.focus(), 0)
const focusTerminal = () => term?.focus()
const copySelection = () => {
if (!term || !term.hasSelection()) return false
const selection = term.getSelection()
if (!selection) return false
const clipboard = navigator.clipboard
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
if (!document.body) return false
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
document.body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
document.body.removeChild(textarea)
return copied
}
const handlePointerDown = () => {
const activeElement = document.activeElement
@@ -97,20 +102,11 @@ export const Terminal = (props: TerminalProps) => {
}
onMount(async () => {
const mod = await import("ghostty-web")
ghostty = await mod.Ghostty.load()
ghostty = await Ghostty.load()
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword
}
const socket = new WebSocket(url)
ws = socket
const t = new mod.Terminal({
ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
term = new Term({
cursorBlink: true,
cursorStyle: "bar",
fontSize: 14,
fontFamily: "IBM Plex Mono, monospace",
allowTransparency: true,
@@ -118,94 +114,50 @@ export const Terminal = (props: TerminalProps) => {
scrollback: 10_000,
ghostty,
})
term = t
const copy = () => {
const selection = t.getSelection()
if (!selection) return false
const body = document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const clipboard = navigator.clipboard
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
return false
}
t.attachCustomKeyEventHandler((event) => {
term.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
copy()
return true
if (key === "c") {
const macCopy = event.metaKey && !event.ctrlKey && !event.altKey
const linuxCopy = event.ctrlKey && event.shiftKey && !event.metaKey
if ((macCopy || linuxCopy) && copySelection()) {
event.preventDefault()
return true
}
}
if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
if (!t.hasSelection()) return true
copy()
return true
}
// allow for ctrl-` to toggle terminal in parent
if (event.ctrlKey && key === "`") {
event.preventDefault()
return true
}
return false
})
fitAddon = new mod.FitAddon()
fitAddon = new FitAddon()
serializeAddon = new SerializeAddon()
t.loadAddon(serializeAddon)
t.loadAddon(fitAddon)
term.loadAddon(serializeAddon)
term.loadAddon(fitAddon)
t.open(container)
term.open(container)
container.addEventListener("pointerdown", handlePointerDown)
handleTextareaFocus = () => {
t.options.cursorBlink = true
}
handleTextareaBlur = () => {
t.options.cursorBlink = false
}
t.textarea?.addEventListener("focus", handleTextareaFocus)
t.textarea?.addEventListener("blur", handleTextareaBlur)
focusTerminal()
if (local.pty.buffer) {
if (local.pty.rows && local.pty.cols) {
t.resize(local.pty.cols, local.pty.rows)
term.resize(local.pty.cols, local.pty.rows)
}
t.write(local.pty.buffer, () => {
if (local.pty.scrollY) {
t.scrollToLine(local.pty.scrollY)
}
fitAddon.fit()
})
term.reset()
term.write(local.pty.buffer)
if (local.pty.scrollY) {
term.scrollToLine(local.pty.scrollY)
}
fitAddon.fit()
}
fitAddon.observeResize()
handleResize = () => fitAddon.fit()
window.addEventListener("resize", handleResize)
t.onResize(async (size) => {
if (socket.readyState === WebSocket.OPEN) {
term.onResize(async (size) => {
if (ws && ws.readyState === WebSocket.OPEN) {
await sdk.client.pty
.update({
ptyID: local.pty.id,
@@ -217,39 +169,39 @@ export const Terminal = (props: TerminalProps) => {
.catch(() => {})
}
})
t.onData((data) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(data)
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
t.onKey((key) => {
term.onKey((key) => {
if (key.key == "Enter") {
props.onSubmit?.()
}
})
// t.onScroll((ydisp) => {
// term.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
socket.addEventListener("open", () => {
ws.addEventListener("open", () => {
console.log("WebSocket connected")
sdk.client.pty
.update({
ptyID: local.pty.id,
size: {
cols: t.cols,
rows: t.rows,
cols: term.cols,
rows: term.rows,
},
})
.catch(() => {})
})
socket.addEventListener("message", (event) => {
t.write(event.data)
ws.addEventListener("message", (event) => {
term.write(event.data)
})
socket.addEventListener("error", (error) => {
ws.addEventListener("error", (error) => {
console.error("WebSocket error:", error)
props.onConnectError?.(error)
})
socket.addEventListener("close", () => {
ws.addEventListener("close", () => {
console.log("WebSocket disconnected")
})
})
@@ -259,23 +211,18 @@ export const Terminal = (props: TerminalProps) => {
window.removeEventListener("resize", handleResize)
}
container.removeEventListener("pointerdown", handlePointerDown)
term?.textarea?.removeEventListener("focus", handleTextareaFocus)
term?.textarea?.removeEventListener("blur", handleTextareaBlur)
const t = term
if (serializeAddon && props.onCleanup && t) {
if (serializeAddon && props.onCleanup) {
const buffer = serializeAddon.serialize()
props.onCleanup({
...local.pty,
buffer,
rows: t.rows,
cols: t.cols,
scrollY: t.getViewportY(),
rows: term.rows,
cols: term.cols,
scrollY: term.getViewportY(),
})
}
ws?.close()
t?.dispose()
term?.dispose()
})
return (
-116
View File
@@ -1,116 +0,0 @@
import { createEffect, createMemo, Show } from "solid-js"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { useTheme } from "@opencode-ai/ui/theme"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useCommand } from "@/context/command"
export function Titlebar() {
const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
const theme = useTheme()
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const reserve = createMemo(
() => platform.platform === "desktop" && (platform.os === "windows" || platform.os === "linux"),
)
const web = createMemo(() => platform.platform === "web")
const getWin = () => {
if (platform.platform !== "desktop") return
const tauri = (
window as unknown as {
__TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise<void> } } }
}
).__TAURI__
if (!tauri?.window?.getCurrentWindow) return
return tauri.window.getCurrentWindow()
}
createEffect(() => {
if (platform.platform !== "desktop") return
const scheme = theme.colorScheme()
const value = scheme === "system" ? null : scheme
const tauri = (window as unknown as { __TAURI__?: { webviewWindow?: { getCurrentWebviewWindow?: () => unknown } } })
.__TAURI__
const get = tauri?.webviewWindow?.getCurrentWebviewWindow
if (!get) return
const win = get() as { setTheme?: (theme?: "light" | "dark" | null) => Promise<void> }
if (!win.setTheme) return
void win.setTheme(value).catch(() => undefined)
})
const interactive = (target: EventTarget | null) => {
if (!(target instanceof Element)) return false
const selector =
"button, a, input, textarea, select, option, [role='button'], [role='menuitem'], [contenteditable='true'], [contenteditable='']"
return !!target.closest(selector)
}
const drag = (e: MouseEvent) => {
if (platform.platform !== "desktop") return
if (e.buttons !== 1) return
if (interactive(e.target)) return
const win = getWin()
if (!win?.startDragging) return
e.preventDefault()
void win.startDragging().catch(() => undefined)
}
return (
<header class="h-10 shrink-0 bg-background-base flex items-center relative">
<div
classList={{
"flex items-center w-full min-w-0 pr-2": true,
"pl-2": !mac(),
}}
onMouseDown={drag}
>
<Show when={mac()}>
<div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
</Show>
<IconButton
icon="menu"
variant="ghost"
class="xl:hidden size-8 rounded-md"
onClick={layout.mobileSidebar.toggle}
/>
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0"}
placement="bottom"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
>
<IconButton
icon={layout.sidebar.opened() ? "layout-left" : "layout-right"}
variant="ghost"
class="size-8 rounded-md"
onClick={layout.sidebar.toggle}
/>
</TooltipKeybind>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
<div class="flex-1 h-full" data-tauri-drag-region />
<div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0" />
<Show when={reserve()}>
<div class="w-[120px] h-full shrink-0" data-tauri-drag-region />
</Show>
</div>
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div id="opencode-titlebar-center" class="pointer-events-auto" />
</div>
</header>
)
}
+70 -24
View File
@@ -1,5 +1,8 @@
import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js"
import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { List } from "@opencode-ai/ui/list"
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
@@ -111,26 +114,71 @@ export function formatKeybind(config: string): string {
return IS_MAC ? parts.join("") : parts.join("+")
}
function DialogCommand(props: { options: CommandOption[] }) {
const dialog = useDialog()
let cleanup: (() => void) | void
let committed = false
const handleMove = (option: CommandOption | undefined) => {
cleanup?.()
cleanup = option?.onHighlight?.()
}
const handleSelect = (option: CommandOption | undefined) => {
if (option) {
committed = true
cleanup = undefined
dialog.close()
option.onSelect?.("palette")
}
}
onCleanup(() => {
if (!committed) {
cleanup?.()
}
})
return (
<Dialog title="Commands">
<List
search={{ placeholder: "Search commands", autofocus: true }}
emptyMessage="No commands found"
items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
key={(x) => x?.id}
filterKeys={["title", "description", "category"]}
groupBy={(x) => x.category ?? ""}
onMove={handleMove}
onSelect={handleSelect}
>
{(option) => (
<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">{option.title}</span>
<Show when={option.description}>
<span class="text-14-regular text-text-weak truncate">{option.description}</span>
</Show>
</div>
<Show when={option.keybind}>
<span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
</Show>
</div>
)}
</List>
</Dialog>
)
}
export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
name: "Command",
init: () => {
const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
const [suspendCount, setSuspendCount] = createSignal(0)
const dialog = useDialog()
const options = createMemo(() => {
const seen = new Set<string>()
const all: CommandOption[] = []
for (const reg of registrations()) {
for (const opt of reg()) {
if (seen.has(opt.id)) continue
seen.add(opt.id)
all.push(opt)
}
}
const all = registrations().flatMap((x) => x())
const suggested = all.filter((x) => x.suggested && !x.disabled)
return [
...suggested.map((x) => ({
...x,
@@ -143,17 +191,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const suspended = () => suspendCount() > 0
const run = (id: string, source?: "palette" | "keybind" | "slash") => {
for (const option of options()) {
if (option.id === id || option.id === "suggested." + id) {
option.onSelect?.(source)
return
}
}
}
const showPalette = () => {
run("file.open", "palette")
if (!dialog.active) {
dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
}
}
const handleKeyDown = (event: KeyboardEvent) => {
@@ -196,7 +237,12 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
})
},
trigger(id: string, source?: "palette" | "keybind" | "slash") {
run(id, source)
for (const option of options()) {
if (option.id === id || option.id === "suggested." + id) {
option.onSelect?.(source)
return
}
}
},
keybind(id: string) {
const option = options().find((x) => x.id === id || x.id === "suggested." + id)
+38 -149
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createRoot, onCleanup } from "solid-js"
import { createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { FileContent } from "@opencode-ai/sdk/v2"
@@ -7,7 +7,7 @@ import { useParams } from "@solidjs/router"
import { getFilename } from "@opencode-ai/util/path"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { Persist, persisted } from "@/utils/persist"
import { persisted } from "@/utils/persist"
export type FileSelection = {
startLine: number
@@ -82,106 +82,8 @@ function normalizeSelectedLines(range: SelectedLineRange): SelectedLineRange {
}
}
const WORKSPACE_KEY = "__workspace__"
const MAX_FILE_VIEW_SESSIONS = 20
const MAX_VIEW_FILES = 500
type ViewSession = ReturnType<typeof createViewSession>
type ViewCacheEntry = {
value: ViewSession
dispose: VoidFunction
}
function createViewSession(dir: string, id: string | undefined) {
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
const [view, setView, _, ready] = persisted(
Persist.scoped(dir, id, "file-view", [legacyViewKey]),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
const meta = { pruned: false }
const pruneView = (keep?: string) => {
const keys = Object.keys(view.file)
if (keys.length <= MAX_VIEW_FILES) return
const drop = keys.filter((key) => key !== keep).slice(0, keys.length - MAX_VIEW_FILES)
if (drop.length === 0) return
setView(
produce((draft) => {
for (const key of drop) {
delete draft.file[key]
}
}),
)
}
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
pruneView()
})
const scrollTop = (path: string) => view.file[path]?.scrollTop
const scrollLeft = (path: string) => view.file[path]?.scrollLeft
const selectedLines = (path: string) => view.file[path]?.selectedLines
const setScrollTop = (path: string, top: number) => {
setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
pruneView(path)
}
const setScrollLeft = (path: string, left: number) => {
setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
pruneView(path)
}
const setSelectedLines = (path: string, range: SelectedLineRange | null) => {
const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
pruneView(path)
}
return {
ready,
scrollTop,
scrollLeft,
selectedLines,
setScrollTop,
setScrollLeft,
setSelectedLines,
}
}
export const { use: useFile, provider: FileProvider } = createSimpleContext({
name: "File",
gate: false,
init: () => {
const sdk = useSDK()
const sync = useSync()
@@ -232,45 +134,16 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
file: {},
})
const viewCache = new Map<string, ViewCacheEntry>()
const viewKey = createMemo(() => `${params.dir}/file${params.id ? "/" + params.id : ""}.v1`)
const disposeViews = () => {
for (const entry of viewCache.values()) {
entry.dispose()
}
viewCache.clear()
}
const pruneViews = () => {
while (viewCache.size > MAX_FILE_VIEW_SESSIONS) {
const first = viewCache.keys().next().value
if (!first) return
const entry = viewCache.get(first)
entry?.dispose()
viewCache.delete(first)
}
}
const loadView = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = viewCache.get(key)
if (existing) {
viewCache.delete(key)
viewCache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createViewSession(dir, id),
dispose,
}))
viewCache.set(key, entry)
pruneViews()
return entry.value
}
const view = createMemo(() => loadView(params.dir!, params.id))
const [view, setView, _, ready] = persisted(
viewKey(),
createStore<{
file: Record<string, FileViewState>
}>({
file: {},
}),
)
function ensure(path: string) {
if (!path) return
@@ -347,32 +220,48 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const get = (input: string) => store.file[normalize(input)]
const scrollTop = (input: string) => view().scrollTop(normalize(input))
const scrollLeft = (input: string) => view().scrollLeft(normalize(input))
const selectedLines = (input: string) => view().selectedLines(normalize(input))
const scrollTop = (input: string) => view.file[normalize(input)]?.scrollTop
const scrollLeft = (input: string) => view.file[normalize(input)]?.scrollLeft
const selectedLines = (input: string) => view.file[normalize(input)]?.selectedLines
const setScrollTop = (input: string, top: number) => {
const path = normalize(input)
view().setScrollTop(path, top)
setView("file", path, (current) => {
if (current?.scrollTop === top) return current
return {
...(current ?? {}),
scrollTop: top,
}
})
}
const setScrollLeft = (input: string, left: number) => {
const path = normalize(input)
view().setScrollLeft(path, left)
setView("file", path, (current) => {
if (current?.scrollLeft === left) return current
return {
...(current ?? {}),
scrollLeft: left,
}
})
}
const setSelectedLines = (input: string, range: SelectedLineRange | null) => {
const path = normalize(input)
view().setSelectedLines(path, range)
const next = range ? normalizeSelectedLines(range) : null
setView("file", path, (current) => {
if (current?.selectedLines === next) return current
return {
...(current ?? {}),
selectedLines: next,
}
})
}
onCleanup(() => {
stop()
disposeViews()
})
onCleanup(() => stop())
return {
ready: () => view().ready(),
ready,
normalize,
tab,
pathFromTab,
+5 -71
View File
@@ -1,7 +1,7 @@
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 { batch, onCleanup } from "solid-js"
import { onCleanup } from "solid-js"
import { usePlatform } from "./platform"
import { useServer } from "./server"
@@ -9,92 +9,26 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
name: "GlobalSDK",
init: () => {
const server = useServer()
const platform = usePlatform()
const abort = new AbortController()
const eventSdk = createOpencodeClient({
baseUrl: server.url,
signal: abort.signal,
fetch: platform.fetch,
})
const emitter = createGlobalEmitter<{
[key: string]: Event
}>()
type Queued = { directory: string; payload: Event }
let queue: Array<Queued | undefined> = []
const coalesced = new Map<string, number>()
let timer: ReturnType<typeof setTimeout> | undefined
let last = 0
const key = (directory: string, payload: Event) => {
if (payload.type === "session.status") return `session.status:${directory}:${payload.properties.sessionID}`
if (payload.type === "lsp.updated") return `lsp.updated:${directory}`
if (payload.type === "message.part.updated") {
const part = payload.properties.part
return `message.part.updated:${directory}:${part.messageID}:${part.id}`
}
}
const flush = () => {
if (timer) clearTimeout(timer)
timer = undefined
const events = queue
queue = []
coalesced.clear()
if (events.length === 0) return
last = Date.now()
batch(() => {
for (const event of events) {
if (!event) continue
emitter.emit(event.directory, event.payload)
}
})
}
const schedule = () => {
if (timer) return
const elapsed = Date.now() - last
timer = setTimeout(flush, Math.max(0, 16 - elapsed))
}
const stop = () => {
flush()
}
void (async () => {
const events = await eventSdk.global.event()
let yielded = Date.now()
for await (const event of events.stream) {
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = undefined
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < 8) continue
yielded = Date.now()
await new Promise<void>((resolve) => setTimeout(resolve, 0))
emitter.emit(event.directory ?? "global", event.payload)
}
})()
.finally(stop)
.catch(() => undefined)
})().catch(() => undefined)
onCleanup(() => {
abort.abort()
stop()
})
onCleanup(() => abort.abort())
const platform = usePlatform()
const sdk = createOpencodeClient({
baseUrl: server.url,
fetch: platform.fetch,
+12 -155
View File
@@ -16,32 +16,16 @@ import {
type LspStatus,
type VcsInfo,
type PermissionRequest,
type QuestionRequest,
createOpencodeClient,
} from "@opencode-ai/sdk/v2/client"
import { createStore, produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useGlobalSDK } from "./global-sdk"
import { ErrorPage, type InitError } from "../pages/error"
import {
batch,
createContext,
createEffect,
getOwner,
runWithOwner,
useContext,
onCleanup,
onMount,
type Accessor,
type ParentProps,
Switch,
Match,
} from "solid-js"
import { batch, createContext, useContext, onMount, type ParentProps, Switch, Match } from "solid-js"
import { showToast } from "@opencode-ai/ui/toast"
import { getFilename } from "@opencode-ai/util/path"
import { usePlatform } from "./platform"
import { Persist, persisted } from "@/utils/persist"
type State = {
status: "loading" | "partial" | "complete"
@@ -52,7 +36,6 @@ type State = {
config: Config
path: Path
session: Session[]
sessionTotal: number
session_status: {
[sessionID: string]: SessionStatus
}
@@ -65,9 +48,6 @@ type State = {
permission: {
[sessionID: string]: PermissionRequest[]
}
question: {
[sessionID: string]: QuestionRequest[]
}
mcp: {
[name: string]: McpStatus
}
@@ -82,18 +62,8 @@ type State = {
}
}
type VcsCache = {
store: Store<{ value: VcsInfo | undefined }>
setStore: SetStoreFunction<{ value: VcsInfo | undefined }>
ready: Accessor<boolean>
}
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
const owner = getOwner()
if (!owner) throw new Error("GlobalSync must be created within owner")
const vcsCache = new Map<string, VcsCache>()
const [globalStore, setGlobalStore] = createStore<{
ready: boolean
error?: InitError
@@ -109,19 +79,10 @@ function createGlobalSync() {
provider_auth: {},
})
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
const children: Record<string, ReturnType<typeof createStore<State>>> = {}
function child(directory: string) {
if (!directory) console.error("No directory provided")
if (!children[directory]) {
const cache = runWithOwner(owner, () =>
persisted(
Persist.workspace(directory, "vcs", ["vcs.v1"]),
createStore({ value: undefined as VcsInfo | undefined }),
),
)
if (!cache) throw new Error("Failed to create persisted cache")
vcsCache.set(directory, { store: cache[0], setStore: cache[1], ready: cache[3] })
children[directory] = createStore<State>({
project: "",
provider: { all: [], connected: [], default: {} },
@@ -131,54 +92,39 @@ function createGlobalSync() {
agent: [],
command: [],
session: [],
sessionTotal: 0,
session_status: {},
session_diff: {},
todo: {},
permission: {},
question: {},
mcp: {},
lsp: [],
vcs: cache[0].value,
vcs: undefined,
limit: 5,
message: {},
part: {},
})
bootstrapInstance(directory)
}
const childStore = children[directory]
if (!childStore) throw new Error("Failed to create store")
return childStore
return children[directory]
}
async function loadSessions(directory: string) {
const [store, setStore] = child(directory)
const limit = store.limit
return globalSDK.client.session
.list({ directory, roots: true })
globalSDK.client.session
.list({ directory })
.then((x) => {
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
.filter((s) => !s.time?.archived)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory))
if (sandboxWorkspace) {
setStore("session", reconcile(nonArchived, { key: "id" }))
return
}
const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
// Include up to the limit, plus any updated in the last 4 hours
const sessions = nonArchived.filter((s, i) => {
if (i < limit) return true
if (i < store.limit) return true
const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
return updated > fourHoursAgo
})
// Store total session count (used for "load more" pagination)
setStore("sessionTotal", nonArchived.length)
setStore("session", reconcile(sessions, { key: "id" }))
})
.catch((err) => {
@@ -191,22 +137,12 @@ function createGlobalSync() {
async function bootstrapInstance(directory: string) {
if (!directory) return
const [store, setStore] = child(directory)
const cache = vcsCache.get(directory)
if (!cache) return
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
createEffect(() => {
if (!cache.ready()) return
const cached = cache.store.value
if (!cached?.branch) return
setStore("vcs", (value) => value ?? cached)
})
const blockingRequests = {
project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)),
provider: () =>
@@ -236,11 +172,7 @@ function createGlobalSync() {
loadSessions(directory),
sdk.mcp.status().then((x) => setStore("mcp", x.data!)),
sdk.lsp.status().then((x) => setStore("lsp", x.data!)),
sdk.vcs.get().then((x) => {
const next = x.data ?? store.vcs
setStore("vcs", next)
if (next?.branch) cache.setStore("value", next)
}),
sdk.vcs.get().then((x) => setStore("vcs", x.data)),
sdk.permission.list().then((x) => {
const grouped: Record<string, PermissionRequest[]> = {}
for (const perm of x.data ?? []) {
@@ -273,38 +205,6 @@ function createGlobalSync() {
}
})
}),
sdk.question.list().then((x) => {
const grouped: Record<string, QuestionRequest[]> = {}
for (const question of x.data ?? []) {
if (!question?.id || !question.sessionID) continue
const existing = grouped[question.sessionID]
if (existing) {
existing.push(question)
continue
}
grouped[question.sessionID] = [question]
}
batch(() => {
for (const sessionID of Object.keys(store.question)) {
if (grouped[sessionID]) continue
setStore("question", sessionID, [])
}
for (const [sessionID, questions] of Object.entries(grouped)) {
setStore(
"question",
sessionID,
reconcile(
questions
.filter((q) => !!q?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
}),
]).then(() => {
setStore("status", "complete")
})
@@ -312,7 +212,7 @@ function createGlobalSync() {
.catch((e) => setGlobalStore("error", e))
}
const unsub = globalSDK.event.listen((e) => {
globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
@@ -453,10 +353,7 @@ function createGlobalSync() {
break
}
case "vcs.branch.updated": {
const next = { branch: event.properties.branch }
setStore("vcs", next)
const cache = vcsCache.get(directory)
if (cache) cache.setStore("value", next)
setStore("vcs", { branch: event.properties.branch })
break
}
case "permission.asked": {
@@ -496,48 +393,9 @@ function createGlobalSync() {
)
break
}
case "question.asked": {
const sessionID = event.properties.sessionID
const questions = store.question[sessionID]
if (!questions) {
setStore("question", sessionID, [event.properties])
break
}
const result = Binary.search(questions, event.properties.id, (q) => q.id)
if (result.found) {
setStore("question", sessionID, result.index, reconcile(event.properties))
break
}
setStore(
"question",
sessionID,
produce((draft) => {
draft.splice(result.index, 0, event.properties)
}),
)
break
}
case "question.replied":
case "question.rejected": {
const questions = store.question[event.properties.sessionID]
if (!questions) break
const result = Binary.search(questions, event.properties.requestID, (q) => q.id)
if (!result.found) break
setStore(
"question",
event.properties.sessionID,
produce((draft) => {
draft.splice(result.index, 1)
}),
)
break
}
case "lsp.updated": {
const sdk = createOpencodeClient({
baseUrl: globalSDK.url,
fetch: platform.fetch,
directory,
throwOnError: true,
})
@@ -546,7 +404,6 @@ function createGlobalSync() {
}
}
})
onCleanup(unsub)
async function bootstrap() {
const health = await globalSDK.client.global
@@ -1,73 +0,0 @@
import { describe, expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { makePersisted, type SyncStorage } from "@solid-primitives/storage"
import { createScrollPersistence } from "./layout-scroll"
describe("createScrollPersistence", () => {
test("debounces persisted scroll writes", async () => {
const key = "layout-scroll.test"
const data = new Map<string, string>()
const writes: string[] = []
const stats = { flushes: 0 }
const storage = {
getItem: (k: string) => data.get(k) ?? null,
setItem: (k: string, v: string) => {
data.set(k, v)
if (k === key) writes.push(v)
},
removeItem: (k: string) => {
data.delete(k)
},
} as SyncStorage
await new Promise<void>((resolve, reject) => {
createRoot((dispose) => {
const [raw, setRaw] = createStore({
sessionView: {} as Record<string, { scroll: Record<string, { x: number; y: number }> }>,
})
const [store, setStore] = makePersisted([raw, setRaw], { name: key, storage })
const scroll = createScrollPersistence({
debounceMs: 30,
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
onFlush: (sessionKey, next) => {
stats.flushes += 1
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: next })
return
}
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
},
})
const run = async () => {
await new Promise((r) => setTimeout(r, 0))
writes.length = 0
for (const i of Array.from({ length: 100 }, (_, n) => n)) {
scroll.setScroll("session", "review", { x: 0, y: i })
}
await new Promise((r) => setTimeout(r, 120))
expect(stats.flushes).toBeGreaterThanOrEqual(1)
expect(writes.length).toBeGreaterThanOrEqual(1)
expect(writes.length).toBeLessThanOrEqual(2)
}
void run()
.then(resolve)
.catch(reject)
.finally(() => {
scroll.dispose()
dispose()
})
})
})
})
})
-118
View File
@@ -1,118 +0,0 @@
import { createStore, produce } from "solid-js/store"
export type SessionScroll = {
x: number
y: number
}
type ScrollMap = Record<string, SessionScroll>
type Options = {
debounceMs?: number
getSnapshot: (sessionKey: string) => ScrollMap | undefined
onFlush: (sessionKey: string, scroll: ScrollMap) => void
}
export function createScrollPersistence(opts: Options) {
const wait = opts.debounceMs ?? 200
const [cache, setCache] = createStore<Record<string, ScrollMap>>({})
const dirty = new Set<string>()
const timers = new Map<string, ReturnType<typeof setTimeout>>()
function clone(input?: ScrollMap) {
const out: ScrollMap = {}
if (!input) return out
for (const key of Object.keys(input)) {
const pos = input[key]
if (!pos) continue
out[key] = { x: pos.x, y: pos.y }
}
return out
}
function seed(sessionKey: string) {
if (cache[sessionKey]) return
setCache(sessionKey, clone(opts.getSnapshot(sessionKey)))
}
function scroll(sessionKey: string, tab: string) {
seed(sessionKey)
return cache[sessionKey]?.[tab] ?? opts.getSnapshot(sessionKey)?.[tab]
}
function schedule(sessionKey: string) {
const prev = timers.get(sessionKey)
if (prev) clearTimeout(prev)
timers.set(
sessionKey,
setTimeout(() => flush(sessionKey), wait),
)
}
function setScroll(sessionKey: string, tab: string, pos: SessionScroll) {
seed(sessionKey)
const prev = cache[sessionKey]?.[tab]
if (prev?.x === pos.x && prev?.y === pos.y) return
setCache(sessionKey, tab, { x: pos.x, y: pos.y })
dirty.add(sessionKey)
schedule(sessionKey)
}
function flush(sessionKey: string) {
const timer = timers.get(sessionKey)
if (timer) clearTimeout(timer)
timers.delete(sessionKey)
if (!dirty.has(sessionKey)) return
dirty.delete(sessionKey)
opts.onFlush(sessionKey, clone(cache[sessionKey]))
}
function flushAll() {
const keys = Array.from(dirty)
if (keys.length === 0) return
for (const key of keys) {
flush(key)
}
}
function drop(keys: string[]) {
if (keys.length === 0) return
for (const key of keys) {
const timer = timers.get(key)
if (timer) clearTimeout(timer)
timers.delete(key)
dirty.delete(key)
}
setCache(
produce((draft) => {
for (const key of keys) {
delete draft[key]
}
}),
)
}
function dispose() {
drop(Array.from(timers.keys()))
}
return {
cache,
drop,
flush,
flushAll,
scroll,
seed,
setScroll,
dispose,
}
}
+56 -262
View File
@@ -1,13 +1,11 @@
import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { batch, createMemo, onMount } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
import { Project } from "@opencode-ai/sdk/v2"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
import { persisted } from "@/utils/persist"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
export type AvatarColorKey = (typeof AVATAR_COLOR_KEYS)[number]
@@ -25,16 +23,26 @@ export function getAvatarColors(key?: string) {
}
}
function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
type SessionTabs = {
active?: string
all: string[]
}
type SessionScroll = {
x: number
y: number
}
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
terminalOpened?: boolean
reviewPanelOpened?: boolean
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
@@ -47,39 +55,19 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
const globalSdk = useGlobalSDK()
const globalSync = useGlobalSync()
const server = useServer()
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const migrate = (value: unknown) => {
if (!isRecord(value)) return value
const sidebar = value.sidebar
if (!isRecord(sidebar)) return value
if (typeof sidebar.workspaces !== "boolean") return value
return {
...value,
sidebar: {
...sidebar,
workspaces: {},
workspacesDefault: sidebar.workspaces,
},
}
}
const target = Persist.global("layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted(
{ ...target, migrate },
"layout.v6",
createStore({
sidebar: {
opened: false,
width: 280,
workspaces: {} as Record<string, boolean>,
workspacesDefault: false,
},
terminal: {
opened: false,
height: 280,
},
review: {
opened: true,
diffStyle: "split" as ReviewDiffStyle,
},
session: {
@@ -93,121 +81,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}),
)
const MAX_SESSION_KEYS = 50
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>()
const SESSION_STATE_KEYS = [
{ key: "prompt", legacy: "prompt", version: "v2" },
{ key: "terminal", legacy: "terminal", version: "v1" },
{ key: "file-view", legacy: "file", version: "v1" },
] as const
const dropSessionState = (keys: string[]) => {
for (const key of keys) {
const parts = key.split("/")
const dir = parts[0]
const session = parts[1]
if (!dir) continue
for (const entry of SESSION_STATE_KEYS) {
const target = session ? Persist.session(dir, session, entry.key) : Persist.workspace(dir, entry.key)
void removePersisted(target)
const legacyKey = `${dir}/${entry.legacy}${session ? "/" + session : ""}.${entry.version}`
void removePersisted({ key: legacyKey })
}
}
}
function prune(keep?: string) {
if (!keep) return
const keys = new Set<string>()
for (const key of Object.keys(store.sessionView)) keys.add(key)
for (const key of Object.keys(store.sessionTabs)) keys.add(key)
if (keys.size <= MAX_SESSION_KEYS) return
const score = (key: string) => {
if (key === keep) return Number.MAX_SAFE_INTEGER
return used.get(key) ?? 0
}
const ordered = Array.from(keys).sort((a, b) => score(b) - score(a))
const drop = ordered.slice(MAX_SESSION_KEYS)
if (drop.length === 0) return
setStore(
produce((draft) => {
for (const key of drop) {
delete draft.sessionView[key]
delete draft.sessionTabs[key]
}
}),
)
scroll.drop(drop)
dropSessionState(drop)
for (const key of drop) {
used.delete(key)
}
}
function touch(sessionKey: string) {
meta.active = sessionKey
used.set(sessionKey, Date.now())
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
prune(sessionKey)
}
const scroll = createScrollPersistence({
debounceMs: 250,
getSnapshot: (sessionKey) => store.sessionView[sessionKey]?.scroll,
onFlush: (sessionKey, next) => {
const current = store.sessionView[sessionKey]
const keep = meta.active ?? sessionKey
if (!current) {
setStore("sessionView", sessionKey, { scroll: next, terminalOpened: false, reviewPanelOpened: true })
prune(keep)
return
}
setStore("sessionView", sessionKey, "scroll", (prev) => ({ ...(prev ?? {}), ...next }))
prune(keep)
},
})
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
const active = meta.active
if (!active) return
meta.pruned = true
prune(active)
})
onMount(() => {
const flush = () => batch(() => scroll.flushAll())
const handleVisibility = () => {
if (document.visibilityState !== "hidden") return
flush()
}
window.addEventListener("pagehide", flush)
document.addEventListener("visibilitychange", handleVisibility)
onCleanup(() => {
window.removeEventListener("pagehide", flush)
document.removeEventListener("visibilitychange", handleVisibility)
scroll.dispose()
})
})
const usedColors = new Set<AvatarColorKey>()
function pickAvailableColor(): AvatarColorKey {
@@ -217,15 +90,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = globalSync.child(project.worktree)
const projectID = childStore.project
const metadata = projectID
? globalSync.data.project.find((x) => x.id === projectID)
: globalSync.data.project.find((x) => x.worktree === project.worktree)
const metadata = globalSync.data.project.find((x) => x.worktree === project.worktree)
return [
{
...(metadata ?? {}),
...project,
...(metadata ?? {}),
icon: { url: metadata?.icon?.url, color: metadata?.icon?.color },
},
]
@@ -242,41 +111,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return project
}
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of globalSync.data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
}
}
return map
})
createEffect(() => {
const map = roots()
if (map.size === 0) return
const projects = server.projects.list()
const seen = new Set(projects.map((project) => project.worktree))
batch(() => {
for (const project of projects) {
const root = map.get(project.worktree)
if (!root) continue
server.projects.close(project.worktree)
if (!seen.has(root)) {
server.projects.open(root)
seen.add(root)
}
if (project.expanded) server.projects.expand(root)
}
})
})
const enriched = createMemo(() => server.projects.list().flatMap(enrich))
const list = createMemo(() => enriched().flatMap(colorize))
@@ -293,10 +127,11 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
projects: {
list,
open(directory: string) {
const root = roots().get(directory) ?? directory
if (server.projects.list().find((x) => x.worktree === root)) return
globalSync.project.loadSessions(root)
server.projects.open(root)
if (server.projects.list().find((x) => x.worktree === directory)) {
return
}
globalSync.project.loadSessions(directory)
server.projects.open(directory)
},
close(directory: string) {
server.projects.close(directory)
@@ -326,32 +161,42 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
resize(width: number) {
setStore("sidebar", "width", width)
},
workspaces(directory: string) {
return createMemo(() => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false)
},
setWorkspaces(directory: string, value: boolean) {
setStore("sidebar", "workspaces", directory, value)
},
toggleWorkspaces(directory: string) {
const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
setStore("sidebar", "workspaces", directory, !current)
},
},
terminal: {
opened: createMemo(() => store.terminal.opened),
open() {
setStore("terminal", "opened", true)
},
close() {
setStore("terminal", "opened", false)
},
toggle() {
setStore("terminal", "opened", (x) => !x)
},
height: createMemo(() => store.terminal.height),
resize(height: number) {
setStore("terminal", "height", height)
},
},
review: {
opened: createMemo(() => store.review?.opened ?? true),
diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
setDiffStyle(diffStyle: ReviewDiffStyle) {
if (!store.review) {
setStore("review", { diffStyle })
setStore("review", { opened: true, diffStyle })
return
}
setStore("review", "diffStyle", diffStyle)
},
open() {
setStore("review", "opened", true)
},
close() {
setStore("review", "opened", false)
},
toggle() {
setStore("review", "opened", (x) => !x)
},
},
session: {
width: createMemo(() => store.session?.width ?? 600),
@@ -376,78 +221,28 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
},
view(sessionKey: string) {
touch(sessionKey)
scroll.seed(sessionKey)
const s = createMemo(() => store.sessionView[sessionKey] ?? { scroll: {} })
const terminalOpened = createMemo(() => s().terminalOpened ?? false)
const reviewPanelOpened = createMemo(() => s().reviewPanelOpened ?? true)
function setTerminalOpened(next: boolean) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: next, reviewPanelOpened: true })
return
}
const value = current.terminalOpened ?? false
if (value === next) return
setStore("sessionView", sessionKey, "terminalOpened", next)
}
function setReviewPanelOpened(next: boolean) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: {}, terminalOpened: false, reviewPanelOpened: next })
return
}
const value = current.reviewPanelOpened ?? true
if (value === next) return
setStore("sessionView", sessionKey, "reviewPanelOpened", next)
}
return {
scroll(tab: string) {
return scroll.scroll(sessionKey, tab)
return s().scroll?.[tab]
},
setScroll(tab: string, pos: SessionScroll) {
scroll.setScroll(sessionKey, tab, pos)
},
terminal: {
opened: terminalOpened,
open() {
setTerminalOpened(true)
},
close() {
setTerminalOpened(false)
},
toggle() {
setTerminalOpened(!terminalOpened())
},
},
reviewPanel: {
opened: reviewPanelOpened,
open() {
setReviewPanelOpened(true)
},
close() {
setReviewPanelOpened(false)
},
toggle() {
setReviewPanelOpened(!reviewPanelOpened())
},
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, { scroll: { [tab]: pos } })
return
}
const prev = current.scroll?.[tab]
if (prev?.x === pos.x && prev?.y === pos.y) return
setStore("sessionView", sessionKey, "scroll", tab, pos)
},
review: {
open: createMemo(() => s().reviewOpen),
setOpen(open: string[]) {
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, {
scroll: {},
terminalOpened: false,
reviewPanelOpened: true,
reviewOpen: open,
})
setStore("sessionView", sessionKey, { scroll: {}, reviewOpen: open })
return
}
@@ -458,7 +253,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
},
tabs(sessionKey: string) {
touch(sessionKey)
const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
return {
tabs,
+10 -24
View File
@@ -1,5 +1,5 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createMemo, onCleanup } from "solid-js"
import { batch, createMemo } from "solid-js"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -8,7 +8,7 @@ import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { useProviders } from "@/hooks/use-providers"
import { DateTime } from "luxon"
import { Persist, persisted } from "@/utils/persist"
import { persisted } from "@/utils/persist"
import { showToast } from "@opencode-ai/ui/toast"
export type LocalFile = FileNode &
@@ -111,7 +111,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const model = (() => {
const [store, setStore, _, modelReady] = persisted(
Persist.global("model", ["model.v1"]),
"model.v1",
createStore<{
user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
recent: ModelKey[]
@@ -160,16 +160,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
),
)
const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`)))
const userVisibilityMap = createMemo(() => {
const map = new Map<string, "show" | "hide">()
for (const item of store.user) {
map.set(`${item.providerID}:${item.modelID}`, item.visibility)
}
return map
})
const list = createMemo(() =>
available().map((m) => ({
...m,
@@ -274,15 +264,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
},
visible(model: ModelKey) {
const key = `${model.providerID}:${model.modelID}`
const visibility = userVisibilityMap().get(key)
if (visibility === "hide") return false
if (visibility === "show") return true
if (latestSet().has(key)) return true
// For models without valid release_date (e.g. custom models), show by default
const m = find(model)
if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true
return false
const user = store.user.find((x) => x.modelID === model.modelID && x.providerID === model.providerID)
return (
user?.visibility !== "hide" &&
(latest().find((x) => x.modelID === model.modelID && x.providerID === model.providerID) ||
user?.visibility === "show")
)
},
setVisibility(model: ModelKey, visible: boolean) {
updateVisibility(model, visible ? "show" : "hide")
@@ -471,7 +458,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const searchFilesAndDirectories = (query: string) =>
sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
const unsub = sdk.event.listen((e) => {
sdk.event.listen((e) => {
const event = e.details
switch (event.type) {
case "file.watcher.updated":
@@ -481,7 +468,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
break
}
})
onCleanup(unsub)
return {
node: async (path: string) => {
+5 -30
View File
@@ -1,5 +1,4 @@
import { createStore } from "solid-js/store"
import { createEffect, onCleanup } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useGlobalSDK } from "./global-sdk"
import { useGlobalSync } from "./global-sync"
@@ -10,7 +9,7 @@ import { EventSessionError } from "@opencode-ai/sdk/v2"
import { makeAudioPlayer } from "@solid-primitives/audio"
import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
import { Persist, persisted } from "@/utils/persist"
import { persisted } from "@/utils/persist"
type NotificationBase = {
directory?: string
@@ -31,16 +30,6 @@ type ErrorNotification = NotificationBase & {
export type Notification = TurnCompleteNotification | ErrorNotification
const MAX_NOTIFICATIONS = 500
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
function pruneNotifications(list: Notification[]) {
const cutoff = Date.now() - NOTIFICATION_TTL_MS
const pruned = list.filter((n) => n.time >= cutoff)
if (pruned.length <= MAX_NOTIFICATIONS) return pruned
return pruned.slice(pruned.length - MAX_NOTIFICATIONS)
}
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
init: () => {
@@ -59,26 +48,13 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
Persist.global("notification", ["notification.v1"]),
"notification.v1",
createStore({
list: [] as Notification[],
}),
)
const meta = { pruned: false }
createEffect(() => {
if (!ready()) return
if (meta.pruned) return
meta.pruned = true
setStore("list", pruneNotifications(store.list))
})
const append = (notification: Notification) => {
setStore("list", (list) => pruneNotifications([...list, notification]))
}
const unsub = globalSDK.event.listen((e) => {
globalSDK.event.listen((e) => {
const directory = e.name
const event = e.details
const base = {
@@ -96,7 +72,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
try {
idlePlayer?.play()
} catch {}
append({
setStore("list", store.list.length, {
...base,
type: "turn-complete",
session: sessionID,
@@ -115,7 +91,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
errorPlayer?.play()
} catch {}
const error = "error" in event.properties ? event.properties.error : undefined
append({
setStore("list", store.list.length, {
...base,
type: "error",
session: sessionID ?? "global",
@@ -128,7 +104,6 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
}
}
})
onCleanup(unsub)
return {
ready,
+20 -65
View File
@@ -1,12 +1,12 @@
import { createMemo, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
import { Persist, persisted } from "@/utils/persist"
import { persisted } from "@/utils/persist"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "./global-sync"
import { useParams } from "@solidjs/router"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { base64Decode } from "@opencode-ai/util/encode"
type PermissionRespondFn = (input: {
sessionID: string
@@ -19,32 +19,6 @@ function shouldAutoAccept(perm: PermissionRequest) {
return perm.permission === "edit"
}
function isNonAllowRule(rule: unknown) {
if (!rule) return false
if (typeof rule === "string") return rule !== "allow"
if (typeof rule !== "object") return false
if (Array.isArray(rule)) return false
for (const action of Object.values(rule)) {
if (action !== "allow") return true
}
return false
}
function hasAutoAcceptPermissionConfig(permission: unknown) {
if (!permission) return false
if (typeof permission === "string") return permission !== "allow"
if (typeof permission !== "object") return false
if (Array.isArray(permission)) return false
const config = permission as Record<string, unknown>
if (isNonAllowRule(config.edit)) return true
if (isNonAllowRule(config.write)) return true
return false
}
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
init: () => {
@@ -53,14 +27,13 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
const globalSync = useGlobalSync()
const permissionsEnabled = createMemo(() => {
const directory = params.dir ? base64Decode(params.dir) : undefined
if (!directory) return false
const [store] = globalSync.child(directory)
return hasAutoAcceptPermissionConfig(store.config.permission)
if (!params.dir || !base64Decode(params.dir)) return false
const [store] = globalSync.child(base64Decode(params.dir))
return store.config.permission !== undefined
})
const [store, setStore, _, ready] = persisted(
Persist.global("permission", ["permission.v3"]),
"permission.v3",
createStore({
autoAcceptEdits: {} as Record<string, boolean>,
}),
@@ -85,14 +58,8 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
})
}
function acceptKey(sessionID: string, directory?: string) {
if (!directory) return sessionID
return `${base64Encode(directory)}/${sessionID}`
}
function isAutoAccepting(sessionID: string, directory?: string) {
const key = acceptKey(sessionID, directory)
return store.autoAcceptEdits[key] ?? store.autoAcceptEdits[sessionID] ?? false
function isAutoAccepting(sessionID: string) {
return store.autoAcceptEdits[sessionID] ?? false
}
const unsubscribe = globalSDK.event.listen((e) => {
@@ -100,7 +67,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
if (event?.type !== "permission.asked") return
const perm = event.properties
if (!isAutoAccepting(perm.sessionID, e.name)) return
if (!isAutoAccepting(perm.sessionID)) return
if (!shouldAutoAccept(perm)) return
respondOnce(perm, e.name)
@@ -108,13 +75,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
onCleanup(unsubscribe)
function enable(sessionID: string, directory: string) {
const key = acceptKey(sessionID, directory)
setStore(
produce((draft) => {
draft.autoAcceptEdits[key] = true
delete draft.autoAcceptEdits[sessionID]
}),
)
setStore("autoAcceptEdits", sessionID, true)
globalSDK.client.permission
.list({ directory })
@@ -129,37 +90,31 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple
.catch(() => undefined)
}
function disable(sessionID: string, directory?: string) {
const key = directory ? acceptKey(sessionID, directory) : undefined
setStore(
produce((draft) => {
if (key) delete draft.autoAcceptEdits[key]
delete draft.autoAcceptEdits[sessionID]
}),
)
function disable(sessionID: string) {
setStore("autoAcceptEdits", sessionID, false)
}
return {
ready,
respond,
autoResponds(permission: PermissionRequest, directory?: string) {
return isAutoAccepting(permission.sessionID, directory) && shouldAutoAccept(permission)
autoResponds(permission: PermissionRequest) {
return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
},
isAutoAccepting,
toggleAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) {
disable(sessionID, directory)
if (isAutoAccepting(sessionID)) {
disable(sessionID)
return
}
enable(sessionID, directory)
},
enableAutoAccept(sessionID: string, directory: string) {
if (isAutoAccepting(sessionID, directory)) return
if (isAutoAccepting(sessionID)) return
enable(sessionID, directory)
},
disableAutoAccept(sessionID: string, directory?: string) {
disable(sessionID, directory)
disableAutoAccept(sessionID: string) {
disable(sessionID)
},
permissionsEnabled,
}
+1 -10
View File
@@ -3,10 +3,7 @@ import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
export type Platform = {
/** Platform discriminator */
platform: "web" | "desktop"
/** Desktop OS (Tauri only) */
os?: "macos" | "windows" | "linux"
platform: "web" | "tauri"
/** App version */
version?: string
@@ -40,12 +37,6 @@ export type Platform = {
/** Fetch override */
fetch?: typeof fetch
/** Get the configured default server URL (desktop only) */
getDefaultServerUrl?(): Promise<string | null>
/** Set the default server URL to use on app startup (desktop only) */
setDefaultServerUrl?(url: string | null): Promise<void>
}
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
+60 -132
View File
@@ -1,9 +1,9 @@
import { createStore } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import type { FileSelection } from "@/context/file"
import { Persist, persisted } from "@/utils/persist"
import { persisted } from "@/utils/persist"
interface PartBase {
content: string
@@ -99,146 +99,74 @@ function clonePrompt(prompt: Prompt): Prompt {
return prompt.map(clonePart)
}
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
type PromptSession = ReturnType<typeof createPromptSession>
type PromptCacheEntry = {
value: PromptSession
dispose: VoidFunction
}
function createPromptSession(dir: string, id: string | undefined) {
const legacy = `${dir}/prompt${id ? "/" + id : ""}.v2`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "prompt", [legacy]),
createStore<{
prompt: Prompt
cursor?: number
context: {
activeTab: boolean
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
activeTab: true,
items: [],
},
}),
)
function keyForItem(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
return `${item.type}:${item.path}:${start}:${end}`
}
return {
ready,
current: createMemo(() => store.prompt),
cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
context: {
activeTab: createMemo(() => store.context.activeTab),
items: createMemo(() => store.context.items),
addActive() {
setStore("context", "activeTab", true)
},
removeActive() {
setStore("context", "activeTab", false)
},
add(item: ContextItem) {
const key = keyForItem(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
},
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
}
}
export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
name: "Prompt",
gate: false,
init: () => {
const params = useParams()
const cache = new Map<string, PromptCacheEntry>()
const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v2`)
const disposeAll = () => {
for (const entry of cache.values()) {
entry.dispose()
}
cache.clear()
const [store, setStore, _, ready] = persisted(
name(),
createStore<{
prompt: Prompt
cursor?: number
context: {
activeTab: boolean
items: (ContextItem & { key: string })[]
}
}>({
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
activeTab: true,
items: [],
},
}),
)
function keyForItem(item: ContextItem) {
if (item.type !== "file") return item.type
const start = item.selection?.startLine
const end = item.selection?.endLine
return `${item.type}:${item.path}:${start}:${end}`
}
onCleanup(disposeAll)
const prune = () => {
while (cache.size > MAX_PROMPT_SESSIONS) {
const first = cache.keys().next().value
if (!first) return
const entry = cache.get(first)
entry?.dispose()
cache.delete(first)
}
}
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createPromptSession(dir, id),
dispose,
}))
cache.set(key, entry)
prune()
return entry.value
}
const session = createMemo(() => load(params.dir!, params.id))
return {
ready: () => session().ready(),
current: () => session().current(),
cursor: () => session().cursor(),
dirty: () => session().dirty(),
ready,
current: createMemo(() => store.prompt),
cursor: createMemo(() => store.cursor),
dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
context: {
activeTab: () => session().context.activeTab(),
items: () => session().context.items(),
addActive: () => session().context.addActive(),
removeActive: () => session().context.removeActive(),
add: (item: ContextItem) => session().context.add(item),
remove: (key: string) => session().context.remove(key),
activeTab: createMemo(() => store.context.activeTab),
items: createMemo(() => store.context.items),
addActive() {
setStore("context", "activeTab", true)
},
removeActive() {
setStore("context", "activeTab", false)
},
add(item: ContextItem) {
const key = keyForItem(item)
if (store.context.items.find((x) => x.key === key)) return
setStore("context", "items", (items) => [...items, { key, ...item }])
},
remove(key: string) {
setStore("context", "items", (items) => items.filter((x) => x.key !== key))
},
},
set(prompt: Prompt, cursorPosition?: number) {
const next = clonePrompt(prompt)
batch(() => {
setStore("prompt", next)
if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
})
},
reset() {
batch(() => {
setStore("prompt", clonePrompt(DEFAULT_PROMPT))
setStore("cursor", 0)
})
},
set: (prompt: Prompt, cursorPosition?: number) => session().set(prompt, cursorPosition),
reset: () => session().reset(),
}
},
})
+1 -3
View File
@@ -1,7 +1,6 @@
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 { onCleanup } from "solid-js"
import { useGlobalSDK } from "./global-sdk"
import { usePlatform } from "./platform"
@@ -21,10 +20,9 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
[key in Event["type"]]: Extract<Event, { type: key }>
}>()
const unsub = globalSDK.event.on(props.directory, (event) => {
globalSDK.event.on(props.directory, async (event) => {
emitter.emit(event.type, event)
})
onCleanup(unsub)
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
},
+27 -45
View File
@@ -1,9 +1,9 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { usePlatform } from "@/context/platform"
import { Persist, persisted } from "@/utils/persist"
import { persisted } from "@/utils/persist"
type StoredProject = { worktree: string; expanded: boolean }
@@ -11,12 +11,16 @@ export function normalizeServerUrl(input: string) {
const trimmed = input.trim()
if (!trimmed) return
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
return withProtocol.replace(/\/+$/, "")
const cleaned = withProtocol.replace(/\/+$/, "")
return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1")
}
export function serverDisplayName(url: string) {
if (!url) return ""
return url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
return url
.replace(/^https?:\/\//, "")
.replace(/\/+$/, "")
.split("/")[0]
}
function projectsKey(url: string) {
@@ -32,7 +36,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const platform = usePlatform()
const [store, setStore, _, ready] = persisted(
Persist.global("server", ["server.v3"]),
"server.v3",
createStore({
list: [] as string[],
projects: {} as Record<string, StoredProject[]>,
@@ -88,49 +92,27 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const isReady = createMemo(() => ready() && !!active())
const [healthy, setHealthy] = createSignal<boolean | undefined>(undefined)
const [healthy, { refetch }] = createResource(
() => active() || undefined,
async (url) => {
if (!url) return
const check = (url: string) => {
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal: AbortSignal.timeout(3000),
})
return sdk.global
.health()
.then((x) => x.data?.healthy === true)
.catch(() => false)
}
const sdk = createOpencodeClient({
baseUrl: url,
fetch: platform.fetch,
signal: AbortSignal.timeout(3000),
})
return sdk.global
.health()
.then((x) => x.data?.healthy === true)
.catch(() => false)
},
)
createEffect(() => {
const url = active()
if (!url) return
setHealthy(undefined)
let alive = true
let busy = false
const run = () => {
if (busy) return
busy = true
void check(url)
.then((next) => {
if (!alive) return
setHealthy(next)
})
.finally(() => {
busy = false
})
}
run()
const interval = setInterval(run, 10_000)
onCleanup(() => {
alive = false
clearInterval(interval)
})
if (!active()) return
const interval = setInterval(() => refetch(), 10_000)
onCleanup(() => clearInterval(interval))
})
const origin = createMemo(() => projectsKey(active()))
+54 -160
View File
@@ -1,5 +1,5 @@
import { batch, createMemo } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { createSimpleContext } from "@opencode-ai/ui/context"
@@ -14,76 +14,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
const sdk = useSDK()
const [store, setStore] = globalSync.child(sdk.directory)
const absolute = (path: string) => (store.path.directory + "/" + path).replace("//", "/")
const chunk = 400
const inflight = new Map<string, Promise<void>>()
const inflightDiff = new Map<string, Promise<void>>()
const inflightTodo = new Map<string, Promise<void>>()
const [meta, setMeta] = createStore({
limit: {} as Record<string, number>,
complete: {} as Record<string, boolean>,
loading: {} as Record<string, boolean>,
})
const getSession = (sessionID: string) => {
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
}
const limitFor = (count: number) => {
if (count <= chunk) return chunk
return Math.ceil(count / chunk) * chunk
}
const hydrateMessages = (sessionID: string) => {
if (meta.limit[sessionID] !== undefined) return
const messages = store.message[sessionID]
if (!messages) return
const limit = limitFor(messages.length)
setMeta("limit", sessionID, limit)
setMeta("complete", sessionID, messages.length < limit)
}
const loadMessages = async (sessionID: string, limit: number) => {
if (meta.loading[sessionID]) return
setMeta("loading", sessionID, true)
await retry(() => sdk.client.session.messages({ sessionID, limit }))
.then((messages) => {
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
batch(() => {
setStore("message", sessionID, reconcile(next, { key: "id" }))
for (const message of items) {
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
setMeta("limit", sessionID, limit)
setMeta("complete", sessionID, next.length < limit)
})
})
.finally(() => {
setMeta("loading", sessionID, false)
})
}
return {
data: store,
@@ -100,7 +30,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
return undefined
},
session: {
get: getSession,
get(sessionID: string) {
const match = Binary.search(store.session, sessionID, (s) => s.id)
if (match.found) return store.session[match.index]
return undefined
},
addOptimisticMessage(input: {
sessionID: string
messageID: string
@@ -132,98 +66,58 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
}),
)
},
async sync(sessionID: string) {
const hasSession = getSession(sessionID) !== undefined
hydrateMessages(sessionID)
async sync(sessionID: string, _isRetry = false) {
const [session, messages, todo, diff] = await Promise.all([
retry(() => sdk.client.session.get({ sessionID })),
retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
retry(() => sdk.client.session.todo({ sessionID })),
retry(() => sdk.client.session.diff({ sessionID })),
])
const hasMessages = store.message[sessionID] !== undefined
if (hasSession && hasMessages) return
batch(() => {
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = session.data!
return
}
draft.splice(match.index, 0, session.data!)
}),
)
const pending = inflight.get(sessionID)
if (pending) return pending
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
setStore(
"message",
sessionID,
reconcile(
(messages.data ?? [])
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
const limit = meta.limit[sessionID] ?? chunk
for (const message of messages.data ?? []) {
if (!message?.info?.id) continue
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
const sessionReq = hasSession
? Promise.resolve()
: retry(() => sdk.client.session.get({ sessionID })).then((session) => {
const data = session.data
if (!data) return
setStore(
"session",
produce((draft) => {
const match = Binary.search(draft, sessionID, (s) => s.id)
if (match.found) {
draft[match.index] = data
return
}
draft.splice(match.index, 0, data)
}),
)
})
const messagesReq = hasMessages ? Promise.resolve() : loadMessages(sessionID, limit)
const promise = Promise.all([sessionReq, messagesReq])
.then(() => {})
.finally(() => {
inflight.delete(sessionID)
})
inflight.set(sessionID, promise)
return promise
},
async diff(sessionID: string) {
if (store.session_diff[sessionID] !== undefined) return
const pending = inflightDiff.get(sessionID)
if (pending) return pending
const promise = retry(() => sdk.client.session.diff({ sessionID }))
.then((diff) => {
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
})
.finally(() => {
inflightDiff.delete(sessionID)
})
inflightDiff.set(sessionID, promise)
return promise
},
async todo(sessionID: string) {
if (store.todo[sessionID] !== undefined) return
const pending = inflightTodo.get(sessionID)
if (pending) return pending
const promise = retry(() => sdk.client.session.todo({ sessionID }))
.then((todo) => {
setStore("todo", sessionID, reconcile(todo.data ?? [], { key: "id" }))
})
.finally(() => {
inflightTodo.delete(sessionID)
})
inflightTodo.set(sessionID, promise)
return promise
},
history: {
more(sessionID: string) {
if (store.message[sessionID] === undefined) return false
if (meta.limit[sessionID] === undefined) return false
if (meta.complete[sessionID]) return false
return true
},
loading(sessionID: string) {
return meta.loading[sessionID] ?? false
},
async loadMore(sessionID: string, count = chunk) {
if (meta.loading[sessionID]) return
if (meta.complete[sessionID]) return
const current = meta.limit[sessionID] ?? chunk
await loadMessages(sessionID, current + count)
},
setStore("session_diff", sessionID, reconcile(diff.data ?? [], { key: "file" }))
})
},
fetch: async (count = 10) => {
setStore("limit", (x) => x + count)
+96 -177
View File
@@ -1,202 +1,121 @@
import { createStore, produce } from "solid-js/store"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createMemo, createRoot, onCleanup } from "solid-js"
import { batch, createMemo } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import { Persist, persisted } from "@/utils/persist"
import { persisted } from "@/utils/persist"
export type LocalPTY = {
id: string
title: string
titleNumber: number
rows?: number
cols?: number
buffer?: string
scrollY?: number
}
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
type TerminalSession = ReturnType<typeof createTerminalSession>
type TerminalCacheEntry = {
value: TerminalSession
dispose: VoidFunction
}
function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, id: string | undefined) {
const legacy = `${dir}/terminal${id ? "/" + id : ""}.v1`
const [store, setStore, _, ready] = persisted(
Persist.scoped(dir, id, "terminal", [legacy]),
createStore<{
active?: string
all: LocalPTY[]
}>({
all: [],
}),
)
return {
ready,
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
const existingTitleNumbers = new Set(
store.all.map((pty) => {
const match = pty.titleNumber
return match
}),
)
let nextNumber = 1
while (existingTitleNumbers.has(nextNumber)) {
nextNumber++
}
sdk.client.pty
.create({ title: `Terminal ${nextNumber}` })
.then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
titleNumber: nextNumber,
},
])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((e) => {
console.error("Failed to update terminal", e)
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty
.create({
title: pty.title,
})
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)
},
async close(id: string) {
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const previous = store.all[Math.max(0, index - 1)]
setStore("active", previous?.id)
}
})
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"all",
produce((all) => {
all.splice(to, 0, all.splice(index, 1)[0])
}),
)
},
}
}
export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
name: "Terminal",
gate: false,
init: () => {
const sdk = useSDK()
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
const disposeAll = () => {
for (const entry of cache.values()) {
entry.dispose()
}
cache.clear()
}
onCleanup(disposeAll)
const prune = () => {
while (cache.size > MAX_TERMINAL_SESSIONS) {
const first = cache.keys().next().value
if (!first) return
const entry = cache.get(first)
entry?.dispose()
cache.delete(first)
}
}
const load = (dir: string, id: string | undefined) => {
const key = `${dir}:${id ?? WORKSPACE_KEY}`
const existing = cache.get(key)
if (existing) {
cache.delete(key)
cache.set(key, existing)
return existing.value
}
const entry = createRoot((dispose) => ({
value: createTerminalSession(sdk, dir, id),
dispose,
}))
cache.set(key, entry)
prune()
return entry.value
}
const session = createMemo(() => load(params.dir!, params.id))
const [store, setStore, _, ready] = persisted(
name(),
createStore<{
active?: string
all: LocalPTY[]
}>({
all: [],
}),
)
return {
ready: () => session().ready(),
all: () => session().all(),
active: () => session().active(),
new: () => session().new(),
update: (pty: Partial<LocalPTY> & { id: string }) => session().update(pty),
clone: (id: string) => session().clone(id),
open: (id: string) => session().open(id),
close: (id: string) => session().close(id),
move: (id: string, to: number) => session().move(id, to),
ready,
all: createMemo(() => Object.values(store.all)),
active: createMemo(() => store.active),
new() {
sdk.client.pty
.create({ title: `Terminal ${store.all.length + 1}` })
.then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("all", [
...store.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("active", id)
})
.catch((e) => {
console.error("Failed to create terminal", e)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
sdk.client.pty
.update({
ptyID: pty.id,
title: pty.title,
size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
})
.catch((e) => {
console.error("Failed to update terminal", e)
})
},
async clone(id: string) {
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
const clone = await sdk.client.pty
.create({
title: pty.title,
})
.catch((e) => {
console.error("Failed to clone terminal", e)
return undefined
})
if (!clone?.data) return
setStore("all", index, {
...pty,
...clone.data,
})
if (store.active === pty.id) {
setStore("active", clone.data.id)
}
},
open(id: string) {
setStore("active", id)
},
async close(id: string) {
batch(() => {
setStore(
"all",
store.all.filter((x) => x.id !== id),
)
if (store.active === id) {
const index = store.all.findIndex((f) => f.id === id)
const previous = store.all[Math.max(0, index - 1)]
setStore("active", previous?.id)
}
})
await sdk.client.pty.remove({ ptyID: id }).catch((e) => {
console.error("Failed to close terminal", e)
})
},
move(id: string, to: number) {
const index = store.all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"all",
produce((all) => {
all.splice(to, 0, all.splice(index, 1)[0])
}),
)
},
}
},
})
+2 -4
View File
@@ -1,6 +1,6 @@
// @refresh reload
import { render } from "solid-js/web"
import { AppBaseProviders, AppInterface } from "@/app"
import { App } from "@/app"
import { Platform, PlatformProvider } from "@/context/platform"
import pkg from "../package.json"
@@ -55,9 +55,7 @@ const platform: Platform = {
render(
() => (
<PlatformProvider value={platform}>
<AppBaseProviders>
<AppInterface />
</AppBaseProviders>
<App />
</PlatformProvider>
),
root!,
-4
View File
@@ -5,7 +5,3 @@
cursor: default;
}
}
*[data-tauri-drag-region] {
app-region: drag;
}
+1 -1
View File
@@ -1,2 +1,2 @@
export { PlatformProvider, type Platform } from "./context/platform"
export { AppBaseProviders, AppInterface } from "./app"
export { App } from "./app"
+2 -20
View File
@@ -1,5 +1,5 @@
import { createMemo, Show, type ParentProps } from "solid-js"
import { useNavigate, useParams } from "@solidjs/router"
import { useParams } from "@solidjs/router"
import { SDKProvider, useSDK } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
@@ -7,11 +7,9 @@ import { LocalProvider } from "@/context/local"
import { base64Decode } from "@opencode-ai/util/encode"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"
import type { QuestionAnswer } from "@opencode-ai/sdk/v2"
export default function Layout(props: ParentProps) {
const params = useParams()
const navigate = useNavigate()
const directory = createMemo(() => {
return base64Decode(params.dir!)
})
@@ -28,24 +26,8 @@ export default function Layout(props: ParentProps) {
response: "once" | "always" | "reject"
}) => sdk.client.permission.respond(input)
const replyToQuestion = (input: { requestID: string; answers: QuestionAnswer[] }) =>
sdk.client.question.reply(input)
const rejectQuestion = (input: { requestID: string }) => sdk.client.question.reject(input)
const navigateToSession = (sessionID: string) => {
navigate(`/${params.dir}/session/${sessionID}`)
}
return (
<DataProvider
data={sync.data}
directory={directory()}
onPermissionRespond={respond}
onQuestionReply={replyToQuestion}
onQuestionReject={rejectQuestion}
onNavigateToSession={navigateToSession}
>
<DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
+2 -2
View File
@@ -53,8 +53,8 @@ export default function Home() {
}
return (
<div class="mx-auto mt-55 w-full md:w-auto px-4">
<Logo class="md:w-xl opacity-12" />
<div class="mx-auto mt-55">
<Logo class="w-xl opacity-12" />
<Button
size="large"
variant="ghost"
+472 -940
View File
@@ -1,5 +1,4 @@
import {
batch,
createEffect,
createMemo,
createSignal,
@@ -13,10 +12,10 @@ import {
untrack,
type JSX,
} from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { Persist, persisted } from "@/utils/persist"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { Avatar } from "@opencode-ai/ui/avatar"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
@@ -24,15 +23,15 @@ import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { HoverCard } from "@opencode-ai/ui/hover-card"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Mark } from "@opencode-ai/ui/logo"
import { getFilename } from "@opencode-ai/util/path"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Session } from "@opencode-ai/sdk/v2/client"
import { usePlatform } from "@/context/platform"
import { createStore, produce, reconcile } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import {
DragDropProvider,
DragDropSensors,
@@ -48,31 +47,29 @@ import { useGlobalSDK } from "@/context/global-sdk"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { Binary } from "@opencode-ai/util/binary"
import { retry } from "@opencode-ai/util/retry"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
import { DialogSelectProvider } from "@/components/dialog-select-provider"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { DialogSelectServer } from "@/components/dialog-select-server"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis } from "@/utils/solid-dnd"
import { navStart } from "@/utils/perf"
import { DialogSelectDirectory } from "@/components/dialog-select-directory"
import { DialogEditProject } from "@/components/dialog-edit-project"
import { Titlebar } from "@/components/titlebar"
import { useServer } from "@/context/server"
export default function Layout(props: ParentProps) {
const [store, setStore, , ready] = persisted(
Persist.global("layout", ["layout.v6"]),
createStore({
lastSession: {} as { [directory: string]: string },
activeProject: undefined as string | undefined,
activeWorkspace: undefined as string | undefined,
workspaceOrder: {} as Record<string, string[]>,
workspaceExpanded: {} as Record<string, boolean>,
}),
)
const [store, setStore] = createStore({
lastSession: {} as { [directory: string]: string },
activeDraggable: undefined as string | undefined,
mobileProjectsExpanded: {} as Record<string, boolean>,
})
const mobileProjects = {
expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false),
}
let scrollContainerRef: HTMLDivElement | undefined
const xlQuery = window.matchMedia("(min-width: 1280px)")
@@ -165,64 +162,53 @@ export default function Layout(props: ParentProps) {
})
onMount(() => {
const alerts = {
"permission.asked": {
title: "Permission required",
icon: "checklist" as const,
description: (sessionTitle: string, projectName: string) =>
`${sessionTitle} in ${projectName} needs permission`,
},
"question.asked": {
title: "Question",
icon: "bubble-5" as const,
description: (sessionTitle: string, projectName: string) => `${sessionTitle} in ${projectName} has a question`,
},
}
const toastBySession = new Map<string, number>()
const alertedAtBySession = new Map<string, number>()
const cooldownMs = 5000
const permissionAlertCooldownMs = 5000
const unsub = globalSDK.event.listen((e) => {
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
const config = alerts[e.details.type]
if (e.details?.type !== "permission.asked") return
const directory = e.name
const props = e.details.properties
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
const perm = e.details.properties
if (permission.autoResponds(perm)) return
const sessionKey = `${directory}:${perm.sessionID}`
const [store] = globalSync.child(directory)
const session = store.session.find((s) => s.id === props.sessionID)
const sessionKey = `${directory}:${props.sessionID}`
const session = store.session.find((s) => s.id === perm.sessionID)
const sessionTitle = session?.title ?? "New session"
const projectName = getFilename(directory)
const description = config.description(sessionTitle, projectName)
const href = `/${base64Encode(directory)}/session/${props.sessionID}`
const description = `${sessionTitle} in ${projectName} needs permission`
const href = `/${base64Encode(directory)}/session/${perm.sessionID}`
const now = Date.now()
const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
if (now - lastAlerted < cooldownMs) return
if (now - lastAlerted < permissionAlertCooldownMs) return
alertedAtBySession.set(sessionKey, now)
void platform.notify(config.title, description, href)
void platform.notify("Permission required", description, href)
const currentDir = params.dir ? base64Decode(params.dir) : undefined
const currentSession = params.id
if (directory === currentDir && props.sessionID === currentSession) return
if (directory === currentDir && perm.sessionID === currentSession) return
if (directory === currentDir && session?.parentID === currentSession) return
const existingToastId = toastBySession.get(sessionKey)
if (existingToastId !== undefined) toaster.dismiss(existingToastId)
if (existingToastId !== undefined) {
toaster.dismiss(existingToastId)
}
const toastId = showToast({
persistent: true,
icon: config.icon,
title: config.title,
icon: "checklist",
title: "Permission required",
description,
actions: [
{
label: "Go to session",
onClick: () => navigate(href),
onClick: () => {
navigate(href)
},
},
{
label: "Dismiss",
@@ -280,246 +266,61 @@ export default function Layout(props: ParentProps) {
}
}
const currentProject = createMemo(() => {
const directory = params.dir ? base64Decode(params.dir) : undefined
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const workspaceSetting = createMemo(() => {
const project = currentProject()
if (!project) return false
return layout.sidebar.workspaces(project.worktree)()
})
createEffect(() => {
if (!ready()) return
const project = currentProject()
if (!project) return
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const existing = store.workspaceOrder[project.worktree]
if (!existing) {
setStore("workspaceOrder", project.worktree, dirs)
return
}
const keep = existing.filter((d) => dirs.includes(d))
const missing = dirs.filter((d) => !existing.includes(d))
const merged = [...keep, ...missing]
if (merged.length !== existing.length) {
setStore("workspaceOrder", project.worktree, merged)
return
}
if (merged.some((d, i) => d !== existing[i])) {
setStore("workspaceOrder", project.worktree, merged)
}
})
function projectSessions(directory: string) {
if (!directory) return []
const sessions = globalSync.child(directory)[0].session.toSorted(sortSessions)
return (sessions ?? []).filter((s) => !s.parentID)
}
const currentSessions = createMemo(() => {
const project = currentProject()
if (!project) return [] as Session[]
if (workspaceSetting()) {
const dirs = workspaceIds(project)
const result: Session[] = []
for (const dir of dirs) {
const [dirStore] = globalSync.child(dir)
const dirSessions = dirStore.session
.filter((session) => session.directory === dirStore.path.directory)
.filter((session) => !session.parentID)
.toSorted(sortSessions)
result.push(...dirSessions)
}
return result
}
const [projectStore] = globalSync.child(project.worktree)
return projectStore.session
.filter((session) => session.directory === projectStore.path.directory)
.filter((session) => !session.parentID)
.toSorted(sortSessions)
})
type PrefetchQueue = {
inflight: Set<string>
pending: string[]
pendingSet: Set<string>
running: number
}
const prefetchChunk = 200
const prefetchConcurrency = 1
const prefetchPendingLimit = 6
const prefetchToken = { value: 0 }
const prefetchQueues = new Map<string, PrefetchQueue>()
createEffect(() => {
params.dir
globalSDK.url
prefetchToken.value += 1
for (const q of prefetchQueues.values()) {
q.pending.length = 0
q.pendingSet.clear()
}
})
const queueFor = (directory: string) => {
const existing = prefetchQueues.get(directory)
if (existing) return existing
const created: PrefetchQueue = {
inflight: new Set(),
pending: [],
pendingSet: new Set(),
running: 0,
}
prefetchQueues.set(directory, created)
return created
}
async function prefetchMessages(directory: string, sessionID: string, token: number) {
const [, setStore] = globalSync.child(directory)
return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items
.map((x) => x.info)
.filter((m) => !!m?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id))
batch(() => {
setStore("message", sessionID, reconcile(next, { key: "id" }))
for (const message of items) {
setStore(
"part",
message.info.id,
reconcile(
message.parts
.filter((p) => !!p?.id)
.slice()
.sort((a, b) => a.id.localeCompare(b.id)),
{ key: "id" },
),
)
}
})
})
.catch(() => undefined)
}
const pumpPrefetch = (directory: string) => {
const q = queueFor(directory)
if (q.running >= prefetchConcurrency) return
const sessionID = q.pending.shift()
if (!sessionID) return
q.pendingSet.delete(sessionID)
q.inflight.add(sessionID)
q.running += 1
const token = prefetchToken.value
void prefetchMessages(directory, sessionID, token).finally(() => {
q.running -= 1
q.inflight.delete(sessionID)
pumpPrefetch(directory)
})
}
const prefetchSession = (session: Session, priority: "high" | "low" = "low") => {
const directory = session.directory
if (!directory) return
const [store] = globalSync.child(directory)
if (store.message[session.id] !== undefined) return
const q = queueFor(directory)
if (q.inflight.has(session.id)) return
if (q.pendingSet.has(session.id)) return
if (priority === "high") q.pending.unshift(session.id)
if (priority !== "high") q.pending.push(session.id)
q.pendingSet.add(session.id)
while (q.pending.length > prefetchPendingLimit) {
const dropped = q.pending.pop()
if (!dropped) continue
q.pendingSet.delete(dropped)
}
pumpPrefetch(directory)
}
createEffect(() => {
const sessions = currentSessions()
const id = params.id
if (!id) {
const first = sessions[0]
if (first) prefetchSession(first)
const second = sessions[1]
if (second) prefetchSession(second)
return
}
const index = sessions.findIndex((s) => s.id === id)
if (index === -1) return
const next = sessions[index + 1]
if (next) prefetchSession(next)
const prev = sessions[index - 1]
if (prev) prefetchSession(prev)
if (!params.dir) return []
const directory = base64Decode(params.dir)
return projectSessions(directory)
})
function navigateSessionByOffset(offset: number) {
const sessions = currentSessions()
if (sessions.length === 0) return
const projects = layout.projects.list()
if (projects.length === 0) return
const currentDirectory = params.dir ? base64Decode(params.dir) : undefined
const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1
if (projectIndex === -1) {
const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
if (targetProject) navigateToProject(targetProject.worktree)
return
}
const sessions = currentSessions()
const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
let targetIndex: number
if (sessionIndex === -1) {
targetIndex = offset > 0 ? 0 : sessions.length - 1
} else {
targetIndex = (sessionIndex + offset + sessions.length) % sessions.length
targetIndex = sessionIndex + offset
}
const session = sessions[targetIndex]
if (!session) return
const next = sessions[(targetIndex + 1) % sessions.length]
const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length]
if (offset > 0) {
if (next) prefetchSession(next, "high")
if (prev) prefetchSession(prev)
if (targetIndex >= 0 && targetIndex < sessions.length) {
const session = sessions[targetIndex]
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id))
return
}
if (offset < 0) {
if (prev) prefetchSession(prev, "high")
if (next) prefetchSession(next)
const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1)
const nextProject = projects[nextProjectIndex]
if (!nextProject) return
const nextProjectSessions = projectSessions(nextProject.worktree)
if (nextProjectSessions.length === 0) {
navigateToProject(nextProject.worktree)
return
}
if (import.meta.env.DEV) {
navStart({
dir: base64Encode(session.directory),
from: params.id,
to: session.id,
trigger: offset > 0 ? "alt+arrowdown" : "alt+arrowup",
})
}
navigateToSession(session)
queueMicrotask(() => scrollToSession(session.id))
const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
queueMicrotask(() => scrollToSession(targetSession.id))
}
async function archiveSession(session: Session) {
@@ -664,7 +465,7 @@ export default function Layout(props: ParentProps) {
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
navigate(`/${params.dir}/session/${session?.id}`)
layout.mobileSidebar.hide()
}
@@ -713,32 +514,17 @@ export default function Layout(props: ParentProps) {
const id = params.id
setStore("lastSession", directory, id)
notification.session.markViewed(id)
untrack(() => setStore("workspaceExpanded", directory, (value) => value ?? true))
untrack(() => layout.projects.expand(directory))
requestAnimationFrame(() => scrollToSession(id))
})
createEffect(() => {
const project = currentProject()
if (!project) return
if (workspaceSetting()) {
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
for (const directory of dirs) {
globalSync.project.loadSessions(directory)
}
return
}
globalSync.project.loadSessions(project.worktree)
})
createEffect(() => {
if (isLargeViewport()) {
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 64
const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
return
} else {
document.documentElement.style.setProperty("--dialog-left-margin", "0px")
}
document.documentElement.style.setProperty("--dialog-left-margin", "0px")
})
function getDraggableId(event: unknown): string | undefined {
@@ -752,7 +538,7 @@ export default function Layout(props: ParentProps) {
function handleDragStart(event: unknown) {
const id = getDraggableId(event)
if (!id) return
setStore("activeProject", id)
setStore("activeDraggable", id)
}
function handleDragOver(event: DragEvent) {
@@ -768,77 +554,44 @@ export default function Layout(props: ParentProps) {
}
function handleDragEnd() {
setStore("activeProject", undefined)
setStore("activeDraggable", undefined)
}
function workspaceIds(project: LocalProject | undefined) {
if (!project) return []
const dirs = [project.worktree, ...(project.sandboxes ?? [])]
const existing = store.workspaceOrder[project.worktree]
if (!existing) return dirs
const keep = existing.filter((d) => dirs.includes(d))
const missing = dirs.filter((d) => !existing.includes(d))
return [...keep, ...missing]
}
function handleWorkspaceDragStart(event: unknown) {
const id = getDraggableId(event)
if (!id) return
setStore("activeWorkspace", id)
}
function handleWorkspaceDragOver(event: DragEvent) {
const { draggable, droppable } = event
if (!draggable || !droppable) return
const project = currentProject()
if (!project) return
const ids = workspaceIds(project)
const fromIndex = ids.findIndex((dir) => dir === draggable.id.toString())
const toIndex = ids.findIndex((dir) => dir === droppable.id.toString())
if (fromIndex === -1 || toIndex === -1) return
if (fromIndex === toIndex) return
const result = ids.slice()
const [item] = result.splice(fromIndex, 1)
if (!item) return
result.splice(toIndex, 0, item)
setStore("workspaceOrder", project.worktree, result)
}
function handleWorkspaceDragEnd() {
setStore("activeWorkspace", undefined)
}
const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
const ProjectAvatar = (props: {
project: LocalProject
class?: string
expandable?: boolean
notify?: boolean
}): JSX.Element => {
const notification = useNotification()
const notifications = createMemo(() => notification.project.unseen(props.project.worktree))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)"
const mask = "radial-gradient(circle 5px at calc(100% - 2px) 2px, transparent 5px, black 5.5px)"
const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
return (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
<div class="size-full rounded overflow-clip">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"
style={
notifications().length > 0 && props.notify
? { "-webkit-mask-image": mask, "mask-image": mask }
: undefined
}
<div class="relative size-5 shrink-0 rounded-sm">
<Avatar
fallback={name()}
src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.url}
{...getAvatarColors(props.project.icon?.color)}
class={`size-full ${props.class ?? ""}`}
style={
notifications().length > 0 && props.notify ? { "-webkit-mask-image": mask, "mask-image": mask } : undefined
}
/>
<Show when={props.expandable}>
<Icon
name="chevron-right"
size="normal"
class="hidden size-full items-center justify-center text-text-subtle group-hover/session:flex group-data-[expanded]/trigger:rotate-90 transition-transform duration-50"
/>
</div>
</Show>
<Show when={notifications().length > 0 && props.notify}>
<div
classList={{
"absolute -top-px -right-px size-2 rounded-full z-10": true,
"absolute -top-0.5 -right-0.5 size-1.5 rounded-full": true,
"bg-icon-critical-base": hasError(),
"bg-text-interactive-base": !hasError(),
}}
@@ -848,666 +601,452 @@ export default function Layout(props: ParentProps) {
)
}
const SessionItem = (props: { session: Session; slug: string; mobile?: boolean; dense?: boolean }): JSX.Element => {
const ProjectVisual = (props: { project: LocalProject; class?: string }): JSX.Element => {
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const current = createMemo(() => base64Decode(params.dir ?? ""))
return (
<Switch>
<Match when={layout.sidebar.opened()}>
<Button
as={"div"}
variant="ghost"
data-active
class="flex items-center justify-between gap-3 w-full px-1 self-stretch h-8 border-none rounded-lg"
>
<div class="flex items-center gap-3 p-0 text-left min-w-0 grow">
<ProjectAvatar project={props.project} />
<span class="truncate text-14-medium text-text-strong">{name()}</span>
</div>
</Button>
</Match>
<Match when={true}>
<Button
variant="ghost"
size="large"
class="flex items-center justify-center p-0 aspect-square border-none rounded-lg"
data-selected={props.project.worktree === current()}
onClick={() => navigateToProject(props.project.worktree)}
>
<ProjectAvatar project={props.project} notify />
</Button>
</Match>
</Switch>
)
}
const SessionItem = (props: {
session: Session
slug: string
project: LocalProject
mobile?: boolean
}): JSX.Element => {
const notification = useNotification()
const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
const notifications = createMemo(() => notification.session.unseen(props.session.id))
const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
const [sessionStore] = globalSync.child(props.session.directory)
const hasPermissions = createMemo(() => {
const permissions = sessionStore.permission?.[props.session.id] ?? []
const store = globalSync.child(props.project.worktree)[0]
const permissions = store.permission?.[props.session.id] ?? []
if (permissions.length > 0) return true
const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id)
const childSessions = store.session.filter((s) => s.parentID === props.session.id)
for (const child of childSessions) {
const childPermissions = sessionStore.permission?.[child.id] ?? []
const childPermissions = store.permission?.[child.id] ?? []
if (childPermissions.length > 0) return true
}
return false
})
const isWorking = createMemo(() => {
if (props.session.id === params.id) return false
if (hasPermissions()) return false
const status = sessionStore.session_status[props.session.id]
const status = globalSync.child(props.project.worktree)[0].session_status[props.session.id]
return status?.type === "busy" || status?.type === "retry"
})
const tint = createMemo(() => {
const messages = sessionStore.message[props.session.id]
if (!messages) return undefined
const user = messages
.slice()
.reverse()
.find((m) => m.role === "user")
if (!user?.agent) return undefined
const agent = sessionStore.agent.find((a) => a.name === user.agent)
return agent?.color
})
return (
<div
data-session-id={props.session.id}
class="group/session relative w-full rounded-md cursor-default transition-colors px-3
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-base-active"
>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={16} openDelay={1000}>
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onMouseEnter={() => prefetchSession(props.session, "high")}
onFocus={() => prefetchSession(props.session, "high")}
>
<div class="flex items-center gap-1 w-full">
<div
class="shrink-0 size-6 flex items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
</div>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<DiffChanges changes={summary()} />
</div>
)}
</Show>
</div>
</A>
</Tooltip>
<>
<div
class={`hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"}`}
data-session-id={props.session.id}
class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
style={{ "padding-left": "16px" }}
>
<TooltipKeybind
placement={props.mobile ? "bottom" : "right"}
title="Archive session"
keybind={command.keybind("session.archive")}
gutter={8}
>
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</TooltipKeybind>
<Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
<A
href={`${props.slug}/session/${props.session.id}`}
class="flex flex-col min-w-0 text-left w-full focus:outline-none"
>
<div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
<span
classList={{
"text-14-regular text-text-strong overflow-hidden text-ellipsis truncate": true,
"animate-pulse": isWorking(),
}}
>
{props.session.title}
</span>
<div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
<Switch>
<Match when={isWorking()}>
<Spinner class="size-2.5 mr-0.5" />
</Match>
<Match when={hasPermissions()}>
<div class="size-1.5 mr-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={notifications().length > 0}>
<div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
</Match>
<Match when={true}>
<span class="text-12-regular text-text-weak text-right whitespace-nowrap">
{Math.abs(updated().diffNow().as("seconds")) < 60
? "Now"
: updated()
.toRelative({
style: "short",
unit: ["days", "hours", "minutes"],
})
?.replace(" ago", "")
?.replace(/ days?/, "d")
?.replace(" min.", "m")
?.replace(" hr.", "h")}
</span>
</Match>
</Switch>
</div>
</div>
<Show when={props.session.summary?.files}>
<div class="flex justify-between items-center self-stretch">
<span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span>
<Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
</div>
</Show>
</A>
</Tooltip>
<div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
<TooltipKeybind
placement={props.mobile ? "bottom" : "right"}
title="Archive session"
keybind={command.keybind("session.archive")}
>
<IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
</TooltipKeybind>
</div>
</div>
</div>
)
}
const SessionSkeleton = (props: { count?: number }): JSX.Element => {
const items = Array.from({ length: props.count ?? 4 }, (_, index) => index)
return (
<div class="flex flex-col gap-1">
<For each={items}>
{() => <div class="h-8 w-full rounded-md bg-surface-raised-base opacity-60 animate-pulse" />}
</For>
</div>
</>
)
}
const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.project.worktree)
const selected = createMemo(() => {
const current = params.dir ? base64Decode(params.dir) : ""
return props.project.worktree === current || props.project.sandboxes?.includes(current)
})
const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2))
const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)())
const label = (directory: string) => {
const [data] = globalSync.child(directory)
const kind = directory === props.project.worktree ? "local" : "sandbox"
const name = data.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name}`
const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
const slug = createMemo(() => base64Encode(props.project.worktree))
const name = createMemo(() => props.project.name || getFilename(props.project.worktree))
const [store, setProjectStore] = globalSync.child(props.project.worktree)
const sessions = createMemo(() => store.session.toSorted(sortSessions))
const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
const hasMoreSessions = createMemo(() => store.session.length >= store.limit)
const loadMoreSessions = async () => {
setProjectStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.project.worktree)
}
const sessions = (directory: string) => {
const [data] = globalSync.child(directory)
return data.session
.filter((session) => session.directory === data.path.directory)
.filter((session) => !session.parentID)
.toSorted(sortSessions)
.slice(0, 2)
}
const projectSessions = () => {
const [data] = globalSync.child(props.project.worktree)
return data.session
.filter((session) => session.directory === data.path.directory)
.filter((session) => !session.parentID)
.toSorted(sortSessions)
.slice(0, 2)
}
const trigger = (
<button
type="button"
classList={{
"flex items-center justify-center size-10 p-1 rounded-lg border transition-colors cursor-default": true,
"bg-transparent border-icon-strong-base hover:bg-surface-base-hover": selected(),
"bg-transparent border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": !selected(),
}}
onClick={() => navigateToProject(props.project.worktree)}
>
<ProjectIcon project={props.project} notify />
</button>
const isExpanded = createMemo(() =>
props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
)
const handleOpenChange = (open: boolean) => {
if (props.mobile) {
if (open) mobileProjects.expand(props.project.worktree)
else mobileProjects.collapse(props.project.worktree)
} else {
if (open) layout.projects.expand(props.project.worktree)
else layout.projects.collapse(props.project.worktree)
}
}
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<HoverCard openDelay={0} closeDelay={0} placement="right-start" gutter={8} trigger={trigger}>
<div class="-m-3 flex flex-col w-72">
<div class="px-3 py-2 text-12-medium text-text-weak">Recent sessions</div>
<div class="px-2 pb-2 flex flex-col gap-2">
<Show
when={workspaceEnabled()}
fallback={
<For each={projectSessions()}>
<Switch>
<Match when={showExpanded()}>
<Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
<Button
as={"div"}
variant="ghost"
class="group/session flex items-center justify-between gap-3 w-full px-1.5 self-stretch h-auto border-none rounded-lg"
>
<Collapsible.Trigger class="group/trigger flex items-center gap-3 p-0 text-left min-w-0 grow border-none">
<ProjectAvatar
project={props.project}
class="group-hover/session:hidden"
expandable
notify={!isExpanded()}
/>
<span class="truncate text-14-medium text-text-strong">{name()}</span>
</Collapsible.Trigger>
<div class="flex invisible gap-1 items-center group-hover/session:visible has-[[data-expanded]]:visible">
<DropdownMenu>
<DropdownMenu.Trigger as={IconButton} icon="dot-grid" variant="ghost" />
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}
>
<DropdownMenu.ItemLabel>Edit project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => closeProject(props.project.worktree)}>
<DropdownMenu.ItemLabel>Close project</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
<IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
</TooltipKeybind>
</div>
</Button>
<Collapsible.Content>
<nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
<For each={rootSessions()}>
{(session) => (
<SessionItem
session={session}
slug={base64Encode(props.project.worktree)}
dense
mobile={props.mobile}
/>
<SessionItem session={session} slug={slug()} project={props.project} mobile={props.mobile} />
)}
</For>
}
>
<For each={workspaces()}>
{(directory) => (
<div class="flex flex-col gap-1">
<div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
<div class="shrink-0 size-6 flex items-center justify-center">
<Icon name="branch" size="small" class="text-icon-base" />
<Show when={rootSessions().length === 0}>
<div
class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
>
<div class="flex items-center self-stretch w-full">
<div class="flex-1 min-w-0">
<Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
<A
href={`${slug()}/session`}
class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
>
<div class="flex items-center self-stretch gap-6 justify-between">
<span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
New session
</span>
</div>
</A>
</Tooltip>
</div>
<span class="truncate text-14-medium text-text-base">{label(directory)}</span>
</div>
<For each={sessions(directory)}>
{(session) => (
<SessionItem session={session} slug={base64Encode(directory)} dense mobile={props.mobile} />
)}
</For>
</div>
)}
</For>
</Show>
</div>
<Show when={!selected()}>
<div class="px-2 py-2 border-t border-border-weak-base">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-text-base px-2"
onClick={() => {
layout.sidebar.open()
navigateToProject(props.project.worktree)
}}
>
View all sessions
</Button>
</div>
</Show>
</div>
</HoverCard>
</Show>
<Show when={hasMoreSessions()}>
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-12-medium opacity-50 px-3.5"
size="large"
onClick={loadMoreSessions}
>
Load more
</Button>
</div>
</Show>
</nav>
</Collapsible.Content>
</Collapsible>
</Match>
<Match when={true}>
<Tooltip placement="right" value={props.project.worktree}>
<ProjectVisual project={props.project} />
</Tooltip>
</Match>
</Switch>
</div>
)
}
const ProjectDragOverlay = (): JSX.Element => {
const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeProject))
const project = createMemo(() => layout.projects.list().find((p) => p.worktree === store.activeDraggable))
return (
<Show when={project()}>
{(p) => (
<div class="bg-background-base rounded-xl p-1">
<ProjectIcon project={p()} />
<div class="bg-background-base rounded-md">
<ProjectVisual project={p()} />
</div>
)}
</Show>
)
}
const WorkspaceDragOverlay = (): JSX.Element => {
const label = createMemo(() => {
const project = currentProject()
if (!project) return
const directory = store.activeWorkspace
if (!directory) return
const [workspaceStore] = globalSync.child(directory)
const kind = directory === project.worktree ? "local" : "sandbox"
const name = workspaceStore.vcs?.branch ?? getFilename(directory)
return `${kind} : ${name}`
})
return (
<Show when={label()}>
{(value) => (
<div class="bg-background-base rounded-md px-2 py-1 text-14-medium text-text-strong">{value()}</div>
)}
</Show>
)
}
const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => {
const sortable = createSortable(props.directory)
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory)
const slug = createMemo(() => base64Encode(props.directory))
const sessions = createMemo(() =>
workspaceStore.session
.filter((session) => session.directory === workspaceStore.path.directory)
.filter((session) => !session.parentID)
.toSorted(sortSessions),
)
const local = createMemo(() => props.directory === props.project.worktree)
const title = createMemo(() => {
const kind = local() ? "local" : "sandbox"
const name = workspaceStore.vcs?.branch ?? getFilename(props.directory)
return `${kind} : ${name}`
})
const open = createMemo(() => store.workspaceExpanded[props.directory] ?? true)
const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0)
const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length)
const loadMore = async () => {
if (!local()) return
setWorkspaceStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.directory)
}
return (
// @ts-ignore
<div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
<Collapsible
variant="ghost"
open={open()}
class="shrink-0"
onOpenChange={(value) => setStore("workspaceExpanded", props.directory, value)}
>
<div class="px-2 py-1">
<div class="group/trigger relative">
<Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
<div class="flex items-center gap-1 min-w-0">
<div class="flex items-center justify-center shrink-0 size-6">
<Icon name="branch" size="small" />
</div>
<span class="truncate text-14-medium text-text-base">{title()}</span>
<Icon
name={open() ? "chevron-down" : "chevron-right"}
size="small"
class="shrink-0 text-icon-base opacity-0 transition-opacity group-hover/trigger:opacity-100 group-focus-within/trigger:opacity-100"
/>
</div>
</Collapsible.Trigger>
<div class="absolute right-1 top-1/2 -translate-y-1/2 hidden items-center gap-0.5 pointer-events-none group-hover/trigger:flex group-focus-within/trigger:flex">
<IconButton icon="dot-grid" variant="ghost" class="size-6 rounded-md pointer-events-auto" />
<TooltipKeybind
class="pointer-events-auto"
placement="right"
title="New session"
keybind={command.keybind("session.new")}
>
<IconButton
icon="plus-small"
variant="ghost"
class="size-6 rounded-md"
onClick={() => navigate(`/${slug()}/session`)}
/>
</TooltipKeybind>
</div>
</div>
</div>
<Collapsible.Content>
<nav class="flex flex-col gap-1 px-2">
<Button
as={A}
href={`${slug()}/session`}
variant="ghost"
size="large"
icon="edit"
class="hidden _flex w-full text-left justify-start text-text-base rounded-md px-3"
>
New session
</Button>
<Show when={loading()}>
<SessionSkeleton />
</Show>
<For each={sessions()}>
{(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
</For>
<Show when={hasMore()}>
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak px-10"
size="large"
onClick={(e: MouseEvent) => {
loadMore()
;(e.currentTarget as HTMLButtonElement).blur()
}}
>
Load more
</Button>
</div>
</Show>
</nav>
</Collapsible.Content>
</Collapsible>
</div>
)
}
const LocalWorkspace = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
const [workspaceStore, setWorkspaceStore] = globalSync.child(props.project.worktree)
const slug = createMemo(() => base64Encode(props.project.worktree))
const sessions = createMemo(() =>
workspaceStore.session
.filter((session) => session.directory === workspaceStore.path.directory)
.filter((session) => !session.parentID)
.toSorted(sortSessions),
)
const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0)
const hasMore = createMemo(() => workspaceStore.sessionTotal > workspaceStore.session.length)
const loadMore = async () => {
setWorkspaceStore("limit", (limit) => limit + 5)
await globalSync.project.loadSessions(props.project.worktree)
}
return (
<div
ref={(el) => {
if (!props.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 overflow-y-auto no-scrollbar"
>
<nav class="flex flex-col gap-1 px-2">
<Show when={loading()}>
<SessionSkeleton />
</Show>
<For each={sessions()}>
{(session) => <SessionItem session={session} slug={slug()} mobile={props.mobile} />}
</For>
<Show when={hasMore()}>
<div class="relative w-full py-1">
<Button
variant="ghost"
class="flex w-full text-left justify-start text-14-regular text-text-weak px-10"
size="large"
onClick={(e: MouseEvent) => {
loadMore()
;(e.currentTarget as HTMLButtonElement).blur()
}}
>
Load more
</Button>
</div>
</Show>
</nav>
</div>
)
}
const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
const sync = useGlobalSync()
const project = createMemo(() => currentProject())
const projectName = createMemo(() => {
const current = project()
if (!current) return ""
return current.name || getFilename(current.worktree)
})
const workspaces = createMemo(() => workspaceIds(project()))
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return "Request failed"
}
const createWorkspace = async () => {
const current = project()
if (!current) return
const created = await globalSDK.client.worktree
.create({ directory: current.worktree })
.then((x) => x.data)
.catch((err) => {
showToast({
title: "Failed to create workspace",
description: errorMessage(err),
})
return undefined
})
if (!created?.directory) return
globalSync.child(created.directory)
navigate(`/${base64Encode(created.directory)}/session`)
}
const homedir = createMemo(() => sync.data.path.home)
return (
<div class="flex h-full w-full overflow-hidden">
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden">
<div class="flex-1 min-h-0 w-full">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
<>
<div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
<Show when={!sidebarProps.mobile}>
<A href="/" class="shrink-0 h-8 flex items-center justify-start px-2" data-tauri-drag-region>
<Mark class="shrink-0" />
</A>
</Show>
<Show when={!sidebarProps.mobile}>
<TooltipKeybind
class="shrink-0"
placement="right"
title="Toggle sidebar"
keybind={command.keybind("sidebar.toggle")}
inactive={expanded()}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div class="h-full w-full flex flex-col items-center gap-3 px-3 py-2 overflow-y-auto no-scrollbar">
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
<For each={layout.projects.list()}>
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
</For>
</SortableProvider>
<Tooltip
placement={sidebarProps.mobile ? "bottom" : "right"}
value={
<div class="flex items-center gap-2">
<span>Open project</span>
<Show when={!sidebarProps.mobile}>
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
</Show>
</div>
}
>
<IconButton icon="plus" variant="ghost" size="large" onClick={chooseProject} />
</Tooltip>
</div>
<DragOverlay>
<ProjectDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
<div class="shrink-0 w-full pt-3 pb-3 flex flex-col items-center gap-2">
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Settings">
<IconButton disabled icon="settings-gear" variant="ghost" size="large" />
</Tooltip>
<Tooltip placement={sidebarProps.mobile ? "bottom" : "right"} value="Help">
<IconButton
icon="help"
<Button
variant="ghost"
size="large"
onClick={() => platform.openLink("https://opencode.ai/desktop-feedback")}
/>
</Tooltip>
</div>
</div>
<Show when={expanded()}>
<div
classList={{
"flex flex-col min-h-0 bg-background-stronger border border-b-0 border-border-weak-base rounded-tl-sm": true,
"flex-1 min-w-0": sidebarProps.mobile,
}}
style={{ width: sidebarProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
class="group/sidebar-toggle shrink-0 w-full text-left justify-start rounded-lg px-2"
onClick={layout.sidebar.toggle}
>
<div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
<Icon
name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
size="small"
class="group-hover/sidebar-toggle:hidden"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
size="small"
class="hidden group-hover/sidebar-toggle:inline-block"
/>
<Icon
name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
size="small"
class="hidden group-active/sidebar-toggle:inline-block"
/>
</div>
<Show when={layout.sidebar.opened()}>
<div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
Toggle sidebar
</div>
</Show>
</Button>
</TooltipKeybind>
</Show>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<Show when={project()} keyed>
{(p) => (
<>
<div class="shrink-0 px-2 py-1">
<div class="group/project flex items-start justify-between gap-2 p-2 pr-1">
<div class="flex flex-col min-w-0">
<span class="text-16-medium text-text-strong truncate">{projectName()}</span>
<Tooltip placement="right" value={project()?.worktree} class="shrink-0">
<span class="text-12-regular text-text-base truncate">
{project()?.worktree.replace(homedir(), "~")}
</span>
</Tooltip>
</div>
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
<DropdownMenu.ItemLabel>Edit</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => layout.sidebar.toggleWorkspaces(p.worktree)}>
<DropdownMenu.ItemLabel>
{layout.sidebar.workspaces(p.worktree)() ? "Disable workspaces" : "Enable workspaces"}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => closeProject(p.worktree)}>
<DropdownMenu.ItemLabel>Close</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
<Show
when={layout.sidebar.workspaces(p.worktree)()}
fallback={
<>
<div class="py-4 px-3">
<Button
size="large"
icon="plus-small"
class="w-full"
onClick={() => {
navigate(`/${base64Encode(p.worktree)}/session`)
layout.mobileSidebar.hide()
}}
>
New session
</Button>
</div>
<div class="flex-1 min-h-0">
<LocalWorkspace project={p} mobile={sidebarProps.mobile} />
</div>
</>
}
>
<>
<div class="py-4 px-3">
<Button size="large" icon="plus-small" class="w-full" onClick={createWorkspace}>
New workspace
</Button>
</div>
<div class="flex-1 min-h-0">
<DragDropProvider
onDragStart={handleWorkspaceDragStart}
onDragEnd={handleWorkspaceDragEnd}
onDragOver={handleWorkspaceDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!sidebarProps.mobile) scrollContainerRef = el
}}
class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar"
>
<SortableProvider ids={workspaces()}>
<For each={workspaces()}>
{(directory) => (
<SortableWorkspace directory={directory} project={p} mobile={sidebarProps.mobile} />
)}
</For>
</SortableProvider>
</div>
<DragOverlay>
<WorkspaceDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
</>
</Show>
</>
)}
</Show>
<Show when={providers.all().length > 0 && providers.paid().length === 0}>
<div class="shrink-0 px-2 py-3 border-t border-border-weak-base">
<div class="rounded-md bg-background-base shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Getting started</div>
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
</div>
<DragDropSensors />
<ConstrainDragXAxis />
<div
ref={(el) => {
if (!sidebarProps.mobile) scrollContainerRef = el
}}
class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
>
<SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
<For each={layout.projects.list()}>
{(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
</For>
</SortableProvider>
</div>
<DragOverlay>
<ProjectDragOverlay />
</DragOverlay>
</DragDropProvider>
</div>
<div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
<Switch>
<Match when={providers.all().length > 0 && !providers.paid().length && expanded()}>
<div class="rounded-md bg-background-stronger shadow-xs-border-base">
<div class="p-3 flex flex-col gap-2">
<div class="text-12-medium text-text-strong">Getting started</div>
<div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
<div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
</div>
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
<Button
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
size="large"
icon="plus"
onClick={connectProvider}
>
Connect provider
</Button>
</div>
</Tooltip>
</div>
</Show>
</div>
</Show>
</div>
</Match>
<Match when={providers.all().length > 0}>
<Tooltip placement="right" value="Connect provider" inactive={expanded()}>
<Button
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="plus"
onClick={connectProvider}
>
<Show when={expanded()}>Connect provider</Show>
</Button>
</Tooltip>
</Match>
</Switch>
<Tooltip
placement="right"
value={
<div class="flex items-center gap-2">
<span>Open project</span>
<Show when={!sidebarProps.mobile}>
<span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
</Show>
</div>
}
inactive={expanded()}
>
<Button
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="folder-add-left"
onClick={chooseProject}
>
<Show when={expanded()}>Open project</Show>
</Button>
</Tooltip>
<Tooltip placement="right" value="Share feedback" inactive={expanded()}>
<Button
as={"a"}
href="https://opencode.ai/desktop-feedback"
target="_blank"
class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
variant="ghost"
size="large"
icon="bubble-5"
>
<Show when={expanded()}>Share feedback</Show>
</Button>
</Tooltip>
</div>
</>
)
}
return (
<div class="relative bg-background-base flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<Titlebar />
<div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
<div class="flex-1 min-h-0 flex">
<div
classList={{
"hidden xl:block": true,
"relative shrink-0": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "64px" }}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : "48px" }}
>
<div class="@container w-full h-full contain-strict">
<div
classList={{
"@container w-full h-full pb-5 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base contain-strict": true,
}}
>
<SidebarContent />
</div>
<Show when={layout.sidebar.opened()}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={214}
max={window.innerWidth * 0.3 + 64}
collapseThreshold={144}
min={150}
max={window.innerWidth * 0.3}
collapseThreshold={80}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}
/>
@@ -1516,7 +1055,7 @@ export default function Layout(props: ParentProps) {
<div class="xl:hidden">
<div
classList={{
"fixed inset-0 z-40 transition-opacity duration-200": true,
"fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
}}
@@ -1526,7 +1065,7 @@ export default function Layout(props: ParentProps) {
/>
<div
classList={{
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base transition-transform duration-200 ease-out": true,
"@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
"translate-x-0": layout.mobileSidebar.opened(),
"-translate-x-full": !layout.mobileSidebar.opened(),
}}
@@ -1536,14 +1075,7 @@ export default function Layout(props: ParentProps) {
</div>
</div>
<main
classList={{
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base": true,
"border-l rounded-tl-sm": !layout.sidebar.opened(),
}}
>
{props.children}
</main>
<main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
</div>
<Toast.Region />
</div>
+413 -755
View File
@@ -1,6 +1,5 @@
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js"
import { For, onCleanup, Show, Match, Switch, createMemo, createEffect, on, createRenderEffect, batch } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type SelectedLineRange } from "@/context/file"
@@ -8,7 +7,6 @@ import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
@@ -26,12 +24,11 @@ import { useSync } from "@/context/sync"
import { useTerminal, type LocalPTY } from "@/context/terminal"
import { useLayout } from "@/context/layout"
import { Terminal } from "@/components/terminal"
import { checksum, base64Encode, base64Decode } from "@opencode-ai/util/encode"
import { checksum } from "@opencode-ai/util/encode"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { DialogSelectFile } from "@/components/dialog-select-file"
import { DialogSelectModel } from "@/components/dialog-select-model"
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
import { DialogFork } from "@/components/dialog-fork"
import { useCommand } from "@/context/command"
import { useNavigate, useParams } from "@solidjs/router"
import { UserMessage } from "@opencode-ai/sdk/v2"
@@ -50,24 +47,20 @@ import {
SortableTerminalTab,
NewSessionView,
} from "@/components/session"
import { usePlatform } from "@/context/platform"
import { navMark, navParams } from "@/utils/perf"
import { same } from "@/utils/same"
function same<T>(a: readonly T[], b: readonly T[]) {
if (a === b) return true
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
type DiffStyle = "unified" | "split"
const handoff = {
prompt: "",
terminals: [] as string[],
files: {} as Record<string, SelectedLineRange | null>,
}
interface SessionReviewTabProps {
diffs: () => FileDiff[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void
onViewFile?: (file: string) => void
classes?: {
root?: string
header?: string
@@ -80,19 +73,13 @@ function SessionReviewTab(props: SessionReviewTabProps) {
let frame: number | undefined
let pending: { x: number; y: number } | undefined
const restoreScroll = (retries = 0) => {
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = props.view().scroll("review")
if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
@@ -147,7 +134,6 @@ function SessionReviewTab(props: SessionReviewTabProps) {
diffs={props.diffs()}
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
onViewFile={props.onViewFile}
/>
)
}
@@ -161,7 +147,6 @@ export default function Page() {
const dialog = useDialog()
const codeComponent = useCodeComponent()
const command = useCommand()
const platform = usePlatform()
const params = useParams()
const navigate = useNavigate()
const sdk = useSDK()
@@ -171,46 +156,6 @@ export default function Page() {
const tabs = createMemo(() => layout.tabs(sessionKey()))
const view = createMemo(() => layout.view(sessionKey()))
if (import.meta.env.DEV) {
createEffect(
on(
() => [params.dir, params.id] as const,
([dir, id], prev) => {
if (!id) return
navParams({ dir, from: prev?.[1], to: id })
},
),
)
createEffect(() => {
const id = params.id
if (!id) return
if (!prompt.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:prompt-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!terminal.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:terminal-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (!file.ready()) return
navMark({ dir: params.dir, to: id, name: "storage:file-view-ready" })
})
createEffect(() => {
const id = params.id
if (!id) return
if (sync.data.message[id] === undefined) return
navMark({ dir: params.dir, to: id, name: "session:data-ready" })
})
}
const isDesktop = createMediaQuery("(min-width: 768px)")
function normalizeTab(tab: string) {
@@ -265,8 +210,6 @@ export default function Page() {
})
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const reviewCount = createMemo(() => info()?.summary?.files ?? 0)
const hasReview = createMemo(() => reviewCount() > 0)
const revertMessageID = createMemo(() => info()?.revert?.messageID)
const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
const messagesReady = createMemo(() => {
@@ -274,23 +217,21 @@ export default function Page() {
if (!id) return true
return sync.data.message[id] !== undefined
})
const historyMore = createMemo(() => {
const id = params.id
if (!id) return false
return sync.session.history.more(id)
})
const historyLoading = createMemo(() => {
const id = params.id
if (!id) return false
return sync.session.history.loading(id)
})
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[], emptyUserMessages)
const visibleUserMessages = createMemo(() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
}, emptyUserMessages)
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
{ equals: same },
)
const visibleUserMessages = createMemo(
() => {
const revert = revertMessageID()
if (!revert) return userMessages()
return userMessages().filter((m) => m.id < revert)
},
emptyUserMessages,
{ equals: same },
)
const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
createEffect(
@@ -308,27 +249,13 @@ export default function Page() {
const [store, setStore] = createStore({
activeDraggable: undefined as string | undefined,
activeTerminalDraggable: undefined as string | undefined,
expanded: {} as Record<string, boolean>,
userInteracted: false,
stepsExpanded: true,
mobileStepsExpanded: {} as Record<string, boolean>,
messageId: undefined as string | undefined,
turnStart: 0,
mobileTab: "session" as "session" | "review",
newSessionWorktree: "main",
promptHeight: 0,
})
const renderedUserMessages = createMemo(() => {
const msgs = visibleUserMessages()
const start = store.turnStart
if (start <= 0) return msgs
if (start >= msgs.length) return emptyUserMessages
return msgs.slice(start)
}, emptyUserMessages)
const newSessionWorktree = createMemo(() => {
if (store.newSessionWorktree === "create") return "create"
const project = sync.project
if (project && sync.data.path.directory !== project.worktree) return sync.data.path.directory
return "main"
ignoreScrollSpy: false,
initialScrollDone: !params.id,
})
const activeMessage = createMemo(() => {
@@ -360,17 +287,9 @@ export default function Page() {
}
const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasReview()) return true
return sync.data.session_diff[id] !== undefined
})
const idle = { type: "idle" as const }
let inputRef!: HTMLDivElement
let promptDock: HTMLDivElement | undefined
let scroller: HTMLDivElement | undefined
createEffect(() => {
if (!params.id) return
@@ -378,10 +297,11 @@ export default function Page() {
})
createEffect(() => {
if (!view().terminal.opened()) return
if (!terminal.ready()) return
if (terminal.all().length !== 0) return
terminal.new()
if (layout.terminal.opened()) {
if (terminal.all().length === 0) {
terminal.new()
}
}
})
createEffect(
@@ -396,24 +316,47 @@ export default function Page() {
),
)
createEffect(
on(
() => params.id,
(id) => {
const status = sync.data.session_status[id ?? ""] ?? idle
batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", status.type !== "idle")
})
},
),
)
const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
createEffect(
on(
() => params.id,
() => {
setStore("messageId", undefined)
setStore("expanded", {})
() => status().type,
(type) => {
if (type !== "idle") return
batch(() => {
setStore("userInteracted", false)
setStore("stepsExpanded", false)
})
},
{ defer: true },
),
)
createEffect(() => {
const id = lastUserMessage()?.id
if (!id) return
setStore("expanded", id, status().type !== "idle")
})
const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
createRenderEffect((prev) => {
const isWorking = working()
if (!prev && isWorking) {
setStore("stepsExpanded", true)
}
if (prev && !isWorking && !store.userInteracted) {
setStore("stepsExpanded", false)
}
return isWorking
}, working())
command.register(() => [
{
@@ -428,7 +371,7 @@ export default function Page() {
{
id: "file.open",
title: "Open file",
description: "Search files and commands",
description: "Search and open a file",
category: "File",
keybind: "mod+p",
slash: "open",
@@ -441,7 +384,7 @@ export default function Page() {
category: "View",
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
onSelect: () => layout.terminal.toggle(),
},
{
id: "review.toggle",
@@ -449,7 +392,7 @@ export default function Page() {
description: "Show or hide the review panel",
category: "View",
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
onSelect: () => layout.review.toggle(),
},
{
id: "terminal.new",
@@ -462,16 +405,12 @@ export default function Page() {
{
id: "steps.toggle",
title: "Toggle steps",
description: "Show or hide steps for the current message",
description: "Show or hide the steps",
category: "View",
keybind: "mod+e",
slash: "steps",
disabled: !params.id,
onSelect: () => {
const msg = activeMessage()
if (!msg) return
setStore("expanded", msg.id, (open: boolean | undefined) => !open)
},
onSelect: () => setStore("stepsExpanded", (x) => !x),
},
{
id: "message.previous",
@@ -542,10 +481,7 @@ export default function Page() {
},
{
id: "permissions.autoaccept",
title:
params.id && permission.isAutoAccepting(params.id, sdk.directory)
? "Stop auto-accepting edits"
: "Auto-accept edits",
title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
category: "Permissions",
keybind: "mod+shift+a",
disabled: !params.id || !permission.permissionsEnabled(),
@@ -554,10 +490,8 @@ export default function Page() {
if (!sessionID) return
permission.toggleAutoAccept(sessionID, sdk.directory)
showToast({
title: permission.isAutoAccepting(sessionID, sdk.directory)
? "Auto-accepting edits"
: "Stopped auto-accepting edits",
description: permission.isAutoAccepting(sessionID, sdk.directory)
title: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
description: permission.isAutoAccepting(sessionID)
? "Edit and write permissions will be automatically approved"
: "Edit and write permissions will require approval",
})
@@ -584,7 +518,7 @@ export default function Page() {
// Restore the prompt from the reverted message
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
const restored = extractPromptFromParts(parts)
prompt.set(restored)
}
// Navigate to the message before the reverted one (which will be the new last visible message)
@@ -646,15 +580,6 @@ export default function Page() {
})
},
},
{
id: "session.fork",
title: "Fork from message",
description: "Create a new session from a previous message",
category: "Session",
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => dialog.show(() => <DialogFork />),
},
])
const handleKeyDown = (event: KeyboardEvent) => {
@@ -727,11 +652,11 @@ export default function Page() {
.filter((tab) => tab !== "context"),
)
const reviewTab = createMemo(() => hasReview() || tabs().active() === "review")
const mobileReview = createMemo(() => !isDesktop() && hasReview() && store.mobileTab === "review")
const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review")
const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review")
const showTabs = createMemo(
() => view().reviewPanel.opened() && (hasReview() || tabs().all().length > 0 || contextOpen()),
() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
)
const activeTab = createMemo(() => {
@@ -748,241 +673,211 @@ export default function Page() {
createEffect(() => {
if (!layout.ready()) return
if (tabs().active()) return
if (!hasReview() && openedTabs().length === 0 && !contextOpen()) return
if (diffs().length === 0 && openedTabs().length === 0 && !contextOpen()) return
tabs().setActive(activeTab())
})
createEffect(() => {
const id = params.id
if (!id) return
if (!hasReview()) return
const wants = isDesktop() ? view().reviewPanel.opened() && activeTab() === "review" : store.mobileTab === "review"
if (!wants) return
if (diffsReady()) return
sync.session.diff(id)
})
const isWorking = createMemo(() => status().type !== "idle")
const autoScroll = createAutoScroll({
working: isWorking,
onUserInteracted: () => setStore("userInteracted", true),
})
let scrollContainer: HTMLDivElement | undefined
let initialScrollFrame: number | undefined
let initialScrollTarget: string | undefined
const cancelInitialScroll = () => {
if (initialScrollFrame === undefined) return
cancelAnimationFrame(initialScrollFrame)
initialScrollFrame = undefined
}
const ensureInitialScroll = () => {
cancelInitialScroll()
initialScrollFrame = requestAnimationFrame(() => {
initialScrollFrame = undefined
if (!params.id) {
initialScrollTarget = undefined
setStore("initialScrollDone", true)
return
}
const msgs = visibleUserMessages()
if (msgs.length === 0) {
if (!messagesReady()) {
ensureInitialScroll()
return
}
initialScrollTarget = undefined
setStore("initialScrollDone", true)
return
}
const last = msgs[msgs.length - 1]
const el = messageRefs.get(last.id)
if (!el || !scrollContainer) {
ensureInitialScroll()
return
}
scrollToMessage(last, "auto")
initialScrollTarget = last.id
setStore("initialScrollDone", true)
})
}
const setScrollRef = (el: HTMLDivElement | undefined) => {
scrollContainer = el
autoScroll.scrollRef(el)
}
const messageRefs = new Map<string, HTMLDivElement>()
let scrollTimer: number | undefined
createEffect(() => {
const msgs = visibleUserMessages()
if (msgs.length === 0) {
messageRefs.clear()
return
}
const ids = new Set(msgs.map((m) => m.id))
for (const id of messageRefs.keys()) {
if (ids.has(id)) continue
messageRefs.delete(id)
}
})
let scrollSpyIndex = 0
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setStore("ignoreScrollSpy", true)
setActiveMessage(message)
const msgs = visibleUserMessages()
const idx = msgs.findIndex((m) => m.id === message.id)
if (idx >= 0) scrollSpyIndex = idx
const el = messageRefs.get(message.id)
if (el) {
el.scrollIntoView({ behavior, block: "start" })
}
if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
scrollTimer = window.setTimeout(() => setStore("ignoreScrollSpy", false), 1000)
}
let scrollSpyFrame: number | undefined
let scrollSpyTarget: HTMLDivElement | undefined
const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => {
scroller = el
autoScroll.scrollRef(el)
}
const turnInit = 20
const turnBatch = 20
let turnHandle: number | undefined
let turnIdle = false
function cancelTurnBackfill() {
const handle = turnHandle
if (handle === undefined) return
turnHandle = undefined
if (turnIdle && window.cancelIdleCallback) {
window.cancelIdleCallback(handle)
return
}
clearTimeout(handle)
}
function scheduleTurnBackfill() {
if (turnHandle !== undefined) return
if (store.turnStart <= 0) return
if (window.requestIdleCallback) {
turnIdle = true
turnHandle = window.requestIdleCallback(() => {
turnHandle = undefined
backfillTurns()
})
return
}
turnIdle = false
turnHandle = window.setTimeout(() => {
turnHandle = undefined
backfillTurns()
}, 0)
}
function backfillTurns() {
const start = store.turnStart
if (start <= 0) return
const next = start - turnBatch
const nextStart = next > 0 ? next : 0
const el = scroller
if (!el) {
setStore("turnStart", nextStart)
scheduleTurnBackfill()
return
}
const beforeTop = el.scrollTop
const beforeHeight = el.scrollHeight
setStore("turnStart", nextStart)
requestAnimationFrame(() => {
const delta = el.scrollHeight - beforeHeight
if (delta) el.scrollTop = beforeTop + delta
})
scheduleTurnBackfill()
}
createEffect(
on(
() => [params.id, messagesReady()] as const,
([id, ready]) => {
cancelTurnBackfill()
setStore("turnStart", 0)
if (!id || !ready) return
const len = visibleUserMessages().length
const start = len > turnInit ? len - turnInit : 0
setStore("turnStart", start)
scheduleTurnBackfill()
},
{ defer: true },
),
)
createResizeObserver(
() => promptDock,
({ height }) => {
const next = Math.ceil(height)
if (next === store.promptHeight) return
const el = scroller
const stick = el ? el.scrollHeight - el.clientHeight - el.scrollTop < 10 : false
setStore("promptHeight", next)
if (stick && el) {
requestAnimationFrame(() => {
el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
})
}
},
)
const updateHash = (id: string) => {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller
if (!root) {
el.scrollIntoView({ behavior, block: "start" })
return
}
const a = el.getBoundingClientRect()
const b = root.getBoundingClientRect()
const top = a.top - b.top + root.scrollTop
root.scrollTo({ top, behavior })
}
const scrollToMessage = (message: UserMessage, behavior: ScrollBehavior = "smooth") => {
setActiveMessage(message)
const msgs = visibleUserMessages()
const index = msgs.findIndex((m) => m.id === message.id)
if (index !== -1 && index < store.turnStart) {
setStore("turnStart", index)
scheduleTurnBackfill()
requestAnimationFrame(() => {
const el = document.getElementById(anchor(message.id))
if (el) scrollToElement(el, behavior)
})
updateHash(message.id)
return
}
const el = document.getElementById(anchor(message.id))
if (el) scrollToElement(el, behavior)
updateHash(message.id)
}
const getActiveMessageId = (container: HTMLDivElement) => {
const cutoff = container.scrollTop + 100
const nodes = container.querySelectorAll<HTMLElement>("[data-message-id]")
let id: string | undefined
for (const node of nodes) {
const next = node.dataset.messageId
if (!next) continue
if (node.offsetTop > cutoff) break
id = next
}
return id
}
const scheduleScrollSpy = (container: HTMLDivElement) => {
if (store.ignoreScrollSpy) return
scrollSpyTarget = container
if (scrollSpyFrame !== undefined) return
scrollSpyFrame = requestAnimationFrame(() => {
scrollSpyFrame = undefined
const target = scrollSpyTarget
scrollSpyTarget = undefined
if (!target) return
if (store.ignoreScrollSpy) return
const id = getActiveMessageId(target)
if (!id) return
if (id === store.messageId) return
const msgs = visibleUserMessages()
const scrollTop = target.scrollTop
const threshold = 100
const cutoff = scrollTop + threshold
setStore("messageId", id)
if (msgs.length === 0) return
if (scrollSpyIndex >= msgs.length) scrollSpyIndex = msgs.length - 1
if (scrollSpyIndex < 0) scrollSpyIndex = 0
while (scrollSpyIndex + 1 < msgs.length) {
const next = msgs[scrollSpyIndex + 1]
if (!next) break
const el = messageRefs.get(next.id)
if (!el) break
if (el.offsetTop <= cutoff) {
scrollSpyIndex += 1
continue
}
break
}
while (scrollSpyIndex > 0) {
const cur = msgs[scrollSpyIndex]
if (!cur) break
const el = messageRefs.get(cur.id)
if (!el) break
if (el.offsetTop > cutoff) {
scrollSpyIndex -= 1
continue
}
break
}
const msg = msgs[scrollSpyIndex]
if (!msg) return
if (msg.id === activeMessage()?.id) return
setActiveMessage(msg)
})
}
createEffect(
on(
() => params.id,
(id) => {
cancelInitialScroll()
if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
scrollTimer = undefined
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
scrollSpyFrame = undefined
scrollSpyTarget = undefined
messageRefs.clear()
scrollSpyIndex = 0
initialScrollTarget = undefined
setStore("initialScrollDone", !id)
},
{ defer: true },
),
)
createEffect(() => {
const sessionID = params.id
const msgs = visibleUserMessages()
const target = msgs.at(-1)?.id
const ready = messagesReady()
if (!sessionID || !ready) return
if (!params.id) {
setStore("initialScrollDone", true)
initialScrollTarget = undefined
return
}
if (!ready) {
setStore("initialScrollDone", false)
ensureInitialScroll()
return
}
if (!store.initialScrollDone) {
ensureInitialScroll()
return
}
if (!initialScrollTarget && target) {
setStore("initialScrollDone", false)
ensureInitialScroll()
}
})
createEffect(() => {
const msgs = visibleUserMessages()
if (msgs.length === 0) return
requestAnimationFrame(() => {
const hash = window.location.hash.slice(1)
if (!hash) {
autoScroll.forceScrollToBottom()
return
}
const hashTarget = document.getElementById(hash)
if (hashTarget) {
scrollToElement(hashTarget, "auto")
return
}
const match = hash.match(/^message-(.+)$/)
if (match) {
const msg = visibleUserMessages().find((m) => m.id === match[1])
if (msg) {
scrollToMessage(msg, "auto")
return
}
}
autoScroll.forceScrollToBottom()
if (!scrollContainer) return
if (!isDesktop()) return
// Manually trigger spy once to set initial active message based on scroll position
scheduleScrollSpy(scrollContainer)
})
})
@@ -990,44 +885,10 @@ export default function Page() {
document.addEventListener("keydown", handleKeyDown)
})
const previewPrompt = () =>
prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim()
createEffect(() => {
if (!prompt.ready()) return
handoff.prompt = previewPrompt()
})
createEffect(() => {
if (!terminal.ready()) return
handoff.terminals = terminal.all().map((t) => t.title)
})
createEffect(() => {
if (!file.ready()) return
handoff.files = Object.fromEntries(
tabs()
.all()
.flatMap((tab) => {
const path = file.pathFromTab(tab)
if (!path) return []
return [[path, file.selectedLines(path) ?? null] as const]
}),
)
})
onCleanup(() => {
cancelTurnBackfill()
document.removeEventListener("keydown", handleKeyDown)
cancelInitialScroll()
if (scrollTimer !== undefined) window.clearTimeout(scrollTimer)
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
})
@@ -1036,7 +897,7 @@ export default function Page() {
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col md:flex-row">
{/* Mobile tab bar - only shown on mobile when there are diffs */}
<Show when={!isDesktop() && hasReview()}>
<Show when={!isDesktop() && diffs().length > 0}>
<Tabs class="h-auto">
<Tabs.List>
<Tabs.Trigger
@@ -1053,7 +914,7 @@ export default function Page() {
classes={{ button: "w-full" }}
onClick={() => setStore("mobileTab", "review")}
>
{reviewCount()} Files Changed
{diffs().length} Files Changed
</Tabs.Trigger>
</Tabs.List>
</Tabs>
@@ -1065,10 +926,7 @@ export default function Page() {
"@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
"flex-1 md:flex-none py-6 md:py-3": true,
}}
style={{
width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%",
"--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
}}
style={{ width: isDesktop() && showTabs() ? `${layout.session.width()}px` : "100%" }}
>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
@@ -1078,26 +936,16 @@ export default function Page() {
when={!mobileReview()}
fallback={
<div class="relative h-full overflow-hidden">
<Show
when={diffsReady()}
fallback={<div class="px-4 py-4 text-text-weak">Loading changes...</div>}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle="unified"
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
classes={{
root: "pb-[calc(var(--prompt-height,8rem)+32px)]",
header: "px-4",
container: "px-4",
}}
/>
</Show>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle="unified"
classes={{
root: "pb-32",
header: "px-4",
container: "px-4",
}}
/>
</div>
}
>
@@ -1121,91 +969,46 @@ export default function Page() {
}}
onClick={autoScroll.handleInteraction}
class="relative min-w-0 w-full h-full overflow-y-auto no-scrollbar"
classList={{
"opacity-0 pointer-events-none": !store.initialScrollDone,
}}
>
<div
ref={autoScroll.contentRef}
class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
class="flex flex-col gap-45 items-start justify-start pb-32 md:pb-40 transition-[margin]"
classList={{
"mt-0.5": !showTabs(),
"mt-0": showTabs(),
}}
>
<Show when={store.turnStart > 0}>
<div class="w-full flex justify-center">
<Button
variant="ghost"
size="large"
class="text-12-medium opacity-50"
onClick={() => setStore("turnStart", 0)}
<For each={visibleUserMessages()}>
{(message) => (
<div
ref={(el) => messageRefs.set(message.id, el)}
class="min-w-0 w-full max-w-full last:min-h-[80vh]"
>
Render earlier messages
</Button>
</div>
</Show>
<Show when={historyMore()}>
<div class="w-full flex justify-center">
<Button
variant="ghost"
size="large"
class="text-12-medium opacity-50"
disabled={historyLoading()}
onClick={() => {
const id = params.id
if (!id) return
setStore("turnStart", 0)
sync.session.history.loadMore(id)
}}
>
{historyLoading() ? "Loading earlier messages..." : "Load earlier messages"}
</Button>
</div>
</Show>
<For each={renderedUserMessages()}>
{(message) => {
if (import.meta.env.DEV) {
onMount(() => {
const id = params.id
if (!id) return
navMark({ dir: params.dir, to: id, name: "session:first-turn-mounted" })
})
}
return (
<div
id={anchor(message.id)}
data-message-id={message.id}
classList={{
"min-w-0 w-full max-w-full": true,
"last:min-h-[calc(100vh-5.5rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-4.5rem-var(--prompt-height,10rem)-64px)]":
platform.platform !== "desktop",
"last:min-h-[calc(100vh-7rem-var(--prompt-height,8rem)-64px)] md:last:min-h-[calc(100vh-6rem-var(--prompt-height,10rem)-64px)]":
platform.platform === "desktop",
<SessionTurn
sessionID={params.id!}
messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
onUserInteracted={() => setStore("userInteracted", true)}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container:
"px-4 md:px-6 " +
(!showTabs()
? "md:max-w-200 md:mx-auto"
: visibleUserMessages().length > 1
? "md:pr-6 md:pl-18"
: ""),
}}
>
<SessionTurn
sessionID={params.id!}
messageID={message.id}
lastUserMessageID={lastUserMessage()?.id}
stepsExpanded={store.expanded[message.id] ?? false}
onStepsExpandedToggle={() =>
setStore("expanded", message.id, (open: boolean | undefined) => !open)
}
classes={{
root: "min-w-0 w-full relative",
content:
"flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
container:
"px-4 md:px-6 " +
(!showTabs()
? "md:max-w-200 md:mx-auto"
: visibleUserMessages().length > 1
? "md:pr-6 md:pl-18"
: ""),
}}
/>
</div>
)
}}
/>
</div>
)}
</For>
</div>
</div>
@@ -1214,54 +1017,24 @@ export default function Page() {
</Show>
</Match>
<Match when={true}>
<NewSessionView
worktree={newSessionWorktree()}
onWorktreeChange={(value) => {
if (value === "create") {
setStore("newSessionWorktree", value)
return
}
setStore("newSessionWorktree", "main")
const target = value === "main" ? sync.project?.worktree : value
if (!target) return
if (target === sync.data.path.directory) return
layout.projects.open(target)
navigate(`/${base64Encode(target)}/session`)
}}
/>
<NewSessionView />
</Match>
</Switch>
</div>
{/* Prompt input */}
<div
ref={(el) => (promptDock = el)}
class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
>
<div class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none">
<div
classList={{
"w-full md:px-6 pointer-events-auto": true,
"md:max-w-200": !showTabs(),
}}
>
<Show
when={prompt.ready()}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoff.prompt || "Loading prompt..."}
</div>
}
>
<PromptInput
ref={(el) => {
inputRef = el
}}
newSessionWorktree={newSessionWorktree()}
onNewSessionWorktreeReset={() => setStore("newSessionWorktree", "main")}
/>
</Show>
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
</div>
</div>
@@ -1316,7 +1089,6 @@ export default function Page() {
</Tooltip>
}
hideCloseButton
onMiddleClick={() => tabs().close("context")}
>
<div class="flex items-center gap-2">
<SessionContextUsage variant="indicator" />
@@ -1345,40 +1117,26 @@ export default function Page() {
</div>
<Show when={reviewTab()}>
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "review"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<Show
when={diffsReady()}
fallback={<div class="px-6 py-4 text-text-weak">Loading changes...</div>}
>
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onViewFile={(path) => {
const value = file.tab(path)
tabs().open(value)
file.load(path)
}}
/>
</Show>
</div>
</Show>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionReviewTab
diffs={diffs}
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
/>
</div>
</Tabs.Content>
</Show>
<Show when={contextOpen()}>
<Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
<Show when={activeTab() === "context"}>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab
messages={messages}
visibleUserMessages={visibleUserMessages}
view={view}
info={info}
/>
</div>
</Show>
<div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
<SessionContextTab
messages={messages}
visibleUserMessages={visibleUserMessages}
view={view}
info={info}
/>
</div>
</Tabs.Content>
</Show>
<For each={openedTabs()}>
@@ -1395,40 +1153,10 @@ export default function Page() {
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => checksum(contents()))
const isImage = createMemo(() => {
const c = state()?.content
return (
c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml"
)
})
const isSvg = createMemo(() => {
const c = state()?.content
return c?.mimeType === "image/svg+xml"
})
const svgContent = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding === "base64") return base64Decode(c.content)
return c.content
})
const svgPreviewUrl = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
if (!c) return
if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}`
})
const imageDataUrl = createMemo(() => {
if (!isImage()) return
const c = state()?.content
return `data:${c?.mimeType};base64,${c?.content}`
})
const selectedLines = createMemo(() => {
const p = path()
if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null
return handoff.files[p] ?? null
return file.selectedLines(p) ?? null
})
const selection = createMemo(() => {
const range = selectedLines()
@@ -1442,19 +1170,13 @@ export default function Page() {
return `L${sel.startLine}-${sel.endLine}`
})
const restoreScroll = (retries = 0) => {
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = view()?.scroll(tab)
if (!s) return
// Wait for content to be scrollable - content may not have rendered yet
if (el.scrollHeight <= el.clientHeight && retries < 10) {
requestAnimationFrame(() => restoreScroll(retries + 1))
return
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
@@ -1499,17 +1221,6 @@ export default function Page() {
),
)
createEffect(
on(
() => tabs().active() === tab,
(active) => {
if (!active) return
if (!state()?.loaded) return
requestAnimationFrame(restoreScroll)
},
),
)
onCleanup(() => {
if (scrollFrame === undefined) return
cancelAnimationFrame(scrollFrame)
@@ -1525,84 +1236,51 @@ export default function Page() {
}}
onScroll={handleScroll}
>
<Show when={activeTab() === tab}>
<Show when={selection()}>
{(sel) => (
<div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
<button
type="button"
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
onClick={() => {
const p = path()
if (!p) return
prompt.context.add({ type: "file", path: p, selection: sel() })
}}
>
<Icon name="plus-small" size="small" />
<span>Add {selectionLabel()} to context</span>
</button>
</div>
)}
</Show>
<Switch>
<Match when={state()?.loaded && isImage()}>
<div class="px-6 py-4 pb-40">
<img src={imageDataUrl()} alt={path()} class="max-w-full" />
</div>
</Match>
<Match when={state()?.loaded && isSvg()}>
<div class="flex flex-col gap-4 px-6 py-4">
<Dynamic
component={codeComponent}
file={{
name: path() ?? "",
contents: svgContent() ?? "",
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
}}
overflow="scroll"
class="select-text"
/>
<Show when={svgPreviewUrl()}>
<div class="flex justify-center pb-40">
<img src={svgPreviewUrl()} alt={path()} class="max-w-full max-h-96" />
</div>
</Show>
</div>
</Match>
<Match when={state()?.loaded}>
<Dynamic
component={codeComponent}
file={{
name: path() ?? "",
contents: contents(),
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
onLineSelected={(range: SelectedLineRange | null) => {
<Show when={selection()}>
{(sel) => (
<div class="hidden sticky top-0 z-10 px-6 py-2 _flex justify-end bg-background-base border-b border-border-weak-base">
<button
type="button"
class="flex items-center gap-2 px-2 py-1 rounded-md bg-surface-base border border-border-base text-12-regular text-text-strong hover:bg-surface-raised-base-hover"
onClick={() => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
prompt.context.add({ type: "file", path: p, selection: sel() })
}}
overflow="scroll"
class="select-text pb-40"
/>
</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">Loading...</div>
</Match>
<Match when={state()?.error}>
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
</Match>
</Switch>
>
<Icon name="plus-small" size="small" />
<span>Add {selectionLabel()} to context</span>
</button>
</div>
)}
</Show>
<Switch>
<Match when={state()?.loaded}>
<Dynamic
component={codeComponent}
file={{
name: path() ?? "",
contents: contents(),
cacheKey: cacheKey(),
}}
enableLineSelection
selectedLines={selectedLines()}
onLineSelected={(range: SelectedLineRange | null) => {
const p = path()
if (!p) return
file.setSelectedLines(p, range)
}}
overflow="scroll"
class="select-text pb-40"
/>
</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">Loading...</div>
</Match>
<Match when={state()?.error}>
{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}
</Match>
</Switch>
</Tabs.Content>
)
}}
@@ -1625,7 +1303,7 @@ export default function Page() {
</Show>
</div>
<Show when={isDesktop() && view().terminal.opened()}>
<Show when={isDesktop() && layout.terminal.opened()}>
<div
class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${layout.terminal.height()}px` }}
@@ -1637,76 +1315,56 @@ export default function Page() {
max={window.innerHeight * 0.6}
collapseThreshold={50}
onResize={layout.terminal.resize}
onCollapse={view().terminal.close}
onCollapse={layout.terminal.close}
/>
<Show
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
<For each={handoff.terminals}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}
</div>
)}
</For>
<div class="flex-1" />
<div class="text-text-weak pr-2">Loading...</div>
</div>
<div class="flex-1 flex items-center justify-center text-text-weak">Loading terminal...</div>
</div>
}
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
>
<DragDropProvider
onDragStart={handleTerminalDragStart}
onDragEnd={handleTerminalDragEnd}
onDragOver={handleTerminalDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
<Tabs.List class="h-10">
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
title="New terminal"
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
</TooltipKeybind>
</div>
</Tabs.List>
<For each={terminal.all()}>
{(pty) => (
<Tabs.Content value={pty.id}>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Tabs.Content>
)}
</For>
</Tabs>
<DragOverlay>
<Show when={store.activeTerminalDraggable}>
{(draggedId) => {
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
return (
<Show when={pty()}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{t().title}
</div>
)}
</Show>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
<Tabs.List class="h-10">
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
</SortableProvider>
<div class="h-full flex items-center justify-center">
<TooltipKeybind
title="New terminal"
keybind={command.keybind("terminal.new")}
class="flex items-center"
>
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
</TooltipKeybind>
</div>
</Tabs.List>
<For each={terminal.all()}>
{(pty) => (
<Tabs.Content value={pty.id}>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Tabs.Content>
)}
</For>
</Tabs>
<DragOverlay>
<Show when={store.activeTerminalDraggable}>
{(draggedId) => {
const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
return (
<Show when={pty()}>
{(t) => (
<div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
{t().title}
</div>
)}
</Show>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
</div>
</Show>
</div>
-135
View File
@@ -1,135 +0,0 @@
type Nav = {
id: string
dir?: string
from?: string
to: string
trigger?: string
start: number
marks: Record<string, number>
logged: boolean
timer?: ReturnType<typeof setTimeout>
}
const dev = import.meta.env.DEV
const key = (dir: string | undefined, to: string) => `${dir ?? ""}:${to}`
const now = () => performance.now()
const uid = () => crypto.randomUUID?.() ?? Math.random().toString(16).slice(2)
const navs = new Map<string, Nav>()
const pending = new Map<string, string>()
const active = new Map<string, string>()
const required = [
"session:params",
"session:data-ready",
"session:first-turn-mounted",
"storage:prompt-ready",
"storage:terminal-ready",
"storage:file-view-ready",
]
function flush(id: string, reason: "complete" | "timeout") {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
nav.logged = true
if (nav.timer) clearTimeout(nav.timer)
const baseName = nav.marks["navigate:start"] !== undefined ? "navigate:start" : "session:params"
const base = nav.marks[baseName] ?? nav.start
const ms = Object.fromEntries(
Object.entries(nav.marks)
.slice()
.sort(([a], [b]) => a.localeCompare(b))
.map(([name, t]) => [name, Math.round((t - base) * 100) / 100]),
)
console.log(
"perf.session-nav " +
JSON.stringify({
type: "perf.session-nav.v0",
id: nav.id,
dir: nav.dir,
from: nav.from,
to: nav.to,
trigger: nav.trigger,
base: baseName,
reason,
ms,
}),
)
navs.delete(id)
}
function maybeFlush(id: string) {
if (!dev) return
const nav = navs.get(id)
if (!nav) return
if (nav.logged) return
if (!required.every((name) => nav.marks[name] !== undefined)) return
flush(id, "complete")
}
function ensure(id: string, data: Omit<Nav, "marks" | "logged" | "timer">) {
const existing = navs.get(id)
if (existing) return existing
const nav: Nav = {
...data,
marks: {},
logged: false,
}
nav.timer = setTimeout(() => flush(id, "timeout"), 5000)
navs.set(id, nav)
return nav
}
export function navStart(input: { dir?: string; from?: string; to: string; trigger?: string }) {
if (!dev) return
const id = uid()
const start = now()
const nav = ensure(id, { ...input, id, start })
nav.marks["navigate:start"] = start
pending.set(key(input.dir, input.to), id)
return id
}
export function navParams(input: { dir?: string; from?: string; to: string }) {
if (!dev) return
const k = key(input.dir, input.to)
const pendingId = pending.get(k)
if (pendingId) pending.delete(k)
const id = pendingId ?? uid()
const start = now()
const nav = ensure(id, { ...input, id, start, trigger: pendingId ? "key" : "route" })
nav.marks["session:params"] = start
active.set(k, id)
maybeFlush(id)
return id
}
export function navMark(input: { dir?: string; to: string; name: string }) {
if (!dev) return
const id = active.get(key(input.dir, input.to))
if (!id) return
const nav = navs.get(id)
if (!nav) return
if (nav.marks[input.name] !== undefined) return
nav.marks[input.name] = now()
maybeFlush(id)
}
+5 -223
View File
@@ -1,235 +1,17 @@
import { usePlatform } from "@/context/platform"
import { makePersisted, type AsyncStorage, type SyncStorage } from "@solid-primitives/storage"
import { checksum } from "@opencode-ai/util/encode"
import { makePersisted } from "@solid-primitives/storage"
import { createResource, type Accessor } from "solid-js"
import type { SetStoreFunction, Store } from "solid-js/store"
type InitType = Promise<string> | string | null
type PersistedWithReady<T> = [Store<T>, SetStoreFunction<T>, InitType, Accessor<boolean>]
type PersistTarget = {
storage?: string
key: string
legacy?: string[]
migrate?: (value: unknown) => unknown
}
const LEGACY_STORAGE = "default.dat"
const GLOBAL_STORAGE = "opencode.global.dat"
function snapshot(value: unknown) {
return JSON.parse(JSON.stringify(value)) as unknown
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
function merge(defaults: unknown, value: unknown): unknown {
if (value === undefined) return defaults
if (value === null) return value
if (Array.isArray(defaults)) {
if (Array.isArray(value)) return value
return defaults
}
if (isRecord(defaults)) {
if (!isRecord(value)) return defaults
const result: Record<string, unknown> = { ...defaults }
for (const key of Object.keys(value)) {
if (key in defaults) {
result[key] = merge((defaults as Record<string, unknown>)[key], (value as Record<string, unknown>)[key])
} else {
result[key] = (value as Record<string, unknown>)[key]
}
}
return result
}
return value
}
function parse(value: string) {
try {
return JSON.parse(value) as unknown
} catch {
return undefined
}
}
function workspaceStorage(dir: string) {
const head = dir.slice(0, 12) || "workspace"
const sum = checksum(dir) ?? "0"
return `opencode.workspace.${head}.${sum}.dat`
}
function localStorageWithPrefix(prefix: string): SyncStorage {
const base = `${prefix}:`
return {
getItem: (key) => localStorage.getItem(base + key),
setItem: (key, value) => localStorage.setItem(base + key, value),
removeItem: (key) => localStorage.removeItem(base + key),
}
}
export const Persist = {
global(key: string, legacy?: string[]): PersistTarget {
return { storage: GLOBAL_STORAGE, key, legacy }
},
workspace(dir: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `workspace:${key}`, legacy }
},
session(dir: string, session: string, key: string, legacy?: string[]): PersistTarget {
return { storage: workspaceStorage(dir), key: `session:${session}:${key}`, legacy }
},
scoped(dir: string, session: string | undefined, key: string, legacy?: string[]): PersistTarget {
if (session) return Persist.session(dir, session, key, legacy)
return Persist.workspace(dir, key, legacy)
},
}
export function removePersisted(target: { storage?: string; key: string }) {
export function persisted<T>(key: string, store: [Store<T>, SetStoreFunction<T>]): PersistedWithReady<T> {
const platform = usePlatform()
const isDesktop = platform.platform === "desktop" && !!platform.storage
if (isDesktop) {
return platform.storage?.(target.storage)?.removeItem(target.key)
}
if (!target.storage) {
localStorage.removeItem(target.key)
return
}
localStorageWithPrefix(target.storage).removeItem(target.key)
}
export function persisted<T>(
target: string | PersistTarget,
store: [Store<T>, SetStoreFunction<T>],
): PersistedWithReady<T> {
const platform = usePlatform()
const config: PersistTarget = typeof target === "string" ? { key: target } : target
const defaults = snapshot(store[0])
const legacy = config.legacy ?? []
const isDesktop = platform.platform === "desktop" && !!platform.storage
const currentStorage = (() => {
if (isDesktop) return platform.storage?.(config.storage)
if (!config.storage) return localStorage
return localStorageWithPrefix(config.storage)
})()
const legacyStorage = (() => {
if (!isDesktop) return localStorage
if (!config.storage) return platform.storage?.()
return platform.storage?.(LEGACY_STORAGE)
})()
const storage = (() => {
if (!isDesktop) {
const current = currentStorage as SyncStorage
const legacyStore = legacyStorage as SyncStorage
const api: SyncStorage = {
getItem: (key) => {
const raw = current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) current.setItem(key, next)
return next
}
for (const legacyKey of legacy) {
const legacyRaw = legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
current.setItem(key, legacyRaw)
legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) current.setItem(key, next)
return next
}
return null
},
setItem: (key, value) => {
current.setItem(key, value)
},
removeItem: (key) => {
current.removeItem(key)
},
}
return api
}
const current = currentStorage as AsyncStorage
const legacyStore = legacyStorage as AsyncStorage | undefined
const api: AsyncStorage = {
getItem: async (key) => {
const raw = await current.getItem(key)
if (raw !== null) {
const parsed = parse(raw)
if (parsed === undefined) return raw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (raw !== next) await current.setItem(key, next)
return next
}
if (!legacyStore) return null
for (const legacyKey of legacy) {
const legacyRaw = await legacyStore.getItem(legacyKey)
if (legacyRaw === null) continue
await current.setItem(key, legacyRaw)
await legacyStore.removeItem(legacyKey)
const parsed = parse(legacyRaw)
if (parsed === undefined) return legacyRaw
const migrated = config.migrate ? config.migrate(parsed) : parsed
const merged = merge(defaults, migrated)
const next = JSON.stringify(merged)
if (legacyRaw !== next) await current.setItem(key, next)
return next
}
return null
},
setItem: async (key, value) => {
await current.setItem(key, value)
},
removeItem: async (key) => {
await current.removeItem(key)
},
}
return api
})()
const [state, setState, init] = makePersisted(store, { name: config.key, storage })
const [state, setState, init] = makePersisted(store, { name: key, storage: platform.storage?.() ?? localStorage })
// Create a resource that resolves when the store is initialized
// This integrates with Suspense and provides a ready signal
const isAsync = init instanceof Promise
const [ready] = createResource(
() => init,
+31 -186
View File
@@ -1,202 +1,47 @@
import type { AgentPart as MessageAgentPart, FilePart, Part, TextPart } from "@opencode-ai/sdk/v2"
import type { AgentPart, FileAttachmentPart, ImageAttachmentPart, Prompt } from "@/context/prompt"
type Inline =
| {
type: "file"
start: number
end: number
value: string
path: string
selection?: {
startLine: number
endLine: number
startChar: number
endChar: number
}
}
| {
type: "agent"
start: number
end: number
value: string
name: string
}
function selectionFromFileUrl(url: string): Extract<Inline, { type: "file" }>["selection"] {
const queryIndex = url.indexOf("?")
if (queryIndex === -1) return undefined
const params = new URLSearchParams(url.slice(queryIndex + 1))
const startLine = Number(params.get("start"))
const endLine = Number(params.get("end"))
if (!Number.isFinite(startLine) || !Number.isFinite(endLine)) return undefined
return {
startLine,
endLine,
startChar: 0,
endChar: 0,
}
}
function textPartValue(parts: Part[]) {
const candidates = parts
.filter((part): part is TextPart => part.type === "text")
.filter((part) => !part.synthetic && !part.ignored)
return candidates.reduce((best: TextPart | undefined, part) => {
if (!best) return part
if (part.text.length > best.text.length) return part
return best
}, undefined)
}
import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
import type { Prompt, FileAttachmentPart } from "@/context/prompt"
/**
* Extract prompt content from message parts for restoring into the prompt input.
* This is used by undo to restore the original user prompt.
*/
export function extractPromptFromParts(parts: Part[], opts?: { directory?: string }): Prompt {
const textPart = textPartValue(parts)
const text = textPart?.text ?? ""
const directory = opts?.directory
const toRelative = (path: string) => {
if (!directory) return path
const prefix = directory.endsWith("/") ? directory : directory + "/"
if (path.startsWith(prefix)) return path.slice(prefix.length)
if (path.startsWith(directory)) {
const next = path.slice(directory.length)
if (next.startsWith("/")) return next.slice(1)
return next
}
return path
}
const inline: Inline[] = []
const images: ImageAttachmentPart[] = []
for (const part of parts) {
if (part.type === "file") {
const filePart = part as FilePart
const sourceText = filePart.source?.text
if (sourceText) {
const value = sourceText.value
const start = sourceText.start
const end = sourceText.end
let path = value
if (value.startsWith("@")) path = value.slice(1)
if (!value.startsWith("@") && filePart.source && "path" in filePart.source) {
path = filePart.source.path
}
inline.push({
type: "file",
start,
end,
value,
path: toRelative(path),
selection: selectionFromFileUrl(filePart.url),
})
continue
}
if (filePart.url.startsWith("data:")) {
images.push({
type: "image",
id: filePart.id,
filename: filePart.filename ?? "attachment",
mime: filePart.mime,
dataUrl: filePart.url,
})
}
}
if (part.type === "agent") {
const agentPart = part as MessageAgentPart
const source = agentPart.source
if (!source) continue
inline.push({
type: "agent",
start: source.start,
end: source.end,
value: source.value,
name: agentPart.name,
})
}
}
inline.sort((a, b) => {
if (a.start !== b.start) return a.start - b.start
return a.end - b.end
})
export function extractPromptFromParts(parts: Part[]): Prompt {
const result: Prompt = []
let position = 0
let cursor = 0
const pushText = (content: string) => {
if (!content) return
result.push({
type: "text",
content,
start: position,
end: position + content.length,
})
position += content.length
}
const pushFile = (item: Extract<Inline, { type: "file" }>) => {
const content = item.value
const attachment: FileAttachmentPart = {
type: "file",
path: item.path,
content,
start: position,
end: position + content.length,
selection: item.selection,
for (const part of parts) {
if (part.type === "text") {
const textPart = part as TextPart
if (!textPart.synthetic && textPart.text) {
result.push({
type: "text",
content: textPart.text,
start: position,
end: position + textPart.text.length,
})
position += textPart.text.length
}
} else if (part.type === "file") {
const filePart = part as FilePart
if (filePart.source?.type === "file") {
const path = filePart.source.path
const content = "@" + path
const attachment: FileAttachmentPart = {
type: "file",
path,
content,
start: position,
end: position + content.length,
}
result.push(attachment)
position += content.length
}
}
result.push(attachment)
position += content.length
}
const pushAgent = (item: Extract<Inline, { type: "agent" }>) => {
const content = item.value
const mention: AgentPart = {
type: "agent",
name: item.name,
content,
start: position,
end: position + content.length,
}
result.push(mention)
position += content.length
}
for (const item of inline) {
if (item.start < 0 || item.end < item.start) continue
const expected = item.value
if (!expected) continue
const mismatch = item.end > text.length || item.start < cursor || text.slice(item.start, item.end) !== expected
const start = mismatch ? text.indexOf(expected, cursor) : item.start
if (start === -1) continue
const end = mismatch ? start + expected.length : item.end
pushText(text.slice(cursor, start))
if (item.type === "file") pushFile(item)
if (item.type === "agent") pushAgent(item)
cursor = end
}
pushText(text.slice(cursor))
if (result.length === 0) {
result.push({ type: "text", content: "", start: 0, end: 0 })
}
if (images.length === 0) return result
return [...result, ...images]
return result
}
-6
View File
@@ -1,6 +0,0 @@
export function same<T>(a: readonly T[] | undefined, b: readonly T[] | undefined) {
if (a === b) return true
if (!a || !b) return false
if (a.length !== b.length) return false
return a.every((x, i) => x === b[i])
}
+2 -9
View File
@@ -1,12 +1,11 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.23",
"version": "1.0.224",
"type": "module",
"license": "MIT",
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vite start"
},
@@ -20,23 +19,17 @@
"@opencode-ai/console-mail": "workspace:*",
"@opencode-ai/console-resource": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@smithy/eventstream-codec": "4.2.7",
"@smithy/util-utf8": "4.2.0",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@solidjs/start": "catalog:",
"@stripe/stripe-js": "8.6.1",
"chart.js": "4.5.1",
"nitro": "3.0.1-alpha.1",
"solid-js": "catalog:",
"solid-list": "0.3.0",
"solid-stripe": "0.8.1",
"vite": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@typescript/native-preview": "catalog:",
"@webgpu/types": "0.1.54",
"typescript": "catalog:",
"wrangler": "4.50.0"
},
@@ -1 +0,0 @@
../../../ui/src/assets/images/social-share-black.png
Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

@@ -24,9 +24,6 @@ export function Footer() {
<div data-slot="cell">
<a href="/docs">Docs</a>
</div>
<div data-slot="cell">
<a href="/changelog">Changelog</a>
</div>
<div data-slot="cell">
<a href="/discord">Discord</a>
</div>
@@ -1,15 +0,0 @@
.spotlight-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 50dvh;
pointer-events: none;
overflow: hidden;
}
.spotlight-container canvas {
display: block;
width: 100%;
height: 100%;
}
@@ -1,820 +0,0 @@
import { createSignal, createEffect, onMount, onCleanup, Accessor } from "solid-js"
import "./spotlight.css"
export interface ParticlesConfig {
enabled: boolean
amount: number
size: [number, number]
speed: number
opacity: number
drift: number
}
export interface SpotlightConfig {
placement: [number, number]
color: string
speed: number
spread: number
length: number
width: number
pulsating: false | [number, number]
distance: number
saturation: number
noiseAmount: number
distortion: number
opacity: number
particles: ParticlesConfig
}
export const defaultConfig: SpotlightConfig = {
placement: [0.5, -0.15],
color: "#ffffff",
speed: 0.8,
spread: 0.5,
length: 4.0,
width: 0.15,
pulsating: [0.95, 1.1],
distance: 3.5,
saturation: 0.35,
noiseAmount: 0.15,
distortion: 0.05,
opacity: 0.325,
particles: {
enabled: true,
amount: 70,
size: [1.25, 1.5],
speed: 0.75,
opacity: 0.9,
drift: 1.5,
},
}
export interface SpotlightAnimationState {
time: number
intensity: number
pulseValue: number
}
interface SpotlightProps {
config: Accessor<SpotlightConfig>
class?: string
onAnimationFrame?: (state: SpotlightAnimationState) => void
}
const hexToRgb = (hex: string): [number, number, number] => {
const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
return m ? [parseInt(m[1], 16) / 255, parseInt(m[2], 16) / 255, parseInt(m[3], 16) / 255] : [1, 1, 1]
}
const getAnchorAndDir = (
placement: [number, number],
w: number,
h: number,
): { anchor: [number, number]; dir: [number, number] } => {
const [px, py] = placement
const outside = 0.2
let anchorX = px * w
let anchorY = py * h
let dirX = 0
let dirY = 0
const centerX = 0.5
const centerY = 0.5
if (py <= 0.25) {
anchorY = -outside * h + py * h
dirY = 1
dirX = (centerX - px) * 0.5
} else if (py >= 0.75) {
anchorY = (1 + outside) * h - (1 - py) * h
dirY = -1
dirX = (centerX - px) * 0.5
} else if (px <= 0.25) {
anchorX = -outside * w + px * w
dirX = 1
dirY = (centerY - py) * 0.5
} else if (px >= 0.75) {
anchorX = (1 + outside) * w - (1 - px) * w
dirX = -1
dirY = (centerY - py) * 0.5
} else {
dirY = 1
}
const len = Math.sqrt(dirX * dirX + dirY * dirY)
if (len > 0) {
dirX /= len
dirY /= len
}
return { anchor: [anchorX, anchorY], dir: [dirX, dirY] }
}
interface UniformData {
iTime: number
iResolution: [number, number]
lightPos: [number, number]
lightDir: [number, number]
color: [number, number, number]
speed: number
lightSpread: number
lightLength: number
sourceWidth: number
pulsating: number
pulsatingMin: number
pulsatingMax: number
fadeDistance: number
saturation: number
noiseAmount: number
distortion: number
particlesEnabled: number
particleAmount: number
particleSizeMin: number
particleSizeMax: number
particleSpeed: number
particleOpacity: number
particleDrift: number
}
const WGSL_SHADER = `
struct Uniforms {
iTime: f32,
_pad0: f32,
iResolution: vec2<f32>,
lightPos: vec2<f32>,
lightDir: vec2<f32>,
color: vec3<f32>,
speed: f32,
lightSpread: f32,
lightLength: f32,
sourceWidth: f32,
pulsating: f32,
pulsatingMin: f32,
pulsatingMax: f32,
fadeDistance: f32,
saturation: f32,
noiseAmount: f32,
distortion: f32,
particlesEnabled: f32,
particleAmount: f32,
particleSizeMin: f32,
particleSizeMax: f32,
particleSpeed: f32,
particleOpacity: f32,
particleDrift: f32,
_pad1: f32,
_pad2: f32,
};
@group(0) @binding(0) var<uniform> uniforms: Uniforms;
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) vUv: vec2<f32>,
};
@vertex
fn vertexMain(@builtin(vertex_index) vertexIndex: u32) -> VertexOutput {
var positions = array<vec2<f32>, 3>(
vec2<f32>(-1.0, -1.0),
vec2<f32>(3.0, -1.0),
vec2<f32>(-1.0, 3.0)
);
var output: VertexOutput;
let pos = positions[vertexIndex];
output.position = vec4<f32>(pos, 0.0, 1.0);
output.vUv = pos * 0.5 + 0.5;
return output;
}
fn hash(p: vec2<f32>) -> f32 {
let p3 = fract(p.xyx * 0.1031);
return fract((p3.x + p3.y) * p3.z + dot(p3, p3.yzx + 33.33));
}
fn hash2(p: vec2<f32>) -> vec2<f32> {
let n = sin(dot(p, vec2<f32>(41.0, 289.0)));
return fract(vec2<f32>(n * 262144.0, n * 32768.0));
}
fn fastNoise(st: vec2<f32>) -> f32 {
return fract(sin(dot(st, vec2<f32>(12.9898, 78.233))) * 43758.5453);
}
fn lightStrengthCombined(lightSource: vec2<f32>, lightRefDirection: vec2<f32>, coord: vec2<f32>) -> f32 {
let sourceToCoord = coord - lightSource;
let distSq = dot(sourceToCoord, sourceToCoord);
let distance = sqrt(distSq);
let baseSize = min(uniforms.iResolution.x, uniforms.iResolution.y);
let maxDistance = max(baseSize * uniforms.lightLength, 0.001);
if (distance > maxDistance) {
return 0.0;
}
let invDist = 1.0 / max(distance, 0.001);
let dirNorm = sourceToCoord * invDist;
let cosAngle = dot(dirNorm, lightRefDirection);
if (cosAngle < 0.0) {
return 0.0;
}
let side = dot(dirNorm, vec2<f32>(-lightRefDirection.y, lightRefDirection.x));
let time = uniforms.iTime;
let speed = uniforms.speed;
let asymNoise = fastNoise(vec2<f32>(side * 6.0 + time * 0.12, distance * 0.004 + cosAngle * 2.0));
let asymShift = (asymNoise - 0.5) * uniforms.distortion * 0.6;
let distortPhase = time * 1.4 + distance * 0.006 + cosAngle * 4.5 + side * 1.7;
let distortedAngle = cosAngle + uniforms.distortion * sin(distortPhase) * 0.22 + asymShift;
let flickerSeed = cosAngle * 9.0 + side * 4.0 + time * speed * 0.35;
let flicker = 0.86 + fastNoise(vec2<f32>(flickerSeed, distance * 0.01)) * 0.28;
let asymSpread = max(uniforms.lightSpread * (0.9 + (asymNoise - 0.5) * 0.25), 0.001);
let spreadFactor = pow(max(distortedAngle, 0.0), 1.0 / asymSpread);
let lengthFalloff = clamp(1.0 - distance / maxDistance, 0.0, 1.0);
let fadeMaxDist = max(baseSize * uniforms.fadeDistance, 0.001);
let fadeFalloff = clamp((fadeMaxDist - distance) / fadeMaxDist, 0.0, 1.0);
var pulse: f32 = 1.0;
if (uniforms.pulsating > 0.5) {
let pulseCenter = (uniforms.pulsatingMin + uniforms.pulsatingMax) * 0.5;
let pulseAmplitude = (uniforms.pulsatingMax - uniforms.pulsatingMin) * 0.5;
pulse = pulseCenter + pulseAmplitude * sin(time * speed * 3.0);
}
let timeSpeed = time * speed;
let wave = 0.5
+ 0.25 * sin(cosAngle * 28.0 + side * 8.0 + timeSpeed * 1.2)
+ 0.18 * cos(cosAngle * 22.0 - timeSpeed * 0.95 + side * 6.0)
+ 0.12 * sin(cosAngle * 35.0 + timeSpeed * 1.6 + asymNoise * 3.0);
let minStrength = 0.14 + asymNoise * 0.06;
let baseStrength = max(clamp(wave * (0.85 + asymNoise * 0.3), 0.0, 1.0), minStrength);
let lightStrength = baseStrength * lengthFalloff * fadeFalloff * spreadFactor * pulse * flicker;
let ambientLight = (0.06 + asymNoise * 0.04) * lengthFalloff * fadeFalloff * spreadFactor;
return max(lightStrength, ambientLight);
}
fn particle(coord: vec2<f32>, particlePos: vec2<f32>, size: f32) -> f32 {
let delta = coord - particlePos;
let distSq = dot(delta, delta);
let sizeSq = size * size;
if (distSq > sizeSq * 9.0) {
return 0.0;
}
let d = sqrt(distSq);
let core = smoothstep(size, size * 0.35, d);
let glow = smoothstep(size * 3.0, 0.0, d) * 0.55;
return core + glow;
}
fn renderParticles(coord: vec2<f32>, lightSource: vec2<f32>, lightDir: vec2<f32>) -> f32 {
if (uniforms.particlesEnabled < 0.5 || uniforms.particleAmount < 1.0) {
return 0.0;
}
var particleSum: f32 = 0.0;
let particleCount = i32(uniforms.particleAmount);
let time = uniforms.iTime * uniforms.particleSpeed;
let perpDir = vec2<f32>(-lightDir.y, lightDir.x);
let baseSize = min(uniforms.iResolution.x, uniforms.iResolution.y);
let maxDist = max(baseSize * uniforms.lightLength, 1.0);
let spreadScale = uniforms.lightSpread * baseSize * 0.65;
let coneHalfWidth = uniforms.lightSpread * baseSize * 0.55;
for (var i: i32 = 0; i < particleCount; i = i + 1) {
let fi = f32(i);
let seed = vec2<f32>(fi * 127.1, fi * 311.7);
let rnd = hash2(seed);
let lifeDuration = 2.0 + hash(seed + vec2<f32>(19.0, 73.0)) * 3.0;
let lifeOffset = hash(seed + vec2<f32>(91.0, 37.0)) * lifeDuration;
let lifeProgress = fract((time + lifeOffset) / lifeDuration);
let fadeIn = smoothstep(0.0, 0.2, lifeProgress);
let fadeOut = 1.0 - smoothstep(0.8, 1.0, lifeProgress);
let lifeFade = fadeIn * fadeOut;
if (lifeFade < 0.01) {
continue;
}
let alongLight = rnd.x * maxDist * 0.8;
let perpOffset = (rnd.y - 0.5) * spreadScale;
let floatPhase = rnd.y * 6.28318 + fi * 0.37;
let floatSpeed = 0.35 + rnd.x * 0.9;
let drift = vec2<f32>(
sin(time * floatSpeed + floatPhase),
cos(time * floatSpeed * 0.85 + floatPhase * 1.3)
) * uniforms.particleDrift * baseSize * 0.08;
let wobble = vec2<f32>(
sin(time * 1.4 + floatPhase * 2.1),
cos(time * 1.1 + floatPhase * 1.6)
) * uniforms.particleDrift * baseSize * 0.03;
let flowOffset = (rnd.x - 0.5) * baseSize * 0.12 + fract(time * 0.06 + rnd.y) * baseSize * 0.1;
let basePos = lightSource + lightDir * (alongLight + flowOffset) + perpDir * perpOffset + drift + wobble;
let toParticle = basePos - lightSource;
let projLen = dot(toParticle, lightDir);
if (projLen < 0.0 || projLen > maxDist) {
continue;
}
let sideDist = abs(dot(toParticle, perpDir));
if (sideDist > coneHalfWidth) {
continue;
}
let size = mix(uniforms.particleSizeMin, uniforms.particleSizeMax, rnd.x);
let twinkle = 0.7 + 0.3 * sin(time * (1.5 + rnd.y * 2.0) + floatPhase);
let distFade = 1.0 - smoothstep(maxDist * 0.2, maxDist * 0.95, projLen);
if (distFade < 0.01) {
continue;
}
let p = particle(coord, basePos, size);
if (p > 0.0) {
particleSum = particleSum + p * lifeFade * twinkle * distFade * uniforms.particleOpacity;
if (particleSum >= 1.0) {
break;
}
}
}
return min(particleSum, 1.0);
}
@fragment
fn fragmentMain(@builtin(position) fragCoord: vec4<f32>, @location(0) vUv: vec2<f32>) -> @location(0) vec4<f32> {
let coord = vec2<f32>(fragCoord.x, fragCoord.y);
let normalizedX = (coord.x / uniforms.iResolution.x) - 0.5;
let widthOffset = -normalizedX * uniforms.sourceWidth * uniforms.iResolution.x;
let perpDir = vec2<f32>(-uniforms.lightDir.y, uniforms.lightDir.x);
let adjustedLightPos = uniforms.lightPos + perpDir * widthOffset;
let lightValue = lightStrengthCombined(adjustedLightPos, uniforms.lightDir, coord);
if (lightValue < 0.001) {
let particles = renderParticles(coord, adjustedLightPos, uniforms.lightDir);
if (particles < 0.001) {
return vec4<f32>(0.0, 0.0, 0.0, 0.0);
}
let particleBrightness = particles * 1.8;
return vec4<f32>(uniforms.color * particleBrightness, particles * 0.9);
}
var fragColor = vec4<f32>(lightValue, lightValue, lightValue, lightValue);
if (uniforms.noiseAmount > 0.01) {
let n = fastNoise(coord * 0.5 + uniforms.iTime * 0.5);
let grain = mix(1.0, n, uniforms.noiseAmount * 0.5);
fragColor = vec4<f32>(fragColor.rgb * grain, fragColor.a);
}
let brightness = 1.0 - (coord.y / uniforms.iResolution.y);
fragColor = vec4<f32>(
fragColor.x * (0.15 + brightness * 0.85),
fragColor.y * (0.35 + brightness * 0.65),
fragColor.z * (0.55 + brightness * 0.45),
fragColor.a
);
if (abs(uniforms.saturation - 1.0) > 0.01) {
let gray = dot(fragColor.rgb, vec3<f32>(0.299, 0.587, 0.114));
fragColor = vec4<f32>(mix(vec3<f32>(gray), fragColor.rgb, uniforms.saturation), fragColor.a);
}
fragColor = vec4<f32>(fragColor.rgb * uniforms.color, fragColor.a);
let particles = renderParticles(coord, adjustedLightPos, uniforms.lightDir);
if (particles > 0.001) {
let particleBrightness = particles * 1.8;
fragColor = vec4<f32>(fragColor.rgb + uniforms.color * particleBrightness, max(fragColor.a, particles * 0.9));
}
return fragColor;
}
`
const UNIFORM_BUFFER_SIZE = 144
function updateUniformBuffer(buffer: Float32Array, data: UniformData): void {
buffer[0] = data.iTime
buffer[2] = data.iResolution[0]
buffer[3] = data.iResolution[1]
buffer[4] = data.lightPos[0]
buffer[5] = data.lightPos[1]
buffer[6] = data.lightDir[0]
buffer[7] = data.lightDir[1]
buffer[8] = data.color[0]
buffer[9] = data.color[1]
buffer[10] = data.color[2]
buffer[11] = data.speed
buffer[12] = data.lightSpread
buffer[13] = data.lightLength
buffer[14] = data.sourceWidth
buffer[15] = data.pulsating
buffer[16] = data.pulsatingMin
buffer[17] = data.pulsatingMax
buffer[18] = data.fadeDistance
buffer[19] = data.saturation
buffer[20] = data.noiseAmount
buffer[21] = data.distortion
buffer[22] = data.particlesEnabled
buffer[23] = data.particleAmount
buffer[24] = data.particleSizeMin
buffer[25] = data.particleSizeMax
buffer[26] = data.particleSpeed
buffer[27] = data.particleOpacity
buffer[28] = data.particleDrift
}
export default function Spotlight(props: SpotlightProps) {
let containerRef: HTMLDivElement | undefined
let canvasRef: HTMLCanvasElement | null = null
let deviceRef: GPUDevice | null = null
let contextRef: GPUCanvasContext | null = null
let pipelineRef: GPURenderPipeline | null = null
let uniformBufferRef: GPUBuffer | null = null
let bindGroupRef: GPUBindGroup | null = null
let animationIdRef: number | null = null
let cleanupFunctionRef: (() => void) | null = null
let uniformDataRef: UniformData | null = null
let uniformArrayRef: Float32Array | null = null
let configRef: SpotlightConfig = props.config()
let frameCount = 0
const [isVisible, setIsVisible] = createSignal(false)
createEffect(() => {
configRef = props.config()
})
onMount(() => {
if (!containerRef) return
const observer = new IntersectionObserver(
(entries) => {
const entry = entries[0]
setIsVisible(entry.isIntersecting)
},
{ threshold: 0.1 },
)
observer.observe(containerRef)
onCleanup(() => {
observer.disconnect()
})
})
createEffect(() => {
const visible = isVisible()
const config = props.config()
if (!visible || !containerRef) {
return
}
if (cleanupFunctionRef) {
cleanupFunctionRef()
cleanupFunctionRef = null
}
const initializeWebGPU = async () => {
if (!containerRef) {
return
}
await new Promise((resolve) => setTimeout(resolve, 10))
if (!containerRef) {
return
}
if (!navigator.gpu) {
console.warn("WebGPU is not supported in this browser")
return
}
const adapter = await navigator.gpu.requestAdapter({
powerPreference: "high-performance",
})
if (!adapter) {
console.warn("Failed to get WebGPU adapter")
return
}
const device = await adapter.requestDevice()
deviceRef = device
const canvas = document.createElement("canvas")
canvas.style.width = "100%"
canvas.style.height = "100%"
canvasRef = canvas
while (containerRef.firstChild) {
containerRef.removeChild(containerRef.firstChild)
}
containerRef.appendChild(canvas)
const context = canvas.getContext("webgpu")
if (!context) {
console.warn("Failed to get WebGPU context")
return
}
contextRef = context
const presentationFormat = navigator.gpu.getPreferredCanvasFormat()
context.configure({
device,
format: presentationFormat,
alphaMode: "premultiplied",
})
const shaderModule = device.createShaderModule({
code: WGSL_SHADER,
})
const uniformBuffer = device.createBuffer({
size: UNIFORM_BUFFER_SIZE,
usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
})
uniformBufferRef = uniformBuffer
const bindGroupLayout = device.createBindGroupLayout({
entries: [
{
binding: 0,
visibility: GPUShaderStage.VERTEX | GPUShaderStage.FRAGMENT,
buffer: { type: "uniform" },
},
],
})
const bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [
{
binding: 0,
resource: { buffer: uniformBuffer },
},
],
})
bindGroupRef = bindGroup
const pipelineLayout = device.createPipelineLayout({
bindGroupLayouts: [bindGroupLayout],
})
const pipeline = device.createRenderPipeline({
layout: pipelineLayout,
vertex: {
module: shaderModule,
entryPoint: "vertexMain",
},
fragment: {
module: shaderModule,
entryPoint: "fragmentMain",
targets: [
{
format: presentationFormat,
blend: {
color: {
srcFactor: "src-alpha",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
alpha: {
srcFactor: "one",
dstFactor: "one-minus-src-alpha",
operation: "add",
},
},
},
],
},
primitive: {
topology: "triangle-list",
},
})
pipelineRef = pipeline
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
const dpr = Math.min(window.devicePixelRatio, 2)
const w = wCSS * dpr
const h = hCSS * dpr
const { anchor, dir } = getAnchorAndDir(config.placement, w, h)
uniformDataRef = {
iTime: 0,
iResolution: [w, h],
lightPos: anchor,
lightDir: dir,
color: hexToRgb(config.color),
speed: config.speed,
lightSpread: config.spread,
lightLength: config.length,
sourceWidth: config.width,
pulsating: config.pulsating !== false ? 1.0 : 0.0,
pulsatingMin: config.pulsating !== false ? config.pulsating[0] : 1.0,
pulsatingMax: config.pulsating !== false ? config.pulsating[1] : 1.0,
fadeDistance: config.distance,
saturation: config.saturation,
noiseAmount: config.noiseAmount,
distortion: config.distortion,
particlesEnabled: config.particles.enabled ? 1.0 : 0.0,
particleAmount: config.particles.amount,
particleSizeMin: config.particles.size[0],
particleSizeMax: config.particles.size[1],
particleSpeed: config.particles.speed,
particleOpacity: config.particles.opacity,
particleDrift: config.particles.drift,
}
const updatePlacement = () => {
if (!containerRef || !canvasRef || !uniformDataRef) {
return
}
const dpr = Math.min(window.devicePixelRatio, 2)
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
const w = Math.floor(wCSS * dpr)
const h = Math.floor(hCSS * dpr)
canvasRef.width = w
canvasRef.height = h
uniformDataRef.iResolution = [w, h]
const { anchor, dir } = getAnchorAndDir(configRef.placement, w, h)
uniformDataRef.lightPos = anchor
uniformDataRef.lightDir = dir
}
const loop = (t: number) => {
if (!deviceRef || !contextRef || !pipelineRef || !uniformBufferRef || !bindGroupRef || !uniformDataRef) {
return
}
const timeSeconds = t * 0.001
uniformDataRef.iTime = timeSeconds
frameCount++
if (props.onAnimationFrame && frameCount % 2 === 0) {
const pulsatingMin = configRef.pulsating !== false ? configRef.pulsating[0] : 1.0
const pulsatingMax = configRef.pulsating !== false ? configRef.pulsating[1] : 1.0
const pulseCenter = (pulsatingMin + pulsatingMax) * 0.5
const pulseAmplitude = (pulsatingMax - pulsatingMin) * 0.5
const pulseValue =
configRef.pulsating !== false
? pulseCenter + pulseAmplitude * Math.sin(timeSeconds * configRef.speed * 3.0)
: 1.0
const baseIntensity1 = 0.45 + 0.15 * Math.sin(timeSeconds * configRef.speed * 1.5)
const baseIntensity2 = 0.3 + 0.2 * Math.cos(timeSeconds * configRef.speed * 1.1)
const intensity = Math.max((baseIntensity1 + baseIntensity2) * pulseValue, 0.55)
props.onAnimationFrame({
time: timeSeconds,
intensity,
pulseValue: Math.max(pulseValue, 0.9),
})
}
try {
if (!uniformArrayRef) {
uniformArrayRef = new Float32Array(36)
}
updateUniformBuffer(uniformArrayRef, uniformDataRef)
deviceRef.queue.writeBuffer(uniformBufferRef, 0, uniformArrayRef.buffer)
const commandEncoder = deviceRef.createCommandEncoder()
const textureView = contextRef.getCurrentTexture().createView()
const renderPass = commandEncoder.beginRenderPass({
colorAttachments: [
{
view: textureView,
clearValue: { r: 0, g: 0, b: 0, a: 0 },
loadOp: "clear",
storeOp: "store",
},
],
})
renderPass.setPipeline(pipelineRef)
renderPass.setBindGroup(0, bindGroupRef)
renderPass.draw(3)
renderPass.end()
deviceRef.queue.submit([commandEncoder.finish()])
animationIdRef = requestAnimationFrame(loop)
} catch (error) {
console.warn("WebGPU rendering error:", error)
return
}
}
window.addEventListener("resize", updatePlacement)
updatePlacement()
animationIdRef = requestAnimationFrame(loop)
cleanupFunctionRef = () => {
if (animationIdRef) {
cancelAnimationFrame(animationIdRef)
animationIdRef = null
}
window.removeEventListener("resize", updatePlacement)
if (uniformBufferRef) {
uniformBufferRef.destroy()
uniformBufferRef = null
}
if (deviceRef) {
deviceRef.destroy()
deviceRef = null
}
if (canvasRef && canvasRef.parentNode) {
canvasRef.parentNode.removeChild(canvasRef)
}
canvasRef = null
contextRef = null
pipelineRef = null
bindGroupRef = null
uniformDataRef = null
}
}
initializeWebGPU()
onCleanup(() => {
if (cleanupFunctionRef) {
cleanupFunctionRef()
cleanupFunctionRef = null
}
})
})
createEffect(() => {
if (!uniformDataRef || !containerRef) {
return
}
const config = props.config()
uniformDataRef.color = hexToRgb(config.color)
uniformDataRef.speed = config.speed
uniformDataRef.lightSpread = config.spread
uniformDataRef.lightLength = config.length
uniformDataRef.sourceWidth = config.width
uniformDataRef.pulsating = config.pulsating !== false ? 1.0 : 0.0
uniformDataRef.pulsatingMin = config.pulsating !== false ? config.pulsating[0] : 1.0
uniformDataRef.pulsatingMax = config.pulsating !== false ? config.pulsating[1] : 1.0
uniformDataRef.fadeDistance = config.distance
uniformDataRef.saturation = config.saturation
uniformDataRef.noiseAmount = config.noiseAmount
uniformDataRef.distortion = config.distortion
uniformDataRef.particlesEnabled = config.particles.enabled ? 1.0 : 0.0
uniformDataRef.particleAmount = config.particles.amount
uniformDataRef.particleSizeMin = config.particles.size[0]
uniformDataRef.particleSizeMax = config.particles.size[1]
uniformDataRef.particleSpeed = config.particles.speed
uniformDataRef.particleOpacity = config.particles.opacity
uniformDataRef.particleDrift = config.particles.drift
const dpr = Math.min(window.devicePixelRatio, 2)
const { clientWidth: wCSS, clientHeight: hCSS } = containerRef
const { anchor, dir } = getAnchorAndDir(config.placement, wCSS * dpr, hCSS * dpr)
uniformDataRef.lightPos = anchor
uniformDataRef.lightDir = dir
})
return (
<div
ref={containerRef}
class={`spotlight-container ${props.class ?? ""}`.trim()}
style={{ opacity: props.config().opacity }}
/>
)
}
+3 -3
View File
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "70K",
full: "70,000",
compact: "45K",
full: "45,000",
},
},
@@ -23,7 +23,7 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "500",
commits: "7,000",
commits: "6,500",
monthlyUsers: "650,000",
},
} as const
@@ -0,0 +1,24 @@
import { useSession } from "@solidjs/start/http"
export interface AuthSession {
account?: Record<
string,
{
id: string
email: string
}
>
current?: string
}
export function useAuthSession() {
return useSession<AuthSession>({
password: "0".repeat(32),
name: "auth",
maxAge: 60 * 60 * 24 * 365,
cookie: {
secure: false,
httpOnly: true,
},
})
}
+1 -26
View File
@@ -5,38 +5,13 @@ import { redirect } from "@solidjs/router"
import { Actor } from "@opencode-ai/console-core/actor.js"
import { createClient } from "@openauthjs/openauth/client"
import { useAuthSession } from "./auth.session"
export const AuthClient = createClient({
clientID: "app",
issuer: import.meta.env.VITE_AUTH_URL,
})
import { useSession } from "@solidjs/start/http"
import { Resource } from "@opencode-ai/console-resource"
export interface AuthSession {
account?: Record<
string,
{
id: string
email: string
}
>
current?: string
}
export function useAuthSession() {
return useSession<AuthSession>({
password: Resource.ZEN_SESSION_SECRET.value,
name: "auth",
maxAge: 60 * 60 * 24 * 365,
cookie: {
secure: false,
httpOnly: true,
},
})
}
export const getActor = async (workspace?: string): Promise<Actor.Info> => {
"use server"
const evt = getRequestEvent()
+6 -7
View File
@@ -14,14 +14,13 @@ export const github = query(async () => {
fetch(`${apiBaseUrl}/releases`, { headers }).then((res) => res.json()),
fetch(`${apiBaseUrl}/contributors?per_page=1`, { headers }),
])
if (!Array.isArray(releases) || releases.length === 0) {
return undefined
}
const [release] = releases
const linkHeader = contributors.headers.get("Link")
const contributorCount = linkHeader
? Number.parseInt(linkHeader.match(/&page=(\d+)>; rel="last"/)?.at(1) ?? "0")
: 0
const contributorCount = Number.parseInt(
contributors.headers
.get("Link")!
.match(/&page=(\d+)>; rel="last"/)!
.at(1)!,
)
return {
stars: meta.stargazers_count,
release: {
@@ -2,9 +2,6 @@ import type { APIEvent } from "@solidjs/start/server"
import { AuthClient } from "~/context/auth"
export async function GET(input: APIEvent) {
const url = new URL(input.request.url)
const cont = url.searchParams.get("continue") ?? ""
const callbackUrl = new URL(`./callback${cont}`, input.request.url)
const result = await AuthClient.authorize(callbackUrl.toString(), "code")
const result = await AuthClient.authorize(new URL("./callback", input.request.url).toString(), "code")
return Response.redirect(result.url, 302)
}

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