Przeglądaj źródła

Merge remote-tracking branch 'public/dev' into ide-plugin

# Conflicts:
#	.gitignore
#	bun.lock
#	packages/opencode/src/project/state.ts
#	packages/opencode/src/server/server.ts
paviko 4 miesięcy temu
rodzic
commit
a820720b57
100 zmienionych plików z 2308 dodań i 519 usunięć
  1. 4 0
      .github/workflows/deploy.yml
  2. 1 1
      .github/workflows/duplicate-issues.yml
  3. 5 1
      .github/workflows/opencode.yml
  4. 7 0
      .github/workflows/publish.yml
  5. 4 1
      .github/workflows/snapshot.yml
  6. 26 3
      .github/workflows/update-nix-hashes.yml
  7. 1 0
      .gitignore
  8. 7 0
      .husky/pre-push
  9. 0 1
      .opencode/command/commit.md
  10. 16 0
      .opencode/opencode.jsonc
  11. 1 1
      README.md
  12. 10 0
      STATS.md
  13. 0 0
      a.out
  14. 101 72
      bun.lock
  15. 3 3
      flake.lock
  16. 26 0
      github/README.md
  17. 44 7
      github/index.ts
  18. 10 4
      infra/console.ts
  19. 17 0
      infra/enterprise.ts
  20. 4 0
      infra/secret.ts
  21. 199 28
      install
  22. 40 0
      nix/bundle.ts
  23. 1 1
      nix/hashes.json
  24. 61 34
      nix/opencode.nix
  25. 24 7
      nix/scripts/canonicalize-node-modules.ts
  26. 39 0
      nix/scripts/patch-wasm.ts
  27. 15 11
      package.json
  28. 0 1
      packages/console/app/.gitignore
  29. 0 23
      packages/console/app/app.config.ts
  30. 13 9
      packages/console/app/package.json
  31. 1 0
      packages/console/app/public/apple-touch-icon.png
  32. 1 0
      packages/console/app/public/favicon-96x96.png
  33. 0 23
      packages/console/app/public/favicon-zen.svg
  34. 1 0
      packages/console/app/public/favicon.ico
  35. 0 4
      packages/console/app/public/favicon.svg
  36. 1 0
      packages/console/app/public/favicon.svg
  37. 2 1
      packages/console/app/public/robots.txt
  38. 1 0
      packages/console/app/public/site.webmanifest
  39. 1 0
      packages/console/app/public/web-app-manifest-192x192.png
  40. 1 0
      packages/console/app/public/web-app-manifest-512x512.png
  41. 3 1
      packages/console/app/src/app.tsx
  42. 12 1
      packages/console/app/src/component/icon.tsx
  43. 1 1
      packages/console/app/src/context/auth.session.ts
  44. 0 1
      packages/console/app/src/entry-server.tsx
  45. 4 0
      packages/console/app/src/global.d.ts
  46. 2 2
      packages/console/app/src/middleware.ts
  47. 1 0
      packages/console/app/src/routes/api/enterprise.ts
  48. 1 0
      packages/console/app/src/routes/auth/callback.ts
  49. 17 0
      packages/console/app/src/routes/auth/logout.ts
  50. 7 0
      packages/console/app/src/routes/auth/status.ts
  51. 2 3
      packages/console/app/src/routes/index.tsx
  52. 1 0
      packages/console/app/src/routes/user-menu.css
  53. 5 8
      packages/console/app/src/routes/user-menu.tsx
  54. 0 2
      packages/console/app/src/routes/workspace.tsx
  55. 2 2
      packages/console/app/src/routes/workspace/[id]/model-section.tsx
  56. 1 0
      packages/console/app/src/routes/workspace/[id]/provider-section.tsx
  57. 67 0
      packages/console/app/src/routes/workspace/[id]/usage-section.module.css
  58. 91 5
      packages/console/app/src/routes/workspace/[id]/usage-section.tsx
  59. 12 5
      packages/console/app/src/routes/zen/index.tsx
  60. 26 0
      packages/console/app/src/routes/zen/util/dataDumper.ts
  61. 59 15
      packages/console/app/src/routes/zen/util/handler.ts
  62. 10 8
      packages/console/app/src/routes/zen/util/provider/provider.ts
  63. 43 0
      packages/console/app/src/routes/zen/util/trialLimiter.ts
  64. 1 1
      packages/console/app/tsconfig.json
  65. 25 0
      packages/console/app/vite.config.ts
  66. 8 0
      packages/console/core/migrations/0038_famous_magik.sql
  67. 981 0
      packages/console/core/migrations/meta/0038_snapshot.json
  68. 7 0
      packages/console/core/migrations/meta/_journal.json
  69. 2 1
      packages/console/core/package.json
  70. 10 9
      packages/console/core/script/promote-models.ts
  71. 31 0
      packages/console/core/script/pull-models.ts
  72. 17 12
      packages/console/core/script/update-models.ts
  73. 2 0
      packages/console/core/src/aws.ts
  74. 10 1
      packages/console/core/src/model.ts
  75. 12 0
      packages/console/core/src/schema/ip.sql.ts
  76. 18 0
      packages/console/core/sst-env.d.ts
  77. 1 1
      packages/console/function/package.json
  78. 18 0
      packages/console/function/sst-env.d.ts
  79. 1 1
      packages/console/mail/package.json
  80. 1 0
      packages/console/resource/resource.cloudflare.ts
  81. 51 39
      packages/console/resource/resource.node.ts
  82. 18 0
      packages/console/resource/sst-env.d.ts
  83. 9 2
      packages/desktop/index.html
  84. 4 3
      packages/desktop/package.json
  85. 1 0
      packages/desktop/public/apple-touch-icon.png
  86. 1 0
      packages/desktop/public/favicon-96x96.png
  87. 1 0
      packages/desktop/public/favicon.ico
  88. 1 0
      packages/desktop/public/site.webmanifest
  89. BIN
      packages/desktop/public/social-share.png
  90. 1 0
      packages/desktop/public/web-app-manifest-192x192.png
  91. 1 0
      packages/desktop/public/web-app-manifest-512x512.png
  92. 4 2
      packages/desktop/src/components/file-tree.tsx
  93. 11 6
      packages/desktop/src/components/prompt-input.tsx
  94. 0 104
      packages/desktop/src/components/session-review.tsx
  95. 0 17
      packages/desktop/src/components/sticky-accordion-header.tsx
  96. 1 1
      packages/desktop/src/context/global-sdk.tsx
  97. 2 2
      packages/desktop/src/context/global-sync.tsx
  98. 0 25
      packages/desktop/src/context/helper.tsx
  99. 1 1
      packages/desktop/src/context/layout.tsx
  100. 1 1
      packages/desktop/src/context/local.tsx

+ 4 - 0
.github/workflows/deploy.yml

@@ -17,6 +17,10 @@ jobs:
 
 
       - uses: ./.github/actions/setup-bun
       - uses: ./.github/actions/setup-bun
 
 
+      - uses: actions/setup-node@v4
+        with:
+          node-version: "24"
+
       - run: bun sst deploy --stage=${{ github.ref_name }}
       - run: bun sst deploy --stage=${{ github.ref_name }}
         env:
         env:
           CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
           CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

+ 1 - 1
.github/workflows/duplicate-issues.yml

@@ -21,7 +21,7 @@ jobs:
 
 
       - name: Check for duplicate issues
       - name: Check for duplicate issues
         env:
         env:
-          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
+          OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
           OPENCODE_PERMISSION: |
           OPENCODE_PERMISSION: |
             {
             {

+ 5 - 1
.github/workflows/opencode.yml

@@ -3,6 +3,8 @@ name: opencode
 on:
 on:
   issue_comment:
   issue_comment:
     types: [created]
     types: [created]
+  pull_request_review_comment:
+    types: [created]
 
 
 jobs:
 jobs:
   opencode:
   opencode:
@@ -21,9 +23,11 @@ jobs:
       - name: Checkout repository
       - name: Checkout repository
         uses: actions/checkout@v4
         uses: actions/checkout@v4
 
 
+      - uses: ./.github/actions/setup-bun
+
       - name: Run opencode
       - name: Run opencode
         uses: sst/opencode/github@latest
         uses: sst/opencode/github@latest
         env:
         env:
           OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
           OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
         with:
         with:
-          model: opencode/glm-4.6
+          model: opencode/claude-haiku-4-5

+ 7 - 0
.github/workflows/publish.yml

@@ -61,6 +61,13 @@ jobs:
         run: |
         run: |
           echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
           echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
 
 
+      - name: Login to GitHub Container Registry
+        uses: docker/login-action@v3
+        with:
+          registry: ghcr.io
+          username: ${{ github.repository_owner }}
+          password: ${{ secrets.GITHUB_TOKEN }}
+
       - name: Publish
       - name: Publish
         run: |
         run: |
           ./script/publish.ts
           ./script/publish.ts

+ 4 - 1
.github/workflows/snapshot.yml

@@ -1,11 +1,14 @@
 name: snapshot
 name: snapshot
 
 
 on:
 on:
+  workflow_dispatch:
   push:
   push:
     branches:
     branches:
       - dev
       - dev
-      - fix-snapshot-2
+      - test-bedrock
       - v0
       - v0
+      - otui-diffs
+      - snapshot-*
 
 
 concurrency: ${{ github.workflow }}-${{ github.ref }}
 concurrency: ${{ github.workflow }}-${{ github.ref }}
 
 

+ 26 - 3
.github/workflows/update-nix-hashes.yml

@@ -18,6 +18,7 @@ on:
 
 
 jobs:
 jobs:
   update:
   update:
+    if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     env:
     env:
       SYSTEM: x86_64-linux
       SYSTEM: x86_64-linux
@@ -28,6 +29,8 @@ jobs:
         with:
         with:
           token: ${{ secrets.GITHUB_TOKEN }}
           token: ${{ secrets.GITHUB_TOKEN }}
           fetch-depth: 0
           fetch-depth: 0
+          ref: ${{ github.head_ref || github.ref_name }}
+          repository: ${{ github.event.pull_request.head.repo.full_name || github.repository }}
 
 
       - name: Setup Nix
       - name: Setup Nix
         uses: DeterminateSystems/nix-installer-action@v20
         uses: DeterminateSystems/nix-installer-action@v20
@@ -37,10 +40,19 @@ jobs:
           git config --global user.email "[email protected]"
           git config --global user.email "[email protected]"
           git config --global user.name "Github Action"
           git config --global user.name "Github Action"
 
 
+      - name: Update flake.lock
+        run: |
+          set -euo pipefail
+          echo "📦 Updating flake.lock..."
+          nix flake update
+          echo "✅ flake.lock updated successfully"
+
       - name: Update node_modules hash
       - name: Update node_modules hash
         run: |
         run: |
           set -euo pipefail
           set -euo pipefail
+          echo "🔄 Updating node_modules hash..."
           nix/scripts/update-hashes.sh
           nix/scripts/update-hashes.sh
+          echo "✅ node_modules hash updated successfully"
 
 
       - name: Commit hash changes
       - name: Commit hash changes
         env:
         env:
@@ -48,6 +60,8 @@ jobs:
         run: |
         run: |
           set -euo pipefail
           set -euo pipefail
 
 
+          echo "🔍 Checking for changes in tracked Nix files..."
+
           summarize() {
           summarize() {
             local status="$1"
             local status="$1"
             {
             {
@@ -62,18 +76,27 @@ jobs:
             echo "" >> "$GITHUB_STEP_SUMMARY"
             echo "" >> "$GITHUB_STEP_SUMMARY"
           }
           }
 
 
-          FILES=(flake.nix nix/node-modules.nix nix/hashes.json)
+          FILES=(flake.lock flake.nix nix/node-modules.nix nix/hashes.json)
           STATUS="$(git status --short -- "${FILES[@]}" || true)"
           STATUS="$(git status --short -- "${FILES[@]}" || true)"
           if [ -z "$STATUS" ]; then
           if [ -z "$STATUS" ]; then
+            echo "✅ No changes detected. Hashes are already up to date."
             summarize "no changes"
             summarize "no changes"
-            echo "No changes to tracked Nix files. Hashes are already up to date."
             exit 0
             exit 0
           fi
           fi
 
 
+          echo "📝 Changes detected:"
+          echo "$STATUS"
+          echo "🔗 Staging files..."
           git add "${FILES[@]}"
           git add "${FILES[@]}"
-          git commit -m "Update Nix hashes"
+          echo "💾 Committing changes..."
+          git commit -m "Update Nix flake.lock and hashes"
+          echo "✅ Changes committed"
 
 
           BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
           BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
+          echo "🌳 Pulling latest from branch: $BRANCH"
+          git pull --rebase origin "$BRANCH"
+          echo "🚀 Pushing changes to branch: $BRANCH"
           git push origin HEAD:"$BRANCH"
           git push origin HEAD:"$BRANCH"
+          echo "✅ Changes pushed successfully"
 
 
           summarize "committed $(git rev-parse --short HEAD)"
           summarize "committed $(git rev-parse --short HEAD)"

+ 1 - 0
.gitignore

@@ -17,6 +17,7 @@ dist
 refs
 refs
 Session.vim
 Session.vim
 opencode.json
 opencode.json
+a.out
 
 
 *.bak
 *.bak
 /hosts/jetbrains-plugin/bin/
 /hosts/jetbrains-plugin/bin/

+ 7 - 0
.husky/pre-push

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

+ 0 - 1
.opencode/command/commit.md

@@ -1,6 +1,5 @@
 ---
 ---
 description: Git commit and push
 description: Git commit and push
-subtask: true
 ---
 ---
 
 
 commit and push
 commit and push

+ 16 - 0
.opencode/opencode.jsonc

@@ -1,6 +1,9 @@
 {
 {
   "$schema": "https://opencode.ai/config.json",
   "$schema": "https://opencode.ai/config.json",
   "plugin": ["opencode-openai-codex-auth"],
   "plugin": ["opencode-openai-codex-auth"],
+  // "enterprise": {
+  //   "url": "https://enterprise.dev.opencode.ai",
+  // },
   "provider": {
   "provider": {
     "opencode": {
     "opencode": {
       "options": {
       "options": {
@@ -8,4 +11,17 @@
       },
       },
     },
     },
   },
   },
+  "mcp": {
+    "exa": {
+      "type": "remote",
+      "url": "https://mcp.exa.ai/mcp",
+    },
+    "morph": {
+      "type": "local",
+      "command": ["bunx", "@morphllm/morphmcp"],
+      "environment": {
+        "ENABLED_TOOLS": "warp_grep",
+      },
+    },
+  },
 }
 }

+ 1 - 1
README.md

@@ -97,7 +97,7 @@ If you are working on a project that's related to OpenCode and is using "opencod
 It's very similar to Claude Code in terms of capability. Here are the key differences:
 It's very similar to Claude Code in terms of capability. Here are the key differences:
 
 
 - 100% open source
 - 100% open source
-- Not coupled to any provider. Although Anthropic is recommended, OpenCode can be used with OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
+- Not coupled to any provider. Although we recommend the models we provide through [OpenCode Zen](https://opencode.ai/zen); OpenCode can be used with Claude, OpenAI, Google or even local models. As models evolve the gaps between them will close and pricing will drop so being provider-agnostic is important.
 - Out of the box LSP support
 - Out of the box LSP support
 - A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
 - A focus on TUI. OpenCode is built by neovim users and the creators of [terminal.shop](https://terminal.shop); we are going to push the limits of what's possible in the terminal.
 - A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.
 - A client/server architecture. This for example can allow OpenCode to run on your computer, while you can drive it remotely from a mobile app. Meaning that the TUI frontend is just one of the possible clients.

+ 10 - 0
STATS.md

@@ -145,3 +145,13 @@
 | 2025-11-17 | 780,161 (+9,092)  | 723,339 (+6,743)  | 1,503,500 (+15,835) |
 | 2025-11-17 | 780,161 (+9,092)  | 723,339 (+6,743)  | 1,503,500 (+15,835) |
 | 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205)  | 1,524,107 (+20,607) |
 | 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205)  | 1,524,107 (+20,607) |
 | 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
 | 2025-11-19 | 804,409 (+12,846) | 747,624 (+15,080) | 1,552,033 (+27,926) |
+| 2025-11-20 | 814,620 (+10,211) | 757,907 (+10,283) | 1,572,527 (+20,494) |
+| 2025-11-21 | 826,309 (+11,689) | 769,307 (+11,400) | 1,595,616 (+23,089) |
+| 2025-11-22 | 837,269 (+10,960) | 780,996 (+11,689) | 1,618,265 (+22,649) |
+| 2025-11-23 | 846,609 (+9,340)  | 795,069 (+14,073) | 1,641,678 (+23,413) |
+| 2025-11-24 | 856,733 (+10,124) | 804,033 (+8,964)  | 1,660,766 (+19,088) |
+| 2025-11-25 | 869,423 (+12,690) | 817,339 (+13,306) | 1,686,762 (+25,996) |
+| 2025-11-26 | 881,414 (+11,991) | 832,518 (+15,179) | 1,713,932 (+27,170) |
+| 2025-11-27 | 893,960 (+12,546) | 846,180 (+13,662) | 1,740,140 (+26,208) |
+| 2025-11-28 | 901,741 (+7,781)  | 856,482 (+10,302) | 1,758,223 (+18,083) |
+| 2025-11-29 | 908,689 (+6,948)  | 863,361 (+6,879)  | 1,772,050 (+13,827) |

+ 0 - 0
a.out


Plik diff jest za duży
+ 101 - 72
bun.lock


+ 3 - 3
flake.lock

@@ -2,11 +2,11 @@
   "nodes": {
   "nodes": {
     "nixpkgs": {
     "nixpkgs": {
       "locked": {
       "locked": {
-        "lastModified": 1762156382,
-        "narHash": "sha256-Yg7Ag7ov5+36jEFC1DaZh/12SEXo6OO3/8rqADRxiqs=",
+        "lastModified": 1764384123,
+        "narHash": "sha256-UoliURDJFaOolycBZYrjzd9Cc66zULEyHqGFH3QHEq0=",
         "owner": "NixOS",
         "owner": "NixOS",
         "repo": "nixpkgs",
         "repo": "nixpkgs",
-        "rev": "7241bcbb4f099a66aafca120d37c65e8dda32717",
+        "rev": "59b6c96beacc898566c9be1052ae806f3835f87d",
         "type": "github"
         "type": "github"
       },
       },
       "original": {
       "original": {

+ 26 - 0
github/README.md

@@ -30,6 +30,24 @@ Leave the following comment on a GitHub PR. opencode will implement the requeste
 Delete the attachment from S3 when the note is removed /oc
 Delete the attachment from S3 when the note is removed /oc
 ```
 ```
 
 
+#### Review specific code lines
+
+Leave a comment directly on code lines in the PR's "Files" tab. opencode will automatically detect the file, line numbers, and diff context to provide precise responses.
+
+```
+[Comment on specific lines in Files tab]
+/oc add error handling here
+```
+
+When commenting on specific lines, opencode receives:
+
+- The exact file being reviewed
+- The specific lines of code
+- The surrounding diff context
+- Line number information
+
+This allows for more targeted requests without needing to specify file paths or line numbers manually.
+
 ## Installation
 ## Installation
 
 
 Run the following command in the terminal from your GitHub repo:
 Run the following command in the terminal from your GitHub repo:
@@ -51,6 +69,8 @@ This will walk you through installing the GitHub app, creating the workflow, and
    on:
    on:
      issue_comment:
      issue_comment:
        types: [created]
        types: [created]
+     pull_request_review_comment:
+       types: [created]
 
 
    jobs:
    jobs:
      opencode:
      opencode:
@@ -135,3 +155,9 @@ Replace the image URL `https://github.com/user-attachments/assets/xxxxxxxx` with
 ```
 ```
 MOCK_EVENT='{"eventName":"issue_comment","repo":{"owner":"sst","repo":"hello-world"},"actor":"fwang","payload":{"issue":{"number":4,"pull_request":{}},"comment":{"id":1,"body":"hey opencode, summarize thread"}}}'
 MOCK_EVENT='{"eventName":"issue_comment","repo":{"owner":"sst","repo":"hello-world"},"actor":"fwang","payload":{"issue":{"number":4,"pull_request":{}},"comment":{"id":1,"body":"hey opencode, summarize thread"}}}'
 ```
 ```
+
+### PR review comment event
+
+```
+MOCK_EVENT='{"eventName":"pull_request_review_comment","repo":{"owner":"sst","repo":"hello-world"},"actor":"fwang","payload":{"pull_request":{"number":7},"comment":{"id":1,"body":"hey opencode, add error handling","path":"src/components/Button.tsx","diff_hunk":"@@ -45,8 +45,11 @@\n- const handleClick = () => {\n-   console.log('clicked')\n+ const handleClick = useCallback(() => {\n+   console.log('clicked')\n+   doSomething()\n+ }, [doSomething])","line":47,"original_line":45,"position":10,"commit_id":"abc123","original_commit_id":"def456"}}}'
+```

+ 44 - 7
github/index.ts

@@ -5,7 +5,7 @@ import { graphql } from "@octokit/graphql"
 import * as core from "@actions/core"
 import * as core from "@actions/core"
 import * as github from "@actions/github"
 import * as github from "@actions/github"
 import type { Context as GitHubContext } from "@actions/github/lib/context"
 import type { Context as GitHubContext } from "@actions/github/lib/context"
-import type { IssueCommentEvent } from "@octokit/webhooks-types"
+import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
 import { createOpencodeClient } from "@opencode-ai/sdk"
 import { createOpencodeClient } from "@opencode-ai/sdk"
 import { spawn } from "node:child_process"
 import { spawn } from "node:child_process"
 
 
@@ -124,7 +124,7 @@ let exitCode = 0
 type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
 type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
 
 
 try {
 try {
-  assertContextEvent("issue_comment")
+  assertContextEvent("issue_comment", "pull_request_review_comment")
   assertPayloadKeyword()
   assertPayloadKeyword()
   await assertOpencodeConnected()
   await assertOpencodeConnected()
 
 
@@ -241,19 +241,43 @@ function createOpencode() {
 }
 }
 
 
 function assertPayloadKeyword() {
 function assertPayloadKeyword() {
-  const payload = useContext().payload as IssueCommentEvent
+  const payload = useContext().payload as IssueCommentEvent | PullRequestReviewCommentEvent
   const body = payload.comment.body.trim()
   const body = payload.comment.body.trim()
   if (!body.match(/(?:^|\s)(?:\/opencode|\/oc)(?=$|\s)/)) {
   if (!body.match(/(?:^|\s)(?:\/opencode|\/oc)(?=$|\s)/)) {
     throw new Error("Comments must mention `/opencode` or `/oc`")
     throw new Error("Comments must mention `/opencode` or `/oc`")
   }
   }
 }
 }
 
 
+function getReviewCommentContext() {
+  const context = useContext()
+  if (context.eventName !== "pull_request_review_comment") {
+    return null
+  }
+
+  const payload = context.payload as PullRequestReviewCommentEvent
+  return {
+    file: payload.comment.path,
+    diffHunk: payload.comment.diff_hunk,
+    line: payload.comment.line,
+    originalLine: payload.comment.original_line,
+    position: payload.comment.position,
+    commitId: payload.comment.commit_id,
+    originalCommitId: payload.comment.original_commit_id,
+  }
+}
+
 async function assertOpencodeConnected() {
 async function assertOpencodeConnected() {
   let retry = 0
   let retry = 0
   let connected = false
   let connected = false
   do {
   do {
     try {
     try {
-      await client.app.get<true>()
+      await client.app.log<true>({
+        body: {
+          service: "github-workflow",
+          level: "info",
+          message: "Prepare to react to Github Workflow event",
+        },
+      })
       connected = true
       connected = true
       break
       break
     } catch (e) {}
     } catch (e) {}
@@ -383,11 +407,24 @@ async function createComment() {
 }
 }
 
 
 async function getUserPrompt() {
 async function getUserPrompt() {
+  const context = useContext()
+  const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
+  const reviewContext = getReviewCommentContext()
+
   let prompt = (() => {
   let prompt = (() => {
-    const payload = useContext().payload as IssueCommentEvent
     const body = payload.comment.body.trim()
     const body = payload.comment.body.trim()
-    if (body === "/opencode" || body === "/oc") return "Summarize this thread"
-    if (body.includes("/opencode") || body.includes("/oc")) return body
+    if (body === "/opencode" || body === "/oc") {
+      if (reviewContext) {
+        return `Review this code change and suggest improvements for the commented lines:\n\nFile: ${reviewContext.file}\nLines: ${reviewContext.line}\n\n${reviewContext.diffHunk}`
+      }
+      return "Summarize this thread"
+    }
+    if (body.includes("/opencode") || body.includes("/oc")) {
+      if (reviewContext) {
+        return `${body}\n\nContext: You are reviewing a comment on file "${reviewContext.file}" at line ${reviewContext.line}.\n\nDiff context:\n${reviewContext.diffHunk}`
+      }
+      return body
+    }
     throw new Error("Comments must mention `/opencode` or `/oc`")
     throw new Error("Comments must mention `/opencode` or `/oc`")
   })()
   })()
 
 

+ 10 - 4
infra/console.ts

@@ -97,8 +97,12 @@ export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint",
   ],
   ],
 })
 })
 
 
-const ZEN_MODELS1 = new sst.Secret("ZEN_MODELS1")
-const ZEN_MODELS2 = new sst.Secret("ZEN_MODELS2")
+const ZEN_MODELS = [
+  new sst.Secret("ZEN_MODELS1"),
+  new sst.Secret("ZEN_MODELS2"),
+  new sst.Secret("ZEN_MODELS3"),
+  new sst.Secret("ZEN_MODELS4"),
+]
 const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
 const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
 const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
 const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
   properties: { value: auth.url.apply((url) => url!) },
   properties: { value: auth.url.apply((url) => url!) },
@@ -112,6 +116,8 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
 // CONSOLE
 // CONSOLE
 ////////////////
 ////////////////
 
 
+const bucket = new sst.cloudflare.Bucket("ConsoleData")
+
 const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
 const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
 const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
 const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
 
 
@@ -128,15 +134,15 @@ new sst.cloudflare.x.SolidStart("Console", {
   domain,
   domain,
   path: "packages/console/app",
   path: "packages/console/app",
   link: [
   link: [
+    bucket,
     database,
     database,
     AUTH_API_URL,
     AUTH_API_URL,
     STRIPE_WEBHOOK_SECRET,
     STRIPE_WEBHOOK_SECRET,
     STRIPE_SECRET_KEY,
     STRIPE_SECRET_KEY,
-    ZEN_MODELS1,
-    ZEN_MODELS2,
     EMAILOCTOPUS_API_KEY,
     EMAILOCTOPUS_API_KEY,
     AWS_SES_ACCESS_KEY_ID,
     AWS_SES_ACCESS_KEY_ID,
     AWS_SES_SECRET_ACCESS_KEY,
     AWS_SES_SECRET_ACCESS_KEY,
+    ...ZEN_MODELS,
     ...($dev
     ...($dev
       ? [
       ? [
           new sst.Secret("CLOUDFLARE_DEFAULT_ACCOUNT_ID", process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID!),
           new sst.Secret("CLOUDFLARE_DEFAULT_ACCOUNT_ID", process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID!),

+ 17 - 0
infra/enterprise.ts

@@ -0,0 +1,17 @@
+import { SECRET } from "./secret"
+import { domain } from "./stage"
+
+const storage = new sst.cloudflare.Bucket("EnterpriseStorage")
+
+const enterprise = new sst.cloudflare.x.SolidStart("Enterprise", {
+  domain: "enterprise." + domain,
+  path: "packages/enterprise",
+  buildCommand: "bun run build:cloudflare",
+  environment: {
+    OPENCODE_STORAGE_ADAPTER: "r2",
+    OPENCODE_STORAGE_ACCOUNT_ID: sst.cloudflare.DEFAULT_ACCOUNT_ID,
+    OPENCODE_STORAGE_ACCESS_KEY_ID: SECRET.R2AccessKey.value,
+    OPENCODE_STORAGE_SECRET_ACCESS_KEY: SECRET.R2SecretKey.value,
+    OPENCODE_STORAGE_BUCKET: storage.name,
+  },
+})

+ 4 - 0
infra/secret.ts

@@ -0,0 +1,4 @@
+export const SECRET = {
+  R2AccessKey: new sst.Secret("R2AccessKey", "unknown"),
+  R2SecretKey: new sst.Secret("R2SecretKey", "unknown"),
+}

+ 199 - 28
install

@@ -2,9 +2,8 @@
 set -euo pipefail
 set -euo pipefail
 APP=opencode
 APP=opencode
 
 
+MUTED='\033[0;2m'
 RED='\033[0;31m'
 RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
 ORANGE='\033[38;2;255;140;0m'
 ORANGE='\033[38;2;255;140;0m'
 NC='\033[0m' # No Color
 NC='\033[0m' # No Color
 
 
@@ -12,39 +11,94 @@ requested_version=${VERSION:-}
 
 
 raw_os=$(uname -s)
 raw_os=$(uname -s)
 os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
 os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
-# Normalize various Unix-like identifiers
 case "$raw_os" in
 case "$raw_os" in
   Darwin*) os="darwin" ;;
   Darwin*) os="darwin" ;;
   Linux*) os="linux" ;;
   Linux*) os="linux" ;;
   MINGW*|MSYS*|CYGWIN*) os="windows" ;;
   MINGW*|MSYS*|CYGWIN*) os="windows" ;;
- esac
-arch=$(uname -m)
+esac
 
 
+arch=$(uname -m)
 if [[ "$arch" == "aarch64" ]]; then
 if [[ "$arch" == "aarch64" ]]; then
   arch="arm64"
   arch="arm64"
-elif [[ "$arch" == "x86_64" ]]; then
+fi
+if [[ "$arch" == "x86_64" ]]; then
   arch="x64"
   arch="x64"
 fi
 fi
 
 
-filename="$APP-$os-$arch.zip"
-
+if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then
+  rosetta_flag=$(sysctl -n sysctl.proc_translated 2>/dev/null || echo 0)
+  if [ "$rosetta_flag" = "1" ]; then
+    arch="arm64"
+  fi
+fi
 
 
-case "$filename" in
-    *"-linux-"*)
-        [[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
-    ;;
-    *"-darwin-"*)
-        [[ "$arch" == "x64" || "$arch" == "arm64" ]] || exit 1
-    ;;
-    *"-windows-"*)
-        [[ "$arch" == "x64" ]] || exit 1
+combo="$os-$arch"
+case "$combo" in
+  linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
     ;;
     ;;
-    *)
-        echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
-        exit 1
+  *)
+    echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
+    exit 1
     ;;
     ;;
 esac
 esac
 
 
+archive_ext=".zip"
+if [ "$os" = "linux" ]; then
+  archive_ext=".tar.gz"
+fi
+
+is_musl=false
+if [ "$os" = "linux" ]; then
+  if [ -f /etc/alpine-release ]; then
+    is_musl=true
+  fi
+
+  if command -v ldd >/dev/null 2>&1; then
+    if ldd --version 2>&1 | grep -qi musl; then
+      is_musl=true
+    fi
+  fi
+fi
+
+needs_baseline=false
+if [ "$arch" = "x64" ]; then
+  if [ "$os" = "linux" ]; then
+    if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
+      needs_baseline=true
+    fi
+  fi
+
+  if [ "$os" = "darwin" ]; then
+    avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
+    if [ "$avx2" != "1" ]; then
+      needs_baseline=true
+    fi
+  fi
+fi
+
+target="$os-$arch"
+if [ "$needs_baseline" = "true" ]; then
+  target="$target-baseline"
+fi
+if [ "$is_musl" = "true" ]; then
+  target="$target-musl"
+fi
+
+filename="$APP-$target$archive_ext"
+
+
+if [ "$os" = "linux" ]; then
+    if ! command -v tar >/dev/null 2>&1; then
+         echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
+         exit 1
+    fi
+else
+    if ! command -v unzip >/dev/null 2>&1; then
+        echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
+        exit 1
+    fi
+fi
+
 INSTALL_DIR=$HOME/.opencode/bin
 INSTALL_DIR=$HOME/.opencode/bin
 mkdir -p "$INSTALL_DIR"
 mkdir -p "$INSTALL_DIR"
 
 
@@ -67,8 +121,8 @@ print_message() {
     local color=""
     local color=""
 
 
     case $level in
     case $level in
-        info) color="${GREEN}" ;;
-        warning) color="${YELLOW}" ;;
+        info) color="${NC}" ;;
+        warning) color="${NC}" ;;
         error) color="${RED}" ;;
         error) color="${RED}" ;;
     esac
     esac
 
 
@@ -86,19 +140,119 @@ check_version() {
         installed_version=$(echo $installed_version | awk '{print $2}')
         installed_version=$(echo $installed_version | awk '{print $2}')
 
 
         if [[ "$installed_version" != "$specific_version" ]]; then
         if [[ "$installed_version" != "$specific_version" ]]; then
-            print_message info "Installed version: ${YELLOW}$installed_version."
+            print_message info "${MUTED}Installed version: ${NC}$installed_version."
         else
         else
-            print_message info "Version ${YELLOW}$specific_version${GREEN} already installed"
+            print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
             exit 0
             exit 0
         fi
         fi
     fi
     fi
 }
 }
 
 
+unbuffered_sed() {
+    if echo | sed -u -e "" >/dev/null 2>&1; then
+        sed -nu "$@"
+    elif echo | sed -l -e "" >/dev/null 2>&1; then
+        sed -nl "$@"
+    else
+        local pad="$(printf "\n%512s" "")"
+        sed -ne "s/$/\\${pad}/" "$@"
+    fi
+}
+
+print_progress() {
+    local bytes="$1"
+    local length="$2"
+    [ "$length" -gt 0 ] || return 0
+
+    local width=50
+    local percent=$(( bytes * 100 / length ))
+    [ "$percent" -gt 100 ] && percent=100
+    local on=$(( percent * width / 100 ))
+    local off=$(( width - on ))
+
+    local filled=$(printf "%*s" "$on" "")
+    filled=${filled// /■}
+    local empty=$(printf "%*s" "$off" "")
+    empty=${empty// /・}
+
+    printf "\r${ORANGE}%s%s %3d%%${NC}" "$filled" "$empty" "$percent" >&4
+}
+
+download_with_progress() {
+    local url="$1"
+    local output="$2"
+
+    if [ -t 2 ]; then
+        exec 4>&2
+    else
+        exec 4>/dev/null
+    fi
+
+    local tmp_dir=${TMPDIR:-/tmp}
+    local basename="${tmp_dir}/opencode_install_$$"
+    local tracefile="${basename}.trace"
+
+    rm -f "$tracefile"
+    mkfifo "$tracefile"
+
+    # Hide cursor
+    printf "\033[?25l" >&4
+
+    trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
+
+    (
+        curl --trace-ascii "$tracefile" -s -L -o "$output" "$url"
+    ) &
+    local curl_pid=$!
+
+    unbuffered_sed \
+        -e 'y/ACDEGHLNORTV/acdeghlnortv/' \
+        -e '/^0000: content-length:/p' \
+        -e '/^<= recv data/p' \
+        "$tracefile" | \
+    {
+        local length=0
+        local bytes=0
+        
+        while IFS=" " read -r -a line; do
+            [ "${#line[@]}" -lt 2 ] && continue
+            local tag="${line[0]} ${line[1]}"
+            
+            if [ "$tag" = "0000: content-length:" ]; then
+                length="${line[2]}"
+                length=$(echo "$length" | tr -d '\r')
+                bytes=0
+            elif [ "$tag" = "<= recv" ]; then
+                local size="${line[3]}"
+                bytes=$(( bytes + size ))
+                if [ "$length" -gt 0 ]; then
+                    print_progress "$bytes" "$length"
+                fi
+            fi
+        done
+    }
+
+    wait $curl_pid
+    local ret=$?
+    echo "" >&4
+    return $ret
+}
+
 download_and_install() {
 download_and_install() {
-    print_message info "Downloading ${ORANGE}opencode ${GREEN}version: ${YELLOW}$specific_version ${GREEN}..."
+    print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
     mkdir -p opencodetmp && cd opencodetmp
     mkdir -p opencodetmp && cd opencodetmp
-    curl -# -L -o "$filename" "$url"
-    unzip -q "$filename"
+    
+    if [[ "$os" == "windows" ]] || ! download_with_progress "$url" "$filename"; then
+        # Fallback to standard curl on Windows or if custom progress fails
+        curl -# -L -o "$filename" "$url"
+    fi
+
+    if [ "$os" = "linux" ]; then
+        tar -xzf "$filename"
+    else
+        unzip -q "$filename"
+    fi
+    
     mv opencode "$INSTALL_DIR"
     mv opencode "$INSTALL_DIR"
     chmod 755 "${INSTALL_DIR}/opencode"
     chmod 755 "${INSTALL_DIR}/opencode"
     cd .. && rm -rf opencodetmp
     cd .. && rm -rf opencodetmp
@@ -117,7 +271,7 @@ add_to_path() {
     elif [[ -w $config_file ]]; then
     elif [[ -w $config_file ]]; then
         echo -e "\n# opencode" >> "$config_file"
         echo -e "\n# opencode" >> "$config_file"
         echo "$command" >> "$config_file"
         echo "$command" >> "$config_file"
-        print_message info "Successfully added ${ORANGE}opencode ${GREEN}to \$PATH in $config_file"
+        print_message info "${MUTED}Successfully added ${NC}opencode ${MUTED}to \$PATH in ${NC}$config_file"
     else
     else
         print_message warning "Manually add the directory to $config_file (or similar):"
         print_message warning "Manually add the directory to $config_file (or similar):"
         print_message info "  $command"
         print_message info "  $command"
@@ -191,3 +345,20 @@ if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
     echo "$INSTALL_DIR" >> $GITHUB_PATH
     echo "$INSTALL_DIR" >> $GITHUB_PATH
     print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
     print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
 fi
 fi
+
+echo -e ""
+echo -e "${MUTED}                    ${NC}             ▄     "
+echo -e "${MUTED}█▀▀█ █▀▀█ █▀▀█ █▀▀▄ ${NC}█▀▀▀ █▀▀█ █▀▀█ █▀▀█"
+echo -e "${MUTED}█░░█ █░░█ █▀▀▀ █░░█ ${NC}█░░░ █░░█ █░░█ █▀▀▀"
+echo -e "${MUTED}▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀  ▀ ${NC}▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"
+echo -e ""
+echo -e ""
+echo -e "${MUTED}To get started, navigate to a project and run:${NC}"
+echo -e "opencode                   ${MUTED}Use free models${NC}"
+echo -e "opencode auth login        ${MUTED}Add paid provider API keys${NC}"
+echo -e "opencode help              ${MUTED}List commands and options${NC}"
+echo -e ""
+echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/docs"
+echo -e ""
+echo -e ""
+

+ 40 - 0
nix/bundle.ts

@@ -0,0 +1,40 @@
+#!/usr/bin/env bun
+
+import solidPlugin from "./node_modules/@opentui/solid/scripts/solid-plugin"
+import path from "path"
+import fs from "fs"
+
+const dir = process.cwd()
+const parser = fs.realpathSync(path.join(dir, "node_modules/@opentui/core/parser.worker.js"))
+const worker = "./src/cli/cmd/tui/worker.ts"
+const version = process.env.OPENCODE_VERSION ?? "local"
+const channel = process.env.OPENCODE_CHANNEL ?? "local"
+
+fs.rmSync(path.join(dir, "dist"), { recursive: true, force: true })
+
+const result = await Bun.build({
+  entrypoints: ["./src/index.ts", worker, parser],
+  outdir: "./dist",
+  target: "bun",
+  sourcemap: "none",
+  tsconfig: "./tsconfig.json",
+  plugins: [solidPlugin],
+  external: ["@opentui/core"],
+  define: {
+    OPENCODE_VERSION: `'${version}'`,
+    OPENCODE_CHANNEL: `'${channel}'`,
+    // Leave undefined so runtime picks bundled/dist worker or fallback in code.
+    OPENCODE_WORKER_PATH: "undefined",
+    OTUI_TREE_SITTER_WORKER_PATH: 'new URL("./cli/cmd/tui/parser.worker.js", import.meta.url).href',
+  },
+})
+
+if (!result.success) {
+  console.error("bundle failed")
+  for (const log of result.logs) console.error(log)
+  process.exit(1)
+}
+
+const parserOut = path.join(dir, "dist/src/cli/cmd/tui/parser.worker.js")
+fs.mkdirSync(path.dirname(parserOut), { recursive: true })
+await Bun.write(parserOut, Bun.file(parser))

+ 1 - 1
nix/hashes.json

@@ -1,3 +1,3 @@
 {
 {
-  "nodeModules": "sha256-xqiDrKpODha+cfU6UpXLEUcApZ1xEkjRpqzFVJmq1uA="
+  "nodeModules": "sha256-a9KY8laOnLStyhKrMT50pLGPV3aB1Wq+FmdqwXl+/Rg="
 }
 }

+ 61 - 34
nix/opencode.nix

@@ -1,4 +1,4 @@
-{ lib, stdenv, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
+{ lib, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
 args:
 args:
 let
 let
   scripts = args.scripts;
   scripts = args.scripts;
@@ -28,66 +28,93 @@ stdenvNoCC.mkDerivation (finalAttrs: {
     makeBinaryWrapper
     makeBinaryWrapper
   ];
   ];
 
 
-  configurePhase = ''
-    runHook preConfigure
-    cp -R ${finalAttrs.node_modules}/. .
-    runHook postConfigure
-  '';
-
   env.MODELS_DEV_API_JSON = args.modelsDev;
   env.MODELS_DEV_API_JSON = args.modelsDev;
   env.OPENCODE_VERSION = args.version;
   env.OPENCODE_VERSION = args.version;
   env.OPENCODE_CHANNEL = "stable";
   env.OPENCODE_CHANNEL = "stable";
+  dontConfigure = true;
 
 
   buildPhase = ''
   buildPhase = ''
     runHook preBuild
     runHook preBuild
 
 
-    cp ${scripts + "/bun-build.ts"} bun-build.ts
+    cp -r ${finalAttrs.node_modules}/node_modules .
+    cp -r ${finalAttrs.node_modules}/packages .
+
+    (
+      cd packages/opencode
 
 
-    substituteInPlace bun-build.ts \
-      --replace '@VERSION@' "${finalAttrs.version}"
+      chmod -R u+w ./node_modules
+      mkdir -p ./node_modules/@opencode-ai
+      rm -f ./node_modules/@opencode-ai/{script,sdk,plugin}
+      ln -s $(pwd)/../../packages/script ./node_modules/@opencode-ai/script
+      ln -s $(pwd)/../../packages/sdk/js ./node_modules/@opencode-ai/sdk
+      ln -s $(pwd)/../../packages/plugin ./node_modules/@opencode-ai/plugin
 
 
-    export BUN_COMPILE_TARGET=${args.target}
-    bun --bun bun-build.ts
+      cp ${./bundle.ts} ./bundle.ts
+      chmod +x ./bundle.ts
+      bun run ./bundle.ts
+    )
 
 
     runHook postBuild
     runHook postBuild
   '';
   '';
 
 
-  dontStrip = true;
-
   installPhase = ''
   installPhase = ''
     runHook preInstall
     runHook preInstall
 
 
     cd packages/opencode
     cd packages/opencode
-    if [ ! -f opencode ]; then
-      echo "ERROR: opencode binary not found in $(pwd)"
-      ls -la
+    if [ ! -d dist ]; then
+      echo "ERROR: dist directory missing after bundle step"
       exit 1
       exit 1
     fi
     fi
-    if [ ! -f opencode-worker.js ]; then
-      echo "ERROR: opencode worker bundle not found in $(pwd)"
-      ls -la
+
+    mkdir -p $out/lib/opencode
+    cp -r dist $out/lib/opencode/
+    chmod -R u+w $out/lib/opencode/dist
+
+    # Select bundled worker assets deterministically (sorted find output)
+    worker_file=$(find "$out/lib/opencode/dist" -type f \( -path '*/tui/worker.*' -o -name 'worker.*' \) | sort | head -n1)
+    parser_worker_file=$(find "$out/lib/opencode/dist" -type f -name 'parser.worker.*' | sort | head -n1)
+    if [ -z "$worker_file" ]; then
+      echo "ERROR: bundled worker not found"
       exit 1
       exit 1
     fi
     fi
 
 
-    install -Dm755 opencode $out/bin/opencode
-    install -Dm644 opencode-worker.js $out/bin/opencode-worker.js
-    if [ -f opencode-assets.manifest ]; then
-      while IFS= read -r asset; do
-        [ -z "$asset" ] && continue
-        if [ ! -f "$asset" ]; then
-          echo "ERROR: referenced asset \"$asset\" missing"
-          exit 1
-        fi
-        install -Dm644 "$asset" "$out/bin/$(basename "$asset")"
-      done < opencode-assets.manifest
-    fi
+    main_wasm=$(printf '%s\n' "$out"/lib/opencode/dist/tree-sitter-*.wasm | sort | head -n1)
+    wasm_list=$(find "$out/lib/opencode/dist" -maxdepth 1 -name 'tree-sitter-*.wasm' -print)
+    for patch_file in "$worker_file" "$parser_worker_file"; do
+      [ -z "$patch_file" ] && continue
+      [ ! -f "$patch_file" ] && continue
+      if [ -n "$wasm_list" ] && grep -q 'tree-sitter' "$patch_file"; then
+        # Rewrite wasm references to absolute store paths to avoid runtime resolve failures.
+        bun --bun ${scripts + "/patch-wasm.ts"} "$patch_file" "$main_wasm" $wasm_list
+      fi
+    done
+
+    mkdir -p $out/lib/opencode/node_modules
+    cp -r ../../node_modules/.bun $out/lib/opencode/node_modules/
+    mkdir -p $out/lib/opencode/node_modules/@opentui
+
+    mkdir -p $out/bin
+    makeWrapper ${bun}/bin/bun $out/bin/opencode \
+      --add-flags "run" \
+      --add-flags "$out/lib/opencode/dist/src/index.js" \
+      --prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]} \
+      --argv0 opencode
+
     runHook postInstall
     runHook postInstall
   '';
   '';
 
 
-  postFixup = ''
-    wrapProgram "$out/bin/opencode" --prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]}
+  postInstall = ''
+    for pkg in $out/lib/opencode/node_modules/.bun/@opentui+core-* $out/lib/opencode/node_modules/.bun/@opentui+solid-* $out/lib/opencode/node_modules/.bun/@opentui+core@* $out/lib/opencode/node_modules/.bun/@opentui+solid@*; do
+      if [ -d "$pkg" ]; then
+        pkgName=$(basename "$pkg" | sed 's/@opentui+\([^@]*\)@.*/\1/')
+        ln -sf ../.bun/$(basename "$pkg")/node_modules/@opentui/$pkgName \
+          $out/lib/opencode/node_modules/@opentui/$pkgName
+      fi
+    done
   '';
   '';
 
 
+  dontFixup = true;
+
   meta = {
   meta = {
     description = "AI coding agent built for the terminal";
     description = "AI coding agent built for the terminal";
     longDescription = ''
     longDescription = ''

+ 24 - 7
nix/scripts/canonicalize-node-modules.ts

@@ -24,15 +24,13 @@ for (const entry of directories) {
   if (!info.isDirectory()) {
   if (!info.isDirectory()) {
     continue
     continue
   }
   }
-  const marker = entry.lastIndexOf("@")
-  if (marker <= 0) {
+  const parsed = parseEntry(entry)
+  if (!parsed) {
     continue
     continue
   }
   }
-  const slug = entry.slice(0, marker).replace(/\+/g, "/")
-  const version = entry.slice(marker + 1)
-  const list = versions.get(slug) ?? []
-  list.push({ dir: full, version, label: entry })
-  versions.set(slug, list)
+  const list = versions.get(parsed.name) ?? []
+  list.push({ dir: full, version: parsed.version, label: entry })
+  versions.set(parsed.name, list)
 }
 }
 
 
 const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
 const semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
@@ -79,6 +77,12 @@ for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0]
   await mkdir(parent, { recursive: true })
   await mkdir(parent, { recursive: true })
   const linkPath = join(parent, leaf)
   const linkPath = join(parent, leaf)
   const desired = join(entry.dir, "node_modules", slug)
   const desired = join(entry.dir, "node_modules", slug)
+  const exists = await lstat(desired)
+    .then((info) => info.isDirectory())
+    .catch(() => false)
+  if (!exists) {
+    continue
+  }
   const relativeTarget = relative(parent, desired)
   const relativeTarget = relative(parent, desired)
   const resolved = relativeTarget.length === 0 ? "." : relativeTarget
   const resolved = relativeTarget.length === 0 ? "." : relativeTarget
   await rm(linkPath, { recursive: true, force: true })
   await rm(linkPath, { recursive: true, force: true })
@@ -94,3 +98,16 @@ for (const line of rewrites.slice(0, 20)) {
 if (rewrites.length > 20) {
 if (rewrites.length > 20) {
   console.log("  ...")
   console.log("  ...")
 }
 }
+
+function parseEntry(label: string) {
+  const marker = label.startsWith("@") ? label.indexOf("@", 1) : label.indexOf("@")
+  if (marker <= 0) {
+    return null
+  }
+  const name = label.slice(0, marker).replace(/\+/g, "/")
+  const version = label.slice(marker + 1)
+  if (!name || !version) {
+    return null
+  }
+  return { name, version }
+}

+ 39 - 0
nix/scripts/patch-wasm.ts

@@ -0,0 +1,39 @@
+#!/usr/bin/env bun
+
+import fs from "fs"
+import path from "path"
+
+/**
+ * Rewrite tree-sitter wasm references inside a JS file to absolute paths.
+ * argv: [node, script, file, mainWasm, ...wasmPaths]
+ */
+const [, , file, mainWasm, ...wasmPaths] = process.argv
+
+if (!file || !mainWasm) {
+  console.error("usage: patch-wasm <file> <mainWasm> [wasmPaths...]")
+  process.exit(1)
+}
+
+const content = fs.readFileSync(file, "utf8")
+const byName = new Map<string, string>()
+
+for (const wasm of wasmPaths) {
+  const name = path.basename(wasm)
+  byName.set(name, wasm)
+}
+
+let next = content
+
+for (const [name, wasmPath] of byName) {
+  next = next.replaceAll(name, wasmPath)
+}
+
+next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
+
+// Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
+next = next.replace(/(\.\/)+/g, "./")
+next = next.replace(/(\.\.\/)+\/?(\/nix\/store[^"']+)/g, "/$2")
+next = next.replace(/(["'])\/{2,}(\/nix\/store[^"']+)(["'])/g, "$1/$2$3")
+next = next.replace(/(["'])\/\/(nix\/store[^"']+)(["'])/g, "$1/$2$3")
+
+if (next !== content) fs.writeFileSync(file, next)

+ 15 - 11
package.json

@@ -4,12 +4,13 @@
   "description": "AI-powered development tool",
   "description": "AI-powered development tool",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
-  "packageManager": "[email protected].2",
+  "packageManager": "[email protected].3",
   "scripts": {
   "scripts": {
     "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
     "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
     "typecheck": "bun turbo typecheck",
     "typecheck": "bun turbo typecheck",
     "prepare": "husky",
     "prepare": "husky",
-    "random": "echo 'Random script'"
+    "random": "echo 'Random script'",
+    "hello": "echo 'Hello World!'"
   },
   },
   "workspaces": {
   "workspaces": {
     "packages": [
     "packages": [
@@ -20,33 +21,37 @@
       "packages/opencode/webgui"
       "packages/opencode/webgui"
     ],
     ],
     "catalog": {
     "catalog": {
-      "@types/bun": "1.3.0",
+      "@types/bun": "1.3.3",
       "@hono/zod-validator": "0.4.2",
       "@hono/zod-validator": "0.4.2",
       "ulid": "3.0.1",
       "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
       "@kobalte/core": "0.13.11",
+      "@types/luxon": "3.7.1",
       "@types/node": "22.13.9",
       "@types/node": "22.13.9",
       "@tsconfig/node22": "22.0.2",
       "@tsconfig/node22": "22.0.2",
       "@tsconfig/bun": "1.0.9",
       "@tsconfig/bun": "1.0.9",
       "@cloudflare/workers-types": "4.20251008.0",
       "@cloudflare/workers-types": "4.20251008.0",
       "@openauthjs/openauth": "0.0.0-20250322224806",
       "@openauthjs/openauth": "0.0.0-20250322224806",
-      "@pierre/precision-diffs": "0.4.4",
-      "@solidjs/meta": "0.29.4",
+      "@pierre/precision-diffs": "0.5.7",
       "@tailwindcss/vite": "4.1.11",
       "@tailwindcss/vite": "4.1.11",
       "diff": "8.0.2",
       "diff": "8.0.2",
       "ai": "5.0.97",
       "ai": "5.0.97",
       "hono": "4.7.10",
       "hono": "4.7.10",
+      "hono-openapi": "1.1.1",
       "fuzzysort": "3.1.0",
       "fuzzysort": "3.1.0",
       "luxon": "3.6.1",
       "luxon": "3.6.1",
       "typescript": "5.8.2",
       "typescript": "5.8.2",
       "@typescript/native-preview": "7.0.0-dev.20251014.1",
       "@typescript/native-preview": "7.0.0-dev.20251014.1",
       "zod": "4.1.8",
       "zod": "4.1.8",
       "remeda": "2.26.0",
       "remeda": "2.26.0",
-      "solid-js": "1.9.9",
       "solid-list": "0.3.0",
       "solid-list": "0.3.0",
       "tailwindcss": "4.1.11",
       "tailwindcss": "4.1.11",
       "virtua": "0.42.3",
       "virtua": "0.42.3",
       "vite": "7.1.4",
       "vite": "7.1.4",
-      "vite-plugin-solid": "2.11.8"
+      "@solidjs/meta": "0.29.4",
+      "@solidjs/router": "0.15.4",
+      "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
+      "solid-js": "1.9.10",
+      "vite-plugin-solid": "2.11.10"
     }
     }
   },
   },
   "devDependencies": {
   "devDependencies": {
@@ -59,8 +64,10 @@
     "remark-gfm": "4.0.1"
     "remark-gfm": "4.0.1"
   },
   },
   "dependencies": {
   "dependencies": {
+    "@aws-sdk/client-s3": "3.933.0",
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/script": "workspace:*",
-    "@opencode-ai/sdk": "workspace:*"
+    "@opencode-ai/sdk": "workspace:*",
+    "typescript": "catalog:"
   },
   },
   "repository": {
   "repository": {
     "type": "git",
     "type": "git",
@@ -79,9 +86,6 @@
     "tree-sitter-bash",
     "tree-sitter-bash",
     "web-tree-sitter"
     "web-tree-sitter"
   ],
   ],
-  "patchedDependencies": {
-    "@solidjs/[email protected]": "patches/@solidjs%[email protected]"
-  },
   "overrides": {
   "overrides": {
     "@types/bun": "catalog:",
     "@types/bun": "catalog:",
     "@types/node": "catalog:"
     "@types/node": "catalog:"

+ 0 - 1
packages/console/app/.gitignore

@@ -3,7 +3,6 @@ dist
 .output
 .output
 .vercel
 .vercel
 .netlify
 .netlify
-.vinxi
 app.config.timestamp_*.js
 app.config.timestamp_*.js
 
 
 # Environment
 # Environment

+ 0 - 23
packages/console/app/app.config.ts

@@ -1,23 +0,0 @@
-import { defineConfig } from "@solidjs/start/config"
-
-export default defineConfig({
-  middleware: "./src/middleware.ts",
-  vite: {
-    server: {
-      allowedHosts: true,
-    },
-    build: {
-      rollupOptions: {
-        external: ["cloudflare:workers"],
-      },
-      minify: false,
-    },
-  },
-  server: {
-    compatibilityDate: "2024-09-19",
-    preset: "cloudflare_module",
-    cloudflare: {
-      nodeCompat: true,
-    },
-  },
-})

+ 13 - 9
packages/console/app/package.json

@@ -1,15 +1,16 @@
 {
 {
   "name": "@opencode-ai/console-app",
   "name": "@opencode-ai/console-app",
+  "version": "1.0.121",
   "type": "module",
   "type": "module",
   "scripts": {
   "scripts": {
     "typecheck": "tsgo --noEmit",
     "typecheck": "tsgo --noEmit",
-    "dev": "vinxi dev --host 0.0.0.0",
+    "dev": "vite dev --host 0.0.0.0",
     "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
     "dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai bun sst shell --stage=dev bun dev",
-    "build": "./script/generate-sitemap.ts && vinxi build && ../../opencode/script/schema.ts ./.output/public/config.json",
-    "start": "vinxi start",
-    "version": "1.0.80"
+    "build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
+    "start": "vite start"
   },
   },
   "dependencies": {
   "dependencies": {
+    "@cloudflare/vite-plugin": "1.15.2",
     "@ibm/plex": "6.4.1",
     "@ibm/plex": "6.4.1",
     "@jsx-email/render": "1.1.1",
     "@jsx-email/render": "1.1.1",
     "@kobalte/core": "catalog:",
     "@kobalte/core": "catalog:",
@@ -17,17 +18,20 @@
     "@opencode-ai/console-core": "workspace:*",
     "@opencode-ai/console-core": "workspace:*",
     "@opencode-ai/console-mail": "workspace:*",
     "@opencode-ai/console-mail": "workspace:*",
     "@opencode-ai/console-resource": "workspace:*",
     "@opencode-ai/console-resource": "workspace:*",
-    "@solidjs/meta": "^0.29.4",
-    "@solidjs/router": "^0.15.0",
-    "@solidjs/start": "^1.1.0",
+    "@opencode-ai/ui": "workspace:*",
+    "@solidjs/meta": "catalog:",
+    "@solidjs/router": "catalog:",
+    "@solidjs/start": "catalog:",
     "chart.js": "4.5.1",
     "chart.js": "4.5.1",
+    "nitro": "3.0.1-alpha.1",
     "solid-js": "catalog:",
     "solid-js": "catalog:",
-    "vinxi": "^0.5.7",
+    "vite": "catalog:",
     "zod": "catalog:"
     "zod": "catalog:"
   },
   },
   "devDependencies": {
   "devDependencies": {
+    "@typescript/native-preview": "catalog:",
     "typescript": "catalog:",
     "typescript": "catalog:",
-    "@typescript/native-preview": "catalog:"
+    "wrangler": "4.50.0"
   },
   },
   "engines": {
   "engines": {
     "node": ">=22"
     "node": ">=22"

+ 1 - 0
packages/console/app/public/apple-touch-icon.png

@@ -0,0 +1 @@
+../../../ui/src/assets/favicon/apple-touch-icon.png

+ 1 - 0
packages/console/app/public/favicon-96x96.png

@@ -0,0 +1 @@
+../../../ui/src/assets/favicon/favicon-96x96.png

+ 0 - 23
packages/console/app/public/favicon-zen.svg

@@ -1,23 +0,0 @@
-<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect width="400" height="400" fill="#FDFCFC"/>
-<path d="M96 122.001V70.001H148V122.001H96Z" fill="#17181C"/>
-<path d="M148.004 122.001V70.001H200.004V122.001H148.004Z" fill="#17181C"/>
-<path d="M200.008 122.001V70.001H252.008V122.001H200.008Z" fill="#17181C"/>
-<path d="M251.996 122.001V70.001H303.996V122.001H251.996Z" fill="#17181C"/>
-<path d="M251.996 173.988V121.988H303.996V173.988H251.996Z" fill="#17181C"/>
-<path d="M96 225.998V173.998H148V225.998H96Z" fill="#CFCECD"/>
-<rect width="52" height="52" transform="translate(148.004 173.998)" fill="#17181C"/>
-<path d="M148.004 225.998V173.998H200.004V225.998H148.004Z" fill="#17181C" fill-opacity="0.1"/>
-<path d="M200.008 225.998V173.998H252.008V225.998H200.008Z" fill="#17181C"/>
-<path d="M252.016 225.998V173.998H304.016V225.998H252.016Z" fill="#CFCECD"/>
-<rect width="52" height="52" transform="translate(96 226.002)" fill="#17181C"/>
-<path d="M96 278.002V226.002H148V278.002H96Z" fill="#17181C" fill-opacity="0.1"/>
-<rect width="52" height="52" transform="translate(148.004 226.002)" fill="white"/>
-<path d="M148.004 278.002V226.002H200.004V278.002H148.004Z" fill="#CFCECD"/>
-<path d="M200.008 278.002V226.002H252.008V278.002H200.008Z" fill="#CFCECD"/>
-<path d="M252.016 278.002V226.002H304.016V278.002H252.016Z" fill="#CFCECD"/>
-<path d="M96 330.012V278.012H148V330.012H96Z" fill="#17181C"/>
-<path d="M148.004 330.012V278.012H200.004V330.012H148.004Z" fill="#17181C"/>
-<path d="M200.008 329.99V277.99H252.008V329.99H200.008Z" fill="#17181C"/>
-<path d="M251.996 330.012V278.012H303.996V330.012H251.996Z" fill="#17181C"/>
-</svg>

+ 1 - 0
packages/console/app/public/favicon.ico

@@ -0,0 +1 @@
+../../../ui/src/assets/favicon/favicon.ico

+ 0 - 4
packages/console/app/public/favicon.svg

@@ -1,4 +0,0 @@
-<svg width="400" height="400" viewBox="0 0 400 400" fill="none" xmlns="http://www.w3.org/2000/svg">
-<rect width="400" height="400" fill="#0E0E0E"/>
-<path fill-rule="evenodd" clip-rule="evenodd" d="M312 340H88V60H312V340ZM256 116H144V284H256V116Z" fill="white"/>
-</svg>

+ 1 - 0
packages/console/app/public/favicon.svg

@@ -0,0 +1 @@
+../../../ui/src/assets/favicon/favicon.svg

+ 2 - 1
packages/console/app/public/robots.txt

@@ -2,4 +2,5 @@ User-agent: *
 Allow: /
 Allow: /
 
 
 # Disallow shared content pages
 # Disallow shared content pages
-Disallow: /s/
+Disallow: /s/
+Disallow: /share/

+ 1 - 0
packages/console/app/public/site.webmanifest

@@ -0,0 +1 @@
+../../../ui/src/assets/favicon/site.webmanifest

+ 1 - 0
packages/console/app/public/web-app-manifest-192x192.png

@@ -0,0 +1 @@
+../../../ui/src/assets/favicon/web-app-manifest-192x192.png

+ 1 - 0
packages/console/app/public/web-app-manifest-512x512.png

@@ -0,0 +1 @@
+../../../ui/src/assets/favicon/web-app-manifest-512x512.png

+ 3 - 1
packages/console/app/src/app.tsx

@@ -1,7 +1,8 @@
 import { MetaProvider, Title, Meta } from "@solidjs/meta"
 import { MetaProvider, Title, Meta } from "@solidjs/meta"
 import { Router } from "@solidjs/router"
 import { Router } from "@solidjs/router"
 import { FileRoutes } from "@solidjs/start/router"
 import { FileRoutes } from "@solidjs/start/router"
-import { ErrorBoundary, Suspense } from "solid-js"
+import { Suspense } from "solid-js"
+import { Favicon } from "@opencode-ai/ui/favicon"
 import "@ibm/plex/css/ibm-plex.css"
 import "@ibm/plex/css/ibm-plex.css"
 import "./app.css"
 import "./app.css"
 
 
@@ -13,6 +14,7 @@ export default function App() {
         <MetaProvider>
         <MetaProvider>
           <Title>opencode</Title>
           <Title>opencode</Title>
           <Meta name="description" content="OpenCode - The AI coding agent built for the terminal." />
           <Meta name="description" content="OpenCode - The AI coding agent built for the terminal." />
+          <Favicon />
           <Suspense>{props.children}</Suspense>
           <Suspense>{props.children}</Suspense>
         </MetaProvider>
         </MetaProvider>
       )}
       )}

+ 12 - 1
packages/console/app/src/component/icon.tsx

@@ -202,7 +202,7 @@ export function IconZai(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
   )
   )
 }
 }
 
 
-export function IconGoogle(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+export function IconGemini(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
   return (
   return (
     <svg {...props} viewBox="0 0 50 50" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
     <svg {...props} viewBox="0 0 50 50" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
       <path d="M49.04,24.001l-1.082-0.043h-0.001C36.134,23.492,26.508,13.866,26.042,2.043L25.999,0.96C25.978,0.424,25.537,0,25,0	s-0.978,0.424-0.999,0.96l-0.043,1.083C23.492,13.866,13.866,23.492,2.042,23.958L0.96,24.001C0.424,24.022,0,24.463,0,25	c0,0.537,0.424,0.978,0.961,0.999l1.082,0.042c11.823,0.467,21.449,10.093,21.915,21.916l0.043,1.083C24.022,49.576,24.463,50,25,50	s0.978-0.424,0.999-0.96l0.043-1.083c0.466-11.823,10.092-21.449,21.915-21.916l1.082-0.042C49.576,25.978,50,25.537,50,25	C50,24.463,49.576,24.022,49.04,24.001z"></path>
       <path d="M49.04,24.001l-1.082-0.043h-0.001C36.134,23.492,26.508,13.866,26.042,2.043L25.999,0.96C25.978,0.424,25.537,0,25,0	s-0.978,0.424-0.999,0.96l-0.043,1.083C23.492,13.866,13.866,23.492,2.042,23.958L0.96,24.001C0.424,24.022,0,24.463,0,25	c0,0.537,0.424,0.978,0.961,0.999l1.082,0.042c11.823,0.467,21.449,10.093,21.915,21.916l0.043,1.083C24.022,49.576,24.463,50,25,50	s0.978-0.424,0.999-0.96l0.043-1.083c0.466-11.823,10.092-21.449,21.915-21.916l1.082-0.042C49.576,25.978,50,25.537,50,25	C50,24.463,49.576,24.022,49.04,24.001z"></path>
@@ -236,3 +236,14 @@ export function IconChevronRight(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
     </svg>
     </svg>
   )
   )
 }
 }
+
+export function IconBreakdown(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+  return (
+    <svg {...props} width="16" height="16" viewBox="0 0 16 16" fill="none">
+      <path d="M2 12L2 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
+      <path d="M6 12L6 4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
+      <path d="M10 12L10 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
+      <path d="M14 12L14 9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
+    </svg>
+  )
+}

+ 1 - 1
packages/console/app/src/context/auth.session.ts

@@ -1,4 +1,4 @@
-import { useSession } from "vinxi/http"
+import { useSession } from "@solidjs/start/http"
 
 
 export interface AuthSession {
 export interface AuthSession {
   account?: Record<
   account?: Record<

+ 0 - 1
packages/console/app/src/entry-server.tsx

@@ -9,7 +9,6 @@ export default createHandler(
           <head>
           <head>
             <meta charset="utf-8" />
             <meta charset="utf-8" />
             <meta name="viewport" content="width=device-width, initial-scale=1" />
             <meta name="viewport" content="width=device-width, initial-scale=1" />
-            <link rel="icon" href="/favicon.svg" />
             <meta property="og:image" content="/social-share.png" />
             <meta property="og:image" content="/social-share.png" />
             <meta property="twitter:image" content="/social-share.png" />
             <meta property="twitter:image" content="/social-share.png" />
             {assets}
             {assets}

+ 4 - 0
packages/console/app/src/global.d.ts

@@ -1 +1,5 @@
 /// <reference types="@solidjs/start/env" />
 /// <reference types="@solidjs/start/env" />
+
+export declare module "@solidjs/start/server" {
+  export type APIEvent = { request: Request }
+}

+ 2 - 2
packages/console/app/src/middleware.ts

@@ -1,5 +1,5 @@
-import { defineMiddleware } from "vinxi/http"
+import { createMiddleware } from "@solidjs/start/middleware"
 
 
-export default defineMiddleware({
+export default createMiddleware({
   onBeforeResponse() {},
   onBeforeResponse() {},
 })
 })

+ 1 - 0
packages/console/app/src/routes/api/enterprise.ts

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

+ 1 - 0
packages/console/app/src/routes/auth/callback.ts

@@ -19,6 +19,7 @@ export async function GET(input: APIEvent) {
     return {
     return {
       ...value,
       ...value,
       account: {
       account: {
+        ...value.account,
         [id]: {
         [id]: {
           id,
           id,
           email: decoded.subject.properties.email,
           email: decoded.subject.properties.email,

+ 17 - 0
packages/console/app/src/routes/auth/logout.ts

@@ -0,0 +1,17 @@
+import { redirect } from "@solidjs/router"
+import { APIEvent } from "@solidjs/start"
+import { useAuthSession } from "~/context/auth.session"
+
+export async function GET(event: APIEvent) {
+  const auth = await useAuthSession()
+  const current = auth.data.current
+  if (current)
+    await auth.update((val) => {
+      delete val.account?.[current]
+      const first = Object.keys(val.account ?? {})[0]
+      val.current = first
+      event!.locals.actor = undefined
+      return val
+    })
+  return redirect("/zen")
+}

+ 7 - 0
packages/console/app/src/routes/auth/status.ts

@@ -0,0 +1,7 @@
+import { APIEvent } from "@solidjs/start"
+import { useAuthSession } from "~/context/auth.session"
+
+export async function GET(input: APIEvent) {
+  const session = await useAuthSession()
+  return Response.json(session.data)
+}

+ 2 - 3
packages/console/app/src/routes/index.tsx

@@ -1,6 +1,6 @@
 import "./index.css"
 import "./index.css"
 import { Title, Meta, Link } from "@solidjs/meta"
 import { Title, Meta, Link } from "@solidjs/meta"
-import { HttpHeader } from "@solidjs/start"
+// import { HttpHeader } from "@solidjs/start"
 import video from "../asset/lander/opencode-min.mp4"
 import video from "../asset/lander/opencode-min.mp4"
 import videoPoster from "../asset/lander/opencode-poster.png"
 import videoPoster from "../asset/lander/opencode-poster.png"
 import { IconCopy, IconCheck } from "../component/icon"
 import { IconCopy, IconCheck } from "../component/icon"
@@ -42,10 +42,9 @@ export default function Home() {
 
 
   return (
   return (
     <main data-page="opencode">
     <main data-page="opencode">
-      <HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
+      {/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
       <Title>OpenCode | The AI coding agent built for the terminal</Title>
       <Title>OpenCode | The AI coding agent built for the terminal</Title>
       <Link rel="canonical" href={config.baseUrl} />
       <Link rel="canonical" href={config.baseUrl} />
-      <Link rel="icon" type="image/svg+xml" href="/favicon.svg" />
       <Meta property="og:image" content="/social-share.png" />
       <Meta property="og:image" content="/social-share.png" />
       <Meta name="twitter:image" content="/social-share.png" />
       <Meta name="twitter:image" content="/social-share.png" />
       <div data-component="container">
       <div data-component="container">

+ 1 - 0
packages/console/app/src/routes/user-menu.css

@@ -12,6 +12,7 @@
 
 
     [data-slot="item"] {
     [data-slot="item"] {
       color: var(--color-danger);
       color: var(--color-danger);
+      text-decoration: none;
     }
     }
   }
   }
 }
 }

+ 5 - 8
packages/console/app/src/routes/user-menu.tsx

@@ -1,4 +1,4 @@
-import { action, redirect } from "@solidjs/router"
+import { action } from "@solidjs/router"
 import { getRequestEvent } from "solid-js/web"
 import { getRequestEvent } from "solid-js/web"
 import { useAuthSession } from "~/context/auth.session"
 import { useAuthSession } from "~/context/auth.session"
 import { Dropdown } from "~/component/dropdown"
 import { Dropdown } from "~/component/dropdown"
@@ -17,18 +17,15 @@ const logout = action(async () => {
       event!.locals.actor = undefined
       event!.locals.actor = undefined
       return val
       return val
     })
     })
-  throw redirect("/zen")
-})
+}, "auth.logout")
 
 
 export function UserMenu(props: { email: string | null | undefined }) {
 export function UserMenu(props: { email: string | null | undefined }) {
   return (
   return (
     <div data-component="user-menu">
     <div data-component="user-menu">
       <Dropdown trigger={props.email ?? ""} align="right">
       <Dropdown trigger={props.email ?? ""} align="right">
-        <form action={logout} method="post">
-          <button type="submit" formaction={logout} data-slot="item">
-            Logout
-          </button>
-        </form>
+        <a href="/auth/logout" data-slot="item">
+          Logout
+        </a>
       </Dropdown>
       </Dropdown>
     </div>
     </div>
   )
   )

+ 0 - 2
packages/console/app/src/routes/workspace.tsx

@@ -6,7 +6,6 @@ import { UserMenu } from "./user-menu"
 import { withActor } from "~/context/auth.withActor"
 import { withActor } from "~/context/auth.withActor"
 import { User } from "@opencode-ai/console-core/user.js"
 import { User } from "@opencode-ai/console-core/user.js"
 import { Actor } from "@opencode-ai/console-core/actor.js"
 import { Actor } from "@opencode-ai/console-core/actor.js"
-import { Link } from "@solidjs/meta"
 
 
 const getUserEmail = query(async (workspaceID: string) => {
 const getUserEmail = query(async (workspaceID: string) => {
   "use server"
   "use server"
@@ -22,7 +21,6 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
   const userEmail = createAsync(() => getUserEmail(params.id!))
   const userEmail = createAsync(() => getUserEmail(params.id!))
   return (
   return (
     <main data-page="workspace">
     <main data-page="workspace">
-      <Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
       <header data-component="workspace-header">
       <header data-component="workspace-header">
         <div data-slot="header-brand">
         <div data-slot="header-brand">
           <A href="/" data-component="site-title">
           <A href="/" data-component="site-title">

+ 2 - 2
packages/console/app/src/routes/workspace/[id]/model-section.tsx

@@ -8,7 +8,7 @@ import { querySessionInfo } from "../common"
 import {
 import {
   IconAlibaba,
   IconAlibaba,
   IconAnthropic,
   IconAnthropic,
-  IconGoogle,
+  IconGemini,
   IconMoonshotAI,
   IconMoonshotAI,
   IconOpenAI,
   IconOpenAI,
   IconStealth,
   IconStealth,
@@ -117,7 +117,7 @@ export function ModelSection() {
                                 case "Anthropic":
                                 case "Anthropic":
                                   return <IconAnthropic width={16} height={16} />
                                   return <IconAnthropic width={16} height={16} />
                                 case "Google":
                                 case "Google":
-                                  return <IconGoogle width={16} height={16} />
+                                  return <IconGemini width={16} height={16} />
                                 case "Moonshot AI":
                                 case "Moonshot AI":
                                   return <IconMoonshotAI width={16} height={16} />
                                   return <IconMoonshotAI width={16} height={16} />
                                 case "Z.ai":
                                 case "Z.ai":

+ 1 - 0
packages/console/app/src/routes/workspace/[id]/provider-section.tsx

@@ -8,6 +8,7 @@ import styles from "./provider-section.module.css"
 const PROVIDERS = [
 const PROVIDERS = [
   { name: "OpenAI", key: "openai", prefix: "sk-" },
   { name: "OpenAI", key: "openai", prefix: "sk-" },
   { name: "Anthropic", key: "anthropic", prefix: "sk-ant-" },
   { name: "Anthropic", key: "anthropic", prefix: "sk-ant-" },
+  { name: "Google Gemini", key: "google", prefix: "AI" },
 ] as const
 ] as const
 
 
 type Provider = (typeof PROVIDERS)[number]
 type Provider = (typeof PROVIDERS)[number]

+ 67 - 0
packages/console/app/src/routes/workspace/[id]/usage-section.module.css

@@ -56,6 +56,53 @@
         color: var(--color-text);
         color: var(--color-text);
         font-weight: 500;
         font-weight: 500;
       }
       }
+
+      [data-slot="tokens-with-breakdown"] {
+        position: relative;
+        display: flex;
+        align-items: center;
+        gap: var(--space-2);
+      }
+
+      [data-slot="breakdown-button"] {
+        display: inline-flex;
+        align-items: center;
+        justify-content: center;
+        padding: 0;
+        background: transparent;
+        border: none;
+        color: var(--color-text-muted);
+        cursor: pointer;
+        transition: color 0.15s ease;
+
+        &:hover {
+          color: var(--color-text);
+        }
+
+        svg {
+          width: 16px;
+          height: 16px;
+        }
+      }
+
+      [data-slot="breakdown-popup"] {
+        position: absolute;
+        left: 0;
+        top: 100%;
+        margin-top: var(--space-2);
+        background: var(--color-bg);
+        border: 1px solid var(--color-border);
+        border-radius: var(--border-radius-sm);
+        padding: var(--space-2);
+        z-index: 10;
+        min-width: 180px;
+        box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
+        font-size: var(--font-size-xs);
+
+        @media (prefers-color-scheme: dark) {
+          box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
+        }
+      }
     }
     }
 
 
     tbody tr:last-child td {
     tbody tr:last-child td {
@@ -116,4 +163,24 @@
       }
       }
     }
     }
   }
   }
+
+  /* Breakdown popup content */
+  [data-slot="breakdown-row"] {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    gap: var(--space-4);
+    padding: var(--space-1) 0;
+  }
+
+  [data-slot="breakdown-label"] {
+    color: var(--color-text-muted);
+    font-size: var(--font-size-xs);
+  }
+
+  [data-slot="breakdown-value"] {
+    color: var(--color-text);
+    font-weight: 500;
+    font-size: var(--font-size-xs);
+  }
 }
 }

+ 91 - 5
packages/console/app/src/routes/workspace/[id]/usage-section.tsx

@@ -1,9 +1,9 @@
 import { Billing } from "@opencode-ai/console-core/billing.js"
 import { Billing } from "@opencode-ai/console-core/billing.js"
 import { createAsync, query, useParams } from "@solidjs/router"
 import { createAsync, query, useParams } from "@solidjs/router"
-import { createMemo, For, Show, createEffect } from "solid-js"
+import { createMemo, For, Show, createEffect, createSignal } from "solid-js"
 import { formatDateUTC, formatDateForTable } from "../common"
 import { formatDateUTC, formatDateForTable } from "../common"
 import { withActor } from "~/context/auth.withActor"
 import { withActor } from "~/context/auth.withActor"
-import { IconChevronLeft, IconChevronRight } from "~/component/icon"
+import { IconChevronLeft, IconChevronRight, IconBreakdown } from "~/component/icon"
 import styles from "./usage-section.module.css"
 import styles from "./usage-section.module.css"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 
 
@@ -22,15 +22,38 @@ export function UsageSection() {
   const params = useParams()
   const params = useParams()
   const usage = createAsync(() => queryUsageInfo(params.id!, 0))
   const usage = createAsync(() => queryUsageInfo(params.id!, 0))
   const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
   const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
+  const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
 
 
   createEffect(() => {
   createEffect(() => {
     setStore({ usage: usage() })
     setStore({ usage: usage() })
   }, [usage])
   }, [usage])
 
 
+  createEffect(() => {
+    if (!openBreakdownId()) return
+
+    const handleClickOutside = (e: MouseEvent) => {
+      const target = e.target as HTMLElement
+      if (!target.closest('[data-slot="tokens-with-breakdown"]')) {
+        setOpenBreakdownId(null)
+      }
+    }
+
+    document.addEventListener("click", handleClickOutside)
+    return () => document.removeEventListener("click", handleClickOutside)
+  })
+
   const hasResults = createMemo(() => store.usage && store.usage.length > 0)
   const hasResults = createMemo(() => store.usage && store.usage.length > 0)
   const canGoPrev = createMemo(() => store.page > 0)
   const canGoPrev = createMemo(() => store.page > 0)
   const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
   const canGoNext = createMemo(() => store.usage && store.usage.length === PAGE_SIZE)
 
 
+  const calculateTotalInputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
+    return u.inputTokens + (u.cacheReadTokens ?? 0) + (u.cacheWrite5mTokens ?? 0) + (u.cacheWrite1hTokens ?? 0)
+  }
+
+  const calculateTotalOutputTokens = (u: Awaited<ReturnType<typeof getUsageInfo>>[0]) => {
+    return u.outputTokens + (u.reasoningTokens ?? 0)
+  }
+
   const goPrev = async () => {
   const goPrev = async () => {
     const usage = await getUsageInfo(params.id!, store.page - 1)
     const usage = await getUsageInfo(params.id!, store.page - 1)
     setStore({
     setStore({
@@ -73,16 +96,79 @@ export function UsageSection() {
             </thead>
             </thead>
             <tbody>
             <tbody>
               <For each={store.usage}>
               <For each={store.usage}>
-                {(usage) => {
+                {(usage, index) => {
                   const date = createMemo(() => new Date(usage.timeCreated))
                   const date = createMemo(() => new Date(usage.timeCreated))
+                  const totalInputTokens = createMemo(() => calculateTotalInputTokens(usage))
+                  const totalOutputTokens = createMemo(() => calculateTotalOutputTokens(usage))
+                  const inputBreakdownId = `input-breakdown-${index()}`
+                  const outputBreakdownId = `output-breakdown-${index()}`
+                  const isInputOpen = createMemo(() => openBreakdownId() === inputBreakdownId)
+                  const isOutputOpen = createMemo(() => openBreakdownId() === outputBreakdownId)
+                  const isClaude = usage.model.toLowerCase().includes("claude")
                   return (
                   return (
                     <tr>
                     <tr>
                       <td data-slot="usage-date" title={formatDateUTC(date())}>
                       <td data-slot="usage-date" title={formatDateUTC(date())}>
                         {formatDateForTable(date())}
                         {formatDateForTable(date())}
                       </td>
                       </td>
                       <td data-slot="usage-model">{usage.model}</td>
                       <td data-slot="usage-model">{usage.model}</td>
-                      <td data-slot="usage-tokens">{usage.inputTokens}</td>
-                      <td data-slot="usage-tokens">{usage.outputTokens}</td>
+                      <td data-slot="usage-tokens">
+                        <div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
+                          <button
+                            data-slot="breakdown-button"
+                            onClick={(e) => {
+                              e.stopPropagation()
+                              setOpenBreakdownId(isInputOpen() ? null : inputBreakdownId)
+                            }}
+                          >
+                            <IconBreakdown />
+                          </button>
+                          <span onClick={() => setOpenBreakdownId(null)}>{totalInputTokens()}</span>
+                          <Show when={isInputOpen()}>
+                            <div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
+                              <div data-slot="breakdown-row">
+                                <span data-slot="breakdown-label">Input</span>
+                                <span data-slot="breakdown-value">{usage.inputTokens}</span>
+                              </div>
+                              <div data-slot="breakdown-row">
+                                <span data-slot="breakdown-label">Cache Read</span>
+                                <span data-slot="breakdown-value">{usage.cacheReadTokens ?? 0}</span>
+                              </div>
+                              <Show when={isClaude}>
+                                <div data-slot="breakdown-row">
+                                  <span data-slot="breakdown-label">Cache Write</span>
+                                  <span data-slot="breakdown-value">{usage.cacheWrite5mTokens ?? 0}</span>
+                                </div>
+                              </Show>
+                            </div>
+                          </Show>
+                        </div>
+                      </td>
+                      <td data-slot="usage-tokens">
+                        <div data-slot="tokens-with-breakdown" onClick={(e) => e.stopPropagation()}>
+                          <button
+                            data-slot="breakdown-button"
+                            onClick={(e) => {
+                              e.stopPropagation()
+                              setOpenBreakdownId(isOutputOpen() ? null : outputBreakdownId)
+                            }}
+                          >
+                            <IconBreakdown />
+                          </button>
+                          <span onClick={() => setOpenBreakdownId(null)}>{totalOutputTokens()}</span>
+                          <Show when={isOutputOpen()}>
+                            <div data-slot="breakdown-popup" onClick={(e) => e.stopPropagation()}>
+                              <div data-slot="breakdown-row">
+                                <span data-slot="breakdown-label">Output</span>
+                                <span data-slot="breakdown-value">{usage.outputTokens}</span>
+                              </div>
+                              <div data-slot="breakdown-row">
+                                <span data-slot="breakdown-label">Reasoning</span>
+                                <span data-slot="breakdown-value">{usage.reasoningTokens ?? 0}</span>
+                              </div>
+                            </div>
+                          </Show>
+                        </div>
+                      </td>
                       <td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
                       <td data-slot="usage-cost">${((usage.cost ?? 0) / 100000000).toFixed(4)}</td>
                     </tr>
                     </tr>
                   )
                   )

+ 12 - 5
packages/console/app/src/routes/zen/index.tsx

@@ -1,7 +1,7 @@
 import "./index.css"
 import "./index.css"
 import { createAsync, query, redirect } from "@solidjs/router"
 import { createAsync, query, redirect } from "@solidjs/router"
 import { Title, Meta, Link } from "@solidjs/meta"
 import { Title, Meta, Link } from "@solidjs/meta"
-import { HttpHeader } from "@solidjs/start"
+// import { HttpHeader } from "@solidjs/start"
 import zenLogoLight from "../../asset/zen-ornate-light.svg"
 import zenLogoLight from "../../asset/zen-ornate-light.svg"
 import { config } from "~/config"
 import { config } from "~/config"
 import zenLogoDark from "../../asset/zen-ornate-dark.svg"
 import zenLogoDark from "../../asset/zen-ornate-dark.svg"
@@ -18,23 +18,24 @@ import { Legal } from "~/component/legal"
 import { Footer } from "~/component/footer"
 import { Footer } from "~/component/footer"
 import { Header } from "~/component/header"
 import { Header } from "~/component/header"
 import { getLastSeenWorkspaceID } from "../workspace/common"
 import { getLastSeenWorkspaceID } from "../workspace/common"
+import { IconGemini, IconZai } from "~/component/icon"
 
 
 const checkLoggedIn = query(async () => {
 const checkLoggedIn = query(async () => {
   "use server"
   "use server"
-  const workspaceID = await getLastSeenWorkspaceID()
+  const workspaceID = await getLastSeenWorkspaceID().catch(() => {})
   if (workspaceID) throw redirect(`/workspace/${workspaceID}`)
   if (workspaceID) throw redirect(`/workspace/${workspaceID}`)
 }, "checkLoggedIn.get")
 }, "checkLoggedIn.get")
 
 
 export default function Home() {
 export default function Home() {
-  createAsync(() => checkLoggedIn())
+  const loggedin = createAsync(() => checkLoggedIn())
   return (
   return (
     <main data-page="zen">
     <main data-page="zen">
-      <HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
+      {/*<HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />*/}
       <Title>OpenCode Zen | A curated set of reliable optimized models for coding agents</Title>
       <Title>OpenCode Zen | A curated set of reliable optimized models for coding agents</Title>
       <Link rel="canonical" href={`${config.baseUrl}/zen`} />
       <Link rel="canonical" href={`${config.baseUrl}/zen`} />
-      <Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
       <Meta property="og:image" content="/social-share-zen.png" />
       <Meta property="og:image" content="/social-share-zen.png" />
       <Meta name="twitter:image" content="/social-share-zen.png" />
       <Meta name="twitter:image" content="/social-share-zen.png" />
+      <Meta name="opencode:auth" content={loggedin() ? "true" : "false"} />
 
 
       <div data-component="container">
       <div data-component="container">
         <Header zen />
         <Header zen />
@@ -81,6 +82,9 @@ export default function Home() {
                     />
                     />
                   </svg>
                   </svg>
                 </div>
                 </div>
+                <div>
+                  <IconGemini width="24" height="24" />
+                </div>
                 <div>
                 <div>
                   <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                   <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                     <path
                     <path
@@ -111,6 +115,9 @@ export default function Home() {
                     />
                     />
                   </svg>
                   </svg>
                 </div>
                 </div>
+                <div>
+                  <IconZai width="24" height="24" />
+                </div>
               </div>
               </div>
               <a href="/auth">
               <a href="/auth">
                 <span>Get started with Zen </span>
                 <span>Get started with Zen </span>

+ 26 - 0
packages/console/app/src/routes/zen/util/dataDumper.ts

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

+ 59 - 15
packages/console/app/src/routes/zen/util/handler.ts

@@ -13,12 +13,20 @@ import { ModelTable } from "@opencode-ai/console-core/schema/model.sql.js"
 import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
 import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
 import { logger } from "./logger"
 import { logger } from "./logger"
 import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
 import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
-import { createBodyConverter, createStreamPartConverter, createResponseConverter } from "./provider/provider"
+import {
+  createBodyConverter,
+  createStreamPartConverter,
+  createResponseConverter,
+  ProviderHelper,
+  UsageInfo,
+} from "./provider/provider"
 import { anthropicHelper } from "./provider/anthropic"
 import { anthropicHelper } from "./provider/anthropic"
 import { googleHelper } from "./provider/google"
 import { googleHelper } from "./provider/google"
 import { openaiHelper } from "./provider/openai"
 import { openaiHelper } from "./provider/openai"
 import { oaCompatHelper } from "./provider/openai-compatible"
 import { oaCompatHelper } from "./provider/openai-compatible"
 import { createRateLimiter } from "./rateLimiter"
 import { createRateLimiter } from "./rateLimiter"
+import { createDataDumper } from "./dataDumper"
+import { createTrialLimiter } from "./trialLimiter"
 
 
 type ZenData = Awaited<ReturnType<typeof ZenData.list>>
 type ZenData = Awaited<ReturnType<typeof ZenData.list>>
 type RetryOptions = {
 type RetryOptions = {
@@ -48,21 +56,26 @@ export async function handler(
   try {
   try {
     const url = input.request.url
     const url = input.request.url
     const body = await input.request.json()
     const body = await input.request.json()
-    const ip = input.request.headers.get("x-real-ip") ?? ""
     const model = opts.parseModel(url, body)
     const model = opts.parseModel(url, body)
     const isStream = opts.parseIsStream(url, body)
     const isStream = opts.parseIsStream(url, body)
+    const ip = input.request.headers.get("x-real-ip") ?? ""
+    const sessionId = input.request.headers.get("x-opencode-session") ?? ""
+    const requestId = input.request.headers.get("x-opencode-request") ?? ""
     logger.metric({
     logger.metric({
       is_tream: isStream,
       is_tream: isStream,
-      session: input.request.headers.get("x-opencode-session"),
-      request: input.request.headers.get("x-opencode-request"),
+      session: sessionId,
+      request: requestId,
     })
     })
     const zenData = ZenData.list()
     const zenData = ZenData.list()
     const modelInfo = validateModel(zenData, model)
     const modelInfo = validateModel(zenData, model)
+    const dataDumper = createDataDumper(sessionId, requestId)
+    const trialLimiter = createTrialLimiter(modelInfo.trial?.limit, ip)
+    const isTrial = await trialLimiter?.isTrial()
     const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
     const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
     await rateLimiter?.check()
     await rateLimiter?.check()
 
 
     const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
     const retriableRequest = async (retry: RetryOptions = { excludeProviders: [], retryCount: 0 }) => {
-      const providerInfo = selectProvider(zenData, modelInfo, ip, retry)
+      const providerInfo = selectProvider(zenData, modelInfo, sessionId, isTrial ?? false, retry)
       const authInfo = await authenticate(modelInfo, providerInfo)
       const authInfo = await authenticate(modelInfo, providerInfo)
       validateBilling(authInfo, modelInfo)
       validateBilling(authInfo, modelInfo)
       validateModelSettings(authInfo)
       validateModelSettings(authInfo)
@@ -104,10 +117,14 @@ export async function handler(
         })
         })
       }
       }
 
 
-      return { providerInfo, authInfo, res, startTimestamp }
+      return { providerInfo, authInfo, reqBody, res, startTimestamp }
     }
     }
 
 
-    const { providerInfo, authInfo, res, startTimestamp } = await retriableRequest()
+    const { providerInfo, authInfo, reqBody, res, startTimestamp } = await retriableRequest()
+
+    // Store model request
+    dataDumper?.provideModel(providerInfo.storeModel)
+    dataDumper?.provideRequest(reqBody)
 
 
     // Scrub response headers
     // Scrub response headers
     const resHeaders = new Headers()
     const resHeaders = new Headers()
@@ -126,8 +143,12 @@ export async function handler(
       const body = JSON.stringify(responseConverter(json))
       const body = JSON.stringify(responseConverter(json))
       logger.metric({ response_length: body.length })
       logger.metric({ response_length: body.length })
       logger.debug("RESPONSE: " + body)
       logger.debug("RESPONSE: " + body)
+      dataDumper?.provideResponse(body)
+      dataDumper?.flush()
+      const tokensInfo = providerInfo.normalizeUsage(json.usage)
+      await trialLimiter?.track(tokensInfo)
       await rateLimiter?.track()
       await rateLimiter?.track()
-      await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
+      await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
       await reload(authInfo)
       await reload(authInfo)
       return new Response(body, {
       return new Response(body, {
         status: res.status,
         status: res.status,
@@ -155,10 +176,13 @@ export async function handler(
                   response_length: responseLength,
                   response_length: responseLength,
                   "timestamp.last_byte": Date.now(),
                   "timestamp.last_byte": Date.now(),
                 })
                 })
+                dataDumper?.flush()
                 await rateLimiter?.track()
                 await rateLimiter?.track()
                 const usage = usageParser.retrieve()
                 const usage = usageParser.retrieve()
                 if (usage) {
                 if (usage) {
-                  await trackUsage(authInfo, modelInfo, providerInfo, usage)
+                  const tokensInfo = providerInfo.normalizeUsage(usage)
+                  await trialLimiter?.track(tokensInfo)
+                  await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
                   await reload(authInfo)
                   await reload(authInfo)
                 }
                 }
                 c.close()
                 c.close()
@@ -174,6 +198,7 @@ export async function handler(
               }
               }
               responseLength += value.length
               responseLength += value.length
               buffer += decoder.decode(value, { stream: true })
               buffer += decoder.decode(value, { stream: true })
+              dataDumper?.provideStream(buffer)
 
 
               const parts = buffer.split(providerInfo.streamSeparator)
               const parts = buffer.split(providerInfo.streamSeparator)
               buffer = parts.pop() ?? ""
               buffer = parts.pop() ?? ""
@@ -263,8 +288,18 @@ export async function handler(
     return { id: modelId, ...modelData }
     return { id: modelId, ...modelData }
   }
   }
 
 
-  function selectProvider(zenData: ZenData, modelInfo: ModelInfo, ip: string, retry: RetryOptions) {
+  function selectProvider(
+    zenData: ZenData,
+    modelInfo: ModelInfo,
+    sessionId: string,
+    isTrial: boolean,
+    retry: RetryOptions,
+  ) {
     const provider = (() => {
     const provider = (() => {
+      if (isTrial) {
+        return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider)
+      }
+
       if (retry.retryCount === MAX_RETRIES) {
       if (retry.retryCount === MAX_RETRIES) {
         return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
         return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
       }
       }
@@ -274,9 +309,13 @@ export async function handler(
         .filter((provider) => !retry.excludeProviders.includes(provider.id))
         .filter((provider) => !retry.excludeProviders.includes(provider.id))
         .flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
         .flatMap((provider) => Array<typeof provider>(provider.weight ?? 1).fill(provider))
 
 
-      // Use the last 2 characters of IP address to select a provider
-      const lastChars = ip.slice(-2)
-      const index = parseInt(lastChars, 16) % providers.length
+      // Use the last 4 characters of session ID to select a provider
+      let h = 0
+      const l = sessionId.length
+      for (let i = l - 4; i < l; i++) {
+        h = (h * 31 + sessionId.charCodeAt(i)) | 0 // 32-bit int
+      }
+      const index = (h >>> 0) % providers.length // make unsigned + range 0..length-1
       return providers[index || 0]
       return providers[index || 0]
     })()
     })()
 
 
@@ -416,9 +455,14 @@ export async function handler(
     providerInfo.apiKey = authInfo.provider.credentials
     providerInfo.apiKey = authInfo.provider.credentials
   }
   }
 
 
-  async function trackUsage(authInfo: AuthInfo, modelInfo: ModelInfo, providerInfo: ProviderInfo, usage: any) {
+  async function trackUsage(
+    authInfo: AuthInfo,
+    modelInfo: ModelInfo,
+    providerInfo: ProviderInfo,
+    usageInfo: UsageInfo,
+  ) {
     const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
     const { inputTokens, outputTokens, reasoningTokens, cacheReadTokens, cacheWrite5mTokens, cacheWrite1hTokens } =
-      providerInfo.normalizeUsage(usage)
+      usageInfo
 
 
     const modelCost =
     const modelCost =
       modelInfo.cost200K &&
       modelInfo.cost200K &&

+ 10 - 8
packages/console/app/src/routes/zen/util/provider/provider.ts

@@ -24,6 +24,15 @@ import {
   toOaCompatibleResponse,
   toOaCompatibleResponse,
 } from "./openai-compatible"
 } from "./openai-compatible"
 
 
+export type UsageInfo = {
+  inputTokens: number
+  outputTokens: number
+  reasoningTokens?: number
+  cacheReadTokens?: number
+  cacheWrite5mTokens?: number
+  cacheWrite1hTokens?: number
+}
+
 export type ProviderHelper = {
 export type ProviderHelper = {
   format: ZenData.Format
   format: ZenData.Format
   modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string
   modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string
@@ -34,14 +43,7 @@ export type ProviderHelper = {
     parse: (chunk: string) => void
     parse: (chunk: string) => void
     retrieve: () => any
     retrieve: () => any
   }
   }
-  normalizeUsage: (usage: any) => {
-    inputTokens: number
-    outputTokens: number
-    reasoningTokens?: number
-    cacheReadTokens?: number
-    cacheWrite5mTokens?: number
-    cacheWrite1hTokens?: number
-  }
+  normalizeUsage: (usage: any) => UsageInfo
 }
 }
 
 
 export interface CommonMessage {
 export interface CommonMessage {

+ 43 - 0
packages/console/app/src/routes/zen/util/trialLimiter.ts

@@ -0,0 +1,43 @@
+import { Database, eq, sql } from "@opencode-ai/console-core/drizzle/index.js"
+import { IpTable } from "@opencode-ai/console-core/schema/ip.sql.js"
+import { UsageInfo } from "./provider/provider"
+
+export function createTrialLimiter(limit: number | undefined, ip: string) {
+  if (!limit) return
+  if (!ip) return
+
+  let trial: boolean
+
+  return {
+    isTrial: async () => {
+      const data = await Database.use((tx) =>
+        tx
+          .select({
+            usage: IpTable.usage,
+          })
+          .from(IpTable)
+          .where(eq(IpTable.ip, ip))
+          .then((rows) => rows[0]),
+      )
+
+      trial = (data?.usage ?? 0) < limit
+      return trial
+    },
+    track: async (usageInfo: UsageInfo) => {
+      if (!trial) return
+      const usage =
+        usageInfo.inputTokens +
+        usageInfo.outputTokens +
+        (usageInfo.reasoningTokens ?? 0) +
+        (usageInfo.cacheReadTokens ?? 0) +
+        (usageInfo.cacheWrite5mTokens ?? 0) +
+        (usageInfo.cacheWrite1hTokens ?? 0)
+      await Database.use((tx) =>
+        tx
+          .insert(IpTable)
+          .values({ ip, usage })
+          .onDuplicateKeyUpdate({ set: { usage: sql`${IpTable.usage} + ${usage}` } }),
+      )
+    },
+  }
+}

+ 1 - 1
packages/console/app/tsconfig.json

@@ -12,7 +12,7 @@
     "allowJs": true,
     "allowJs": true,
     "strict": true,
     "strict": true,
     "noEmit": true,
     "noEmit": true,
-    "types": ["vinxi/types/client"],
+    "types": ["vite/client"],
     "isolatedModules": true,
     "isolatedModules": true,
     "paths": {
     "paths": {
       "~/*": ["./src/*"]
       "~/*": ["./src/*"]

+ 25 - 0
packages/console/app/vite.config.ts

@@ -0,0 +1,25 @@
+import { defineConfig, PluginOption } from "vite"
+import { solidStart } from "@solidjs/start/config"
+import { nitro } from "nitro/vite"
+
+export default defineConfig({
+  plugins: [
+    solidStart() as PluginOption,
+    nitro({
+      compatibilityDate: "2024-09-19",
+      preset: "cloudflare_module",
+      cloudflare: {
+        nodeCompat: true,
+      },
+    }),
+  ],
+  server: {
+    allowedHosts: true,
+  },
+  build: {
+    rollupOptions: {
+      external: ["cloudflare:workers"],
+    },
+    minify: false,
+  },
+})

+ 8 - 0
packages/console/core/migrations/0038_famous_magik.sql

@@ -0,0 +1,8 @@
+CREATE TABLE `ip` (
+	`ip` varchar(45) NOT NULL,
+	`time_created` timestamp(3) NOT NULL DEFAULT (now()),
+	`time_updated` timestamp(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+	`time_deleted` timestamp(3),
+	`usage` int,
+	CONSTRAINT `ip_ip_pk` PRIMARY KEY(`ip`)
+);

+ 981 - 0
packages/console/core/migrations/meta/0038_snapshot.json

@@ -0,0 +1,981 @@
+{
+  "version": "5",
+  "dialect": "mysql",
+  "id": "9d5d9885-7ec5-45f6-ac53-45a8e25dede7",
+  "prevId": "8b7fa839-a088-408e-84a4-1a07325c0290",
+  "tables": {
+    "account": {
+      "name": "account",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "account_id_pk": {
+          "name": "account_id_pk",
+          "columns": ["id"]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "auth": {
+      "name": "auth",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "enum('email','github','google')",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "subject": {
+          "name": "subject",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "account_id": {
+          "name": "account_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "provider": {
+          "name": "provider",
+          "columns": ["provider", "subject"],
+          "isUnique": true
+        },
+        "account_id": {
+          "name": "account_id",
+          "columns": ["account_id"],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "auth_id_pk": {
+          "name": "auth_id_pk",
+          "columns": ["id"]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "billing": {
+      "name": "billing",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "customer_id": {
+          "name": "customer_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_method_id": {
+          "name": "payment_method_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_method_type": {
+          "name": "payment_method_type",
+          "type": "varchar(32)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_method_last4": {
+          "name": "payment_method_last4",
+          "type": "varchar(4)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "balance": {
+          "name": "balance",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "monthly_limit": {
+          "name": "monthly_limit",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "monthly_usage": {
+          "name": "monthly_usage",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_monthly_usage_updated": {
+          "name": "time_monthly_usage_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reload": {
+          "name": "reload",
+          "type": "boolean",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reload_trigger": {
+          "name": "reload_trigger",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reload_amount": {
+          "name": "reload_amount",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "reload_error": {
+          "name": "reload_error",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_reload_error": {
+          "name": "time_reload_error",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_reload_locked_till": {
+          "name": "time_reload_locked_till",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "global_customer_id": {
+          "name": "global_customer_id",
+          "columns": ["customer_id"],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "billing_workspace_id_id_pk": {
+          "name": "billing_workspace_id_id_pk",
+          "columns": ["workspace_id", "id"]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "payment": {
+      "name": "payment",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "customer_id": {
+          "name": "customer_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "invoice_id": {
+          "name": "invoice_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "payment_id": {
+          "name": "payment_id",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "amount": {
+          "name": "amount",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_refunded": {
+          "name": "time_refunded",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "payment_workspace_id_id_pk": {
+          "name": "payment_workspace_id_id_pk",
+          "columns": ["workspace_id", "id"]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "usage": {
+      "name": "usage",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "input_tokens": {
+          "name": "input_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "output_tokens": {
+          "name": "output_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "reasoning_tokens": {
+          "name": "reasoning_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_read_tokens": {
+          "name": "cache_read_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_5m_tokens": {
+          "name": "cache_write_5m_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cache_write_1h_tokens": {
+          "name": "cache_write_1h_tokens",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "cost": {
+          "name": "cost",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key_id": {
+          "name": "key_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "usage_workspace_id_id_pk": {
+          "name": "usage_workspace_id_id_pk",
+          "columns": ["workspace_id", "id"]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "ip": {
+      "name": "ip",
+      "columns": {
+        "ip": {
+          "name": "ip",
+          "type": "varchar(45)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "usage": {
+          "name": "usage",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {},
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "ip_ip_pk": {
+          "name": "ip_ip_pk",
+          "columns": ["ip"]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "key": {
+      "name": "key",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "key": {
+          "name": "key",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "user_id": {
+          "name": "user_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_used": {
+          "name": "time_used",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "global_key": {
+          "name": "global_key",
+          "columns": ["key"],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "key_workspace_id_id_pk": {
+          "name": "key_workspace_id_id_pk",
+          "columns": ["workspace_id", "id"]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "model": {
+      "name": "model",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "model": {
+          "name": "model",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "model_workspace_model": {
+          "name": "model_workspace_model",
+          "columns": ["workspace_id", "model"],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "model_workspace_id_id_pk": {
+          "name": "model_workspace_id_id_pk",
+          "columns": ["workspace_id", "id"]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "provider": {
+      "name": "provider",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "provider": {
+          "name": "provider",
+          "type": "varchar(64)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "credentials": {
+          "name": "credentials",
+          "type": "text",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "workspace_provider": {
+          "name": "workspace_provider",
+          "columns": ["workspace_id", "provider"],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "provider_workspace_id_id_pk": {
+          "name": "provider_workspace_id_id_pk",
+          "columns": ["workspace_id", "id"]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "user": {
+      "name": "user",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "workspace_id": {
+          "name": "workspace_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "account_id": {
+          "name": "account_id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "email": {
+          "name": "email",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_seen": {
+          "name": "time_seen",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "color": {
+          "name": "color",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "role": {
+          "name": "role",
+          "type": "enum('admin','member')",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "monthly_limit": {
+          "name": "monthly_limit",
+          "type": "int",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "monthly_usage": {
+          "name": "monthly_usage",
+          "type": "bigint",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "time_monthly_usage_updated": {
+          "name": "time_monthly_usage_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "user_account_id": {
+          "name": "user_account_id",
+          "columns": ["workspace_id", "account_id"],
+          "isUnique": true
+        },
+        "user_email": {
+          "name": "user_email",
+          "columns": ["workspace_id", "email"],
+          "isUnique": true
+        },
+        "global_account_id": {
+          "name": "global_account_id",
+          "columns": ["account_id"],
+          "isUnique": false
+        },
+        "global_email": {
+          "name": "global_email",
+          "columns": ["email"],
+          "isUnique": false
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "user_workspace_id_id_pk": {
+          "name": "user_workspace_id_id_pk",
+          "columns": ["workspace_id", "id"]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    },
+    "workspace": {
+      "name": "workspace",
+      "columns": {
+        "id": {
+          "name": "id",
+          "type": "varchar(30)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "slug": {
+          "name": "slug",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        },
+        "name": {
+          "name": "name",
+          "type": "varchar(255)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false
+        },
+        "time_created": {
+          "name": "time_created",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "(now())"
+        },
+        "time_updated": {
+          "name": "time_updated",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": true,
+          "autoincrement": false,
+          "default": "CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)"
+        },
+        "time_deleted": {
+          "name": "time_deleted",
+          "type": "timestamp(3)",
+          "primaryKey": false,
+          "notNull": false,
+          "autoincrement": false
+        }
+      },
+      "indexes": {
+        "slug": {
+          "name": "slug",
+          "columns": ["slug"],
+          "isUnique": true
+        }
+      },
+      "foreignKeys": {},
+      "compositePrimaryKeys": {
+        "workspace_id": {
+          "name": "workspace_id",
+          "columns": ["id"]
+        }
+      },
+      "uniqueConstraints": {},
+      "checkConstraint": {}
+    }
+  },
+  "views": {},
+  "_meta": {
+    "schemas": {},
+    "tables": {},
+    "columns": {}
+  },
+  "internal": {
+    "tables": {},
+    "indexes": {}
+  }
+}

+ 7 - 0
packages/console/core/migrations/meta/_journal.json

@@ -267,6 +267,13 @@
       "when": 1761928273807,
       "when": 1761928273807,
       "tag": "0037_messy_jackal",
       "tag": "0037_messy_jackal",
       "breakpoints": true
       "breakpoints": true
+    },
+    {
+      "idx": 38,
+      "version": "5",
+      "when": 1764110043942,
+      "tag": "0038_famous_magik",
+      "breakpoints": true
     }
     }
   ]
   ]
 }
 }

+ 2 - 1
packages/console/core/package.json

@@ -1,7 +1,7 @@
 {
 {
   "$schema": "https://json.schemastore.org/package.json",
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/console-core",
   "name": "@opencode-ai/console-core",
-  "version": "1.0.80",
+  "version": "1.0.121",
   "private": true,
   "private": true,
   "type": "module",
   "type": "module",
   "dependencies": {
   "dependencies": {
@@ -30,6 +30,7 @@
     "update-models": "script/update-models.ts",
     "update-models": "script/update-models.ts",
     "promote-models-to-dev": "script/promote-models.ts dev",
     "promote-models-to-dev": "script/promote-models.ts dev",
     "promote-models-to-prod": "script/promote-models.ts production",
     "promote-models-to-prod": "script/promote-models.ts production",
+    "pull-models-from-dev": "script/pull-models.ts dev",
     "typecheck": "tsgo --noEmit"
     "typecheck": "tsgo --noEmit"
   },
   },
   "devDependencies": {
   "devDependencies": {

+ 10 - 9
packages/console/core/script/promote-models.ts

@@ -11,20 +11,21 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
 
 
 // read the secret
 // read the secret
 const ret = await $`bun sst secret list`.cwd(root).text()
 const ret = await $`bun sst secret list`.cwd(root).text()
-const value1 = ret
-  .split("\n")
-  .find((line) => line.startsWith("ZEN_MODELS1"))
-  ?.split("=")[1]
-const value2 = ret
-  .split("\n")
-  .find((line) => line.startsWith("ZEN_MODELS2"))
-  ?.split("=")[1]
+const lines = ret.split("\n")
+const value1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[1]
+const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1]
+const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
+const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
 if (!value1) throw new Error("ZEN_MODELS1 not found")
 if (!value1) throw new Error("ZEN_MODELS1 not found")
 if (!value2) throw new Error("ZEN_MODELS2 not found")
 if (!value2) throw new Error("ZEN_MODELS2 not found")
+if (!value3) throw new Error("ZEN_MODELS3 not found")
+if (!value4) throw new Error("ZEN_MODELS4 not found")
 
 
 // validate value
 // validate value
-ZenData.validate(JSON.parse(value1 + value2))
+ZenData.validate(JSON.parse(value1 + value2 + value3 + value4))
 
 
 // update the secret
 // update the secret
 await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
 await $`bun sst secret set ZEN_MODELS1 ${value1} --stage ${stage}`
 await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}`
 await $`bun sst secret set ZEN_MODELS2 ${value2} --stage ${stage}`
+await $`bun sst secret set ZEN_MODELS3 ${value3} --stage ${stage}`
+await $`bun sst secret set ZEN_MODELS4 ${value4} --stage ${stage}`

+ 31 - 0
packages/console/core/script/pull-models.ts

@@ -0,0 +1,31 @@
+#!/usr/bin/env bun
+
+import { $ } from "bun"
+import path from "path"
+import { ZenData } from "../src/model"
+
+const stage = process.argv[2]
+if (!stage) throw new Error("Stage is required")
+
+const root = path.resolve(process.cwd(), "..", "..", "..")
+
+// read the secret
+const ret = await $`bun sst secret list --stage ${stage}`.cwd(root).text()
+const lines = ret.split("\n")
+const value1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[1]
+const value2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1]
+const value3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
+const value4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
+if (!value1) throw new Error("ZEN_MODELS1 not found")
+if (!value2) throw new Error("ZEN_MODELS2 not found")
+if (!value3) throw new Error("ZEN_MODELS3 not found")
+if (!value4) throw new Error("ZEN_MODELS4 not found")
+
+// validate value
+ZenData.validate(JSON.parse(value1 + value2 + value3 + value4))
+
+// update the secret
+await $`bun sst secret set ZEN_MODELS1 ${value1}`
+await $`bun sst secret set ZEN_MODELS2 ${value2}`
+await $`bun sst secret set ZEN_MODELS3 ${value3}`
+await $`bun sst secret set ZEN_MODELS4 ${value4}`

+ 17 - 12
packages/console/core/script/update-models.ts

@@ -9,21 +9,20 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
 const models = await $`bun sst secret list`.cwd(root).text()
 const models = await $`bun sst secret list`.cwd(root).text()
 
 
 // read the line starting with "ZEN_MODELS"
 // read the line starting with "ZEN_MODELS"
-const oldValue1 = models
-  .split("\n")
-  .find((line) => line.startsWith("ZEN_MODELS1"))
-  ?.split("=")[1]
-const oldValue2 = models
-  .split("\n")
-  .find((line) => line.startsWith("ZEN_MODELS2"))
-  ?.split("=")[1]
+const lines = models.split("\n")
+const oldValue1 = lines.find((line) => line.startsWith("ZEN_MODELS1"))?.split("=")[1]
+const oldValue2 = lines.find((line) => line.startsWith("ZEN_MODELS2"))?.split("=")[1]
+const oldValue3 = lines.find((line) => line.startsWith("ZEN_MODELS3"))?.split("=")[1]
+const oldValue4 = lines.find((line) => line.startsWith("ZEN_MODELS4"))?.split("=")[1]
 if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
 if (!oldValue1) throw new Error("ZEN_MODELS1 not found")
 if (!oldValue2) throw new Error("ZEN_MODELS2 not found")
 if (!oldValue2) throw new Error("ZEN_MODELS2 not found")
+if (!oldValue3) throw new Error("ZEN_MODELS3 not found")
+if (!oldValue4) throw new Error("ZEN_MODELS4 not found")
 
 
 // store the prettified json to a temp file
 // store the prettified json to a temp file
 const filename = `models-${Date.now()}.json`
 const filename = `models-${Date.now()}.json`
 const tempFile = Bun.file(path.join(os.tmpdir(), filename))
 const tempFile = Bun.file(path.join(os.tmpdir(), filename))
-await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2), null, 2))
+await tempFile.write(JSON.stringify(JSON.parse(oldValue1 + oldValue2 + oldValue3 + oldValue4), null, 2))
 console.log("tempFile", tempFile.name)
 console.log("tempFile", tempFile.name)
 
 
 // open temp file in vim and read the file on close
 // open temp file in vim and read the file on close
@@ -32,6 +31,12 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
 ZenData.validate(JSON.parse(newValue))
 ZenData.validate(JSON.parse(newValue))
 
 
 // update the secret
 // update the secret
-const mid = Math.floor(newValue.length / 2)
-await $`bun sst secret set ZEN_MODELS1 ${newValue.slice(0, mid)}`
-await $`bun sst secret set ZEN_MODELS2 ${newValue.slice(mid)}`
+const chunk = Math.ceil(newValue.length / 4)
+const newValue1 = newValue.slice(0, chunk)
+const newValue2 = newValue.slice(chunk, chunk * 2)
+const newValue3 = newValue.slice(chunk * 2, chunk * 3)
+const newValue4 = newValue.slice(chunk * 3)
+await $`bun sst secret set ZEN_MODELS1 ${newValue1}`
+await $`bun sst secret set ZEN_MODELS2 ${newValue2}`
+await $`bun sst secret set ZEN_MODELS3 ${newValue3}`
+await $`bun sst secret set ZEN_MODELS4 ${newValue4}`

+ 2 - 0
packages/console/core/src/aws.ts

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

+ 10 - 1
packages/console/core/src/model.ts

@@ -24,6 +24,12 @@ export namespace ZenData {
     cost: ModelCostSchema,
     cost: ModelCostSchema,
     cost200K: ModelCostSchema.optional(),
     cost200K: ModelCostSchema.optional(),
     allowAnonymous: z.boolean().optional(),
     allowAnonymous: z.boolean().optional(),
+    trial: z
+      .object({
+        limit: z.number(),
+        provider: z.string(),
+      })
+      .optional(),
     rateLimit: z.number().optional(),
     rateLimit: z.number().optional(),
     fallbackProvider: z.string().optional(),
     fallbackProvider: z.string().optional(),
     providers: z.array(
     providers: z.array(
@@ -32,6 +38,7 @@ export namespace ZenData {
         model: z.string(),
         model: z.string(),
         weight: z.number().optional(),
         weight: z.number().optional(),
         disabled: z.boolean().optional(),
         disabled: z.boolean().optional(),
+        storeModel: z.string().optional(),
       }),
       }),
     ),
     ),
   })
   })
@@ -53,7 +60,9 @@ export namespace ZenData {
   })
   })
 
 
   export const list = fn(z.void(), () => {
   export const list = fn(z.void(), () => {
-    const json = JSON.parse(Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value)
+    const json = JSON.parse(
+      Resource.ZEN_MODELS1.value + Resource.ZEN_MODELS2.value + Resource.ZEN_MODELS3.value + Resource.ZEN_MODELS4.value,
+    )
     return ModelsSchema.parse(json)
     return ModelsSchema.parse(json)
   })
   })
 }
 }

+ 12 - 0
packages/console/core/src/schema/ip.sql.ts

@@ -0,0 +1,12 @@
+import { mysqlTable, int, primaryKey, varchar } from "drizzle-orm/mysql-core"
+import { timestamps } from "../drizzle/types"
+
+export const IpTable = mysqlTable(
+  "ip",
+  {
+    ip: varchar("ip", { length: 45 }).notNull(),
+    ...timestamps,
+    usage: int("usage"),
+  },
+  (table) => [primaryKey({ columns: [table.ip] })],
+)

+ 18 - 0
packages/console/core/sst-env.d.ts

@@ -74,6 +74,14 @@ declare module "sst" {
       type: "sst.sst.Secret"
       type: "sst.sst.Secret"
       value: string
       value: string
     }
     }
+    R2AccessKey: {
+      type: "sst.sst.Secret"
+      value: string
+    }
+    R2SecretKey: {
+      type: "sst.sst.Secret"
+      value: string
+    }
     STRIPE_SECRET_KEY: {
     STRIPE_SECRET_KEY: {
       type: "sst.sst.Secret"
       type: "sst.sst.Secret"
       value: string
       value: string
@@ -94,6 +102,14 @@ declare module "sst" {
       type: "sst.sst.Secret"
       type: "sst.sst.Secret"
       value: string
       value: string
     }
     }
+    ZEN_MODELS3: {
+      type: "sst.sst.Secret"
+      value: string
+    }
+    ZEN_MODELS4: {
+      type: "sst.sst.Secret"
+      value: string
+    }
   }
   }
 }
 }
 // cloudflare
 // cloudflare
@@ -104,6 +120,8 @@ declare module "sst" {
     AuthApi: cloudflare.Service
     AuthApi: cloudflare.Service
     AuthStorage: cloudflare.KVNamespace
     AuthStorage: cloudflare.KVNamespace
     Bucket: cloudflare.R2Bucket
     Bucket: cloudflare.R2Bucket
+    ConsoleData: cloudflare.R2Bucket
+    EnterpriseStorage: cloudflare.R2Bucket
     GatewayKv: cloudflare.KVNamespace
     GatewayKv: cloudflare.KVNamespace
     LogProcessor: cloudflare.Service
     LogProcessor: cloudflare.Service
   }
   }

+ 1 - 1
packages/console/function/package.json

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

+ 18 - 0
packages/console/function/sst-env.d.ts

@@ -74,6 +74,14 @@ declare module "sst" {
       type: "sst.sst.Secret"
       type: "sst.sst.Secret"
       value: string
       value: string
     }
     }
+    R2AccessKey: {
+      type: "sst.sst.Secret"
+      value: string
+    }
+    R2SecretKey: {
+      type: "sst.sst.Secret"
+      value: string
+    }
     STRIPE_SECRET_KEY: {
     STRIPE_SECRET_KEY: {
       type: "sst.sst.Secret"
       type: "sst.sst.Secret"
       value: string
       value: string
@@ -94,6 +102,14 @@ declare module "sst" {
       type: "sst.sst.Secret"
       type: "sst.sst.Secret"
       value: string
       value: string
     }
     }
+    ZEN_MODELS3: {
+      type: "sst.sst.Secret"
+      value: string
+    }
+    ZEN_MODELS4: {
+      type: "sst.sst.Secret"
+      value: string
+    }
   }
   }
 }
 }
 // cloudflare
 // cloudflare
@@ -104,6 +120,8 @@ declare module "sst" {
     AuthApi: cloudflare.Service
     AuthApi: cloudflare.Service
     AuthStorage: cloudflare.KVNamespace
     AuthStorage: cloudflare.KVNamespace
     Bucket: cloudflare.R2Bucket
     Bucket: cloudflare.R2Bucket
+    ConsoleData: cloudflare.R2Bucket
+    EnterpriseStorage: cloudflare.R2Bucket
     GatewayKv: cloudflare.KVNamespace
     GatewayKv: cloudflare.KVNamespace
     LogProcessor: cloudflare.Service
     LogProcessor: cloudflare.Service
   }
   }

+ 1 - 1
packages/console/mail/package.json

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

+ 1 - 0
packages/console/resource/resource.cloudflare.ts

@@ -1,4 +1,5 @@
 import { env } from "cloudflare:workers"
 import { env } from "cloudflare:workers"
+export { waitUntil } from "cloudflare:workers"
 
 
 export const Resource = new Proxy(
 export const Resource = new Proxy(
   {},
   {},

+ 51 - 39
packages/console/resource/resource.node.ts

@@ -2,54 +2,66 @@ import type { KVNamespaceListOptions, KVNamespaceListResult, KVNamespacePutOptio
 import { Resource as ResourceBase } from "sst"
 import { Resource as ResourceBase } from "sst"
 import Cloudflare from "cloudflare"
 import Cloudflare from "cloudflare"
 
 
+export const waitUntil = async (fn: () => Promise<void>) => {
+  await fn()
+}
+
 export const Resource = new Proxy(
 export const Resource = new Proxy(
   {},
   {},
   {
   {
     get(_target, prop: keyof typeof ResourceBase) {
     get(_target, prop: keyof typeof ResourceBase) {
       const value = ResourceBase[prop]
       const value = ResourceBase[prop]
-      // @ts-ignore
-      if ("type" in value && value.type === "sst.cloudflare.Kv") {
-        const client = new Cloudflare({
-          apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value,
-        })
+      if ("type" in value) {
+        // @ts-ignore
+        if (value.type === "sst.cloudflare.Bucket") {
+          return {
+            put: async () => {},
+          }
+        }
         // @ts-ignore
         // @ts-ignore
-        const namespaceId = value.namespaceId
-        const accountId = ResourceBase.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value
-        return {
-          get: (k: string | string[]) => {
-            const isMulti = Array.isArray(k)
-            return client.kv.namespaces
-              .bulkGet(namespaceId, {
-                keys: Array.isArray(k) ? k : [k],
+        if (value.type === "sst.cloudflare.Kv") {
+          const client = new Cloudflare({
+            apiToken: ResourceBase.CLOUDFLARE_API_TOKEN.value,
+          })
+          // @ts-ignore
+          const namespaceId = value.namespaceId
+          const accountId = ResourceBase.CLOUDFLARE_DEFAULT_ACCOUNT_ID.value
+          return {
+            get: (k: string | string[]) => {
+              const isMulti = Array.isArray(k)
+              return client.kv.namespaces
+                .bulkGet(namespaceId, {
+                  keys: Array.isArray(k) ? k : [k],
+                  account_id: accountId,
+                })
+                .then((result) => (isMulti ? new Map(Object.entries(result?.values ?? {})) : result?.values?.[k]))
+            },
+            put: (k: string, v: string, opts?: KVNamespacePutOptions) =>
+              client.kv.namespaces.values.update(namespaceId, k, {
                 account_id: accountId,
                 account_id: accountId,
-              })
-              .then((result) => (isMulti ? new Map(Object.entries(result?.values ?? {})) : result?.values?.[k]))
-          },
-          put: (k: string, v: string, opts?: KVNamespacePutOptions) =>
-            client.kv.namespaces.values.update(namespaceId, k, {
-              account_id: accountId,
-              value: v,
-              expiration: opts?.expiration,
-              expiration_ttl: opts?.expirationTtl,
-              metadata: opts?.metadata,
-            }),
-          delete: (k: string) =>
-            client.kv.namespaces.values.delete(namespaceId, k, {
-              account_id: accountId,
-            }),
-          list: (opts?: KVNamespaceListOptions): Promise<KVNamespaceListResult<unknown, string>> =>
-            client.kv.namespaces.keys
-              .list(namespaceId, {
+                value: v,
+                expiration: opts?.expiration,
+                expiration_ttl: opts?.expirationTtl,
+                metadata: opts?.metadata,
+              }),
+            delete: (k: string) =>
+              client.kv.namespaces.values.delete(namespaceId, k, {
                 account_id: accountId,
                 account_id: accountId,
-                prefix: opts?.prefix ?? undefined,
-              })
-              .then((result) => {
-                return {
-                  keys: result.result,
-                  list_complete: true,
-                  cacheStatus: null,
-                }
               }),
               }),
+            list: (opts?: KVNamespaceListOptions): Promise<KVNamespaceListResult<unknown, string>> =>
+              client.kv.namespaces.keys
+                .list(namespaceId, {
+                  account_id: accountId,
+                  prefix: opts?.prefix ?? undefined,
+                })
+                .then((result) => {
+                  return {
+                    keys: result.result,
+                    list_complete: true,
+                    cacheStatus: null,
+                  }
+                }),
+          }
         }
         }
       }
       }
       return value
       return value

+ 18 - 0
packages/console/resource/sst-env.d.ts

@@ -74,6 +74,14 @@ declare module "sst" {
       type: "sst.sst.Secret"
       type: "sst.sst.Secret"
       value: string
       value: string
     }
     }
+    R2AccessKey: {
+      type: "sst.sst.Secret"
+      value: string
+    }
+    R2SecretKey: {
+      type: "sst.sst.Secret"
+      value: string
+    }
     STRIPE_SECRET_KEY: {
     STRIPE_SECRET_KEY: {
       type: "sst.sst.Secret"
       type: "sst.sst.Secret"
       value: string
       value: string
@@ -94,6 +102,14 @@ declare module "sst" {
       type: "sst.sst.Secret"
       type: "sst.sst.Secret"
       value: string
       value: string
     }
     }
+    ZEN_MODELS3: {
+      type: "sst.sst.Secret"
+      value: string
+    }
+    ZEN_MODELS4: {
+      type: "sst.sst.Secret"
+      value: string
+    }
   }
   }
 }
 }
 // cloudflare
 // cloudflare
@@ -104,6 +120,8 @@ declare module "sst" {
     AuthApi: cloudflare.Service
     AuthApi: cloudflare.Service
     AuthStorage: cloudflare.KVNamespace
     AuthStorage: cloudflare.KVNamespace
     Bucket: cloudflare.R2Bucket
     Bucket: cloudflare.R2Bucket
+    ConsoleData: cloudflare.R2Bucket
+    EnterpriseStorage: cloudflare.R2Bucket
     GatewayKv: cloudflare.KVNamespace
     GatewayKv: cloudflare.KVNamespace
     LogProcessor: cloudflare.Service
     LogProcessor: cloudflare.Service
   }
   }

+ 9 - 2
packages/desktop/index.html

@@ -3,9 +3,16 @@
   <head>
   <head>
     <meta charset="utf-8" />
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <meta name="theme-color" content="#000000" />
-    <link rel="shortcut icon" type="image/svg+xml" href="/favicon.svg" />
     <title>OpenCode</title>
     <title>OpenCode</title>
+    <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
+    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
+    <link rel="shortcut icon" href="/favicon.ico" />
+    <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+    <link rel="manifest" href="/site.webmanifest" />
+    <meta name="theme-color" content="#F8F7F7" />
+    <meta name="theme-color" content="#131010" media="(prefers-color-scheme: dark)" />
+    <meta property="og:image" content="/social-share.png" />
+    <meta property="twitter:image" content="/social-share.png" />
   </head>
   </head>
   <body class="antialiased overscroll-none select-none text-12-regular">
   <body class="antialiased overscroll-none select-none text-12-regular">
     <script>
     <script>

+ 4 - 3
packages/desktop/package.json

@@ -1,6 +1,6 @@
 {
 {
   "name": "@opencode-ai/desktop",
   "name": "@opencode-ai/desktop",
-  "version": "1.0.80",
+  "version": "1.0.121",
   "description": "",
   "description": "",
   "type": "module",
   "type": "module",
   "scripts": {
   "scripts": {
@@ -14,7 +14,7 @@
   "devDependencies": {
   "devDependencies": {
     "@tailwindcss/vite": "catalog:",
     "@tailwindcss/vite": "catalog:",
     "@tsconfig/bun": "1.0.9",
     "@tsconfig/bun": "1.0.9",
-    "@types/luxon": "3.7.1",
+    "@types/luxon": "catalog:",
     "@types/node": "catalog:",
     "@types/node": "catalog:",
     "@typescript/native-preview": "catalog:",
     "@typescript/native-preview": "catalog:",
     "typescript": "catalog:",
     "typescript": "catalog:",
@@ -26,6 +26,7 @@
     "@kobalte/core": "catalog:",
     "@kobalte/core": "catalog:",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
+    "@opencode-ai/util": "workspace:*",
     "@shikijs/transformers": "3.9.2",
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/active-element": "2.1.3",
     "@solid-primitives/active-element": "2.1.3",
     "@solid-primitives/event-bus": "1.1.2",
     "@solid-primitives/event-bus": "1.1.2",
@@ -33,7 +34,7 @@
     "@solid-primitives/scroll": "2.1.3",
     "@solid-primitives/scroll": "2.1.3",
     "@solid-primitives/storage": "4.3.3",
     "@solid-primitives/storage": "4.3.3",
     "@solidjs/meta": "catalog:",
     "@solidjs/meta": "catalog:",
-    "@solidjs/router": "0.15.3",
+    "@solidjs/router": "catalog:",
     "@thisbeyond/solid-dnd": "0.7.5",
     "@thisbeyond/solid-dnd": "0.7.5",
     "diff": "catalog:",
     "diff": "catalog:",
     "fuzzysort": "catalog:",
     "fuzzysort": "catalog:",

+ 1 - 0
packages/desktop/public/apple-touch-icon.png

@@ -0,0 +1 @@
+../../ui/src/assets/favicon/apple-touch-icon.png

+ 1 - 0
packages/desktop/public/favicon-96x96.png

@@ -0,0 +1 @@
+../../ui/src/assets/favicon/favicon-96x96.png

+ 1 - 0
packages/desktop/public/favicon.ico

@@ -0,0 +1 @@
+../../ui/src/assets/favicon/favicon.ico

+ 1 - 0
packages/desktop/public/site.webmanifest

@@ -0,0 +1 @@
+../../ui/src/assets/favicon/site.webmanifest

BIN
packages/desktop/public/social-share.png


+ 1 - 0
packages/desktop/public/web-app-manifest-192x192.png

@@ -0,0 +1 @@
+../../ui/src/assets/favicon/web-app-manifest-192x192.png

+ 1 - 0
packages/desktop/public/web-app-manifest-512x512.png

@@ -0,0 +1 @@
+../../ui/src/assets/favicon/web-app-manifest-512x512.png

+ 4 - 2
packages/desktop/src/components/file-tree.tsx

@@ -1,6 +1,7 @@
 import { useLocal, type LocalFile } from "@/context/local"
 import { useLocal, type LocalFile } from "@/context/local"
-import { Tooltip } from "@opencode-ai/ui"
-import { Collapsible, FileIcon } from "@/ui"
+import { Collapsible } from "@opencode-ai/ui/collapsible"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
 import { For, Match, Switch, Show, type ComponentProps, type ParentProps } from "solid-js"
 import { Dynamic } from "solid-js/web"
 import { Dynamic } from "solid-js/web"
 
 
@@ -75,6 +76,7 @@ export default function FileTree(props: {
             <Switch>
             <Switch>
               <Match when={node.type === "directory"}>
               <Match when={node.type === "directory"}>
                 <Collapsible
                 <Collapsible
+                  variant="ghost"
                   class="w-full"
                   class="w-full"
                   forceMount={false}
                   forceMount={false}
                   // open={local.file.node(node.path)?.expanded}
                   // open={local.file.node(node.path)?.expanded}

+ 11 - 6
packages/desktop/src/components/prompt-input.tsx

@@ -1,9 +1,6 @@
-import { Button, Icon, IconButton, Select, SelectDialog, Tooltip } from "@opencode-ai/ui"
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { useFilteredList } from "@opencode-ai/ui/hooks"
 import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
 import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
-import { FileIcon } from "@/ui"
-import { getDirectory, getFilename } from "@/utils"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
 import { useLocal } from "@/context/local"
 import { DateTime } from "luxon"
 import { DateTime } from "luxon"
@@ -11,6 +8,14 @@ import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "
 import { useSDK } from "@/context/sdk"
 import { useSDK } from "@/context/sdk"
 import { useNavigate } from "@solidjs/router"
 import { useNavigate } from "@solidjs/router"
 import { useSync } from "@/context/sync"
 import { useSync } from "@/context/sync"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { SelectDialog } from "@opencode-ai/ui/select-dialog"
+import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Select } from "@opencode-ai/ui/select"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
 
 
 interface PromptInputProps {
 interface PromptInputProps {
   class?: string
   class?: string
@@ -184,8 +189,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const range = selection.getRangeAt(0)
       const range = selection.getRangeAt(0)
 
 
       if (atMatch) {
       if (atMatch) {
-        let node: Node | null = range.startContainer
-        let offset = range.startOffset
+        // let node: Node | null = range.startContainer
+        // let offset = range.startOffset
         let runningLength = 0
         let runningLength = 0
 
 
         const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
         const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
@@ -448,7 +453,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               {(i) => (
               {(i) => (
                 <div class="w-full flex items-center justify-between gap-x-3">
                 <div class="w-full flex items-center justify-between gap-x-3">
                   <div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
                   <div class="flex items-center gap-x-2.5 text-text-muted grow min-w-0">
-                    <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0 " />
+                    <img src={`https://models.dev/logos/${i.provider.id}.svg`} class="size-6 p-0.5 shrink-0" />
                     <div class="flex gap-x-3 items-baseline flex-[1_0_0]">
                     <div class="flex gap-x-3 items-baseline flex-[1_0_0]">
                       <span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
                       <span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
                       <Show when={i.release_date}>
                       <Show when={i.release_date}>

+ 0 - 104
packages/desktop/src/components/session-review.tsx

@@ -1,104 +0,0 @@
-import { useSession } from "@/context/session"
-import { FileIcon } from "@/ui"
-import { getDirectory, getFilename } from "@/utils"
-import { Accordion, Button, Diff, DiffChanges, Icon, IconButton, Tooltip } from "@opencode-ai/ui"
-import { For, Match, Show, Switch } from "solid-js"
-import { StickyAccordionHeader } from "./sticky-accordion-header"
-import { createStore } from "solid-js/store"
-import { useLayout } from "@/context/layout"
-
-export const SessionReview = (props: { split?: boolean; class?: string; hideExpand?: boolean }) => {
-  const layout = useLayout()
-  const session = useSession()
-  const [store, setStore] = createStore({
-    open: session.diffs().map((d) => d.file),
-  })
-
-  const handleChange = (open: string[]) => {
-    setStore("open", open)
-  }
-
-  const handleExpandOrCollapseAll = () => {
-    if (store.open.length > 0) {
-      setStore("open", [])
-    } else {
-      setStore(
-        "open",
-        session.diffs().map((d) => d.file),
-      )
-    }
-  }
-
-  return (
-    <div
-      classList={{
-        "flex flex-col gap-3 h-full overflow-y-auto no-scrollbar": true,
-        [props.class ?? ""]: !!props.class,
-      }}
-    >
-      <div class="sticky top-0 z-20 bg-background-stronger h-8 shrink-0 flex justify-between items-center self-stretch">
-        <div class="text-14-medium text-text-strong">Session changes</div>
-        <div class="flex items-center gap-x-4 pr-px">
-          <Button size="normal" icon="chevron-grabber-vertical" onClick={handleExpandOrCollapseAll}>
-            <Switch>
-              <Match when={store.open.length > 0}>Collapse all</Match>
-              <Match when={true}>Expand all</Match>
-            </Switch>
-          </Button>
-          <Show when={!props.hideExpand}>
-            <Tooltip value="Open in tab">
-              <IconButton
-                icon="expand"
-                variant="ghost"
-                onClick={() => {
-                  layout.review.tab()
-                  session.layout.setActiveTab("review")
-                }}
-              />
-            </Tooltip>
-          </Show>
-        </div>
-      </div>
-      <Accordion multiple value={store.open} onChange={handleChange}>
-        <For each={session.diffs()}>
-          {(diff) => (
-            <Accordion.Item value={diff.file}>
-              <StickyAccordionHeader class="top-11 data-expanded:before:-top-11">
-                <Accordion.Trigger class="bg-background-stronger">
-                  <div class="flex items-center justify-between w-full gap-5">
-                    <div class="grow flex items-center gap-5 min-w-0">
-                      <FileIcon node={{ path: diff.file, type: "file" }} class="shrink-0 size-4" />
-                      <div class="flex grow min-w-0">
-                        <Show when={diff.file.includes("/")}>
-                          <span class="text-text-base truncate-start">{getDirectory(diff.file)}&lrm;</span>
-                        </Show>
-                        <span class="text-text-strong shrink-0">{getFilename(diff.file)}</span>
-                      </div>
-                    </div>
-                    <div class="shrink-0 flex gap-4 items-center justify-end">
-                      <DiffChanges changes={diff} />
-                      <Icon name="chevron-grabber-vertical" size="small" />
-                    </div>
-                  </div>
-                </Accordion.Trigger>
-              </StickyAccordionHeader>
-              <Accordion.Content>
-                <Diff
-                  diffStyle={props.split ? "split" : "unified"}
-                  before={{
-                    name: diff.file!,
-                    contents: diff.before!,
-                  }}
-                  after={{
-                    name: diff.file!,
-                    contents: diff.after!,
-                  }}
-                />
-              </Accordion.Content>
-            </Accordion.Item>
-          )}
-        </For>
-      </Accordion>
-    </div>
-  )
-}

+ 0 - 17
packages/desktop/src/components/sticky-accordion-header.tsx

@@ -1,17 +0,0 @@
-import { Accordion } from "@opencode-ai/ui"
-import { ParentProps } from "solid-js"
-
-export function StickyAccordionHeader(props: ParentProps<{ class?: string }>) {
-  return (
-    <Accordion.Header
-      classList={{
-        "sticky top-0 data-expanded:z-10": true,
-        "data-expanded:before:content-[''] data-expanded:before:z-[-10]": true,
-        "data-expanded:before:absolute data-expanded:before:inset-0 data-expanded:before:bg-background-stronger": true,
-        [props.class ?? ""]: !!props.class,
-      }}
-    >
-      {props.children}
-    </Accordion.Header>
-  )
-}

+ 1 - 1
packages/desktop/src/context/global-sdk.tsx

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

+ 2 - 2
packages/desktop/src/context/global-sync.tsx

@@ -14,8 +14,8 @@ import type {
   SessionStatus,
   SessionStatus,
 } from "@opencode-ai/sdk"
 } from "@opencode-ai/sdk"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { createStore, produce, reconcile } from "solid-js/store"
-import { Binary } from "@/utils/binary"
-import { createSimpleContext } from "./helper"
+import { Binary } from "@opencode-ai/util/binary"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSDK } from "./global-sdk"
 import { useGlobalSDK } from "./global-sdk"
 
 
 type State = {
 type State = {

+ 0 - 25
packages/desktop/src/context/helper.tsx

@@ -1,25 +0,0 @@
-import { createContext, Show, useContext, type ParentProps } from "solid-js"
-
-export function createSimpleContext<T, Props extends Record<string, any>>(input: {
-  name: string
-  init: ((input: Props) => T) | (() => T)
-}) {
-  const ctx = createContext<T>()
-
-  return {
-    provider: (props: ParentProps<Props>) => {
-      const init = input.init(props)
-      return (
-        // @ts-expect-error
-        <Show when={init.ready === undefined || init.ready === true}>
-          <ctx.Provider value={init}>{props.children}</ctx.Provider>
-        </Show>
-      )
-    },
-    use() {
-      const value = useContext(ctx)
-      if (!value) throw new Error(`${input.name} context must be used within a context provider`)
-      return value
-    },
-  }
-}

+ 1 - 1
packages/desktop/src/context/layout.tsx

@@ -1,6 +1,6 @@
 import { createStore } from "solid-js/store"
 import { createStore } from "solid-js/store"
 import { createMemo } from "solid-js"
 import { createMemo } from "solid-js"
-import { createSimpleContext } from "./helper"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { makePersisted } from "@solid-primitives/storage"
 import { makePersisted } from "@solid-primitives/storage"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSync } from "./global-sync"
 
 

+ 1 - 1
packages/desktop/src/context/local.tsx

@@ -2,7 +2,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
 import { batch, createEffect, createMemo } from "solid-js"
 import { batch, createEffect, createMemo } from "solid-js"
 import { uniqueBy } from "remeda"
 import { uniqueBy } from "remeda"
 import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
 import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk"
-import { createSimpleContext } from "./helper"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useSDK } from "./sdk"
 import { useSDK } from "./sdk"
 import { useSync } from "./sync"
 import { useSync } from "./sync"
 import { base64Encode } from "@/utils"
 import { base64Encode } from "@/utils"

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików