Compare commits

..

86 Commits

Author SHA1 Message Date
Frank
7a881b6950 sync 2026-04-01 00:49:29 -04:00
Frank
2cc738fb17 wip: zen 2026-03-31 00:07:56 -04:00
opencode-agent[bot]
71b20698bb chore: generate 2026-03-31 01:57:41 +00:00
Kit Langton
3df18dcde1 refactor(provider): effectify Provider service (#20160) 2026-03-30 21:56:43 -04:00
Kit Langton
a898c2ea3a refactor(storage): effectify Storage service (#20132) 2026-03-31 01:16:02 +00:00
Kit Langton
bf777298c8 fix(theme): darken muted text in catppuccin tui themes (#20161) 2026-03-30 21:06:05 -04:00
Luke Parker
93fad99f7f smarter changelog (#20138) 2026-03-31 00:05:46 +00:00
opencode
057848deb8 release: v1.3.9 2026-03-30 23:43:58 +00:00
Luke Parker
1de06452d3 fix(plugin): properly resolve entrypoints without leading dot (#20140) 2026-03-31 09:21:17 +10:00
Frank
58f60629a1 wip: zen 2026-03-30 19:04:42 -04:00
Frank
39a47c9b8c wip: zen 2026-03-30 18:50:09 -04:00
opencode-agent[bot]
ea88044f2e chore: generate 2026-03-30 21:49:45 +00:00
Kit Langton
e6f6f7aff1 refactor: replace Filesystem util with AppFileSystem service (#20127) 2026-03-30 21:48:28 +00:00
opencode
48e97b47af release: v1.3.8 2026-03-30 21:35:10 +00:00
opencode-agent[bot]
fe120e3cbf chore: update nix node_modules hashes 2026-03-30 20:46:10 +00:00
Frank
f2dd774660 zen: qwen3.6 plus 2026-03-30 16:43:55 -04:00
Frank
e7ff0f17c8 zen: qwen3.6 plus 2026-03-30 16:32:22 -04:00
Kit Langton
2ed756c72c fix(session): restore busy route handling and add regression coverage (#20125) 2026-03-30 20:30:34 +00:00
Frank
054f4be185 zen: add safety identifier 2026-03-30 16:28:40 -04:00
Jaaneek
e3e1e9af50 fix(Opencode): Bump ai-sdk/xai to 3.0.75 to fix tool calls (#20123)
Co-authored-by: Jaaneek <jankiewiczmilosz@gmail.com>
2026-03-30 15:11:21 -05:00
opencode-agent[bot]
c8389cf96d chore: generate 2026-03-30 20:08:09 +00:00
Kit Langton
c5442d418d refactor(session): effectify SessionPrompt service (#19483) 2026-03-30 20:06:51 +00:00
Sebastian
fa95a61c4e Refactor into plugin loader and do not enforce (#20112) 2026-03-30 20:36:21 +02:00
Aiden Cline
9f3c2bd861 fix: agent value passed to chat.params and chat.headers hooks was not a string (#19996) 2026-03-30 16:32:27 +00:00
Adam
c2f78224ae chore(app): cleanup (#20062) 2026-03-30 08:50:42 -05:00
Sebastian
14f9e21d5c pluggable home footer (#20057) 2026-03-30 14:33:01 +02:00
Sebastian
8e4bab5181 update plugin themes when plugin was updated (#20052) 2026-03-30 13:51:07 +02:00
Jack
3c32013eb1 fix: preserve image attachments when selecting slash commands (#19771) 2026-03-30 17:11:34 +08:00
opencode
47d2ab120a release: v1.3.7 2026-03-30 06:06:12 +00:00
Luke Parker
186af2723d make variant modal less annoying (#19998) 2026-03-30 15:42:38 +10:00
Luke Parker
6926fe1c74 fix: stabilize release changelog generation (#19987) 2026-03-30 04:37:02 +00:00
Chris Yang
ee018d5c82 docs: rename patch tool to apply_patch and clarify apply_patch behavior (#19979) 2026-03-29 23:01:57 -05:00
opencode-agent[bot]
0465579d6b chore: update nix node_modules hashes 2026-03-30 03:53:11 +00:00
Knut Zuidema
196a03caff fix: discourage _noop tool call during LiteLLM compaction (#18539) 2026-03-29 22:48:17 -05:00
Luke Parker
b234370080 feat(windows): add first-class pwsh/powershell support (#16069) 2026-03-30 13:10:01 +10:00
Sebastian
5d2dc8888c theme colors for dialog textarea placeholders (#19939) 2026-03-29 21:37:46 +02:00
Sebastian
0b1018f6dd plugins installs should preserve jsonc comments (#19938) 2026-03-29 21:15:03 +02:00
Aiden Cline
afb6abff73 fix: ensure OPENCODE_DISABLE_CLAUDE_CODE_PROMPT is respected for project lvl CLAUDE.md (#19924) 2026-03-29 18:02:29 +00:00
opencode
e7f94f9b9a release: v1.3.6 2026-03-29 18:02:20 +00:00
ualtinok
72c77d0e7b fix(session): fix token usage double-counting w/ anthropic & bedrock due to AI SDK v6 upgrade (#19758)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-03-29 12:40:10 -05:00
Ariane Emory
5c15755a10 docs: add question tool to available permissions list (#19922) 2026-03-29 17:35:52 +00:00
Frank
3a4bfeb5b5 wip: zen 2026-03-29 13:06:00 -04:00
Frank
1037c72d99 wip: zen 2026-03-29 12:47:53 -04:00
Kit Langton
ba00e9a993 Fix variant dialog filtering (#19917) 2026-03-29 16:43:13 +00:00
Frank
963dad75ef ci: fix 2026-03-29 12:41:52 -04:00
Frank
7e9b721e97 wip: zen 2026-03-29 12:04:20 -04:00
Luke Parker
a5b1dc081d test: add regression coverage for sync plugin hooks (#19589) 2026-03-29 03:43:00 +00:00
opencode
0bc2f99f2d release: v1.3.5 2026-03-29 03:42:52 +00:00
Aiden Cline
55895d0663 core: fix plugin hooks to properly handle async operations ensuring plugins can execute async code without errors (#19586) 2026-03-29 03:15:41 +00:00
Aiden Cline
72cb9dfa31 tweak: adjust gpt prompt to be more minimal, fix file reference annoyances (#19585) 2026-03-28 21:32:40 -05:00
opencode
f0a9075fdf release: v1.3.4 2026-03-29 01:00:44 +00:00
Luke Parker
fee1e25ab4 ci: cancel stale nix-hashes runs (#19571) 2026-03-29 10:39:02 +10:00
Frank
a94ac5aa2c zen: ZDR policy 2026-03-28 20:16:52 -04:00
Frank
62ac45a9c9 wip: zen 2026-03-28 20:16:51 -04:00
Frank
f7c2ef876f wip: zen 2026-03-28 20:16:51 -04:00
opencode-agent[bot]
6639f92739 chore: update nix node_modules hashes 2026-03-29 00:07:30 +00:00
Aiden Cline
36aeb32159 ignore: kill todo (#19566) 2026-03-29 00:06:32 +00:00
Caleb Norton
ff37d7c2df fix: nix embedded web-ui support (#19561) 2026-03-28 18:39:22 -05:00
Caleb Norton
4f96eb239f fix: respect semver build identifiers for nix (#11915) 2026-03-28 18:39:03 -05:00
Sebastian
38af99dcb4 prompt slot (#19563) 2026-03-29 00:27:27 +01:00
opencode-agent[bot]
772059acb5 chore: update nix node_modules hashes 2026-03-28 20:46:03 +00:00
Vladimir Glafirov
1f290fc1ba fix: update opencode-gitlab-auth to 2.0.1 (#19552) 2026-03-28 20:10:06 +00:00
Sebastian
77d4f99497 use theme color for prompt placeholder (#19535) 2026-03-28 17:20:37 +01:00
Dax
aa2d753e7e feat: dialog variant menu and subagent improvements (#19537) 2026-03-28 16:16:00 +00:00
Kit Langton
860531c275 refactor(session): effectify session processor (#19485) 2026-03-28 12:09:47 -04:00
Dax
2b86b36c8c feat: open dialog for model variant selection instead of cycling (#19534) 2026-03-28 15:37:20 +00:00
Sebastian
8ac2fbbd12 kv theme before default fallback (#19523) 2026-03-28 15:54:54 +01:00
opencode-agent[bot]
26382c6216 chore: update nix node_modules hashes 2026-03-28 14:52:13 +00:00
opencode-agent[bot]
0981b8eb71 chore: generate 2026-03-28 14:26:45 +00:00
Kit Langton
aa9ed001d3 refactor(file): use AppFileSystem instead of raw Filesystem (#19458) 2026-03-28 10:25:46 -04:00
Sebastian
6086072567 upgrade opentui to 0.1.91 (#19440) 2026-03-28 15:10:59 +01:00
Dax Raad
6c14ea1d22 tweak(session): add top spacing and remove obsolete docs prompt 2026-03-28 01:56:11 -04:00
Dax
c3a9ec4a99 fix: restore subagent footer and fix style guide violations (#19491) 2026-03-28 01:46:29 -04:00
Dax
41b0d03f6a feat: add model variant selection dialog (#19488) 2026-03-28 01:21:28 -04:00
Dax
81eb6e670b refactor(prompt): remove variant cycle display from footer (#19489) 2026-03-28 01:21:16 -04:00
Dax
8446719b13 refactor(session): move context into prompt footer (#19486) 2026-03-28 01:05:18 -04:00
Aiden Cline
15a8c22a26 tweak: adjust bash tool description to increase cache hit rates between projects (#19487) 2026-03-27 23:53:36 -05:00
opencode-agent[bot]
48326e8d9c chore: update nix node_modules hashes 2026-03-28 04:20:56 +00:00
Kit Langton
43bc5551e8 update effect to 4.0.0-beta.42 (#19484) 2026-03-27 23:25:05 -04:00
Adam
f736116967 fix(app): more startup efficiency (#19454) 2026-03-28 01:26:57 +00:00
Luke Parker
82fc493520 feat(ci): use Azure Artifact Signing for Windows releases (#15201) 2026-03-28 11:20:19 +10:00
Kit Langton
2145d97f18 refactor(session): effectify SessionCompaction service (#19459) 2026-03-28 01:09:14 +00:00
Sebastian
f3997d8082 Single target plugin entrypoints (#19467) 2026-03-27 23:44:46 +00:00
opencode-agent[bot]
02b19bc3d7 chore: generate 2026-03-27 21:38:08 +00:00
Kit Langton
5cd54ec345 refactor(format): use ChildProcessSpawner instead of Process.spawn (#19457) 2026-03-27 17:37:07 -04:00
opencode-agent[bot]
c8909908f5 chore: update nix node_modules hashes 2026-03-27 21:11:06 +00:00
247 changed files with 18570 additions and 8496 deletions

View File

@@ -9,7 +9,8 @@ on:
jobs:
sync-locales:
if: github.actor != 'opencode-agent[bot]'
if: false
#if: github.actor != 'opencode-agent[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
permissions:
contents: write
@@ -34,7 +35,7 @@ jobs:
- name: Compute changed English docs
id: changes
run: |
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- 'packages/web/src/content/docs/*.mdx' || true)
FILES=$(git diff --name-only "${{ github.event.before }}" "${{ github.sha }}" -- ':(glob)packages/web/src/content/docs/*.mdx' || true)
if [ -z "$FILES" ]; then
echo "has_changes=false" >> "$GITHUB_OUTPUT"
echo "No English docs changed in push range"

View File

@@ -17,6 +17,10 @@ on:
- "patches/**"
- ".github/workflows/nix-hashes.yml"
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
# Native runners required: bun install cross-compilation flags (--os/--cpu)
# do not produce byte-identical node_modules as native installs.

View File

@@ -98,15 +98,129 @@ jobs:
- uses: actions/upload-artifact@v4
with:
name: opencode-cli
path: packages/opencode/dist
path: |
packages/opencode/dist/opencode-darwin*
packages/opencode/dist/opencode-linux*
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist/opencode-windows*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-windows:
needs:
- build-cli
- version
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
steps:
- uses: actions/checkout@v3
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- name: Setup git committer
id: committer
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Azure login
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: azure/artifact-signing-action@v1
with:
endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
files: |
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
exclude-environment-credential: true
exclude-workload-identity-credential: true
exclude-managed-identity-credential: true
exclude-shared-token-cache-credential: true
exclude-visual-studio-credential: true
exclude-visual-studio-code-credential: true
exclude-azure-cli-credential: false
exclude-azure-powershell-credential: true
exclude-azure-developer-cli-credential: true
exclude-interactive-browser-credential: true
- name: Verify Windows CLI signatures
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
)
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- name: Repack Windows CLI archives
working-directory: packages/opencode/dist
shell: pwsh
run: |
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
- name: Upload signed Windows CLI release assets
if: needs.version.outputs.release != ''
shell: pwsh
env:
GH_TOKEN: ${{ steps.committer.outputs.token }}
run: |
gh release upload "v${{ needs.version.outputs.version }}" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
--clobber `
--repo "${{ needs.version.outputs.repo }}"
- uses: actions/upload-artifact@v4
with:
name: opencode-cli-signed-windows
path: |
packages/opencode/dist/opencode-windows-arm64
packages/opencode/dist/opencode-windows-x64
packages/opencode/dist/opencode-windows-x64-baseline
build-tauri:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
@@ -152,6 +266,14 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
@@ -190,6 +312,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
GITHUB_TOKEN: ${{ steps.committer.outputs.token }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -246,11 +369,34 @@ jobs:
APPLE_API_KEY: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8
- name: Verify signed Windows desktop artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @(
"${{ github.workspace }}\packages\desktop\src-tauri\sidecars\opencode-cli-${{ matrix.settings.target }}.exe"
)
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop\src-tauri\target\${{ matrix.settings.target }}\release\bundle\nsis\*.exe" | Select-Object -ExpandProperty FullName
foreach ($file in $files) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
build-electron:
needs:
- build-cli
- version
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
AZURE_TRUSTED_SIGNING_ACCOUNT_NAME: ${{ secrets.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
strategy:
fail-fast: false
matrix:
@@ -292,6 +438,14 @@ jobs:
- uses: ./.github/actions/setup-bun
- name: Azure login
if: runner.os == 'Windows'
uses: azure/login@v2
with:
client-id: ${{ env.AZURE_CLIENT_ID }}
tenant-id: ${{ env.AZURE_TENANT_ID }}
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
- uses: actions/setup-node@v4
with:
node-version: "24"
@@ -326,6 +480,7 @@ jobs:
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }}
RUST_TARGET: ${{ matrix.settings.target }}
GH_TOKEN: ${{ github.token }}
GITHUB_RUN_ID: ${{ github.run_id }}
@@ -358,6 +513,22 @@ jobs:
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
- name: Verify signed Windows Electron artifacts
if: runner.os == 'Windows'
shell: pwsh
run: |
$files = @()
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\*.exe" | Select-Object -ExpandProperty FullName
$files += Get-ChildItem "${{ github.workspace }}\packages\desktop-electron\dist\*unpacked\resources\opencode-cli.exe" -ErrorAction SilentlyContinue | Select-Object -ExpandProperty FullName
foreach ($file in $files | Select-Object -Unique) {
$sig = Get-AuthenticodeSignature $file
if ($sig.Status -ne "Valid") {
throw "Invalid signature for ${file}: $($sig.Status)"
}
}
- uses: actions/upload-artifact@v4
with:
name: opencode-electron-${{ matrix.settings.target }}
@@ -373,6 +544,7 @@ jobs:
needs:
- version
- build-cli
- sign-cli-windows
- build-tauri
- build-electron
runs-on: blacksmith-4vcpu-ubuntu-2404
@@ -411,6 +583,16 @@ jobs:
name: opencode-cli
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
with:
name: opencode-cli-signed-windows
path: packages/opencode/dist
- uses: actions/download-artifact@v4
if: needs.version.outputs.release
with:

View File

@@ -1,54 +0,0 @@
name: sign-cli
on:
push:
branches:
- brendan/desktop-signpath
workflow_dispatch:
permissions:
contents: read
actions: read
jobs:
sign-cli:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@v3
with:
fetch-tags: true
- uses: ./.github/actions/setup-bun
- name: Build
run: |
./packages/opencode/script/build.ts
- name: Upload unsigned Windows CLI
id: upload_unsigned_windows_cli
uses: actions/upload-artifact@v4
with:
name: unsigned-opencode-windows-cli
path: packages/opencode/dist/opencode-windows-x64/bin/opencode.exe
if-no-files-found: error
- name: Submit SignPath signing request
id: submit_signpath_signing_request
uses: signpath/github-action-submit-signing-request@v1
with:
api-token: ${{ secrets.SIGNPATH_API_KEY }}
organization-id: ${{ secrets.SIGNPATH_ORGANIZATION_ID }}
project-slug: ${{ secrets.SIGNPATH_PROJECT_SLUG }}
signing-policy-slug: ${{ secrets.SIGNPATH_SIGNING_POLICY_SLUG }}
artifact-configuration-slug: ${{ secrets.SIGNPATH_ARTIFACT_CONFIGURATION_SLUG }}
github-artifact-id: ${{ steps.upload_unsigned_windows_cli.outputs.artifact-id }}
wait-for-completion: true
output-artifact-directory: signed-opencode-cli
- name: Upload signed Windows CLI
uses: actions/upload-artifact@v4
with:
name: signed-opencode-windows-cli
path: signed-opencode-cli/*.exe
if-no-files-found: error

1
.gitignore vendored
View File

@@ -25,6 +25,7 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
logs/
*.bun-build
tsconfig.tsbuildinfo

View File

@@ -1,34 +0,0 @@
---
description: ALWAYS use this when writing docs
color: "#38A3EE"
---
You are an expert technical documentation writer
You are not verbose
Use a relaxed and friendly tone
The title of the page should be a word or a 2-3 word phrase
The description should be one short line, should not start with "The", should
avoid repeating the title of the page, should be 5-10 words long
Chunks of text should not be more than 2 sentences long
Each section is separated by a divider of 3 dashes
The section titles are short with only the first letter of the word capitalized
The section titles are in the imperative mood
The section titles should not repeat the term used in the page title, for
example, if the page title is "Models", avoid using a section title like "Add
new models". This might be unavoidable in some cases, but try to avoid it.
Check out the /packages/web/src/content/docs/docs/index.mdx as an example.
For JS or TS code snippets remove trailing semicolons and any trailing commas
that might not be needed.
If you are making a commit prefix the commit message with `docs:`

View File

@@ -1,23 +1,46 @@
---
model: opencode/kimi-k2.5
model: opencode/gpt-5.4
---
create UPCOMING_CHANGELOG.md
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
Do not preserve, merge, or reuse text from the existing file.
it should have sections
The input already contains the exact commit range since the last non-draft release.
The commits are already filtered to the release-relevant packages and grouped into
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
The input may also include a `## Community Contributors Input` section.
```
## TUI
Before writing any entry you keep, inspect the real diff with
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
understand the actual code changes and not just the commit message (they may be misleading).
Do not use `git log` or author metadata when deciding attribution.
## Desktop
Rules:
## Core
- Write the final file with sections in this order:
`## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions`
- Only include sections that have at least one notable entry
- Keep one bullet per commit you keep
- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing
- Start each bullet with a capital letter
- Prefer what changed for users over what code changed internally
- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)`
- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input
- If an input bullet has no `(@username)` suffix, do not add one
- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses
- If no notable entries remain and there is no contributor block, write exactly `No notable changes.`
- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block
- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
- Do not derive the thank-you section from the main summary bullets
- Do not include the heading `## Community Contributors Input` in the final file
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
## Misc
```
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
fetch the latest github release for this repository to determine the last release version.
<changelog_input>
find each PR that was merged since the last release
!`bun script/raw-changelog.ts $ARGUMENTS`
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
</changelog_input>

View File

@@ -1,7 +1,14 @@
/** @jsxImportSource @opentui/solid */
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid"
import { RGBA, VignetteEffect } from "@opentui/core"
import type { TuiKeybindSet, TuiPluginApi, TuiPluginMeta, TuiSlotPlugin } from "@opencode-ai/plugin/tui"
import type {
TuiKeybindSet,
TuiPlugin,
TuiPluginApi,
TuiPluginMeta,
TuiPluginModule,
TuiSlotPlugin,
} from "@opencode-ai/plugin/tui"
const tabs = ["overview", "counter", "help"]
const bind = {
@@ -608,7 +615,7 @@ const Modal = (props: {
)
}
const home = (input: Cfg): TuiSlotPlugin => ({
const home = (api: TuiPluginApi, input: Cfg) => ({
slots: {
home_logo(ctx) {
const map = ctx.theme.current
@@ -642,6 +649,36 @@ const home = (input: Cfg): TuiSlotPlugin => ({
</box>
)
},
home_prompt(ctx, value) {
const skin = look(ctx.theme.current)
type Prompt = (props: {
workspaceID?: string
hint?: JSX.Element
placeholders?: {
normal?: string[]
shell?: string[]
}
}) => JSX.Element
if (!("Prompt" in api.ui)) return null
const view = api.ui.Prompt
if (typeof view !== "function") return null
const Prompt = view as Prompt
const normal = [
`[SMOKE] route check for ${input.label}`,
"[SMOKE] confirm home_prompt slot override",
"[SMOKE] verify api.ui.Prompt rendering",
]
const shell = ["printf '[SMOKE] home prompt\n'", "git status --short", "bun --version"]
const Hint = (
<box flexShrink={0} flexDirection="row" gap={1}>
<text fg={skin.muted}>
<span style={{ fg: skin.accent }}></span> smoke home prompt
</text>
</box>
)
return <Prompt workspaceID={value.workspace_id} hint={Hint} placeholders={{ normal, shell }} />
},
home_bottom(ctx) {
const skin = look(ctx.theme.current)
const text = "extra content in the unified home bottom slot"
@@ -699,8 +736,8 @@ const block = (input: Cfg, order: number, title: string, text: string): TuiSlotP
},
})
const slot = (input: Cfg): TuiSlotPlugin[] => [
home(input),
const slot = (api: TuiPluginApi, input: Cfg): TuiSlotPlugin[] => [
home(api, input),
block(input, 50, "Smoke above", "renders above internal sidebar blocks"),
block(input, 250, "Smoke between", "renders between internal sidebar blocks"),
block(input, 650, "Smoke below", "renders below internal sidebar blocks"),
@@ -813,7 +850,7 @@ const reg = (api: TuiPluginApi, input: Cfg, keys: Keys) => {
])
}
const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, meta: TuiPluginMeta) => {
const tui: TuiPlugin = async (api, options, meta) => {
if (options?.enabled === false) return
await api.theme.install("./smoke-theme.json")
@@ -841,12 +878,14 @@ const tui = async (api: TuiPluginApi, options: Record<string, unknown> | null, m
])
reg(api, value, keys)
for (const item of slot(value)) {
for (const item of slot(api, value)) {
api.slots.register(item)
}
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id: "tui-smoke",
tui,
}
export default plugin

View File

@@ -1,5 +0,0 @@
github-policies:
runners:
allowed_groups:
- "GitHub Actions"
- "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt"

View File

@@ -26,7 +26,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -79,7 +79,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -113,7 +113,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -140,7 +140,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -164,7 +164,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -188,7 +188,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -221,7 +221,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -252,7 +252,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -281,7 +281,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -297,7 +297,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.3.3",
"version": "1.3.9",
"bin": {
"opencode": "./bin/opencode",
},
@@ -323,7 +323,7 @@
"@ai-sdk/provider-utils": "4.0.21",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.74",
"@ai-sdk/xai": "3.0.75",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
@@ -338,8 +338,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.3.3",
"@opentui/core": "0.1.90",
"@opentui/solid": "0.1.90",
"@opentui/core": "0.1.92",
"@opentui/solid": "0.1.92",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -369,7 +369,7 @@
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opencode-gitlab-auth": "2.0.1",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
@@ -378,6 +378,7 @@
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",
@@ -422,22 +423,22 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
},
"devDependencies": {
"@opentui/core": "0.1.90",
"@opentui/solid": "0.1.90",
"@opentui/core": "0.1.92",
"@opentui/solid": "0.1.92",
"@tsconfig/node22": "catalog:",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
"peerDependencies": {
"@opentui/core": ">=0.1.90",
"@opentui/solid": ">=0.1.90",
"@opentui/core": ">=0.1.92",
"@opentui/solid": ">=0.1.92",
},
"optionalPeers": [
"@opentui/core",
@@ -456,7 +457,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.3.3",
"version": "1.3.9",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -467,7 +468,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -502,7 +503,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -549,7 +550,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"zod": "catalog:",
},
@@ -560,7 +561,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.3.3",
"version": "1.3.9",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -593,8 +594,9 @@
},
},
"trustedDependencies": [
"electron",
"esbuild",
"tree-sitter-powershell",
"electron",
"web-tree-sitter",
"tree-sitter-bash",
],
@@ -610,7 +612,7 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@effect/platform-node": "4.0.0-beta.37",
"@effect/platform-node": "4.0.0-beta.42",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@octokit/rest": "22.0.0",
@@ -634,7 +636,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.37",
"effect": "4.0.0-beta.42",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -717,7 +719,7 @@
"@ai-sdk/vercel": ["@ai-sdk/vercel@2.0.39", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8eu3ljJpkCTP4ppcyYB+NcBrkcBoSOFthCSgk5VnjaxnDaOJFaxnPwfddM7wx3RwMk2CiK1O61Px/LlqNc7QkQ=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.74", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HDDLsT+QrzE3c2QZLRV/HKAwMtXDb0PMDdk1PYUXLJ3r9Qv76zGKGyvJLX7Pu6c8TOHD1mwLrOVYrsTpC/eTMw=="],
"@ai-sdk/xai": ["@ai-sdk/xai@3.0.75", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-V8UKK4fNpI9cnrtsZBvUp9O9J6Y9fTKBRoSLyEaNGPirACewixmLDbXsSgAeownPVWiWpK34bFysd+XouI5Ywg=="],
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
@@ -993,9 +995,9 @@
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.37", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.37", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.37", "ioredis": "^5.7.0" } }, "sha512-dCfTNYGAT+1K+nu/0jw3FL/0DJXcobZCJs9SD5XJbj1DewWPhR9/AptP6zLGj8vdP8hXem6Aa53nze3HSujW3w=="],
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.42", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.42", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42", "ioredis": "^5.7.0" } }, "sha512-kbdRML2FBa4q8U8rZQcnmLKZ5zN/z1bAA7t5D1/UsBHZqJgnfRgu1CP6kaEfb1Nie6YyaWshxTktZQryjvW/Yg=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.40", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.40" } }, "sha512-WMRVG7T8ZDALKCOacsx2ZZj3Ccaoq8YGeD9q7ZL4q8RwQv8Nmrl+4+KZl95/zHCqXzgK9oUJOlBfQ7CZr6PQOQ=="],
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.42", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.42" } }, "sha512-PC+lxLsrwob3+nBChAPrQq32olCeyApgXBvs1NrRsoArLViNT76T/68CttuCAksCZj5e1bZ1ZibLPel3vUmx2g=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -1459,21 +1461,21 @@
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
"@opentui/core": ["@opentui/core@0.1.90", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.90", "@opentui/core-darwin-x64": "0.1.90", "@opentui/core-linux-arm64": "0.1.90", "@opentui/core-linux-x64": "0.1.90", "@opentui/core-win32-arm64": "0.1.90", "@opentui/core-win32-x64": "0.1.90", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Os2dviqWVETU3kaK36lbSvdcI93GAWhw0xb9ng/d0DWYuM9scRmAhLHiOayp61saWv/BR8OJXeuQYHvrp5rd6A=="],
"@opentui/core": ["@opentui/core@0.1.92", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.92", "@opentui/core-darwin-x64": "0.1.92", "@opentui/core-linux-arm64": "0.1.92", "@opentui/core-linux-x64": "0.1.92", "@opentui/core-win32-arm64": "0.1.92", "@opentui/core-win32-x64": "0.1.92", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-c+KdYAIH3M8n24RYaor+t7AQtKZ3l84L7xdP7DEaN4xtuYH8W08E6Gi+wUal4g+HSai3HS9irox68yFf0VPAxw=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.90", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XFrm2zCg1SlHPQ5A2HX/I4dCrmTjYaCJIIpo3QuPIvZBGH3aBMdWDJh2tXw7AB5Mmh8X1K4hDkP5nlK9x0Ewow=="],
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.92", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NX/qFRuc7My0pazyOrw9fdTXmU7omXcZzQuHcsaVnwssljaT52UYMrJ7mCKhSo69RhHw0lnGCymTorvz3XBdsA=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.90", "", { "os": "darwin", "cpu": "x64" }, "sha512-vbDpUsnlZ+0CeVKyBBXE+l2+X1XoVncMxMOhXTiMtud2/Cwu+Vfs/g3LC/6Zv08yaytA+9g7Z8sdf0QCqFyQ4w=="],
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.92", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zb4jn33hOf167llINKLniOabQIycs14LPOBZnQ6l4khbeeTPVJdG8gy9PhlAyIQygDKmRTFncVlP0RP+L6C7og=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.90", "", { "os": "linux", "cpu": "arm64" }, "sha512-OTbvBTP5mVQ4uwKyuz6b59ElG+D0i1Ln+q6cVhNkLgeRLySIn1uXEzUFQGlnVgb8lFDANsn3yQmdv+R+Cpw0og=="],
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.92", "", { "os": "linux", "cpu": "arm64" }, "sha512-4VA1A91OTMPJ3LkAyaxKEZVJsk5jIc3Kz0gV2vip8p2aGLPpYHHpkFZpXP/FyzsnJzoSGftBeA6ya1GKa5bkXg=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.90", "", { "os": "linux", "cpu": "x64" }, "sha512-2PJi/LLlO7tGk9Ful/n+6iBdg1RFrA9ibU7wVneE6Z1P0LCYeu7bpwMzea1TXL0eAQWPHsjTs9aPlqPxln0EJw=="],
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.92", "", { "os": "linux", "cpu": "x64" }, "sha512-tr7va8hfKS1uY+TBmulQBoBlwijzJk56K/U/L9/tbHfW7oJctqxPVwEFHIh1HDcOQ3/UhMMWGvMfeG6cFiK8/A=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.90", "", { "os": "win32", "cpu": "arm64" }, "sha512-+sTRaOb7gCMZ6iLuuG4y9kzyweJzBDcIJN0Xh49ikFWTwVECDXEVtXahNGlw57avm2yYUoNzmpBjK/LV7zBj9A=="],
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.92", "", { "os": "win32", "cpu": "arm64" }, "sha512-34YM3uPtDjzUVeSnJWIK2J8mxyduzV7f3mYc4Hub0glNpUdM1jjzF2HvvvnrKK5ElzTsIcno3c3lOYT8yvG1Zg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.90", "", { "os": "win32", "cpu": "x64" }, "sha512-aVFyErckWp4oW9NJ/ZDKBUAlTlfVUiRXGP63JXFOoeqI7EYaM8uBt6rgZAJuUdFWCN2Q66WRS8Y2mk+0BJwVBg=="],
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.92", "", { "os": "win32", "cpu": "x64" }, "sha512-uk442kA2Vn0mmJHHqk5sPM+Zai/AN9sgl7egekhoEOUx2VK3gxftKsVlx2YVpCHTvTE/S+vnD2WpQaJk2SNjww=="],
"@opentui/solid": ["@opentui/solid@0.1.90", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.90", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-zEHDpJOTGS707ts5j4diqoWuFLSqV6yARKl1H0FJkwWOotu+rxCyksL+C0gX0jJUonAw2cjlZ2NNtZY8g78zkg=="],
"@opentui/solid": ["@opentui/solid@0.1.92", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.92", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-0Sx1+6zRpmMJ5oDEY0JS9b9+eGd/Q0fPndNllrQNnp7w2FCjpXmvHdBdq+pFI6kFp01MHq2ZOkUU5zX5/9YMSQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
@@ -2769,7 +2771,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"effect": ["effect@4.0.0-beta.37", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AVMXXtb6n62W4uvo1EvT7FJ41HfDvQRX8IY2FGPvfP361dtBArKK2JtE5vmFXTsxkW90WUdvJZYpVATGIzr/BA=="],
"effect": ["effect@4.0.0-beta.42", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-c1UrRP+tLzyHb4Fepl8XBDJlLQLkrcMXrRBba441GQRxMbeQ/aIOSFcBwSda1iMJ5l9F0lYc3Bhe33/whrmavQ=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -3801,7 +3803,7 @@
"opencode": ["opencode@workspace:packages/opencode"],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.0", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-jmZOOvYIurRScQCtdBqIW5HbP1JbmIiq7UtI7NGgn2vjke46g9d4NVPBg5/ZmFFVIBwZcgyFgJ7b8kGEOR9ujA=="],
"opencode-gitlab-auth": ["opencode-gitlab-auth@2.0.1", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-1EMZHdbADLMVaTVLQ6C/V8uVMDr6MP++osj2lmOecowtn46AafP/w6ADkV4AN/ddjA1rob5cWpMuf/iME6DI6A=="],
"opencode-poe-auth": ["opencode-poe-auth@0.0.1", "", { "dependencies": { "open": "^10.0.0", "poe-oauth": "*" }, "peerDependencies": { "@opencode-ai/plugin": "*" } }, "sha512-cXqTlS6AXHzo1oBdosnxbT47ZJEZ9WXn050X8Re6wZ1vaNnTpB/l2fMQt90evT7RBK0fB8UjXQUDMKyd7bbiqg=="],
@@ -4485,6 +4487,8 @@
"tree-sitter-bash": ["tree-sitter-bash@0.25.0", "", { "dependencies": { "node-addon-api": "^8.2.1", "node-gyp-build": "^4.8.2" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-gZtlj9+qFS81qKxpLfD6H0UssQ3QBc/F0nKkPsiFDyfQF2YBqYvglFJUzchrPpVhZe9kLZTrJ9n2J6lmka69Vg=="],
"tree-sitter-powershell": ["tree-sitter-powershell@0.25.10", "", { "dependencies": { "node-addon-api": "^7.1.0", "node-gyp-build": "^4.8.0" }, "peerDependencies": { "tree-sitter": "^0.25.0" }, "optionalPeers": ["tree-sitter"] }, "sha512-bEt8QoySpGFnU3aa8WedQyNMaN6aTwy/WUbvIVt0JSKF+BbJoSHNHu+wCbhj7xLMsfB0AuffmiJm+B8gzva8Lg=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
@@ -5285,6 +5289,8 @@
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.74", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.37", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HDDLsT+QrzE3c2QZLRV/HKAwMtXDb0PMDdk1PYUXLJ3r9Qv76zGKGyvJLX7Pu6c8TOHD1mwLrOVYrsTpC/eTMw=="],
"ajv-keywords/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="],
"ansi-align/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-4XhUHjgqinKxOeT8K5hGAjpFA2vzOp8QpEg0uYCZwvg=",
"aarch64-linux": "sha256-X2YTNOpJocIkWkkfS8RnuDW+tvj4riHs7CXM+cS9iv0=",
"aarch64-darwin": "sha256-pN0rY+cpdW+6gNWeegVprdmhc2H72OZ9WxKDIs1fvJM=",
"x86_64-darwin": "sha256-l8+Yz/6UfSPJrdgfcqy/L2SvxN2i9Apv2R0B61rpEmw="
"x86_64-linux": "sha256-5w+DwEvUrCly9LHZuTa1yTSD45X56cGJG8sds/N29mU=",
"aarch64-linux": "sha256-pLhyzajYinBlFyGWwPypyC8gHEU8S7fVXIs6aqgBmhg=",
"aarch64-darwin": "sha256-vN0sXYs7pLtpq7U9SorR2z6st/wMfHA3dybOnwIh1pU=",
"x86_64-darwin": "sha256-P8fgyBcZJmY5VbNxNer/EL4r/F28dNxaqheaqNZH488="
}
}

View File

@@ -20,7 +20,7 @@ let
in
stdenvNoCC.mkDerivation {
pname = "opencode-node_modules";
version = "${packageJson.version}-${rev}";
version = "${packageJson.version}+${lib.replaceString "-" "." rev}";
src = lib.fileset.toSource {
root = ../.;
@@ -54,6 +54,7 @@ stdenvNoCC.mkDerivation {
--filter '!./' \
--filter './packages/opencode' \
--filter './packages/desktop' \
--filter './packages/app' \
--frozen-lockfile \
--ignore-scripts \
--no-progress

View File

@@ -3,6 +3,7 @@
stdenvNoCC,
callPackage,
bun,
nodejs,
sysctl,
makeBinaryWrapper,
models-dev,
@@ -19,6 +20,7 @@ stdenvNoCC.mkDerivation (finalAttrs: {
nativeBuildInputs = [
bun
nodejs # for patchShebangs node_modules
installShellFiles
makeBinaryWrapper
models-dev
@@ -29,6 +31,8 @@ stdenvNoCC.mkDerivation (finalAttrs: {
runHook preConfigure
cp -R ${finalAttrs.node_modules}/. .
patchShebangs node_modules
patchShebangs packages/*/node_modules
runHook postConfigure
'';

View File

@@ -25,7 +25,7 @@
"packages/slack"
],
"catalog": {
"@effect/platform-node": "4.0.0-beta.37",
"@effect/platform-node": "4.0.0-beta.42",
"@types/bun": "1.3.11",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
@@ -45,7 +45,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
"effect": "4.0.0-beta.37",
"effect": "4.0.0-beta.42",
"ai": "6.0.138",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -104,6 +104,7 @@
"protobufjs",
"tree-sitter",
"tree-sitter-bash",
"tree-sitter-powershell",
"web-tree-sitter",
"electron"
],

View File

@@ -159,7 +159,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
const dialog = await openSettings(page)
const input = dialog.locator(settingsCodeFontSelector)
await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
await expect(input).toHaveAttribute("placeholder", "System Mono")
const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
@@ -167,7 +167,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
const initialUIFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(initialFontFamily).toContain("IBM Plex Mono")
expect(initialFontFamily).toContain("ui-monospace")
const next = "Test Mono"
@@ -185,7 +185,7 @@ test("typing a code font with spaces persists and updates CSS variable", async (
})
.toMatchObject({
appearance: {
font: next,
mono: next,
},
})
@@ -206,7 +206,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
const dialog = await openSettings(page)
const input = dialog.locator(settingsUIFontSelector)
await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "Inter")
await expect(input).toHaveAttribute("placeholder", "System Sans")
const initialFontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
@@ -214,7 +214,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
const initialCodeFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(initialFontFamily).toContain("Inter")
expect(initialFontFamily).toContain("ui-sans-serif")
const next = "Test Sans"
@@ -232,7 +232,7 @@ test("typing a UI font with spaces persists and updates CSS variable", async ({
})
.toMatchObject({
appearance: {
uiFont: next,
sans: next,
},
})
@@ -267,14 +267,14 @@ test("clearing the code font field restores the default placeholder and stack",
})
.toMatchObject({
appearance: {
font: "Reset Mono",
mono: "Reset Mono",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
await expect(input).toHaveAttribute("placeholder", "System Mono")
await expect
.poll(async () => {
@@ -285,14 +285,14 @@ test("clearing the code font field restores the default placeholder and stack",
})
.toMatchObject({
appearance: {
font: "",
mono: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
)
expect(fontFamily).toContain("IBM Plex Mono")
expect(fontFamily).toContain("ui-monospace")
expect(fontFamily).not.toContain("Reset Mono")
})
@@ -316,14 +316,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
})
.toMatchObject({
appearance: {
uiFont: "Reset Sans",
sans: "Reset Sans",
},
})
await input.clear()
await input.press("Space")
await expect(input).toHaveValue("")
await expect(input).toHaveAttribute("placeholder", "Inter")
await expect(input).toHaveAttribute("placeholder", "System Sans")
await expect
.poll(async () => {
@@ -334,14 +334,14 @@ test("clearing the UI font field restores the default placeholder and stack", as
})
.toMatchObject({
appearance: {
uiFont: "",
sans: "",
},
})
const fontFamily = await page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
)
expect(fontFamily).toContain("Inter")
expect(fontFamily).toContain("ui-sans-serif")
expect(fontFamily).not.toContain("Reset Sans")
})
@@ -373,8 +373,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
return raw ? JSON.parse(raw) : null
}, settingsKey)
const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
const mono = initialSettings?.appearance?.mono === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
const sans = initialSettings?.appearance?.sans === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
await code.click()
await code.clear()
@@ -395,8 +395,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
})
.toMatchObject({
appearance: {
font: mono,
uiFont: sans,
mono,
sans,
},
})
@@ -415,8 +415,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
expect(updatedMono).not.toBe(initialMono)
expect(updatedSans).toContain(sans)
expect(updatedSans).not.toBe(initialSans)
expect(updatedSettings?.appearance?.font).toBe(mono)
expect(updatedSettings?.appearance?.uiFont).toBe(sans)
expect(updatedSettings?.appearance?.mono).toBe(mono)
expect(updatedSettings?.appearance?.sans).toBe(sans)
await closeDialog(page, dialog)
await page.reload()
@@ -432,8 +432,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
})
.toMatchObject({
appearance: {
font: mono,
uiFont: sans,
mono,
sans,
},
})
@@ -468,8 +468,8 @@ test("color scheme, code font, and UI font rehydrate after reload", async ({ pag
expect(rehydratedMono).not.toBe(initialMono)
expect(rehydratedSans).toContain(sans)
expect(rehydratedSans).not.toBe(initialSans)
expect(rehydratedSettings?.appearance?.font).toBe(mono)
expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
expect(rehydratedSettings?.appearance?.mono).toBe(mono)
expect(rehydratedSettings?.appearance?.sans).toBe(sans)
})
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.3",
"version": "1.3.9",
"description": "",
"type": "module",
"exports": {

View File

@@ -47,9 +47,14 @@ import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
const HomeRoute = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const loadSession = () => import("@/pages/session")
const Session = lazy(loadSession)
const Loading = () => <div class="size-full" />
if (typeof location === "object" && /\/session(?:\/|$)/.test(location.pathname)) {
void loadSession()
}
const SessionRoute = () => (
<SessionProviders>
<Session />
@@ -178,7 +183,7 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
}
}).pipe(
effectMinDuration(checkMode() === "blocking" ? "1.2 seconds" : 0),
Effect.timeoutOrElse({ duration: "10 seconds", onTimeout: () => Effect.succeed(false) }),
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
@@ -278,7 +283,11 @@ export function AppInterface(props: {
disableHealthCheck?: boolean
}) {
return (
<ServerProvider defaultServer={props.defaultServer} servers={props.servers}>
<ServerProvider
defaultServer={props.defaultServer}
disableHealthCheck={props.disableHealthCheck}
servers={props.servers}
>
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
<ServerKey>
<GlobalSDKProvider>

View File

@@ -624,17 +624,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!cmd) return
promptProbe.select(cmd.id)
closePopover()
const images = imageAttachments()
if (cmd.type === "custom") {
const text = `/${cmd.trigger} `
setEditorText(text)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length)
focusEditorEnd()
return
}
clearEditor()
prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
prompt.set([...DEFAULT_PROMPT, ...images], 0)
command.trigger(cmd.id, "slash")
}

View File

@@ -105,6 +105,8 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
const aborted = (error: unknown) => abortError.safeParse(error).success
let attempt: AbortController | undefined
let run: Promise<void> | undefined
let started = false
const HEARTBEAT_TIMEOUT_MS = 15_000
let lastEventAt = Date.now()
let heartbeat: ReturnType<typeof setTimeout> | undefined
@@ -121,78 +123,93 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
heartbeat = undefined
}
void (async () => {
while (!abort.signal.aborted) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
const start = () => {
if (started) return run
started = true
run = (async () => {
while (!abort.signal.aborted && started) {
attempt = new AbortController()
lastEventAt = Date.now()
const onAbort = () => {
attempt?.abort()
}
abort.signal.addEventListener("abort", onAbort)
try {
const events = await eventSdk.global.event({
signal: attempt.signal,
onSseError: (error) => {
if (aborted(error)) return
if (streamErrorLogged) return
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream error", {
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
},
})
let yielded = Date.now()
resetHeartbeat()
for await (const event of events.stream) {
resetHeartbeat()
streamErrorLogged = false
const directory = event.directory ?? "global"
const payload = event.payload
const k = key(directory, payload)
if (k) {
const i = coalesced.get(k)
if (i !== undefined) {
queue[i] = { directory, payload }
if (payload.type === "message.part.updated") {
const part = payload.properties.part
staleDeltas.add(deltaKey(directory, part.messageID, part.id))
}
continue
}
coalesced.set(k, queue.length)
}
queue.push({ directory, payload })
schedule()
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
}
if (Date.now() - yielded < STREAM_YIELD_MS) continue
yielded = Date.now()
await wait(0)
}
} catch (error) {
if (!aborted(error) && !streamErrorLogged) {
streamErrorLogged = true
console.error("[global-sdk] event stream failed", {
url: currentServer.http.url,
fetch: eventFetch ? "platform" : "webview",
error,
})
}
} finally {
abort.signal.removeEventListener("abort", onAbort)
attempt = undefined
clearHeartbeat()
if (abort.signal.aborted || !started) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(() => {
run = undefined
flush()
})
return run
}
if (abort.signal.aborted) return
await wait(RECONNECT_DELAY_MS)
}
})().finally(flush)
const stop = () => {
started = false
attempt?.abort()
clearHeartbeat()
}
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
}
@@ -204,6 +221,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
stop()
abort.abort()
flush()
})
@@ -217,7 +235,11 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
return {
url: currentServer.http.url,
client: sdk,
event: emitter,
event: {
on: emitter.on.bind(emitter),
listen: emitter.listen.bind(emitter),
start,
},
createClient(opts: Omit<Parameters<typeof createSdkForServer>[0], "server" | "fetch">) {
const s = server.current
if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable"))

View File

@@ -72,10 +72,16 @@ function createGlobalSync() {
let projectWritten = false
let bootedAt = 0
let bootingRoot = false
let eventFrame: number | undefined
let eventTimer: ReturnType<typeof setTimeout> | undefined
onCleanup(() => {
active = false
})
onCleanup(() => {
if (eventFrame !== undefined) cancelAnimationFrame(eventFrame)
if (eventTimer !== undefined) clearTimeout(eventTimer)
})
const cacheProjects = () => {
setProjectCache(
@@ -348,6 +354,20 @@ function createGlobalSync() {
}
onMount(() => {
if (typeof requestAnimationFrame === "function") {
eventFrame = requestAnimationFrame(() => {
eventFrame = undefined
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
}, 0)
})
} else {
eventTimer = setTimeout(() => {
eventTimer = undefined
globalSDK.event.start()
}, 0)
}
void bootstrap()
})

View File

@@ -43,8 +43,10 @@ function waitForPaint() {
const timer = setTimeout(finish, 50)
if (typeof requestAnimationFrame !== "function") return
requestAnimationFrame(() => {
clearTimeout(timer)
finish()
setTimeout(() => {
clearTimeout(timer)
finish()
}, 0)
})
})
}
@@ -87,12 +89,6 @@ export async function bootstrapGlobal(input: {
setGlobalStore: SetStoreFunction<GlobalStore>
}) {
const fast = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.global.config.get().then((x) => {
@@ -108,6 +104,12 @@ export async function bootstrapGlobal(input: {
]
const slow = [
() =>
retry(() =>
input.globalSDK.path.get().then((x) => {
input.setGlobalStore("path", x.data!)
}),
),
() =>
retry(() =>
input.globalSDK.project.list().then((x) => {
@@ -221,12 +223,16 @@ export async function bootstrapDirectory(input: {
if (loading) input.setStore("status", "partial")
const fast = [
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
]
const slow = [
() =>
seededProject
? Promise.resolve()
: retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)),
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
() =>
seededPath
? Promise.resolve()
@@ -237,7 +243,6 @@ export async function bootstrapDirectory(input: {
if (next) input.setStore("project", next)
}),
),
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
() =>
retry(() =>
input.sdk.vcs.get().then((x) => {
@@ -299,9 +304,6 @@ export async function bootstrapDirectory(input: {
)
}),
),
]
const slow = [
() => Promise.resolve(input.loadSessions(input.directory)),
() =>
retry(() =>

View File

@@ -544,12 +544,26 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
})
let sessionFrame: number | undefined
let sessionTimer: number | undefined
onMount(() => {
Promise.all(
server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
}),
)
sessionFrame = requestAnimationFrame(() => {
sessionFrame = undefined
sessionTimer = window.setTimeout(() => {
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return globalSync.project.loadSessions(project.worktree)
}),
)
}, 0)
})
})
onCleanup(() => {
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
})
return {

View File

@@ -94,7 +94,11 @@ export namespace ServerConnection {
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
init: (props: { defaultServer: ServerConnection.Key; servers?: Array<ServerConnection.Any> }) => {
init: (props: {
defaultServer: ServerConnection.Key
disableHealthCheck?: boolean
servers?: Array<ServerConnection.Any>
}) => {
const checkServerHealth = useCheckServerHealth()
const [store, setStore, _, ready] = persisted(
@@ -202,6 +206,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const current_ = current()
if (!current_) return
if (props.disableHealthCheck) {
setState("healthy", true)
return
}
setState("healthy", undefined)
onCleanup(startHealthPolling(current_))
})

View File

@@ -32,8 +32,8 @@ export interface Settings {
}
appearance: {
fontSize: number
font: string
uiFont: string
mono: string
sans: string
}
keybinds: Record<string, string>
permissions: {
@@ -43,20 +43,18 @@ export interface Settings {
sounds: SoundSettings
}
export const monoDefault = "IBM Plex Mono"
export const sansDefault = "Inter"
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
const monoKey = "ibm-plex-mono"
const monoBase = monoFallback
const sansBase = sansFallback
function input(font: string | undefined, key?: string) {
if (!font || font === key || !font.trim()) return ""
return font
function input(font: string | undefined) {
return font ?? ""
}
function family(font: string) {
@@ -64,14 +62,14 @@ function family(font: string) {
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
}
function stack(font: string | undefined, base: string, key?: string) {
const value = input(font, key).trim()
function stack(font: string | undefined, base: string) {
const value = font?.trim() ?? ""
if (!value) return base
return `${family(value)}, ${base}`
}
export function monoInput(font: string | undefined) {
return input(font, monoKey)
return input(font)
}
export function sansInput(font: string | undefined) {
@@ -79,7 +77,7 @@ export function sansInput(font: string | undefined) {
}
export function monoFontFamily(font: string | undefined) {
return stack(font, monoBase, monoKey)
return stack(font, monoBase)
}
export function sansFontFamily(font: string | undefined) {
@@ -100,8 +98,8 @@ const defaultSettings: Settings = {
},
appearance: {
fontSize: 14,
font: "",
uiFont: "",
mono: "",
sans: "",
},
keybinds: {},
permissions: {
@@ -134,8 +132,8 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
createEffect(() => {
if (typeof document === "undefined") return
const root = document.documentElement
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.mono))
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.sans))
})
return {
@@ -189,13 +187,13 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setFontSize(value: number) {
setStore("appearance", "fontSize", value)
},
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
font: withFallback(() => store.appearance?.mono, defaultSettings.appearance.mono),
setFont(value: string) {
setStore("appearance", "font", value.trim() ? value : "")
setStore("appearance", "mono", value.trim() ? value : "")
},
uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
uiFont: withFallback(() => store.appearance?.sans, defaultSettings.appearance.sans),
setUIFont(value: string) {
setStore("appearance", "uiFont", value.trim() ? value : "")
setStore("appearance", "sans", value.trim() ? value : "")
},
},
keybinds: {

View File

@@ -544,6 +544,8 @@ export default function Page() {
let reviewFrame: number | undefined
let refreshFrame: number | undefined
let refreshTimer: number | undefined
let todoFrame: number | undefined
let todoTimer: number | undefined
let diffFrame: number | undefined
let diffTimer: number | undefined
@@ -718,7 +720,6 @@ export default function Page() {
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
untrack(() => {
void sync.session.sync(id)
})
@@ -730,13 +731,47 @@ export default function Page() {
if (params.id !== id) return
untrack(() => {
if (stale) void sync.session.sync(id, { force: true })
void sync.session.todo(id, todos ? { force: true } : undefined)
})
}, 0)
})
}),
)
createEffect(
on(
() => {
const id = params.id
return [
sdk.directory,
id,
id ? (sync.data.session_status[id]?.type ?? "idle") : "idle",
id ? composer.blocked() : false,
] as const
},
([dir, id, status, blocked]) => {
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
todoFrame = undefined
todoTimer = undefined
if (!id) return
if (status === "idle" && !blocked) return
const cached = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
todoFrame = requestAnimationFrame(() => {
todoFrame = undefined
todoTimer = window.setTimeout(() => {
todoTimer = undefined
if (sdk.directory !== dir || params.id !== id) return
untrack(() => {
void sync.session.todo(id, cached ? { force: true } : undefined)
})
}, 0)
})
},
{ defer: true },
),
)
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@@ -1658,6 +1693,8 @@ export default function Page() {
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)

View File

@@ -11,6 +11,47 @@ import { useSDK } from "@/context/sdk"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) {
return (
<span data-slot="question-option-check" aria-hidden="true" onClick={props.onClick}>
<span data-slot="question-option-box" data-type={props.multi ? "checkbox" : "radio"} data-picked={props.picked}>
<Show when={props.multi} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
)
}
function Option(props: {
multi: boolean
picked: boolean
label: string
description?: string
disabled: boolean
onClick: VoidFunction
}) {
return (
<button
type="button"
data-slot="question-option"
data-picked={props.picked}
role={props.multi ? "checkbox" : "radio"}
aria-checked={props.picked}
disabled={props.disabled}
onClick={props.onClick}
>
<Mark multi={props.multi} picked={props.picked} />
<span data-slot="question-option-main">
<span data-slot="option-label">{props.label}</span>
<Show when={props.description}>
<span data-slot="option-description">{props.description}</span>
</Show>
</span>
</button>
)
}
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK()
const language = useLanguage()
@@ -41,6 +82,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
return language.t("session.question.progress", { current: n, total: total() })
})
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
const last = createMemo(() => store.tab >= total() - 1)
const customUpdate = (value: string, selected: boolean = on()) => {
@@ -164,6 +208,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? []))
const answered = (i: number) => {
if ((store.answers[i]?.length ?? 0) > 0) return true
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
}
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
const pick = (answer: string, custom: boolean = false) => {
setStore("answers", store.tab, [answer])
if (custom) setStore("custom", store.tab, answer)
@@ -230,6 +281,24 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
customUpdate(input())
}
const resizeInput = (el: HTMLTextAreaElement) => {
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}
const focusCustom = (el: HTMLTextAreaElement) => {
setTimeout(() => {
el.focus()
resizeInput(el)
}, 0)
}
const toggleCustomMark = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
customToggle()
}
const next = () => {
if (sending()) return
if (store.editing) commitCustom()
@@ -270,10 +339,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
type="button"
data-slot="question-progress-segment"
data-active={i() === store.tab}
data-answered={
(store.answers[i()]?.length ?? 0) > 0 ||
(store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0)
}
data-answered={answered(i())}
disabled={sending()}
onClick={() => jump(i())}
aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`}
@@ -307,43 +373,23 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
</Show>
<div data-slot="question-options">
<For each={options()}>
{(opt, i) => {
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
return (
<button
data-slot="question-option"
data-picked={picked()}
role={multi() ? "checkbox" : "radio"}
aria-checked={picked()}
disabled={sending()}
onClick={() => selectOption(i())}
>
<span data-slot="question-option-check" aria-hidden="true">
<span
data-slot="question-option-box"
data-type={multi() ? "checkbox" : "radio"}
data-picked={picked()}
>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<span data-slot="question-option-main">
<span data-slot="option-label">{opt.label}</span>
<Show when={opt.description}>
<span data-slot="option-description">{opt.description}</span>
</Show>
</span>
</button>
)
}}
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.label)}
label={opt.label}
description={opt.description}
disabled={sending()}
onClick={() => selectOption(i())}
/>
)}
</For>
<Show
when={store.editing}
fallback={
<button
type="button"
data-slot="question-option"
data-custom="true"
data-picked={on()}
@@ -352,24 +398,10 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
disabled={sending()}
onClick={customOpen}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-description">{input() || language.t("ui.question.custom.placeholder")}</span>
<span data-slot="option-label">{customLabel()}</span>
<span data-slot="option-description">{input() || customPlaceholder()}</span>
</span>
</button>
}
@@ -394,33 +426,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
commitCustom()
}}
>
<span
data-slot="question-option-check"
aria-hidden="true"
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
customToggle()
}}
>
<span data-slot="question-option-box" data-type={multi() ? "checkbox" : "radio"} data-picked={on()}>
<Show when={multi()} fallback={<span data-slot="question-option-radio-dot" />}>
<Icon name="check-small" size="small" />
</Show>
</span>
</span>
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
<span data-slot="question-option-main">
<span data-slot="option-label">{language.t("ui.messagePart.option.typeOwnAnswer")}</span>
<span data-slot="option-label">{customLabel()}</span>
<textarea
ref={(el) =>
setTimeout(() => {
el.focus()
el.style.height = "0px"
el.style.height = `${el.scrollHeight}px`
}, 0)
}
ref={focusCustom}
data-slot="question-custom-input"
placeholder={language.t("ui.question.custom.placeholder")}
placeholder={customPlaceholder()}
value={input()}
rows={1}
disabled={sending()}
@@ -436,8 +448,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
e.currentTarget.style.height = "0px"
e.currentTarget.style.height = `${e.currentTarget.scrollHeight}px`
resizeInput(e.currentTarget)
}}
/>
</span>

View File

@@ -52,6 +52,132 @@ function FileCommentMenu(props: {
)
}
type ScrollPos = { x: number; y: number }
function createScrollSync(input: { tab: () => string; view: ReturnType<typeof useSessionLayout>["view"] }) {
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: ScrollPos | undefined
let code: HTMLElement[] = []
const getCode = () => {
const el = scroll
if (!el) return []
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return []
const root = host.shadowRoot
if (!root) return []
return Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
)
}
const save = (next: ScrollPos) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const out = pending
pending = undefined
if (!out) return
input.view().setScroll(input.tab(), out)
})
}
const onCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
save({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const sync = () => {
const next = getCode()
if (next.length === code.length && next.every((el, i) => el === code[i])) return
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
code = next
for (const item of code) {
item.addEventListener("scroll", onCodeScroll)
}
}
const restore = () => {
const el = scroll
if (!el) return
const pos = input.view().scroll(input.tab())
if (!pos) return
sync()
if (code.length > 0) {
for (const item of code) {
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
}
}
if (el.scrollTop !== pos.y) el.scrollTop = pos.y
if (code.length > 0) return
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restore()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (code.length === 0) sync()
save({
x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
const setViewport = (el: HTMLDivElement) => {
scroll = el
restore()
}
onCleanup(() => {
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
})
return {
handleScroll,
queueRestore,
setViewport,
}
}
export function FileTabContent(props: { tab: string }) {
const file = useFile()
const comments = useComments()
@@ -65,11 +191,6 @@ export function FileTabContent(props: { tab: string }) {
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
}).activeFileTab
let scroll: HTMLDivElement | undefined
let scrollFrame: number | undefined
let restoreFrame: number | undefined
let pending: { x: number; y: number } | undefined
let codeScroll: HTMLElement[] = []
let find: FileSearchHandle | null = null
const search = {
@@ -92,6 +213,10 @@ export function FileTabContent(props: { tab: string }) {
if (file.ready()) return (file.selectedLines(p) as SelectedLineRange | undefined) ?? null
return (getSessionHandoff(sessionKey())?.files[p] as SelectedLineRange | undefined) ?? null
})
const scrollSync = createScrollSync({
tab: () => props.tab,
view,
})
const selectionPreview = (source: string, selection: FileSelection) => {
return previewSelectedLines(source, {
@@ -100,6 +225,12 @@ export function FileTabContent(props: { tab: string }) {
})
}
const buildPreview = (filePath: string, selection: FileSelection) => {
const source = filePath === path() ? contents() : file.get(filePath)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
}
const addCommentToContext = (input: {
file: string
selection: SelectedLineRange
@@ -108,14 +239,7 @@ export function FileTabContent(props: { tab: string }) {
origin?: "review" | "file"
}) => {
const selection = selectionFromLines(input.selection)
const preview =
input.preview ??
(() => {
if (input.file === path()) return selectionPreview(contents(), selection)
const source = file.get(input.file)?.content?.content
if (!source) return undefined
return selectionPreview(source, selection)
})()
const preview = input.preview ?? buildPreview(input.file, selection)
const saved = comments.add({
file: input.file,
@@ -140,8 +264,7 @@ export function FileTabContent(props: { tab: string }) {
comment: string
}) => {
comments.update(input.file, input.id, input.comment)
const preview =
input.file === path() ? selectionPreview(contents(), selectionFromLines(input.selection)) : undefined
const preview = input.file === path() ? buildPreview(input.file, selectionFromLines(input.selection)) : undefined
prompt.context.updateComment(input.file, input.id, {
comment: input.comment,
...(preview ? { preview } : {}),
@@ -260,102 +383,6 @@ export function FileTabContent(props: { tab: string }) {
requestAnimationFrame(() => comments.clearFocus())
})
const getCodeScroll = () => {
const el = scroll
if (!el) return []
const host = el.querySelector("diffs-container")
if (!(host instanceof HTMLElement)) return []
const root = host.shadowRoot
if (!root) return []
return Array.from(root.querySelectorAll("[data-code]")).filter(
(node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0,
)
}
const queueScrollUpdate = (next: { x: number; y: number }) => {
pending = next
if (scrollFrame !== undefined) return
scrollFrame = requestAnimationFrame(() => {
scrollFrame = undefined
const out = pending
pending = undefined
if (!out) return
view().setScroll(props.tab, out)
})
}
const handleCodeScroll = (event: Event) => {
const el = scroll
if (!el) return
const target = event.currentTarget
if (!(target instanceof HTMLElement)) return
queueScrollUpdate({
x: target.scrollLeft,
y: el.scrollTop,
})
}
const syncCodeScroll = () => {
const next = getCodeScroll()
if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
codeScroll = next
for (const item of codeScroll) {
item.addEventListener("scroll", handleCodeScroll)
}
}
const restoreScroll = () => {
const el = scroll
if (!el) return
const s = view().scroll(props.tab)
if (!s) return
syncCodeScroll()
if (codeScroll.length > 0) {
for (const item of codeScroll) {
if (item.scrollLeft !== s.x) item.scrollLeft = s.x
}
}
if (el.scrollTop !== s.y) el.scrollTop = s.y
if (codeScroll.length > 0) return
if (el.scrollLeft !== s.x) el.scrollLeft = s.x
}
const queueRestore = () => {
if (restoreFrame !== undefined) return
restoreFrame = requestAnimationFrame(() => {
restoreFrame = undefined
restoreScroll()
})
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (codeScroll.length === 0) syncCodeScroll()
queueScrollUpdate({
x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
const cancelCommenting = () => {
const p = path()
if (p) file.setSelectedLines(p, null)
@@ -375,16 +402,7 @@ export function FileTabContent(props: { tab: string }) {
const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active)
prev = { loaded, ready, active }
if (!restore) return
queueRestore()
})
onCleanup(() => {
for (const item of codeScroll) {
item.removeEventListener("scroll", handleCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
scrollSync.queueRestore()
})
const renderFile = (source: string) => (
@@ -402,7 +420,7 @@ export function FileTabContent(props: { tab: string }) {
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
queueRestore()
scrollSync.queueRestore()
}}
annotations={commentsUi.annotations()}
renderAnnotation={commentsUi.renderAnnotation}
@@ -420,7 +438,7 @@ export function FileTabContent(props: { tab: string }) {
mode: "auto",
path: path(),
current: state()?.content,
onLoad: queueRestore,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
@@ -435,14 +453,7 @@ export function FileTabContent(props: { tab: string }) {
return (
<Tabs.Content value={props.tab} class="mt-3 relative h-full">
<ScrollView
class="h-full"
viewportRef={(el: HTMLDivElement) => {
scroll = el
restoreScroll()
}}
onScroll={handleScroll as any}
>
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll as any}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>

View File

@@ -128,380 +128,452 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory)
return permission.isAutoAcceptingDirectory(sdk.directory)
}
command.register("session", () => {
const share =
sync.data.config.share === "disabled"
? []
: [
sessionCommand({
id: "session.share",
title: info()?.share?.url
? language.t("session.share.copy.copyLink")
: language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
onSelect: async () => {
if (!params.id) return
const write = async (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = value
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const write = (value: string) => {
const body = typeof document === "undefined" ? undefined : document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = value
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
textarea.style.pointerEvents = "none"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return Promise.resolve(true)
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return false
return clipboard.writeText(value).then(
() => true,
() => false,
)
}
const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard
if (!clipboard?.writeText) return Promise.resolve(false)
return clipboard.writeText(value).then(
() => true,
() => false,
)
}
const copyShare = async (url: string, existing: boolean) => {
if (!(await write(url))) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
const copy = async (url: string, existing: boolean) => {
const ok = await write(url)
if (!ok) {
showToast({
title: language.t("toast.session.share.copyFailed.title"),
variant: "error",
})
return
}
showToast({
title: existing ? language.t("session.share.copy.copied") : language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
showToast({
title: existing
? language.t("session.share.copy.copied")
: language.t("toast.session.share.success.title"),
description: language.t("toast.session.share.success.description"),
variant: "success",
})
}
const share = async () => {
const sessionID = params.id
if (!sessionID) return
const existing = info()?.share?.url
if (existing) {
await copy(existing, true)
return
}
const existing = info()?.share?.url
if (existing) {
await copyShare(existing, true)
return
}
const url = await sdk.client.session
.share({ sessionID: params.id })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
})
return
}
const url = await sdk.client.session
.share({ sessionID })
.then((res) => res.data?.share?.url)
.catch(() => undefined)
if (!url) {
showToast({
title: language.t("toast.session.share.failed.title"),
description: language.t("toast.session.share.failed.description"),
variant: "error",
})
return
}
await copy(url, false)
},
}),
sessionCommand({
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: async () => {
if (!params.id) return
await sdk.client.session
.unshare({ sessionID: params.id })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
},
}),
]
await copyShare(url, false)
}
const unshare = async () => {
const sessionID = params.id
if (!sessionID) return
await sdk.client.session
.unshare({ sessionID })
.then(() =>
showToast({
title: language.t("toast.session.unshare.success.title"),
description: language.t("toast.session.unshare.success.description"),
variant: "success",
}),
)
.catch(() =>
showToast({
title: language.t("toast.session.unshare.failed.title"),
description: language.t("toast.session.unshare.failed.description"),
variant: "error",
}),
)
}
const openFile = () => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
})
}
const closeTab = () => {
const tab = closableTab()
if (!tab) return
tabs().close(tab)
}
const addSelection = () => {
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
}
const openTerminal = () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
}
const chooseModel = () => {
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
}
const chooseMcp = () => {
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
}
const toggleAutoAccept = () => {
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
}
const undo = async () => {
const sessionID = params.id
if (!sessionID) return
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const prev = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(prev)
}
const redo = async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const next = userMessages().find((x) => x.id > revertMessageID)
if (!next) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const last = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(last)
return
}
await sdk.client.session.revert({ sessionID, messageID: next.id })
const prev = findLast(userMessages(), (x) => x.id < next.id)
setActiveMessage(prev)
}
const compact = async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
}
const fork = () => {
void import("@/components/dialog-fork").then((x) => {
dialog.show(() => <x.DialogFork />)
})
}
const shareCmds = () => {
if (sync.data.config.share === "disabled") return []
return [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
}),
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: () => {
void import("@/components/dialog-select-file").then((x) => {
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
})
},
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: () => {
const tab = closableTab()
if (!tab) return
tabs().close(tab)
},
}),
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext(),
onSelect: () => {
const tab = activeFileTab()
if (!tab) return
const path = file.pathFromTab(tab)
if (!path) return
const range = file.selectedLines(path) as SelectedLineRange | null | undefined
if (!range) {
showToast({
title: language.t("toast.context.noLineSelection.title"),
description: language.t("toast.context.noLineSelection.description"),
})
return
}
addSelectionToContext(path, selectionFromLines(range))
},
}),
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: focusInput,
}),
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: () => {
if (terminal.all().length > 0) terminal.new()
view().terminal.open()
},
}),
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+alt+[",
id: "session.share",
title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"),
description: info()?.share?.url
? language.t("toast.session.share.success.description")
: language.t("command.session.share.description"),
slash: "share",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
onSelect: share,
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+alt+]",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
id: "session.unshare",
title: language.t("command.session.unshare"),
description: language.t("command.session.unshare.description"),
slash: "unshare",
disabled: !params.id || !info()?.share?.url,
onSelect: unshare,
}),
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: () => {
void import("@/components/dialog-select-model").then((x) => {
dialog.show(() => <x.DialogSelectModel model={local.model} />)
})
},
}),
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: () => {
void import("@/components/dialog-select-mcp").then((x) => {
dialog.show(() => <x.DialogSelectMcp />)
})
},
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
onSelect: () => {
const sessionID = params.id
if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory)
else permission.toggleAutoAcceptDirectory(sdk.directory)
const active = sessionID
? permission.isAutoAccepting(sessionID, sdk.directory)
: permission.isAutoAcceptingDirectory(sdk.directory)
showToast({
title: active
? language.t("toast.permissions.autoaccept.on.title")
: language.t("toast.permissions.autoaccept.off.title"),
description: active
? language.t("toast.permissions.autoaccept.on.description")
: language.t("toast.permissions.autoaccept.off.description"),
})
},
}),
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
if (status().type !== "idle") {
await sdk.client.session.abort({ sessionID }).catch(() => {})
}
const revert = info()?.revert?.messageID
const message = findLast(userMessages(), (x) => !revert || x.id < revert)
if (!message) return
await sdk.client.session.revert({ sessionID, messageID: message.id })
const parts = sync.data.part[message.id]
if (parts) {
const restored = extractPromptFromParts(parts, { directory: sdk.directory })
prompt.set(restored)
}
const priorMessage = findLast(userMessages(), (x) => x.id < message.id)
setActiveMessage(priorMessage)
},
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const revertMessageID = info()?.revert?.messageID
if (!revertMessageID) return
const nextMessage = userMessages().find((x) => x.id > revertMessageID)
if (!nextMessage) {
await sdk.client.session.unrevert({ sessionID })
prompt.reset()
const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID)
setActiveMessage(lastMsg)
return
}
await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id)
setActiveMessage(priorMsg)
},
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: async () => {
const sessionID = params.id
if (!sessionID) return
const model = local.model.current()
if (!model) {
showToast({
title: language.t("toast.model.none.title"),
description: language.t("toast.model.none.description"),
})
return
}
await sdk.client.session.summarize({
sessionID,
modelID: model.id,
providerID: model.provider.id,
})
},
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: () => {
void import("@/components/dialog-fork").then((x) => {
dialog.show(() => <x.DialogFork />)
})
},
}),
...share,
]
})
}
const sessionCmds = () => [
sessionCommand({
id: "session.new",
title: language.t("command.session.new"),
keybind: "mod+shift+s",
slash: "new",
onSelect: () => navigate(`/${params.dir}/session`),
}),
sessionCommand({
id: "session.undo",
title: language.t("command.session.undo"),
description: language.t("command.session.undo.description"),
slash: "undo",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: undo,
}),
sessionCommand({
id: "session.redo",
title: language.t("command.session.redo"),
description: language.t("command.session.redo.description"),
slash: "redo",
disabled: !params.id || !info()?.revert?.messageID,
onSelect: redo,
}),
sessionCommand({
id: "session.compact",
title: language.t("command.session.compact"),
description: language.t("command.session.compact.description"),
slash: "compact",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: compact,
}),
sessionCommand({
id: "session.fork",
title: language.t("command.session.fork"),
description: language.t("command.session.fork.description"),
slash: "fork",
disabled: !params.id || visibleUserMessages().length === 0,
onSelect: fork,
}),
]
const fileCmds = () => [
fileCommand({
id: "file.open",
title: language.t("command.file.open"),
description: language.t("palette.search.placeholder"),
keybind: "mod+k,mod+p",
slash: "open",
onSelect: openFile,
}),
fileCommand({
id: "tab.close",
title: language.t("command.tab.close"),
keybind: "mod+w",
disabled: !closableTab(),
onSelect: closeTab,
}),
]
const contextCmds = () => [
contextCommand({
id: "context.addSelection",
title: language.t("command.context.addSelection"),
description: language.t("command.context.addSelection.description"),
keybind: "mod+shift+l",
disabled: !canAddSelectionContext(),
onSelect: addSelection,
}),
]
const viewCmds = () => [
viewCommand({
id: "terminal.toggle",
title: language.t("command.terminal.toggle"),
keybind: "ctrl+`",
slash: "terminal",
onSelect: () => view().terminal.toggle(),
}),
viewCommand({
id: "review.toggle",
title: language.t("command.review.toggle"),
keybind: "mod+shift+r",
onSelect: () => view().reviewPanel.toggle(),
}),
viewCommand({
id: "fileTree.toggle",
title: language.t("command.fileTree.toggle"),
keybind: "mod+\\",
onSelect: () => layout.fileTree.toggle(),
}),
viewCommand({
id: "input.focus",
title: language.t("command.input.focus"),
keybind: "ctrl+l",
onSelect: focusInput,
}),
]
const terminalCmds = () => [
terminalCommand({
id: "terminal.new",
title: language.t("command.terminal.new"),
description: language.t("command.terminal.new.description"),
keybind: "ctrl+alt+t",
onSelect: openTerminal,
}),
]
const messageCmds = () => [
sessionCommand({
id: "message.previous",
title: language.t("command.message.previous"),
description: language.t("command.message.previous.description"),
keybind: "mod+alt+[",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(-1),
}),
sessionCommand({
id: "message.next",
title: language.t("command.message.next"),
description: language.t("command.message.next.description"),
keybind: "mod+alt+]",
disabled: !params.id,
onSelect: () => navigateMessageByOffset(1),
}),
]
const modelCmds = () => [
modelCommand({
id: "model.choose",
title: language.t("command.model.choose"),
description: language.t("command.model.choose.description"),
keybind: "mod+'",
slash: "model",
onSelect: chooseModel,
}),
modelCommand({
id: "model.variant.cycle",
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => local.model.variant.cycle(),
}),
]
const mcpCmds = () => [
mcpCommand({
id: "mcp.toggle",
title: language.t("command.mcp.toggle"),
description: language.t("command.mcp.toggle.description"),
keybind: "mod+;",
slash: "mcp",
onSelect: chooseMcp,
}),
]
const agentCmds = () => [
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
description: language.t("command.agent.cycle.description"),
keybind: "mod+.",
slash: "agent",
onSelect: () => local.agent.move(1),
}),
agentCommand({
id: "agent.cycle.reverse",
title: language.t("command.agent.cycle.reverse"),
description: language.t("command.agent.cycle.reverse.description"),
keybind: "shift+mod+.",
onSelect: () => local.agent.move(-1),
}),
]
const permissionsCmds = () => [
permissionsCommand({
id: "permissions.autoaccept",
title: isAutoAcceptActive()
? language.t("command.permissions.autoaccept.disable")
: language.t("command.permissions.autoaccept.enable"),
keybind: "mod+shift+a",
disabled: false,
onSelect: toggleAutoAccept,
}),
]
command.register("session", () => [
...sessionCmds(),
...shareCmds(),
...fileCmds(),
...contextCmds(),
...viewCmds(),
...terminalCmds(),
...messageCmds(),
...modelCmds(),
...mcpCmds(),
...agentCmds(),
...permissionsCmds(),
])
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.3.3",
"version": "1.3.9",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -331,8 +331,7 @@ export const dict = {
"go.faq.a4.p3": "ألغِ في أي وقت.",
"go.faq.q5": "ماذا عن البيانات والخصوصية؟",
"go.faq.a5.body":
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر.",
"go.faq.a5.contactAfter": "إذا كان لديك أي أسئلة.",
"تم تصميم الخطة بشكل أساسي للمستخدمين الدوليين، مع استضافة النماذج في الولايات المتحدة والاتحاد الأوروبي وسنغافورة للحصول على وصول عالمي مستقر. يتبع مزودونا سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج.",
"go.faq.a5.beforeExceptions":
"تتم استضافة نماذج Go في الولايات المتحدة. يتبع المزودون سياسة عدم الاحتفاظ بالبيانات ولا يستخدمون بياناتك لتدريب النماذج، مع",
"go.faq.a5.exceptionsLink": "الاستثناءات التالية",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Cancele a qualquer momento.",
"go.faq.q5": "E sobre dados e privacidade?",
"go.faq.a5.body":
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável.",
"go.faq.a5.contactAfter": "se você tiver alguma dúvida.",
"O plano é projetado principalmente para usuários internacionais, com modelos hospedados nos EUA, UE e Singapura para acesso global estável. Nossos provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos.",
"go.faq.a5.beforeExceptions":
"Os modelos Go são hospedados nos EUA. Os provedores seguem uma política de retenção zero e não usam seus dados para treinamento de modelos, com as",
"go.faq.a5.exceptionsLink": "seguintes exceções",

View File

@@ -336,8 +336,7 @@ export const dict = {
"go.faq.a4.p3": "Annuller til enhver tid.",
"go.faq.q5": "Hvad med data og privatliv?",
"go.faq.a5.body":
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang.",
"go.faq.a5.contactAfter": "hvis du har spørgsmål.",
"Planen er primært designet til internationale brugere, med modeller hostet i USA, EU og Singapore for stabil global adgang. Vores udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning.",
"go.faq.a5.beforeExceptions":
"Go-modeller hostes i USA. Udbydere følger en nulopbevaringspolitik og bruger ikke dine data til modeltræning, med de",
"go.faq.a5.exceptionsLink": "følgende undtagelser",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Jederzeit kündbar.",
"go.faq.q5": "Was ist mit Daten und Privatsphäre?",
"go.faq.a5.body":
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang.",
"go.faq.a5.contactAfter": "wenn du Fragen hast.",
"Der Plan ist primär für internationale Nutzer konzipiert, mit Modellen gehostet in den USA, der EU und Singapur für stabilen globalen Zugang. Unsere Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen.",
"go.faq.a5.beforeExceptions":
"Go-Modelle werden in den USA gehostet. Anbieter verfolgen eine Zero-Retention-Politik und nutzen deine Daten nicht für das Training von Modellen, mit den",
"go.faq.a5.exceptionsLink": "folgenden Ausnahmen",

View File

@@ -331,8 +331,8 @@ export const dict = {
"go.faq.a4.p3": "Cancel any time.",
"go.faq.q5": "What about data and privacy?",
"go.faq.a5.body":
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access.",
"go.faq.a5.contactAfter": "if you have any questions.",
"The plan is designed primarily for international users, with models hosted in the US, EU, and Singapore for stable global access. Our providers follow a zero-retention policy and do not use your data for model training.",
"go.faq.a5.beforeExceptions":
"Go models are hosted in the US. Providers follow a zero-retention policy and do not use your data for model training, with the",
"go.faq.a5.exceptionsLink": "following exceptions",

View File

@@ -338,8 +338,7 @@ export const dict = {
"go.faq.a4.p3": "Cancela en cualquier momento.",
"go.faq.q5": "¿Qué pasa con los datos y la privacidad?",
"go.faq.a5.body":
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable.",
"go.faq.a5.contactAfter": "si tienes alguna pregunta.",
"El plan está diseñado principalmente para usuarios internacionales, con modelos alojados en EE. UU., UE y Singapur para un acceso global estable. Nuestros proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos.",
"go.faq.a5.beforeExceptions":
"Los modelos de Go están alojados en EE. UU. Los proveedores siguen una política de retención cero y no utilizan tus datos para el entrenamiento de modelos, con las",
"go.faq.a5.exceptionsLink": "siguientes excepciones",

View File

@@ -340,8 +340,7 @@ export const dict = {
"go.faq.a4.p3": "Annulez à tout moment.",
"go.faq.q5": "Et pour les données et la confidentialité ?",
"go.faq.a5.body":
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable.",
"go.faq.a5.contactAfter": "si vous avez des questions.",
"Le plan est conçu principalement pour les utilisateurs internationaux, avec des modèles hébergés aux États-Unis, dans l'UE et à Singapour pour un accès mondial stable. Nos fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles.",
"go.faq.a5.beforeExceptions":
"Les modèles Go sont hébergés aux États-Unis. Les fournisseurs suivent une politique de rétention zéro et n'utilisent pas vos données pour l'entraînement des modèles, avec les",
"go.faq.a5.exceptionsLink": "exceptions suivantes",

View File

@@ -334,8 +334,7 @@ export const dict = {
"go.faq.a4.p3": "Annulla in qualsiasi momento.",
"go.faq.q5": "E per quanto riguarda dati e privacy?",
"go.faq.a5.body":
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile.",
"go.faq.a5.contactAfter": "se hai domande.",
"Il piano è progettato principalmente per gli utenti internazionali, con modelli ospitati negli Stati Uniti, UE e Singapore per un accesso globale stabile. I nostri provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli.",
"go.faq.a5.beforeExceptions":
"I modelli Go sono ospitati negli Stati Uniti. I provider seguono una policy di zero-retention e non usano i tuoi dati per l'addestramento dei modelli, con le",
"go.faq.a5.exceptionsLink": "seguenti eccezioni",

View File

@@ -335,8 +335,7 @@ export const dict = {
"go.faq.a4.p3": "いつでもキャンセル可能です。",
"go.faq.q5": "データとプライバシーは?",
"go.faq.a5.body":
"このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。",
"go.faq.a5.contactAfter": "ご質問がございましたら。",
"このプランは主に海外ユーザー向けに設計されており、米国、EU、シンガポールでホストされたモデルにより安定したグローバルアクセスを提供します。プロバイダーはゼロ保持ポリシーに従い、お客様のデータをモデルのトレーニングに使用しません。",
"go.faq.a5.beforeExceptions":
"Goのモデルは米国でホストされています。プロバイダーはゼロ保持ポリシーに従い、モデルのトレーニングにデータを使用しません",
"go.faq.a5.exceptionsLink": "以下の例外",

View File

@@ -331,8 +331,7 @@ export const dict = {
"go.faq.a4.p3": "언제든지 취소할 수 있습니다.",
"go.faq.q5": "데이터와 프라이버시는 어떤가요?",
"go.faq.a5.body":
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다.",
"go.faq.a5.contactAfter": "질문이 있으시면 언제든지 문의해 주세요.",
"이 플랜은 주로 글로벌 사용자를 위해 설계되었으며, 안정적인 글로벌 액세스를 위해 미국, EU, 싱가포르에 모델이 호스팅되어 있습니다. 당사의 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다.",
"go.faq.a5.beforeExceptions":
"Go 모델은 미국에서 호스팅됩니다. 제공자들은 데이터 보존 금지 정책을 따르며 모델 학습에 데이터를 사용하지 않습니다. 단,",
"go.faq.a5.exceptionsLink": "다음 예외",

View File

@@ -335,8 +335,7 @@ export const dict = {
"go.faq.a4.p3": "Avslutt når som helst.",
"go.faq.q5": "Hva med data og personvern?",
"go.faq.a5.body":
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang.",
"go.faq.a5.contactAfter": "hvis du har spørsmål.",
"Planen er primært designet for internasjonale brukere, med modeller driftet i USA, EU og Singapore for stabil global tilgang. Våre leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening.",
"go.faq.a5.beforeExceptions":
"Go-modeller hostes i USA. Leverandører følger en policy om null oppbevaring og bruker ikke dataene dine til modelltrening, med",
"go.faq.a5.exceptionsLink": "følgende unntak",

View File

@@ -336,8 +336,7 @@ export const dict = {
"go.faq.a4.p3": "Anuluj w dowolnym momencie.",
"go.faq.q5": "A co z danymi i prywatnością?",
"go.faq.a5.body":
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp.",
"go.faq.a5.contactAfter": "jeśli masz jakiekolwiek pytania.",
"Plan został zaprojektowany głównie dla użytkowników międzynarodowych, z modelami hostowanymi w USA, UE i Singapurze, aby zapewnić stabilny globalny dostęp. Nasi dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli.",
"go.faq.a5.beforeExceptions":
"Modele Go są hostowane w USA. Dostawcy stosują politykę zerowej retencji i nie używają Twoich danych do trenowania modeli, z",
"go.faq.a5.exceptionsLink": "następującymi wyjątkami",

View File

@@ -340,8 +340,7 @@ export const dict = {
"go.faq.a4.p3": "Отмена в любое время.",
"go.faq.q5": "Как насчет данных и приватности?",
"go.faq.a5.body":
"План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа.",
"go.faq.a5.contactAfter": "если у вас есть вопросы.",
"План разработан в первую очередь для международных пользователей, с моделями, размещенными в США, ЕС и Сингапуре для стабильного глобального доступа. Наши провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей.",
"go.faq.a5.beforeExceptions":
"Модели Go размещены в США. Провайдеры следуют политике нулевого хранения и не используют ваши данные для обучения моделей, за",
"go.faq.a5.exceptionsLink": "следующими исключениями",

View File

@@ -333,8 +333,7 @@ export const dict = {
"go.faq.a4.p3": "ยกเลิกได้ตลอดเวลา",
"go.faq.q5": "แล้วเรื่องข้อมูลและความเป็นส่วนตัวล่ะ?",
"go.faq.a5.body":
"แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร",
"go.faq.a5.contactAfter": "หากคุณมีคำถามใดๆ",
"แผนนี้ออกแบบมาเพื่อผู้ใช้งานระหว่างประเทศเป็นหลัก โดยมีโมเดลโฮสต์ในสหรัฐอเมริกา สหภาพยุโรป และสิงคโปร์ เพื่อการเข้าถึงทั่วโลกที่เสถียร ผู้ให้บริการของเราปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูลและไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล",
"go.faq.a5.beforeExceptions":
"โมเดล Go โฮสต์ในสหรัฐอเมริกา ผู้ให้บริการปฏิบัติตามนโยบายไม่เก็บรักษาข้อมูล (zero-retention policy) และไม่ใช้ข้อมูลของคุณสำหรับการฝึกโมเดล โดยมี",
"go.faq.a5.exceptionsLink": "ข้อยกเว้นดังนี้",

View File

@@ -339,8 +339,7 @@ export const dict = {
"go.faq.a4.p3": "yönetebilirsiniz. İstediğiniz zaman iptal edin.",
"go.faq.q5": "Veri ve gizlilik ne olacak?",
"go.faq.a5.body":
"Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır.",
"go.faq.a5.contactAfter": "herhangi bir sorunuz varsa.",
"Bu plan öncelikle uluslararası kullanıcılar için tasarlanmış olup, istikrarlı küresel erişim için modeller ABD, AB ve Singapur'da barındırılmaktadır. Sağlayıcılarımız sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz.",
"go.faq.a5.beforeExceptions":
"Go modelleri ABD'de barındırılmaktadır. Sağlayıcılar sıfır saklama politikası izler ve verilerinizi model eğitimi için kullanmaz; şu",
"go.faq.a5.exceptionsLink": "aşağıdaki istisnalar",

View File

@@ -319,8 +319,8 @@ export const dict = {
"go.faq.a4.p2.accountLink": "账户",
"go.faq.a4.p3": "中管理订阅。随时取消。",
"go.faq.q5": "数据和隐私如何?",
"go.faq.a5.body": "该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。",
"go.faq.a5.contactAfter": "如果您有任何问题。",
"go.faq.a5.body":
"该计划主要面向国际用户设计,模型部署在美国、欧盟和新加坡,以确保稳定的全球访问。我们的提供商遵循零留存政策,不使用您的数据进行模型训练。",
"go.faq.a5.beforeExceptions": "Go 模型托管在美国。提供商遵循零留存政策,不使用您的数据进行模型训练,",
"go.faq.a5.exceptionsLink": "以下例外情况除外",
"go.faq.q6": "我可以充值余额吗?",

View File

@@ -319,8 +319,8 @@ export const dict = {
"go.faq.a4.p2.accountLink": "帳戶",
"go.faq.a4.p3": "中管理訂閱。隨時取消。",
"go.faq.q5": "資料與隱私怎麼辦?",
"go.faq.a5.body": "該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。",
"go.faq.a5.contactAfter": "如果你有任何問題。",
"go.faq.a5.body":
"該方案主要面向國際用戶設計,模型託管在美國、歐盟和新加坡,以確保全球穩定存取。我們的供應商遵循零留存政策,不會將你的資料用於模型訓練。",
"go.faq.a5.beforeExceptions": "Go 模型託管在美國。供應商遵循零留存政策,不會將你的資料用於模型訓練,但有",
"go.faq.a5.exceptionsLink": "以下例外",
"go.faq.q6": "我可以儲值額度嗎?",

View File

@@ -432,10 +432,7 @@ export default function Home() {
</Faq>
</li>
<li>
<Faq question={i18n.t("go.faq.q5")}>
{i18n.t("go.faq.a5.body")} <a href="mailto:contact@anoma.ly">{i18n.t("common.contactUs")}</a>{" "}
{i18n.t("go.faq.a5.contactAfter")}
</Faq>
<Faq question={i18n.t("go.faq.q5")}>{i18n.t("go.faq.a5.body")}</Faq>
</li>
<li>
<Faq question={i18n.t("go.faq.q6")}>{i18n.t("go.faq.a6")}</Faq>

View File

@@ -0,0 +1,541 @@
[data-page="zen"][data-route="rankings"] {
[data-component="hero"][data-variant="rankings"] {
padding-top: var(--vertical-padding);
padding-bottom: var(--vertical-padding);
[data-slot="hero-copy"] {
p {
margin-bottom: 0;
max-width: 44rem;
}
}
}
[data-component="container"] {
border: none;
}
[data-component="footer"] {
border-top: none;
}
[data-component="rankings-section"] {
padding: var(--vertical-padding) var(--padding);
h3 {
font-size: 16px;
font-weight: 700;
color: var(--color-text-strong);
}
}
[data-slot="chart"] {
margin-top: 1.5rem;
background: var(--color-background-weak);
border: 1px solid var(--color-border-weak);
border-radius: 0.5rem;
padding: 2rem 1.5rem 1.5rem;
overflow: visible;
@media (max-width: 40rem) {
padding: 1.5rem 1rem 1rem;
}
}
[data-slot="bars"] {
display: flex;
align-items: flex-end;
gap: 0.75rem;
height: 20rem;
@media (max-width: 40rem) {
height: 14rem;
gap: 0.5rem;
}
}
[data-slot="bar-group"] {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
height: 100%;
justify-content: flex-end;
min-width: 2.5rem;
cursor: default;
transition: opacity 0.15s ease;
&[data-dimmed] {
opacity: 0.35;
}
}
[data-slot="bar-label"] {
color: var(--color-text-weak);
font-size: 0.75rem;
white-space: nowrap;
}
[data-slot="bar-wrap"] {
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-end;
align-items: center;
}
[data-slot="bar"] {
width: 100%;
max-width: 4rem;
border-radius: 0.25rem 0.25rem 0 0;
min-height: 2px;
display: flex;
flex-direction: column-reverse;
gap: 1px;
overflow: hidden;
span {
display: block;
min-height: 1px;
}
span:last-child {
border-radius: 0.25rem 0.25rem 0 0;
}
}
[data-slot="bar-week"] {
color: var(--color-text-weak);
font-size: 0.75rem;
white-space: nowrap;
}
[data-slot="tooltip"] {
position: fixed;
z-index: 100;
transform: translateY(-100%);
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 0.375rem;
padding: 0.75rem;
min-width: 14rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
pointer-events: none;
strong {
display: block;
color: var(--color-text-strong);
font-size: 0.8125rem;
font-weight: 600;
margin-bottom: 0.125rem;
}
}
[data-slot="tooltip-total"] {
display: block;
color: var(--color-text-weak);
font-size: 0.75rem;
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-border-weak);
}
[data-slot="tooltip-row"] {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 0.5rem;
font-size: 0.75rem;
color: var(--color-text);
padding: 0.175rem 0;
i {
width: 0.5rem;
height: 0.5rem;
border-radius: 2px;
flex: none;
}
span:last-child {
text-align: right;
color: var(--color-text-strong);
font-weight: 500;
}
}
[data-slot="legend"] {
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1.25rem;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid var(--color-border-weak);
}
[data-slot="legend-item"] {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8125rem;
color: var(--color-text);
i {
width: 0.75rem;
height: 0.75rem;
border-radius: 2px;
flex: none;
}
}
[data-slot="subtitle"] {
color: var(--color-text);
margin-top: 0.5rem;
}
[data-slot="lb-podium"] {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
margin-top: 1.5rem;
@media (max-width: 40rem) {
grid-template-columns: 1fr;
}
}
[data-slot="lb-podium-item"] {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 1.25rem;
background: var(--color-background-weak);
border: 1px solid var(--color-border-weak);
border-radius: 0.5rem;
strong {
color: var(--color-text-strong);
font-size: 1.0625rem;
font-weight: 600;
}
}
[data-slot="lb-podium-rank"] {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border-radius: 999px;
font-size: 0.8125rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
[data-slot="lb-podium-item"][data-rank="1"] [data-slot="lb-podium-rank"] {
background: hsl(45, 93%, 47%);
color: hsl(45, 100%, 10%);
}
[data-slot="lb-podium-item"][data-rank="2"] [data-slot="lb-podium-rank"] {
background: hsl(0, 0%, 75%);
color: hsl(0, 0%, 15%);
}
[data-slot="lb-podium-item"][data-rank="3"] [data-slot="lb-podium-rank"] {
background: hsl(30, 60%, 50%);
color: hsl(30, 60%, 10%);
}
[data-slot="lb-podium-tokens"] {
color: var(--color-text-weak);
font-size: 0.875rem;
}
[data-slot="leaderboard"] {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 2rem;
margin-top: 1rem;
@media (max-width: 58rem) {
grid-template-columns: 1fr;
}
}
[data-slot="lb-col"] {
display: grid;
align-content: start;
}
[data-slot="lb-col"] [data-slot="lb-row"]:last-child {
border-bottom: none;
}
[data-slot="lb-row"] {
display: grid;
grid-template-columns: 2rem minmax(0, 1fr) auto;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 0;
border-bottom: 1px solid var(--color-border-weak);
}
[data-slot="lb-row"]:last-child {
border-bottom: none;
}
[data-slot="lb-rank"] {
color: var(--color-text-weak);
font-size: 0.875rem;
}
[data-slot="lb-info"] {
min-width: 0;
strong {
display: block;
color: var(--color-text-strong);
font-size: 0.9375rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
[data-slot="lb-tokens"] {
white-space: nowrap;
color: var(--color-text);
font-size: 0.875rem;
}
[data-slot="lb-toggle"] {
display: block;
margin: 1rem auto 0;
padding: 0;
background: none;
border: none;
color: var(--color-text-weak);
font: inherit;
font-size: 0.875rem;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 0.14em;
text-decoration-thickness: 1px;
&:hover {
color: var(--color-text-strong);
}
}
[data-slot="share-bars"] {
display: flex;
gap: 0.5rem;
height: 20rem;
@media (max-width: 40rem) {
height: 14rem;
gap: 0.375rem;
}
}
[data-slot="share-col"] {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
min-width: 2.5rem;
cursor: default;
transition: opacity 0.15s ease;
&[data-dimmed] {
opacity: 0.35;
}
}
[data-slot="share-stack"] {
flex: 1;
display: flex;
flex-direction: column-reverse;
gap: 1px;
border-radius: 0.25rem;
overflow: hidden;
span {
display: block;
min-height: 1px;
}
}
[data-slot="share-week"] {
color: var(--color-text-weak);
font-size: 0.75rem;
text-align: center;
white-space: nowrap;
}
[data-slot="pricing-table"] {
margin-top: 1.5rem;
border: 1px solid var(--color-border-weak);
border-radius: 0.5rem;
overflow: hidden;
}
[data-slot="pricing-header"],
[data-slot="pricing-row"] {
display: grid;
grid-template-columns: minmax(0, 1.5fr) repeat(4, minmax(0, 1fr));
gap: 0.75rem;
padding: 0.75rem 1.25rem;
&[data-cols="4"] {
grid-template-columns: minmax(0, 1.5fr) repeat(3, minmax(0, 1fr));
}
&[data-cols="3"] {
grid-template-columns: minmax(0, 1.5fr) repeat(2, minmax(0, 1fr));
}
&[data-cols="2"] {
grid-template-columns: minmax(0, 1fr) auto;
}
@media (max-width: 40rem) {
padding: 0.625rem 0.75rem;
gap: 0.5rem;
}
}
[data-slot="pricing-header"] {
background: var(--color-background-weak);
border-bottom: 1px solid var(--color-border-weak);
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-weak);
}
[data-slot="pricing-row"] {
border-bottom: 1px solid var(--color-border-weak);
font-size: 0.875rem;
color: var(--color-text);
&:last-child {
border-bottom: none;
}
}
[data-slot="cost-list"] {
margin-top: 1.5rem;
}
[data-slot="cost-group"] {
&:last-child {
margin-bottom: 0;
padding-bottom: 0;
}
}
[data-slot="cost-row"] {
padding: 0.5rem 0;
}
[data-slot="cost-main"] {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1.2fr);
align-items: center;
gap: 0.75rem 2rem;
}
[data-slot="cost-detail"] {
display: flex;
gap: 1rem;
margin-top: 0.375rem;
font-size: 0.75rem;
color: var(--color-text-weak);
}
[data-slot="cost-bar-wrap"] {
height: 0.375rem;
background: var(--color-border-weak);
border-radius: 999px;
}
[data-slot="cost-bar"] {
display: block;
height: 100%;
background: var(--color-background-strong);
border-radius: 999px;
min-width: 2px;
}
[data-slot="pricing-model"] {
color: var(--color-text-strong);
font-weight: 500;
font-size: 0.9375rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-slot="pricing-effective"] {
color: var(--color-text-strong);
font-weight: 600;
}
[data-slot="tps-cell"] {
position: relative;
display: flex;
align-items: center;
height: 0.5rem;
background: var(--color-border-weak);
border-radius: 999px;
cursor: default;
&:hover::after {
content: attr(title);
position: absolute;
bottom: calc(100% + 0.375rem);
left: 50%;
transform: translateX(-50%);
padding: 0.25rem 0.5rem;
background: var(--color-background);
border: 1px solid var(--color-border);
border-radius: 0.25rem;
font-size: 0.75rem;
color: var(--color-text-strong);
white-space: nowrap;
pointer-events: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
z-index: 10;
}
}
[data-slot="tps-bar"] {
height: 100%;
background: var(--color-background-strong);
border-radius: 999px 0 0 999px;
min-width: 2px;
}
[data-slot="map-wrap"] {
position: relative;
margin-top: 1.5rem;
}
[data-slot="world-map"] {
width: 100%;
height: auto;
path {
cursor: default;
transition: opacity 0.15s ease;
&:hover {
opacity: 0.8;
}
}
}
}

View File

@@ -0,0 +1,920 @@
import "./zen/index.css"
import "./rankings.css"
import { Title } from "@solidjs/meta"
import { createSignal, For, Show } from "solid-js"
import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { Legal } from "~/component/legal"
import { LocaleLinks } from "~/component/locale-links"
import { paths as worldPaths } from "./world-paths"
const models = [
{ name: "minimax-m2.5-free", color: "#f25bb2" },
{ name: "big-pickle", color: "#8b5cf6" },
{ name: "mimo-v2-pro-free", color: "#18a57b" },
{ name: "kimi-k2.5-free", color: "#3481cf" },
{ name: "kimi-k2.5", color: "#e4ad27" },
{ name: "minimax-m2.1-free", color: "#ff5a1f" },
{ name: "glm-5-free", color: "#46b786" },
{ name: "gpt-5-nano", color: "#84cc16" },
{ name: "nemotron-3-super-free", color: "#14c2a3" },
{ name: "claude-opus-4-6", color: "#ff7b3d" },
] as const
type Week = {
week: string
segments: { model: string; color: string; tokens: number }[]
total: number
}
const raw: Record<string, Record<string, number>> = {
"jan-27": {
"big-pickle": 368743864804,
"kimi-k2.5-free": 915886375887,
"kimi-k2.5": 39168495089,
"minimax-m2.1-free": 270764664177,
"gpt-5-nano": 41276213376,
},
"feb-3": {
"big-pickle": 520618971808,
"kimi-k2.5-free": 980317788887,
"kimi-k2.5": 92823557285,
"minimax-m2.1-free": 721380871693,
"gpt-5-nano": 74420810088,
"claude-opus-4-6": 49826050233,
},
"feb-10": {
"minimax-m2.5-free": 905276977685,
"big-pickle": 775593605696,
"kimi-k2.5-free": 546100624556,
"kimi-k2.5": 106869700825,
"minimax-m2.1-free": 273479063739,
"glm-5-free": 21944995438,
"gpt-5-nano": 86562724981,
"claude-opus-4-6": 71785580030,
},
"feb-17": {
"minimax-m2.5-free": 1313909105944,
"big-pickle": 1017431400927,
"kimi-k2.5-free": 350112011799,
"kimi-k2.5": 100616295719,
"minimax-m2.1-free": 11269742719,
"glm-5-free": 735726749938,
"gpt-5-nano": 79036772388,
"claude-opus-4-6": 66789922519,
},
"feb-24": {
"minimax-m2.5-free": 1988229542028,
"big-pickle": 1332574843957,
"kimi-k2.5-free": 17964822381,
"kimi-k2.5": 152009783828,
"minimax-m2.1-free": 10376282858,
"glm-5-free": 3848233361,
"gpt-5-nano": 94195723658,
"claude-opus-4-6": 72579249230,
},
"mar-3": {
"minimax-m2.5-free": 1103526073812,
"big-pickle": 864075264151,
"kimi-k2.5": 236732504356,
"gpt-5-nano": 62076480967,
"claude-opus-4-6": 75122544903,
},
"mar-10": {
"minimax-m2.5-free": 1765538911999,
"big-pickle": 1456821813321,
"kimi-k2.5": 270005019814,
"gpt-5-nano": 80064940458,
"nemotron-3-super-free": 226767628611,
"claude-opus-4-6": 121826865344,
},
"mar-17": {
"minimax-m2.5-free": 2258964526070,
"big-pickle": 2157757939980,
"mimo-v2-pro-free": 1393092895745,
"kimi-k2.5": 253277628360,
"gpt-5-nano": 91075743861,
"nemotron-3-super-free": 257336931857,
"claude-opus-4-6": 107180831444,
},
"mar-24": {
"minimax-m2.5-free": 2256417941175,
"big-pickle": 2596072141208,
"mimo-v2-pro-free": 1612680626347,
"kimi-k2.5": 263140655381,
"gpt-5-nano": 130043140955,
"nemotron-3-super-free": 254171104198,
"claude-opus-4-6": 107813898809,
},
}
const totals: Record<string, number> = {
"jan-27": 1954431878631,
"feb-3": 2782873471532,
"feb-10": 2982021636344,
"feb-17": 3898395901667,
"feb-24": 3980255846812,
"mar-3": 2789862168396,
"mar-10": 4672779512356,
"mar-17": 7453410720062,
"mar-24": 8434615944207,
}
const weeks = ["jan-27", "feb-3", "feb-10", "feb-17", "feb-24", "mar-3", "mar-10", "mar-17", "mar-24"]
const labels: Record<string, string> = {
"jan-27": "Jan 27",
"feb-3": "Feb 3",
"feb-10": "Feb 10",
"feb-17": "Feb 17",
"feb-24": "Feb 24",
"mar-3": "Mar 3",
"mar-10": "Mar 10",
"mar-17": "Mar 17",
"mar-24": "Mar 24",
}
const usage: Week[] = weeks.map((key) => {
const data = raw[key]
const total = totals[key]
const named = models.filter((m) => data[m.name]).map((m) => ({ model: m.name, color: m.color, tokens: data[m.name] }))
const rest = total - named.reduce((sum, s) => sum + s.tokens, 0)
return {
week: labels[key],
total,
segments: [...named, { model: "Other", color: "rgba(148, 163, 184, 0.35)", tokens: rest }],
}
})
const max = Math.max(...usage.map((w) => w.total))
const fmt = (n: number) => {
if (n >= 1e12) return `${(n / 1e12).toFixed(1)}T`
return `${(n / 1e9).toFixed(0)}B`
}
const leaderboard = [
{ rank: 1, model: "big-pickle", tokens: 2_596_072_141_208 },
{ rank: 2, model: "minimax-m2.5-free", tokens: 2_256_417_941_175 },
{ rank: 3, model: "mimo-v2-pro-free", tokens: 1_612_680_626_347 },
{ rank: 4, model: "mimo-v2-omni-free", tokens: 313_963_070_777 },
{ rank: 5, model: "kimi-k2.5", tokens: 263_140_655_381 },
{ rank: 6, model: "nemotron-3-super-free", tokens: 254_171_104_198 },
{ rank: 7, model: "minimax-m2.7", tokens: 235_471_532_073 },
{ rank: 8, model: "qwen3.6-plus-free", tokens: 211_645_852_696 },
{ rank: 9, model: "glm-5", tokens: 149_968_059_435 },
{ rank: 10, model: "gpt-5-nano", tokens: 130_043_140_955 },
{ rank: 11, model: "claude-opus-4-6", tokens: 107_813_898_809 },
{ rank: 12, model: "minimax-m2.5", tokens: 91_477_876_330 },
{ rank: 13, model: "gpt-5.4", tokens: 64_216_544_736 },
{ rank: 14, model: "claude-sonnet-4-6", tokens: 53_638_240_479 },
{ rank: 15, model: "gpt-5.3-codex", tokens: 31_928_019_743 },
{ rank: 16, model: "gemini-3-flash", tokens: 14_528_488_448 },
{ rank: 17, model: "claude-haiku-4-5", tokens: 8_921_076_835 },
{ rank: 18, model: "gemini-3.1-pro", tokens: 7_676_935_166 },
{ rank: 19, model: "claude-sonnet-4-5", tokens: 5_051_555_617 },
{ rank: 20, model: "gpt-5.4-mini", tokens: 4_751_125_737 },
]
const providers = [
{ name: "opencode", color: "#8b5cf6" },
{ name: "minimax", color: "#f25bb2" },
{ name: "xiaomi", color: "#ff5a1f" },
{ name: "moonshot", color: "#3481cf" },
{ name: "nvidia", color: "#14c2a3" },
{ name: "openai", color: "#84cc16" },
{ name: "anthropic", color: "#ff7b3d" },
{ name: "zhipu", color: "#46b786" },
{ name: "google", color: "#e4ad27" },
{ name: "alibaba", color: "#ef5db1" },
{ name: "arcee", color: "#2085ec" },
] as const
const shareRaw: Record<string, Record<string, number>> = {
"jan-27": {
moonshot: 957101046076,
opencode: 368743864804,
minimax: 273789245099,
zhipu: 175220173052,
openai: 71428726197,
anthropic: 63795092028,
arcee: 22900709163,
google: 21116481943,
alibaba: 336540269,
},
"feb-3": {
moonshot: 1075728836899,
minimax: 727777460147,
opencode: 520618971808,
anthropic: 126083048915,
openai: 119992167519,
zhipu: 106315324243,
arcee: 70511760645,
google: 35345191581,
alibaba: 500567954,
},
"feb-10": {
minimax: 1195064506114,
opencode: 775593605696,
moonshot: 655693064959,
openai: 126021773769,
anthropic: 114969689993,
zhipu: 66447874989,
google: 27728156767,
arcee: 20502880487,
},
"feb-17": {
minimax: 1340689075939,
opencode: 1017431400927,
zhipu: 753486920157,
moonshot: 453006079914,
anthropic: 136280713749,
openai: 117036878336,
google: 44709966980,
arcee: 35460739820,
},
"feb-24": {
minimax: 2037390464296,
opencode: 1332574843957,
moonshot: 172345287072,
openai: 165404921924,
anthropic: 137007019288,
arcee: 65348467867,
zhipu: 36192647725,
google: 32632757942,
},
"mar-3": {
minimax: 1236589693899,
opencode: 864075264151,
moonshot: 236940261927,
openai: 144296631970,
anthropic: 143388311109,
zhipu: 89420861866,
google: 35499950281,
xiaomi: 24396507662,
arcee: 14674544044,
},
"mar-10": {
minimax: 1919467888084,
opencode: 1456821813321,
xiaomi: 302031698856,
moonshot: 270021430246,
nvidia: 226767628611,
anthropic: 181566633371,
openai: 171713393912,
zhipu: 113460952065,
google: 30109971757,
arcee: 818102133,
},
"mar-17": {
minimax: 2536581988215,
opencode: 2157757939980,
xiaomi: 1708313298084,
nvidia: 257336931857,
moonshot: 253304780525,
openai: 196305815207,
anthropic: 188087354693,
zhipu: 127277270842,
google: 28180238053,
arcee: 265058488,
},
"mar-24": {
opencode: 2596072141208,
minimax: 2583441903425,
xiaomi: 1930671539776,
moonshot: 263142158847,
nvidia: 254171104198,
openai: 242821016755,
alibaba: 211645852696,
anthropic: 179934787304,
zhipu: 150050493012,
google: 22325363704,
arcee: 336333746,
},
}
const share = weeks.map((key) => {
const data = shareRaw[key]
const total = totals[key]
const segs = providers
.filter((p) => data[p.name])
.map((p) => ({
provider: p.name,
color: p.color,
tokens: data[p.name],
pct: (data[p.name] / total) * 100,
}))
return { week: labels[key], total, segments: segs }
})
const pricing: Record<string, { input: number; output: number; cached: number }> = {
"kimi-k2.5": { input: 0.6, output: 3.0, cached: 0.1 },
"minimax-m2.7": { input: 0.3, output: 1.2, cached: 0.06 },
"glm-5": { input: 1.0, output: 3.2, cached: 0.2 },
"claude-opus-4-6": { input: 5.0, output: 25.0, cached: 0.5 },
"minimax-m2.5": { input: 0.3, output: 1.2, cached: 0.03 },
"gpt-5.4": { input: 2.5, output: 15.0, cached: 0.25 },
"claude-sonnet-4-6": { input: 3.0, output: 15.0, cached: 0.3 },
"gpt-5.3-codex": { input: 1.75, output: 14.0, cached: 0.175 },
"gemini-3-flash": { input: 0.5, output: 3.0, cached: 0.05 },
"claude-haiku-4-5": { input: 1.0, output: 5.0, cached: 0.1 },
"gemini-3.1-pro": { input: 2.0, output: 12.0, cached: 0.2 },
"claude-sonnet-4-5": { input: 3.0, output: 15.0, cached: 0.3 },
"gpt-5.4-mini": { input: 0.75, output: 4.5, cached: 0.075 },
}
const effective = (p: { input: number; output: number; cached: number }) =>
p.cached * 0.94 + p.input * 0.06 + p.output * 0.01
const price = (n: number) => (n === 0 ? "Free" : `$${n.toFixed(2)}`)
const modelGroup: Record<string, string> = {
"kimi-k2.5": "moonshot",
"minimax-m2.7": "minimax",
"minimax-m2.5": "minimax",
"glm-5": "zhipu",
"claude-opus-4-6": "anthropic",
"claude-sonnet-4-6": "anthropic",
"claude-sonnet-4-5": "anthropic",
"claude-haiku-4-5": "anthropic",
"gpt-5.4": "openai",
"gpt-5.3-codex": "openai",
"gpt-5.4-mini": "openai",
"gemini-3-flash": "google",
"gemini-3.1-pro": "google",
}
const pricedModels = leaderboard
.filter((r) => !r.model.endsWith("-free") && pricing[r.model])
.sort((a, b) => effective(pricing[a.model]) - effective(pricing[b.model]))
const maxEffective = Math.max(...pricedModels.map((r) => effective(pricing[r.model])))
const grouped = (() => {
const groups: { provider: string; models: typeof pricedModels }[] = []
const seen = new Set<string>()
for (const row of pricedModels) {
const g = modelGroup[row.model] || row.model
if (seen.has(g)) continue
seen.add(g)
const items = pricedModels.filter((r) => (modelGroup[r.model] || r.model) === g)
groups.push({ provider: g, models: items })
}
return groups
})()
const sessions = [
{ model: "gpt-5.4-nano", spend: 56.19, sessions: 2469, tokens: 2_058_320_992 },
{ model: "gpt-5.1-codex-mini", spend: 46.49, sessions: 649, tokens: 1_114_706_819 },
{ model: "minimax-m2.5", spend: 10774.93, sessions: 101862, tokens: 91_512_287_752 },
{ model: "claude-haiku-4-5", spend: 2169.56, sessions: 14896, tokens: 8_965_515_678 },
{ model: "gpt-5.4-mini", spend: 858.63, sessions: 4726, tokens: 4_759_454_622 },
{ model: "minimax-m2.7", spend: 24120.99, sessions: 118523, tokens: 235_267_498_689 },
{ model: "gpt-5.4", spend: 31834.9, sessions: 142909, tokens: 64_146_279_556 },
{ model: "kimi-k2.5", spend: 39724.09, sessions: 150135, tokens: 263_660_128_320 },
{ model: "gemini-3-flash", spend: 2294.28, sessions: 8405, tokens: 14_752_884_651 },
{ model: "glm-5", spend: 37814.87, sessions: 105292, tokens: 150_626_981_063 },
{ model: "claude-sonnet-4-6", spend: 32133.34, sessions: 42237, tokens: 53_550_067_871 },
{ model: "gpt-5.3-codex", spend: 10809.82, sessions: 13888, tokens: 31_907_600_634 },
{ model: "claude-sonnet-4-5", spend: 4061.09, sessions: 4057, tokens: 5_157_076_730 },
{ model: "gemini-3.1-pro", spend: 6257.1, sessions: 5777, tokens: 7_569_358_077 },
{ model: "claude-opus-4-6", spend: 114219.22, sessions: 42550, tokens: 106_575_958_684 },
{ model: "claude-opus-4-5", spend: 3582.53, sessions: 1576, tokens: 3_219_688_476 },
{ model: "gpt-5.4-pro", spend: 6555.13, sessions: 1264, tokens: 200_314_429 },
]
const avg = (r: (typeof sessions)[number]) => r.spend / r.sessions
const avgTokens = (r: (typeof sessions)[number]) => Math.round(r.tokens / r.sessions)
const fmtTokens = (n: number) => {
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`
if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`
return String(n)
}
const maxTps = Math.max(...sessions.map((r) => avgTokens(r)))
const countries: Record<string, number> = {
CN: 1_954_638_555_964,
US: 893_134_696_046,
IN: 535_643_693_640,
BR: 382_163_396_828,
DE: 311_242_520_442,
ES: 209_329_692_495,
RU: 201_527_328_150,
HK: 196_512_051_704,
GB: 166_553_586_780,
AR: 162_194_105_984,
SG: 158_456_781_083,
FR: 156_789_513_531,
JP: 152_176_549_930,
NL: 142_283_579_531,
ID: 133_099_866_730,
CA: 130_175_404_155,
IT: 104_164_651_206,
MX: 103_470_313_882,
TW: 99_750_103_092,
CO: 96_782_169_230,
VN: 89_760_456_363,
PL: 87_055_503_038,
TR: 83_380_620_873,
KR: 82_692_257_401,
AU: 82_626_936_402,
PH: 63_600_637_182,
EG: 61_672_925_246,
PT: 55_231_430_725,
BD: 54_398_663_664,
SE: 53_569_291_560,
CL: 52_319_761_865,
TH: 50_343_490_695,
NG: 50_159_406_105,
PK: 49_490_412_870,
FI: 45_825_535_387,
PE: 43_961_712_555,
CH: 43_300_038_112,
MY: 41_561_444_011,
RO: 40_383_208_346,
BE: 38_322_022_514,
MA: 38_272_211_062,
ZA: 38_180_939_069,
UA: 36_032_426_930,
VE: 34_807_973_969,
KE: 34_239_081_597,
IL: 32_705_941_531,
AT: 31_995_376_039,
DZ: 29_533_977_612,
CZ: 28_976_529_740,
NP: 25_623_967_353,
DO: 23_957_780_174,
DK: 22_736_971_721,
EC: 21_934_976_342,
AE: 21_823_728_235,
TN: 21_412_135_454,
NO: 20_735_253_026,
SA: 20_234_711_215,
BY: 18_939_368_533,
GH: 16_936_621_597,
RS: 16_778_051_855,
NZ: 16_319_979_678,
ET: 15_465_960_765,
IE: 14_978_507_068,
BO: 14_833_821_684,
GR: 14_785_265_286,
HU: 14_540_890_452,
UY: 13_804_790_917,
UZ: 13_008_621_109,
LK: 12_735_597_659,
BG: 12_045_851_521,
CR: 11_261_069_513,
PY: 10_614_168_600,
KH: 10_555_315_979,
KZ: 10_159_168_104,
LT: 9_815_793_110,
EE: 9_417_177_100,
UG: 9_165_111_455,
SK: 9_070_826_870,
LV: 8_198_013_338,
IQ: 8_187_011_695,
HR: 7_605_019_662,
CM: 7_377_050_916,
ZW: 7_314_742_432,
YE: 6_747_216_591,
JO: 6_211_747_207,
PA: 6_092_366_683,
QA: 5_955_042_936,
MD: 5_773_585_201,
TZ: 5_625_754_397,
NI: 5_228_293_282,
GE: 5_165_628_063,
SI: 4_955_336_900,
SN: 4_688_066_204,
BA: 4_549_605_753,
BJ: 4_543_143_957,
GT: 4_424_503_835,
HN: 4_405_852_608,
OM: 4_395_319_849,
}
const maxCountry = Math.max(...Object.values(countries))
const names: Record<string, string> = {
CN: "China",
US: "United States",
IN: "India",
BR: "Brazil",
DE: "Germany",
ES: "Spain",
RU: "Russia",
HK: "Hong Kong",
GB: "United Kingdom",
AR: "Argentina",
SG: "Singapore",
FR: "France",
JP: "Japan",
NL: "Netherlands",
ID: "Indonesia",
CA: "Canada",
IT: "Italy",
MX: "Mexico",
TW: "Taiwan",
CO: "Colombia",
VN: "Vietnam",
PL: "Poland",
TR: "Turkey",
KR: "South Korea",
AU: "Australia",
PH: "Philippines",
EG: "Egypt",
PT: "Portugal",
BD: "Bangladesh",
SE: "Sweden",
CL: "Chile",
TH: "Thailand",
NG: "Nigeria",
PK: "Pakistan",
FI: "Finland",
PE: "Peru",
CH: "Switzerland",
MY: "Malaysia",
RO: "Romania",
BE: "Belgium",
MA: "Morocco",
ZA: "South Africa",
UA: "Ukraine",
VE: "Venezuela",
KE: "Kenya",
IL: "Israel",
AT: "Austria",
DZ: "Algeria",
CZ: "Czechia",
NP: "Nepal",
DO: "Dominican Republic",
DK: "Denmark",
EC: "Ecuador",
AE: "UAE",
TN: "Tunisia",
NO: "Norway",
SA: "Saudi Arabia",
BY: "Belarus",
GH: "Ghana",
RS: "Serbia",
NZ: "New Zealand",
ET: "Ethiopia",
IE: "Ireland",
BO: "Bolivia",
GR: "Greece",
HU: "Hungary",
UY: "Uruguay",
UZ: "Uzbekistan",
LK: "Sri Lanka",
BG: "Bulgaria",
CR: "Costa Rica",
PY: "Paraguay",
KH: "Cambodia",
KZ: "Kazakhstan",
LT: "Lithuania",
EE: "Estonia",
UG: "Uganda",
SK: "Slovakia",
LV: "Latvia",
IQ: "Iraq",
HR: "Croatia",
CM: "Cameroon",
ZW: "Zimbabwe",
YE: "Yemen",
JO: "Jordan",
PA: "Panama",
QA: "Qatar",
MD: "Moldova",
TZ: "Tanzania",
NI: "Nicaragua",
GE: "Georgia",
SI: "Slovenia",
SN: "Senegal",
BA: "Bosnia",
BJ: "Benin",
GT: "Guatemala",
HN: "Honduras",
OM: "Oman",
}
const flag = (code: string) => String.fromCodePoint(...[...code].map((c) => 0x1f1e6 + c.charCodeAt(0) - 65))
const fill = (code: string) => {
const val = countries[code]
if (!val) return "var(--color-border-weak)"
const t = Math.pow(Math.log(val) / Math.log(maxCountry), 1.5)
const s = 50 + t * 40
const l = 92 - t * 60
return `hsl(220, ${s}%, ${l}%)`
}
export default function Rankings() {
const [active, setActive] = createSignal<number | null>(null)
const [pos, setPos] = createSignal({ x: 0, y: 0 })
const [expanded, setExpanded] = createSignal(false)
const [shareActive, setShareActive] = createSignal<number | null>(null)
const [sharePos, setSharePos] = createSignal({ x: 0, y: 0 })
const [mapHover, setMapHover] = createSignal<string | null>(null)
const [mapPos, setMapPos] = createSignal({ x: 0, y: 0 })
return (
<main data-page="zen" data-route="rankings">
<Title>Model Rankings</Title>
<LocaleLinks path="/rankings" />
<div data-component="container">
<Header zen hideGetStarted />
<div data-component="content">
<section data-component="hero" data-variant="rankings">
<div data-slot="hero-copy">
<h1>Model Rankings</h1>
<p>
See which models are winning real usage, how the mix shifts over time, and where momentum is moving each
week.
</p>
</div>
</section>
<section data-component="rankings-section">
<h3>Usage</h3>
<div data-slot="chart">
<div
data-slot="bars"
onMouseLeave={() => setActive(null)}
onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}
>
<For each={usage}>
{(week, i) => (
<div
data-slot="bar-group"
data-active={active() === i() ? "" : undefined}
data-dimmed={active() !== null && active() !== i() ? "" : undefined}
onMouseEnter={() => setActive(i())}
>
<span data-slot="bar-label">{fmt(week.total)}</span>
<div data-slot="bar-wrap">
<div data-slot="bar" style={{ height: `${(week.total / max) * 100}%` }}>
<For each={week.segments}>
{(seg) => (
<span
style={{
flex: String(seg.tokens),
background: seg.color,
}}
/>
)}
</For>
</div>
</div>
<span data-slot="bar-week">{week.week}</span>
</div>
)}
</For>
</div>
<Show when={active() !== null}>
<div
data-slot="tooltip"
style={{
left: `${pos().x + 16}px`,
top: `${pos().y - 16}px`,
}}
>
<strong>{usage[active()!].week}</strong>
<span data-slot="tooltip-total">{fmt(usage[active()!].total)} total</span>
<For each={usage[active()!].segments.filter((s) => s.tokens > 0)}>
{(seg) => (
<span data-slot="tooltip-row">
<i style={{ background: seg.color }} />
<span>{seg.model}</span>
<span>{fmt(seg.tokens)}</span>
</span>
)}
</For>
</div>
</Show>
<div data-slot="legend">
<For each={models}>
{(m) => (
<span data-slot="legend-item">
<i style={{ background: m.color }} />
{m.name}
</span>
)}
</For>
<span data-slot="legend-item">
<i style={{ background: "rgba(148, 163, 184, 0.35)" }} />
Other
</span>
</div>
</div>
</section>
<section data-component="rankings-section">
<h3>Leaderboard</h3>
<p data-slot="subtitle">Week of Mar 24 top models by token usage.</p>
<div data-slot="lb-podium">
<For each={leaderboard.slice(0, 3)}>
{(row) => (
<div data-slot="lb-podium-item" data-rank={row.rank}>
<span data-slot="lb-podium-rank">{row.rank}</span>
<strong>{row.model}</strong>
<span data-slot="lb-podium-tokens">{fmt(row.tokens)}</span>
</div>
)}
</For>
</div>
<div data-slot="leaderboard">
<For
each={[
leaderboard.slice(3, expanded() ? 13 : 8),
leaderboard.slice(expanded() ? 13 : 8, expanded() ? leaderboard.length : 13),
]}
>
{(col) => (
<div data-slot="lb-col">
<For each={col}>
{(row) => (
<article data-slot="lb-row">
<span data-slot="lb-rank">{row.rank}.</span>
<div data-slot="lb-info">
<strong>{row.model}</strong>
</div>
<span data-slot="lb-tokens">{fmt(row.tokens)}</span>
</article>
)}
</For>
</div>
)}
</For>
</div>
<Show when={leaderboard.length > 13}>
<button data-slot="lb-toggle" onClick={() => setExpanded(!expanded())}>
{expanded() ? "Show less" : "Show all"}
</button>
</Show>
</section>
<section data-component="rankings-section">
<h3>Market Share</h3>
<div data-slot="chart">
<div
data-slot="share-bars"
onMouseLeave={() => setShareActive(null)}
onMouseMove={(e) => setSharePos({ x: e.clientX, y: e.clientY })}
>
<For each={share}>
{(week, i) => (
<div
data-slot="share-col"
data-dimmed={shareActive() !== null && shareActive() !== i() ? "" : undefined}
onMouseEnter={() => setShareActive(i())}
>
<div data-slot="share-stack">
<For each={week.segments}>
{(seg) => <span style={{ flex: String(seg.pct), background: seg.color }} />}
</For>
</div>
<span data-slot="share-week">{week.week}</span>
</div>
)}
</For>
</div>
<Show when={shareActive() !== null}>
<div
data-slot="tooltip"
style={{
left: `${sharePos().x + 16}px`,
top: `${sharePos().y - 16}px`,
}}
>
<strong>{share[shareActive()!].week}</strong>
<span data-slot="tooltip-total">{fmt(share[shareActive()!].total)} total</span>
<For each={share[shareActive()!].segments.filter((s) => s.tokens > 0)}>
{(seg) => (
<span data-slot="tooltip-row">
<i style={{ background: seg.color }} />
<span>{seg.provider}</span>
<span>{seg.pct.toFixed(1)}%</span>
</span>
)}
</For>
</div>
</Show>
<div data-slot="legend">
<For each={providers}>
{(p) => (
<span data-slot="legend-item">
<i style={{ background: p.color }} />
{p.name}
</span>
)}
</For>
</div>
</div>
</section>
<section data-component="rankings-section">
<h3>Token Cost</h3>
<p data-slot="subtitle">Price per 1M tokens on OpenCode Zen.</p>
<div data-slot="cost-list">
<For each={grouped}>
{(group) => (
<div data-slot="cost-group">
<For each={group.models}>
{(row) => {
const p = pricing[row.model]
const eff = effective(p)
return (
<div data-slot="cost-row">
<div data-slot="cost-main">
<span data-slot="pricing-model">{row.model}</span>
<span data-slot="pricing-effective">{price(eff)}</span>
<span data-slot="cost-bar-wrap">
<span data-slot="cost-bar" style={{ width: `${(eff / maxEffective) * 100}%` }} />
</span>
</div>
<div data-slot="cost-detail">
<span>Input {price(p.input)}</span>
<span>Output {price(p.output)}</span>
<span>Cached {price(p.cached)}</span>
</div>
</div>
)
}}
</For>
</div>
)}
</For>
</div>
</section>
<section data-component="rankings-section">
<h3>Session Cost</h3>
<p data-slot="subtitle">Average cost per session on OpenCode Zen.</p>
<div data-slot="pricing-table">
<div data-slot="pricing-header" data-cols="3">
<span>Model</span>
<span>Cost / Session</span>
<span>Tokens / Session</span>
</div>
<For each={sessions}>
{(row) => (
<div data-slot="pricing-row" data-cols="3">
<span data-slot="pricing-model">{row.model}</span>
<span data-slot="pricing-effective">${avg(row).toFixed(4)}</span>
<span data-slot="tps-cell" title={fmtTokens(avgTokens(row))}>
<span data-slot="tps-bar" style={{ width: `${(avgTokens(row) / maxTps) * 100}%` }} />
</span>
</div>
)}
</For>
</div>
</section>
<section data-component="rankings-section">
<h3>Token by Country</h3>
<div data-slot="map-wrap" onMouseLeave={() => setMapHover(null)}>
<svg
data-slot="world-map"
viewBox="30.767 241.591 784.077 458.627"
onMouseMove={(e) => setMapPos({ x: e.clientX, y: e.clientY })}
>
<For each={Object.entries(worldPaths)}>
{([code, d]) => (
<path
d={d}
fill={fill(code)}
stroke="var(--color-background)"
stroke-width="0.5"
onMouseEnter={() => setMapHover(code)}
onMouseLeave={() => setMapHover(null)}
/>
)}
</For>
</svg>
<Show when={mapHover() && countries[mapHover()!]}>
<div
data-slot="tooltip"
style={{
left: `${mapPos().x + 16}px`,
top: `${mapPos().y - 16}px`,
}}
>
<strong>
{flag(mapHover()!)} {names[mapHover()!] || mapHover()}
</strong>
<span data-slot="tooltip-total">{fmt(countries[mapHover()!])}</span>
</div>
</Show>
</div>
</section>
<Footer />
</div>
</div>
<Legal />
</main>
)
}

File diff suppressed because one or more lines are too long

View File

@@ -139,19 +139,16 @@ export async function handler(
const startTimestamp = Date.now()
const reqUrl = providerInfo.modifyUrl(providerInfo.api, isStream)
const reqBody = JSON.stringify(
providerInfo.modifyBody(
{
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
...Object.fromEntries(
Object.entries(providerInfo.payloadMappings ?? {})
.map(([k, v]) => [k, input.request.headers.get(v)])
.filter(([_k, v]) => !!v),
),
},
authInfo?.workspaceID,
),
providerInfo.modifyBody({
...createBodyConverter(opts.format, providerInfo.format)(body),
model: providerInfo.model,
...(providerInfo.payloadModifier ?? {}),
...Object.fromEntries(
Object.entries(providerInfo.payloadMappings ?? {})
.map(([k, v]) => [k, input.request.headers.get(v)])
.filter(([_k, v]) => !!v),
),
}),
)
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
@@ -470,15 +467,17 @@ export async function handler(
...(() => {
const providerProps = zenData.providers[modelProvider.id]
const format = providerProps.format
const providerModel = modelProvider.model
if (format === "anthropic") return anthropicHelper({ reqModel, providerModel })
if (format === "google") return googleHelper({ reqModel, providerModel })
if (format === "openai") return openaiHelper({ reqModel, providerModel })
return oaCompatHelper({
const opts = {
reqModel,
providerModel,
providerModel: modelProvider.model,
adjustCacheUsage: providerProps.adjustCacheUsage,
})
safetyIdentifier: modelProvider.safetyIdentifier ? ip : undefined,
workspaceID: authInfo?.workspaceID,
}
if (format === "anthropic") return anthropicHelper(opts)
if (format === "google") return googleHelper(opts)
if (format === "openai") return openaiHelper(opts)
return oaCompatHelper(opts)
})(),
}
}

View File

@@ -21,17 +21,18 @@ type Usage = {
}
}
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage }) => ({
export const oaCompatHelper: ProviderHelper = ({ adjustCacheUsage, safetyIdentifier }) => ({
format: "oa-compat",
modifyUrl: (providerApi: string) => providerApi + "/chat/completions",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
headers.set("x-session-affinity", headers.get("x-opencode-session") ?? "")
},
modifyBody: (body: Record<string, any>) => {
modifyBody: (body: Record<string, any>, workspaceID?: string) => {
return {
...body,
...(body.stream ? { stream_options: { include_usage: true } } : {}),
...(safetyIdentifier ? { safety_identifier: safetyIdentifier } : {}),
}
},
createBinaryStreamDecoder: () => undefined,

View File

@@ -12,13 +12,13 @@ type Usage = {
total_tokens?: number
}
export const openaiHelper: ProviderHelper = () => ({
export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({
format: "openai",
modifyUrl: (providerApi: string) => providerApi + "/responses",
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
headers.set("authorization", `Bearer ${apiKey}`)
},
modifyBody: (body: Record<string, any>, workspaceID?: string) => ({
modifyBody: (body: Record<string, any>) => ({
...body,
...(workspaceID ? { safety_identifier: workspaceID } : {}),
}),

View File

@@ -33,11 +33,17 @@ export type UsageInfo = {
cacheWrite1hTokens?: number
}
export type ProviderHelper = (input: { reqModel: string; providerModel: string; adjustCacheUsage?: boolean }) => {
export type ProviderHelper = (input: {
reqModel: string
providerModel: string
adjustCacheUsage?: boolean
safetyIdentifier?: string
workspaceID?: string
}) => {
format: ZenData.Format
modifyUrl: (providerApi: string, isStream?: boolean) => string
modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
modifyBody: (body: Record<string, any>, workspaceID?: string) => Record<string, any>
modifyBody: (body: Record<string, any>) => Record<string, any>
createBinaryStreamDecoder: () => ((chunk: Uint8Array) => Uint8Array | undefined) | undefined
streamSeparator: string
createUsageParser: () => {

View File

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

View File

@@ -0,0 +1,39 @@
import { Billing } from "../src/billing.js"
import { Database, eq } from "../src/drizzle/index.js"
import { BillingTable } from "../src/schema/billing.sql.js"
import { WorkspaceTable } from "../src/schema/workspace.sql.js"
import { microCentsToCents } from "../src/util/price.js"
// get input from command line
const workspaceID = process.argv[2]
if (!workspaceID) {
console.error("Usage: bun freeze-workspace.ts <workspaceID>")
process.exit(1)
}
// check workspace exists
const workspace = await Database.use((tx) =>
tx
.select()
.from(WorkspaceTable)
.where(eq(WorkspaceTable.id, workspaceID))
.then((rows) => rows[0]),
)
if (!workspace) {
console.error("Error: Workspace not found")
process.exit(1)
}
const billing = await Database.use((tx) =>
tx
.select()
.from(BillingTable)
.where(eq(BillingTable.workspaceID, workspaceID))
.then((rows) => rows[0]),
)
const amountInDollars = microCentsToCents(billing.balance) / 100
await Billing.grantCredit(workspaceID, 0 - amountInDollars)
console.log(`Removed payment of $${amountInDollars.toFixed(2)} from workspace ${workspaceID}`)

View File

@@ -18,8 +18,9 @@ import { ModelTable } from "../src/schema/model.sql.js"
// get input from command line
const identifier = process.argv[2]
const verbose = process.argv[process.argv.length - 1] === "-v"
if (!identifier) {
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey>")
console.error("Usage: bun lookup-user.ts <email|workspaceID|apiKey> [-v]")
process.exit(1)
}
@@ -223,93 +224,68 @@ async function printWorkspace(workspaceID: string) {
),
)
await printTable("28-Day Usage", (tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
requests: sql<number>`COUNT(*)`.as("requests"),
inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspace.id),
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
if (verbose) {
await printTable("28-Day Usage", (tx) =>
tx
.select({
date: sql<string>`DATE(${UsageTable.timeCreated})`.as("date"),
requests: sql<number>`COUNT(*)`.as("requests"),
inputTokens: sql<number>`SUM(${UsageTable.inputTokens})`.as("input_tokens"),
outputTokens: sql<number>`SUM(${UsageTable.outputTokens})`.as("output_tokens"),
reasoningTokens: sql<number>`SUM(${UsageTable.reasoningTokens})`.as("reasoning_tokens"),
cacheReadTokens: sql<number>`SUM(${UsageTable.cacheReadTokens})`.as("cache_read_tokens"),
cacheWrite5mTokens: sql<number>`SUM(${UsageTable.cacheWrite5mTokens})`.as("cache_write_5m_tokens"),
cacheWrite1hTokens: sql<number>`SUM(${UsageTable.cacheWrite1hTokens})`.as("cache_write_1h_tokens"),
cost: sql<number>`SUM(${UsageTable.cost})`.as("cost"),
})
.from(UsageTable)
.where(
and(
eq(UsageTable.workspaceID, workspace.id),
sql`${UsageTable.timeCreated} >= DATE_SUB(NOW(), INTERVAL 28 DAY)`,
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
.then((rows) => {
const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
const mapped = rows.map((row) => ({
...row,
cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
}))
if (mapped.length > 0) {
mapped.push({
date: "TOTAL",
requests: null as any,
inputTokens: null as any,
outputTokens: null as any,
reasoningTokens: null as any,
cacheReadTokens: null as any,
cacheWrite5mTokens: null as any,
cacheWrite1hTokens: null as any,
cost: `$${(totalCost / 100000000).toFixed(2)}`,
})
}
return mapped
}),
)
await printTable("Disabled Models", (tx) =>
tx
.select({
model: ModelTable.model,
timeCreated: ModelTable.timeCreated,
})
.from(ModelTable)
.where(eq(ModelTable.workspaceID, workspace.id))
.orderBy(sql`${ModelTable.timeCreated} DESC`)
.then((rows) =>
rows.map((row) => ({
model: row.model,
timeCreated: formatDate(row.timeCreated),
})),
),
)
.groupBy(sql`DATE(${UsageTable.timeCreated})`)
.orderBy(sql`DATE(${UsageTable.timeCreated}) DESC`)
.then((rows) => {
const totalCost = rows.reduce((sum, r) => sum + Number(r.cost), 0)
const mapped = rows.map((row) => ({
...row,
cost: `$${(Number(row.cost) / 100000000).toFixed(2)}`,
}))
if (mapped.length > 0) {
mapped.push({
date: "TOTAL",
requests: null as any,
inputTokens: null as any,
outputTokens: null as any,
reasoningTokens: null as any,
cacheReadTokens: null as any,
cacheWrite5mTokens: null as any,
cacheWrite1hTokens: null as any,
cost: `$${(totalCost / 100000000).toFixed(2)}`,
})
}
return mapped
}),
)
/*
await printTable("Usage", (tx) =>
tx
.select({
model: UsageTable.model,
provider: UsageTable.provider,
inputTokens: UsageTable.inputTokens,
outputTokens: UsageTable.outputTokens,
reasoningTokens: UsageTable.reasoningTokens,
cacheReadTokens: UsageTable.cacheReadTokens,
cacheWrite5mTokens: UsageTable.cacheWrite5mTokens,
cacheWrite1hTokens: UsageTable.cacheWrite1hTokens,
cost: UsageTable.cost,
timeCreated: UsageTable.timeCreated,
})
.from(UsageTable)
.where(eq(UsageTable.workspaceID, workspace.id))
.orderBy(sql`${UsageTable.timeCreated} DESC`)
.limit(10)
.then((rows) =>
rows.map((row) => ({
...row,
cost: `$${(row.cost / 100000000).toFixed(2)}`,
})),
),
)
await printTable("Disabled Models", (tx) =>
tx
.select({
model: ModelTable.model,
timeCreated: ModelTable.timeCreated,
})
.from(ModelTable)
.where(eq(ModelTable.workspaceID, workspace.id))
.orderBy(sql`${ModelTable.timeCreated} DESC`)
.then((rows) =>
rows.map((row) => ({
model: row.model,
timeCreated: formatDate(row.timeCreated),
})),
),
)
*/
)
}
}
function formatMicroCents(value: number | null | undefined) {

View File

@@ -10,7 +10,7 @@ if (!stage) throw new Error("Stage is required")
const root = path.resolve(process.cwd(), "..", "..", "..")
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
const lines = ret.split("\n")
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
if (!value) throw new Error("ZEN_LIMITS not found")

View File

@@ -12,7 +12,7 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
const PARTS = 30
// read the secret
const ret = await $`bun sst secret list`.cwd(root).text()
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
const lines = ret.split("\n")
const values = Array.from({ length: PARTS }, (_, i) => {
const value = lines

View File

@@ -6,7 +6,7 @@ import os from "os"
import { Subscription } from "../src/subscription"
const root = path.resolve(process.cwd(), "..", "..", "..")
const secrets = await $`bun sst secret list`.cwd(root).text()
const secrets = await $`bun sst secret list --stage frank`.cwd(root).text()
// read value
const lines = secrets.split("\n")
@@ -25,4 +25,4 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
Subscription.validate(JSON.parse(newValue))
// update the secret
await $`bun sst secret set ZEN_LIMITS ${newValue}`
await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)

View File

@@ -6,7 +6,7 @@ import os from "os"
import { ZenData } from "../src/model"
const root = path.resolve(process.cwd(), "..", "..", "..")
const models = await $`bun sst secret list`.cwd(root).text()
const models = await $`bun sst secret list --stage frank`.cwd(root).text()
const PARTS = 30
// read the line starting with "ZEN_MODELS"
@@ -40,4 +40,4 @@ const newValues = Array.from({ length: PARTS }, (_, i) =>
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
await $`bun sst secret load ${envFile.name}`.cwd(root)
await $`bun sst secret load ${envFile.name} --stage frank`.cwd(root)

View File

@@ -37,6 +37,7 @@ export namespace ZenData {
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
payloadModifier: z.record(z.string(), z.any()).optional(),
safetyIdentifier: z.boolean().optional(),
}),
),
})

View File

@@ -1,3 +1,7 @@
export function centsToMicroCents(amount: number) {
return Math.round(amount * 1000000)
}
export function microCentsToCents(amount: number) {
return Math.round(amount / 1000000)
}

View File

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

View File

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

View File

@@ -1,5 +1,25 @@
import { execFile } from "node:child_process"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
import type { Configuration } from "electron-builder"
const execFileAsync = promisify(execFile)
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..")
const signScript = path.join(rootDir, "script", "sign-windows.ps1")
async function signWindows(configuration: { path: string }) {
if (process.platform !== "win32") return
if (process.env.GITHUB_ACTIONS !== "true") return
await execFileAsync(
"pwsh",
["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", signScript, configuration.path],
{ cwd: rootDir },
)
}
const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
@@ -44,6 +64,9 @@ const getBase = (): Configuration => ({
},
win: {
icon: `resources/icons/icon.ico`,
signtoolOptions: {
sign: signWindows,
},
target: ["nsis"],
},
nsis: {

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.3",
"version": "1.3.9",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -13,11 +13,12 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
console.log(`Updated package.json version to ${Script.version}`)
const sidecarConfig = getCurrentSidecar()
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
const dir = "resources/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))

View File

@@ -63,6 +63,9 @@ export async function copyBinaryToSidecarFolder(source: string) {
await $`mkdir -p ${dir}`
const dest = windowsify(`${dir}/opencode-cli`)
await $`cp ${source} ${dest}`
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
}
if (process.platform === "darwin") await $`codesign --force --sign - ${dest}`
console.log(`Copied ${source} to ${dest}`)

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.3.3",
"version": "1.3.9",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -10,10 +10,11 @@ await Bun.write("./package.json", JSON.stringify(pkg, null, 2) + "\n")
console.log(`Updated package.json version to ${Script.version}`)
const sidecarConfig = getCurrentSidecar()
const artifact = process.env.OPENCODE_CLI_ARTIFACT ?? "opencode-cli"
const dir = "src-tauri/target/opencode-binaries"
await $`mkdir -p ${dir}`
await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
await $`gh run download ${process.env.GITHUB_RUN_ID} -n ${artifact}`.cwd(dir)
await copyBinaryToSidecarFolder(windowsify(`${dir}/${sidecarConfig.ocBinary}/bin/opencode`))

View File

@@ -48,6 +48,9 @@ export async function copyBinaryToSidecarFolder(source: string, target = RUST_TA
await $`mkdir -p src-tauri/sidecars`
const dest = windowsify(`src-tauri/sidecars/opencode-cli-${target}`)
await $`cp ${source} ${dest}`
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
}
console.log(`Copied ${source} to ${dest}`)
}

View File

@@ -12,6 +12,10 @@
"icons/beta/icon.ico"
],
"windows": {
"signCommand": {
"cmd": "powershell",
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
},
"nsis": {
"installerIcon": "icons/beta/icon.ico"
}

View File

@@ -45,6 +45,10 @@
"entitlements": "./entitlements.plist"
},
"windows": {
"signCommand": {
"cmd": "powershell",
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
},
"nsis": {
"installerIcon": "icons/dev/icon.ico",
"headerImage": "assets/nsis-header.bmp",

View File

@@ -12,6 +12,10 @@
"icons/prod/icon.ico"
],
"windows": {
"signCommand": {
"cmd": "powershell",
"args": ["-ExecutionPolicy", "Bypass", "-File", "../../../script/sign-windows.ps1", "%1"]
},
"nsis": {
"installerIcon": "icons/prod/icon.ico"
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.3",
"version": "1.3.9",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.3.3"
version = "1.3.9"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.9/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.9/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.9/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.9/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.9/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

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

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.3.3",
"version": "1.3.9",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -10,6 +10,7 @@
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"build": "bun run script/build.ts",
"upgrade-opentui": "bun run script/upgrade-opentui.ts",
"dev": "bun run --conditions=browser ./src/index.ts",
"random": "echo 'Random script updated at $(date)' && echo 'Change queued successfully' && echo 'Another change made' && echo 'Yet another change' && echo 'One more change' && echo 'Final change' && echo 'Another final change' && echo 'Yet another final change'",
"clean": "echo 'Cleaning up...' && rm -rf node_modules dist",
@@ -86,7 +87,7 @@
"@ai-sdk/provider-utils": "4.0.21",
"@ai-sdk/togetherai": "2.0.41",
"@ai-sdk/vercel": "2.0.39",
"@ai-sdk/xai": "3.0.74",
"@ai-sdk/xai": "3.0.75",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "catalog:",
@@ -101,8 +102,8 @@
"@opencode-ai/sdk": "workspace:*",
"@opencode-ai/util": "workspace:*",
"@openrouter/ai-sdk-provider": "2.3.3",
"@opentui/core": "0.1.90",
"@opentui/solid": "0.1.90",
"@opentui/core": "0.1.92",
"@opentui/solid": "0.1.92",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
@@ -132,7 +133,7 @@
"mime-types": "3.0.2",
"minimatch": "10.0.3",
"open": "10.1.2",
"opencode-gitlab-auth": "2.0.0",
"opencode-gitlab-auth": "2.0.1",
"opencode-poe-auth": "0.0.1",
"opentui-spinner": "0.0.6",
"partial-json": "0.1.7",
@@ -141,6 +142,7 @@
"solid-js": "catalog:",
"strip-ansi": "7.1.2",
"tree-sitter-bash": "0.25.0",
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"vscode-jsonrpc": "8.2.1",

View File

@@ -0,0 +1,64 @@
#!/usr/bin/env bun
import path from "node:path"
const raw = process.argv[2]
if (!raw) {
console.error("Usage: bun run script/upgrade-opentui.ts <version>")
process.exit(1)
}
const ver = raw.replace(/^v/, "")
const root = path.resolve(import.meta.dir, "../../..")
const skip = new Set([".git", ".opencode", ".turbo", "dist", "node_modules"])
const keys = ["@opentui/core", "@opentui/solid"] as const
const files = (await Array.fromAsync(new Bun.Glob("**/package.json").scan({ cwd: root }))).filter(
(file) => !file.split("/").some((part) => skip.has(part)),
)
const set = (cur: string) => {
if (cur.startsWith(">=")) return `>=${ver}`
if (cur.startsWith("^")) return `^${ver}`
if (cur.startsWith("~")) return `~${ver}`
return ver
}
const edit = (obj: unknown) => {
if (!obj || typeof obj !== "object") return false
const map = obj as Record<string, unknown>
return keys
.map((key) => {
const cur = map[key]
if (typeof cur !== "string") return false
const next = set(cur)
if (next === cur) return false
map[key] = next
return true
})
.some(Boolean)
}
const out = (
await Promise.all(
files.map(async (rel) => {
const file = path.join(root, rel)
const txt = await Bun.file(file).text()
const json = JSON.parse(txt)
const hit = [json.dependencies, json.devDependencies, json.peerDependencies].map(edit).some(Boolean)
if (!hit) return null
await Bun.write(file, `${JSON.stringify(json, null, 2)}\n`)
return rel
}),
)
).filter((item): item is string => item !== null)
if (out.length === 0) {
console.log("No opentui deps found")
process.exit(0)
}
console.log(`Updated opentui to ${ver} in:`)
for (const file of out) {
console.log(`- ${file}`)
}

View File

@@ -210,15 +210,13 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
- [x] `Vcs``project/vcs.ts`
- [x] `Worktree``worktree/index.ts`
Still open and likely worth migrating:
- [x] `Session``session/index.ts`
- [ ] `SessionProcessor`blocked by AI SDK v6 PR (#18433)
- [ ] `SessionPrompt`blocked by AI SDK v6 PR (#18433)
- [ ] `SessionCompaction`blocked by AI SDK v6 PR (#18433)
- [ ] `Provider`blocked by AI SDK v6 PR (#18433)
- [x] `SessionProcessor``session/processor.ts`
- [x] `SessionPrompt``session/prompt.ts`
- [x] `SessionCompaction``session/compaction.ts`
- [x] `Provider``provider/provider.ts`
Other services not yet migrated:
Still open:
- [ ] `SessionSummary``session/summary.ts`
- [ ] `SessionTodo``session/todo.ts`
@@ -235,7 +233,7 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
1. Migrate each tool to return Effects
2. Update `Tool.define()` factory to work with Effects
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
Individual tools, ordered by value:
@@ -277,6 +275,7 @@ Some services are effectified but still use raw `Filesystem.*` or `Process.spawn
Similarly, **28 files** still import raw `fs` or `fs/promises` directly. These should migrate to `AppFileSystem` or `Filesystem.*` as they're touched.
Current raw fs users that will convert during tool migration:
- `tool/read.ts` — fs.createReadStream, readline
- `tool/apply_patch.ts` — fs/promises
- `tool/bash.ts` — fs/promises

View File

@@ -8,6 +8,8 @@ Technical reference for the current TUI plugin system.
- Author package entrypoint is `@opencode-ai/plugin/tui`.
- Internal plugins load inside the CLI app the same way external TUI plugins do.
- Package plugins can be installed from CLI or TUI.
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
## TUI config
@@ -27,6 +29,7 @@ Example:
- `plugin` entries can be either a string spec or `[spec, options]`.
- Plugin specs can be npm specs, `file://` URLs, relative paths, or absolute paths.
- Relative path specs are resolved relative to the config file that declared them.
- A file module listed in `tui.json` must be a TUI module (`default export { id?, tui }`) and must not export `server`.
- Duplicate npm plugins are deduped by package name; higher-precedence config wins.
- Duplicate file plugins are deduped by exact resolved file spec. This happens while merging config, before plugin modules are loaded.
- `plugin_enabled` is keyed by plugin id, not by plugin spec.
@@ -46,7 +49,7 @@ Minimal module shape:
```tsx
/** @jsxImportSource @opentui/solid */
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
const tui: TuiPlugin = async (api, options, meta) => {
api.command.register(() => [
@@ -69,20 +72,30 @@ const tui: TuiPlugin = async (api, options, meta) => {
])
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id: "acme.demo",
tui,
}
export default plugin
```
- Loader only reads the module default export object. Named exports are ignored.
- TUI shape is `default export { id?, tui }`.
- TUI shape is `default export { id?, tui }`; including `server` is rejected.
- A single module cannot export both `server` and `tui`.
- `tui` signature is `(api, options, meta) => Promise<void>`.
- If package `exports` contains `./tui`, the loader resolves that entrypoint. Otherwise it uses the resolved package target.
- If package `exports` contains `./tui`, the loader resolves that entrypoint.
- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
- `package.json` `main` is only used for server plugin entrypoint resolution.
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
- File/path plugins must export a non-empty `id`.
- npm plugins may omit `id`; package `name` is used.
- Runtime identity is the resolved plugin id. Later plugins with the same id are rejected, including collisions with internal plugin ids.
- If a path spec points at a directory, that directory must have `package.json` with `main`.
- If a path spec points at a directory, server loading can use `package.json` `main`.
- TUI path loading never uses `package.json` `main`.
- Legacy compatibility: path specs like `./plugin` can resolve to `./plugin/index.ts` (or `index.js`) when `package.json` is missing.
- The `./plugin -> ./plugin/index.*` fallback applies to both server and TUI v1 loading.
- There is no directory auto-discovery for TUI plugins; they must be listed in `tui.json`.
## Package manifest and install
@@ -133,10 +146,13 @@ npm plugins can declare a version compatibility range in `package.json` using th
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
- Without `--force`, an already-configured npm package name is a no-op.
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
- Tuple targets in `oc-plugin` provide default options written into config.
- A package can target `server`, `tui`, or both.
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
- There is no uninstall, list, or update CLI command for external plugins.
- Local file plugins are configured directly in `tui.json`.
@@ -156,7 +172,7 @@ Top-level API groups exposed to `tui(api, options, meta)`:
- `api.app.version`
- `api.command.register(cb)` / `api.command.trigger(value)`
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `ui.toast`, `ui.dialog`
- `api.ui.Dialog`, `DialogAlert`, `DialogConfirm`, `DialogPrompt`, `DialogSelect`, `Prompt`, `ui.toast`, `ui.dialog`
- `api.keybind.match`, `print`, `create`
- `api.tuiConfig`
- `api.kv.get`, `set`, `ready`
@@ -202,6 +218,7 @@ Command behavior:
- `ui.Dialog` is the base dialog wrapper.
- `ui.DialogAlert`, `ui.DialogConfirm`, `ui.DialogPrompt`, `ui.DialogSelect` are built-in dialog components.
- `ui.Prompt` renders the same prompt component used by the host app.
- `ui.toast(...)` shows a toast.
- `ui.dialog` exposes the host dialog stack:
- `replace(render, onClose?)`
@@ -258,7 +275,9 @@ Theme install behavior:
- Relative theme paths are resolved from the plugin root.
- Theme name is the JSON basename.
- Install is skipped if that theme name already exists.
- First install writes only when the destination file is missing.
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed.
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
- Global plugins persist installed themes under the global `themes` dir.
- Invalid or unreadable theme files are ignored.
@@ -269,6 +288,7 @@ Current host slot names:
- `app`
- `home_logo`
- `home_prompt` with props `{ workspace_id? }`
- `home_bottom`
- `sidebar_title` with props `{ session_id, title, share_url? }`
- `sidebar_content` with props `{ session_id }`
@@ -281,7 +301,7 @@ Slot notes:
- `api.slots.register(plugin)` does not return an unregister function.
- Returned ids are `pluginId`, `pluginId:1`, `pluginId:2`, and so on.
- Plugin-provided `id` is not allowed.
- The current host renders `home_logo` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
- The current host renders `home_logo` and `home_prompt` with `replace`, `sidebar_title` and `sidebar_footer` with `single_winner`, and `app`, `home_bottom`, and `sidebar_content` with the slot library default mode.
- Plugins cannot define new slot names in this branch.
### Plugin control and lifecycle

View File

@@ -393,7 +393,7 @@ export namespace Agent {
)
export const defaultLayer = layer.pipe(
Layer.provide(Auth.layer),
Layer.provide(Auth.defaultLayer),
Layer.provide(Config.defaultLayer),
Layer.provide(Skill.defaultLayer),
)

View File

@@ -3,7 +3,7 @@ import { Effect, Layer, Record, Result, Schema, ServiceMap } from "effect"
import { makeRuntime } from "@/effect/run-service"
import { zod } from "@/util/effect-zod"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { AppFileSystem } from "../filesystem"
export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
@@ -53,17 +53,13 @@ export namespace Auth {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fsys = yield* AppFileSystem.Service
const decode = Schema.decodeUnknownOption(Info)
const all = Effect.fn("Auth.all")(() =>
Effect.tryPromise({
try: async () => {
const data = await Filesystem.readJson<Record<string, unknown>>(file).catch(() => ({}))
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
},
catch: fail("Failed to read auth data"),
}),
)
const all = Effect.fn("Auth.all")(function* () {
const data = (yield* fsys.readJson(file).pipe(Effect.orElseSucceed(() => ({})))) as Record<string, unknown>
return Record.filterMap(data, (value) => Result.fromOption(decode(value), () => undefined))
})
const get = Effect.fn("Auth.get")(function* (providerID: string) {
return (yield* all())[providerID]
@@ -74,10 +70,9 @@ export namespace Auth {
const data = yield* all()
if (norm !== key) delete data[key]
delete data[norm + "/"]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, { ...data, [norm]: info }, 0o600),
catch: fail("Failed to write auth data"),
})
yield* fsys
.writeJson(file, { ...data, [norm]: info }, 0o600)
.pipe(Effect.mapError(fail("Failed to write auth data")))
})
const remove = Effect.fn("Auth.remove")(function* (key: string) {
@@ -85,17 +80,16 @@ export namespace Auth {
const data = yield* all()
delete data[key]
delete data[norm]
yield* Effect.tryPromise({
try: () => Filesystem.writeJson(file, data, 0o600),
catch: fail("Failed to write auth data"),
})
yield* fsys.writeJson(file, data, 0o600).pipe(Effect.mapError(fail("Failed to write auth data")))
})
return Service.of({ get, all, set, remove })
}),
)
const { runPromise } = makeRuntime(Service, layer)
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
const { runPromise } = makeRuntime(Service, defaultLayer)
export async function get(providerID: string) {
return runPromise((service) => service.get(providerID))

View File

@@ -121,6 +121,7 @@ async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
}
import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
return {
@@ -584,11 +585,22 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
value: "variant.cycle",
keybind: "variant_cycle",
category: "Agent",
hidden: true,
onSelect: () => {
local.model.variant.cycle()
},
},
{
title: "Switch model variant",
value: "variant.list",
category: "Agent",
hidden: local.model.variant.list().length === 0,
slash: {
name: "variants",
},
onSelect: () => {
dialog.replace(() => <DialogVariant />)
},
},
{
title: "Agent cycle reverse",
value: "agent.cycle.reverse",

View File

@@ -5,6 +5,7 @@ import { map, pipe, flatMap, entries, filter, sortBy, take } from "remeda"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
@@ -50,8 +51,7 @@ export function DialogModel(props: { providerID?: string }) {
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect: () => {
dialog.clear()
local.model.set({ providerID: provider.id, modelID: model.id }, { recent: true })
onSelect(provider.id, model.id)
},
},
]
@@ -88,8 +88,7 @@ export function DialogModel(props: { providerID?: string }) {
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
dialog.clear()
local.model.set({ providerID: provider.id, modelID: model }, { recent: true })
onSelect(provider.id, model)
},
})),
filter((x) => {
@@ -135,6 +134,21 @@ export function DialogModel(props: { providerID?: string }) {
const title = createMemo(() => provider()?.name ?? "Select model")
function onSelect(providerID: string, modelID: string) {
local.model.set({ providerID, modelID }, { recent: true })
const list = local.model.variant.list()
const cur = local.model.variant.selected()
if (cur === "default" || (cur && list.includes(cur))) {
dialog.clear()
return
}
if (list.length > 0) {
dialog.replace(() => <DialogVariant />)
return
}
dialog.clear()
}
return (
<DialogSelect<ReturnType<typeof options>[number]["value"]>
options={options()}

View File

@@ -0,0 +1,39 @@
import { createMemo } from "solid-js"
import { useLocal } from "@tui/context/local"
import { DialogSelect } from "@tui/ui/dialog-select"
import { useDialog } from "@tui/ui/dialog"
export function DialogVariant() {
const local = useLocal()
const dialog = useDialog()
const options = createMemo(() => {
return [
{
value: "default",
title: "Default",
onSelect: () => {
dialog.clear()
local.model.variant.set(undefined)
},
},
...local.model.variant.list().map((variant) => ({
value: variant,
title: variant,
onSelect: () => {
dialog.clear()
local.model.variant.set(variant)
},
})),
]
})
return (
<DialogSelect<string>
options={options()}
title={"Select variant"}
current={local.model.variant.selected()}
flat={true}
/>
)
}

View File

@@ -5,7 +5,7 @@ import path from "path"
import { Filesystem } from "@/util/filesystem"
import { useLocal } from "@tui/context/local"
import { useTheme } from "@tui/context/theme"
import { EmptyBorder } from "@tui/component/border"
import { EmptyBorder, SplitBorder } from "@tui/component/border"
import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useSync } from "@tui/context/sync"
@@ -22,7 +22,7 @@ import { useKeyboard, useRenderer } from "@opentui/solid"
import { Editor } from "@tui/util/editor"
import { useExit } from "../../context/exit"
import { Clipboard } from "../../util/clipboard"
import type { FilePart } from "@opencode-ai/sdk/v2"
import type { AssistantMessage, FilePart } from "@opencode-ai/sdk/v2"
import { TuiEvent } from "../../event"
import { iife } from "@/util/iife"
import { Locale } from "@/util/locale"
@@ -45,6 +45,10 @@ export type PromptProps = {
ref?: (ref: PromptRef) => void
hint?: JSX.Element
showPlaceholder?: boolean
placeholders?: {
normal?: string[]
shell?: string[]
}
}
export type PromptRef = {
@@ -57,8 +61,15 @@ export type PromptRef = {
submit(): void
}
const PLACEHOLDERS = ["Fix a TODO in the codebase", "What is the tech stack of this project?", "Fix broken tests"]
const SHELL_PLACEHOLDERS = ["ls -la", "git status", "pwd"]
const money = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
function randomIndex(count: number) {
if (count <= 0) return 0
return Math.floor(Math.random() * count)
}
export function Prompt(props: PromptProps) {
let input: TextareaRenderable
@@ -79,6 +90,8 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
function promptModelWarning() {
toast.show({
@@ -122,6 +135,25 @@ export function Prompt(props: PromptProps) {
return messages.findLast((m) => m.role === "user")
})
const usage = createMemo(() => {
if (!props.sessionID) return
const msg = sync.data.message[props.sessionID] ?? []
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
if (!last) return
const tokens =
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
if (tokens <= 0) return
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
const cost = msg.reduce((sum, item) => sum + (item.role === "assistant" ? item.cost : 0), 0)
return {
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
cost: cost > 0 ? money.format(cost) : undefined,
}
})
const [store, setStore] = createStore<{
prompt: PromptInfo
mode: "normal" | "shell"
@@ -129,7 +161,7 @@ export function Prompt(props: PromptProps) {
interrupt: number
placeholder: number
}>({
placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
placeholder: randomIndex(list().length),
prompt: {
input: "",
parts: [],
@@ -143,7 +175,7 @@ export function Prompt(props: PromptProps) {
on(
() => props.sessionID,
() => {
setStore("placeholder", Math.floor(Math.random() * PLACEHOLDERS.length))
setStore("placeholder", randomIndex(list().length))
},
{ defer: true },
),
@@ -778,12 +810,14 @@ export function Prompt(props: PromptProps) {
})
const placeholderText = createMemo(() => {
if (props.sessionID) return undefined
if (props.showPlaceholder === false) return undefined
if (store.mode === "shell") {
const example = SHELL_PLACEHOLDERS[store.placeholder % SHELL_PLACEHOLDERS.length]
if (!shell().length) return undefined
const example = shell()[store.placeholder % shell().length]
return `Run a command... "${example}"`
}
return `Ask anything... "${PLACEHOLDERS[store.placeholder % PLACEHOLDERS.length]}"`
if (!list().length) return undefined
return `Ask anything... "${list()[store.placeholder % list().length]}"`
})
const spinnerDef = createMemo(() => {
@@ -833,8 +867,7 @@ export function Prompt(props: PromptProps) {
border={["left"]}
borderColor={highlight()}
customBorderChars={{
...EmptyBorder,
vertical: "┃",
...SplitBorder.customBorderChars,
bottomLeft: "╹",
}}
>
@@ -848,6 +881,7 @@ export function Prompt(props: PromptProps) {
>
<textarea
placeholder={placeholderText()}
placeholderColor={theme.textMuted}
textColor={keybind.leader ? theme.textMuted : theme.text}
focusedTextColor={keybind.leader ? theme.textMuted : theme.text}
minHeight={1}
@@ -899,7 +933,7 @@ export function Prompt(props: PromptProps) {
}
}
if (e.name === "!" && input.visualCursor.offset === 0) {
setStore("placeholder", Math.floor(Math.random() * SHELL_PLACEHOLDERS.length))
setStore("placeholder", randomIndex(shell().length))
setStore("mode", "shell")
e.preventDefault()
return
@@ -1074,7 +1108,7 @@ export function Prompt(props: PromptProps) {
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={<text />}>
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"
gap={1}
@@ -1158,14 +1192,20 @@ export function Prompt(props: PromptProps) {
<box gap={2} flexDirection="row">
<Switch>
<Match when={store.mode === "normal"}>
<Show when={local.model.variant.list().length > 0}>
<text fg={theme.text}>
{keybind.print("variant_cycle")} <span style={{ fg: theme.textMuted }}>variants</span>
</text>
</Show>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
<Switch>
<Match when={usage()}>
{(item) => (
<text fg={theme.textMuted} wrapMode="none">
{[item().context, item().cost].filter(Boolean).join(" · ")}
</text>
)}
</Match>
<Match when={true}>
<text fg={theme.text}>
{keybind.print("agent_cycle")} <span style={{ fg: theme.textMuted }}>agents</span>
</text>
</Match>
</Switch>
<text fg={theme.text}>
{keybind.print("command_list")} <span style={{ fg: theme.textMuted }}>commands</span>
</text>

View File

@@ -321,12 +321,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
},
variant: {
current() {
selected() {
const m = currentModel()
if (!m) return undefined
const key = `${m.providerID}/${m.modelID}`
return modelStore.variant[key]
},
current() {
const v = this.selected()
if (!v) return undefined
if (!this.list().includes(v)) return undefined
return v
},
list() {
const m = currentModel()
if (!m) return []
@@ -339,7 +345,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const m = currentModel()
if (!m) return
const key = `${m.providerID}/${m.modelID}`
setModelStore("variant", key, value)
setModelStore("variant", key, value ?? "default")
save()
},
cycle() {

View File

@@ -183,6 +183,18 @@ export function addTheme(name: string, theme: unknown) {
return true
}
export function upsertTheme(name: string, theme: unknown) {
if (!name) return false
if (!isTheme(theme)) return false
if (customThemes[name] !== undefined) {
customThemes[name] = theme
} else {
pluginThemes[name] = theme
}
syncThemes()
return true
}
export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
const defs = theme.defs ?? {}
function resolveColor(c: ColorValue, chain: string[] = []): RGBA {
@@ -399,7 +411,16 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})
const values = createMemo(() => {
return resolveTheme(store.themes[store.active] ?? store.themes.opencode, store.mode)
const active = store.themes[store.active]
if (active) return resolveTheme(active, store.mode)
const saved = kv.get("theme")
if (typeof saved === "string") {
const theme = store.themes[saved]
if (theme) return resolveTheme(theme, store.mode)
}
return resolveTheme(store.themes.opencode, store.mode)
})
createEffect(() => {

View File

@@ -62,8 +62,8 @@
"light": "frappeText"
},
"textMuted": {
"dark": "frappeSubtext1",
"light": "frappeSubtext1"
"dark": "frappeOverlay2",
"light": "frappeOverlay2"
},
"background": {
"dark": "frappeBase",

View File

@@ -62,8 +62,8 @@
"light": "macText"
},
"textMuted": {
"dark": "macSubtext1",
"light": "macSubtext1"
"dark": "macOverlay2",
"light": "macOverlay2"
},
"background": {
"dark": "macBase",

View File

@@ -63,7 +63,7 @@
"success": { "dark": "darkGreen", "light": "lightGreen" },
"info": { "dark": "darkTeal", "light": "lightTeal" },
"text": { "dark": "darkText", "light": "lightText" },
"textMuted": { "dark": "darkSubtext1", "light": "lightSubtext1" },
"textMuted": { "dark": "darkOverlay2", "light": "lightOverlay2" },
"background": { "dark": "darkBase", "light": "lightBase" },
"backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" },
"backgroundElement": { "dark": "darkCrust", "light": "lightCrust" },

View File

@@ -0,0 +1,93 @@
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Match, Show, Switch } from "solid-js"
import { Global } from "@/global"
const id = "internal:home-footer"
function Directory(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const dir = createMemo(() => {
const dir = props.api.state.path.directory || process.cwd()
const out = dir.replace(Global.Path.home, "~")
const branch = props.api.state.vcs?.branch
if (branch) return out + ":" + branch
return out
})
return <text fg={theme().textMuted}>{dir()}</text>
}
function Mcp(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
const list = createMemo(() => props.api.state.mcp())
const has = createMemo(() => list().length > 0)
const err = createMemo(() => list().some((item) => item.status === "failed"))
const count = createMemo(() => list().filter((item) => item.status === "connected").length)
return (
<Show when={has()}>
<box gap={1} flexDirection="row" flexShrink={0}>
<text fg={theme().text}>
<Switch>
<Match when={err()}>
<span style={{ fg: theme().error }}> </span>
</Match>
<Match when={true}>
<span style={{ fg: count() > 0 ? theme().success : theme().textMuted }}> </span>
</Match>
</Switch>
{count()} MCP
</text>
<text fg={theme().textMuted}>/status</text>
</box>
</Show>
)
}
function Version(props: { api: TuiPluginApi }) {
const theme = () => props.api.theme.current
return (
<box flexShrink={0}>
<text fg={theme().textMuted}>{props.api.app.version}</text>
</box>
)
}
function View(props: { api: TuiPluginApi }) {
return (
<box
width="100%"
paddingTop={1}
paddingBottom={1}
paddingLeft={2}
paddingRight={2}
flexDirection="row"
flexShrink={0}
gap={2}
>
<Directory api={props.api} />
<Mcp api={props.api} />
<box flexGrow={1} />
<Version api={props.api} />
</box>
)
}
const tui: TuiPlugin = async (api) => {
api.slots.register({
order: 100,
slots: {
home_footer() {
return <View api={api} />
},
},
})
}
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,4 +1,4 @@
import type { TuiPlugin } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { Tips } from "./tips-view"
@@ -42,7 +42,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,5 +1,5 @@
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo } from "solid-js"
const id = "internal:sidebar-context"
@@ -55,7 +55,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-files"
@@ -54,7 +54,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, Show } from "solid-js"
import { Global } from "@/global"
@@ -85,7 +85,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Show, createSignal } from "solid-js"
const id = "internal:sidebar-lsp"
@@ -58,7 +58,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

View File

@@ -1,4 +1,4 @@
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui"
import { createMemo, For, Match, Show, Switch, createSignal } from "solid-js"
const id = "internal:sidebar-mcp"
@@ -88,7 +88,9 @@ const tui: TuiPlugin = async (api) => {
})
}
export default {
const plugin: TuiPluginModule & { id: string } = {
id,
tui,
}
export default plugin

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