Compare commits

..

1 Commits

Author SHA1 Message Date
opencode
aaddd532aa release: v1.0.116 2025-11-27 01:21:40 +00:00
783 changed files with 44706 additions and 49147 deletions

57
.github/guidelines-check.yml vendored Normal file
View File

@@ -0,0 +1,57 @@
#
# This file is intentionally in the wrong dir, will move and add later....
#
name: Guidelines Check
on:
# Disabled - uncomment to re-enable
# pull_request_target:
# types: [opened, synchronize]
jobs:
check-guidelines:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Check PR guidelines compliance
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OPENCODE_PERMISSION: '{ "bash": { "gh*": "allow", "gh pr review*": "deny", "*": "deny" } }'
run: |
opencode run -m anthropic/claude-sonnet-4-20250514 "A new pull request has been created: '${{ github.event.pull_request.title }}'
<pr-number>
${{ github.event.pull_request.number }}
</pr-number>
<pr-description>
${{ github.event.pull_request.body }}
</pr-description>
Please check all the code changes in this pull request against the guidelines in AGENTS.md file in this repository. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
Command MUST be like this.
```
gh api \
--method POST \
-H "Accept: application/vnd.github+json" \
-H "X-GitHub-Api-Version: 2022-11-28" \
/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/comments \
-f 'body=[summary of issue]' -f 'commit_id=${{ github.event.pull_request.head.sha }}' -f 'path=[path-to-file]' -F "line=[line]" -f 'side=RIGHT'
```
Only create comments for actual violations. If the code follows all guidelines, don't run any gh commands."

View File

@@ -6,7 +6,7 @@ on:
jobs:
auto-label:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
permissions:
contents: read
issues: write

View File

@@ -11,7 +11,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
deploy:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3

View File

@@ -6,7 +6,7 @@ on:
jobs:
check-duplicates:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
@@ -55,7 +55,4 @@ jobs:
Feel free to ignore if none of these address your specific case.'
Additionally, if the issue mentions keybinds, keyboard shortcuts, or key bindings, please add a comment mentioning the pinned keybinds issue #4997:
'For keybind-related issues, please also check our pinned keybinds documentation: #4997'
If no clear duplicates are found, do not comment."

View File

@@ -10,7 +10,7 @@ on:
workflow_dispatch:
jobs:
format:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
permissions:
contents: write
steps:
@@ -18,8 +18,6 @@ jobs:
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
ref: ${{ github.event.pull_request.head.ref || github.ref_name }}
- name: Setup Bun
uses: ./.github/actions/setup-bun
@@ -29,4 +27,3 @@ jobs:
./script/format.ts
env:
CI: true
PUSH_BRANCH: ${{ github.event.pull_request.head.ref || github.ref_name }}

View File

@@ -6,7 +6,7 @@ on:
jobs:
notify:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- name: Send nicely-formatted embed to Discord
uses: SethCohen/github-releases-to-discord@v1

View File

@@ -13,7 +13,7 @@ jobs:
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
startsWith(github.event.comment.body, '/opencode')
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
@@ -30,4 +30,4 @@ jobs:
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
with:
model: opencode/claude-haiku-4-5
model: opencode/glm-4.6

View File

@@ -14,7 +14,7 @@ permissions:
jobs:
publish:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:

View File

@@ -13,7 +13,7 @@ permissions:
jobs:
publish:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:

View File

@@ -25,8 +25,7 @@ permissions:
jobs:
publish:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'sst/opencode' && github.ref == 'refs/heads/dev'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
@@ -80,103 +79,3 @@ jobs:
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
publish-tauri:
continue-on-error: true
strategy:
fail-fast: false
matrix:
settings:
- host: macos-latest
target: x86_64-apple-darwin
- host: macos-latest
target: aarch64-apple-darwin
- host: windows-latest
target: x86_64-pc-windows-msvc
- host: ubuntu-24.04
target: x86_64-unknown-linux-gnu
runs-on: ${{ matrix.settings.host }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
- uses: apple-actions/import-codesign-certs@v2
if: ${{ runner.os == 'macOS' }}
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- name: Verify Certificate
if: ${{ runner.os == 'macOS' }}
run: |
CERT_INFO=$(security find-identity -v -p codesigning build.keychain | grep "Developer ID Application")
CERT_ID=$(echo "$CERT_INFO" | awk -F'"' '{print $2}')
echo "CERT_ID=$CERT_ID" >> $GITHUB_ENV
echo "Certificate imported."
- name: Setup Apple API Key
if: ${{ runner.os == 'macOS' }}
run: |
echo "${{ secrets.APPLE_API_KEY_PATH }}" > $RUNNER_TEMP/apple-api-key.p8
- run: git fetch --force --tags
- uses: ./.github/actions/setup-bun
- name: install dependencies (ubuntu only)
if: startsWith(matrix.settings.host, 'ubuntu')
run: |
sudo apt-get update
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- name: install Rust stable
uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.settings.target }}
- uses: Swatinem/rust-cache@v2
with:
workspaces: packages/tauri/src-tauri
shared-key: ${{ matrix.settings.target }}
- name: Prepare
run: |
cd packages/tauri
bun ./scripts/prepare.ts
env:
OPENCODE_BUMP: ${{ inputs.bump }}
OPENCODE_VERSION: ${{ inputs.version }}
OPENCODE_CHANNEL: latest
NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }}
GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
AUR_KEY: ${{ secrets.AUR_KEY }}
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
# Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
- run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage
if: startsWith(matrix.settings.host, 'ubuntu')
- name: Build and upload artifacts
uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAURI_BUNDLER_NEW_APPIMAGE_FORMAT: true
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ env.CERT_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
with:
projectPath: packages/tauri
uploadWorkflowArtifacts: true
tauriScript: ${{ (startsWith(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
args: --target ${{ matrix.settings.target }}
updaterJsonPreferNsis: true
# releaseId: TODO

View File

@@ -1,79 +0,0 @@
name: Guidelines Check
on:
issue_comment:
types: [created]
jobs:
check-guidelines:
if: |
github.event.issue.pull_request &&
startsWith(github.event.comment.body, '/review') &&
contains(fromJson('["OWNER","MEMBER"]'), github.event.comment.author_association)
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: read
pull-requests: write
steps:
- name: Get PR number
id: pr-number
run: |
if [ "${{ github.event_name }}" = "pull_request_target" ]; then
echo "number=${{ github.event.pull_request.number }}" >> $GITHUB_OUTPUT
else
echo "number=${{ github.event.issue.number }}" >> $GITHUB_OUTPUT
fi
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Install opencode
run: curl -fsSL https://opencode.ai/install | bash
- name: Get PR details
id: pr-details
run: |
gh api /repos/${{ github.repository }}/pulls/${{ steps.pr-number.outputs.number }} > pr_data.json
echo "title=$(jq -r .title pr_data.json)" >> $GITHUB_OUTPUT
echo "sha=$(jq -r .head.sha pr_data.json)" >> $GITHUB_OUTPUT
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check PR guidelines compliance
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
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)
opencode run -m anthropic/claude-opus-4-5 "A new pull request has been created: '${PR_TITLE}'
<pr-number>
${{ steps.pr-number.outputs.number }}
</pr-number>
<pr-description>
$PR_BODY
</pr-description>
Please check all the code changes in this pull request against the style guide, also look for any bugs if they exist. Diffs are important but make sure you read the entire file to get proper context. Make it clear the suggestions are merely suggestions and the human can decide what to do
When critiquing code against the style guide, be sure that the code is ACTUALLY in violation, don't complain about else statements if they already use early returns there. You may complain about excessive nesting though, regardless of else statement usage.
When critiquing code style don't be a zealot, we don't like "let" statements but sometimes they are the simpliest option, if someone does a bunch of nesting with let, they should consider using iife (see packages/opencode/src/util.iife.ts)
Use the gh cli to create comments on the files for the violations. Try to leave the comment on the exact line number. If you have a suggested fix include it in a suggestion code block.
Command MUST be like this.
\`\`\`
gh api \
--method POST \
-H \"Accept: application/vnd.github+json\" \
-H \"X-GitHub-Api-Version: 2022-11-28\" \
/repos/${{ github.repository }}/pulls/${{ steps.pr-number.outputs.number }}/comments \
-f 'body=[summary of issue]' -f 'commit_id=${{ steps.pr-details.outputs.sha }}' -f 'path=[path-to-file]' -F \"line=[line]\" -f 'side=RIGHT'
\`\`\`
Only create comments for actual violations. If the code follows all guidelines, comment on the issue using gh cli: 'lgtm' AND NOTHING ELSE!!!!."

View File

@@ -1,39 +0,0 @@
name: sdk
on:
push:
branches-ignore:
- production
pull_request:
branches-ignore:
- production
workflow_dispatch:
jobs:
format:
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: run
run: |
bun ./packages/sdk/js/script/build.ts
(cd packages/opencode && bun dev generate > ../sdk/openapi.json)
if [ -z "$(git status --porcelain)" ]; then
echo "No changes to commit"
exit 0
fi
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add -A
git commit -m "chore: regen sdk"
git push --no-verify
env:
CI: true

View File

@@ -1,20 +1,17 @@
name: snapshot
on:
workflow_dispatch:
push:
branches:
- dev
- test-bedrock
- v0
- otui-diffs
- snapshot-*
concurrency: ${{ github.workflow }}-${{ github.ref }}
jobs:
publish:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:

View File

@@ -7,7 +7,7 @@ on:
jobs:
stats:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
permissions:
contents: write

View File

@@ -8,7 +8,7 @@ on:
jobs:
zed:
name: Release Zed Extension
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:

View File

@@ -10,7 +10,7 @@ on:
workflow_dispatch:
jobs:
test:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -28,3 +28,9 @@ jobs:
bun turbo test
env:
CI: true
- name: Check SDK is up to date
run: |
bun ./packages/sdk/js/script/build.ts
git diff --exit-code packages/sdk/js/src/gen packages/sdk/js/dist
continue-on-error: false

View File

@@ -7,7 +7,7 @@ on:
jobs:
typecheck:
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

View File

@@ -18,8 +18,7 @@ on:
jobs:
update:
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: blacksmith-4vcpu-ubuntu-2404
runs-on: ubuntu-latest
env:
SYSTEM: x86_64-linux
@@ -30,7 +29,6 @@ jobs:
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: DeterminateSystems/nix-installer-action@v20

2
.gitignore vendored
View File

@@ -6,6 +6,7 @@ node_modules
.idea
.vscode
*~
openapi.json
playground
tmp
dist
@@ -17,4 +18,3 @@ refs
Session.vim
opencode.json
a.out
target

View File

@@ -1,9 +1,2 @@
#!/bin/sh
# Check if bun version matches package.json
EXPECTED_VERSION=$(grep '"packageManager"' package.json | sed 's/.*"bun@\([^"]*\)".*/\1/')
CURRENT_VERSION=$(bun --version)
if [ "$CURRENT_VERSION" != "$EXPECTED_VERSION" ]; then
echo "Error: Bun version $CURRENT_VERSION does not match expected version $EXPECTED_VERSION from package.json"
exit 1
fi
bun typecheck

View File

@@ -1,5 +1,5 @@
---
description: git commit and push
description: Git commit and push
---
commit and push
@@ -21,6 +21,3 @@ WHAT was done.
do not do generic messages like "improved agent experience" be very specific
about what user facing changes were made
if there are changes do a git pull --rebase
if there are conflicts DO NOT FIX THEM. notify me and I will fix them

View File

@@ -0,0 +1,8 @@
---
description: hello world iaosd ioasjdoiasjd oisadjoisajd osiajd oisaj dosaij dsoajsajdaijdoisa jdoias jdoias jdoia jois jo jdois jdoias jdoias j djoasdj
---
hey there $ARGUMENTS
!`ls`
check out @README.md

View File

@@ -1,5 +1,5 @@
---
description: "find issue(s) on github"
description: "Find issue(s) on github"
model: opencode/claude-haiku-4-5
---

View File

@@ -1,15 +0,0 @@
---
description: Remove AI code slop
---
Check the diff against dev, and remove all AI generated slop introduced in this branch.
This includes:
- Extra comments that a human wouldn't add or is inconsistent with the rest of the file
- Extra defensive checks or try/catch blocks that are abnormal for that area of the codebase (especially if called by trusted / validated codepaths)
- Casts to any to get around type issues
- Any other style that is inconsistent with the file
- Unnecessary emoji usage
Report at the end with only a 1-3 sentence summary of what you changed

View File

@@ -1,5 +1,5 @@
---
description: spellcheck all markdown file changes
description: Spellcheck all markdown file changes
---
Look at all the unstaged changes to markdown (.md, .mdx) files, pull out the lines that have changed, and check for spelling and grammar errors.

View File

@@ -4,7 +4,6 @@
// "enterprise": {
// "url": "https://enterprise.dev.opencode.ai",
// },
"instructions": ["STYLE_GUIDE.md"],
"provider": {
"opencode": {
"options": {
@@ -17,12 +16,5 @@
"type": "remote",
"url": "https://mcp.exa.ai/mcp",
},
"morph": {
"type": "local",
"command": ["bunx", "@morphllm/morphmcp"],
"environment": {
"ENABLED_TOOLS": "warp_grep",
},
},
},
}

View File

@@ -1 +0,0 @@
sst-env.d.ts

View File

@@ -1,3 +1,16 @@
## IMPORTANT
- 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()
## Debugging
- To test opencode in the `packages/opencode` directory you can run `bun dev`

View File

@@ -42,8 +42,6 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
> [!NOTE]
> After touching `packages/opencode/src/server/server.ts`, run "./packages/sdk/js/script/build.ts" to regenerate the JS sdk.
Please try to follow the [style guide](./STYLE_GUIDE.md)
### Setting up a Debugger
Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points.

316
STATS.md
View File

@@ -1,166 +1,154 @@
# 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) |
| 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) |

View File

@@ -1,12 +0,0 @@
## Style Guide
- 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()

439
bun.lock

File diff suppressed because it is too large Load Diff

6
flake.lock generated
View File

@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1764947035,
"narHash": "sha256-EYHSjVM4Ox4lvCXUMiKKs2vETUSL5mx+J2FfutM7T9w=",
"lastModified": 1764081664,
"narHash": "sha256-sUoHmPr/EwXzRMpv1u/kH+dXuvJEyyF2Q7muE+t0EU4=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "a672be65651c80d3f592a89b3945466584a22069",
"rev": "dc205f7b4fdb04c8b7877b43edb7b73be7730081",
"type": "github"
},
"original": {

View File

@@ -13,10 +13,6 @@ inputs:
description: "Share the opencode session (defaults to true for public repos)"
required: false
prompt:
description: "Custom prompt to override the default prompt"
required: false
runs:
using: "composite"
steps:
@@ -31,4 +27,3 @@ runs:
env:
MODEL: ${{ inputs.model }}
SHARE: ${{ inputs.share }}
PROMPT: ${{ inputs.prompt }}

2
github/sst-env.d.ts vendored
View File

@@ -6,4 +6,4 @@
/// <reference path="../sst-env.d.ts" />
import "sst"
export {}
export {}

View File

@@ -116,7 +116,7 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
// CONSOLE
////////////////
const bucket = new sst.cloudflare.Bucket("ZenData")
const bucket = new sst.cloudflare.Bucket("ConsoleData")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")

10
install
View File

@@ -4,7 +4,7 @@ APP=opencode
MUTED='\033[0;2m'
RED='\033[0;31m'
ORANGE='\033[38;5;214m'
ORANGE='\033[38;2;255;140;0m'
NC='\033[0m' # No Color
requested_version=${VERSION:-}
@@ -353,10 +353,10 @@ echo -e "${MUTED}█░░█ █░░█ █▀▀▀ █░░█ ${NC}█░
echo -e "${MUTED}▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ${NC}▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"
echo -e ""
echo -e ""
echo -e "${MUTED}OpenCode includes free models, to start:${NC}"
echo -e ""
echo -e "cd <project> ${MUTED}# Open directory${NC}"
echo -e "opencode ${MUTED}# Run command${NC}"
echo -e "${MUTED}To get started, navigate to a project and run:${NC}"
echo -e "opencode ${MUTED}Use free models${NC}"
echo -e "opencode auth login ${MUTED}Add paid provider API keys${NC}"
echo -e "opencode help ${MUTED}List commands and options${NC}"
echo -e ""
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
echo -e ""

View File

@@ -1,3 +1,3 @@
{
"nodeModules": "sha256-IzF5XDY09Z1p/8jgYIHhE/jpKPub15KKUpV+a/aKpuc="
"nodeModules": "sha256-XFJXjBWbHIUWKdSOZkGW7VmUPBVtGRgLHM2/1xVThFc="
}

View File

@@ -30,16 +30,16 @@
"@tsconfig/bun": "1.0.9",
"@cloudflare/workers-types": "4.20251008.0",
"@openauthjs/openauth": "0.0.0-20250322224806",
"@pierre/precision-diffs": "0.6.0-beta.10",
"@pierre/precision-diffs": "0.5.7",
"@tailwindcss/vite": "4.1.11",
"diff": "8.0.2",
"ai": "5.0.97",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
"hono": "4.7.10",
"hono-openapi": "1.1.1",
"fuzzysort": "3.1.0",
"luxon": "3.6.1",
"typescript": "5.8.2",
"@typescript/native-preview": "7.0.0-dev.20251207.1",
"@typescript/native-preview": "7.0.0-dev.20251014.1",
"zod": "4.1.8",
"remeda": "2.26.0",
"solid-list": "0.3.0",
@@ -63,8 +63,7 @@
"dependencies": {
"@aws-sdk/client-s3": "3.933.0",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"typescript": "catalog:"
"@opencode-ai/sdk": "workspace:*"
},
"repository": {
"type": "git",
@@ -86,8 +85,5 @@
"overrides": {
"@types/bun": "catalog:",
"@types/node": "catalog:"
},
"patchedDependencies": {
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.0.137",
"version": "1.0.116",
"type": "module",
"scripts": {
"typecheck": "tsgo --noEmit",

View File

@@ -1 +0,0 @@
../../../ui/src/assets/images/social-share-zen.png

Before

Width:  |  Height:  |  Size: 50 B

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 B

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -1 +0,0 @@
../../../ui/src/assets/images/social-share.png

Before

Width:  |  Height:  |  Size: 46 B

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 B

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/sst/opencode",
starsFormatted: {
compact: "35K",
full: "35,000",
compact: "30K",
full: "30,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "350",
commits: "5,000",
monthlyUsers: "400,000",
contributors: "300",
commits: "4,000",
monthlyUsers: "300,000",
},
} as const

View File

@@ -36,7 +36,6 @@ ${body.email}`.trim()
to: "contact@anoma.ly",
subject: `Enterprise Inquiry from ${body.name}`,
body: emailContent,
replyTo: body.email,
})
return Response.json({ success: true, message: "Form submitted successfully" }, { status: 200 })

View File

@@ -338,11 +338,6 @@ body {
}
}
[data-slot="installation-instructions"] {
color: var(--color-text-strong);
margin-bottom: 8px;
}
[data-slot="installation"] {
width: 100%;
max-width: 100%;
@@ -353,11 +348,6 @@ body {
}
}
[data-slot="installation-options"] {
font-size: 13px;
margin-top: 12px;
}
[data-component="tabs"] {
[data-slot="tablist"] {
display: flex;
@@ -490,10 +480,10 @@ body {
}
h1 {
font-size: 38px;
font-size: 28px;
color: var(--color-text-strong);
font-weight: 500;
margin-bottom: 8px;
margin-bottom: 16px;
@media (max-width: 60rem) {
font-size: 22px;
@@ -502,7 +492,7 @@ body {
p {
color: var(--color-text);
margin-bottom: 40px;
margin-bottom: 24px;
max-width: 82%;
@media (max-width: 50rem) {
@@ -617,25 +607,6 @@ body {
padding: var(--vertical-padding) var(--padding);
color: var(--color-text);
a {
background: var(--color-background-strong);
padding: 8px 12px 8px 20px;
color: var(--color-text-inverted);
border: none;
border-radius: 4px;
font-weight: 500;
cursor: pointer;
margin-top: 40px;
display: flex;
width: fit-content;
gap: 12px;
text-decoration: none;
}
a:hover {
background: var(--color-background-strong-hover);
}
ul {
padding: 0;
li {

View File

@@ -43,7 +43,7 @@ export default function Home() {
return (
<main data-page="opencode">
{/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
<Title>OpenCode | The open source AI coding agent</Title>
<Title>OpenCode | The AI coding agent built for the terminal</Title>
<Link rel="canonical" href={config.baseUrl} />
<Meta property="og:image" content="/social-share.png" />
<Meta name="twitter:image" content="/social-share.png" />
@@ -56,13 +56,23 @@ export default function Home() {
<a data-slot="releases" href={release()?.url ?? `${config.github.repoUrl}/releases`} target="_blank">
Whats new in {release()?.name ?? "the latest release"}
</a>
<h1>The open source coding agent</h1>
<h1>The AI coding agent built for the terminal</h1>
<p>
OpenCode includes free models or connect from any provider to <br />
use other models, including Claude, GPT, Gemini and more.
OpenCode is fully open source, giving you control and freedom to use any provider, any model, and any
editor.
</p>
<a href="/docs">
<span>Read docs </span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</a>
</div>
<p data-slot="installation-instructions">Install and use. No account, no email, and no credit card.</p>
<div data-slot="installation">
<Tabs
as="section"
@@ -141,11 +151,6 @@ export default function Home() {
</div>
</Tabs>
</div>
<p data-slot="installation-options">
Available in terminal, web, and desktop (coming soon).
<br />
Extensions for VS Code, Cursor, Windsurf, and more.
</p>
</section>
<section data-component="video">
@@ -203,17 +208,6 @@ export default function Home() {
</div>
</li>
</ul>
<a href="/docs">
<span>Read docs </span>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M6.5 12L17 12M13 16.5L17.5 12L13 7.5"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="square"
/>
</svg>
</a>
</section>
<section data-component="growth">

View File

@@ -1,7 +0,0 @@
export async function GET() {
const response = await fetch(
"https://raw.githubusercontent.com/sst/opencode/refs/heads/dev/packages/sdk/openapi.json",
)
const json = await response.json()
return json
}

View File

@@ -14,7 +14,7 @@ import "./workspace-picker.css"
const getWorkspaces = query(async () => {
"use server"
return withActor(async () => {
return Database.use((tx) =>
return Database.transaction((tx) =>
tx
.select({
id: WorkspaceTable.id,

View File

@@ -3,7 +3,7 @@ import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
import { useParams } from "@solidjs/router"
import { createAsync, query, useParams } from "@solidjs/router"
import { createEffect, createMemo, onCleanup, Show, For } from "solid-js"
import { createStore } from "solid-js/store"
import { withActor } from "~/context/auth.withActor"
@@ -94,6 +94,8 @@ async function getCosts(workspaceID: string, year: number, month: number) {
}, workspaceID)
}
const queryCosts = query(getCosts, "costs.get")
const MODEL_COLORS: Record<string, string> = {
"claude-sonnet-4-5": "#D4745C",
"claude-sonnet-4": "#E8B4A4",
@@ -156,27 +158,32 @@ export function GraphSection() {
model: null as string | null,
modelDropdownOpen: false,
keyDropdownOpen: false,
colorScheme: "light" as "light" | "dark",
})
const initialData = createAsync(() => queryCosts(params.id!, store.year, store.month))
const onPreviousMonth = async () => {
const month = store.month === 0 ? 11 : store.month - 1
const year = store.month === 0 ? store.year - 1 : store.year
setStore({ month, year })
const data = await getCosts(params.id!, year, month)
setStore({ month, year, data })
}
const onNextMonth = async () => {
const month = store.month === 11 ? 0 : store.month + 1
const year = store.month === 11 ? store.year + 1 : store.year
setStore({ month, year })
setStore({ month, year, data: await getCosts(params.id!, year, month) })
}
const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false })
const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
const getData = createMemo(() => store.data ?? initialData())
const getModels = createMemo(() => {
if (!store.data?.usage) return []
return Array.from(new Set(store.data.usage.map((row) => row.model))).sort()
const data = getData()
if (!data?.usage) return []
return Array.from(new Set(data.usage.map((row) => row.model))).sort()
})
const getDates = createMemo(() => {
@@ -199,19 +206,10 @@ export function GraphSection() {
const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
const chartConfig = createMemo((): ChartConfiguration | null => {
const data = store.data
const data = getData()
const dates = getDates()
if (!data?.usage?.length) return null
store.colorScheme
const styles = getComputedStyle(document.documentElement)
const colorTextMuted = styles.getPropertyValue("--color-text-muted").trim()
const colorBorderMuted = styles.getPropertyValue("--color-border-muted").trim()
const colorBgElevated = styles.getPropertyValue("--color-bg-elevated").trim()
const colorText = styles.getPropertyValue("--color-text").trim()
const colorTextSecondary = styles.getPropertyValue("--color-text-secondary").trim()
const colorBorder = styles.getPropertyValue("--color-border").trim()
const dailyData = new Map<string, Map<string, number>>()
for (const dateKey of dates) dailyData.set(dateKey, new Map())
@@ -254,7 +252,7 @@ export function GraphSection() {
ticks: {
maxRotation: 0,
autoSkipPadding: 20,
color: colorTextMuted,
color: "rgba(255, 255, 255, 0.5)",
font: {
family: "monospace",
size: 11,
@@ -265,10 +263,10 @@ export function GraphSection() {
stacked: true,
beginAtZero: true,
grid: {
color: colorBorderMuted,
color: "rgba(255, 255, 255, 0.1)",
},
ticks: {
color: colorTextMuted,
color: "rgba(255, 255, 255, 0.5)",
font: {
family: "monospace",
size: 11,
@@ -284,10 +282,10 @@ export function GraphSection() {
tooltip: {
mode: "index",
intersect: false,
backgroundColor: colorBgElevated,
titleColor: colorText,
bodyColor: colorTextSecondary,
borderColor: colorBorder,
backgroundColor: "rgba(0, 0, 0, 0.9)",
titleColor: "rgba(255, 255, 255, 0.9)",
bodyColor: "rgba(255, 255, 255, 0.8)",
borderColor: "rgba(255, 255, 255, 0.1)",
borderWidth: 1,
padding: 12,
displayColors: true,
@@ -303,7 +301,7 @@ export function GraphSection() {
display: true,
position: "bottom",
labels: {
color: colorTextSecondary,
color: "rgba(255, 255, 255, 0.7)",
font: {
size: 12,
},
@@ -341,32 +339,15 @@ export function GraphSection() {
}
})
createEffect(async () => {
const data = await getCosts(params.id!, store.year, store.month)
setStore({ data })
})
createEffect(() => {
const config = chartConfig()
if (!config || !canvasRef) return
if (chartInstance) chartInstance.destroy()
chartInstance = new Chart(canvasRef, config)
onCleanup(() => chartInstance?.destroy())
})
createEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)")
setStore({ colorScheme: mediaQuery.matches ? "dark" : "light" })
const handleColorSchemeChange = (e: MediaQueryListEvent) => {
setStore({ colorScheme: e.matches ? "dark" : "light" })
}
mediaQuery.addEventListener("change", handleColorSchemeChange)
onCleanup(() => mediaQuery.removeEventListener("change", handleColorSchemeChange))
})
onCleanup(() => chartInstance?.destroy())
return (
<section class={styles.root}>
@@ -375,53 +356,55 @@ export function GraphSection() {
<p>Usage costs broken down by model.</p>
</div>
<div data-slot="filter-container">
<div data-slot="month-picker">
<button data-slot="month-button" onClick={onPreviousMonth}>
<IconChevronLeft />
</button>
<span data-slot="month-label">{formatMonthYear()}</span>
<button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
<IconChevronRight />
</button>
<Show when={getData()}>
<div data-slot="filter-container">
<div data-slot="month-picker">
<button data-slot="month-button" onClick={onPreviousMonth}>
<IconChevronLeft />
</button>
<span data-slot="month-label">{formatMonthYear()}</span>
<button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
<IconChevronRight />
</button>
</div>
<Dropdown
trigger={store.model === null ? "All Models" : store.model}
open={store.modelDropdownOpen}
onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectModel(null)}>
<span>All Models</span>
</button>
<For each={getModels()}>
{(model) => (
<button data-slot="model-item" onClick={() => onSelectModel(model)}>
<span>{model}</span>
</button>
)}
</For>
</>
</Dropdown>
<Dropdown
trigger={getKeyName(store.key)}
open={store.keyDropdownOpen}
onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectKey(null)}>
<span>All Keys</span>
</button>
<For each={getData()?.keys || []}>
{(key) => (
<button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
<span>{key.displayName}</span>
</button>
)}
</For>
</>
</Dropdown>
</div>
<Dropdown
trigger={store.model === null ? "All Models" : store.model}
open={store.modelDropdownOpen}
onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectModel(null)}>
<span>All Models</span>
</button>
<For each={getModels()}>
{(model) => (
<button data-slot="model-item" onClick={() => onSelectModel(model)}>
<span>{model}</span>
</button>
)}
</For>
</>
</Dropdown>
<Dropdown
trigger={getKeyName(store.key)}
open={store.keyDropdownOpen}
onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
>
<>
<button data-slot="model-item" onClick={() => onSelectKey(null)}>
<span>All Keys</span>
</button>
<For each={store.data?.keys || []}>
{(key) => (
<button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
<span>{key.displayName}</span>
</button>
)}
</For>
</>
</Dropdown>
</div>
</Show>
<Show
when={chartConfig()}

View File

@@ -1,37 +1,25 @@
import { Resource, waitUntil } from "@opencode-ai/console-resource"
export function createDataDumper(sessionId: string, requestId: string, projectId: string) {
export function createDataDumper(sessionId: string, requestId: string) {
if (Resource.App.stage !== "production") return
if (sessionId === "") return
let data: Record<string, any> = { sessionId, requestId, projectId }
let metadata: Record<string, any> = { sessionId, requestId, projectId }
let data: Record<string, any> = {}
let modelName: string | undefined
return {
provideModel: (model?: string) => {
data.modelName = model
metadata.modelName = model
},
provideModel: (model?: string) => (modelName = model),
provideRequest: (request: string) => (data.request = request),
provideResponse: (response: string) => (data.response = response),
provideStream: (chunk: string) => (data.response = (data.response ?? "") + chunk),
flush: () => {
if (!data.modelName) return
if (!modelName) return
const timestamp = new Date().toISOString().replace(/[^0-9]/g, "")
const str = new Date().toISOString().replace(/[^0-9]/g, "")
const yyyymmdd = str.substring(0, 8)
const hh = str.substring(8, 10)
waitUntil(
Resource.ZenData.put(
`data/${data.modelName}/${sessionId}/${requestId}.json`,
JSON.stringify({ timestamp, ...data }),
),
)
waitUntil(
Resource.ZenData.put(
`meta/${data.modelName}/${timestamp}/${requestId}.json`,
JSON.stringify({ timestamp, ...metadata }),
),
Resource.ConsoleData.put(`${yyyymmdd}/${hh}/${modelName}/${sessionId}/${requestId}.json`, JSON.stringify(data)),
)
},
}

View File

@@ -13,7 +13,13 @@ import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
import { logger } from "./logger"
import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
import { createBodyConverter, createStreamPartConverter, createResponseConverter, UsageInfo } from "./provider/provider"
import {
createBodyConverter,
createStreamPartConverter,
createResponseConverter,
ProviderHelper,
UsageInfo,
} from "./provider/provider"
import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai"
@@ -21,7 +27,6 @@ import { oaCompatHelper } from "./provider/openai-compatible"
import { createRateLimiter } from "./rateLimiter"
import { createDataDumper } from "./dataDumper"
import { createTrialLimiter } from "./trialLimiter"
import { createStickyTracker } from "./stickyProviderTracker"
type ZenData = Awaited<ReturnType<typeof ZenData.list>>
type RetryOptions = {
@@ -56,7 +61,6 @@ export async function handler(
const ip = input.request.headers.get("x-real-ip") ?? ""
const sessionId = input.request.headers.get("x-opencode-session") ?? ""
const requestId = input.request.headers.get("x-opencode-request") ?? ""
const projectId = input.request.headers.get("x-opencode-project") ?? ""
logger.metric({
is_tream: isStream,
session: sessionId,
@@ -64,25 +68,15 @@ export async function handler(
})
const zenData = ZenData.list()
const modelInfo = validateModel(zenData, model)
const dataDumper = createDataDumper(sessionId, requestId, projectId)
const dataDumper = createDataDumper(sessionId, requestId)
const trialLimiter = createTrialLimiter(modelInfo.trial?.limit, ip)
const isTrial = await trialLimiter?.isTrial()
const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
await rateLimiter?.check()
const stickyTracker = createStickyTracker(modelInfo.stickyProvider ?? false, sessionId)
const stickyProvider = await stickyTracker?.get()
const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
const authInfo = await authenticate(modelInfo)
const providerInfo = selectProvider(
zenData,
authInfo,
modelInfo,
sessionId,
isTrial ?? false,
retry,
stickyProvider,
)
const providerInfo = selectProvider(zenData, modelInfo, sessionId, isTrial ?? false, retry)
const authInfo = await authenticate(modelInfo, providerInfo)
validateBilling(authInfo, modelInfo)
validateModelSettings(authInfo)
updateProviderKey(authInfo, providerInfo)
@@ -132,9 +126,6 @@ export async function handler(
dataDumper?.provideModel(providerInfo.storeModel)
dataDumper?.provideRequest(reqBody)
// Store sticky provider
await stickyTracker?.set(providerInfo.id)
// Scrub response headers
const resHeaders = new Headers()
const keepHeaders = ["content-type", "cache-control"]
@@ -299,27 +290,16 @@ export async function handler(
function selectProvider(
zenData: ZenData,
authInfo: AuthInfo,
modelInfo: ModelInfo,
sessionId: string,
isTrial: boolean,
retry: RetryOptions,
stickyProvider: string | undefined,
) {
const provider = (() => {
if (authInfo?.provider?.credentials) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.byokProvider)
}
if (isTrial) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider)
}
if (stickyProvider) {
const provider = modelInfo.providers.find((provider) => provider.id === stickyProvider)
if (provider) return provider
}
if (retry.retryCount === MAX_RETRIES) {
return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
}
@@ -355,7 +335,7 @@ export async function handler(
}
}
async function authenticate(modelInfo: ModelInfo) {
async function authenticate(modelInfo: ModelInfo, providerInfo: ProviderInfo) {
const apiKey = opts.parseApiKey(input.request.headers)
if (!apiKey || apiKey === "public") {
if (modelInfo.allowAnonymous) return
@@ -393,12 +373,7 @@ export async function handler(
.leftJoin(ModelTable, and(eq(ModelTable.workspaceID, KeyTable.workspaceID), eq(ModelTable.model, modelInfo.id)))
.leftJoin(
ProviderTable,
modelInfo.byokProvider
? and(
eq(ProviderTable.workspaceID, KeyTable.workspaceID),
eq(ProviderTable.provider, modelInfo.byokProvider),
)
: sql`false`,
and(eq(ProviderTable.workspaceID, KeyTable.workspaceID), eq(ProviderTable.provider, providerInfo.id)),
)
.where(and(eq(KeyTable.key, apiKey), isNull(KeyTable.timeDeleted)))
.then((rows) => rows[0]),
@@ -475,7 +450,8 @@ export async function handler(
}
function updateProviderKey(authInfo: AuthInfo, providerInfo: ProviderInfo) {
if (!authInfo?.provider?.credentials) return
if (!authInfo) return
if (!authInfo.provider?.credentials) return
providerInfo.apiKey = authInfo.provider.credentials
}

View File

@@ -1,16 +0,0 @@
import { Resource } from "@opencode-ai/console-resource"
export function createStickyTracker(stickyProvider: boolean, session: string) {
if (!stickyProvider) return
if (!session) return
const key = `sticky:${session}`
return {
get: async () => {
return await Resource.GatewayKv.get(key)
},
set: async (providerId: string) => {
await Resource.GatewayKv.put(key, providerId, { expirationTtl: 86400 })
},
}
}

View File

@@ -6,4 +6,4 @@
/// <reference path="../../../sst-env.d.ts" />
import "sst"
export {}
export {}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.0.137",
"version": "1.0.116",
"private": true,
"type": "module",
"dependencies": {

View File

@@ -11,7 +11,7 @@ export namespace Account {
id: z.string().optional(),
}),
async (input) =>
Database.use(async (tx) => {
Database.transaction(async (tx) => {
const id = input.id ?? Identifier.create("account")
await tx.insert(AccountTable).values({
id,
@@ -21,12 +21,13 @@ export namespace Account {
)
export const fromID = fn(z.string(), async (id) =>
Database.use((tx) =>
tx
Database.transaction(async (tx) => {
return tx
.select()
.from(AccountTable)
.where(eq(AccountTable.id, id))
.then((rows) => rows[0]),
),
.execute()
.then((rows) => rows[0])
}),
)
}

View File

@@ -22,7 +22,6 @@ export namespace AWS {
to: z.string(),
subject: z.string(),
body: z.string(),
replyTo: z.string().optional(),
}),
async (input) => {
const res = await createClient().fetch("https://email.us-east-1.amazonaws.com/v2/email/outbound-emails", {
@@ -36,7 +35,6 @@ export namespace AWS {
Destination: {
ToAddresses: [input.to],
},
...(input.replyTo && { ReplyToAddresses: [input.replyTo] }),
Content: {
Simple: {
Subject: {

View File

@@ -24,8 +24,6 @@ export namespace ZenData {
cost: ModelCostSchema,
cost200K: ModelCostSchema.optional(),
allowAnonymous: z.boolean().optional(),
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
stickyProvider: z.boolean().optional(),
trial: z
.object({
limit: z.number(),

View File

@@ -47,7 +47,7 @@ export namespace Provider {
}),
async ({ provider }) => {
Actor.assertAdmin()
return Database.use((tx) =>
return Database.transaction((tx) =>
tx
.delete(ProviderTable)
.where(and(eq(ProviderTable.provider, provider), eq(ProviderTable.workspaceID, Actor.workspace()))),

View File

@@ -6,130 +6,126 @@
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
ADMIN_SECRET: {
type: "sst.sst.Secret"
value: string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
AUTH_API_URL: {
type: "sst.sst.Linkable"
value: string
}
"AWS_SES_ACCESS_KEY_ID": {
"type": "sst.sst.Secret"
"value": string
AWS_SES_ACCESS_KEY_ID: {
type: "sst.sst.Secret"
value: string
}
"AWS_SES_SECRET_ACCESS_KEY": {
"type": "sst.sst.Secret"
"value": string
AWS_SES_SECRET_ACCESS_KEY: {
type: "sst.sst.Secret"
value: string
}
"CLOUDFLARE_API_TOKEN": {
"type": "sst.sst.Secret"
"value": string
CLOUDFLARE_API_TOKEN: {
type: "sst.sst.Secret"
value: string
}
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
"type": "sst.sst.Secret"
"value": string
CLOUDFLARE_DEFAULT_ACCOUNT_ID: {
type: "sst.sst.Secret"
value: string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
Console: {
type: "sst.cloudflare.SolidStart"
url: string
}
"Database": {
"database": string
"host": string
"password": string
"port": number
"type": "sst.sst.Linkable"
"username": string
Database: {
database: string
host: string
password: string
port: number
type: "sst.sst.Linkable"
username: string
}
"Desktop": {
"type": "sst.cloudflare.StaticSite"
"url": string
Desktop: {
type: "sst.cloudflare.StaticSite"
url: string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
EMAILOCTOPUS_API_KEY: {
type: "sst.sst.Secret"
value: string
}
"Enterprise": {
"type": "sst.cloudflare.SolidStart"
"url": string
GITHUB_APP_ID: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
GITHUB_APP_PRIVATE_KEY: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_APP_PRIVATE_KEY": {
"type": "sst.sst.Secret"
"value": string
GITHUB_CLIENT_ID_CONSOLE: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_CLIENT_ID_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
GITHUB_CLIENT_SECRET_CONSOLE: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_CLIENT_SECRET_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
GOOGLE_CLIENT_ID: {
type: "sst.sst.Secret"
value: string
}
"GOOGLE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
HONEYCOMB_API_KEY: {
type: "sst.sst.Secret"
value: string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
"R2AccessKey": {
"type": "sst.sst.Secret"
"value": string
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
"R2SecretKey": {
"type": "sst.sst.Secret"
"value": string
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
STRIPE_WEBHOOK_SECRET: {
type: "sst.sst.Linkable"
value: string
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
Web: {
type: "sst.cloudflare.Astro"
url: string
}
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
ZEN_MODELS1: {
type: "sst.sst.Secret"
value: string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
ZEN_MODELS2: {
type: "sst.sst.Secret"
value: string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
ZEN_MODELS3: {
type: "sst.sst.Secret"
value: string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
ZEN_MODELS4: {
type: "sst.sst.Secret"
value: string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
// cloudflare
import * as cloudflare from "@cloudflare/workers-types"
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"ZenData": cloudflare.R2Bucket
Api: cloudflare.Service
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
}
import "sst"
export {}
export {}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.0.137",
"version": "1.0.116",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -194,7 +194,7 @@ export default {
// Get workspace
await Actor.provide("account", { accountID, email }, async () => {
await User.joinInvitedWorkspaces()
const workspaces = await Database.use((tx) =>
const workspaces = await Database.transaction(async (tx) =>
tx
.select({ id: WorkspaceTable.id })
.from(WorkspaceTable)

View File

@@ -6,130 +6,126 @@
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
ADMIN_SECRET: {
type: "sst.sst.Secret"
value: string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
AUTH_API_URL: {
type: "sst.sst.Linkable"
value: string
}
"AWS_SES_ACCESS_KEY_ID": {
"type": "sst.sst.Secret"
"value": string
AWS_SES_ACCESS_KEY_ID: {
type: "sst.sst.Secret"
value: string
}
"AWS_SES_SECRET_ACCESS_KEY": {
"type": "sst.sst.Secret"
"value": string
AWS_SES_SECRET_ACCESS_KEY: {
type: "sst.sst.Secret"
value: string
}
"CLOUDFLARE_API_TOKEN": {
"type": "sst.sst.Secret"
"value": string
CLOUDFLARE_API_TOKEN: {
type: "sst.sst.Secret"
value: string
}
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
"type": "sst.sst.Secret"
"value": string
CLOUDFLARE_DEFAULT_ACCOUNT_ID: {
type: "sst.sst.Secret"
value: string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
Console: {
type: "sst.cloudflare.SolidStart"
url: string
}
"Database": {
"database": string
"host": string
"password": string
"port": number
"type": "sst.sst.Linkable"
"username": string
Database: {
database: string
host: string
password: string
port: number
type: "sst.sst.Linkable"
username: string
}
"Desktop": {
"type": "sst.cloudflare.StaticSite"
"url": string
Desktop: {
type: "sst.cloudflare.StaticSite"
url: string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
EMAILOCTOPUS_API_KEY: {
type: "sst.sst.Secret"
value: string
}
"Enterprise": {
"type": "sst.cloudflare.SolidStart"
"url": string
GITHUB_APP_ID: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
GITHUB_APP_PRIVATE_KEY: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_APP_PRIVATE_KEY": {
"type": "sst.sst.Secret"
"value": string
GITHUB_CLIENT_ID_CONSOLE: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_CLIENT_ID_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
GITHUB_CLIENT_SECRET_CONSOLE: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_CLIENT_SECRET_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
GOOGLE_CLIENT_ID: {
type: "sst.sst.Secret"
value: string
}
"GOOGLE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
HONEYCOMB_API_KEY: {
type: "sst.sst.Secret"
value: string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
"R2AccessKey": {
"type": "sst.sst.Secret"
"value": string
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
"R2SecretKey": {
"type": "sst.sst.Secret"
"value": string
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
STRIPE_WEBHOOK_SECRET: {
type: "sst.sst.Linkable"
value: string
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
Web: {
type: "sst.cloudflare.Astro"
url: string
}
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
ZEN_MODELS1: {
type: "sst.sst.Secret"
value: string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
ZEN_MODELS2: {
type: "sst.sst.Secret"
value: string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
ZEN_MODELS3: {
type: "sst.sst.Secret"
value: string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
ZEN_MODELS4: {
type: "sst.sst.Secret"
value: string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
// cloudflare
import * as cloudflare from "@cloudflare/workers-types"
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"ZenData": cloudflare.R2Bucket
Api: cloudflare.Service
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
}
import "sst"
export {}
export {}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.0.137",
"version": "1.0.116",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -6,4 +6,4 @@
/// <reference path="../../../sst-env.d.ts" />
import "sst"
export {}
export {}

View File

@@ -2,8 +2,8 @@ import type { KVNamespaceListOptions, KVNamespaceListResult, KVNamespacePutOptio
import { Resource as ResourceBase } from "sst"
import Cloudflare from "cloudflare"
export const waitUntil = async (promise: Promise<any>) => {
await promise
export const waitUntil = async (fn: () => Promise<void>) => {
await fn()
}
export const Resource = new Proxy(

View File

@@ -6,130 +6,126 @@
import "sst"
declare module "sst" {
export interface Resource {
"ADMIN_SECRET": {
"type": "sst.sst.Secret"
"value": string
ADMIN_SECRET: {
type: "sst.sst.Secret"
value: string
}
"AUTH_API_URL": {
"type": "sst.sst.Linkable"
"value": string
AUTH_API_URL: {
type: "sst.sst.Linkable"
value: string
}
"AWS_SES_ACCESS_KEY_ID": {
"type": "sst.sst.Secret"
"value": string
AWS_SES_ACCESS_KEY_ID: {
type: "sst.sst.Secret"
value: string
}
"AWS_SES_SECRET_ACCESS_KEY": {
"type": "sst.sst.Secret"
"value": string
AWS_SES_SECRET_ACCESS_KEY: {
type: "sst.sst.Secret"
value: string
}
"CLOUDFLARE_API_TOKEN": {
"type": "sst.sst.Secret"
"value": string
CLOUDFLARE_API_TOKEN: {
type: "sst.sst.Secret"
value: string
}
"CLOUDFLARE_DEFAULT_ACCOUNT_ID": {
"type": "sst.sst.Secret"
"value": string
CLOUDFLARE_DEFAULT_ACCOUNT_ID: {
type: "sst.sst.Secret"
value: string
}
"Console": {
"type": "sst.cloudflare.SolidStart"
"url": string
Console: {
type: "sst.cloudflare.SolidStart"
url: string
}
"Database": {
"database": string
"host": string
"password": string
"port": number
"type": "sst.sst.Linkable"
"username": string
Database: {
database: string
host: string
password: string
port: number
type: "sst.sst.Linkable"
username: string
}
"Desktop": {
"type": "sst.cloudflare.StaticSite"
"url": string
Desktop: {
type: "sst.cloudflare.StaticSite"
url: string
}
"EMAILOCTOPUS_API_KEY": {
"type": "sst.sst.Secret"
"value": string
EMAILOCTOPUS_API_KEY: {
type: "sst.sst.Secret"
value: string
}
"Enterprise": {
"type": "sst.cloudflare.SolidStart"
"url": string
GITHUB_APP_ID: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_APP_ID": {
"type": "sst.sst.Secret"
"value": string
GITHUB_APP_PRIVATE_KEY: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_APP_PRIVATE_KEY": {
"type": "sst.sst.Secret"
"value": string
GITHUB_CLIENT_ID_CONSOLE: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_CLIENT_ID_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
GITHUB_CLIENT_SECRET_CONSOLE: {
type: "sst.sst.Secret"
value: string
}
"GITHUB_CLIENT_SECRET_CONSOLE": {
"type": "sst.sst.Secret"
"value": string
GOOGLE_CLIENT_ID: {
type: "sst.sst.Secret"
value: string
}
"GOOGLE_CLIENT_ID": {
"type": "sst.sst.Secret"
"value": string
HONEYCOMB_API_KEY: {
type: "sst.sst.Secret"
value: string
}
"HONEYCOMB_API_KEY": {
"type": "sst.sst.Secret"
"value": string
R2AccessKey: {
type: "sst.sst.Secret"
value: string
}
"R2AccessKey": {
"type": "sst.sst.Secret"
"value": string
R2SecretKey: {
type: "sst.sst.Secret"
value: string
}
"R2SecretKey": {
"type": "sst.sst.Secret"
"value": string
STRIPE_SECRET_KEY: {
type: "sst.sst.Secret"
value: string
}
"STRIPE_SECRET_KEY": {
"type": "sst.sst.Secret"
"value": string
STRIPE_WEBHOOK_SECRET: {
type: "sst.sst.Linkable"
value: string
}
"STRIPE_WEBHOOK_SECRET": {
"type": "sst.sst.Linkable"
"value": string
Web: {
type: "sst.cloudflare.Astro"
url: string
}
"Web": {
"type": "sst.cloudflare.Astro"
"url": string
ZEN_MODELS1: {
type: "sst.sst.Secret"
value: string
}
"ZEN_MODELS1": {
"type": "sst.sst.Secret"
"value": string
ZEN_MODELS2: {
type: "sst.sst.Secret"
value: string
}
"ZEN_MODELS2": {
"type": "sst.sst.Secret"
"value": string
ZEN_MODELS3: {
type: "sst.sst.Secret"
value: string
}
"ZEN_MODELS3": {
"type": "sst.sst.Secret"
"value": string
}
"ZEN_MODELS4": {
"type": "sst.sst.Secret"
"value": string
ZEN_MODELS4: {
type: "sst.sst.Secret"
value: string
}
}
}
// cloudflare
import * as cloudflare from "@cloudflare/workers-types";
// cloudflare
import * as cloudflare from "@cloudflare/workers-types"
declare module "sst" {
export interface Resource {
"Api": cloudflare.Service
"AuthApi": cloudflare.Service
"AuthStorage": cloudflare.KVNamespace
"Bucket": cloudflare.R2Bucket
"EnterpriseStorage": cloudflare.R2Bucket
"GatewayKv": cloudflare.KVNamespace
"LogProcessor": cloudflare.Service
"ZenData": cloudflare.R2Bucket
Api: cloudflare.Service
AuthApi: cloudflare.Service
AuthStorage: cloudflare.KVNamespace
Bucket: cloudflare.R2Bucket
ConsoleData: cloudflare.R2Bucket
EnterpriseStorage: cloudflare.R2Bucket
GatewayKv: cloudflare.KVNamespace
LogProcessor: cloudflare.Service
}
}
import "sst"
export {}
export {}

View File

@@ -1,2 +0,0 @@
[test]
preload = ["./happydom.ts"]

View File

@@ -1,75 +0,0 @@
import { GlobalRegistrator } from "@happy-dom/global-registrator"
GlobalRegistrator.register()
const originalGetContext = HTMLCanvasElement.prototype.getContext
// @ts-expect-error - we're overriding with a simplified mock
HTMLCanvasElement.prototype.getContext = function (contextType: string, _options?: unknown) {
if (contextType === "2d") {
return {
canvas: this,
fillStyle: "#000000",
strokeStyle: "#000000",
font: "12px monospace",
textAlign: "start",
textBaseline: "alphabetic",
globalAlpha: 1,
globalCompositeOperation: "source-over",
imageSmoothingEnabled: true,
lineWidth: 1,
lineCap: "butt",
lineJoin: "miter",
miterLimit: 10,
shadowBlur: 0,
shadowColor: "rgba(0, 0, 0, 0)",
shadowOffsetX: 0,
shadowOffsetY: 0,
fillRect: () => {},
strokeRect: () => {},
clearRect: () => {},
fillText: () => {},
strokeText: () => {},
measureText: (text: string) => ({ width: text.length * 8 }),
drawImage: () => {},
save: () => {},
restore: () => {},
scale: () => {},
rotate: () => {},
translate: () => {},
transform: () => {},
setTransform: () => {},
resetTransform: () => {},
createLinearGradient: () => ({ addColorStop: () => {} }),
createRadialGradient: () => ({ addColorStop: () => {} }),
createPattern: () => null,
beginPath: () => {},
closePath: () => {},
moveTo: () => {},
lineTo: () => {},
bezierCurveTo: () => {},
quadraticCurveTo: () => {},
arc: () => {},
arcTo: () => {},
ellipse: () => {},
rect: () => {},
fill: () => {},
stroke: () => {},
clip: () => {},
isPointInPath: () => false,
isPointInStroke: () => false,
getTransform: () => ({}),
getImageData: () => ({
data: new Uint8ClampedArray(0),
width: 0,
height: 0,
}),
putImageData: () => {},
createImageData: () => ({
data: new Uint8ClampedArray(0),
width: 0,
height: 0,
}),
} as unknown as CanvasRenderingContext2D
}
return originalGetContext.call(this, contextType as "2d", _options)
}

View File

@@ -9,8 +9,7 @@
<link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="/site.webmanifest" />
<meta name="theme-color" content="#F8F7F7" />
<meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#000000" />
<meta property="og:image" content="/social-share.png" />
<meta property="twitter:image" content="/social-share.png" />
</head>
@@ -23,6 +22,6 @@
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="/src/entry.tsx" type="module"></script>
<script src="/src/index.tsx" type="module"></script>
</body>
</html>

View File

@@ -1,12 +1,8 @@
{
"name": "@opencode-ai/desktop",
"version": "1.0.137",
"version": "1.0.116",
"description": "",
"type": "module",
"exports": {
".": "./src/index.ts",
"./vite": "./vite.js"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"start": "vite",
@@ -16,10 +12,8 @@
},
"license": "MIT",
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
"@tailwindcss/vite": "catalog:",
"@tsconfig/bun": "1.0.9",
"@types/bun": "catalog:",
"@types/luxon": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
@@ -39,13 +33,11 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "4.3.3",
"@solid-primitives/websocket": "1.3.1",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
"fuzzysort": "catalog:",
"ghostty-web": "0.3.0",
"luxon": "catalog:",
"marked": "16.2.0",
"marked-shiki": "1.2.1",

View File

@@ -1 +0,0 @@
../../ui/src/assets/images/social-share-zen.png

View File

@@ -1 +0,0 @@
../../ui/src/assets/images/social-share.png

Before

Width:  |  Height:  |  Size: 43 B

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 B

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -1,67 +0,0 @@
import "@/index.css"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
import { Favicon } from "@opencode-ai/ui/favicon"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { DiffComponentProvider } from "@opencode-ai/ui/context/diff"
import { Diff } from "@opencode-ai/ui/diff"
import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import Session from "@/pages/session"
import { LayoutProvider } from "./context/layout"
import { GlobalSDKProvider } from "./context/global-sdk"
import { SessionProvider } from "./context/session"
import { base64Encode } from "@opencode-ai/util/encode"
import { createMemo, Show } from "solid-js"
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
const url =
new URLSearchParams(document.location.search).get("url") ||
(location.hostname.includes("opencode.ai") || location.hostname.includes("localhost")
? `http://${host}:${port}`
: "/")
export function DesktopInterface() {
return (
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<MetaProvider>
<Font />
<Router root={Layout}>
<Route
path="/"
component={() => {
const globalSync = useGlobalSync()
const slug = createMemo(() => base64Encode(globalSync.data.defaultProject!.worktree))
return <Navigate href={`${slug()}/session`} />
}}
/>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<SessionProvider>
<Session />
</SessionProvider>
</Show>
)}
/>
</Route>
</Router>
</MetaProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</DiffComponentProvider>
</MarkedProvider>
)
}

View File

@@ -1,14 +0,0 @@
import { createContext } from "solid-js"
import { useContext } from "solid-js"
export interface Platform {}
const PlatformContext = createContext<Platform>()
export const PlatformProvider = PlatformContext.Provider
export function usePlatform() {
const ctx = useContext(PlatformContext)
if (!ctx) throw new Error("usePlatform must be used within a PlatformProvider")
return ctx
}

View File

@@ -1,272 +0,0 @@
import { describe, test, expect, beforeAll, afterEach } from "bun:test"
import { Terminal, Ghostty } from "ghostty-web"
import { SerializeAddon } from "./serialize"
let ghostty: Ghostty
beforeAll(async () => {
ghostty = await Ghostty.load()
})
const terminals: Terminal[] = []
afterEach(() => {
for (const term of terminals) {
term.dispose()
}
terminals.length = 0
document.body.innerHTML = ""
})
function createTerminal(cols = 80, rows = 24): { term: Terminal; addon: SerializeAddon; container: HTMLElement } {
const container = document.createElement("div")
document.body.appendChild(container)
const term = new Terminal({ cols, rows, ghostty })
const addon = new SerializeAddon()
term.loadAddon(addon)
term.open(container)
terminals.push(term)
return { term, addon, container }
}
function writeAndWait(term: Terminal, data: string): Promise<void> {
return new Promise((resolve) => {
term.write(data, resolve)
})
}
describe("SerializeAddon", () => {
describe("ANSI color preservation", () => {
test("should preserve text attributes (bold, italic, underline)", async () => {
const { term, addon } = createTerminal()
const input = "\x1b[1mBOLD\x1b[0m \x1b[3mITALIC\x1b[0m \x1b[4mUNDER\x1b[0m"
await writeAndWait(term, input)
const origLine = term.buffer.active.getLine(0)
expect(origLine!.getCell(0)!.isBold()).toBe(1)
expect(origLine!.getCell(5)!.isItalic()).toBe(1)
expect(origLine!.getCell(12)!.isUnderline()).toBe(1)
const serialized = addon.serialize({ range: { start: 0, end: 0 } })
const { term: term2 } = createTerminal()
terminals.push(term2)
await writeAndWait(term2, serialized)
const line = term2.buffer.active.getLine(0)
const boldCell = line!.getCell(0)
expect(boldCell!.getChars()).toBe("B")
expect(boldCell!.isBold()).toBe(1)
const italicCell = line!.getCell(5)
expect(italicCell!.getChars()).toBe("I")
expect(italicCell!.isItalic()).toBe(1)
const underCell = line!.getCell(12)
expect(underCell!.getChars()).toBe("U")
expect(underCell!.isUnderline()).toBe(1)
})
test("should preserve basic 16-color foreground colors", async () => {
const { term, addon } = createTerminal()
const input = "\x1b[31mRED\x1b[32mGREEN\x1b[34mBLUE\x1b[0mNORMAL"
await writeAndWait(term, input)
const origLine = term.buffer.active.getLine(0)
const origRedFg = origLine!.getCell(0)!.getFgColor()
const origGreenFg = origLine!.getCell(3)!.getFgColor()
const origBlueFg = origLine!.getCell(8)!.getFgColor()
const serialized = addon.serialize({ range: { start: 0, end: 0 } })
const { term: term2 } = createTerminal()
terminals.push(term2)
await writeAndWait(term2, serialized)
const line = term2.buffer.active.getLine(0)
expect(line).toBeDefined()
const redCell = line!.getCell(0)
expect(redCell!.getChars()).toBe("R")
expect(redCell!.getFgColor()).toBe(origRedFg)
const greenCell = line!.getCell(3)
expect(greenCell!.getChars()).toBe("G")
expect(greenCell!.getFgColor()).toBe(origGreenFg)
const blueCell = line!.getCell(8)
expect(blueCell!.getChars()).toBe("B")
expect(blueCell!.getFgColor()).toBe(origBlueFg)
})
test("should preserve 256-color palette colors", async () => {
const { term, addon } = createTerminal()
const input = "\x1b[38;5;196mRED256\x1b[0mNORMAL"
await writeAndWait(term, input)
const origLine = term.buffer.active.getLine(0)
const origRedFg = origLine!.getCell(0)!.getFgColor()
const serialized = addon.serialize({ range: { start: 0, end: 0 } })
const { term: term2 } = createTerminal()
terminals.push(term2)
await writeAndWait(term2, serialized)
const line = term2.buffer.active.getLine(0)
const redCell = line!.getCell(0)
expect(redCell!.getChars()).toBe("R")
expect(redCell!.getFgColor()).toBe(origRedFg)
})
test("should preserve RGB/truecolor colors", async () => {
const { term, addon } = createTerminal()
const input = "\x1b[38;2;255;128;64mRGB_TEXT\x1b[0mNORMAL"
await writeAndWait(term, input)
const origLine = term.buffer.active.getLine(0)
const origRgbFg = origLine!.getCell(0)!.getFgColor()
const serialized = addon.serialize({ range: { start: 0, end: 0 } })
const { term: term2 } = createTerminal()
terminals.push(term2)
await writeAndWait(term2, serialized)
const line = term2.buffer.active.getLine(0)
const rgbCell = line!.getCell(0)
expect(rgbCell!.getChars()).toBe("R")
expect(rgbCell!.getFgColor()).toBe(origRgbFg)
})
test("should preserve background colors", async () => {
const { term, addon } = createTerminal()
const input = "\x1b[48;2;255;0;0mRED_BG\x1b[48;2;0;255;0mGREEN_BG\x1b[0mNORMAL"
await writeAndWait(term, input)
const origLine = term.buffer.active.getLine(0)
const origRedBg = origLine!.getCell(0)!.getBgColor()
const origGreenBg = origLine!.getCell(6)!.getBgColor()
const serialized = addon.serialize({ range: { start: 0, end: 0 } })
const { term: term2 } = createTerminal()
terminals.push(term2)
await writeAndWait(term2, serialized)
const line = term2.buffer.active.getLine(0)
const redBgCell = line!.getCell(0)
expect(redBgCell!.getChars()).toBe("R")
expect(redBgCell!.getBgColor()).toBe(origRedBg)
const greenBgCell = line!.getCell(6)
expect(greenBgCell!.getChars()).toBe("G")
expect(greenBgCell!.getBgColor()).toBe(origGreenBg)
})
test("should handle combined colors and attributes", async () => {
const { term, addon } = createTerminal()
const input =
"\x1b[1;38;2;255;0;0;48;2;255;255;0mCOMBO\x1b[0mNORMAL "
await writeAndWait(term, input)
const origLine = term.buffer.active.getLine(0)
const origFg = origLine!.getCell(0)!.getFgColor()
const origBg = origLine!.getCell(0)!.getBgColor()
expect(origLine!.getCell(0)!.isBold()).toBe(1)
const serialized = addon.serialize({ range: { start: 0, end: 0 } })
const cleanSerialized = serialized.replace(/\x1b\[\d+X/g, "")
expect(cleanSerialized.startsWith("\x1b[1;")).toBe(true)
const { term: term2 } = createTerminal()
terminals.push(term2)
await writeAndWait(term2, cleanSerialized)
const line = term2.buffer.active.getLine(0)
const comboCell = line!.getCell(0)
expect(comboCell!.getChars()).toBe("C")
expect(cleanSerialized).toContain("\x1b[1;38;2;255;0;0;48;2;255;255;0m")
})
})
describe("round-trip serialization", () => {
test("should not produce ECH sequences", async () => {
const { term, addon } = createTerminal()
await writeAndWait(term, "\x1b[31mHello\x1b[0m World")
const serialized = addon.serialize()
const hasECH = /\x1b\[\d+X/.test(serialized)
expect(hasECH).toBe(false)
})
test("multi-line content should not have garbage characters", 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()
expect(/\x1b\[\d+X/.test(serialized)).toBe(false)
const { term: term2 } = createTerminal()
terminals.push(term2)
await writeAndWait(term2, serialized)
for (let row = 0; row < 3; row++) {
const line = term2.buffer.active.getLine(row)?.translateToString(true)
expect(line?.includes("𑼝")).toBe(false)
}
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("serialized output written to new terminal should match original colors", async () => {
const { term, addon } = createTerminal(40, 5)
const input = "\x1b[38;2;255;0;0mHello\x1b[0m \x1b[38;2;0;255;0mWorld\x1b[0m! "
await writeAndWait(term, input)
const origLine = term.buffer.active.getLine(0)
const origHelloFg = origLine!.getCell(0)!.getFgColor()
const origWorldFg = origLine!.getCell(6)!.getFgColor()
const serialized = addon.serialize({ range: { start: 0, end: 0 } })
const { term: term2 } = createTerminal(40, 5)
terminals.push(term2)
await writeAndWait(term2, serialized)
const newLine = term2.buffer.active.getLine(0)
expect(newLine!.getCell(0)!.getChars()).toBe("H")
expect(newLine!.getCell(0)!.getFgColor()).toBe(origHelloFg)
expect(newLine!.getCell(6)!.getChars()).toBe("W")
expect(newLine!.getCell(6)!.getFgColor()).toBe(origWorldFg)
expect(newLine!.getCell(11)!.getChars()).toBe("!")
})
})
})

View File

@@ -1,595 +0,0 @@
/**
* SerializeAddon - Serialize terminal buffer contents
*
* Port of xterm.js addon-serialize for ghostty-web.
* Enables serialization of terminal contents to a string that can
* be written back to restore terminal state.
*
* Usage:
* ```typescript
* const serializeAddon = new SerializeAddon();
* term.loadAddon(serializeAddon);
* const content = serializeAddon.serialize();
* ```
*/
import type { ITerminalAddon, ITerminalCore, IBufferRange } from "ghostty-web"
// ============================================================================
// Buffer Types (matching ghostty-web internal interfaces)
// ============================================================================
interface IBuffer {
readonly type: "normal" | "alternate"
readonly cursorX: number
readonly cursorY: number
readonly viewportY: number
readonly baseY: number
readonly length: number
getLine(y: number): IBufferLine | undefined
getNullCell(): IBufferCell
}
interface IBufferLine {
readonly length: number
readonly isWrapped: boolean
getCell(x: number): IBufferCell | undefined
translateToString(trimRight?: boolean, startColumn?: number, endColumn?: number): string
}
interface IBufferCell {
getChars(): string
getCode(): number
getWidth(): number
getFgColorMode(): number
getBgColorMode(): number
getFgColor(): number
getBgColor(): number
isBold(): number
isItalic(): number
isUnderline(): number
isStrikethrough(): number
isBlink(): number
isInverse(): number
isInvisible(): number
isFaint(): number
isDim(): boolean
}
// ============================================================================
// Types
// ============================================================================
export interface ISerializeOptions {
/**
* The row range to serialize. When an explicit range is specified, the cursor
* will get its final repositioning.
*/
range?: ISerializeRange
/**
* The number of rows in the scrollback buffer to serialize, starting from
* the bottom of the scrollback buffer. When not specified, all available
* rows in the scrollback buffer will be serialized.
*/
scrollback?: number
/**
* Whether to exclude the terminal modes from the serialization.
* Default: false
*/
excludeModes?: boolean
/**
* Whether to exclude the alt buffer from the serialization.
* Default: false
*/
excludeAltBuffer?: boolean
}
export interface ISerializeRange {
/**
* The line to start serializing (inclusive).
*/
start: number
/**
* The line to end serializing (inclusive).
*/
end: number
}
export interface IHTMLSerializeOptions {
/**
* The number of rows in the scrollback buffer to serialize, starting from
* the bottom of the scrollback buffer.
*/
scrollback?: number
/**
* Whether to only serialize the selection.
* Default: false
*/
onlySelection?: boolean
/**
* Whether to include the global background of the terminal.
* Default: false
*/
includeGlobalBackground?: boolean
/**
* The range to serialize. This is prioritized over onlySelection.
*/
range?: {
startLine: number
endLine: number
startCol: number
}
}
// ============================================================================
// Helper Functions
// ============================================================================
function constrain(value: number, low: number, high: number): number {
return Math.max(low, Math.min(value, high))
}
function equalFg(cell1: IBufferCell, cell2: IBufferCell): boolean {
return cell1.getFgColorMode() === cell2.getFgColorMode() && cell1.getFgColor() === cell2.getFgColor()
}
function equalBg(cell1: IBufferCell, cell2: IBufferCell): boolean {
return cell1.getBgColorMode() === cell2.getBgColorMode() && cell1.getBgColor() === cell2.getBgColor()
}
function equalFlags(cell1: IBufferCell, cell2: IBufferCell): boolean {
return (
!!cell1.isInverse() === !!cell2.isInverse() &&
!!cell1.isBold() === !!cell2.isBold() &&
!!cell1.isUnderline() === !!cell2.isUnderline() &&
!!cell1.isBlink() === !!cell2.isBlink() &&
!!cell1.isInvisible() === !!cell2.isInvisible() &&
!!cell1.isItalic() === !!cell2.isItalic() &&
!!cell1.isDim() === !!cell2.isDim() &&
!!cell1.isStrikethrough() === !!cell2.isStrikethrough()
)
}
// ============================================================================
// Base Serialize Handler
// ============================================================================
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()
const startRow = range.start.y
const endRow = range.end.y
const startColumn = range.start.x
const endColumn = range.end.x
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 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) {
continue
}
this._nextCell(c, oldCell, row, col)
oldCell = c
}
}
this._rowEnd(row, row === endRow)
}
this._afterSerialize()
return this._serializeString(excludeFinalCursorPosition)
}
protected _nextCell(_cell: IBufferCell, _oldCell: IBufferCell, _row: number, _col: number): void {}
protected _rowEnd(_row: number, _isLastRow: boolean): void {}
protected _beforeSerialize(_rows: number, _startRow: number, _endRow: number): void {}
protected _afterSerialize(): void {}
protected _serializeString(_excludeFinalCursorPosition?: boolean): string {
return ""
}
}
// ============================================================================
// String Serialize Handler
// ============================================================================
class StringSerializeHandler extends BaseSerializeHandler {
private _rowIndex: number = 0
private _allRows: string[] = []
private _allRowSeparators: string[] = []
private _currentRow: string = ""
private _nullCellCount: number = 0
private _cursorStyle: IBufferCell
private _firstRow: number = 0
private _lastCursorRow: number = 0
private _lastCursorCol: number = 0
private _lastContentCursorRow: number = 0
private _lastContentCursorCol: number = 0
constructor(
buffer: IBuffer,
private readonly _terminal: ITerminalCore,
) {
super(buffer)
this._cursorStyle = this._buffer.getNullCell()
}
protected _beforeSerialize(rows: number, start: number, _end: number): void {
this._allRows = new Array<string>(rows)
this._lastContentCursorRow = start
this._lastCursorRow = start
this._firstRow = start
}
protected _rowEnd(row: number, isLastRow: boolean): void {
let rowSeparator = ""
if (!isLastRow) {
const nextLine = this._buffer.getLine(row + 1)
if (!nextLine?.isWrapped) {
rowSeparator = "\r\n"
this._lastCursorRow = row + 1
this._lastCursorCol = 0
}
}
this._allRows[this._rowIndex] = this._currentRow
this._allRowSeparators[this._rowIndex++] = rowSeparator
this._currentRow = ""
this._nullCellCount = 0
}
private _diffStyle(cell: IBufferCell, oldCell: IBufferCell): number[] {
const sgrSeq: number[] = []
const fgChanged = !equalFg(cell, oldCell)
const bgChanged = !equalBg(cell, oldCell)
const flagsChanged = !equalFlags(cell, oldCell)
if (fgChanged || bgChanged || flagsChanged) {
if (this._isAttributeDefault(cell)) {
if (!this._isAttributeDefault(oldCell)) {
sgrSeq.push(0)
}
} else {
if (flagsChanged) {
if (!!cell.isInverse() !== !!oldCell.isInverse()) {
sgrSeq.push(cell.isInverse() ? 7 : 27)
}
if (!!cell.isBold() !== !!oldCell.isBold()) {
sgrSeq.push(cell.isBold() ? 1 : 22)
}
if (!!cell.isUnderline() !== !!oldCell.isUnderline()) {
sgrSeq.push(cell.isUnderline() ? 4 : 24)
}
if (!!cell.isBlink() !== !!oldCell.isBlink()) {
sgrSeq.push(cell.isBlink() ? 5 : 25)
}
if (!!cell.isInvisible() !== !!oldCell.isInvisible()) {
sgrSeq.push(cell.isInvisible() ? 8 : 28)
}
if (!!cell.isItalic() !== !!oldCell.isItalic()) {
sgrSeq.push(cell.isItalic() ? 3 : 23)
}
if (!!cell.isDim() !== !!oldCell.isDim()) {
sgrSeq.push(cell.isDim() ? 2 : 22)
}
if (!!cell.isStrikethrough() !== !!oldCell.isStrikethrough()) {
sgrSeq.push(cell.isStrikethrough() ? 9 : 29)
}
}
if (fgChanged) {
const color = cell.getFgColor()
const mode = cell.getFgColorMode()
if (mode === 2 || mode === 3 || mode === -1) {
sgrSeq.push(38, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
} else if (mode === 1) {
// Palette
if (color >= 16) {
sgrSeq.push(38, 5, color)
} else {
sgrSeq.push(color & 8 ? 90 + (color & 7) : 30 + (color & 7))
}
} else {
sgrSeq.push(39)
}
}
if (bgChanged) {
const color = cell.getBgColor()
const mode = cell.getBgColorMode()
if (mode === 2 || mode === 3 || mode === -1) {
sgrSeq.push(48, 2, (color >>> 16) & 0xff, (color >>> 8) & 0xff, color & 0xff)
} else if (mode === 1) {
// Palette
if (color >= 16) {
sgrSeq.push(48, 5, color)
} else {
sgrSeq.push(color & 8 ? 100 + (color & 7) : 40 + (color & 7))
}
} else {
sgrSeq.push(49)
}
}
}
}
return sgrSeq
}
private _isAttributeDefault(cell: IBufferCell): boolean {
const mode = cell.getFgColorMode()
const bgMode = cell.getBgColorMode()
if (mode === 0 && bgMode === 0) {
return (
!cell.isBold() &&
!cell.isItalic() &&
!cell.isUnderline() &&
!cell.isBlink() &&
!cell.isInverse() &&
!cell.isInvisible() &&
!cell.isDim() &&
!cell.isStrikethrough()
)
}
const fgColor = cell.getFgColor()
const bgColor = cell.getBgColor()
const nullCell = this._buffer.getNullCell()
const nullFg = nullCell.getFgColor()
const nullBg = nullCell.getBgColor()
return (
fgColor === nullFg &&
bgColor === nullBg &&
!cell.isBold() &&
!cell.isItalic() &&
!cell.isUnderline() &&
!cell.isBlink() &&
!cell.isInverse() &&
!cell.isInvisible() &&
!cell.isDim() &&
!cell.isStrikethrough()
)
}
protected _nextCell(cell: IBufferCell, _oldCell: IBufferCell, row: number, col: number): void {
const isPlaceHolderCell = cell.getWidth() === 0
if (isPlaceHolderCell) {
return
}
const codepoint = cell.getCode()
const isGarbage = codepoint >= 0xf000
const isEmptyCell = codepoint === 0 || cell.getChars() === "" || isGarbage
const sgrSeq = this._diffStyle(cell, this._cursorStyle)
const styleChanged = isEmptyCell ? !equalBg(this._cursorStyle, cell) : sgrSeq.length > 0
if (styleChanged) {
if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C`
this._nullCellCount = 0
}
this._lastContentCursorRow = this._lastCursorRow = row
this._lastContentCursorCol = this._lastCursorCol = col
this._currentRow += `\u001b[${sgrSeq.join(";")}m`
const line = this._buffer.getLine(row)
const cellFromLine = line?.getCell(col)
if (cellFromLine) {
this._cursorStyle = cellFromLine
}
}
if (isEmptyCell) {
this._nullCellCount += cell.getWidth()
} else {
if (this._nullCellCount > 0) {
this._currentRow += `\u001b[${this._nullCellCount}C`
this._nullCellCount = 0
}
this._currentRow += cell.getChars()
this._lastContentCursorRow = this._lastCursorRow = row
this._lastContentCursorCol = this._lastCursorCol = col + cell.getWidth()
}
}
protected _serializeString(excludeFinalCursorPosition?: boolean): string {
let rowEnd = this._allRows.length
if (this._buffer.length - this._firstRow <= this._terminal.rows) {
rowEnd = this._lastContentCursorRow + 1 - this._firstRow
this._lastCursorCol = this._lastContentCursorCol
this._lastCursorRow = this._lastContentCursorRow
}
let content = ""
for (let i = 0; i < rowEnd; i++) {
content += this._allRows[i]
if (i + 1 < rowEnd) {
content += this._allRowSeparators[i]
}
}
if (!excludeFinalCursorPosition) {
const absoluteCursorRow = (this._buffer.baseY ?? 0) + this._buffer.cursorY
const cursorRow = constrain(absoluteCursorRow - this._firstRow + 1, 1, Number.MAX_SAFE_INTEGER)
const cursorCol = this._buffer.cursorX + 1
content += `\u001b[${cursorRow};${cursorCol}H`
}
return content
}
}
// ============================================================================
// SerializeAddon Class
// ============================================================================
export class SerializeAddon implements ITerminalAddon {
private _terminal?: ITerminalCore
/**
* Activate the addon (called by Terminal.loadAddon)
*/
public activate(terminal: ITerminalCore): void {
this._terminal = terminal
}
/**
* Dispose the addon and clean up resources
*/
public dispose(): void {
this._terminal = undefined
}
/**
* Serializes terminal rows into a string that can be written back to the
* terminal to restore the state. The cursor will also be positioned to the
* correct cell.
*
* @param options Custom options to allow control over what gets serialized.
*/
public serialize(options?: ISerializeOptions): string {
if (!this._terminal) {
throw new Error("Cannot use addon until it has been loaded")
}
const terminal = this._terminal as any
const buffer = terminal.buffer
if (!buffer) {
return ""
}
const normalBuffer = buffer.normal || buffer.active
const altBuffer = buffer.alternate
if (!normalBuffer) {
return ""
}
let content = options?.range
? this._serializeBufferByRange(normalBuffer, options.range, true)
: this._serializeBufferByScrollback(normalBuffer, options?.scrollback)
if (!options?.excludeAltBuffer && buffer.active?.type === "alternate" && altBuffer) {
const alternateContent = this._serializeBufferByScrollback(altBuffer, undefined)
content += `\u001b[?1049h\u001b[H${alternateContent}`
}
return content
}
/**
* Serializes terminal content as plain text (no escape sequences)
* @param options Custom options to allow control over what gets serialized.
*/
public serializeAsText(options?: { scrollback?: number; trimWhitespace?: boolean }): string {
if (!this._terminal) {
throw new Error("Cannot use addon until it has been loaded")
}
const terminal = this._terminal as any
const buffer = terminal.buffer
if (!buffer) {
return ""
}
const activeBuffer = buffer.active || buffer.normal
if (!activeBuffer) {
return ""
}
const maxRows = activeBuffer.length
const scrollback = options?.scrollback
const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + this._terminal.rows, 0, maxRows)
const startRow = maxRows - correctRows
const endRow = maxRows - 1
const lines: string[] = []
for (let row = startRow; row <= endRow; row++) {
const line = activeBuffer.getLine(row)
if (line) {
const text = line.translateToString(options?.trimWhitespace ?? true)
lines.push(text)
}
}
// Trim trailing empty lines if requested
if (options?.trimWhitespace) {
while (lines.length > 0 && lines[lines.length - 1] === "") {
lines.pop()
}
}
return lines.join("\n")
}
private _serializeBufferByScrollback(buffer: IBuffer, scrollback?: number): string {
const maxRows = buffer.length
const rows = this._terminal?.rows ?? 24
const correctRows = scrollback === undefined ? maxRows : constrain(scrollback + rows, 0, maxRows)
return this._serializeBufferByRange(
buffer,
{
start: maxRows - correctRows,
end: maxRows - 1,
},
false,
)
}
private _serializeBufferByRange(
buffer: IBuffer,
range: ISerializeRange,
excludeFinalCursorPosition: boolean,
): string {
const handler = new StringSerializeHandler(buffer, this._terminal!)
const cols = this._terminal?.cols ?? 80
return handler.serialize(
{
start: { x: 0, y: range.start },
end: { x: cols, y: range.end },
},
excludeFinalCursorPosition,
)
}
}

View File

@@ -235,7 +235,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
const abort = () =>
sdk.client.session.abort({
sessionID: session.id!,
path: {
id: session.id!,
},
})
const handleKeyDown = (event: KeyboardEvent) => {
@@ -327,19 +329,21 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
sdk.client.session.prompt({
sessionID: existing.id,
agent: local.agent.current()!.name,
model: {
modelID: local.model.current()!.id,
providerID: local.model.current()!.provider.id,
},
parts: [
{
type: "text",
text,
path: { id: existing.id },
body: {
agent: local.agent.current()!.name,
model: {
modelID: local.model.current()!.id,
providerID: local.model.current()!.provider.id,
},
...attachmentParts,
],
parts: [
{
type: "text",
text,
},
...attachmentParts,
],
},
})
}
@@ -452,9 +456,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
<img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0" />
<div class="flex gap-x-3 items-baseline flex-[1_0_0]">
<span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
<Show when={false}>
<Show when={i.release_date}>
<span class="text-12-medium text-text-weak overflow-hidden text-ellipsis truncate min-w-0">
{DateTime.fromFormat("unknown", "yyyy-MM-dd").toFormat("LLL yyyy")}
{DateTime.fromFormat(i.release_date, "yyyy-MM-dd").toFormat("LLL yyyy")}
</span>
</Show>
</div>

View File

@@ -1,150 +0,0 @@
import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
import { ComponentProps, onCleanup, onMount, splitProps } from "solid-js"
import { useSDK } from "@/context/sdk"
import { SerializeAddon } from "@/addons/serialize"
import { LocalPTY } from "@/context/session"
export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY
onSubmit?: () => void
onCleanup?: (pty: LocalPTY) => void
onConnectError?: (error: unknown) => void
}
export const Terminal = (props: TerminalProps) => {
const sdk = useSDK()
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnectError"])
let ws: WebSocket
let term: Term
let ghostty: Ghostty
let serializeAddon: SerializeAddon
let fitAddon: FitAddon
let handleResize: () => void
onMount(async () => {
ghostty = await Ghostty.load()
ws = new WebSocket(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
term = new Term({
cursorBlink: true,
fontSize: 14,
fontFamily: "TX-02, monospace",
allowTransparency: true,
theme: {
background: "#191515",
foreground: "#d4d4d4",
},
scrollback: 10_000,
ghostty,
})
term.attachCustomKeyEventHandler((event) => {
// allow for ctrl-` to toggle terminal in parent
if (event.ctrlKey && event.key.toLowerCase() === "`") {
event.preventDefault()
return true
}
return false
})
fitAddon = new FitAddon()
serializeAddon = new SerializeAddon()
term.loadAddon(serializeAddon)
term.loadAddon(fitAddon)
term.open(container)
if (local.pty.buffer) {
if (local.pty.rows && local.pty.cols) {
term.resize(local.pty.cols, local.pty.rows)
}
term.reset()
term.write(local.pty.buffer)
if (local.pty.scrollY) {
term.scrollToLine(local.pty.scrollY)
}
fitAddon.fit()
}
container.focus()
fitAddon.observeResize()
handleResize = () => fitAddon.fit()
window.addEventListener("resize", handleResize)
term.onResize(async (size) => {
if (ws && ws.readyState === WebSocket.OPEN) {
await sdk.client.pty.update({
ptyID: local.pty.id,
size: {
cols: size.cols,
rows: size.rows,
},
})
}
})
term.onData((data) => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(data)
}
})
term.onKey((key) => {
if (key.key == "Enter") {
props.onSubmit?.()
}
})
// term.onScroll((ydisp) => {
// console.log("Scroll position:", ydisp)
// })
ws.addEventListener("open", () => {
console.log("WebSocket connected")
sdk.client.pty.update({
ptyID: local.pty.id,
size: {
cols: term.cols,
rows: term.rows,
},
})
})
ws.addEventListener("message", (event) => {
term.write(event.data)
})
ws.addEventListener("error", (error) => {
console.error("WebSocket error:", error)
props.onConnectError?.(error)
})
ws.addEventListener("close", () => {
console.log("WebSocket disconnected")
})
})
onCleanup(() => {
if (handleResize) {
window.removeEventListener("resize", handleResize)
}
if (serializeAddon && props.onCleanup) {
const buffer = serializeAddon.serialize()
props.onCleanup({
...local.pty,
buffer,
rows: term.rows,
cols: term.cols,
scrollY: term.getViewportY(),
})
}
ws?.close()
term?.dispose()
})
return (
<div
ref={container}
data-component="terminal"
classList={{
...(local.classList ?? {}),
"size-full px-6 py-3 font-mono": true,
[local.class ?? ""]: !!local.class,
}}
{...others}
/>
)
}

View File

@@ -1,4 +1,4 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"

View File

@@ -12,7 +12,7 @@ import type {
FileDiff,
Todo,
SessionStatus,
} from "@opencode-ai/sdk/v2"
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { Binary } from "@opencode-ai/util/binary"
import { createSimpleContext } from "@opencode-ai/ui/context"

View File

@@ -15,16 +15,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
opened: true,
width: 280,
},
terminal: {
opened: false,
height: 280,
},
review: {
state: "pane" as "pane" | "tab",
},
}),
{
name: "____default-layout",
name: "___default-layout",
},
)
@@ -65,22 +61,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("sidebar", "width", width)
},
},
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: {
state: createMemo(() => store.review?.state ?? "closed"),
pane() {

View File

@@ -1,11 +1,11 @@
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createEffect, createMemo } from "solid-js"
import { uniqueBy } from "remeda"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2"
import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { base64Encode } from "@/utils"
export type LocalFile = FileNode &
Partial<{
@@ -257,7 +257,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const load = async (path: string) => {
const relativePath = relative(path)
sdk.client.file.read({ path: relativePath }).then((x) => {
sdk.client.file.read({ query: { path: relativePath } }).then((x) => {
setStore(
"node",
relativePath,
@@ -305,7 +305,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
const list = async (path: string) => {
return sdk.client.file.list({ path: path + "/" }).then((x) => {
return sdk.client.file.list({ query: { path: path + "/" } }).then((x) => {
setStore(
"node",
produce((draft) => {
@@ -318,9 +318,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
}
const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!)
const searchFiles = (query: string) =>
sdk.client.find.files({ query: { query, dirs: "false" } }).then((x) => x.data!)
const searchFilesAndDirectories = (query: string) =>
sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!)
sdk.client.find.files({ query: { query, dirs: "true" } }).then((x) => x.data!)
sdk.event.listen((e) => {
const event = e.details

View File

@@ -1,4 +1,4 @@
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { onCleanup } from "solid-js"
@@ -27,6 +27,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
abort.abort()
})
return { directory: props.directory, client: sdk, event: emitter, url: globalSDK.url }
return { directory: props.directory, client: sdk, event: emitter }
},
})

View File

@@ -5,28 +5,17 @@ import { useSync } from "./sync"
import { makePersisted } from "@solid-primitives/storage"
import { TextSelection } from "./local"
import { pipe, sumBy } from "remeda"
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
import { AssistantMessage, UserMessage } from "@opencode-ai/sdk"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode-ai/util/encode"
import { useSDK } from "./sdk"
export type LocalPTY = {
id: string
title: string
rows?: number
cols?: number
buffer?: string
scrollY?: number
}
import { base64Encode } from "@/utils"
export const { use: useSession, provider: SessionProvider } = createSimpleContext({
name: "Session",
init: () => {
const sdk = useSDK()
const params = useParams()
const sync = useSync()
const name = createMemo(
() => `${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}.v2`,
() => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
)
const [store, setStore] = makePersisted(
@@ -34,21 +23,16 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
messageId?: string
tabs: {
active?: string
all: string[]
opened: string[]
}
prompt: Prompt
cursor?: number
terminals: {
active?: string
all: LocalPTY[]
}
}>({
tabs: {
all: [],
opened: [],
},
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
terminals: { all: [] },
}),
{
name: name(),
@@ -154,7 +138,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
setStore("tabs", "active", tab)
},
setOpenedTabs(tabs: string[]) {
setStore("tabs", "all", tabs)
setStore("tabs", "opened", tabs)
},
async openTab(tab: string) {
if (tab === "chat") {
@@ -162,8 +146,8 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
return
}
if (tab !== "review") {
if (!store.tabs.all.includes(tab)) {
setStore("tabs", "all", [...store.tabs.all, tab])
if (!store.tabs.opened.includes(tab)) {
setStore("tabs", "opened", [...store.tabs.opened, tab])
}
}
setStore("tabs", "active", tab)
@@ -172,99 +156,28 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
batch(() => {
setStore(
"tabs",
"all",
store.tabs.all.filter((x) => x !== tab),
"opened",
store.tabs.opened.filter((x) => x !== tab),
)
if (store.tabs.active === tab) {
const index = store.tabs.all.findIndex((f) => f === tab)
const previous = store.tabs.all[Math.max(0, index - 1)]
const index = store.tabs.opened.findIndex((f) => f === tab)
const previous = store.tabs.opened[Math.max(0, index - 1)]
setStore("tabs", "active", previous)
}
})
},
moveTab(tab: string, to: number) {
const index = store.tabs.all.findIndex((f) => f === tab)
const index = store.tabs.opened.findIndex((f) => f === tab)
if (index === -1) return
setStore(
"tabs",
"all",
"opened",
produce((opened) => {
opened.splice(to, 0, opened.splice(index, 1)[0])
}),
)
},
},
terminal: {
all: createMemo(() => Object.values(store.terminals.all)),
active: createMemo(() => store.terminals.active),
new() {
sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => {
const id = pty.data?.id
if (!id) return
setStore("terminals", "all", [
...store.terminals.all,
{
id,
title: pty.data?.title ?? "Terminal",
},
])
setStore("terminals", "active", id)
})
},
update(pty: Partial<LocalPTY> & { id: string }) {
setStore("terminals", "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,
})
},
async clone(id: string) {
const index = store.terminals.all.findIndex((x) => x.id === id)
const pty = store.terminals.all[index]
if (!pty) return
const clone = await sdk.client.pty.create({
title: pty.title,
})
if (!clone.data) return
setStore("terminals", "all", index, {
...pty,
...clone.data,
})
if (store.terminals.active === pty.id) {
setStore("terminals", "active", clone.data.id)
}
},
open(id: string) {
setStore("terminals", "active", id)
},
async close(id: string) {
batch(() => {
setStore(
"terminals",
"all",
store.terminals.all.filter((x) => x.id !== id),
)
if (store.terminals.active === id) {
const index = store.terminals.all.findIndex((f) => f.id === id)
const previous = store.tabs.all[Math.max(0, index - 1)]
setStore("terminals", "active", previous)
}
})
await sdk.client.pty.remove({ ptyID: id })
},
move(id: string, to: number) {
const index = store.terminals.all.findIndex((f) => f.id === id)
if (index === -1) return
setStore(
"terminals",
"all",
produce((all) => {
all.splice(to, 0, all.splice(index, 1)[0])
}),
)
},
},
}
},
})

View File

@@ -28,7 +28,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
status: () => sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
node: () => sdk.client.file.list({ path: "/" }).then((x) => setStore("node", x.data!)),
node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),
}
Promise.all(Object.values(load).map((p) => p())).then(() => setStore("ready", true))
@@ -49,10 +49,10 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
},
async sync(sessionID: string, _isRetry = false) {
const [session, messages, todo, diff] = await Promise.all([
sdk.client.session.get({ sessionID }, { throwOnError: true }),
sdk.client.session.messages({ sessionID, limit: 100 }),
sdk.client.session.todo({ sessionID }),
sdk.client.session.diff({ sessionID }),
sdk.client.session.get({ path: { id: sessionID }, throwOnError: true }),
sdk.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } }),
sdk.client.session.todo({ path: { id: sessionID } }),
sdk.client.session.diff({ path: { id: sessionID } }),
])
setStore(
produce((draft) => {

View File

@@ -1,22 +0,0 @@
// @refresh reload
import { render } from "solid-js/web"
import { DesktopInterface } from "@/DesktopInterface"
import { Platform, PlatformProvider } from "@/PlatformContext"
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
)
}
const platform: Platform = {}
render(
() => (
<PlatformProvider value={platform}>
<DesktopInterface />
</PlatformProvider>
),
root!,
)

View File

@@ -1,2 +0,0 @@
export { PlatformProvider, type Platform } from "./PlatformContext"
export { DesktopInterface } from "./DesktopInterface"

View File

@@ -0,0 +1,73 @@
/* @refresh reload */
import "@/index.css"
import { render } from "solid-js/web"
import { Router, Route, Navigate } from "@solidjs/router"
import { MetaProvider } from "@solidjs/meta"
import { Font } from "@opencode-ai/ui/font"
import { Favicon } from "@opencode-ai/ui/favicon"
import { MarkedProvider } from "@opencode-ai/ui/context/marked"
import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
import Layout from "@/pages/layout"
import DirectoryLayout from "@/pages/directory-layout"
import Session from "@/pages/session"
import { LayoutProvider } from "./context/layout"
import { GlobalSDKProvider } from "./context/global-sdk"
import { SessionProvider } from "./context/session"
import { base64Encode } from "./utils"
import { createMemo, Show } from "solid-js"
const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
const url =
new URLSearchParams(document.location.search).get("url") ||
(location.hostname.includes("opencode.ai") || location.hostname.includes("localhost")
? `http://${host}:${port}`
: "/")
const root = document.getElementById("root")
if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
throw new Error(
"Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?",
)
}
render(
() => (
<MarkedProvider>
<GlobalSDKProvider url={url}>
<GlobalSyncProvider>
<LayoutProvider>
<MetaProvider>
<Font />
<Router root={Layout}>
<Route
path="/"
component={() => {
const globalSync = useGlobalSync()
const slug = createMemo(() => base64Encode(globalSync.data.defaultProject!.worktree))
return <Navigate href={`${slug()}/session`} />
}}
/>
<Route path="/:dir" component={DirectoryLayout}>
<Route path="/" component={() => <Navigate href="session" />} />
<Route
path="/session/:id?"
component={(p) => (
<Show when={p.params.id || true} keyed>
<SessionProvider>
<Session />
</SessionProvider>
</Show>
)}
/>
</Route>
</Router>
</MetaProvider>
</LayoutProvider>
</GlobalSyncProvider>
</GlobalSDKProvider>
</MarkedProvider>
),
root!,
)

View File

@@ -4,7 +4,7 @@ import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
import { LocalProvider } from "@/context/local"
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode } from "@opencode-ai/util/encode"
import { base64Decode } from "@/utils"
import { DataProvider } from "@opencode-ai/ui/context"
import { iife } from "@opencode-ai/util/iife"

View File

@@ -1,5 +1,5 @@
import { useGlobalSync } from "@/context/global-sync"
import { base64Encode } from "@opencode-ai/util/encode"
import { base64Encode } from "@/utils"
import { For } from "solid-js"
import { A } from "@solidjs/router"
import { Button } from "@opencode-ai/ui/button"

View File

@@ -1,11 +1,10 @@
import { createMemo, For, ParentProps, Show } from "solid-js"
import { DateTime } from "luxon"
import { A, useNavigate, useParams } from "@solidjs/router"
import { A, useParams } from "@solidjs/router"
import { useLayout } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { base64Encode } from "@/utils"
import { Mark } from "@opencode-ai/ui/logo"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -13,22 +12,11 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Collapsible } from "@opencode-ai/ui/collapsible"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { getFilename } from "@opencode-ai/util/path"
import { Select } from "@opencode-ai/ui/select"
import { Session } from "@opencode-ai/sdk/v2/client"
export default function Layout(props: ParentProps) {
const navigate = useNavigate()
const params = useParams()
const globalSync = useGlobalSync()
const layout = useLayout()
const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
const sessions = createMemo(() => globalSync.child(currentDirectory())[0].session ?? [])
const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
function navigateToSession(session: Session | undefined) {
if (!session) return
navigate(`/${params.dir}/session/${session?.id}`)
}
const handleOpenProject = async () => {
// layout.projects.open(dir.)
@@ -36,7 +24,7 @@ export default function Layout(props: ParentProps) {
return (
<div class="relative h-screen flex flex-col">
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
<header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base">
<A
href="/"
classList={{
@@ -45,88 +33,19 @@ export default function Layout(props: ParentProps) {
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
data-tauri-drag-region
>
<Mark class="shrink-0" />
</A>
<div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
<div class="flex items-center gap-3">
<div class="flex items-center gap-2">
<Select
options={layout.projects.list().map((project) => getFilename(project.directory))}
current={getFilename(currentDirectory())}
class="text-14-regular text-text-base"
variant="ghost"
/>
<div class="text-text-weaker">/</div>
<Select
options={sessions()}
current={currentSession()}
placeholder="Select session"
label={(x) => x.title}
value={(x) => x.id}
onSelect={navigateToSession}
class="text-14-regular text-text-base max-w-md"
variant="ghost"
/>
</div>
<Button as={A} href={`/${params.dir}/session`} icon="plus-small">
New session
</Button>
</div>
<div class="flex items-center gap-4">
<Tooltip
class="shrink-0"
value={
<div class="flex items-center gap-2">
<span>Toggle terminal</span>
<span class="text-icon-base text-12-medium">Ctrl `</span>
</div>
}
>
<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>
</Tooltip>
</div>
</div>
</header>
<div class="h-[calc(100vh-3rem)] flex">
<div
classList={{
"relative @container w-12 pb-5 shrink-0 bg-background-base": true,
"@container w-12 pb-5 shrink-0 bg-background-base": true,
"flex flex-col gap-5.5 items-start self-stretch justify-between": true,
"border-r border-border-weak-base": true,
}}
style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
>
<Show when={layout.sidebar.opened()}>
<ResizeHandle
direction="horizontal"
size={layout.sidebar.width()}
min={150}
max={window.innerWidth * 0.3}
collapseThreshold={80}
onResize={layout.sidebar.resize}
onCollapse={layout.sidebar.close}
/>
</Show>
<div class="grow flex flex-col items-start self-stretch gap-4 p-2 min-h-0">
<Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
<Button
@@ -190,7 +109,7 @@ export default function Layout(props: ParentProps) {
>
<Tooltip placement="right" value={session.title}>
<div
class="w-full px-2 py-1 rounded-md
class="w-full px-2 py-1 rounded-md
group-data-[active=true]/session:bg-surface-raised-base-hover
group-hover/session:bg-surface-raised-base-hover
group-focus/session:bg-surface-raised-base-hover"
@@ -278,7 +197,7 @@ export default function Layout(props: ParentProps) {
</Tooltip>
</div>
</div>
<main class="size-full overflow-x-hidden flex flex-col items-start">{props.children}</main>
<main class="size-full overflow-x-hidden">{props.children}</main>
</div>
</div>
)

View File

@@ -1,4 +1,4 @@
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js"
import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo } from "solid-js"
import { useLocal, type LocalFile } from "@/context/local"
import { createStore } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
@@ -9,11 +9,10 @@ import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { Code } from "@opencode-ai/ui/code"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
import { MessageNav } from "@opencode-ai/ui/message-nav"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { SelectDialog } from "@opencode-ai/ui/select-dialog"
import {
@@ -31,8 +30,6 @@ import { useSync } from "@/context/sync"
import { useSession } from "@/context/session"
import { useLayout } from "@/context/layout"
import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Terminal } from "@/components/terminal"
import { checksum } from "@opencode-ai/util/encode"
export default function Page() {
const layout = useLayout()
@@ -56,14 +53,6 @@ export default function Page() {
document.removeEventListener("keydown", handleKeyDown)
})
createEffect(() => {
if (layout.terminal.opened()) {
if (session.terminal.all().length === 0) {
session.terminal.new()
}
}
})
const handleKeyDown = (event: KeyboardEvent) => {
if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
event.preventDefault()
@@ -83,20 +72,6 @@ export default function Page() {
document.documentElement.setAttribute("data-theme", nextTheme)
return
}
if (event.ctrlKey && event.key.toLowerCase() === "`") {
event.preventDefault()
if (event.shiftKey) {
session.terminal.new()
return
}
layout.terminal.toggle()
return
}
// @ts-expect-error
if (document.activeElement?.dataset?.component === "terminal") {
return
}
const focused = document.activeElement === inputRef
if (focused) {
@@ -148,6 +123,7 @@ export default function Page() {
const handleTabClick = async (tab: string) => {
if (store.clickTimer) {
resetClickTimer()
// local.file.update(file.path, { ...file, pinned: true })
} else {
if (tab.startsWith("file://")) {
local.file.open(tab.replace("file://", ""))
@@ -165,7 +141,7 @@ export default function Page() {
const handleDragOver = (event: DragEvent) => {
const { draggable, droppable } = event
if (draggable && droppable) {
const currentTabs = session.layout.tabs.all
const currentTabs = session.layout.tabs.opened
const fromIndex = currentTabs?.indexOf(draggable.id.toString())
const toIndex = currentTabs?.indexOf(droppable.id.toString())
if (fromIndex !== toIndex && toIndex !== undefined) {
@@ -283,375 +259,314 @@ export default function Page() {
const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
return (
<div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
<div class="min-h-0 grow w-full">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat">
<div class="flex gap-x-[17px] items-center">
<div>Session</div>
<Tooltip
value={`${new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(session.usage.tokens() ?? 0)} Tokens`}
class="flex items-center gap-1.5"
>
<ProgressCircle percentage={session.usage.context() ?? 0} />
<div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
</Tooltip>
</div>
</Tabs.Trigger>
<Show when={layout.review.state() === "tab" && session.diffs().length}>
<Tabs.Trigger
value="review"
closeButton={
<IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
}
<div class="relative bg-background-base size-full overflow-x-hidden">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
<div class="sticky top-0 shrink-0 flex">
<Tabs.List>
<Tabs.Trigger value="chat">
<div class="flex gap-x-[17px] items-center">
<div>Session</div>
<Tooltip
value={`${new Intl.NumberFormat("en-US", {
notation: "compact",
compactDisplay: "short",
}).format(session.usage.tokens() ?? 0)} Tokens`}
class="flex items-center gap-1.5"
>
<div class="flex items-center gap-3">
<Show when={session.diffs()}>
<DiffChanges changes={session.diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>Review</div>
<Show when={session.info()?.summary?.files}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{session.info()?.summary?.files ?? 0}
</div>
</Show>
</div>
</div>
</Tabs.Trigger>
</Show>
<SortableProvider ids={session.layout.tabs.all ?? []}>
<For each={session.layout.tabs.all ?? []}>
{(tab) => (
<SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />
)}
</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<Tooltip value="Open file" class="flex items-center">
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => setStore("fileSelectOpen", true)}
/>
<ProgressCircle percentage={session.usage.context() ?? 0} />
<div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
</Tooltip>
</div>
</Tabs.List>
</div>
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
<div
classList={{
"w-full flex-1 min-h-0": true,
grid: layout.review.state() === "tab",
flex: layout.review.state() === "pane",
}}
>
<div
classList={{
"relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full": true,
"max-w-146 mx-auto": !wide(),
}}
</Tabs.Trigger>
<Show when={layout.review.state() === "tab" && session.diffs().length}>
<Tabs.Trigger
value="review"
closeButton={
<IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
}
>
<Switch>
<Match when={session.id}>
<div class="flex items-start justify-start h-full min-h-0">
<SessionMessageRail
messages={session.messages.user()}
current={session.messages.active()}
onMessageSelect={session.messages.setActive}
working={session.working()}
wide={wide()}
/>
<SessionTurn
sessionID={session.id!}
messageID={session.messages.active()?.id!}
classes={{
root: "pb-20 flex-1 min-w-0",
content: "pb-20",
container:
"w-full " +
(wide()
? "max-w-146 mx-auto px-6"
: session.messages.user().length > 1
? "pr-6 pl-18"
: "px-6"),
}}
/>
</div>
</Match>
<Match when={true}>
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-146 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(sync.data.path.directory)}
<span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
</div>
<div class="flex items-center gap-3">
<Show when={session.diffs()}>
<DiffChanges changes={session.diffs()} variant="bars" />
</Show>
<div class="flex items-center gap-1.5">
<div>Review</div>
<Show when={session.info()?.summary?.files}>
<div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
{session.info()?.summary?.files ?? 0}
</div>
<div class="flex justify-center items-center gap-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(sync.data.project.time.created).toRelative()}
</span>
</div>
</div>
</div>
</Match>
</Switch>
<div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
<div class="w-full max-w-146 px-6">
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
</Show>
</div>
</div>
</div>
<Show when={layout.review.state() === "pane" && session.diffs().length}>
<div
classList={{
"relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
}}
>
<SessionReview
classes={{
root: "pb-20",
header: "px-6",
container: "px-6",
}}
diffs={session.diffs()}
actions={
<Tooltip value="Open in tab">
<IconButton
icon="expand"
variant="ghost"
onClick={() => {
layout.review.tab()
session.layout.setActiveTab("review")
}}
/>
</Tooltip>
}
/>
</div>
</Show>
</Tabs.Trigger>
</Show>
<SortableProvider ids={session.layout.tabs.opened ?? []}>
<For each={session.layout.tabs.opened ?? []}>
{(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />}
</For>
</SortableProvider>
<div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
<Tooltip value="Open file" class="flex items-center">
<IconButton
icon="plus-small"
variant="ghost"
iconSize="large"
onClick={() => setStore("fileSelectOpen", true)}
/>
</Tooltip>
</div>
</Tabs.Content>
<Show when={layout.review.state() === "tab" && session.diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
</Tabs.List>
</div>
<Tabs.Content value="chat" class="@container select-text flex flex-col flex-1 min-h-0 overflow-y-hidden">
<div
classList={{
"w-full flex-1 min-h-0": true,
grid: layout.review.state() === "tab",
flex: layout.review.state() === "pane",
}}
>
<div class="relative shrink-0 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-2xl mx-auto">
<Switch>
<Match when={session.id}>
<div class="flex items-start justify-start h-full min-h-0">
<Show when={session.messages.user().length > 1}>
<>
<MessageNav
class="@6xl:hidden mt-3 mr-8"
messages={session.messages.user()}
current={session.messages.active()}
onMessageSelect={session.messages.setActive}
size="compact"
working={session.working()}
/>
<MessageNav
classList={{
"hidden @6xl:flex": true,
"mt-0.5 mr-3 absolute right-full": wide(),
"mt-3 mr-8": !wide(),
}}
messages={session.messages.user()}
current={session.messages.active()}
onMessageSelect={session.messages.setActive}
size={wide() ? "normal" : "compact"}
working={session.working()}
/>
</>
</Show>
<SessionTurn
sessionID={session.id!}
messageID={session.messages.active()?.id!}
classes={{ root: "pb-20 flex-1 min-w-0", content: "pb-20", container: "px-6" }}
/>
</div>
</Match>
<Match when={true}>
<div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch">
<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(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-3">
<Icon name="pencil-line" size="small" />
<div class="text-12-medium text-text-weak">
Last modified&nbsp;
<span class="text-text-strong">
{DateTime.fromMillis(sync.data.project.time.created).toRelative()}
</span>
</div>
</div>
</div>
</Match>
</Switch>
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
</div>
</div>
<Show when={layout.review.state() === "pane" && session.diffs().length}>
<div
classList={{
"relative pt-3 flex-1 min-h-0 overflow-hidden": true,
"relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
}}
>
<SessionReview
classes={{
root: "pb-40",
root: "pb-20",
header: "px-6",
container: "px-6",
}}
diffs={session.diffs()}
split
actions={
<Tooltip value="Open in tab">
<IconButton
icon="expand"
variant="ghost"
onClick={() => {
layout.review.tab()
session.layout.setActiveTab("review")
}}
/>
</Tooltip>
}
/>
</div>
</Tabs.Content>
</Show>
<For each={session.layout.tabs.all}>
{(tab) => {
const [file] = createResource(
() => tab,
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<Tabs.Content value={tab} class="select-text mt-3">
<Switch>
<Match when={file()}>
{(f) => (
<Code
file={{
name: f().path,
contents: f().content?.content ?? "",
cacheKey: checksum(f().content?.content ?? ""),
}}
overflow="scroll"
class="pb-40"
/>
)}
</Match>
</Switch>
</Tabs.Content>
)
}}
</For>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedFile) => {
const [file] = createResource(
() => draggedFile(),
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
<Show when={session.layout.tabs.active}>
<div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
</div>
</Show>
<div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
{/* <FileTree path="" onFileClick={ handleTabClick} /> */}
</div>
<div class="hidden shrink-0 w-56 p-2">
<Show
when={local.file.changes().length}
fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
>
<ul class="">
<For each={local.file.changes()}>
{(path) => (
<li>
<button
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
>
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
{getDirectory(path)}
</span>
</button>
</li>
)}
</For>
</ul>
</Show>
</div>
<Show when={store.fileSelectOpen}>
<SelectDialog
defaultOpen
title="Select file"
placeholder="Search files"
emptyMessage="No files found"
items={local.file.searchFiles}
key={(x) => x}
onOpenChange={(open) => setStore("fileSelectOpen", open)}
onSelect={(x) => {
if (x) {
local.file.open(x)
return session.layout.openTab("file://" + x)
}
return undefined
}}
>
{(i) => (
</Show>
</div>
</Tabs.Content>
<Show when={layout.review.state() === "tab" && session.diffs().length}>
<Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
<div
classList={{
"w-full flex items-center justify-between rounded-md": true,
"relative pt-3 flex-1 min-h-0 overflow-hidden": true,
}}
>
<div class="flex items-center gap-x-2 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>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
<SessionReview
classes={{
root: "pb-40",
header: "px-6",
container: "px-6",
}}
diffs={session.diffs()}
split
/>
</div>
)}
</SelectDialog>
</Show>
</div>
<Show when={layout.terminal.opened()}>
<div
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${layout.terminal.height()}px` }}
>
<ResizeHandle
direction="vertical"
size={layout.terminal.height()}
min={100}
max={window.innerHeight * 0.6}
collapseThreshold={50}
onResize={layout.terminal.resize}
onCollapse={layout.terminal.close}
/>
<Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}>
<Tabs.List class="h-10">
<For each={session.terminal.all()}>
{(terminal) => (
<Tabs.Trigger
value={terminal.id}
closeButton={
session.terminal.all().length > 1 && (
<IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(terminal.id)} />
)
}
>
{terminal.title}
</Tabs.Trigger>
)}
</For>
<div class="h-full flex items-center justify-center">
<Tooltip value="Open file" class="flex items-center">
<IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
</Tooltip>
</div>
</Tabs.List>
<For each={session.terminal.all()}>
{(terminal) => (
<Tabs.Content value={terminal.id}>
<Terminal
pty={terminal}
onCleanup={session.terminal.update}
onConnectError={() => session.terminal.clone(terminal.id)}
/>
</Tabs.Content>
</Show>
<For each={session.layout.tabs.opened}>
{(tab) => {
const [file] = createResource(
() => tab,
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<Tabs.Content value={tab} class="select-text mt-3">
<Switch>
<Match when={file()}>
{(f) => (
<Code
file={{ name: f().path, contents: f().content?.content ?? "" }}
overflow="scroll"
class="pb-40"
/>
)}
</Match>
</Switch>
</Tabs.Content>
)
}}
</For>
</Tabs>
<DragOverlay>
<Show when={store.activeDraggable}>
{(draggedFile) => {
const [file] = createResource(
() => draggedFile(),
async (tab) => {
if (tab.startsWith("file://")) {
return local.file.node(tab.replace("file://", ""))
}
return undefined
},
)
return (
<div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
<Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
</div>
)
}}
</Show>
</DragOverlay>
</DragDropProvider>
<Show when={session.layout.tabs.active}>
<div class="absolute inset-x-0 px-6 max-w-2xl flex flex-col justify-center items-center z-50 mx-auto bottom-8">
<PromptInput
ref={(el) => {
inputRef = el
}}
/>
</div>
</Show>
<div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
{/* <FileTree path="" onFileClick={ handleTabClick} /> */}
</div>
<div class="hidden shrink-0 w-56 p-2">
<Show when={local.file.changes().length} fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}>
<ul class="">
<For each={local.file.changes()}>
{(path) => (
<li>
<button
onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
>
<FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
<span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
<span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
{getDirectory(path)}
</span>
</button>
</li>
)}
</For>
</Tabs>
</div>
</ul>
</Show>
</div>
<Show when={store.fileSelectOpen}>
<SelectDialog
defaultOpen
title="Select file"
placeholder="Search files"
emptyMessage="No files found"
items={local.file.searchFiles}
key={(x) => x}
onOpenChange={(open) => setStore("fileSelectOpen", open)}
onSelect={(x) => {
if (x) {
local.file.open(x)
return session.layout.openTab("file://" + x)
}
return undefined
}}
>
{(i) => (
<div
classList={{
"w-full flex items-center justify-between rounded-md": true,
}}
>
<div class="flex items-center gap-x-2 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>
<div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
</div>
)}
</SelectDialog>
</Show>
</div>
)

View File

@@ -2,9 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/// <reference types="vite/client" />
interface ImportMetaEnv {
}
interface ImportMetaEnv {}
interface ImportMeta {
readonly env: ImportMetaEnv
}
}

View File

@@ -0,0 +1,7 @@
export function base64Encode(value: string) {
return btoa(value).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
}
export function base64Decode(value: string) {
return atob(value.replace(/-/g, "+").replace(/_/g, "/"))
}

View File

@@ -1 +1,2 @@
export * from "./dom"
export * from "./encode"

View File

@@ -6,4 +6,4 @@
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
export {}

View File

@@ -16,6 +16,5 @@
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["dist"]
}
}

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