paviko 4 месяцев назад
Родитель
Сommit
81eae65ac7
100 измененных файлов с 2971 добавлено и 760 удалено
  1. 4 0
      .github/guidelines-check.yml
  2. 3 3
      .github/workflows/duplicate-issues.yml
  3. 1 1
      .github/workflows/snapshot.yml
  4. 6 0
      .github/workflows/test.yml
  5. 79 0
      .github/workflows/update-nix-hashes.yml
  6. 4 0
      .gitignore
  7. 1 0
      .opencode/command/commit.md
  8. 23 0
      .opencode/command/issues.md
  9. 0 4
      .opencode/opencode.json
  10. 11 0
      .opencode/opencode.jsonc
  11. 11 0
      .vscode/launch.example.json
  12. 5 0
      .vscode/settings.example.json
  13. 32 0
      CONTRIBUTING.md
  14. 18 1
      README.md
  15. 2 0
      STATS.md
  16. 0 0
      a.out
  17. 66 37
      bun.lock
  18. 27 0
      flake.lock
  19. 107 0
      flake.nix
  20. 4 0
      hosts/jetbrains-plugin/CHANGELOG.md
  21. 8 0
      hosts/jetbrains-plugin/README.md
  22. 1 1
      hosts/jetbrains-plugin/build.gradle.kts
  23. 4 0
      hosts/vscode-plugin/CHANGELOG.md
  24. 8 0
      hosts/vscode-plugin/README.md
  25. 1 1
      hosts/vscode-plugin/package.json
  26. 3 0
      nix/hashes.json
  27. 52 0
      nix/node-modules.nix
  28. 108 0
      nix/opencode.nix
  29. 115 0
      nix/scripts/bun-build.ts
  30. 96 0
      nix/scripts/canonicalize-node-modules.ts
  31. 138 0
      nix/scripts/normalize-bun-binaries.ts
  32. 112 0
      nix/scripts/update-hashes.sh
  33. 5 4
      packages/console/app/package.json
  34. 16 0
      packages/console/app/src/component/icon.tsx
  35. 145 0
      packages/console/app/src/routes/workspace/[id]/graph-section.module.css
  36. 423 0
      packages/console/app/src/routes/workspace/[id]/graph-section.tsx
  37. 4 0
      packages/console/app/src/routes/workspace/[id]/index.tsx
  38. 99 91
      packages/console/app/src/routes/workspace/[id]/usage-section.module.css
  39. 5 4
      packages/console/app/src/routes/workspace/[id]/usage-section.tsx
  40. 12 5
      packages/console/app/src/routes/zen/util/handler.ts
  41. 1 0
      packages/console/app/src/routes/zen/util/provider/anthropic.ts
  42. 74 0
      packages/console/app/src/routes/zen/util/provider/google.ts
  43. 1 0
      packages/console/app/src/routes/zen/util/provider/openai-compatible.ts
  44. 1 0
      packages/console/app/src/routes/zen/util/provider/openai.ts
  45. 2 1
      packages/console/app/src/routes/zen/util/provider/provider.ts
  46. 2 0
      packages/console/app/src/routes/zen/v1/chat/completions.ts
  47. 2 0
      packages/console/app/src/routes/zen/v1/messages.ts
  48. 13 0
      packages/console/app/src/routes/zen/v1/models/[model].ts
  49. 2 0
      packages/console/app/src/routes/zen/v1/responses.ts
  50. 1 1
      packages/console/core/package.json
  51. 1 1
      packages/console/core/src/model.ts
  52. 1 1
      packages/console/function/package.json
  53. 2 1
      packages/console/function/src/log-processor.ts
  54. 1 1
      packages/console/mail/package.json
  55. 1 1
      packages/desktop/package.json
  56. 1 1
      packages/desktop/src/components/prompt-input.tsx
  57. 3 3
      packages/desktop/src/components/session-review.tsx
  58. 32 0
      packages/desktop/src/context/global-sdk.tsx
  59. 183 0
      packages/desktop/src/context/global-sync.tsx
  60. 75 0
      packages/desktop/src/context/layout.tsx
  61. 2 47
      packages/desktop/src/context/local.tsx
  62. 8 8
      packages/desktop/src/context/sdk.tsx
  63. 25 23
      packages/desktop/src/context/session.tsx
  64. 6 121
      packages/desktop/src/context/sync.tsx
  65. 6 0
      packages/desktop/src/index.css
  66. 34 13
      packages/desktop/src/index.tsx
  67. 23 0
      packages/desktop/src/pages/directory-layout.tsx
  68. 20 0
      packages/desktop/src/pages/home.tsx
  69. 163 87
      packages/desktop/src/pages/layout.tsx
  70. 0 12
      packages/desktop/src/pages/session-layout.tsx
  71. 113 97
      packages/desktop/src/pages/session.tsx
  72. 2 1
      packages/desktop/src/ui/file-icon.tsx
  73. 7 0
      packages/desktop/src/utils/encode.ts
  74. 1 0
      packages/desktop/src/utils/index.ts
  75. 6 6
      packages/extensions/zed/extension.toml
  76. 1 1
      packages/function/package.json
  77. 8 6
      packages/opencode/package.json
  78. 14 0
      packages/opencode/parsers-config.ts
  79. 25 1
      packages/opencode/src/cli/cmd/agent.ts
  80. 16 0
      packages/opencode/src/cli/cmd/debug/file.ts
  81. 1 1
      packages/opencode/src/cli/cmd/tui/app.tsx
  82. 31 11
      packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx
  83. 1 1
      packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
  84. 5 0
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  85. 1 2
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  86. 2 2
      packages/opencode/src/cli/cmd/tui/context/local.tsx
  87. 21 2
      packages/opencode/src/cli/cmd/tui/context/sync.tsx
  88. 10 1
      packages/opencode/src/cli/cmd/tui/context/theme.tsx
  89. 129 71
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  90. 16 8
      packages/opencode/src/cli/cmd/tui/thread.ts
  91. 5 1
      packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx
  92. 40 11
      packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
  93. 3 0
      packages/opencode/src/cli/cmd/tui/ui/toast.tsx
  94. 31 24
      packages/opencode/src/config/config.ts
  95. 3 0
      packages/opencode/src/file/ripgrep.ts
  96. 8 0
      packages/opencode/src/format/index.ts
  97. 12 1
      packages/opencode/src/lsp/index.ts
  98. 6 1
      packages/opencode/src/permission/index.ts
  99. 7 0
      packages/opencode/src/provider/models-macro.ts
  100. 1 37
      packages/opencode/src/provider/provider.ts

+ 4 - 0
.github/workflows/guidelines-check.yml → .github/guidelines-check.yml

@@ -1,3 +1,7 @@
+#
+# This file is intentionally in the wrong dir, will move and add later....
+#
+
 name: Guidelines Check
 
 on:

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

@@ -27,12 +27,12 @@ jobs:
             {
               "bash": {
                 "gh issue*": "allow",
-                "*": "deny" 
-              }, 
+                "*": "deny"
+              },
               "webfetch": "deny"
             }
         run: |
-          opencode run -m anthropic/claude-sonnet-4-20250514 "A new issue has been created:'
+          opencode run -m opencode/claude-haiku-4-5 "A new issue has been created:'
 
           Issue number:
           ${{ github.event.issue.number }}

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

@@ -4,7 +4,7 @@ on:
   push:
     branches:
       - dev
-      - fix-build
+      - fix-snapshot-2
       - v0
 
 concurrency: ${{ github.workflow }}-${{ github.ref }}

+ 6 - 0
.github/workflows/test.yml

@@ -37,3 +37,9 @@ jobs:
           bun turbo test
         env:
           CI: true
+
+      - name: Check SDK is up to date
+        run: |
+          bun ./packages/sdk/js/script/build.ts
+          git diff --exit-code packages/sdk/js/src/gen packages/sdk/js/dist
+        continue-on-error: false

+ 79 - 0
.github/workflows/update-nix-hashes.yml

@@ -0,0 +1,79 @@
+name: Update Nix Hashes
+
+permissions:
+  contents: write
+
+on:
+  workflow_dispatch:
+  push:
+    paths:
+      - "bun.lock"
+      - "package.json"
+      - "packages/*/package.json"
+  pull_request:
+    paths:
+      - "bun.lock"
+      - "package.json"
+      - "packages/*/package.json"
+
+jobs:
+  update:
+    runs-on: ubuntu-latest
+    env:
+      SYSTEM: x86_64-linux
+
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          token: ${{ secrets.GITHUB_TOKEN }}
+          fetch-depth: 0
+
+      - name: Setup Nix
+        uses: DeterminateSystems/nix-installer-action@v20
+
+      - name: Configure git
+        run: |
+          git config --global user.email "[email protected]"
+          git config --global user.name "Github Action"
+
+      - name: Update node_modules hash
+        run: |
+          set -euo pipefail
+          nix/scripts/update-hashes.sh
+
+      - name: Commit hash changes
+        env:
+          TARGET_BRANCH: ${{ github.head_ref || github.ref_name }}
+        run: |
+          set -euo pipefail
+
+          summarize() {
+            local status="$1"
+            {
+              echo "### Nix Hash Update"
+              echo ""
+              echo "- ref: ${GITHUB_REF_NAME}"
+              echo "- status: ${status}"
+            } >> "$GITHUB_STEP_SUMMARY"
+            if [ -n "${GITHUB_SERVER_URL:-}" ] && [ -n "${GITHUB_REPOSITORY:-}" ] && [ -n "${GITHUB_RUN_ID:-}" ]; then
+              echo "- run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" >> "$GITHUB_STEP_SUMMARY"
+            fi
+            echo "" >> "$GITHUB_STEP_SUMMARY"
+          }
+
+          FILES=(flake.nix nix/node-modules.nix nix/hashes.json)
+          STATUS="$(git status --short -- "${FILES[@]}" || true)"
+          if [ -z "$STATUS" ]; then
+            summarize "no changes"
+            echo "No changes to tracked Nix files. Hashes are already up to date."
+            exit 0
+          fi
+
+          git add "${FILES[@]}"
+          git commit -m "Update Nix hashes"
+
+          BRANCH="${TARGET_BRANCH:-${GITHUB_REF_NAME}}"
+          git push origin HEAD:"$BRANCH"
+
+          summarize "committed $(git rev-parse --short HEAD)"

+ 4 - 0
.gitignore

@@ -13,6 +13,10 @@ dist
 .turbo
 **/.serena
 .serena/
+/result
+refs
+Session.vim
+opencode.json
 
 *.bak
 /hosts/jetbrains-plugin/bin/

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

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

+ 23 - 0
.opencode/command/issues.md

@@ -0,0 +1,23 @@
+---
+description: "Find issue(s) on github"
+model: opencode/claude-haiku-4-5
+---
+
+Search through existing issues in sst/opencode using the gh cli to find issues matching this query:
+
+$ARGUMENTS
+
+Consider:
+
+1. Similar titles or descriptions
+2. Same error messages or symptoms
+3. Related functionality or components
+4. Similar feature requests
+
+Please list any matching issues with:
+
+- Issue number and title
+- Brief explanation of why it matches the query
+- Link to the issue
+
+If no clear matches are found, say so.

+ 0 - 4
.opencode/opencode.json

@@ -1,4 +0,0 @@
-{
-  "$schema": "https://opencode.ai/config.json",
-  "plugin": ["opencode-openai-codex-auth"]
-}

+ 11 - 0
.opencode/opencode.jsonc

@@ -0,0 +1,11 @@
+{
+  "$schema": "https://opencode.ai/config.json",
+  "plugin": ["opencode-openai-codex-auth"],
+  "provider": {
+    "opencode": {
+      "options": {
+        // "baseURL": "http://localhost:8080",
+      },
+    },
+  },
+}

+ 11 - 0
.vscode/launch.example.json

@@ -0,0 +1,11 @@
+{
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "type": "bun",
+      "request": "attach",
+      "name": "opencode (attach)",
+      "url": "ws://localhost:6499/"
+    }
+  ]
+}

+ 5 - 0
.vscode/settings.example.json

@@ -0,0 +1,5 @@
+{
+  "recommendations": [
+    "oven.bun-vscode"
+  ]
+}

+ 32 - 0
CONTRIBUTING.md

@@ -42,6 +42,38 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
 > [!NOTE]
 > After touching `packages/opencode/src/server/server.ts`, run "./packages/sdk/js/script/build.ts" to regenerate the JS sdk.
 
+### Setting up a Debugger
+
+Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points.
+
+The most reliable way to debug OpenCode is to run it manually in a terminal via `bun run --inspect=<url> dev ...` and attach
+your debugger via that URL. Other methods can result in breakpoints being mapped incorrectly, at least in VSCode (YMMV).
+
+Caveats:
+
+- `*.tsx` files won't have their breakpoints correctly mapped. This seems due to Bun currently not supporting source maps on code transformed
+  via `BunPlugin`s (currently necessary due to our dependency on `@opentui/solid`). Currently, the best you can do in terms of debugging `*.tsx`
+  files is writing a `debugger;` statement. Debugging facilities like stepping won't work, but at least you will be informed if a specific code
+  is triggered.
+- If you want to run the OpenCode TUI and have breakpoints triggered in the server code, you might need to run `bun dev spawn` instead of
+  the usual `bun dev`. This is because `bun dev` runs the server in a worker thread and breakpoints might not work there.
+
+Other tips and tricks:
+
+- You might want to use `--inspect-wait` or `--inspect-brk` instead of `--inspect`, depending on your workflow
+- Specifying `--inspect=ws://localhost:6499/` on every invocation can be tiresome, you may want to `export BUN_OPTIONS=--inspect=ws://localhost:6499/` instead
+
+#### VSCode Setup
+
+If you use VSCode, you can use our example configurations [.vscode/settings.example.json](.vscode/settings.example.json) and [.vscode/launch.example.json](.vscode/launch.example.json).
+
+Some debug methods that can be problematic:
+
+- Debug configurations with `"request": "launch"` can have breakpoints incorrectly mapped and thus unusable
+- The same problem arises when running OpenCode in the VSCode `JavaScript Debug Terminal`
+
+With that said, you may want to try these methods, as they might work for you.
+
 ## Pull Request Expectations
 
 - Try to keep pull requests small and focused.

+ 18 - 1
README.md

@@ -38,9 +38,10 @@ curl -fsSL https://opencode.ai/install | bash
 npm i -g opencode-ai@latest        # or bun/pnpm/yarn
 scoop bucket add extras; scoop install extras/opencode  # Windows
 choco install opencode             # Windows
-brew install opencode      # macOS and Linux
+brew install opencode              # macOS and Linux
 paru -S opencode-bin               # Arch Linux
 mise use --pin -g ubi:sst/opencode # Any OS
+nix run nixpkgs#opencode           # or github:sst/opencode for latest dev branch
 ```
 
 > [!TIP]
@@ -61,6 +62,22 @@ OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bas
 XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
 ```
 
+### Agents
+
+OpenCode includes two built-in agents you can switch between,
+you can switch between these using the `Tab` key.
+
+- **build** - Default, full access agent for development work
+- **plan** - Read-only agent for analysis and code exploration
+  - Denies file edits by default
+  - Asks permission before running bash commands
+  - Ideal for exploring unfamiliar codebases or planning changes
+
+Also, included is a **general** subagent for complex searches and multi-step tasks.
+This is used internally and can be invoked using `@general` in messages.
+
+Learn more about [agents](https://opencode.ai/docs/agents).
+
 ### Documentation
 
 For more info on how to configure OpenCode [**head over to our docs**](https://opencode.ai/docs).

+ 2 - 0
STATS.md

@@ -142,3 +142,5 @@
 | 2025-11-14 | 759,928 (+10,023) | 705,237 (+9,080)  | 1,465,165 (+19,103) |
 | 2025-11-15 | 765,955 (+6,027)  | 712,870 (+7,633)  | 1,478,825 (+13,660) |
 | 2025-11-16 | 771,069 (+5,114)  | 716,596 (+3,726)  | 1,487,665 (+8,840)  |
+| 2025-11-17 | 780,161 (+9,092)  | 723,339 (+6,743)  | 1,503,500 (+15,835) |
+| 2025-11-18 | 791,563 (+11,402) | 732,544 (+9,205)  | 1,524,107 (+20,607) |


+ 66 - 37
bun.lock

@@ -1,6 +1,6 @@
 {
   "lockfileVersion": 1,
-  "configVersion": 0,
+  "configVersion": 1,
   "workspaces": {
     "": {
       "name": "opencode",
@@ -31,6 +31,7 @@
         "@solidjs/meta": "^0.29.4",
         "@solidjs/router": "^0.15.0",
         "@solidjs/start": "^1.1.0",
+        "chart.js": "4.5.1",
         "solid-js": "catalog:",
         "vinxi": "^0.5.7",
         "zod": "catalog:",
@@ -42,7 +43,7 @@
     },
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
-      "version": "1.0.68",
+      "version": "1.0.78",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
@@ -69,7 +70,7 @@
     },
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
-      "version": "1.0.68",
+      "version": "1.0.78",
       "dependencies": {
         "@ai-sdk/anthropic": "2.0.0",
         "@ai-sdk/openai": "2.0.2",
@@ -93,7 +94,7 @@
     },
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
-      "version": "1.0.68",
+      "version": "1.0.78",
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
@@ -117,7 +118,7 @@
     },
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
-      "version": "1.0.68",
+      "version": "1.0.78",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -157,7 +158,7 @@
     },
     "packages/function": {
       "name": "@opencode-ai/function",
-      "version": "1.0.68",
+      "version": "1.0.78",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "22.0.0",
@@ -173,7 +174,7 @@
     },
     "packages/opencode": {
       "name": "opencode",
-      "version": "1.0.68",
+      "version": "1.0.78",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -192,8 +193,8 @@
         "@opencode-ai/plugin": "workspace:*",
         "@opencode-ai/script": "workspace:*",
         "@opencode-ai/sdk": "workspace:*",
-        "@opentui/core": "0.1.45",
-        "@opentui/solid": "0.1.45",
+        "@opentui/core": "0.1.46",
+        "@opentui/solid": "0.1.46",
         "@parcel/watcher": "2.5.1",
         "@pierre/precision-diffs": "catalog:",
         "@solid-primitives/event-bus": "1.1.2",
@@ -230,6 +231,7 @@
         "@ai-sdk/amazon-bedrock": "2.2.10",
         "@ai-sdk/google-vertex": "3.0.16",
         "@babel/core": "7.28.4",
+        "@babel/preset-typescript": "7.24.7",
         "@octokit/webhooks-types": "7.6.1",
         "@opencode-ai/script": "workspace:*",
         "@parcel/watcher-darwin-arm64": "2.5.1",
@@ -244,6 +246,7 @@
         "@types/turndown": "5.0.5",
         "@types/yargs": "17.0.33",
         "@typescript/native-preview": "catalog:",
+        "babel-preset-solid": "1.9.10",
         "typescript": "catalog:",
         "vscode-languageserver-types": "3.17.5",
         "why-is-node-running": "3.2.2",
@@ -286,7 +289,7 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "1.0.68",
+      "version": "1.0.78",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "zod": "catalog:",
@@ -306,7 +309,7 @@
     },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
-      "version": "1.0.68",
+      "version": "1.0.78",
       "devDependencies": {
         "@hey-api/openapi-ts": "0.81.0",
         "@tsconfig/node22": "catalog:",
@@ -317,7 +320,7 @@
     },
     "packages/slack": {
       "name": "@opencode-ai/slack",
-      "version": "1.0.68",
+      "version": "1.0.78",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
@@ -330,7 +333,7 @@
     },
     "packages/ui": {
       "name": "@opencode-ai/ui",
-      "version": "1.0.68",
+      "version": "1.0.78",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -358,9 +361,19 @@
         "vite-plugin-solid": "catalog:",
       },
     },
+    "packages/util": {
+      "name": "@opencode-ai/util",
+      "version": "0.0.0",
+      "dependencies": {
+        "zod": "catalog:",
+      },
+      "devDependencies": {
+        "typescript": "catalog:",
+      },
+    },
     "packages/web": {
       "name": "@opencode-ai/web",
-      "version": "1.0.68",
+      "version": "1.0.78",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
@@ -595,7 +608,7 @@
 
     "@babel/plugin-transform-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.27.3", "@babel/helper-create-class-features-plugin": "^7.28.5", "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-x2Qa+v/CuEoX7Dr31iAfr0IhInrVOWZU/2vJMJ00FOR/2nM0BcBEclpaf9sWCDc+v5e9dMrhSH8/atq/kX7+bA=="],
 
-    "@babel/preset-typescript": ["@babel/[email protected]7.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
+    "@babel/preset-typescript": ["@babel/[email protected]4.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.24.7", "@babel/helper-validator-option": "^7.24.7", "@babel/plugin-syntax-jsx": "^7.24.7", "@babel/plugin-transform-modules-commonjs": "^7.24.7", "@babel/plugin-transform-typescript": "^7.24.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-SyXRe3OdWwIwalxDg5UtJnJQO+YPcTfwiIY2B0Xlddh9o7jpWLvv8X1RthIeDOxQ+O1ML5BLPCONToObyVQVuQ=="],
 
     "@babel/runtime": ["@babel/[email protected]", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
 
@@ -937,6 +950,8 @@
 
     "@kobalte/utils": ["@kobalte/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.2.14", "@solid-primitives/keyed": "^1.2.0", "@solid-primitives/map": "^0.4.7", "@solid-primitives/media": "^2.2.4", "@solid-primitives/props": "^3.1.8", "@solid-primitives/refs": "^1.0.5", "@solid-primitives/utils": "^6.2.1" }, "peerDependencies": { "solid-js": "^1.8.8" } }, "sha512-eeU60A3kprIiBDAfv9gUJX1tXGLuZiKMajUfSQURAF2pk4ZoMYiqIzmrMBvzcxP39xnYttgTyQEVLwiTZnrV4w=="],
 
+    "@kurkle/color": ["@kurkle/[email protected]", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="],
+
     "@lexical/clipboard": ["@lexical/[email protected]", "", { "dependencies": { "@lexical/html": "0.37.0", "@lexical/list": "0.37.0", "@lexical/selection": "0.37.0", "@lexical/utils": "0.37.0", "lexical": "0.37.0" } }, "sha512-hRwASFX/ilaI5r8YOcZuQgONFshRgCPfdxfofNL7uruSFYAO6LkUhsjzZwUgf0DbmCJmbBADFw15FSthgCUhGA=="],
 
     "@lexical/code": ["@lexical/[email protected]", "", { "dependencies": { "@lexical/utils": "0.37.0", "lexical": "0.37.0", "prismjs": "^1.30.0" } }, "sha512-ZXA4j/S8yLrxjrTnEp39VeDMp4Rd8bLYUlT4Buy1MQlS1WafxOiMhNQJG7k0BP/pO96YPkAebpA81ATKJL0IgA=="],
@@ -1073,25 +1088,27 @@
 
     "@opencode-ai/ui": ["@opencode-ai/ui@workspace:packages/ui"],
 
+    "@opencode-ai/util": ["@opencode-ai/util@workspace:packages/util"],
+
     "@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
 
     "@opentelemetry/api": ["@opentelemetry/[email protected]", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
 
-    "@opentui/core": ["@opentui/[email protected]5", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.45", "@opentui/core-darwin-x64": "0.1.45", "@opentui/core-linux-arm64": "0.1.45", "@opentui/core-linux-x64": "0.1.45", "@opentui/core-win32-arm64": "0.1.45", "@opentui/core-win32-x64": "0.1.45", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-iGzU1rZNfBfnw9TEHppvt1B9gtOC/jl0KU3fezCoXoMRCcy3STYLhZbEsYM6cX6AnRK85DqTzC6E6eWWUwGMlA=="],
+    "@opentui/core": ["@opentui/[email protected]6", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.46", "@opentui/core-darwin-x64": "0.1.46", "@opentui/core-linux-arm64": "0.1.46", "@opentui/core-linux-x64": "0.1.46", "@opentui/core-win32-arm64": "0.1.46", "@opentui/core-win32-x64": "0.1.46", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-9682jrr65vYP0hPHfrZRK3xymlCSLVBMrRKNtclFasDi6bRvACUrtziFOIIyMIvPHRJCFWPbtz0MppARmN4zvQ=="],
 
-    "@opentui/core-darwin-arm64": ["@opentui/[email protected]5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7pzgeYTkR88bufNY4ZnUBnqjsmR9wjx/wH81YyAtc8Hnp/6l+tU+1DHEJEseIArJKRIETwMMNs0W4oxSyQEAJg=="],
+    "@opentui/core-darwin-arm64": ["@opentui/[email protected]6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sp/uFS8J/1yVfhgkTJ43OZfy64hv1/9sdT+oC5yb8XTNPI3QGtg6ixjr3nRoD/Lkxuj2i5SJ30RZufqH6rkCpA=="],
 
-    "@opentui/core-darwin-x64": ["@opentui/[email protected]5", "", { "os": "darwin", "cpu": "x64" }, "sha512-N1dk4T57/qZorpshJqJaObp9PT3WP8F7aRX5bSjcPSeHDZkfkWYnfIhgc5tRzCu1FqYx7M3HEUlI21YKuThCyQ=="],
+    "@opentui/core-darwin-x64": ["@opentui/[email protected]6", "", { "os": "darwin", "cpu": "x64" }, "sha512-JtxEruRyLQRK8ByPmBm1nYYSvnX6mXNC+mngvd5RDiCzLzkM6qVBQBd/m3Hxp2/s6MO5Z2+iVBzZ8XFH5T4IZw=="],
 
-    "@opentui/core-linux-arm64": ["@opentui/[email protected]5", "", { "os": "linux", "cpu": "arm64" }, "sha512-Rm1DeH80wGHgXZkIMzP9KRrbZOMJJZxG1f2CO7NEt5/sE2buPQsLF4HcqwycIFJvBdSnpTovzm+hK1sT08zr4w=="],
+    "@opentui/core-linux-arm64": ["@opentui/[email protected]6", "", { "os": "linux", "cpu": "arm64" }, "sha512-pN8nR4CwBlkZ5uh5KvoytiXXav2GhkP9cB2d3gPe49c7MBz2XrjGexgb47xjaq0hAVbytv9XUifqdPTcFQdPaQ=="],
 
-    "@opentui/core-linux-x64": ["@opentui/[email protected]5", "", { "os": "linux", "cpu": "x64" }, "sha512-0Olnj36Sqb1ukCngvf0SPBmZHtT2p9SMYWj6EZgx6KkHTNbDs00/sA8mctViX1B/AJW5v0+E3FT1SgyDwvBDbw=="],
+    "@opentui/core-linux-x64": ["@opentui/[email protected]6", "", { "os": "linux", "cpu": "x64" }, "sha512-oH4/FEYZYce9qMQVqGl4+Btw+Mfsf6ybpWIIJUJjXMWWZlAgsTMAWM8m195Oe6WstfFLF+nRH7NUcm/YOsCHnw=="],
 
-    "@opentui/core-win32-arm64": ["@opentui/[email protected]5", "", { "os": "win32", "cpu": "arm64" }, "sha512-cwo4nSiqSeesleNaWD/PqbWgiXesd6NOwbH6dkDfW5CNenRRm9kNtm+Z5EXYfIf4wcCwyeqOlQZXZgMVNL3cIg=="],
+    "@opentui/core-win32-arm64": ["@opentui/[email protected]6", "", { "os": "win32", "cpu": "arm64" }, "sha512-C/rTBJ9bzBcZJRCLIxi9Ka/DANe2SaHtryotseWPk9RDydw7LTHGoi3VtRW0RFijQGqmvFg+31MeNhvY1YZ65Q=="],
 
-    "@opentui/core-win32-x64": ["@opentui/[email protected]5", "", { "os": "win32", "cpu": "x64" }, "sha512-11ZiCoAya94oNmyav3OB5FdeMoR0bKlQLMK/ObxwWSJW5N7Ccw+2BNz1VQZNSvUQfJeWWXRaVz7XesOhFu/tLg=="],
+    "@opentui/core-win32-x64": ["@opentui/[email protected]6", "", { "os": "win32", "cpu": "x64" }, "sha512-d2DXSlA93LbSriX+pDDZ5sMwkcW1+eVoeykxeW4UParSb4/3ceBCD4aSARaZ6yoq0rR1IWOdgKdiihZH4mwdJQ=="],
 
-    "@opentui/solid": ["@opentui/[email protected]5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.45", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-Mtn+Kz9osYx/3PMJUMYVHjQch1r4DgEi8V3Es2/vD7ha/kMg2PMTDg+J5RWOheUkXCyfm3G9imxlOIG+aEcGuA=="],
+    "@opentui/solid": ["@opentui/[email protected]6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.46", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-t+LDXS+FBT+fjQHOxuL44Bx1jGuXuLouyB25BZuypQKKT8sOhi8rJ5+Q4UwH5lrI4OoRBXmzrgsWj7DD58sHDw=="],
 
     "@oslojs/asn1": ["@oslojs/[email protected]", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
 
@@ -1593,25 +1610,25 @@
 
     "@types/yargs-parser": ["@types/[email protected]", "", {}, "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ=="],
 
-    "@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected]6.4", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/type-utils": "8.46.4", "@typescript-eslint/utils": "8.46.4", "@typescript-eslint/visitor-keys": "8.46.4", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.4", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-R48VhmTJqplNyDxCyqqVkFSZIx1qX6PzwqgcXn1olLrzxcSBDlOsbtcnQuQhNtnNiJ4Xe5gREI1foajYaYU2Vg=="],
+    "@typescript-eslint/eslint-plugin": ["@typescript-eslint/[email protected]7.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/type-utils": "8.47.0", "@typescript-eslint/utils": "8.47.0", "@typescript-eslint/visitor-keys": "8.47.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.47.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA=="],
 
-    "@typescript-eslint/parser": ["@typescript-eslint/[email protected]6.4", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", "@typescript-eslint/typescript-estree": "8.46.4", "@typescript-eslint/visitor-keys": "8.46.4", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-tK3GPFWbirvNgsNKto+UmB/cRtn6TZfyw0D6IKrW55n6Vbs7KJoZtI//kpTKzE/DUmmnAFD8/Ca46s7Obs92/w=="],
+    "@typescript-eslint/parser": ["@typescript-eslint/[email protected]7.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", "@typescript-eslint/typescript-estree": "8.47.0", "@typescript-eslint/visitor-keys": "8.47.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ=="],
 
-    "@typescript-eslint/project-service": ["@typescript-eslint/[email protected]6.4", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.46.4", "@typescript-eslint/types": "^8.46.4", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-nPiRSKuvtTN+no/2N1kt2tUh/HoFzeEgOm9fQ6XQk4/ApGqjx0zFIIaLJ6wooR1HIoozvj2j6vTi/1fgAz7UYQ=="],
+    "@typescript-eslint/project-service": ["@typescript-eslint/[email protected]7.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.47.0", "@typescript-eslint/types": "^8.47.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA=="],
 
-    "@typescript-eslint/scope-manager": ["@typescript-eslint/[email protected]6.4", "", { "dependencies": { "@typescript-eslint/types": "8.46.4", "@typescript-eslint/visitor-keys": "8.46.4" } }, "sha512-tMDbLGXb1wC+McN1M6QeDx7P7c0UWO5z9CXqp7J8E+xGcJuUuevWKxuG8j41FoweS3+L41SkyKKkia16jpX7CA=="],
+    "@typescript-eslint/scope-manager": ["@typescript-eslint/[email protected]7.0", "", { "dependencies": { "@typescript-eslint/types": "8.47.0", "@typescript-eslint/visitor-keys": "8.47.0" } }, "sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg=="],
 
-    "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/[email protected]6.4", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-+/XqaZPIAk6Cjg7NWgSGe27X4zMGqrFqZ8atJsX3CWxH/jACqWnrWI68h7nHQld0y+k9eTTjb9r+KU4twLoo9A=="],
+    "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/[email protected]7.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g=="],
 
-    "@typescript-eslint/type-utils": ["@typescript-eslint/[email protected]6.4", "", { "dependencies": { "@typescript-eslint/types": "8.46.4", "@typescript-eslint/typescript-estree": "8.46.4", "@typescript-eslint/utils": "8.46.4", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-V4QC8h3fdT5Wro6vANk6eojqfbv5bpwHuMsBcJUJkqs2z5XnYhJzyz9Y02eUmF9u3PgXEUiOt4w4KHR3P+z0PQ=="],
+    "@typescript-eslint/type-utils": ["@typescript-eslint/[email protected]7.0", "", { "dependencies": { "@typescript-eslint/types": "8.47.0", "@typescript-eslint/typescript-estree": "8.47.0", "@typescript-eslint/utils": "8.47.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A=="],
 
-    "@typescript-eslint/types": ["@typescript-eslint/[email protected]6.4", "", {}, "sha512-USjyxm3gQEePdUwJBFjjGNG18xY9A2grDVGuk7/9AkjIF1L+ZrVnwR5VAU5JXtUnBL/Nwt3H31KlRDaksnM7/w=="],
+    "@typescript-eslint/types": ["@typescript-eslint/[email protected]7.0", "", {}, "sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A=="],
 
-    "@typescript-eslint/typescript-estree": ["@typescript-eslint/[email protected]6.4", "", { "dependencies": { "@typescript-eslint/project-service": "8.46.4", "@typescript-eslint/tsconfig-utils": "8.46.4", "@typescript-eslint/types": "8.46.4", "@typescript-eslint/visitor-keys": "8.46.4", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-7oV2qEOr1d4NWNmpXLR35LvCfOkTNymY9oyW+lUHkmCno7aOmIf/hMaydnJBUTBMRCOGZh8YjkFOc8dadEoNGA=="],
+    "@typescript-eslint/typescript-estree": ["@typescript-eslint/[email protected]7.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.47.0", "@typescript-eslint/tsconfig-utils": "8.47.0", "@typescript-eslint/types": "8.47.0", "@typescript-eslint/visitor-keys": "8.47.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg=="],
 
-    "@typescript-eslint/utils": ["@typescript-eslint/[email protected]6.4", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.46.4", "@typescript-eslint/types": "8.46.4", "@typescript-eslint/typescript-estree": "8.46.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-AbSv11fklGXV6T28dp2Me04Uw90R2iJ30g2bgLz529Koehrmkbs1r7paFqr1vPCZi7hHwYxYtxfyQMRC8QaVSg=="],
+    "@typescript-eslint/utils": ["@typescript-eslint/[email protected]7.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.47.0", "@typescript-eslint/types": "8.47.0", "@typescript-eslint/typescript-estree": "8.47.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ=="],
 
-    "@typescript-eslint/visitor-keys": ["@typescript-eslint/[email protected]6.4", "", { "dependencies": { "@typescript-eslint/types": "8.46.4", "eslint-visitor-keys": "^4.2.1" } }, "sha512-/++5CYLQqsO9HFGLI7APrxBJYo+5OCMpViuhV8q5/Qa3o5mMrF//eQHks+PXcsAVaLdn817fMuS7zqoXNNZGaw=="],
+    "@typescript-eslint/visitor-keys": ["@typescript-eslint/[email protected]7.0", "", { "dependencies": { "@typescript-eslint/types": "8.47.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ=="],
 
     "@typescript/native-preview": ["@typescript/[email protected]", "", { "optionalDependencies": { "@typescript/native-preview-darwin-arm64": "7.0.0-dev.20251014.1", "@typescript/native-preview-darwin-x64": "7.0.0-dev.20251014.1", "@typescript/native-preview-linux-arm": "7.0.0-dev.20251014.1", "@typescript/native-preview-linux-arm64": "7.0.0-dev.20251014.1", "@typescript/native-preview-linux-x64": "7.0.0-dev.20251014.1", "@typescript/native-preview-win32-arm64": "7.0.0-dev.20251014.1", "@typescript/native-preview-win32-x64": "7.0.0-dev.20251014.1" }, "bin": { "tsgo": "bin/tsgo.js" } }, "sha512-IqmX5CYCBqXbfL+HKlcQAMaDlfJ0Z8OhUxvADFV2TENnzSYI4CuhvKxwOB2wFSLXufVsgtAlf3Fjwn24KmMyPQ=="],
 
@@ -1869,6 +1886,8 @@
 
     "character-reference-invalid": ["[email protected]", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
 
+    "chart.js": ["[email protected]", "", { "dependencies": { "@kurkle/color": "^0.3.0" } }, "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw=="],
+
     "cheerio": ["[email protected]", "", { "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "htmlparser2": "^8.0.1", "parse5": "^7.0.0", "parse5-htmlparser2-tree-adapter": "^7.0.0" } }, "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q=="],
 
     "cheerio-select": ["[email protected]", "", { "dependencies": { "boolbase": "^1.0.0", "css-select": "^5.1.0", "css-what": "^6.1.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1" } }, "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g=="],
@@ -3611,7 +3630,7 @@
 
     "typescript": ["[email protected]", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ=="],
 
-    "typescript-eslint": ["[email protected]6.4", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.46.4", "@typescript-eslint/parser": "8.46.4", "@typescript-eslint/typescript-estree": "8.46.4", "@typescript-eslint/utils": "8.46.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-KALyxkpYV5Ix7UhvjTwJXZv76VWsHG+NjNlt/z+a17SOQSiOcBdUXdbJdyXi7RPxrBFECtFOiPwUJQusJuCqrg=="],
+    "typescript-eslint": ["[email protected]7.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.47.0", "@typescript-eslint/parser": "8.47.0", "@typescript-eslint/typescript-estree": "8.47.0", "@typescript-eslint/utils": "8.47.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q=="],
 
     "ufo": ["[email protected]", "", {}, "sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA=="],
 
@@ -4001,6 +4020,8 @@
 
     "@opentui/solid/@babel/core": ["@babel/[email protected]", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
 
+    "@opentui/solid/@babel/preset-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
+
     "@opentui/solid/babel-preset-solid": ["[email protected]", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.1" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.8" }, "optionalPeers": ["solid-js"] }, "sha512-pCnxWrciluXCeli/dj5PIEHgbNzim3evtTn12snjqqg8QZWJNMjH1AWIp4iG/tbVjqQ72aBEymMSagvmgxubXw=="],
 
     "@oslojs/jwt/@oslojs/encoding": ["@oslojs/[email protected]", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
@@ -4095,13 +4116,15 @@
 
     "@tanstack/directive-functions-plugin/@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="],
 
+    "@tanstack/router-utils/@babel/preset-typescript": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
+
     "@tanstack/router-utils/pathe": ["[email protected]", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
 
     "@tanstack/server-functions-plugin/@babel/code-frame": ["@babel/[email protected]", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.25.9", "js-tokens": "^4.0.0", "picocolors": "^1.0.0" } }, "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ=="],
 
-    "@types/react-dom/@types/react": ["@types/[email protected].5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw=="],
+    "@types/react-dom/@types/react": ["@types/[email protected].6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
 
-    "@types/react-syntax-highlighter/@types/react": ["@types/[email protected].5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw=="],
+    "@types/react-syntax-highlighter/@types/react": ["@types/[email protected].6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
 
     "@typescript-eslint/typescript-estree/minimatch": ["[email protected]", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
 
@@ -4447,7 +4470,7 @@
 
     "vite-plugin-icons-spritesheet/chalk": ["[email protected]", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
 
-    "webgui/@types/react": ["@types/[email protected].5", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-keKxkZMqnDicuvFoJbzrhbtdLSPhj/rZThDlKWCDbgXmUg0rEUFtRssDXKYmtXluZlIqiC5VqkCgRwzuyLHKHw=="],
+    "webgui/@types/react": ["@types/[email protected].6", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-p/jUvulfgU7oKtj6Xpk8cA2Y1xKTtICGpJYeJXz2YVO2UcvjQgeRMLDGfDeqeRW2Ta+0QNFwcc8X3GH8SxZz6w=="],
 
     "webgui/diff": ["[email protected]", "", {}, "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw=="],
 
@@ -4803,6 +4826,10 @@
 
     "@tailwindcss/postcss/@tailwindcss/oxide/@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/[email protected]", "", { "os": "win32", "cpu": "x64" }, "sha512-SKWM4waLuqx0IH+FMDUw6R66Hu4OuTALFgnleKbqhgGU30DY20NORZMZUKgLRjQXNN2TLzKvh48QXTig4h4bGw=="],
 
+    "@types/react-dom/@types/react/csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+    "@types/react-syntax-highlighter/@types/react/csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
     "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["[email protected]", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
 
     "@vercel/nft/glob/jackspeak": ["[email protected]", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
@@ -5031,6 +5058,8 @@
 
     "unstorage/h3/cookie-es": ["[email protected]", "", {}, "sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg=="],
 
+    "webgui/@types/react/csstype": ["[email protected]", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
     "wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/[email protected]", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
 
     "wrangler/esbuild/@esbuild/android-arm": ["@esbuild/[email protected]", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],

+ 27 - 0
flake.lock

@@ -0,0 +1,27 @@
+{
+  "nodes": {
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1762156382,
+        "narHash": "sha256-Yg7Ag7ov5+36jEFC1DaZh/12SEXo6OO3/8rqADRxiqs=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "7241bcbb4f099a66aafca120d37c65e8dda32717",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixpkgs-unstable",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}

+ 107 - 0
flake.nix

@@ -0,0 +1,107 @@
+{
+  description = "OpenCode development flake";
+
+  inputs = {
+    nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
+  };
+
+  outputs =
+    {
+      nixpkgs,
+      ...
+    }:
+    let
+      systems = [
+        "aarch64-linux"
+        "x86_64-linux"
+        "aarch64-darwin"
+        "x86_64-darwin"
+      ];
+      lib = nixpkgs.lib;
+      forEachSystem = lib.genAttrs systems;
+      pkgsFor = system: nixpkgs.legacyPackages.${system};
+      packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
+      bunTarget = {
+        "aarch64-linux" = "bun-linux-arm64";
+        "x86_64-linux" = "bun-linux-x64";
+        "aarch64-darwin" = "bun-darwin-arm64";
+        "x86_64-darwin" = "bun-darwin-x64";
+      };
+      defaultNodeModules = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
+      hashesFile = "${./nix}/hashes.json";
+      hashesData =
+        if builtins.pathExists hashesFile then builtins.fromJSON (builtins.readFile hashesFile) else { };
+      nodeModulesHash = hashesData.nodeModules or defaultNodeModules;
+      modelsDev = forEachSystem (
+        system:
+        let
+          pkgs = pkgsFor system;
+        in
+        pkgs."models-dev"
+      );
+    in
+    {
+      devShells = forEachSystem (
+        system:
+        let
+          pkgs = pkgsFor system;
+        in
+        {
+          default = pkgs.mkShell {
+            packages = with pkgs; [
+              bun
+              nodejs_20
+              pkg-config
+              openssl
+              git
+            ];
+          };
+        }
+      );
+
+      packages = forEachSystem (
+        system:
+        let
+          pkgs = pkgsFor system;
+          mkNodeModules = pkgs.callPackage ./nix/node-modules.nix {
+            hash = nodeModulesHash;
+          };
+          mkPackage = pkgs.callPackage ./nix/opencode.nix { };
+        in
+        {
+          default = mkPackage {
+            version = packageJson.version;
+            src = ./.;
+            scripts = ./nix/scripts;
+            target = bunTarget.${system};
+            modelsDev = "${modelsDev.${system}}/dist/_api.json";
+            mkNodeModules = mkNodeModules;
+          };
+        }
+      );
+
+      apps = forEachSystem (
+        system:
+        let
+          pkgs = pkgsFor system;
+        in
+        {
+          opencode-dev = {
+            type = "app";
+            meta = {
+              description = "Nix devshell shell for OpenCode";
+              runtimeInputs = [ pkgs.bun ];
+            };
+            program = "${
+              pkgs.writeShellApplication {
+                name = "opencode-dev";
+                text = ''
+                  exec bun run dev "$@"
+                '';
+              }
+            }/bin/opencode-dev";
+          };
+        }
+      );
+    };
+}

+ 4 - 0
hosts/jetbrains-plugin/CHANGELOG.md

@@ -1,5 +1,9 @@
 # OpenCode JetBrains Plugin Changelog
 
+## 25.11.19
+
+- Updated OpenCode to v1.0.78
+
 ## 25.11.18
 
 - First release of the OpenCode JetBrains plugin, based on OpenCode v1.0.68

+ 8 - 0
hosts/jetbrains-plugin/README.md

@@ -1 +1,9 @@
 # OpenCode JetBrains Plugin
+
+Unofficial OpenCode JetBrain
+
+- Drag and drop files to context (JetBrains: from Project Window; VS Code: from Explorer or editor tab)
+- Add all opened files to context via command/shortcut
+- Add current opened file to context via command/shortcut
+- Add selected line ranges to context via command/shortcut
+- Easier prompt editing in a dedicated text area

+ 1 - 1
hosts/jetbrains-plugin/build.gradle.kts

@@ -5,7 +5,7 @@ plugins {
 }
 
 group = "paviko.opencode"
-version = "25.11.18"
+version = "25.11.19"
 
 repositories {
     mavenCentral()

+ 4 - 0
hosts/vscode-plugin/CHANGELOG.md

@@ -5,6 +5,10 @@ All notable changes to the OpenCode VSCode extension will be documented in this
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [25.11.19] - 2025-11-19
+
+- Updated OpenCode to v1.0.78
+
 ## [25.11.18] - 2025-11-18
 
 ### Added

+ 8 - 0
hosts/vscode-plugin/README.md

@@ -1 +1,9 @@
 # OpenCode VSCode Extension
+
+Unofficial OpenCode VSCode plugin
+
+- Drag and drop files to context (JetBrains: from Project Window; VS Code: from Explorer or editor tab)
+- Add all opened files to context via command/shortcut
+- Add current opened file to context via command/shortcut
+- Add selected line ranges to context via command/shortcut
+- Easier prompt editing in a dedicated text area

+ 1 - 1
hosts/vscode-plugin/package.json

@@ -2,7 +2,7 @@
   "name": "opencode",
   "displayName": "OpenCode",
   "description": "OpenCode for VSCode",
-  "version": "25.11.18",
+  "version": "25.11.19",
   "publisher": "opencode",
   "author": {
     "name": "OpenCode Team"

+ 3 - 0
nix/hashes.json

@@ -0,0 +1,3 @@
+{
+  "nodeModules": "sha256-Z3GTCHOVaBW79tx2rSkKOCHlZRPiTD6h9pdIDD12kxU="
+}

+ 52 - 0
nix/node-modules.nix

@@ -0,0 +1,52 @@
+{ hash, lib, stdenvNoCC, bun, cacert, curl }:
+args:
+stdenvNoCC.mkDerivation {
+  pname = "opencode-node_modules";
+  version = args.version;
+  src = args.src;
+
+  impureEnvVars =
+    lib.fetchers.proxyImpureEnvVars
+    ++ [
+      "GIT_PROXY_COMMAND"
+      "SOCKS_SERVER"
+    ];
+
+  nativeBuildInputs = [ bun cacert curl ];
+
+  dontConfigure = true;
+
+  buildPhase = ''
+    runHook preBuild
+    export HOME=$(mktemp -d)
+    export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
+    bun install \
+      --cpu="*" \
+      --os="*" \
+      --frozen-lockfile \
+      --ignore-scripts \
+      --no-progress \
+      --linker=isolated
+    bun --bun ${args.canonicalizeScript}
+    bun --bun ${args.normalizeBinsScript}
+    runHook postBuild
+  '';
+
+  installPhase = ''
+    runHook preInstall
+    mkdir -p $out
+    while IFS= read -r dir; do
+      rel="''${dir#./}"
+      dest="$out/$rel"
+      mkdir -p "$(dirname "$dest")"
+      cp -R "$dir" "$dest"
+    done < <(find . -type d -name node_modules -prune | sort)
+    runHook postInstall
+  '';
+
+  dontFixup = true;
+
+  outputHashAlgo = "sha256";
+  outputHashMode = "recursive";
+  outputHash = hash;
+}

+ 108 - 0
nix/opencode.nix

@@ -0,0 +1,108 @@
+{ lib, stdenv, stdenvNoCC, bun, fzf, ripgrep, makeBinaryWrapper }:
+args:
+let
+  scripts = args.scripts;
+  mkModules =
+    attrs:
+    args.mkNodeModules (
+      attrs
+      // {
+        canonicalizeScript = scripts + "/canonicalize-node-modules.ts";
+        normalizeBinsScript = scripts + "/normalize-bun-binaries.ts";
+      }
+    );
+in
+stdenvNoCC.mkDerivation (finalAttrs: {
+  pname = "opencode";
+  version = args.version;
+
+  src = args.src;
+
+  node_modules = mkModules {
+    version = finalAttrs.version;
+    src = finalAttrs.src;
+  };
+
+  nativeBuildInputs = [
+    bun
+    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";
+
+  buildPhase = ''
+    runHook preBuild
+
+    cp ${scripts + "/bun-build.ts"} bun-build.ts
+
+    substituteInPlace bun-build.ts \
+      --replace '@VERSION@' "${finalAttrs.version}"
+
+    export BUN_COMPILE_TARGET=${args.target}
+    bun --bun bun-build.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
+      exit 1
+    fi
+    if [ ! -f opencode-worker.js ]; then
+      echo "ERROR: opencode worker bundle not found in $(pwd)"
+      ls -la
+      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
+    runHook postInstall
+  '';
+
+  postFixup = ''
+    wrapProgram "$out/bin/opencode" --prefix PATH : ${lib.makeBinPath [ fzf ripgrep ]}
+  '';
+
+  meta = {
+    description = "AI coding agent built for the terminal";
+    longDescription = ''
+      OpenCode is a terminal-based agent that can build anything.
+      It combines a TypeScript/JavaScript core with a Go-based TUI
+      to provide an interactive AI coding experience.
+    '';
+    homepage = "https://github.com/sst/opencode";
+    license = lib.licenses.mit;
+    platforms = [
+      "aarch64-linux"
+      "x86_64-linux"
+      "aarch64-darwin"
+      "x86_64-darwin"
+    ];
+    mainProgram = "opencode";
+  };
+})

+ 115 - 0
nix/scripts/bun-build.ts

@@ -0,0 +1,115 @@
+import solidPlugin from "./packages/opencode/node_modules/@opentui/solid/scripts/solid-plugin"
+import path from "path"
+import fs from "fs"
+
+const version = "@VERSION@"
+const pkg = path.join(process.cwd(), "packages/opencode")
+const parser = fs.realpathSync(path.join(pkg, "./node_modules/@opentui/core/parser.worker.js"))
+const worker = "./src/cli/cmd/tui/worker.ts"
+const target = process.env["BUN_COMPILE_TARGET"]
+
+if (!target) {
+  throw new Error("BUN_COMPILE_TARGET not set")
+}
+
+process.chdir(pkg)
+
+const manifestName = "opencode-assets.manifest"
+const manifestPath = path.join(pkg, manifestName)
+
+const readTrackedAssets = () => {
+  if (!fs.existsSync(manifestPath)) return []
+  return fs
+    .readFileSync(manifestPath, "utf8")
+    .split("\n")
+    .map((line) => line.trim())
+    .filter((line) => line.length > 0)
+}
+
+const removeTrackedAssets = () => {
+  for (const file of readTrackedAssets()) {
+    const filePath = path.join(pkg, file)
+    if (fs.existsSync(filePath)) {
+      fs.rmSync(filePath, { force: true })
+    }
+  }
+}
+
+const assets = new Set<string>()
+
+const addAsset = async (p: string) => {
+  const file = path.basename(p)
+  const dest = path.join(pkg, file)
+  await Bun.write(dest, Bun.file(p))
+  assets.add(file)
+}
+
+removeTrackedAssets()
+
+const result = await Bun.build({
+  conditions: ["browser"],
+  tsconfig: "./tsconfig.json",
+  plugins: [solidPlugin],
+  sourcemap: "external",
+  entrypoints: ["./src/index.ts", parser, worker],
+  define: {
+    OPENCODE_VERSION: `'@VERSION@'`,
+    OTUI_TREE_SITTER_WORKER_PATH: "/$bunfs/root/" + path.relative(pkg, parser).replace(/\\/g, "/"),
+    OPENCODE_CHANNEL: "'latest'",
+  },
+  compile: {
+    target,
+    outfile: "opencode",
+    execArgv: ["--user-agent=opencode/" + version, '--env-file=""', "--"],
+    windows: {},
+  },
+})
+
+if (!result.success) {
+  console.error("Build failed!")
+  for (const log of result.logs) {
+    console.error(log)
+  }
+  throw new Error("Compilation failed")
+}
+
+const assetOutputs = result.outputs?.filter((x) => x.kind === "asset") ?? []
+for (const x of assetOutputs) {
+  await addAsset(x.path)
+}
+
+const bundle = await Bun.build({
+  entrypoints: [worker],
+  tsconfig: "./tsconfig.json",
+  plugins: [solidPlugin],
+  target: "bun",
+  outdir: "./.opencode-worker",
+  sourcemap: "none",
+})
+
+if (!bundle.success) {
+  console.error("Worker build failed!")
+  for (const log of bundle.logs) {
+    console.error(log)
+  }
+  throw new Error("Worker compilation failed")
+}
+
+const workerAssets = bundle.outputs?.filter((x) => x.kind === "asset") ?? []
+for (const x of workerAssets) {
+  await addAsset(x.path)
+}
+
+const output = bundle.outputs.find((x) => x.kind === "entry-point")
+if (!output) {
+  throw new Error("Worker build produced no entry-point output")
+}
+
+const dest = path.join(pkg, "opencode-worker.js")
+await Bun.write(dest, Bun.file(output.path))
+fs.rmSync(path.dirname(output.path), { recursive: true, force: true })
+
+const list = Array.from(assets)
+await Bun.write(manifestPath, list.length > 0 ? list.join("\n") + "\n" : "")
+
+console.log("Build successful!")

+ 96 - 0
nix/scripts/canonicalize-node-modules.ts

@@ -0,0 +1,96 @@
+import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
+import { join, relative } from "path"
+
+type SemverLike = {
+  valid: (value: string) => string | null
+  rcompare: (left: string, right: string) => number
+}
+
+type Entry = {
+  dir: string
+  version: string
+  label: string
+}
+
+const root = process.cwd()
+const bunRoot = join(root, "node_modules/.bun")
+const linkRoot = join(bunRoot, "node_modules")
+const directories = (await readdir(bunRoot)).sort()
+const versions = new Map<string, Entry[]>()
+
+for (const entry of directories) {
+  const full = join(bunRoot, entry)
+  const info = await lstat(full)
+  if (!info.isDirectory()) {
+    continue
+  }
+  const marker = entry.lastIndexOf("@")
+  if (marker <= 0) {
+    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 semverModule = (await import(join(bunRoot, "node_modules/semver"))) as
+  | SemverLike
+  | {
+      default: SemverLike
+    }
+const semver = "default" in semverModule ? semverModule.default : semverModule
+const selections = new Map<string, Entry>()
+
+for (const [slug, list] of versions) {
+  list.sort((a, b) => {
+    const left = semver.valid(a.version)
+    const right = semver.valid(b.version)
+    if (left && right) {
+      const delta = semver.rcompare(left, right)
+      if (delta !== 0) {
+        return delta
+      }
+    }
+    if (left && !right) {
+      return -1
+    }
+    if (!left && right) {
+      return 1
+    }
+    return b.version.localeCompare(a.version)
+  })
+  selections.set(slug, list[0])
+}
+
+await rm(linkRoot, { recursive: true, force: true })
+await mkdir(linkRoot, { recursive: true })
+
+const rewrites: string[] = []
+
+for (const [slug, entry] of Array.from(selections.entries()).sort((a, b) => a[0].localeCompare(b[0]))) {
+  const parts = slug.split("/")
+  const leaf = parts.pop()
+  if (!leaf) {
+    continue
+  }
+  const parent = join(linkRoot, ...parts)
+  await mkdir(parent, { recursive: true })
+  const linkPath = join(parent, leaf)
+  const desired = join(entry.dir, "node_modules", slug)
+  const relativeTarget = relative(parent, desired)
+  const resolved = relativeTarget.length === 0 ? "." : relativeTarget
+  await rm(linkPath, { recursive: true, force: true })
+  await symlink(resolved, linkPath)
+  rewrites.push(slug + " -> " + resolved)
+}
+
+rewrites.sort()
+console.log("[canonicalize-node-modules] rebuilt", rewrites.length, "links")
+for (const line of rewrites.slice(0, 20)) {
+  console.log("  ", line)
+}
+if (rewrites.length > 20) {
+  console.log("  ...")
+}

+ 138 - 0
nix/scripts/normalize-bun-binaries.ts

@@ -0,0 +1,138 @@
+import { lstat, mkdir, readdir, rm, symlink } from "fs/promises"
+import { join, relative } from "path"
+
+type PackageManifest = {
+  name?: string
+  bin?: string | Record<string, string>
+}
+
+const root = process.cwd()
+const bunRoot = join(root, "node_modules/.bun")
+const bunEntries = (await safeReadDir(bunRoot)).sort()
+let rewritten = 0
+
+for (const entry of bunEntries) {
+  const modulesRoot = join(bunRoot, entry, "node_modules")
+  if (!(await exists(modulesRoot))) {
+    continue
+  }
+  const binRoot = join(modulesRoot, ".bin")
+  await rm(binRoot, { recursive: true, force: true })
+  await mkdir(binRoot, { recursive: true })
+
+  const packageDirs = await collectPackages(modulesRoot)
+  for (const packageDir of packageDirs) {
+    const manifest = await readManifest(packageDir)
+    if (!manifest) {
+      continue
+    }
+    const binField = manifest.bin
+    if (!binField) {
+      continue
+    }
+    const seen = new Set<string>()
+    if (typeof binField === "string") {
+      const fallback = manifest.name ?? packageDir.split("/").pop()
+      if (fallback) {
+        await linkBinary(binRoot, fallback, packageDir, binField, seen)
+      }
+    } else {
+      const entries = Object.entries(binField).sort((a, b) => a[0].localeCompare(b[0]))
+      for (const [name, target] of entries) {
+        await linkBinary(binRoot, name, packageDir, target, seen)
+      }
+    }
+  }
+}
+
+console.log(`[normalize-bun-binaries] rewrote ${rewritten} links`)
+
+async function collectPackages(modulesRoot: string) {
+  const found: string[] = []
+  const topLevel = (await safeReadDir(modulesRoot)).sort()
+  for (const name of topLevel) {
+    if (name === ".bin" || name === ".bun") {
+      continue
+    }
+    const full = join(modulesRoot, name)
+    if (!(await isDirectory(full))) {
+      continue
+    }
+    if (name.startsWith("@")) {
+      const scoped = (await safeReadDir(full)).sort()
+      for (const child of scoped) {
+        const scopedDir = join(full, child)
+        if (await isDirectory(scopedDir)) {
+          found.push(scopedDir)
+        }
+      }
+      continue
+    }
+    found.push(full)
+  }
+  return found.sort()
+}
+
+async function readManifest(dir: string) {
+  const file = Bun.file(join(dir, "package.json"))
+  if (!(await file.exists())) {
+    return null
+  }
+  const data = (await file.json()) as PackageManifest
+  return data
+}
+
+async function linkBinary(binRoot: string, name: string, packageDir: string, target: string, seen: Set<string>) {
+  if (!name || !target) {
+    return
+  }
+  const normalizedName = normalizeBinName(name)
+  if (seen.has(normalizedName)) {
+    return
+  }
+  const resolved = join(packageDir, target)
+  const script = Bun.file(resolved)
+  if (!(await script.exists())) {
+    return
+  }
+  seen.add(normalizedName)
+  const destination = join(binRoot, normalizedName)
+  const relativeTarget = relative(binRoot, resolved) || "."
+  await rm(destination, { force: true })
+  await symlink(relativeTarget, destination)
+  rewritten++
+}
+
+async function exists(path: string) {
+  try {
+    await lstat(path)
+    return true
+  } catch {
+    return false
+  }
+}
+
+async function isDirectory(path: string) {
+  try {
+    const info = await lstat(path)
+    return info.isDirectory()
+  } catch {
+    return false
+  }
+}
+
+async function safeReadDir(path: string) {
+  try {
+    return await readdir(path)
+  } catch {
+    return []
+  }
+}
+
+function normalizeBinName(name: string) {
+  const slash = name.lastIndexOf("/")
+  if (slash >= 0) {
+    return name.slice(slash + 1)
+  }
+  return name
+}

+ 112 - 0
nix/scripts/update-hashes.sh

@@ -0,0 +1,112 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+DUMMY="sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
+SYSTEM=${SYSTEM:-x86_64-linux}
+DEFAULT_HASH_FILE=${MODULES_HASH_FILE:-nix/hashes.json}
+HASH_FILE=${HASH_FILE:-$DEFAULT_HASH_FILE}
+
+if [ ! -f "$HASH_FILE" ]; then
+  cat >"$HASH_FILE" <<EOF
+{
+  "nodeModules": "$DUMMY"
+}
+EOF
+fi
+
+if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
+  if ! git ls-files --error-unmatch "$HASH_FILE" >/dev/null 2>&1; then
+    git add -N "$HASH_FILE" >/dev/null 2>&1 || true
+  fi
+fi
+
+export DUMMY
+export NIX_KEEP_OUTPUTS=1
+export NIX_KEEP_DERIVATIONS=1
+
+cleanup() {
+  rm -f "${JSON_OUTPUT:-}" "${BUILD_LOG:-}" "${TMP_EXPR:-}"
+}
+
+trap cleanup EXIT
+
+write_node_modules_hash() {
+  local value="$1"
+  local temp
+  temp=$(mktemp)
+  jq --arg value "$value" '.nodeModules = $value' "$HASH_FILE" >"$temp"
+  mv "$temp" "$HASH_FILE"
+}
+
+TARGET="packages.${SYSTEM}.default"
+MODULES_ATTR=".#packages.${SYSTEM}.default.node_modules"
+CORRECT_HASH=""
+
+DRV_PATH="$(nix eval --raw "${MODULES_ATTR}.drvPath")"
+
+echo "Setting dummy node_modules outputHash for ${SYSTEM}..."
+write_node_modules_hash "$DUMMY"
+
+BUILD_LOG=$(mktemp)
+JSON_OUTPUT=$(mktemp)
+
+echo "Building node_modules for ${SYSTEM} to discover correct outputHash..."
+echo "Attempting to realize derivation: ${DRV_PATH}"
+REALISE_OUT=$(nix-store --realise "$DRV_PATH" --keep-failed 2>&1 | tee "$BUILD_LOG" || true)
+
+BUILD_PATH=$(echo "$REALISE_OUT" | grep "^/nix/store/" | head -n1 || true)
+if [ -n "$BUILD_PATH" ] && [ -d "$BUILD_PATH" ]; then
+  echo "Realized node_modules output: $BUILD_PATH"
+  CORRECT_HASH=$(nix hash path --sri "$BUILD_PATH" 2>/dev/null || true)
+fi
+
+if [ -z "$CORRECT_HASH" ]; then
+  CORRECT_HASH="$(grep -E 'got:\s+sha256-[A-Za-z0-9+/=]+' "$BUILD_LOG" | awk '{print $2}' | head -n1 || true)"
+
+  if [ -z "$CORRECT_HASH" ]; then
+    CORRECT_HASH="$(grep -A2 'hash mismatch' "$BUILD_LOG" | grep 'got:' | awk '{print $2}' | sed 's/sha256:/sha256-/' || true)"
+  fi
+
+  if [ -z "$CORRECT_HASH" ]; then
+    echo "Searching for kept failed build directory..."
+    KEPT_DIR=$(grep -oE "build directory.*'[^']+'" "$BUILD_LOG" | grep -oE "'/[^']+'" | tr -d "'" | head -n1)
+
+    if [ -z "$KEPT_DIR" ]; then
+      KEPT_DIR=$(grep -oE '/nix/var/nix/builds/[^ ]+' "$BUILD_LOG" | head -n1)
+    fi
+
+    if [ -n "$KEPT_DIR" ] && [ -d "$KEPT_DIR" ]; then
+      echo "Found kept build directory: $KEPT_DIR"
+      if [ -d "$KEPT_DIR/build" ]; then
+        HASH_PATH="$KEPT_DIR/build"
+      else
+        HASH_PATH="$KEPT_DIR"
+      fi
+
+      echo "Attempting to hash: $HASH_PATH"
+      ls -la "$HASH_PATH" || true
+
+      if [ -d "$HASH_PATH/node_modules" ]; then
+        CORRECT_HASH=$(nix hash path --sri "$HASH_PATH" 2>/dev/null || true)
+        echo "Computed hash from kept build: $CORRECT_HASH"
+      fi
+    fi
+  fi
+fi
+
+if [ -z "$CORRECT_HASH" ]; then
+  echo "Failed to determine correct node_modules hash for ${SYSTEM}."
+  echo "Build log:"
+  cat "$BUILD_LOG"
+  exit 1
+fi
+
+write_node_modules_hash "$CORRECT_HASH"
+
+jq -e --arg hash "$CORRECT_HASH" '.nodeModules == $hash' "$HASH_FILE" >/dev/null
+
+echo "node_modules hash updated for ${SYSTEM}: $CORRECT_HASH"
+
+rm -f "$BUILD_LOG"
+unset BUILD_LOG

+ 5 - 4
packages/console/app/package.json

@@ -7,19 +7,20 @@
     "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.68"
+    "version": "1.0.78"
   },
   "dependencies": {
     "@ibm/plex": "6.4.1",
+    "@jsx-email/render": "1.1.1",
+    "@kobalte/core": "catalog:",
+    "@openauthjs/openauth": "catalog:",
     "@opencode-ai/console-core": "workspace:*",
     "@opencode-ai/console-mail": "workspace:*",
-    "@openauthjs/openauth": "catalog:",
-    "@kobalte/core": "catalog:",
-    "@jsx-email/render": "1.1.1",
     "@opencode-ai/console-resource": "workspace:*",
     "@solidjs/meta": "^0.29.4",
     "@solidjs/router": "^0.15.0",
     "@solidjs/start": "^1.1.0",
+    "chart.js": "4.5.1",
     "solid-js": "catalog:",
     "vinxi": "^0.5.7",
     "zod": "catalog:"

+ 16 - 0
packages/console/app/src/component/icon.tsx

@@ -212,3 +212,19 @@ export function IconStealth(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
     </svg>
   )
 }
+
+export function IconChevronLeft(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+  return (
+    <svg {...props} viewBox="0 0 20 20" fill="none">
+      <path d="M12 15L7 10L12 5" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
+    </svg>
+  )
+}
+
+export function IconChevronRight(props: JSX.SvgSVGAttributes<SVGSVGElement>) {
+  return (
+    <svg {...props} viewBox="0 0 20 20" fill="none">
+      <path d="M8 5L13 10L8 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" />
+    </svg>
+  )
+}

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

@@ -0,0 +1,145 @@
+.root {
+  [data-component="empty-state"] {
+    padding: var(--space-20) var(--space-6);
+    text-align: center;
+    border: 1px dashed var(--color-border);
+    border-radius: var(--border-radius-sm);
+    height: 400px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+
+    p {
+      font-size: var(--font-size-sm);
+      color: var(--color-text-muted);
+    }
+  }
+
+  [data-slot="filter-container"] {
+    margin-bottom: 0;
+    display: flex;
+    align-items: center;
+    gap: var(--space-3);
+
+    [data-component="dropdown"] {
+      [data-slot="trigger"] {
+        border: 1px solid var(--color-border);
+        background-color: var(--color-bg);
+        padding: var(--space-2) var(--space-3);
+        border-radius: var(--border-radius-sm);
+        color: var(--color-text);
+        font-size: var(--font-size-sm);
+        line-height: 1.5;
+
+        &:hover {
+          border-color: var(--color-accent);
+        }
+
+        &:focus {
+          outline: none;
+          border-color: var(--color-accent);
+          box-shadow: 0 0 0 3px var(--color-accent-alpha);
+        }
+      }
+
+      [data-slot="chevron"] {
+        opacity: 0.6;
+      }
+
+      [data-slot="dropdown"] {
+        min-width: 200px;
+        max-height: 300px;
+        overflow-y: auto;
+        padding: var(--space-1);
+      }
+    }
+  }
+
+  [data-slot="month-picker"] {
+    display: flex;
+    align-items: center;
+    background-color: var(--color-bg);
+    border: 1px solid var(--color-border);
+    border-radius: var(--border-radius-sm);
+    padding: 0;
+  }
+
+  [data-slot="month-button"] {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    background: none;
+    border: none !important;
+    color: var(--color-text);
+    cursor: pointer;
+    padding: var(--space-2) var(--space-3);
+    border-radius: var(--border-radius-xs);
+    transition: background-color 0.2s;
+    line-height: 1;
+
+    &:hover {
+      background-color: var(--color-bg-hover);
+    }
+
+    svg {
+      display: block;
+      width: 16px;
+      height: 16px;
+      stroke-width: 2;
+    }
+  }
+
+  [data-slot="month-label"] {
+    font-size: var(--font-size-sm);
+    font-weight: 500;
+    color: var(--color-text);
+    line-height: 1.5;
+    min-width: 140px;
+    text-align: center;
+    white-space: nowrap;
+  }
+
+  [data-slot="model-item"] {
+    display: flex;
+    align-items: center;
+    gap: var(--space-2);
+    padding: var(--space-2) var(--space-3);
+    cursor: pointer;
+    transition: background-color 0.2s;
+    font-size: var(--font-size-sm);
+    color: var(--color-text);
+    border: none !important;
+    background: none;
+    width: 100%;
+    text-align: left;
+    white-space: nowrap;
+
+    &:hover {
+      background: var(--color-bg-hover);
+    }
+
+    span {
+      flex: 1;
+      user-select: none;
+    }
+  }
+
+  [data-slot="chart-container"] {
+    padding: var(--space-6);
+    background: var(--color-bg-secondary);
+    border: 1px solid var(--color-border);
+    border-radius: var(--border-radius-sm);
+    height: 400px;
+  }
+
+  @media (max-width: 40rem) {
+    [data-slot="chart-container"] {
+      height: 300px;
+      padding: var(--space-4);
+    }
+
+    [data-component="empty-state"] {
+      height: 300px;
+    }
+  }
+}

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

@@ -0,0 +1,423 @@
+import { and, Database, eq, gte, inArray, isNull, lte, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js"
+import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js"
+import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js"
+import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js"
+import { AuthTable } from "@opencode-ai/console-core/schema/auth.sql.js"
+import { createAsync, query, useParams } from "@solidjs/router"
+import { createEffect, createMemo, onCleanup, Show, For } from "solid-js"
+import { createStore } from "solid-js/store"
+import { withActor } from "~/context/auth.withActor"
+import { Dropdown } from "~/component/dropdown"
+import { IconChevronLeft, IconChevronRight } from "~/component/icon"
+import styles from "./graph-section.module.css"
+import {
+  Chart,
+  BarController,
+  BarElement,
+  CategoryScale,
+  LinearScale,
+  Tooltip,
+  Legend,
+  type ChartConfiguration,
+} from "chart.js"
+
+Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip, Legend)
+
+async function getCosts(workspaceID: string, year: number, month: number) {
+  "use server"
+  return withActor(async () => {
+    const startDate = new Date(year, month, 1)
+    const endDate = new Date(year, month + 1, 0)
+
+    // First query: get usage data without joining keys
+    const usageData = await Database.use((tx) =>
+      tx
+        .select({
+          date: sql<string>`DATE(${UsageTable.timeCreated})`,
+          model: UsageTable.model,
+          totalCost: sum(UsageTable.cost),
+          keyId: UsageTable.keyID,
+        })
+        .from(UsageTable)
+        .where(
+          and(
+            eq(UsageTable.workspaceID, workspaceID),
+            gte(UsageTable.timeCreated, startDate),
+            lte(UsageTable.timeCreated, endDate),
+          ),
+        )
+        .groupBy(sql`DATE(${UsageTable.timeCreated})`, UsageTable.model, UsageTable.keyID)
+        .then((x) =>
+          x.map((r) => ({
+            ...r,
+            totalCost: r.totalCost ? parseInt(r.totalCost) : 0,
+          })),
+        ),
+    )
+
+    // Get unique key IDs from usage
+    const usageKeyIds = new Set(usageData.map((r) => r.keyId).filter((id) => id !== null))
+
+    // Second query: get all existing keys plus any keys from usage
+    const keysData = await Database.use((tx) =>
+      tx
+        .select({
+          keyId: KeyTable.id,
+          keyName: KeyTable.name,
+          userEmail: AuthTable.subject,
+          timeDeleted: KeyTable.timeDeleted,
+        })
+        .from(KeyTable)
+        .innerJoin(UserTable, and(eq(KeyTable.userID, UserTable.id), eq(KeyTable.workspaceID, UserTable.workspaceID)))
+        .innerJoin(AuthTable, and(eq(UserTable.accountID, AuthTable.accountID), eq(AuthTable.provider, "email")))
+        .where(
+          and(
+            eq(KeyTable.workspaceID, workspaceID),
+            usageKeyIds.size > 0
+              ? or(inArray(KeyTable.id, Array.from(usageKeyIds)), isNull(KeyTable.timeDeleted))
+              : isNull(KeyTable.timeDeleted),
+          ),
+        )
+        .orderBy(AuthTable.subject, KeyTable.name),
+    )
+
+    return {
+      usage: usageData,
+      keys: keysData.map((key) => ({
+        id: key.keyId,
+        displayName:
+          key.timeDeleted !== null
+            ? `${key.userEmail} - ${key.keyName} (deleted)`
+            : `${key.userEmail} - ${key.keyName}`,
+      })),
+    }
+  }, workspaceID)
+}
+
+const queryCosts = query(getCosts, "costs.get")
+
+const MODEL_COLORS: Record<string, string> = {
+  "claude-sonnet-4-5": "#D4745C",
+  "claude-sonnet-4": "#E8B4A4",
+  "claude-opus-4": "#C8A098",
+  "claude-haiku-4-5": "#F0D8D0",
+  "claude-3-5-haiku": "#F8E8E0",
+  "gpt-5.1": "#4A90E2",
+  "gpt-5.1-codex": "#6BA8F0",
+  "gpt-5": "#7DB8F8",
+  "gpt-5-codex": "#9FCAFF",
+  "gpt-5-nano": "#B8D8FF",
+  "grok-code": "#8B5CF6",
+  "big-pickle": "#10B981",
+  "kimi-k2": "#F59E0B",
+  "qwen3-coder": "#EC4899",
+  "glm-4.6": "#14B8A6",
+}
+
+function getModelColor(model: string): string {
+  if (MODEL_COLORS[model]) return MODEL_COLORS[model]
+
+  const hash = model.split("").reduce((acc, char) => char.charCodeAt(0) + ((acc << 5) - acc), 0)
+  const hue = Math.abs(hash) % 360
+  return `hsl(${hue}, 50%, 65%)`
+}
+
+function formatDateLabel(dateStr: string): string {
+  const date = new Date()
+  const [y, m, d] = dateStr.split("-").map(Number)
+  date.setFullYear(y)
+  date.setMonth(m - 1)
+  date.setDate(d)
+  date.setHours(0, 0, 0, 0)
+  const month = date.toLocaleDateString("en-US", { month: "short" })
+  const day = date.getUTCDate().toString().padStart(2, "0")
+  return `${month} ${day}`
+}
+
+function addOpacityToColor(color: string, opacity: number): string {
+  if (color.startsWith("#")) {
+    const r = parseInt(color.slice(1, 3), 16)
+    const g = parseInt(color.slice(3, 5), 16)
+    const b = parseInt(color.slice(5, 7), 16)
+    return `rgba(${r}, ${g}, ${b}, ${opacity})`
+  }
+  if (color.startsWith("hsl")) return color.replace(")", `, ${opacity})`).replace("hsl", "hsla")
+  return color
+}
+
+export function GraphSection() {
+  let canvasRef: HTMLCanvasElement | undefined
+  let chartInstance: Chart | undefined
+  const params = useParams()
+  const now = new Date()
+  const [store, setStore] = createStore({
+    data: null as Awaited<ReturnType<typeof getCosts>> | null,
+    year: now.getFullYear(),
+    month: now.getMonth(),
+    key: null as string | null,
+    model: null as string | null,
+    modelDropdownOpen: false,
+    keyDropdownOpen: false,
+  })
+  const initialData = createAsync(() => queryCosts(params.id!, store.year, store.month))
+
+  const onPreviousMonth = async () => {
+    const month = store.month === 0 ? 11 : store.month - 1
+    const year = store.month === 0 ? store.year - 1 : store.year
+    const data = await getCosts(params.id!, year, month)
+    setStore({ month, year, data })
+  }
+
+  const onNextMonth = async () => {
+    const month = store.month === 11 ? 0 : store.month + 1
+    const year = store.month === 11 ? store.year + 1 : store.year
+    setStore({ month, year, data: await getCosts(params.id!, year, month) })
+  }
+
+  const onSelectModel = (model: string | null) => setStore({ model, modelDropdownOpen: false })
+
+  const onSelectKey = (keyID: string | null) => setStore({ key: keyID, keyDropdownOpen: false })
+
+  const getData = createMemo(() => store.data ?? initialData())
+
+  const getModels = createMemo(() => {
+    const data = getData()
+    if (!data?.usage) return []
+    return Array.from(new Set(data.usage.map((row) => row.model))).sort()
+  })
+
+  const getDates = createMemo(() => {
+    const daysInMonth = new Date(store.year, store.month + 1, 0).getDate()
+    return Array.from({ length: daysInMonth }, (_, i) => {
+      const date = new Date(store.year, store.month, i + 1)
+      return date.toISOString().split("T")[0]
+    })
+  })
+
+  const getKeyName = (keyID: string | null): string => {
+    if (!keyID || !store.data?.keys) return "All Keys"
+    const found = store.data.keys.find((k) => k.id === keyID)
+    return found?.displayName ?? "All Keys"
+  }
+
+  const formatMonthYear = () =>
+    new Date(store.year, store.month, 1).toLocaleDateString("en-US", { month: "long", year: "numeric" })
+
+  const isCurrentMonth = () => store.year === now.getFullYear() && store.month === now.getMonth()
+
+  const chartConfig = createMemo((): ChartConfiguration | null => {
+    const data = getData()
+    const dates = getDates()
+    if (!data?.usage?.length) return null
+
+    const dailyData = new Map<string, Map<string, number>>()
+    for (const dateKey of dates) dailyData.set(dateKey, new Map())
+
+    data.usage
+      .filter((row) => (store.key ? row.keyId === store.key : true))
+      .forEach((row) => {
+        const dayMap = dailyData.get(row.date)
+        if (!dayMap) return
+        dayMap.set(row.model, (dayMap.get(row.model) ?? 0) + row.totalCost)
+      })
+
+    const filteredModels = store.model === null ? getModels() : [store.model]
+
+    const datasets = filteredModels.map((model) => {
+      const color = getModelColor(model)
+      return {
+        label: model,
+        data: dates.map((date) => (dailyData.get(date)?.get(model) || 0) / 100_000_000),
+        backgroundColor: color,
+        hoverBackgroundColor: color,
+        borderWidth: 0,
+      }
+    })
+
+    return {
+      type: "bar",
+      data: {
+        labels: dates.map(formatDateLabel),
+        datasets,
+      },
+      options: {
+        responsive: true,
+        maintainAspectRatio: false,
+        scales: {
+          x: {
+            stacked: true,
+            grid: {
+              display: false,
+            },
+            ticks: {
+              maxRotation: 0,
+              autoSkipPadding: 20,
+              color: "rgba(255, 255, 255, 0.5)",
+              font: {
+                family: "monospace",
+                size: 11,
+              },
+            },
+          },
+          y: {
+            stacked: true,
+            beginAtZero: true,
+            grid: {
+              color: "rgba(255, 255, 255, 0.1)",
+            },
+            ticks: {
+              color: "rgba(255, 255, 255, 0.5)",
+              font: {
+                family: "monospace",
+                size: 11,
+              },
+              callback: (value) => {
+                const num = Number(value)
+                return num >= 1000 ? `$${(num / 1000).toFixed(1)}k` : `$${num.toFixed(0)}`
+              },
+            },
+          },
+        },
+        plugins: {
+          tooltip: {
+            mode: "index",
+            intersect: false,
+            backgroundColor: "rgba(0, 0, 0, 0.9)",
+            titleColor: "rgba(255, 255, 255, 0.9)",
+            bodyColor: "rgba(255, 255, 255, 0.8)",
+            borderColor: "rgba(255, 255, 255, 0.1)",
+            borderWidth: 1,
+            padding: 12,
+            displayColors: true,
+            callbacks: {
+              label: (context) => {
+                const value = context.parsed.y
+                if (!value || value === 0) return
+                return `${context.dataset.label}: $${value.toFixed(2)}`
+              },
+            },
+          },
+          legend: {
+            display: true,
+            position: "bottom",
+            labels: {
+              color: "rgba(255, 255, 255, 0.7)",
+              font: {
+                size: 12,
+              },
+              padding: 16,
+              boxWidth: 16,
+              boxHeight: 16,
+              usePointStyle: false,
+            },
+            onHover: (event, legendItem, legend) => {
+              const chart = legend.chart
+              chart.data.datasets?.forEach((dataset, i) => {
+                const meta = chart.getDatasetMeta(i)
+                const baseColor = getModelColor(dataset.label || "")
+                const color = i === legendItem.datasetIndex ? baseColor : addOpacityToColor(baseColor, 0.3)
+                meta.data.forEach((bar: any) => {
+                  bar.options.backgroundColor = color
+                })
+              })
+              chart.update("none")
+            },
+            onLeave: (event, legendItem, legend) => {
+              const chart = legend.chart
+              chart.data.datasets?.forEach((dataset, i) => {
+                const meta = chart.getDatasetMeta(i)
+                const baseColor = getModelColor(dataset.label || "")
+                meta.data.forEach((bar: any) => {
+                  bar.options.backgroundColor = baseColor
+                })
+              })
+              chart.update("none")
+            },
+          },
+        },
+      },
+    }
+  })
+
+  createEffect(() => {
+    const config = chartConfig()
+    if (!config || !canvasRef) return
+
+    if (chartInstance) chartInstance.destroy()
+    chartInstance = new Chart(canvasRef, config)
+  })
+
+  onCleanup(() => chartInstance?.destroy())
+
+  return (
+    <section class={styles.root}>
+      <div data-slot="section-title">
+        <h2>Cost</h2>
+        <p>Usage costs broken down by model.</p>
+      </div>
+
+      <Show when={getData()}>
+        <div data-slot="filter-container">
+          <div data-slot="month-picker">
+            <button data-slot="month-button" onClick={onPreviousMonth}>
+              <IconChevronLeft />
+            </button>
+            <span data-slot="month-label">{formatMonthYear()}</span>
+            <button data-slot="month-button" onClick={onNextMonth} disabled={isCurrentMonth()}>
+              <IconChevronRight />
+            </button>
+          </div>
+          <Dropdown
+            trigger={store.model === null ? "All Models" : store.model}
+            open={store.modelDropdownOpen}
+            onOpenChange={(open) => setStore({ modelDropdownOpen: open })}
+          >
+            <>
+              <button data-slot="model-item" onClick={() => onSelectModel(null)}>
+                <span>All Models</span>
+              </button>
+              <For each={getModels()}>
+                {(model) => (
+                  <button data-slot="model-item" onClick={() => onSelectModel(model)}>
+                    <span>{model}</span>
+                  </button>
+                )}
+              </For>
+            </>
+          </Dropdown>
+          <Dropdown
+            trigger={getKeyName(store.key)}
+            open={store.keyDropdownOpen}
+            onOpenChange={(open) => setStore({ keyDropdownOpen: open })}
+          >
+            <>
+              <button data-slot="model-item" onClick={() => onSelectKey(null)}>
+                <span>All Keys</span>
+              </button>
+              <For each={getData()?.keys || []}>
+                {(key) => (
+                  <button data-slot="model-item" onClick={() => onSelectKey(key.id)}>
+                    <span>{key.displayName}</span>
+                  </button>
+                )}
+              </For>
+            </>
+          </Dropdown>
+        </div>
+      </Show>
+
+      <Show
+        when={chartConfig()}
+        fallback={
+          <div data-component="empty-state">
+            <p>No usage data available for the selected period.</p>
+          </div>
+        }
+      >
+        <div data-slot="chart-container">
+          <canvas ref={canvasRef} />
+        </div>
+      </Show>
+    </section>
+  )
+}

+ 4 - 0
packages/console/app/src/routes/workspace/[id]/index.tsx

@@ -5,6 +5,7 @@ import { NewUserSection } from "./new-user-section"
 import { UsageSection } from "./usage-section"
 import { ModelSection } from "./model-section"
 import { ProviderSection } from "./provider-section"
+import { GraphSection } from "./graph-section"
 import { IconLogo } from "~/component/icon"
 import { querySessionInfo, queryBillingInfo, createCheckoutUrl, formatBalance } from "../common"
 
@@ -66,6 +67,9 @@ export default function () {
 
       <div data-slot="sections">
         <NewUserSection />
+        <Show when={userInfo()?.isAdmin}>
+          <GraphSection />
+        </Show>
         <ModelSection />
         <Show when={userInfo()?.isAdmin}>
           <ProviderSection />

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

@@ -1,111 +1,119 @@
-/* Empty state */
-[data-component="empty-state"] {
-  padding: var(--space-20) var(--space-6);
-  text-align: center;
-  border: 1px dashed var(--color-border);
-  border-radius: var(--border-radius-sm);
-
-  p {
-    font-size: var(--font-size-sm);
-    color: var(--color-text-muted);
-  }
-}
-
-/* Table container */
-[data-slot="usage-table"] {
-  overflow-x: auto;
-}
-
-/* Table element */
-[data-slot="usage-table-element"] {
-  width: 100%;
-  border-collapse: collapse;
-  font-size: var(--font-size-sm);
+.root {
+  /* Empty state */
+  [data-component="empty-state"] {
+    padding: var(--space-20) var(--space-6);
+    text-align: center;
+    border: 1px dashed var(--color-border);
+    border-radius: var(--border-radius-sm);
 
-  thead {
-    border-bottom: 1px solid var(--color-border);
+    p {
+      font-size: var(--font-size-sm);
+      color: var(--color-text-muted);
+    }
   }
 
-  th {
-    padding: var(--space-3) var(--space-4);
-    text-align: left;
-    font-weight: normal;
-    color: var(--color-text-muted);
-    text-transform: uppercase;
+  /* Table container */
+  [data-slot="usage-table"] {
+    overflow-x: auto;
   }
 
-  td {
-    padding: var(--space-3) var(--space-4);
-    border-bottom: 1px solid var(--color-border-muted);
-    color: var(--color-text-muted);
-    font-family: var(--font-mono);
+  /* Table element */
+  [data-slot="usage-table-element"] {
+    width: 100%;
+    border-collapse: collapse;
+    font-size: var(--font-size-sm);
 
-    &[data-slot="usage-date"] {
-      color: var(--color-text);
+    thead {
+      border-bottom: 1px solid var(--color-border);
     }
 
-    &[data-slot="usage-model"] {
-      font-family: var(--font-sans);
-      color: var(--color-text-secondary);
-      max-width: 200px;
-      word-break: break-word;
+    th {
+      padding: var(--space-3) var(--space-4);
+      text-align: left;
+      font-weight: normal;
+      color: var(--color-text-muted);
+      text-transform: uppercase;
     }
 
-    &[data-slot="usage-cost"] {
-      color: var(--color-text);
-      font-weight: 500;
-    }
-  }
-
-  tbody tr:last-child td {
-    border-bottom: none;
-  }
-}
-
-/* Pagination */
-[data-slot="pagination"] {
-  display: flex;
-  justify-content: flex-end;
-  gap: var(--space-2);
-  padding: var(--space-4) 0;
-  border-top: 1px solid var(--color-border-muted);
-  margin-top: var(--space-2);
-
-  button {
-    padding: var(--space-2) var(--space-4);
-    background: var(--color-bg-secondary);
-    border: 1px solid var(--color-border);
-    border-radius: var(--border-radius-sm);
-    color: var(--color-text);
-    font-size: var(--font-size-sm);
-    cursor: pointer;
-    transition: all 0.15s ease;
-
-    &:hover:not(:disabled) {
-      background: var(--color-bg-tertiary);
-      border-color: var(--color-border-hover);
+    td {
+      padding: var(--space-3) var(--space-4);
+      border-bottom: 1px solid var(--color-border-muted);
+      color: var(--color-text-muted);
+      font-family: var(--font-mono);
+
+      &[data-slot="usage-date"] {
+        color: var(--color-text);
+      }
+
+      &[data-slot="usage-model"] {
+        font-family: var(--font-sans);
+        color: var(--color-text-secondary);
+        max-width: 200px;
+        word-break: break-word;
+      }
+
+      &[data-slot="usage-cost"] {
+        color: var(--color-text);
+        font-weight: 500;
+      }
     }
 
-    &:disabled {
-      opacity: 0.5;
-      cursor: not-allowed;
+    tbody tr:last-child td {
+      border-bottom: none;
     }
   }
-}
 
-/* Mobile responsive */
-@media (max-width: 40rem) {
-  [data-slot="usage-table-element"] {
-    th,
-    td {
-      padding: var(--space-2) var(--space-3);
-      font-size: var(--font-size-xs);
+  /* Pagination */
+  [data-slot="pagination"] {
+    display: flex;
+    justify-content: flex-end;
+    gap: var(--space-2);
+    padding: var(--space-4) 0;
+    border-top: 1px solid var(--color-border-muted);
+    margin-top: var(--space-2);
+
+    button {
+      padding: var(--space-2) var(--space-4);
+      background: var(--color-bg-secondary);
+      border: 1px solid var(--color-border);
+      border-radius: var(--border-radius-sm);
+      color: var(--color-text);
+      font-size: var(--font-size-sm);
+      cursor: pointer;
+      transition: all 0.15s ease;
+
+      svg {
+        width: 16px;
+        height: 16px;
+        stroke-width: 2;
+      }
+
+      &:hover:not(:disabled) {
+        background: var(--color-bg-tertiary);
+        border-color: var(--color-border-hover);
+      }
+
+      &:disabled {
+        opacity: 0.5;
+        cursor: not-allowed;
+      }
     }
+  }
 
-    /* Hide Model column on mobile */
-    th:nth-child(2),
-    td:nth-child(2) {
-      display: none;
+  /* Mobile responsive */
+  @media (max-width: 40rem) {
+    [data-slot="usage-table-element"] {
+      th,
+      td {
+        padding: var(--space-2) var(--space-3);
+        font-size: var(--font-size-xs);
+      }
+
+      /* Hide Model column on mobile */
+      th:nth-child(2),
+      td:nth-child(2) {
+        display: none;
+      }
     }
   }
 }

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

@@ -3,7 +3,8 @@ import { createAsync, query, useParams } from "@solidjs/router"
 import { createMemo, For, Show, createEffect } from "solid-js"
 import { formatDateUTC, formatDateForTable } from "../common"
 import { withActor } from "~/context/auth.withActor"
-import "./usage-section.module.css"
+import { IconChevronLeft, IconChevronRight } from "~/component/icon"
+import styles from "./usage-section.module.css"
 import { createStore } from "solid-js/store"
 
 const PAGE_SIZE = 50
@@ -46,7 +47,7 @@ export function UsageSection() {
   }
 
   return (
-    <section>
+    <section class={styles.root}>
       <div data-slot="section-title">
         <h2>Usage History</h2>
         <p>Recent API usage and costs.</p>
@@ -92,10 +93,10 @@ export function UsageSection() {
           <Show when={canGoPrev() || canGoNext()}>
             <div data-slot="pagination">
               <button disabled={!canGoPrev()} onClick={goPrev}>
-                
+                <IconChevronLeft />
               </button>
               <button disabled={!canGoNext()} onClick={goNext}>
-                
+                <IconChevronRight />
               </button>
             </div>
           </Show>

+ 12 - 5
packages/console/app/src/routes/zen/util/handler.ts

@@ -15,6 +15,7 @@ import { logger } from "./logger"
 import { AuthError, CreditsError, MonthlyLimitError, UserLimitError, ModelError, RateLimitError } from "./error"
 import { createBodyConverter, createStreamPartConverter, createResponseConverter } 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"
@@ -30,6 +31,8 @@ export async function handler(
   opts: {
     format: ZenData.Format
     parseApiKey: (headers: Headers) => string | undefined
+    parseModel: (url: string, body: any) => string
+    parseIsStream: (url: string, body: any) => boolean
   },
 ) {
   type AuthInfo = Awaited<ReturnType<typeof authenticate>>
@@ -43,15 +46,18 @@ 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)
     logger.metric({
-      is_tream: !!body.stream,
+      is_tream: isStream,
       session: input.request.headers.get("x-opencode-session"),
       request: input.request.headers.get("x-opencode-request"),
     })
     const zenData = ZenData.list()
-    const modelInfo = validateModel(zenData, body.model)
+    const modelInfo = validateModel(zenData, model)
     const rateLimiter = createRateLimiter(modelInfo.id, modelInfo.rateLimit, ip)
     await rateLimiter?.check()
 
@@ -64,7 +70,7 @@ export async function handler(
       logger.metric({ provider: providerInfo.id })
 
       const startTimestamp = Date.now()
-      const reqUrl = providerInfo.modifyUrl(providerInfo.api)
+      const reqUrl = providerInfo.modifyUrl(providerInfo.api, providerInfo.model, isStream)
       const reqBody = JSON.stringify(
         providerInfo.modifyBody({
           ...createBodyConverter(opts.format, providerInfo.format)(body),
@@ -114,7 +120,7 @@ export async function handler(
     logger.debug("STATUS: " + res.status + " " + res.statusText)
 
     // Handle non-streaming response
-    if (!body.stream) {
+    if (!isStream) {
       const responseConverter = createResponseConverter(providerInfo.format, opts.format)
       const json = await res.json()
       const body = JSON.stringify(responseConverter(json))
@@ -169,7 +175,7 @@ export async function handler(
               responseLength += value.length
               buffer += decoder.decode(value, { stream: true })
 
-              const parts = buffer.split("\n\n")
+              const parts = buffer.split(providerInfo.streamSeparator)
               buffer = parts.pop() ?? ""
 
               for (let part of parts) {
@@ -283,6 +289,7 @@ export async function handler(
       ...(() => {
         const format = zenData.providers[provider.id].format
         if (format === "anthropic") return anthropicHelper
+        if (format === "google") return googleHelper
         if (format === "openai") return openaiHelper
         return oaCompatHelper
       })(),

+ 1 - 0
packages/console/app/src/routes/zen/util/provider/anthropic.ts

@@ -30,6 +30,7 @@ export const anthropicHelper = {
       service_tier: "standard_only",
     }
   },
+  streamSeparator: "\n\n",
   createUsageParser: () => {
     let usage: Usage
 

+ 74 - 0
packages/console/app/src/routes/zen/util/provider/google.ts

@@ -0,0 +1,74 @@
+import { ProviderHelper } from "./provider"
+
+/*
+{
+  promptTokenCount: 11453,
+  candidatesTokenCount: 71,
+  totalTokenCount: 11625,
+  cachedContentTokenCount: 8100,
+  promptTokensDetails: [
+    {modality: "TEXT",tokenCount: 11453}
+  ],
+  cacheTokensDetails: [
+    {modality: "TEXT",tokenCount: 8100}
+  ],
+  thoughtsTokenCount: 101
+}
+*/
+
+type Usage = {
+  promptTokenCount?: number
+  candidatesTokenCount?: number
+  totalTokenCount?: number
+  cachedContentTokenCount?: number
+  promptTokensDetails?: { modality: string; tokenCount: number }[]
+  cacheTokensDetails?: { modality: string; tokenCount: number }[]
+  thoughtsTokenCount?: number
+}
+
+export const googleHelper = {
+  format: "google",
+  modifyUrl: (providerApi: string, model?: string, isStream?: boolean) =>
+    `${providerApi}/models/${model}:${isStream ? "streamGenerateContent?alt=sse" : "generateContent"}`,
+  modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => {
+    headers.set("x-goog-api-key", apiKey)
+  },
+  modifyBody: (body: Record<string, any>) => {
+    return body
+  },
+  streamSeparator: "\r\n\r\n",
+  createUsageParser: () => {
+    let usage: Usage
+
+    return {
+      parse: (chunk: string) => {
+        if (!chunk.startsWith("data: ")) return
+
+        let json
+        try {
+          json = JSON.parse(chunk.slice(6)) as { usageMetadata?: Usage }
+        } catch (e) {
+          return
+        }
+
+        if (!json.usageMetadata) return
+        usage = json.usageMetadata
+      },
+      retrieve: () => usage,
+    }
+  },
+  normalizeUsage: (usage: Usage) => {
+    const inputTokens = usage.promptTokenCount ?? 0
+    const outputTokens = usage.candidatesTokenCount ?? 0
+    const reasoningTokens = usage.thoughtsTokenCount ?? 0
+    const cacheReadTokens = usage.cachedContentTokenCount ?? 0
+    return {
+      inputTokens: inputTokens - cacheReadTokens,
+      outputTokens,
+      reasoningTokens,
+      cacheReadTokens,
+      cacheWrite5mTokens: undefined,
+      cacheWrite1hTokens: undefined,
+    }
+  },
+} satisfies ProviderHelper

+ 1 - 0
packages/console/app/src/routes/zen/util/provider/openai-compatible.ts

@@ -33,6 +33,7 @@ export const oaCompatHelper = {
       ...(body.stream ? { stream_options: { include_usage: true } } : {}),
     }
   },
+  streamSeparator: "\n\n",
   createUsageParser: () => {
     let usage: Usage
 

+ 1 - 0
packages/console/app/src/routes/zen/util/provider/openai.ts

@@ -21,6 +21,7 @@ export const openaiHelper = {
   modifyBody: (body: Record<string, any>) => {
     return body
   },
+  streamSeparator: "\n\n",
   createUsageParser: () => {
     let usage: Usage
 

+ 2 - 1
packages/console/app/src/routes/zen/util/provider/provider.ts

@@ -26,9 +26,10 @@ import {
 
 export type ProviderHelper = {
   format: ZenData.Format
-  modifyUrl: (providerApi: string) => string
+  modifyUrl: (providerApi: string, model?: string, isStream?: boolean) => string
   modifyHeaders: (headers: Headers, body: Record<string, any>, apiKey: string) => void
   modifyBody: (body: Record<string, any>) => Record<string, any>
+  streamSeparator: string
   createUsageParser: () => {
     parse: (chunk: string) => void
     retrieve: () => any

+ 2 - 0
packages/console/app/src/routes/zen/v1/chat/completions.ts

@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
   return handler(input, {
     format: "oa-compat",
     parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
+    parseModel: (url: string, body: any) => body.model,
+    parseIsStream: (url: string, body: any) => !!body.stream,
   })
 }

+ 2 - 0
packages/console/app/src/routes/zen/v1/messages.ts

@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
   return handler(input, {
     format: "anthropic",
     parseApiKey: (headers: Headers) => headers.get("x-api-key") ?? undefined,
+    parseModel: (url: string, body: any) => body.model,
+    parseIsStream: (url: string, body: any) => !!body.stream,
   })
 }

+ 13 - 0
packages/console/app/src/routes/zen/v1/models/[model].ts

@@ -0,0 +1,13 @@
+import type { APIEvent } from "@solidjs/start/server"
+import { handler } from "~/routes/zen/util/handler"
+
+export function POST(input: APIEvent) {
+  return handler(input, {
+    format: "google",
+    parseApiKey: (headers: Headers) => headers.get("x-goog-api-key") ?? undefined,
+    parseModel: (url: string, body: any) => url.split("/").pop()?.split(":")?.[0] ?? "",
+    parseIsStream: (url: string, body: any) =>
+      // ie. url: https://opencode.ai/zen/v1/models/gemini-3-pro:streamGenerateContent?alt=sse'
+      url.split("/").pop()?.split(":")?.[1]?.startsWith("streamGenerateContent") ?? false,
+  })
+}

+ 2 - 0
packages/console/app/src/routes/zen/v1/responses.ts

@@ -5,5 +5,7 @@ export function POST(input: APIEvent) {
   return handler(input, {
     format: "openai",
     parseApiKey: (headers: Headers) => headers.get("authorization")?.split(" ")[1],
+    parseModel: (url: string, body: any) => body.model,
+    parseIsStream: (url: string, body: any) => !!body.stream,
   })
 }

+ 1 - 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.68",
+  "version": "1.0.78",
   "private": true,
   "type": "module",
   "dependencies": {

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

@@ -8,7 +8,7 @@ import { Actor } from "./actor"
 import { Resource } from "@opencode-ai/console-resource"
 
 export namespace ZenData {
-  const FormatSchema = z.enum(["anthropic", "openai", "oa-compat"])
+  const FormatSchema = z.enum(["anthropic", "google", "openai", "oa-compat"])
   export type Format = z.infer<typeof FormatSchema>
 
   const ModelCostSchema = z.object({

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

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

+ 2 - 1
packages/console/function/src/log-processor.ts

@@ -12,7 +12,8 @@ export default {
       if (
         url.pathname !== "/zen/v1/chat/completions" &&
         url.pathname !== "/zen/v1/messages" &&
-        url.pathname !== "/zen/v1/responses"
+        url.pathname !== "/zen/v1/responses" &&
+        !url.pathname.startsWith("/zen/v1/models/")
       )
         return
 

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

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

+ 1 - 1
packages/desktop/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/desktop",
-  "version": "1.0.68",
+  "version": "1.0.78",
   "description": "",
   "type": "module",
   "scripts": {

+ 1 - 1
packages/desktop/src/components/prompt-input.tsx

@@ -266,7 +266,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!existing) {
       const created = await sdk.client.session.create()
       existing = created.data ?? undefined
-      if (existing) navigate(`/session/${existing.id}`)
+      if (existing) navigate(existing.id)
     }
     if (!existing) return
 

+ 3 - 3
packages/desktop/src/components/session-review.tsx

@@ -1,4 +1,3 @@
-import { useLocal } from "@/context/local"
 import { useSession } from "@/context/session"
 import { FileIcon } from "@/ui"
 import { getDirectory, getFilename } from "@/utils"
@@ -6,9 +5,10 @@ import { Accordion, Button, Diff, DiffChanges, Icon, IconButton, Tooltip } from
 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 local = useLocal()
+  const layout = useLayout()
   const session = useSession()
   const [store, setStore] = createStore({
     open: session.diffs().map((d) => d.file),
@@ -51,7 +51,7 @@ export const SessionReview = (props: { split?: boolean; class?: string; hideExpa
                 icon="expand"
                 variant="ghost"
                 onClick={() => {
-                  local.layout.review.tab()
+                  layout.review.tab()
                   session.layout.setActiveTab("review")
                 }}
               />

+ 32 - 0
packages/desktop/src/context/global-sdk.tsx

@@ -0,0 +1,32 @@
+import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
+import { createSimpleContext } from "./helper"
+import { createGlobalEmitter } from "@solid-primitives/event-bus"
+import { onCleanup } from "solid-js"
+
+export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
+  name: "GlobalSDK",
+  init: (props: { url: string }) => {
+    const abort = new AbortController()
+    const sdk = createOpencodeClient({
+      baseUrl: props.url,
+      signal: abort.signal,
+    })
+
+    const emitter = createGlobalEmitter<{
+      [key: string]: Event
+    }>()
+
+    sdk.global.event().then(async (events) => {
+      for await (const event of events.stream) {
+        // console.log("event", event)
+        emitter.emit(event.directory, event.payload)
+      }
+    })
+
+    onCleanup(() => {
+      abort.abort()
+    })
+
+    return { url: props.url, client: sdk, event: emitter }
+  },
+})

+ 183 - 0
packages/desktop/src/context/global-sync.tsx

@@ -0,0 +1,183 @@
+import type {
+  Message,
+  Agent,
+  Provider,
+  Session,
+  Part,
+  Config,
+  Path,
+  File,
+  FileNode,
+  Project,
+  FileDiff,
+  Todo,
+  SessionStatus,
+} from "@opencode-ai/sdk"
+import { createStore, produce, reconcile } from "solid-js/store"
+import { Binary } from "@/utils/binary"
+import { createSimpleContext } from "./helper"
+import { useGlobalSDK } from "./global-sdk"
+
+type State = {
+  ready: boolean
+  provider: Provider[]
+  agent: Agent[]
+  project: Project
+  config: Config
+  path: Path
+  session: Session[]
+  session_status: {
+    [sessionID: string]: SessionStatus
+  }
+  session_diff: {
+    [sessionID: string]: FileDiff[]
+  }
+  todo: {
+    [sessionID: string]: Todo[]
+  }
+  limit: number
+  message: {
+    [sessionID: string]: Message[]
+  }
+  part: {
+    [messageID: string]: Part[]
+  }
+  node: FileNode[]
+  changes: File[]
+}
+
+export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimpleContext({
+  name: "GlobalSync",
+  init: () => {
+    const [globalStore, setGlobalStore] = createStore<{
+      ready: boolean
+      defaultProject?: Project // TODO: remove this when we can select projects
+      projects: Project[]
+      children: Record<string, State>
+    }>({
+      ready: false,
+      projects: [],
+      children: {},
+    })
+
+    const children: Record<string, ReturnType<typeof createStore<State>>> = {}
+
+    function child(directory: string) {
+      if (!children[directory]) {
+        setGlobalStore("children", directory, {
+          project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
+          config: {},
+          path: { state: "", config: "", worktree: "", directory: "" },
+          ready: false,
+          agent: [],
+          provider: [],
+          session: [],
+          session_status: {},
+          session_diff: {},
+          todo: {},
+          limit: 10,
+          message: {},
+          part: {},
+          node: [],
+          changes: [],
+        })
+        children[directory] = createStore(globalStore.children[directory])
+      }
+      return children[directory]
+    }
+
+    const sdk = useGlobalSDK()
+    sdk.event.listen((e) => {
+      const directory = e.name
+      const [store, setStore] = child(directory)
+
+      const event = e.details
+      switch (event.type) {
+        case "session.updated": {
+          const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
+          if (result.found) {
+            setStore("session", result.index, reconcile(event.properties.info))
+            break
+          }
+          setStore(
+            "session",
+            produce((draft) => {
+              draft.splice(result.index, 0, event.properties.info)
+            }),
+          )
+          break
+        }
+        case "session.diff":
+          setStore("session_diff", event.properties.sessionID, event.properties.diff)
+          break
+        case "todo.updated":
+          setStore("todo", event.properties.sessionID, event.properties.todos)
+          break
+        case "session.status": {
+          setStore("session_status", event.properties.sessionID, event.properties.status)
+          break
+        }
+        case "message.updated": {
+          const messages = store.message[event.properties.info.sessionID]
+          if (!messages) {
+            setStore("message", event.properties.info.sessionID, [event.properties.info])
+            break
+          }
+          const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
+          if (result.found) {
+            setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
+            break
+          }
+          setStore(
+            "message",
+            event.properties.info.sessionID,
+            produce((draft) => {
+              draft.splice(result.index, 0, event.properties.info)
+            }),
+          )
+          break
+        }
+        case "message.part.updated": {
+          const part = event.properties.part
+          const parts = store.part[part.messageID]
+          if (!parts) {
+            setStore("part", part.messageID, [part])
+            break
+          }
+          const result = Binary.search(parts, part.id, (p) => p.id)
+          if (result.found) {
+            setStore("part", part.messageID, result.index, reconcile(part))
+            break
+          }
+          setStore(
+            "part",
+            part.messageID,
+            produce((draft) => {
+              draft.splice(result.index, 0, part)
+            }),
+          )
+          break
+        }
+      }
+    })
+
+    Promise.all([
+      sdk.client.project.list().then((x) =>
+        setGlobalStore(
+          "projects",
+          x.data!.filter((x) => !x.worktree.includes("opencode-test")),
+        ),
+      ),
+      // TODO: remove this when we can select projects
+      sdk.client.project.current().then((x) => setGlobalStore("defaultProject", x.data)),
+    ]).then(() => setGlobalStore("ready", true))
+
+    return {
+      data: globalStore,
+      get ready() {
+        return globalStore.ready
+      },
+      child,
+    }
+  },
+})

+ 75 - 0
packages/desktop/src/context/layout.tsx

@@ -0,0 +1,75 @@
+import { createStore } from "solid-js/store"
+import { createMemo } from "solid-js"
+import { createSimpleContext } from "./helper"
+import { makePersisted } from "@solid-primitives/storage"
+import { useGlobalSync } from "./global-sync"
+
+export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
+  name: "Layout",
+  init: () => {
+    const globalSync = useGlobalSync()
+    const [store, setStore] = makePersisted(
+      createStore({
+        projects: [] as { directory: string; expanded: boolean }[],
+        sidebar: {
+          opened: true,
+          width: 280,
+        },
+        review: {
+          state: "pane" as "pane" | "tab",
+        },
+      }),
+      {
+        name: "___default-layout",
+      },
+    )
+
+    return {
+      projects: {
+        list: createMemo(() =>
+          globalSync.data.defaultProject
+            ? [{ directory: globalSync.data.defaultProject!.worktree, expanded: true }, ...store.projects]
+            : store.projects,
+        ),
+        open(directory: string) {
+          if (store.projects.find((x) => x.directory === directory)) return
+          setStore("projects", (x) => [...x, { directory, expanded: true }])
+        },
+        close(directory: string) {
+          setStore("projects", (x) => x.filter((x) => x.directory !== directory))
+        },
+        expand(directory: string) {
+          setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: true } : x)))
+        },
+        collapse(directory: string) {
+          setStore("projects", (x) => x.map((x) => (x.directory === directory ? { ...x, expanded: false } : x)))
+        },
+      },
+      sidebar: {
+        opened: createMemo(() => store.sidebar.opened),
+        open() {
+          setStore("sidebar", "opened", true)
+        },
+        close() {
+          setStore("sidebar", "opened", false)
+        },
+        toggle() {
+          setStore("sidebar", "opened", (x) => !x)
+        },
+        width: createMemo(() => store.sidebar.width),
+        resize(width: number) {
+          setStore("sidebar", "width", width)
+        },
+      },
+      review: {
+        state: createMemo(() => store.review?.state ?? "closed"),
+        pane() {
+          setStore("review", "state", "pane")
+        },
+        tab() {
+          setStore("review", "state", "tab")
+        },
+      },
+    }
+  },
+})

+ 2 - 47
packages/desktop/src/context/local.tsx

@@ -5,7 +5,7 @@ import type { FileContent, FileNode, Model, Provider, File as FileStatus } from
 import { createSimpleContext } from "./helper"
 import { useSDK } from "./sdk"
 import { useSync } from "./sync"
-import { makePersisted } from "@solid-primitives/storage"
+import { base64Encode } from "@/utils"
 
 export type LocalFile = FileNode &
   Partial<{
@@ -457,57 +457,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       }
     })()
 
-    const layout = (() => {
-      const [store, setStore] = makePersisted(
-        createStore({
-          sidebar: {
-            opened: true,
-            width: 240,
-          },
-          review: {
-            state: "pane" as "pane" | "tab",
-          },
-        }),
-        {
-          name: "_default-layout",
-        },
-      )
-
-      return {
-        sidebar: {
-          opened: createMemo(() => store.sidebar.opened),
-          open() {
-            setStore("sidebar", "opened", true)
-          },
-          close() {
-            setStore("sidebar", "opened", false)
-          },
-          toggle() {
-            setStore("sidebar", "opened", (x) => !x)
-          },
-          width: createMemo(() => store.sidebar.width),
-          resize(width: number) {
-            setStore("sidebar", "width", width)
-          },
-        },
-        review: {
-          state: createMemo(() => store.review?.state ?? "closed"),
-          pane() {
-            setStore("review", "state", "pane")
-          },
-          tab() {
-            setStore("review", "state", "tab")
-          },
-        },
-      }
-    })()
-
     const result = {
+      slug: createMemo(() => base64Encode(sdk.directory)),
       model,
       agent,
       file,
       context,
-      layout,
     }
     return result
   },

+ 8 - 8
packages/desktop/src/context/sdk.tsx

@@ -2,31 +2,31 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/client"
 import { createSimpleContext } from "./helper"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { onCleanup } from "solid-js"
+import { useGlobalSDK } from "./global-sdk"
 
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
   name: "SDK",
-  init: (props: { url: string }) => {
+  init: (props: { directory: string }) => {
+    const globalSDK = useGlobalSDK()
     const abort = new AbortController()
     const sdk = createOpencodeClient({
-      baseUrl: props.url,
+      baseUrl: globalSDK.url,
       signal: abort.signal,
+      directory: props.directory,
     })
 
     const emitter = createGlobalEmitter<{
       [key in Event["type"]]: Extract<Event, { type: key }>
     }>()
 
-    sdk.event.subscribe().then(async (events) => {
-      for await (const event of events.stream) {
-        console.log("event", event.type)
-        emitter.emit(event.type, event)
-      }
+    globalSDK.event.on(props.directory, async (event) => {
+      emitter.emit(event.type, event)
     })
 
     onCleanup(() => {
       abort.abort()
     })
 
-    return { client: sdk, event: emitter }
+    return { directory: props.directory, client: sdk, event: emitter }
   },
 })

+ 25 - 23
packages/desktop/src/context/session.tsx

@@ -3,15 +3,20 @@ import { createSimpleContext } from "./helper"
 import { batch, createEffect, createMemo } from "solid-js"
 import { useSync } from "./sync"
 import { makePersisted } from "@solid-primitives/storage"
-import { TextSelection, useLocal } from "./local"
+import { TextSelection } from "./local"
 import { pipe, sumBy } from "remeda"
 import { AssistantMessage } from "@opencode-ai/sdk"
+import { useParams } from "@solidjs/router"
+import { base64Encode } from "@/utils"
 
 export const { use: useSession, provider: SessionProvider } = createSimpleContext({
   name: "Session",
-  init: (props: { sessionId?: string }) => {
+  init: () => {
+    const params = useParams()
     const sync = useSync()
-    const local = useLocal()
+    const name = createMemo(
+      () => `___${base64Encode(sync.data.project.worktree)}/session${params.id ? "/" + params.id : ""}`,
+    )
 
     const [store, setStore] = makePersisted(
       createStore<{
@@ -30,17 +35,17 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
         cursor: undefined,
       }),
       {
-        name: props.sessionId ?? "new-session",
+        name: name(),
       },
     )
 
     createEffect(() => {
-      if (!props.sessionId) return
-      sync.session.sync(props.sessionId)
+      if (!params.id) return
+      sync.session.sync(params.id)
     })
 
-    const info = createMemo(() => (props.sessionId ? sync.session.get(props.sessionId) : undefined))
-    const messages = createMemo(() => (props.sessionId ? (sync.data.message[props.sessionId] ?? []) : []))
+    const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+    const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
     const userMessages = createMemo(() =>
       messages()
         .filter((m) => m.role === "user")
@@ -53,16 +58,13 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
       if (!store.messageId) return lastUserMessage()
       return userMessages()?.find((m) => m.id === store.messageId)
     })
-    const working = createMemo(() => {
-      if (!props.sessionId) return false
-      const last = lastUserMessage()
-      if (!last) return false
-      const assistantMessages = sync.data.message[props.sessionId]?.filter(
-        (m) => m.role === "assistant" && m.parentID == last?.id,
-      ) as AssistantMessage[]
-      const error = assistantMessages?.find((m) => m?.error)?.error
-      return !last?.summary?.body && !error
-    })
+    const status = createMemo(
+      () =>
+        sync.data.session_status[params.id] ?? {
+          type: "idle",
+        },
+    )
+    const working = createMemo(() => status()?.type !== "idle")
 
     const cost = createMemo(() => {
       const total = pipe(
@@ -81,7 +83,7 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
     const model = createMemo(() =>
       last() ? sync.data.provider.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
     )
-    const diffs = createMemo(() => (props.sessionId ? (sync.data.session_diff[props.sessionId] ?? []) : []))
+    const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
 
     const tokens = createMemo(() => {
       if (!last()) return
@@ -97,8 +99,11 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
     })
 
     return {
-      id: props.sessionId,
+      get id() {
+        return params.id
+      },
       info,
+      status,
       working,
       diffs,
       prompt: {
@@ -140,9 +145,6 @@ export const { use: useSession, provider: SessionProvider } = createSimpleContex
             setStore("tabs", "active", undefined)
             return
           }
-          if (tab.startsWith("file://")) {
-            await local.file.open(tab.replace("file://", ""))
-          }
           if (tab !== "review") {
             if (!store.tabs.opened.includes(tab)) {
               setStore("tabs", "opened", [...store.tabs.opened, tab])

+ 6 - 121
packages/desktop/src/context/sync.tsx

@@ -1,133 +1,17 @@
-import type {
-  Message,
-  Agent,
-  Provider,
-  Session,
-  Part,
-  Config,
-  Path,
-  File,
-  FileNode,
-  Project,
-  FileDiff,
-  Todo,
-} from "@opencode-ai/sdk"
-import { createStore, produce, reconcile } from "solid-js/store"
+import type { Part } from "@opencode-ai/sdk"
+import { produce } from "solid-js/store"
 import { createMemo } from "solid-js"
 import { Binary } from "@/utils/binary"
 import { createSimpleContext } from "./helper"
+import { useGlobalSync } from "./global-sync"
 import { useSDK } from "./sdk"
 
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
   init: () => {
-    const [store, setStore] = createStore<{
-      ready: boolean
-      provider: Provider[]
-      agent: Agent[]
-      project: Project
-      config: Config
-      path: Path
-      session: Session[]
-      session_diff: {
-        [sessionID: string]: FileDiff[]
-      }
-      todo: {
-        [sessionID: string]: Todo[]
-      }
-      limit: number
-      message: {
-        [sessionID: string]: Message[]
-      }
-      part: {
-        [messageID: string]: Part[]
-      }
-      node: FileNode[]
-      changes: File[]
-    }>({
-      project: { id: "", worktree: "", time: { created: 0, initialized: 0 } },
-      config: {},
-      path: { state: "", config: "", worktree: "", directory: "" },
-      ready: false,
-      agent: [],
-      provider: [],
-      session: [],
-      session_diff: {},
-      todo: {},
-      limit: 10,
-      message: {},
-      part: {},
-      node: [],
-      changes: [],
-    })
-
+    const globalSync = useGlobalSync()
     const sdk = useSDK()
-    sdk.event.listen((e) => {
-      const event = e.details
-      switch (event.type) {
-        case "session.updated": {
-          const result = Binary.search(store.session, event.properties.info.id, (s) => s.id)
-          if (result.found) {
-            setStore("session", result.index, reconcile(event.properties.info))
-            break
-          }
-          setStore(
-            "session",
-            produce((draft) => {
-              draft.splice(result.index, 0, event.properties.info)
-            }),
-          )
-          break
-        }
-        case "session.diff":
-          setStore("session_diff", event.properties.sessionID, event.properties.diff)
-          break
-        case "todo.updated":
-          setStore("todo", event.properties.sessionID, event.properties.todos)
-          break
-        case "message.updated": {
-          const messages = store.message[event.properties.info.sessionID]
-          if (!messages) {
-            setStore("message", event.properties.info.sessionID, [event.properties.info])
-            break
-          }
-          const result = Binary.search(messages, event.properties.info.id, (m) => m.id)
-          if (result.found) {
-            setStore("message", event.properties.info.sessionID, result.index, reconcile(event.properties.info))
-            break
-          }
-          setStore(
-            "message",
-            event.properties.info.sessionID,
-            produce((draft) => {
-              draft.splice(result.index, 0, event.properties.info)
-            }),
-          )
-          break
-        }
-        case "message.part.updated": {
-          const part = sanitizePart(event.properties.part)
-          const parts = store.part[part.messageID]
-          if (!parts) {
-            setStore("part", part.messageID, [part])
-            break
-          }
-          const result = Binary.search(parts, part.id, (p) => p.id)
-          if (result.found) {
-            setStore("part", part.messageID, result.index, reconcile(part))
-            break
-          }
-          setStore(
-            "part",
-            part.messageID,
-            produce((draft) => {
-              draft.splice(result.index, 0, part)
-            }),
-          )
-          break
-        }
-      }
-    })
+    const [store, setStore] = globalSync.child(sdk.directory)
 
     const load = {
       project: () => sdk.client.project.current().then((x) => setStore("project", x.data!)),
@@ -142,6 +26,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             .slice(0, store.limit)
           setStore("session", sessions)
         }),
+      status: () => sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
       config: () => sdk.client.config.get().then((x) => setStore("config", x.data!)),
       changes: () => sdk.client.file.status().then((x) => setStore("changes", x.data!)),
       node: () => sdk.client.file.list({ query: { path: "/" } }).then((x) => setStore("node", x.data!)),

+ 6 - 0
packages/desktop/src/index.css

@@ -1 +1,7 @@
 @import "@opencode-ai/ui/styles/tailwind";
+
+:root {
+  a {
+    cursor: default;
+  }
+}

+ 34 - 13
packages/desktop/src/index.tsx

@@ -1,15 +1,18 @@
 /* @refresh reload */
 import "@/index.css"
 import { render } from "solid-js/web"
-import { Router, Route } from "@solidjs/router"
+import { Router, Route, Navigate } from "@solidjs/router"
 import { MetaProvider } from "@solidjs/meta"
 import { Fonts, MarkedProvider } from "@opencode-ai/ui"
-import { SDKProvider } from "./context/sdk"
-import { SyncProvider } from "./context/sync"
-import { LocalProvider } from "./context/local"
+import { GlobalSyncProvider, useGlobalSync } from "./context/global-sync"
 import Layout from "@/pages/layout"
-import SessionLayout from "@/pages/session-layout"
+import DirectoryLayout from "@/pages/directory-layout"
 import Session from "@/pages/session"
+import { LayoutProvider } from "./context/layout"
+import { GlobalSDKProvider } from "./context/global-sdk"
+import { SessionProvider } from "./context/session"
+import { base64Encode } from "./utils"
+import { createMemo, Show } from "solid-js"
 
 const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
 const port = import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
@@ -30,20 +33,38 @@ if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
 render(
   () => (
     <MarkedProvider>
-      <SDKProvider url={url}>
-        <SyncProvider>
-          <LocalProvider>
+      <GlobalSDKProvider url={url}>
+        <GlobalSyncProvider>
+          <LayoutProvider>
             <MetaProvider>
               <Fonts />
               <Router root={Layout}>
-                <Route path={["/", "/session"]} component={SessionLayout}>
-                  <Route path="/:id?" component={Session} />
+                <Route
+                  path="/"
+                  component={() => {
+                    const globalSync = useGlobalSync()
+                    const slug = createMemo(() => base64Encode(globalSync.data.defaultProject!.worktree))
+                    return <Navigate href={`${slug()}/session`} />
+                  }}
+                />
+                <Route path="/:dir" component={DirectoryLayout}>
+                  <Route path="/" component={() => <Navigate href="session" />} />
+                  <Route
+                    path="/session/:id?"
+                    component={(p) => (
+                      <Show when={p.params.id || true} keyed>
+                        <SessionProvider>
+                          <Session />
+                        </SessionProvider>
+                      </Show>
+                    )}
+                  />
                 </Route>
               </Router>
             </MetaProvider>
-          </LocalProvider>
-        </SyncProvider>
-      </SDKProvider>
+          </LayoutProvider>
+        </GlobalSyncProvider>
+      </GlobalSDKProvider>
     </MarkedProvider>
   ),
   root!,

+ 23 - 0
packages/desktop/src/pages/directory-layout.tsx

@@ -0,0 +1,23 @@
+import { createMemo, type ParentProps } from "solid-js"
+import { useParams } from "@solidjs/router"
+import { SDKProvider } from "@/context/sdk"
+import { SyncProvider } from "@/context/sync"
+import { LocalProvider } from "@/context/local"
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Decode } from "@/utils"
+
+export default function Layout(props: ParentProps) {
+  const params = useParams()
+  const sync = useGlobalSync()
+  const directory = createMemo(() => {
+    const decoded = base64Decode(params.dir)
+    return sync.data.projects.find((x) => x.worktree === decoded)?.worktree ?? "/"
+  })
+  return (
+    <SDKProvider directory={directory()}>
+      <SyncProvider>
+        <LocalProvider>{props.children}</LocalProvider>
+      </SyncProvider>
+    </SDKProvider>
+  )
+}

+ 20 - 0
packages/desktop/src/pages/home.tsx

@@ -0,0 +1,20 @@
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Encode, getFilename } from "@/utils"
+import { For } from "solid-js"
+import { A } from "@solidjs/router"
+import { Button } from "@opencode-ai/ui"
+
+export default function Home() {
+  const sync = useGlobalSync()
+  return (
+    <div class="flex flex-col gap-3">
+      <For each={sync.data.projects}>
+        {(project) => (
+          <Button as={A} href={base64Encode(project.worktree)}>
+            {getFilename(project.worktree)}
+          </Button>
+        )}
+      </For>
+    </div>
+  )
+}

+ 163 - 87
packages/desktop/src/pages/layout.tsx

@@ -1,116 +1,192 @@
-import { Button, Tooltip, DiffChanges, IconButton } from "@opencode-ai/ui"
+import { Button, Tooltip, DiffChanges, IconButton, Mark, Icon, Collapsible } from "@opencode-ai/ui"
 import { createMemo, For, ParentProps, Show } from "solid-js"
 import { DateTime } from "luxon"
-import { useSync } from "@/context/sync"
 import { A, useParams } from "@solidjs/router"
-import { useLocal } from "@/context/local"
+import { useLayout } from "@/context/layout"
+import { useGlobalSync } from "@/context/global-sync"
+import { base64Encode, getFilename } from "@/utils"
 
 export default function Layout(props: ParentProps) {
   const params = useParams()
-  const sync = useSync()
-  const local = useLocal()
+  const globalSync = useGlobalSync()
+  const layout = useLayout()
+
+  const handleOpenProject = async () => {
+    // layout.projects.open(dir.)
+  }
 
   return (
     <div class="relative h-screen flex flex-col">
-      <header class="hidden h-12 shrink-0 bg-background-strong border-b border-border-weak-base"></header>
-      <div class="h-[calc(100vh-0rem)] flex">
+      <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base">
+        <A
+          href="/"
+          classList={{
+            "w-12 shrink-0 px-4 py-3.5": true,
+            "flex items-center justify-start self-stretch": true,
+            "border-r border-border-weak-base": true,
+          }}
+          style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
+        >
+          <Mark class="shrink-0" />
+        </A>
+      </header>
+      <div class="h-[calc(100vh-3rem)] flex">
         <div
           classList={{
-            "@container w-14 pb-4 shrink-0 bg-background-weak": true,
-            "flex flex-col items-start self-stretch justify-between": true,
+            "@container w-12 pb-5 shrink-0 bg-background-base": true,
+            "flex flex-col gap-5.5 items-start self-stretch justify-between": true,
             "border-r border-border-weak-base": true,
-            "w-70": local.layout.sidebar.opened(),
           }}
+          style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
         >
-          <div class="flex flex-col justify-center items-start gap-4 self-stretch py-2 overflow-hidden mx-auto @[4rem]:mx-0">
-            <div class="h-8 shrink-0 flex items-center self-stretch px-3">
-              <Tooltip placement="right" value="Collapse sidebar">
-                <IconButton icon="layout-left" variant="ghost" size="large" onClick={local.layout.sidebar.toggle} />
-              </Tooltip>
-            </div>
-            <div class="w-full px-3">
-              <Button as={A} href="/session" class="hidden @[4rem]:flex w-full" size="large" icon="edit-small-2">
-                New Session
+          <div class="grow flex flex-col items-start self-stretch gap-4 p-2 min-h-0">
+            <Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
+              <Button
+                variant="ghost"
+                size="large"
+                class="group/sidebar-toggle shrink-0 w-full text-left justify-start"
+                onClick={layout.sidebar.toggle}
+              >
+                <div class="relative -ml-px flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                  <Icon
+                    name={layout.sidebar.opened() ? "layout-left" : "layout-right"}
+                    size="small"
+                    class="group-hover/sidebar-toggle:hidden"
+                  />
+                  <Icon
+                    name={layout.sidebar.opened() ? "layout-left-partial" : "layout-right-partial"}
+                    size="small"
+                    class="hidden group-hover/sidebar-toggle:inline-block"
+                  />
+                  <Icon
+                    name={layout.sidebar.opened() ? "layout-left-full" : "layout-right-full"}
+                    size="small"
+                    class="hidden group-active/sidebar-toggle:inline-block"
+                  />
+                </div>
+                <Show when={layout.sidebar.opened()}>
+                  <div class="hidden group-hover/sidebar-toggle:block group-active/sidebar-toggle:block text-text-base">
+                    Toggle sidebar
+                  </div>
+                </Show>
               </Button>
-              <Tooltip placement="right" value="New session">
-                <IconButton as={A} href="/session" icon="edit-small-2" size="large" class="@[4rem]:hidden" />
-              </Tooltip>
-            </div>
-            <div class="hidden @[4rem]:flex size-full overflow-y-auto no-scrollbar flex-col flex-1 px-3">
-              <nav class="w-full">
-                <For each={sync.data.session}>
-                  {(session) => {
-                    const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
+            </Tooltip>
+            <div class="flex flex-col justify-center items-start gap-4 self-stretch min-h-0">
+              <div class="hidden @[4rem]:flex size-full flex-col grow overflow-y-auto no-scrollbar">
+                <For each={layout.projects.list()}>
+                  {(project) => {
+                    const [store] = globalSync.child(project.directory)
+                    const slug = createMemo(() => base64Encode(project.directory))
                     return (
-                      <A
-                        data-active={session.id === params.id}
-                        href={`/session/${session.id}`}
-                        class="group/session focus:outline-none cursor-default"
-                      >
-                        <Tooltip placement="right" value={session.title}>
-                          <div
-                            class="w-full mb-1.5 px-3 py-1 rounded-md 
-                               group-data-[active=true]/session:bg-surface-raised-base-hover
-                               group-hover/session:bg-surface-raised-base-hover
-                               group-focus/session:bg-surface-raised-base-hover"
-                          >
-                            <div class="flex items-center self-stretch gap-6 justify-between">
-                              <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
-                                {session.title}
-                              </span>
-                              <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
-                                {Math.abs(updated().diffNow().as("seconds")) < 60
-                                  ? "Now"
-                                  : updated()
-                                      .toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
-                                      ?.replace(" ago", "")
-                                      ?.replace(/ days?/, "d")
-                                      ?.replace(" min.", "m")
-                                      ?.replace(" hr.", "h")}
-                              </span>
-                            </div>
-                            <div class="flex justify-between items-center self-stretch">
-                              <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
-                              <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
-                            </div>
-                          </div>
-                        </Tooltip>
-                      </A>
+                      <Collapsible variant="ghost" defaultOpen class="gap-2">
+                        <Button
+                          as={"div"}
+                          variant="ghost"
+                          class="flex items-center justify-between gap-3 w-full h-8 pl-2 pr-2.25 self-stretch"
+                        >
+                          <Collapsible.Trigger class="p-0 text-left text-14-medium text-text-strong grow min-w-0 truncate">
+                            {getFilename(project.directory)}
+                          </Collapsible.Trigger>
+                          <IconButton as={A} href={`${slug()}/session`} icon="plus-small" size="normal" />
+                        </Button>
+                        <Collapsible.Content>
+                          <nav class="w-full flex flex-col gap-1.5">
+                            <For each={store.session}>
+                              {(session) => {
+                                const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
+                                return (
+                                  <A
+                                    data-active={session.id === params.id}
+                                    href={`${slug()}/session/${session.id}`}
+                                    class="group/session focus:outline-none cursor-default"
+                                  >
+                                    <Tooltip placement="right" value={session.title}>
+                                      <div
+                                        class="w-full px-2 py-1 rounded-md 
+                                               group-data-[active=true]/session:bg-surface-raised-base-hover
+                                               group-hover/session:bg-surface-raised-base-hover
+                                               group-focus/session:bg-surface-raised-base-hover"
+                                      >
+                                        <div class="flex items-center self-stretch gap-6 justify-between">
+                                          <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
+                                            {session.title}
+                                          </span>
+                                          <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
+                                            {Math.abs(updated().diffNow().as("seconds")) < 60
+                                              ? "Now"
+                                              : updated()
+                                                  .toRelative({ style: "short", unit: ["days", "hours", "minutes"] })
+                                                  ?.replace(" ago", "")
+                                                  ?.replace(/ days?/, "d")
+                                                  ?.replace(" min.", "m")
+                                                  ?.replace(" hr.", "h")}
+                                          </span>
+                                        </div>
+                                        <div class="hidden _flex justify-between items-center self-stretch">
+                                          <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
+                                          <Show when={session.summary}>
+                                            {(summary) => <DiffChanges changes={summary()} />}
+                                          </Show>
+                                        </div>
+                                      </div>
+                                    </Tooltip>
+                                  </A>
+                                )
+                              }}
+                            </For>
+                          </nav>
+                          {/* <Show when={sync.session.more()}> */}
+                          {/*   <button */}
+                          {/*     class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong" */}
+                          {/*     onClick={() => sync.session.fetch()} */}
+                          {/*   > */}
+                          {/*     Show more */}
+                          {/*   </button> */}
+                          {/* </Show> */}
+                        </Collapsible.Content>
+                      </Collapsible>
                     )
                   }}
                 </For>
-              </nav>
-              <Show when={sync.session.more()}>
-                <button
-                  class="shrink-0 self-start p-3 text-12-medium text-text-weak hover:text-text-strong"
-                  onClick={() => sync.session.fetch()}
-                >
-                  Show more
-                </button>
-              </Show>
+              </div>
             </div>
           </div>
-          <div class="flex flex-col items-start shrink-0 px-3 py-1 mx-auto @[4rem]:mx-0">
-            <Button
-              as={"a"}
-              href="https://opencode.ai/desktop-feedback"
-              target="_blank"
-              class="hidden @[4rem]:flex w-full text-12-medium text-text-base stroke-[1.5px]"
-              variant="ghost"
-              icon="speech-bubble"
-            >
-              Share feedback
-            </Button>
-            <Tooltip placement="right" value="Share feedback">
-              <IconButton
+          <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
+            <Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
+              <Button
+                disabled
+                class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
+                variant="ghost"
+                size="large"
+                icon="folder-add-left"
+                onClick={handleOpenProject}
+              >
+                <Show when={layout.sidebar.opened()}>Open project</Show>
+              </Button>
+            </Tooltip>
+            <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}>
+              <Button
+                disabled
+                class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
+                variant="ghost"
+                size="large"
+                icon="settings-gear"
+              >
+                <Show when={layout.sidebar.opened()}>Settings</Show>
+              </Button>
+            </Tooltip>
+            <Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
+              <Button
                 as={"a"}
                 href="https://opencode.ai/desktop-feedback"
                 target="_blank"
-                icon="speech-bubble"
+                class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px]"
                 variant="ghost"
                 size="large"
-                class="@[4rem]:hidden stroke-[1.5px]"
-              />
+                icon="bubble-5"
+              >
+                <Show when={layout.sidebar.opened()}>Share feedback</Show>
+              </Button>
             </Tooltip>
           </div>
         </div>

+ 0 - 12
packages/desktop/src/pages/session-layout.tsx

@@ -1,12 +0,0 @@
-import { Show, type ParentProps } from "solid-js"
-import { SessionProvider } from "@/context/session"
-import { useParams } from "@solidjs/router"
-
-export default function Layout(props: ParentProps) {
-  const params = useParams()
-  return (
-    <Show when={params.id || true} keyed>
-      <SessionProvider sessionId={params.id}>{props.children}</SessionProvider>
-    </Show>
-  )
-}

+ 113 - 97
packages/desktop/src/pages/session.tsx

@@ -13,7 +13,6 @@ import {
   Code,
   Tooltip,
   ProgressCircle,
-  Button,
 } from "@opencode-ai/ui"
 import { FileIcon } from "@/ui"
 import { MessageProgress } from "@/components/message-progress"
@@ -52,8 +51,10 @@ import { Spinner } from "@/components/spinner"
 import { useSession } from "@/context/session"
 import { StickyAccordionHeader } from "@/components/sticky-accordion-header"
 import { SessionReview } from "@/components/session-review"
+import { useLayout } from "@/context/layout"
 
 export default function Page() {
+  const layout = useLayout()
   const local = useLocal()
   const sync = useSync()
   const session = useSession()
@@ -176,10 +177,16 @@ export default function Page() {
     setStore("activeDraggable", undefined)
   }
 
-  const FileVisual = (props: { file: LocalFile }): JSX.Element => {
+  const FileVisual = (props: { file: LocalFile; active?: boolean }): JSX.Element => {
     return (
       <div class="flex items-center gap-x-1.5">
-        <FileIcon node={props.file} class="grayscale-100 group-data-[selected]/tab:grayscale-0" />
+        <FileIcon
+          node={props.file}
+          classList={{
+            "grayscale-100 group-data-[selected]/tab:grayscale-0": !props.active,
+            "grayscale-0": props.active,
+          }}
+        />
         <span
           classList={{
             "text-14-medium": true,
@@ -287,7 +294,7 @@ export default function Page() {
             <Tabs.List>
               <Tabs.Trigger value="chat">
                 <div class="flex gap-x-[17px] items-center">
-                  <div>Chat</div>
+                  <div>Session</div>
                   <Tooltip
                     value={`${new Intl.NumberFormat("en-US", {
                       notation: "compact",
@@ -300,11 +307,11 @@ export default function Page() {
                   </Tooltip>
                 </div>
               </Tabs.Trigger>
-              <Show when={local.layout.review.state() === "tab" && session.diffs().length}>
+              <Show when={layout.review.state() === "tab" && session.diffs().length}>
                 <Tabs.Trigger
                   value="review"
                   closeButton={
-                    <IconButton icon="collapse" size="normal" variant="ghost" onClick={local.layout.review.pane} />
+                    <IconButton icon="collapse" size="normal" variant="ghost" onClick={layout.review.pane} />
                   }
                 >
                   <div class="flex items-center gap-3">
@@ -343,8 +350,8 @@ export default function Page() {
             <div
               classList={{
                 "w-full flex-1 min-h-0": true,
-                grid: local.layout.review.state() === "tab",
-                flex: local.layout.review.state() === "pane",
+                grid: layout.review.state() === "tab",
+                flex: layout.review.state() === "pane",
               }}
             >
               <div class="relative shrink-0 px-6 py-3 flex flex-col gap-6 flex-1 min-h-0 w-full max-w-xl mx-auto">
@@ -353,92 +360,92 @@ export default function Page() {
                     <div
                       classList={{
                         "flex-1 min-h-0 pb-20": true,
-                        "flex items-start justify-start": local.layout.review.state() === "pane",
+                        "flex items-start justify-start": layout.review.state() === "pane",
                       }}
                     >
                       <Show when={session.messages.user().length > 1}>
-                        <ul
-                          role="list"
-                          classList={{
-                            "mr-8 shrink-0 flex flex-col items-start": true,
-                            "absolute right-full w-60 mt-3 @7xl:gap-2 @7xl:mt-1": local.layout.review.state() === "tab",
-                            "mt-3": local.layout.review.state() === "pane",
-                          }}
-                        >
-                          <For each={session.messages.user()}>
-                            {(message) => {
-                              const assistantMessages = createMemo(() => {
-                                if (!session.id) return []
-                                return sync.data.message[session.id]?.filter(
-                                  (m) => m.role === "assistant" && m.parentID == message.id,
-                                ) as AssistantMessageType[]
-                              })
-                              const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
-                              const working = createMemo(() => !message.summary?.body && !error())
+                        {(_) => {
+                          const expanded = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
 
-                              const handleClick = () => session.messages.setActive(message.id)
+                          return (
+                            <ul
+                              role="list"
+                              classList={{
+                                "mr-8 shrink-0 flex flex-col items-start": true,
+                                "absolute right-full w-60 mt-3 @7xl:gap-2 @7xl:mt-1": expanded(),
+                                "mt-3": !expanded(),
+                              }}
+                            >
+                              <For each={session.messages.user()}>
+                                {(message) => {
+                                  const working = createMemo(
+                                    () => message.id === session.messages.last()?.id && session.working(),
+                                  )
+                                  const handleClick = () => session.messages.setActive(message.id)
 
-                              return (
-                                <li
-                                  classList={{
-                                    "group/li flex items-center self-stretch justify-end": true,
-                                    "@7xl:justify-start": local.layout.review.state() === "tab",
-                                  }}
-                                >
-                                  <Tooltip
-                                    placement="right"
-                                    gutter={8}
-                                    value={
-                                      <div class="flex items-center gap-2">
-                                        <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
-                                        {message.summary?.title}
-                                      </div>
-                                    }
-                                  >
-                                    <button
-                                      data-active={session.messages.active()?.id === message.id}
-                                      onClick={handleClick}
+                                  return (
+                                    <li
                                       classList={{
-                                        "group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
-                                        "data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
-                                        "@7xl:hidden": local.layout.review.state() === "tab",
+                                        "group/li flex items-center self-stretch justify-end": true,
+                                        "@7xl:justify-start": expanded(),
                                       }}
                                     >
-                                      <div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
-                                    </button>
-                                  </Tooltip>
-                                  <button
-                                    classList={{
-                                      "hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
-                                      "@7xl:flex": local.layout.review.state() === "tab",
-                                    }}
-                                    onClick={handleClick}
-                                  >
-                                    <Switch>
-                                      <Match when={working()}>
-                                        <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
-                                      </Match>
-                                      <Match when={true}>
-                                        <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
-                                      </Match>
-                                    </Switch>
-                                    <div
-                                      data-active={session.messages.active()?.id === message.id}
-                                      classList={{
-                                        "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
-                                        "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
-                                      }}
-                                    >
-                                      <Show when={message.summary?.title} fallback="New message">
-                                        {message.summary?.title}
-                                      </Show>
-                                    </div>
-                                  </button>
-                                </li>
-                              )
-                            }}
-                          </For>
-                        </ul>
+                                      <Tooltip
+                                        placement="right"
+                                        gutter={8}
+                                        value={
+                                          <div class="flex items-center gap-2">
+                                            <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
+                                            {message.summary?.title}
+                                          </div>
+                                        }
+                                      >
+                                        <button
+                                          data-active={session.messages.active()?.id === message.id}
+                                          onClick={handleClick}
+                                          classList={{
+                                            "group/tick flex items-center justify-start h-2 w-8 -mr-3": true,
+                                            "data-[active=true]:[&>div]:bg-icon-strong-base data-[active=true]:[&>div]:w-full": true,
+                                            "@7xl:hidden": expanded(),
+                                          }}
+                                        >
+                                          <div class="h-px w-5 bg-icon-base group-hover/tick:w-full group-hover/tick:bg-icon-strong-base" />
+                                        </button>
+                                      </Tooltip>
+                                      <button
+                                        classList={{
+                                          "hidden items-center self-stretch w-full gap-x-2 cursor-default": true,
+                                          "@7xl:flex": expanded(),
+                                        }}
+                                        onClick={handleClick}
+                                      >
+                                        <Switch>
+                                          <Match when={working()}>
+                                            <Spinner class="text-text-base shrink-0 w-[18px] aspect-square" />
+                                          </Match>
+                                          <Match when={true}>
+                                            <DiffChanges changes={message.summary?.diffs ?? []} variant="bars" />
+                                          </Match>
+                                        </Switch>
+                                        <div
+                                          data-active={session.messages.active()?.id === message.id}
+                                          classList={{
+                                            "text-14-regular text-text-weak whitespace-nowrap truncate min-w-0": true,
+                                            "text-text-weak data-[active=true]:text-text-strong group-hover/li:text-text-base": true,
+                                          }}
+                                        >
+                                          <Show when={message.summary?.title} fallback="New message">
+                                            {message.summary?.title}
+                                          </Show>
+                                        </div>
+                                      </button>
+                                    </li>
+                                  )
+                                }}
+                              </For>
+                            </ul>
+                          )
+                        }}
                       </Show>
                       <div ref={messageScrollElement} class="grow size-full min-w-0 overflow-y-auto no-scrollbar">
                         <For each={session.messages.user()}>
@@ -452,7 +459,6 @@ export default function Page() {
                               ) as AssistantMessageType[]
                             })
                             const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
-                            const [completed, setCompleted] = createSignal(!!message.summary?.body || !!error())
                             const [detailsExpanded, setDetailsExpanded] = createSignal(false)
                             const parts = createMemo(() => sync.data.part[message.id])
                             const hasToolPart = createMemo(() =>
@@ -460,7 +466,11 @@ export default function Page() {
                                 ?.flatMap((m) => sync.data.part[m.id])
                                 .some((p) => p?.type === "tool"),
                             )
-                            const working = createMemo(() => !message.summary?.body && !error())
+                            const working = createMemo(
+                              () => message.id === session.messages.last()?.id && session.working(),
+                            )
+                            const initialCompleted = !(message.id === session.messages.last()?.id && session.working())
+                            const [completed, setCompleted] = createSignal(initialCompleted)
 
                             // allowing time for the animations to finish
                             createEffect(() => {
@@ -468,9 +478,8 @@ export default function Page() {
                               setTimeout(() => setTitled(!!title), 10_000)
                             })
                             createEffect(() => {
-                              const summary = message.summary?.body
-                              const complete = !!summary || !!error()
-                              setTimeout(() => setCompleted(complete), 1200)
+                              const completed = !working()
+                              setTimeout(() => setCompleted(completed), 1200)
                             })
 
                             return (
@@ -514,7 +523,8 @@ export default function Page() {
                                             <Markdown
                                               classList={{
                                                 "text-14-regular": !!message.summary?.diffs?.length,
-                                                "[&>*]:fade-up-text": !message.summary?.diffs?.length,
+                                                "[&>*]:fade-up-text":
+                                                  !message.summary?.diffs?.length && !initialCompleted,
                                               }}
                                               text={summary()}
                                             />
@@ -654,7 +664,7 @@ export default function Page() {
                   />
                 </div>
               </div>
-              <Show when={local.layout.review.state() === "pane" && session.diffs().length}>
+              <Show when={layout.review.state() === "pane" && session.diffs().length}>
                 <div
                   classList={{
                     "relative grow px-6 py-3 flex-1 min-h-0 border-l border-border-weak-base": true,
@@ -665,7 +675,7 @@ export default function Page() {
               </Show>
             </div>
           </Tabs.Content>
-          <Show when={local.layout.review.state() === "tab" && session.diffs().length}>
+          <Show when={layout.review.state() === "tab" && session.diffs().length}>
             <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
               <div
                 classList={{
@@ -718,8 +728,8 @@ export default function Page() {
                 },
               )
               return (
-                <div class="relative px-3 h-10 flex items-center bg-background-base border-x border-border-weak-base border-b border-b-transparent">
-                  <Show when={file()}>{(f) => <FileVisual file={f()} />}</Show>
+                <div class="relative px-6 h-12 flex items-center bg-background-stronger border-x border-border-weak-base border-b border-b-transparent">
+                  <Show when={file()}>{(f) => <FileVisual active file={f()} />}</Show>
                 </div>
               )
             }}
@@ -769,7 +779,13 @@ export default function Page() {
           items={local.file.searchFiles}
           key={(x) => x}
           onOpenChange={(open) => setStore("fileSelectOpen", open)}
-          onSelect={(x) => (x ? session.layout.openTab("file://" + x) : undefined)}
+          onSelect={(x) => {
+            if (x) {
+              local.file.open(x)
+              return session.layout.openTab("file://" + x)
+            }
+            return undefined
+          }}
         >
           {(i) => (
             <div

+ 2 - 1
packages/desktop/src/ui/file-icon.tsx

@@ -9,12 +9,13 @@ export type FileIconProps = JSX.GSVGAttributes<SVGSVGElement> & {
 }
 
 export const FileIcon: Component<FileIconProps> = (props) => {
-  const [local, rest] = splitProps(props, ["node", "class", "expanded"])
+  const [local, rest] = splitProps(props, ["node", "class", "classList", "expanded"])
   const name = createMemo(() => chooseIconName(local.node.path, local.node.type, local.expanded || false))
   return (
     <svg
       {...rest}
       classList={{
+        ...(local.classList ?? {}),
         "shrink-0 size-4": true,
         [local.class ?? ""]: !!local.class,
       }}

+ 7 - 0
packages/desktop/src/utils/encode.ts

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

+ 1 - 0
packages/desktop/src/utils/index.ts

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

+ 6 - 6
packages/extensions/zed/extension.toml

@@ -1,7 +1,7 @@
 id = "opencode"
 name = "OpenCode"
 description = "The AI coding agent built for the terminal"
-version = "1.0.68"
+version = "1.0.78"
 schema_version = 1
 authors = ["Anomaly"]
 repository = "https://github.com/sst/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
 icon = "./icons/opencode.svg"
 
 [agent_servers.opencode.targets.darwin-aarch64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.68/opencode-darwin-arm64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.78/opencode-darwin-arm64.zip"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.darwin-x86_64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.68/opencode-darwin-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.78/opencode-darwin-x64.zip"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.68/opencode-linux-arm64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.78/opencode-linux-arm64.zip"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.linux-x86_64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.68/opencode-linux-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.78/opencode-linux-x64.zip"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.windows-x86_64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.68/opencode-windows-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.78/opencode-windows-x64.zip"
 cmd = "./opencode.exe"
 args = ["acp"]

+ 1 - 1
packages/function/package.json

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

+ 8 - 6
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.0.68",
+  "version": "1.0.78",
   "name": "opencode",
   "type": "module",
   "private": true,
@@ -22,7 +22,9 @@
     "@ai-sdk/amazon-bedrock": "2.2.10",
     "@ai-sdk/google-vertex": "3.0.16",
     "@babel/core": "7.28.4",
+    "@babel/preset-typescript": "7.24.7",
     "@octokit/webhooks-types": "7.6.1",
+    "@opencode-ai/script": "workspace:*",
     "@parcel/watcher-darwin-arm64": "2.5.1",
     "@parcel/watcher-darwin-x64": "2.5.1",
     "@parcel/watcher-linux-arm64-glibc": "2.5.1",
@@ -34,12 +36,12 @@
     "@types/bun": "catalog:",
     "@types/turndown": "5.0.5",
     "@types/yargs": "17.0.33",
-    "typescript": "catalog:",
     "@typescript/native-preview": "catalog:",
+    "babel-preset-solid": "1.9.10",
+    "typescript": "catalog:",
     "vscode-languageserver-types": "3.17.5",
     "why-is-node-running": "3.2.2",
-    "zod-to-json-schema": "3.24.5",
-    "@opencode-ai/script": "workspace:*"
+    "zod-to-json-schema": "3.24.5"
   },
   "dependencies": {
     "@actions/core": "1.11.1",
@@ -56,8 +58,8 @@
     "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
-    "@opentui/core": "0.1.45",
-    "@opentui/solid": "0.1.45",
+    "@opentui/core": "0.1.46",
+    "@opentui/solid": "0.1.46",
     "@parcel/watcher": "2.5.1",
     "@pierre/precision-diffs": "catalog:",
     "@solid-primitives/event-bus": "1.1.2",

+ 14 - 0
packages/opencode/parsers-config.ts

@@ -212,5 +212,19 @@ export default {
         ],
       },
     },
+    {
+      filetype: "swift",
+      wasm: "https://github.com/alex-pinkus/tree-sitter-swift/releases/download/0.7.1/tree-sitter-swift.wasm",
+      queries: {
+        highlights: [
+          // NOTE: Using parser repo queries instead of nvim-treesitter due to incompatible #lua-match? predicates
+          // "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/highlights.scm
+          "https://raw.githubusercontent.com/alex-pinkus/tree-sitter-swift/main/queries/highlights.scm",
+        ],
+        locals: [
+          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/swift/locals.scm",
+        ],
+      },
+    },
   ],
 }

+ 25 - 1
packages/opencode/src/cli/cmd/agent.ts

@@ -6,6 +6,7 @@ import { Agent } from "../../agent/agent"
 import path from "path"
 import matter from "gray-matter"
 import { Instance } from "../../project/instance"
+import { EOL } from "os"
 
 const AgentCreateCommand = cmd({
   command: "create",
@@ -133,9 +134,32 @@ const AgentCreateCommand = cmd({
   },
 })
 
+const AgentListCommand = cmd({
+  command: "list",
+  describe: "list all available agents",
+  async handler() {
+    await Instance.provide({
+      directory: process.cwd(),
+      async fn() {
+        const agents = await Agent.list()
+        const sortedAgents = agents.sort((a, b) => {
+          if (a.builtIn !== b.builtIn) {
+            return a.builtIn ? -1 : 1
+          }
+          return a.name.localeCompare(b.name)
+        })
+
+        for (const agent of sortedAgents) {
+          process.stdout.write(`${agent.name} (${agent.mode})${EOL}`)
+        }
+      },
+    })
+  },
+})
+
 export const AgentCommand = cmd({
   command: "agent",
   describe: "manage agents",
-  builder: (yargs) => yargs.command(AgentCreateCommand).demandCommand(),
+  builder: (yargs) => yargs.command(AgentCreateCommand).command(AgentListCommand).demandCommand(),
   async handler() {},
 })

+ 16 - 0
packages/opencode/src/cli/cmd/debug/file.ts

@@ -2,6 +2,7 @@ import { EOL } from "os"
 import { File } from "../../../file"
 import { bootstrap } from "../../bootstrap"
 import { cmd } from "../cmd"
+import { Ripgrep } from "@/file/ripgrep"
 
 const FileSearchCommand = cmd({
   command: "search <query>",
@@ -62,6 +63,20 @@ const FileListCommand = cmd({
   },
 })
 
+const FileTreeCommand = cmd({
+  command: "tree [dir]",
+  builder: (yargs) =>
+    yargs.positional("dir", {
+      type: "string",
+      description: "Directory to tree",
+      default: process.cwd(),
+    }),
+  async handler(args) {
+    const files = await Ripgrep.tree({ cwd: args.dir, limit: 200 })
+    console.log(files)
+  },
+})
+
 export const FileCommand = cmd({
   command: "file",
   builder: (yargs) =>
@@ -70,6 +85,7 @@ export const FileCommand = cmd({
       .command(FileStatusCommand)
       .command(FileListCommand)
       .command(FileSearchCommand)
+      .command(FileTreeCommand)
       .demandCommand(),
   async handler() {},
 })

+ 1 - 1
packages/opencode/src/cli/cmd/tui/app.tsx

@@ -312,7 +312,7 @@ function App() {
     {
       title: "Exit the app",
       value: "app.exit",
-      onSelect: exit,
+      onSelect: () => exit(),
       category: "System",
     },
     {

+ 31 - 11
packages/opencode/src/cli/cmd/tui/component/dialog-agent.tsx

@@ -1,21 +1,39 @@
 import { createMemo } from "solid-js"
 import { useLocal } from "@tui/context/local"
+import { useSync } from "@tui/context/sync"
 import { DialogSelect } from "@tui/ui/dialog-select"
 import { useDialog } from "@tui/ui/dialog"
+import { useTheme } from "@tui/context/theme"
 
 export function DialogAgent() {
   const local = useLocal()
+  const sync = useSync()
   const dialog = useDialog()
+  const { theme } = useTheme()
 
-  const options = createMemo(() =>
-    local.agent.list().map((item) => {
-      return {
-        value: item.name,
-        title: item.name,
-        description: item.builtIn ? "native" : item.description,
-      }
-    }),
-  )
+  const options = createMemo(() => {
+    const allAgents = sync.data.agent
+    const primaryAgents = allAgents.filter((x) => x.mode !== "subagent")
+    const subagents = allAgents.filter((x) => x.mode === "subagent")
+
+    const primaryOptions = primaryAgents.map((item) => ({
+      value: item.name,
+      title: item.name,
+      description: item.builtIn ? "native" : item.description,
+      category: "Primary Agents",
+    }))
+
+    const subagentOptions = subagents.map((item) => ({
+      value: item.name,
+      title: item.name,
+      description: item.builtIn ? "native" : item.description,
+      category: "Subagents (non-selectable)",
+      disabled: true,
+      bg: theme.backgroundPanel,
+    }))
+
+    return [...primaryOptions, ...subagentOptions]
+  })
 
   return (
     <DialogSelect
@@ -23,8 +41,10 @@ export function DialogAgent() {
       current={local.agent.current().name}
       options={options()}
       onSelect={(option) => {
-        local.agent.set(option.value)
-        dialog.clear()
+        if (!option.disabled) {
+          local.agent.set(option.value)
+          dialog.clear()
+        }
       }}
     />
   )

+ 1 - 1
packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx

@@ -16,7 +16,6 @@ export function DialogModel() {
   const sync = useSync()
   const dialog = useDialog()
   const [ref, setRef] = createSignal<DialogSelectRef<unknown>>()
-  const { theme } = useTheme()
 
   const options = createMemo(() => {
     return [
@@ -62,6 +61,7 @@ export function DialogModel() {
               footer: info.cost?.input === 0 && provider.id === "opencode" ? <Free /> : undefined,
             })),
             filter((x) => Boolean(ref()?.filter) || !local.model.recent().find((y) => isDeepEqual(y, x.value))),
+            sortBy((x) => x.title),
           ),
         ),
       ),

+ 5 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx

@@ -245,6 +245,11 @@ export function Autocomplete(props: {
           description: "jump to message",
           onSelect: () => command.trigger("session.timeline"),
         },
+        {
+          display: "/thinking",
+          description: "toggle thinking blocks",
+          onSelect: () => command.trigger("session.toggle.thinking"),
+        },
       )
       if (sync.data.config.share !== "disabled") {
         results.push({

+ 1 - 2
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -590,8 +590,7 @@ export function Prompt(props: PromptProps) {
                 syncExtmarksWithPromptParts()
               }}
               keyBindings={textareaKeybindings()}
-              // TODO: fix this any
-              onKeyDown={async (e: any) => {
+              onKeyDown={async (e) => {
                 if (props.disabled) {
                   e.preventDefault()
                   return

+ 2 - 2
packages/opencode/src/cli/cmd/tui/context/local.tsx

@@ -158,10 +158,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           }
         }
         const provider = sync.data.provider[0]
-        const model = Object.values(provider.models)[0]
+        const model = sync.data.provider_default[provider.id] ?? Object.values(provider.models)[0].id
         return {
           providerID: provider.id,
-          modelID: model.id,
+          modelID: model,
         }
       })
 

+ 21 - 2
packages/opencode/src/cli/cmd/tui/context/sync.tsx

@@ -11,6 +11,7 @@ import type {
   LspStatus,
   McpStatus,
   FormatterStatus,
+  SessionStatus,
 } from "@opencode-ai/sdk"
 import { createStore, produce, reconcile } from "solid-js/store"
 import { useSDK } from "@tui/context/sdk"
@@ -18,7 +19,7 @@ import { Binary } from "@/util/binary"
 import { createSimpleContext } from "./helper"
 import type { Snapshot } from "@/snapshot"
 import { useExit } from "./exit"
-import { onMount } from "solid-js"
+import { batch, onMount } from "solid-js"
 
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
@@ -26,6 +27,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
     const [store, setStore] = createStore<{
       status: "loading" | "partial" | "complete"
       provider: Provider[]
+      provider_default: Record<string, string>
       agent: Agent[]
       command: Command[]
       permission: {
@@ -33,6 +35,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       }
       config: Config
       session: Session[]
+      session_status: {
+        [sessionID: string]: SessionStatus
+      }
       session_diff: {
         [sessionID: string]: Snapshot.FileDiff[]
       }
@@ -57,7 +62,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
       permission: {},
       command: [],
       provider: [],
+      provider_default: {},
       session: [],
+      session_status: {},
       session_diff: {},
       todo: {},
       message: {},
@@ -140,6 +147,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             }),
           )
           break
+
+        case "session.status": {
+          setStore("session_status", event.properties.sessionID, event.properties.status)
+          break
+        }
+
         case "message.updated": {
           const messages = store.message[event.properties.info.sessionID]
           if (!messages) {
@@ -222,7 +235,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
     onMount(() => {
       // blocking
       Promise.all([
-        sdk.client.config.providers({ throwOnError: true }).then((x) => setStore("provider", x.data!.providers)),
+        sdk.client.config.providers({ throwOnError: true }).then((x) => {
+          batch(() => {
+            setStore("provider", x.data!.providers)
+            setStore("provider_default", x.data!.default)
+          })
+        }),
         sdk.client.app.agents({ throwOnError: true }).then((x) => setStore("agent", x.data ?? [])),
         sdk.client.config.get({ throwOnError: true }).then((x) => setStore("config", x.data!)),
       ])
@@ -240,6 +258,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             sdk.client.lsp.status().then((x) => setStore("lsp", x.data!)),
             sdk.client.mcp.status().then((x) => setStore("mcp", x.data!)),
             sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
+            sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
           ]).then(() => {
             setStore("status", "complete")
           })

+ 10 - 1
packages/opencode/src/cli/cmd/tui/context/theme.tsx

@@ -132,7 +132,16 @@ function resolveTheme(theme: ThemeJson, mode: "dark" | "light") {
     if (c instanceof RGBA) return c
     if (typeof c === "string") {
       if (c === "transparent" || c === "none") return RGBA.fromInts(0, 0, 0, 0)
-      return c.startsWith("#") ? RGBA.fromHex(c) : resolveColor(defs[c])
+
+      if (c.startsWith("#")) return RGBA.fromHex(c)
+
+      if (defs[c]) {
+        return resolveColor(defs[c])
+      } else if (theme.theme[c as keyof Theme]) {
+        return resolveColor(theme.theme[c as keyof Theme])
+      } else {
+        throw new Error(`Color reference "${c}" not found in defs or theme`)
+      }
     }
     return resolveColor(c[mode])
   }

+ 129 - 71
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -6,6 +6,8 @@ import {
   For,
   Match,
   on,
+  onCleanup,
+  onMount,
   Show,
   Switch,
   useContext,
@@ -20,7 +22,6 @@ import { useTheme } from "@tui/context/theme"
 import {
   BoxRenderable,
   ScrollBoxRenderable,
-  TextAttributes,
   addDefaultParsers,
   MacOSScrollAccel,
   type ScrollAcceleration,
@@ -65,7 +66,6 @@ import { Editor } from "../../util/editor"
 import { Global } from "@/global"
 import fs from "fs/promises"
 import stripAnsi from "strip-ansi"
-import { LSP } from "@/lsp/index.ts"
 
 addDefaultParsers(parsers.parsers)
 
@@ -82,6 +82,7 @@ class CustomSpeedScroll implements ScrollAcceleration {
 const context = createContext<{
   width: number
   conceal: () => boolean
+  showThinking: () => boolean
 }>()
 
 function use() {
@@ -101,12 +102,18 @@ export function Session() {
   const permissions = createMemo(() => sync.data.permission[route.sessionID] ?? [])
 
   const pending = createMemo(() => {
-    return messages().findLast((x) => x.role === "assistant" && !x.time?.completed)?.id
+    return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
+  })
+
+  const lastUserMessage = createMemo(() => {
+    const p = pending()
+    return messages().findLast((x) => x.role === "user" && (!p || x.id < p)) as UserMessage
   })
 
   const dimensions = useTerminalDimensions()
   const [sidebar, setSidebar] = createSignal<"show" | "hide" | "auto">(kv.get("sidebar", "auto"))
   const [conceal, setConceal] = createSignal(true)
+  const [showThinking, setShowThinking] = createSignal(true)
 
   const wide = createMemo(() => dimensions().width > 120)
   const sidebarVisible = createMemo(() => sidebar() === "show" || (sidebar() === "auto" && wide()))
@@ -380,6 +387,15 @@ export function Session() {
         dialog.clear()
       },
     },
+    {
+      title: "Toggle thinking blocks",
+      value: "session.toggle.thinking",
+      category: "Session",
+      onSelect: (dialog) => {
+        setShowThinking((prev) => !prev)
+        dialog.clear()
+      },
+    },
     {
       title: "Page up",
       value: "session.page.up",
@@ -669,6 +685,7 @@ export function Session() {
           return contentWidth()
         },
         conceal,
+        showThinking,
       }}
     >
       <box flexDirection="row" paddingBottom={1} paddingTop={1} paddingLeft={2} paddingRight={2} gap={2}>
@@ -801,7 +818,7 @@ export function Session() {
                     </Match>
                     <Match when={message.role === "assistant"}>
                       <AssistantMessage
-                        last={index() === messages().length - 1}
+                        last={pending() === message.id}
                         message={message as AssistantMessage}
                         parts={sync.data.part[message.id] ?? []}
                       />
@@ -856,64 +873,84 @@ function UserMessage(props: {
   const queued = createMemo(() => props.pending && props.message.id > props.pending)
   const color = createMemo(() => (queued() ? theme.accent : theme.secondary))
 
+  const compaction = createMemo(() => props.parts.find((x) => x.type === "compaction"))
+
   return (
-    <Show when={text()}>
-      <box
-        id={props.message.id}
-        onMouseOver={() => {
-          setHover(true)
-        }}
-        onMouseOut={() => {
-          setHover(false)
-        }}
-        onMouseUp={props.onMouseUp}
-        border={["left"]}
-        paddingTop={1}
-        paddingBottom={1}
-        paddingLeft={2}
-        marginTop={props.index === 0 ? 0 : 1}
-        backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
-        customBorderChars={SplitBorder.customBorderChars}
-        borderColor={color()}
-        flexShrink={0}
-      >
-        <text fg={theme.text}>{text()?.text}</text>
-        <Show when={files().length}>
-          <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
-            <For each={files()}>
-              {(file) => {
-                const bg = createMemo(() => {
-                  if (file.mime.startsWith("image/")) return theme.accent
-                  if (file.mime === "application/pdf") return theme.primary
-                  return theme.secondary
-                })
-                return (
-                  <text fg={theme.text}>
-                    <span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
-                    <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
-                  </text>
-                )
-              }}
-            </For>
-          </box>
-        </Show>
-        <text fg={theme.text}>
-          {sync.data.config.username ?? "You"}{" "}
-          <Show
-            when={queued()}
-            fallback={<span style={{ fg: theme.textMuted }}>({Locale.time(props.message.time.created)})</span>}
-          >
-            <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
+    <>
+      <Show when={text()}>
+        <box
+          id={props.message.id}
+          onMouseOver={() => {
+            setHover(true)
+          }}
+          onMouseOut={() => {
+            setHover(false)
+          }}
+          onMouseUp={props.onMouseUp}
+          border={["left"]}
+          paddingTop={1}
+          paddingBottom={1}
+          paddingLeft={2}
+          marginTop={props.index === 0 ? 0 : 1}
+          backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
+          customBorderChars={SplitBorder.customBorderChars}
+          borderColor={color()}
+          flexShrink={0}
+        >
+          <text fg={theme.text}>{text()?.text}</text>
+          <Show when={files().length}>
+            <box flexDirection="row" paddingBottom={1} paddingTop={1} gap={1} flexWrap="wrap">
+              <For each={files()}>
+                {(file) => {
+                  const bg = createMemo(() => {
+                    if (file.mime.startsWith("image/")) return theme.accent
+                    if (file.mime === "application/pdf") return theme.primary
+                    return theme.secondary
+                  })
+                  return (
+                    <text fg={theme.text}>
+                      <span style={{ bg: bg(), fg: theme.background }}> {MIME_BADGE[file.mime] ?? file.mime} </span>
+                      <span style={{ bg: theme.backgroundElement, fg: theme.textMuted }}> {file.filename} </span>
+                    </text>
+                  )
+                }}
+              </For>
+            </box>
           </Show>
-        </text>
-      </box>
-    </Show>
+          <text fg={theme.text}>
+            {sync.data.config.username ?? "You"}{" "}
+            <Show
+              when={queued()}
+              fallback={<span style={{ fg: theme.textMuted }}>({Locale.time(props.message.time.created)})</span>}
+            >
+              <span style={{ bg: theme.accent, fg: theme.backgroundPanel, bold: true }}> QUEUED </span>
+            </Show>
+          </text>
+        </box>
+      </Show>
+      <Show when={compaction()}>
+        <box
+          marginTop={1}
+          border={["top"]}
+          title=" Compaction "
+          titleAlignment="center"
+          borderColor={theme.borderActive}
+        />
+      </Show>
+    </>
   )
 }
 
 function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
   const local = useLocal()
   const { theme } = useTheme()
+  const sync = useSync()
+  const status = createMemo(
+    () =>
+      sync.data.session_status[props.message.sessionID] ?? {
+        type: "idle",
+      },
+  )
   return (
     <>
       <For each={props.parts}>
@@ -945,23 +982,44 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
           <text fg={theme.textMuted}>{props.message.error?.data.message}</text>
         </box>
       </Show>
-      <Show
-        when={
-          !props.message.time.completed ||
-          (props.last && props.parts.some((item) => item.type === "step-finish" && item.reason === "tool-calls"))
-        }
-      >
-        <box
-          paddingLeft={2}
-          marginTop={1}
-          flexDirection="row"
-          gap={1}
-          border={["left"]}
-          customBorderChars={SplitBorder.customBorderChars}
-          borderColor={theme.backgroundElement}
-        >
+      <Show when={props.last && status().type !== "idle"}>
+        <box paddingLeft={3} flexDirection="row" gap={1} marginTop={1}>
           <text fg={local.agent.color(props.message.mode)}>{Locale.titlecase(props.message.mode)}</text>
-          <Shimmer text={`${props.message.modelID}`} color={theme.text} />
+          <Shimmer text={props.message.modelID} color={theme.text} />
+          {(() => {
+            const retry = createMemo(() => {
+              const s = status()
+              if (s.type !== "retry") return
+              return s
+            })
+            const message = createMemo(() => {
+              const r = retry()
+              if (!r) return
+              if (r.message.includes("exceeded your current quota") && r.message.includes("gemini"))
+                return "gemini 3 way too hot right now"
+              if (r.message.length > 50) return r.message.slice(0, 50) + "..."
+              return r.message
+            })
+            const [seconds, setSeconds] = createSignal(0)
+            onMount(() => {
+              const timer = setInterval(() => {
+                const next = retry()?.next
+                if (next) setSeconds(Math.round((next - Date.now()) / 1000))
+              }, 1000)
+
+              onCleanup(() => {
+                clearInterval(timer)
+              })
+            })
+            return (
+              <Show when={retry()}>
+                <text fg={theme.error}>
+                  {message()} [retrying {seconds() > 0 ? `in ${seconds()}s ` : ""}
+                  attempt #{retry()!.attempt}]
+                </text>
+              </Show>
+            )
+          })()}
         </box>
       </Show>
       <Show
@@ -992,7 +1050,7 @@ function ReasoningPart(props: { last: boolean; part: ReasoningPart; message: Ass
   const ctx = use()
   const content = createMemo(() => props.part.text.trim())
   return (
-    <Show when={content()}>
+    <Show when={content() && ctx.showThinking()}>
       <box
         id={"text-" + props.part.id}
         paddingLeft={2}

+ 16 - 8
packages/opencode/src/cli/cmd/tui/thread.ts

@@ -5,6 +5,7 @@ import { type rpc } from "./worker"
 import path from "path"
 import { UI } from "@/cli/ui"
 import { iife } from "@/util/iife"
+import { Log } from "@/util/log"
 
 declare global {
   const OPENCODE_WORKER_PATH: string
@@ -57,11 +58,16 @@ export const TuiThreadCommand = cmd({
     // Resolve relative paths against PWD to preserve behavior when using --cwd flag
     const baseCwd = process.env.PWD ?? process.cwd()
     const cwd = args.project ? path.resolve(baseCwd, args.project) : process.cwd()
-    let workerPath: string | URL = new URL("./worker.ts", import.meta.url)
-
-    if (typeof OPENCODE_WORKER_PATH !== "undefined") {
-      workerPath = OPENCODE_WORKER_PATH
-    }
+    const defaultWorker = new URL("./worker.ts", import.meta.url)
+    // Nix build creates a bundled worker next to the binary; prefer it when present.
+    const execDir = path.dirname(process.execPath)
+    const bundledWorker = path.join(execDir, "opencode-worker.js")
+    const hasBundledWorker = await Bun.file(bundledWorker).exists()
+    const workerPath = (() => {
+      if (typeof OPENCODE_WORKER_PATH !== "undefined") return OPENCODE_WORKER_PATH
+      if (hasBundledWorker) return bundledWorker
+      return defaultWorker
+    })()
     try {
       process.chdir(cwd)
     } catch (e) {
@@ -74,13 +80,15 @@ export const TuiThreadCommand = cmd({
         Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
       ),
     })
-    worker.onerror = console.error
+    worker.onerror = (e) => {
+      Log.Default.error(e)
+    }
     const client = Rpc.client<typeof rpc>(worker)
     process.on("uncaughtException", (e) => {
-      console.error(e)
+      Log.Default.error(e)
     })
     process.on("unhandledRejection", (e) => {
-      console.error(e)
+      Log.Default.error(e)
     })
     const server = await client.call("server", {
       port: args.port,

+ 5 - 1
packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx

@@ -2,10 +2,12 @@ import { TextAttributes } from "@opentui/core"
 import { useTheme } from "@tui/context/theme"
 import { useDialog } from "./dialog"
 import { useKeyboard } from "@opentui/solid"
+import { useKeybind } from "@tui/context/keybind"
 
 export function DialogHelp() {
   const dialog = useDialog()
   const { theme } = useTheme()
+  const keybind = useKeybind()
 
   useKeyboard((evt) => {
     if (evt.name === "return" || evt.name === "escape") {
@@ -20,7 +22,9 @@ export function DialogHelp() {
         <text fg={theme.textMuted}>esc/enter</text>
       </box>
       <box paddingBottom={1}>
-        <text fg={theme.textMuted}>Press Ctrl+P to see all available actions and commands in any context.</text>
+        <text fg={theme.textMuted}>
+          Press {keybind.print("command_list")} to see all available actions and commands in any context.
+        </text>
       </box>
       <box flexDirection="row" justifyContent="flex-end" paddingBottom={1}>
         <box paddingLeft={3} paddingRight={3} backgroundColor={theme.primary} onMouseUp={() => dialog.clear()}>

+ 40 - 11
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx

@@ -54,10 +54,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
 
   const filtered = createMemo(() => {
     const needle = store.filter.toLowerCase()
-    const result = pipe(
-      props.options,
-      filter((x) => x.disabled !== true),
-      (x) => (!needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj)),
+    const result = pipe(props.options, (x) =>
+      !needle ? x : fuzzysort.go(needle, x, { keys: ["title", "category"] }).map((x) => x.obj),
     )
     return result
   })
@@ -96,6 +94,16 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     let next = store.selected + direction
     if (next < 0) next = flat().length - 1
     if (next >= flat().length) next = 0
+
+    // Skip disabled options when flipping through agents
+    let attempts = 0
+    while (flat()[next]?.disabled && attempts < flat().length) {
+      next = next + direction
+      if (next < 0) next = flat().length - 1
+      if (next >= flat().length) next = 0
+      attempts++
+    }
+
     moveTo(next)
   }
 
@@ -126,7 +134,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     if (evt.name === "pagedown") move(10)
     if (evt.name === "return") {
       const option = selected()
-      if (option) {
+      if (option && !option.disabled) {
         // evt.preventDefault()
         if (option.onSelect) option.onSelect(dialog)
         props.onSelect?.(option)
@@ -136,7 +144,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     for (const item of props.keybind ?? []) {
       if (Keybind.match(item.keybind, keybind.parse(evt))) {
         const s = selected()
-        if (s) {
+        if (s && !s.disabled) {
           evt.preventDefault()
           item.onTrigger(s)
         }
@@ -208,15 +216,19 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                       id={JSON.stringify(option.value)}
                       flexDirection="row"
                       onMouseUp={() => {
-                        option.onSelect?.(dialog)
-                        props.onSelect?.(option)
+                        if (!option.disabled) {
+                          option.onSelect?.(dialog)
+                          props.onSelect?.(option)
+                        }
                       }}
                       onMouseOver={() => {
                         const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
                         if (index === -1) return
                         moveTo(index)
                       }}
-                      backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
+                      backgroundColor={
+                        active() && !option.disabled ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)
+                      }
                       paddingLeft={1}
                       paddingRight={1}
                       gap={1}
@@ -227,6 +239,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
                         description={option.description !== category ? option.description : undefined}
                         active={active()}
                         current={isDeepEqual(option.value, props.current)}
+                        disabled={option.disabled}
                       />
                     </box>
                   )
@@ -256,13 +269,22 @@ function Option(props: {
   active?: boolean
   current?: boolean
   footer?: JSX.Element | string
+  disabled?: boolean
   onMouseOver?: () => void
 }) {
   const { theme } = useTheme()
 
+  const textColor = props.disabled
+    ? theme.textMuted
+    : props.active
+      ? theme.background
+      : props.current
+        ? theme.primary
+        : theme.text
+
   return (
     <>
-      <Show when={props.current}>
+      <Show when={props.current && !props.disabled}>
         <text
           flexShrink={0}
           fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
@@ -271,10 +293,17 @@ function Option(props: {
         </text>
       </Show>
+      <Show when={props.disabled}>
+        <text flexShrink={0} fg={theme.textMuted} marginRight={0.5}>
+          ○
+        </text>
+      </Show>
       <text
         flexGrow={1}
         fg={props.active ? theme.background : props.current ? theme.primary : theme.text}
-        attributes={props.active ? TextAttributes.BOLD : undefined}
+        attributes={
+          props.active && !props.disabled ? TextAttributes.BOLD : props.disabled ? TextAttributes.DIM : undefined
+        }
         overflow="hidden"
         wrapMode="none"
       >

+ 3 - 0
packages/opencode/src/cli/cmd/tui/ui/toast.tsx

@@ -1,6 +1,7 @@
 import { createContext, useContext, type ParentProps, Show } from "solid-js"
 import { createStore } from "solid-js/store"
 import { useTheme } from "@tui/context/theme"
+import { useTerminalDimensions } from "@opentui/solid"
 import { SplitBorder } from "../component/border"
 import { TextAttributes } from "@opentui/core"
 import z from "zod"
@@ -11,6 +12,7 @@ export type ToastOptions = z.infer<typeof TuiEvent.ToastShow.properties>
 export function Toast() {
   const toast = useToast()
   const { theme } = useTheme()
+  const dimensions = useTerminalDimensions()
 
   return (
     <Show when={toast.currentToast}>
@@ -21,6 +23,7 @@ export function Toast() {
           alignItems="flex-start"
           top={2}
           right={2}
+          maxWidth={Math.min(60, dimensions().width - 6)}
           paddingLeft={2}
           paddingRight={2}
           paddingTop={1}

+ 31 - 24
packages/opencode/src/config/config.ts

@@ -439,7 +439,7 @@ export namespace Config {
     })
 
   export const TUI = z.object({
-    scroll_speed: z.number().min(1).optional().default(1).describe("TUI scroll speed"),
+    scroll_speed: z.number().min(0.001).optional().default(1).describe("TUI scroll speed"),
     scroll_acceleration: z
       .object({
         enabled: z.boolean().describe("Enable scroll acceleration"),
@@ -544,36 +544,43 @@ export namespace Config {
         .describe("Custom provider configurations and model overrides"),
       mcp: z.record(z.string(), Mcp).optional().describe("MCP (Model Context Protocol) server configurations"),
       formatter: z
-        .record(
-          z.string(),
-          z.object({
-            disabled: z.boolean().optional(),
-            command: z.array(z.string()).optional(),
-            environment: z.record(z.string(), z.string()).optional(),
-            extensions: z.array(z.string()).optional(),
-          }),
-        )
-        .optional(),
-      lsp: z
-        .record(
-          z.string(),
-          z.union([
+        .union([
+          z.literal(false),
+          z.record(
+            z.string(),
             z.object({
-              disabled: z.literal(true),
-            }),
-            z.object({
-              command: z.array(z.string()),
-              extensions: z.array(z.string()).optional(),
               disabled: z.boolean().optional(),
-              env: z.record(z.string(), z.string()).optional(),
-              initialization: z.record(z.string(), z.any()).optional(),
+              command: z.array(z.string()).optional(),
+              environment: z.record(z.string(), z.string()).optional(),
+              extensions: z.array(z.string()).optional(),
             }),
-          ]),
-        )
+          ),
+        ])
+        .optional(),
+      lsp: z
+        .union([
+          z.literal(false),
+          z.record(
+            z.string(),
+            z.union([
+              z.object({
+                disabled: z.literal(true),
+              }),
+              z.object({
+                command: z.array(z.string()),
+                extensions: z.array(z.string()).optional(),
+                disabled: z.boolean().optional(),
+                env: z.record(z.string(), z.string()).optional(),
+                initialization: z.record(z.string(), z.any()).optional(),
+              }),
+            ]),
+          ),
+        ])
         .optional()
         .refine(
           (data) => {
             if (!data) return true
+            if (typeof data === "boolean") return true
             const serverIds = new Set(Object.values(LSPServer).map((s) => s.id))
 
             return Object.entries(data).every(([id, config]) => {

+ 3 - 0
packages/opencode/src/file/ripgrep.ts

@@ -8,8 +8,10 @@ import { lazy } from "../util/lazy"
 import { $ } from "bun"
 
 import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
+import { Log } from "@/util/log"
 
 export namespace Ripgrep {
+  const log = Log.create({ service: "ripgrep" })
   const Stats = z.object({
     elapsed: z.object({
       secs: z.number(),
@@ -254,6 +256,7 @@ export namespace Ripgrep {
   }
 
   export async function tree(input: { cwd: string; limit?: number }) {
+    log.info("tree", input)
     const files = await Array.fromAsync(Ripgrep.files({ cwd: input.cwd }))
     interface Node {
       path: string[]

+ 8 - 0
packages/opencode/src/format/index.ts

@@ -28,6 +28,14 @@ export namespace Format {
     const cfg = await Config.get()
 
     const formatters: Record<string, Formatter.Info> = {}
+    if (cfg.formatter === false) {
+      log.info("all formatters are disabled")
+      return {
+        enabled,
+        formatters,
+      }
+    }
+
     for (const item of Object.values(Formatter)) {
       formatters[item.name] = item
     }

+ 12 - 1
packages/opencode/src/lsp/index.ts

@@ -62,10 +62,21 @@ export namespace LSP {
     async () => {
       const clients: LSPClient.Info[] = []
       const servers: Record<string, LSPServer.Info> = {}
+      const cfg = await Config.get()
+
+      if (cfg.lsp === false) {
+        log.info("all LSPs are disabled")
+        return {
+          broken: new Set<string>(),
+          servers,
+          clients,
+          spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
+        }
+      }
+
       for (const server of Object.values(LSPServer)) {
         servers[server.id] = server
       }
-      const cfg = await Config.get()
       for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
         const existing = servers[name]
         if (item.disabled) {

+ 6 - 1
packages/opencode/src/permission/index.ts

@@ -186,8 +186,13 @@ export namespace Permission {
       public readonly permissionID: string,
       public readonly toolCallID?: string,
       public readonly metadata?: Record<string, any>,
+      public readonly reason?: string,
     ) {
-      super(`The user rejected permission to use this specific tool call. You may try again with different parameters.`)
+      super(
+        reason !== undefined
+          ? reason
+          : `The user rejected permission to use this specific tool call. You may try again with different parameters.`,
+      )
     }
   }
 }

+ 7 - 0
packages/opencode/src/provider/models-macro.ts

@@ -1,4 +1,11 @@
 export async function data() {
+  const path = Bun.env.MODELS_DEV_API_JSON
+  if (path) {
+    const file = Bun.file(path)
+    if (await file.exists()) {
+      return await file.text()
+    }
+  }
   const json = await fetch("https://models.dev/api.json").then((x) => x.text())
   return json
 }

+ 1 - 37
packages/opencode/src/provider/provider.ts

@@ -627,7 +627,7 @@ export namespace Provider {
     }
   }
 
-  const priority = ["gemini-2.5-pro-preview", "gpt-5", "claude-sonnet-4"]
+  const priority = ["gpt-5", "claude-sonnet-4", "big-pickle", "gemini-3-pro"]
   export function sort(models: ModelsDev.Model[]) {
     return sortBy(
       models,
@@ -641,42 +641,6 @@ export namespace Provider {
     const cfg = await Config.get()
     if (cfg.model) return parseModel(cfg.model)
 
-    // this will be adjusted when migration to opentui is complete,
-    // for now we just read the tui state toml file directly
-    //
-    // NOTE: cannot just import file as toml without cleaning due to lack of
-    // support for date/time references in Bun toml parser: https://github.com/oven-sh/bun/issues/22426
-    const lastused = await Bun.file(path.join(Global.Path.state, "tui"))
-      .text()
-      .then((text) => {
-        // remove the date/time references since Bun toml parser doesn't support yet
-        const cleaned = text
-          .split("\n")
-          .filter((line) => !line.trim().startsWith("last_used ="))
-          .join("\n")
-        const state = Bun.TOML.parse(cleaned) as {
-          recently_used_models?: {
-            provider_id: string
-            model_id: string
-          }[]
-        }
-        const [model] = state?.recently_used_models ?? []
-        if (model) {
-          return {
-            providerID: model.provider_id,
-            modelID: model.model_id,
-          }
-        }
-      })
-      .catch((error) => {
-        log.error("failed to find last used model", {
-          error,
-        })
-        return undefined
-      })
-
-    if (lastused) return lastused
-
     const provider = await list()
       .then((val) => Object.values(val))
       .then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.info.id)))

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