Просмотр исходного кода

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 месяцев назад
Родитель
Сommit
a820720b57
100 измененных файлов с 2308 добавлено и 519 удалено
  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: actions/setup-node@v4
+        with:
+          node-version: "24"
+
       - run: bun sst deploy --stage=${{ github.ref_name }}
         env:
           CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}

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

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

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

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

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

@@ -61,6 +61,13 @@ jobs:
         run: |
           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
         run: |
           ./script/publish.ts

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

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

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

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

+ 1 - 0
.gitignore

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

+ 7 - 0
.husky/pre-push

@@ -1,2 +1,9 @@
 #!/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

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

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

+ 16 - 0
.opencode/opencode.jsonc

@@ -1,6 +1,9 @@
 {
   "$schema": "https://opencode.ai/config.json",
   "plugin": ["opencode-openai-codex-auth"],
+  // "enterprise": {
+  //   "url": "https://enterprise.dev.opencode.ai",
+  // },
   "provider": {
     "opencode": {
       "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:
 
 - 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
 - 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.

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


Разница между файлами не показана из-за своего большого размера
+ 101 - 72
bun.lock


+ 3 - 3
flake.lock

@@ -2,11 +2,11 @@
   "nodes": {
     "nixpkgs": {
       "locked": {
-        "lastModified": 1762156382,
-        "narHash": "sha256-Yg7Ag7ov5+36jEFC1DaZh/12SEXo6OO3/8rqADRxiqs=",
+        "lastModified": 1764384123,
+        "narHash": "sha256-UoliURDJFaOolycBZYrjzd9Cc66zULEyHqGFH3QHEq0=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "7241bcbb4f099a66aafca120d37c65e8dda32717",
+        "rev": "59b6c96beacc898566c9be1052ae806f3835f87d",
         "type": "github"
       },
       "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
 ```
 
+#### 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
 
 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:
      issue_comment:
        types: [created]
+     pull_request_review_comment:
+       types: [created]
 
    jobs:
      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"}}}'
 ```
+
+### 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 github from "@actions/github"
 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 { spawn } from "node:child_process"
 
@@ -124,7 +124,7 @@ let exitCode = 0
 type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
 
 try {
-  assertContextEvent("issue_comment")
+  assertContextEvent("issue_comment", "pull_request_review_comment")
   assertPayloadKeyword()
   await assertOpencodeConnected()
 
@@ -241,19 +241,43 @@ function createOpencode() {
 }
 
 function assertPayloadKeyword() {
-  const payload = useContext().payload as IssueCommentEvent
+  const payload = useContext().payload as IssueCommentEvent | PullRequestReviewCommentEvent
   const body = payload.comment.body.trim()
   if (!body.match(/(?:^|\s)(?:\/opencode|\/oc)(?=$|\s)/)) {
     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() {
   let retry = 0
   let connected = false
   do {
     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
       break
     } catch (e) {}
@@ -383,11 +407,24 @@ async function createComment() {
 }
 
 async function getUserPrompt() {
+  const context = useContext()
+  const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
+  const reviewContext = getReviewCommentContext()
+
   let prompt = (() => {
-    const payload = useContext().payload as IssueCommentEvent
     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`")
   })()
 

+ 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 AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
   properties: { value: auth.url.apply((url) => url!) },
@@ -112,6 +116,8 @@ const gatewayKv = new sst.cloudflare.Kv("GatewayKv")
 // 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_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
 
@@ -128,15 +134,15 @@ new sst.cloudflare.x.SolidStart("Console", {
   domain,
   path: "packages/console/app",
   link: [
+    bucket,
     database,
     AUTH_API_URL,
     STRIPE_WEBHOOK_SECRET,
     STRIPE_SECRET_KEY,
-    ZEN_MODELS1,
-    ZEN_MODELS2,
     EMAILOCTOPUS_API_KEY,
     AWS_SES_ACCESS_KEY_ID,
     AWS_SES_SECRET_ACCESS_KEY,
+    ...ZEN_MODELS,
     ...($dev
       ? [
           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
 APP=opencode
 
+MUTED='\033[0;2m'
 RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
 ORANGE='\033[38;2;255;140;0m'
 NC='\033[0m' # No Color
 
@@ -12,39 +11,94 @@ requested_version=${VERSION:-}
 
 raw_os=$(uname -s)
 os=$(echo "$raw_os" | tr '[:upper:]' '[:lower:]')
-# Normalize various Unix-like identifiers
 case "$raw_os" in
   Darwin*) os="darwin" ;;
   Linux*) os="linux" ;;
   MINGW*|MSYS*|CYGWIN*) os="windows" ;;
- esac
-arch=$(uname -m)
+esac
 
+arch=$(uname -m)
 if [[ "$arch" == "aarch64" ]]; then
   arch="arm64"
-elif [[ "$arch" == "x86_64" ]]; then
+fi
+if [[ "$arch" == "x86_64" ]]; then
   arch="x64"
 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
 
+archive_ext=".zip"
+if [ "$os" = "linux" ]; then
+  archive_ext=".tar.gz"
+fi
+
+is_musl=false
+if [ "$os" = "linux" ]; then
+  if [ -f /etc/alpine-release ]; then
+    is_musl=true
+  fi
+
+  if command -v ldd >/dev/null 2>&1; then
+    if ldd --version 2>&1 | grep -qi musl; then
+      is_musl=true
+    fi
+  fi
+fi
+
+needs_baseline=false
+if [ "$arch" = "x64" ]; then
+  if [ "$os" = "linux" ]; then
+    if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then
+      needs_baseline=true
+    fi
+  fi
+
+  if [ "$os" = "darwin" ]; then
+    avx2=$(sysctl -n hw.optional.avx2_0 2>/dev/null || echo 0)
+    if [ "$avx2" != "1" ]; then
+      needs_baseline=true
+    fi
+  fi
+fi
+
+target="$os-$arch"
+if [ "$needs_baseline" = "true" ]; then
+  target="$target-baseline"
+fi
+if [ "$is_musl" = "true" ]; then
+  target="$target-musl"
+fi
+
+filename="$APP-$target$archive_ext"
+
+
+if [ "$os" = "linux" ]; then
+    if ! command -v tar >/dev/null 2>&1; then
+         echo -e "${RED}Error: 'tar' is required but not installed.${NC}"
+         exit 1
+    fi
+else
+    if ! command -v unzip >/dev/null 2>&1; then
+        echo -e "${RED}Error: 'unzip' is required but not installed.${NC}"
+        exit 1
+    fi
+fi
+
 INSTALL_DIR=$HOME/.opencode/bin
 mkdir -p "$INSTALL_DIR"
 
@@ -67,8 +121,8 @@ print_message() {
     local color=""
 
     case $level in
-        info) color="${GREEN}" ;;
-        warning) color="${YELLOW}" ;;
+        info) color="${NC}" ;;
+        warning) color="${NC}" ;;
         error) color="${RED}" ;;
     esac
 
@@ -86,19 +140,119 @@ check_version() {
         installed_version=$(echo $installed_version | awk '{print $2}')
 
         if [[ "$installed_version" != "$specific_version" ]]; then
-            print_message info "Installed version: ${YELLOW}$installed_version."
+            print_message info "${MUTED}Installed version: ${NC}$installed_version."
         else
-            print_message info "Version ${YELLOW}$specific_version${GREEN} already installed"
+            print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
             exit 0
         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() {
-    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
-    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"
     chmod 755 "${INSTALL_DIR}/opencode"
     cd .. && rm -rf opencodetmp
@@ -117,7 +271,7 @@ add_to_path() {
     elif [[ -w $config_file ]]; then
         echo -e "\n# opencode" >> "$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
         print_message warning "Manually add the directory to $config_file (or similar):"
         print_message info "  $command"
@@ -191,3 +345,20 @@ if [ -n "${GITHUB_ACTIONS-}" ] && [ "${GITHUB_ACTIONS}" == "true" ]; then
     echo "$INSTALL_DIR" >> $GITHUB_PATH
     print_message info "Added $INSTALL_DIR to \$GITHUB_PATH"
 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:
 let
   scripts = args.scripts;
@@ -28,66 +28,93 @@ stdenvNoCC.mkDerivation (finalAttrs: {
     makeBinaryWrapper
   ];
 
-  configurePhase = ''
-    runHook preConfigure
-    cp -R ${finalAttrs.node_modules}/. .
-    runHook postConfigure
-  '';
-
   env.MODELS_DEV_API_JSON = args.modelsDev;
   env.OPENCODE_VERSION = args.version;
   env.OPENCODE_CHANNEL = "stable";
+  dontConfigure = true;
 
   buildPhase = ''
     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
   '';
 
-  dontStrip = true;
-
   installPhase = ''
     runHook preInstall
 
     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
     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
     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
   '';
 
-  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 = {
     description = "AI coding agent built for the terminal";
     longDescription = ''

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

@@ -24,15 +24,13 @@ for (const entry of directories) {
   if (!info.isDirectory()) {
     continue
   }
-  const marker = entry.lastIndexOf("@")
-  if (marker <= 0) {
+  const parsed = parseEntry(entry)
+  if (!parsed) {
     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
@@ -79,6 +77,12 @@ for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0]
   await mkdir(parent, { recursive: true })
   const linkPath = join(parent, leaf)
   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 resolved = relativeTarget.length === 0 ? "." : relativeTarget
   await rm(linkPath, { recursive: true, force: true })
@@ -94,3 +98,16 @@ for (const line of rewrites.slice(0, 20)) {
 if (rewrites.length > 20) {
   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",
   "private": true,
   "type": "module",
-  "packageManager": "[email protected].2",
+  "packageManager": "[email protected].3",
   "scripts": {
     "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
     "typecheck": "bun turbo typecheck",
     "prepare": "husky",
-    "random": "echo 'Random script'"
+    "random": "echo 'Random script'",
+    "hello": "echo 'Hello World!'"
   },
   "workspaces": {
     "packages": [
@@ -20,33 +21,37 @@
       "packages/opencode/webgui"
     ],
     "catalog": {
-      "@types/bun": "1.3.0",
+      "@types/bun": "1.3.3",
       "@hono/zod-validator": "0.4.2",
       "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
+      "@types/luxon": "3.7.1",
       "@types/node": "22.13.9",
       "@tsconfig/node22": "22.0.2",
       "@tsconfig/bun": "1.0.9",
       "@cloudflare/workers-types": "4.20251008.0",
       "@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",
       "diff": "8.0.2",
       "ai": "5.0.97",
       "hono": "4.7.10",
+      "hono-openapi": "1.1.1",
       "fuzzysort": "3.1.0",
       "luxon": "3.6.1",
       "typescript": "5.8.2",
       "@typescript/native-preview": "7.0.0-dev.20251014.1",
       "zod": "4.1.8",
       "remeda": "2.26.0",
-      "solid-js": "1.9.9",
       "solid-list": "0.3.0",
       "tailwindcss": "4.1.11",
       "virtua": "0.42.3",
       "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": {
@@ -59,8 +64,10 @@
     "remark-gfm": "4.0.1"
   },
   "dependencies": {
+    "@aws-sdk/client-s3": "3.933.0",
     "@opencode-ai/script": "workspace:*",
-    "@opencode-ai/sdk": "workspace:*"
+    "@opencode-ai/sdk": "workspace:*",
+    "typescript": "catalog:"
   },
   "repository": {
     "type": "git",
@@ -79,9 +86,6 @@
     "tree-sitter-bash",
     "web-tree-sitter"
   ],
-  "patchedDependencies": {
-    "@solidjs/[email protected]": "patches/@solidjs%[email protected]"
-  },
   "overrides": {
     "@types/bun": "catalog:",
     "@types/node": "catalog:"

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

@@ -3,7 +3,6 @@ dist
 .output
 .vercel
 .netlify
-.vinxi
 app.config.timestamp_*.js
 
 # 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",
+  "version": "1.0.121",
   "type": "module",
   "scripts": {
     "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",
-    "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": {
+    "@cloudflare/vite-plugin": "1.15.2",
     "@ibm/plex": "6.4.1",
     "@jsx-email/render": "1.1.1",
     "@kobalte/core": "catalog:",
@@ -17,17 +18,20 @@
     "@opencode-ai/console-core": "workspace:*",
     "@opencode-ai/console-mail": "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",
+    "nitro": "3.0.1-alpha.1",
     "solid-js": "catalog:",
-    "vinxi": "^0.5.7",
+    "vite": "catalog:",
     "zod": "catalog:"
   },
   "devDependencies": {
+    "@typescript/native-preview": "catalog:",
     "typescript": "catalog:",
-    "@typescript/native-preview": "catalog:"
+    "wrangler": "4.50.0"
   },
   "engines": {
     "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: /
 
 # 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 { Router } from "@solidjs/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 "./app.css"
 
@@ -13,6 +14,7 @@ export default function App() {
         <MetaProvider>
           <Title>opencode</Title>
           <Meta name="description" content="OpenCode - The AI coding agent built for the terminal." />
+          <Favicon />
           <Suspense>{props.children}</Suspense>
         </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 (
     <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>
@@ -236,3 +236,14 @@ export function IconChevronRight(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
     </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 {
   account?: Record<

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

@@ -9,7 +9,6 @@ export default createHandler(
           <head>
             <meta charset="utf-8" />
             <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="twitter:image" content="/social-share.png" />
             {assets}

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

@@ -1 +1,5 @@
 /// <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() {},
 })

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

@@ -36,6 +36,7 @@ ${body.email}`.trim()
       to: "[email protected]",
       subject: `Enterprise Inquiry from ${body.name}`,
       body: emailContent,
+      replyTo: body.email,
     })
 
     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 {
       ...value,
       account: {
+        ...value.account,
         [id]: {
           id,
           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 { 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 videoPoster from "../asset/lander/opencode-poster.png"
 import { IconCopy, IconCheck } from "../component/icon"
@@ -42,10 +42,9 @@ export default function Home() {
 
   return (
     <main data-page="opencode">
-      <HttpHeader name="Cache-Control" value="public, max-age=1, s-maxage=3600, stale-while-revalidate=86400" />
+      {/*<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>
       <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 name="twitter:image" content="/social-share.png" />
       <div data-component="container">

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

@@ -12,6 +12,7 @@
 
     [data-slot="item"] {
       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 { useAuthSession } from "~/context/auth.session"
 import { Dropdown } from "~/component/dropdown"
@@ -17,18 +17,15 @@ const logout = action(async () => {
       event!.locals.actor = undefined
       return val
     })
-  throw redirect("/zen")
-})
+}, "auth.logout")
 
 export function UserMenu(props: { email: string | null | undefined }) {
   return (
     <div data-component="user-menu">
       <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>
     </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 { User } from "@opencode-ai/console-core/user.js"
 import { Actor } from "@opencode-ai/console-core/actor.js"
-import { Link } from "@solidjs/meta"
 
 const getUserEmail = query(async (workspaceID: string) => {
   "use server"
@@ -22,7 +21,6 @@ export default function WorkspaceLayout(props: RouteSectionProps) {
   const userEmail = createAsync(() => getUserEmail(params.id!))
   return (
     <main data-page="workspace">
-      <Link rel="icon" type="image/svg+xml" href="/favicon-zen.svg" />
       <header data-component="workspace-header">
         <div data-slot="header-brand">
           <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 {
   IconAlibaba,
   IconAnthropic,
-  IconGoogle,
+  IconGemini,
   IconMoonshotAI,
   IconOpenAI,
   IconStealth,
@@ -117,7 +117,7 @@ export function ModelSection() {
                                 case "Anthropic":
                                   return <IconAnthropic width={16} height={16} />
                                 case "Google":
-                                  return <IconGoogle width={16} height={16} />
+                                  return <IconGemini width={16} height={16} />
                                 case "Moonshot AI":
                                   return <IconMoonshotAI width={16} height={16} />
                                 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 = [
   { name: "OpenAI", key: "openai", prefix: "sk-" },
   { name: "Anthropic", key: "anthropic", prefix: "sk-ant-" },
+  { name: "Google Gemini", key: "google", prefix: "AI" },
 ] as const
 
 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);
         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 {
@@ -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 { 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 { 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 { createStore } from "solid-js/store"
 
@@ -22,15 +22,38 @@ export function UsageSection() {
   const params = useParams()
   const usage = createAsync(() => queryUsageInfo(params.id!, 0))
   const [store, setStore] = createStore({ page: 0, usage: [] as Awaited<ReturnType<typeof getUsageInfo>> })
+  const [openBreakdownId, setOpenBreakdownId] = createSignal<string | null>(null)
 
   createEffect(() => {
     setStore({ 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 canGoPrev = createMemo(() => store.page > 0)
   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 usage = await getUsageInfo(params.id!, store.page - 1)
     setStore({
@@ -73,16 +96,79 @@ export function UsageSection() {
             </thead>
             <tbody>
               <For each={store.usage}>
-                {(usage) => {
+                {(usage, index) => {
                   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 (
                     <tr>
                       <td data-slot="usage-date" title={formatDateUTC(date())}>
                         {formatDateForTable(date())}
                       </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>
                     </tr>
                   )

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

@@ -1,7 +1,7 @@
 import "./index.css"
 import { createAsync, query, redirect } from "@solidjs/router"
 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 { config } from "~/config"
 import zenLogoDark from "../../asset/zen-ornate-dark.svg"
@@ -18,23 +18,24 @@ import { Legal } from "~/component/legal"
 import { Footer } from "~/component/footer"
 import { Header } from "~/component/header"
 import { getLastSeenWorkspaceID } from "../workspace/common"
+import { IconGemini, IconZai } from "~/component/icon"
 
 const checkLoggedIn = query(async () => {
   "use server"
-  const workspaceID = await getLastSeenWorkspaceID()
+  const workspaceID = await getLastSeenWorkspaceID().catch(() => {})
   if (workspaceID) throw redirect(`/workspace/${workspaceID}`)
 }, "checkLoggedIn.get")
 
 export default function Home() {
-  createAsync(() => checkLoggedIn())
+  const loggedin = createAsync(() => checkLoggedIn())
   return (
     <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>
       <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 name="twitter:image" content="/social-share-zen.png" />
+      <Meta name="opencode:auth" content={loggedin() ? "true" : "false"} />
 
       <div data-component="container">
         <Header zen />
@@ -81,6 +82,9 @@ export default function Home() {
                     />
                   </svg>
                 </div>
+                <div>
+                  <IconGemini width="24" height="24" />
+                </div>
                 <div>
                   <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
                     <path
@@ -111,6 +115,9 @@ export default function Home() {
                     />
                   </svg>
                 </div>
+                <div>
+                  <IconZai width="24" height="24" />
+                </div>
               </div>
               <a href="/auth">
                 <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 { logger } from "./logger"
 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 { googleHelper } from "./provider/google"
 import { openaiHelper } from "./provider/openai"
 import { oaCompatHelper } from "./provider/openai-compatible"
 import { createRateLimiter } from "./rateLimiter"
+import { createDataDumper } from "./dataDumper"
+import { createTrialLimiter } from "./trialLimiter"
 
 type ZenData = Awaited<ReturnType<typeof ZenData.list>>
 type RetryOptions = {
@@ -48,21 +56,26 @@ export async function handler(
   try {
     const url = input.request.url
     const body = await input.request.json()
-    const ip = input.request.headers.get("x-real-ip") ?? ""
     const model = opts.parseModel(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({
       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 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)
     await rateLimiter?.check()
 
     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)
       validateBilling(authInfo, modelInfo)
       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
     const resHeaders = new Headers()
@@ -126,8 +143,12 @@ export async function handler(
       const body = JSON.stringify(responseConverter(json))
       logger.metric({ response_length: body.length })
       logger.debug("RESPONSE: " + body)
+      dataDumper?.provideResponse(body)
+      dataDumper?.flush()
+      const tokensInfo = providerInfo.normalizeUsage(json.usage)
+      await trialLimiter?.track(tokensInfo)
       await rateLimiter?.track()
-      await trackUsage(authInfo, modelInfo, providerInfo, json.usage)
+      await trackUsage(authInfo, modelInfo, providerInfo, tokensInfo)
       await reload(authInfo)
       return new Response(body, {
         status: res.status,
@@ -155,10 +176,13 @@ export async function handler(
                   response_length: responseLength,
                   "timestamp.last_byte": Date.now(),
                 })
+                dataDumper?.flush()
                 await rateLimiter?.track()
                 const usage = usageParser.retrieve()
                 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)
                 }
                 c.close()
@@ -174,6 +198,7 @@ export async function handler(
               }
               responseLength += value.length
               buffer += decoder.decode(value, { stream: true })
+              dataDumper?.provideStream(buffer)
 
               const parts = buffer.split(providerInfo.streamSeparator)
               buffer = parts.pop() ?? ""
@@ -263,8 +288,18 @@ export async function handler(
     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 = (() => {
+      if (isTrial) {
+        return modelInfo.providers.find((provider) => provider.id === modelInfo.trial!.provider)
+      }
+
       if (retry.retryCount === MAX_RETRIES) {
         return modelInfo.providers.find((provider) => provider.id === modelInfo.fallbackProvider)
       }
@@ -274,9 +309,13 @@ export async function handler(
         .filter((provider) => !retry.excludeProviders.includes(provider.id))
         .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]
     })()
 
@@ -416,9 +455,14 @@ export async function handler(
     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 } =
-      providerInfo.normalizeUsage(usage)
+      usageInfo
 
     const modelCost =
       modelInfo.cost200K &&

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

@@ -24,6 +24,15 @@ import {
   toOaCompatibleResponse,
 } from "./openai-compatible"
 
+export type UsageInfo = {
+  inputTokens: number
+  outputTokens: number
+  reasoningTokens?: number
+  cacheReadTokens?: number
+  cacheWrite5mTokens?: number
+  cacheWrite1hTokens?: number
+}
+
 export type ProviderHelper = {
   format: ZenData.Format
   modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string
@@ -34,14 +43,7 @@ export type ProviderHelper = {
     parse: (chunk: string) => void
     retrieve: () => any
   }
-  normalizeUsage: (usage: any) => {
-    inputTokens: number
-    outputTokens: number
-    reasoningTokens?: number
-    cacheReadTokens?: number
-    cacheWrite5mTokens?: number
-    cacheWrite1hTokens?: number
-  }
+  normalizeUsage: (usage: any) => UsageInfo
 }
 
 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,
     "strict": true,
     "noEmit": true,
-    "types": ["vinxi/types/client"],
+    "types": ["vite/client"],
     "isolatedModules": true,
     "paths": {
       "~/*": ["./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,
       "tag": "0037_messy_jackal",
       "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",
   "name": "@opencode-ai/console-core",
-  "version": "1.0.80",
+  "version": "1.0.121",
   "private": true,
   "type": "module",
   "dependencies": {
@@ -30,6 +30,7 @@
     "update-models": "script/update-models.ts",
     "promote-models-to-dev": "script/promote-models.ts dev",
     "promote-models-to-prod": "script/promote-models.ts production",
+    "pull-models-from-dev": "script/pull-models.ts dev",
     "typecheck": "tsgo --noEmit"
   },
   "devDependencies": {

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

@@ -11,20 +11,21 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
 
 // read the secret
 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 (!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))
+ZenData.validate(JSON.parse(value1 + value2 + value3 + value4))
 
 // update the secret
 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_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()
 
 // 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 (!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
 const filename = `models-${Date.now()}.json`
 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)
 
 // 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))
 
 // 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(),
       subject: z.string(),
       body: z.string(),
+      replyTo: z.string().optional(),
     }),
     async (input) => {
       const res = await createClient().fetch("https://email.us-east-1.amazonaws.com/v2/email/outbound-emails", {
@@ -35,6 +36,7 @@ export namespace AWS {
           Destination: {
             ToAddresses: [input.to],
           },
+          ...(input.replyTo && { ReplyToAddresses: [input.replyTo] }),
           Content: {
             Simple: {
               Subject: {

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

@@ -24,6 +24,12 @@ export namespace ZenData {
     cost: ModelCostSchema,
     cost200K: ModelCostSchema.optional(),
     allowAnonymous: z.boolean().optional(),
+    trial: z
+      .object({
+        limit: z.number(),
+        provider: z.string(),
+      })
+      .optional(),
     rateLimit: z.number().optional(),
     fallbackProvider: z.string().optional(),
     providers: z.array(
@@ -32,6 +38,7 @@ export namespace ZenData {
         model: z.string(),
         weight: z.number().optional(),
         disabled: z.boolean().optional(),
+        storeModel: z.string().optional(),
       }),
     ),
   })
@@ -53,7 +60,9 @@ export namespace ZenData {
   })
 
   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)
   })
 }

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

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

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

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

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

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

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

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

@@ -1,4 +1,5 @@
 import { env } from "cloudflare:workers"
+export { waitUntil } from "cloudflare:workers"
 
 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 Cloudflare from "cloudflare"
 
+export const waitUntil = async (fn: () => Promise<void>) => {
+  await fn()
+}
+
 export const Resource = new Proxy(
   {},
   {
     get(_target, prop: keyof typeof ResourceBase) {
       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
-        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,
-              })
-              .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,
-                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

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

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

+ 9 - 2
packages/desktop/index.html

@@ -3,9 +3,16 @@
   <head>
     <meta charset="utf-8" />
     <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>
+    <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>
   <body class="antialiased overscroll-none select-none text-12-regular">
     <script>

+ 4 - 3
packages/desktop/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/desktop",
-  "version": "1.0.80",
+  "version": "1.0.121",
   "description": "",
   "type": "module",
   "scripts": {
@@ -14,7 +14,7 @@
   "devDependencies": {
     "@tailwindcss/vite": "catalog:",
     "@tsconfig/bun": "1.0.9",
-    "@types/luxon": "3.7.1",
+    "@types/luxon": "catalog:",
     "@types/node": "catalog:",
     "@typescript/native-preview": "catalog:",
     "typescript": "catalog:",
@@ -26,6 +26,7 @@
     "@kobalte/core": "catalog:",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
+    "@opencode-ai/util": "workspace:*",
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/active-element": "2.1.3",
     "@solid-primitives/event-bus": "1.1.2",
@@ -33,7 +34,7 @@
     "@solid-primitives/scroll": "2.1.3",
     "@solid-primitives/storage": "4.3.3",
     "@solidjs/meta": "catalog:",
-    "@solidjs/router": "0.15.3",
+    "@solidjs/router": "catalog:",
     "@thisbeyond/solid-dnd": "0.7.5",
     "diff": "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 { 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 { Dynamic } from "solid-js/web"
 
@@ -75,6 +76,7 @@ export default function FileTree(props: {
             <Switch>
               <Match when={node.type === "directory"}>
                 <Collapsible
+                  variant="ghost"
                   class="w-full"
                   forceMount={false}
                   // 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 { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
 import { createStore } from "solid-js/store"
-import { FileIcon } from "@/ui"
-import { getDirectory, getFilename } from "@/utils"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
 import { DateTime } from "luxon"
@@ -11,6 +8,14 @@ import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "
 import { useSDK } from "@/context/sdk"
 import { useNavigate } from "@solidjs/router"
 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 {
   class?: string
@@ -184,8 +189,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const range = selection.getRangeAt(0)
 
       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
 
         const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
@@ -448,7 +453,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               {(i) => (
                 <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">
-                    <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]">
                       <span class="text-14-medium text-text-strong overflow-hidden text-ellipsis">{i.name}</span>
                       <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 { createSimpleContext } from "./helper"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { onCleanup } from "solid-js"
 

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

@@ -14,8 +14,8 @@ import type {
   SessionStatus,
 } from "@opencode-ai/sdk"
 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"
 
 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 { createMemo } from "solid-js"
-import { createSimpleContext } from "./helper"
+import { createSimpleContext } from "@opencode-ai/ui/context"
 import { makePersisted } from "@solid-primitives/storage"
 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 { uniqueBy } from "remeda"
 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 { useSync } from "./sync"
 import { base64Encode } from "@/utils"

Некоторые файлы не были показаны из-за большого количества измененных файлов