Преглед изворни кода

Merge remote-tracking branch 'origin/sqlite2' into sqlite2

Dax Raad пре 2 месеци
родитељ
комит
5f552534c7
100 измењених фајлова са 2077 додато и 504 уклоњено
  1. 1 0
      .github/workflows/beta.yml
  2. 76 18
      .github/workflows/close-stale-prs.yml
  3. 3 1
      README.ar.md
  4. 3 1
      README.br.md
  5. 3 1
      README.da.md
  6. 3 1
      README.de.md
  7. 3 1
      README.es.md
  8. 3 1
      README.fr.md
  9. 3 1
      README.it.md
  10. 3 1
      README.ja.md
  11. 3 1
      README.ko.md
  12. 2 1
      README.md
  13. 3 1
      README.no.md
  14. 3 1
      README.pl.md
  15. 3 1
      README.ru.md
  16. 2 1
      README.th.md
  17. 135 0
      README.tr.md
  18. 3 1
      README.zh.md
  19. 4 2
      README.zht.md
  20. 3 3
      nix/desktop.nix
  21. 4 4
      nix/hashes.json
  22. 4 3
      nix/node_modules.nix
  23. 70 0
      packages/app/e2e/actions.ts
  24. 391 0
      packages/app/e2e/projects/workspaces.spec.ts
  25. 9 0
      packages/app/e2e/selectors.ts
  26. 3 1
      packages/app/playwright.config.ts
  27. 4 3
      packages/app/src/components/dialog-select-model.tsx
  28. 93 46
      packages/app/src/components/prompt-input.tsx
  29. 9 3
      packages/app/src/components/settings-general.tsx
  30. 8 2
      packages/app/src/components/settings-keybinds.tsx
  31. 8 2
      packages/app/src/components/settings-models.tsx
  32. 8 2
      packages/app/src/components/settings-providers.tsx
  33. 3 0
      packages/app/src/i18n/ar.ts
  34. 3 0
      packages/app/src/i18n/br.ts
  35. 3 0
      packages/app/src/i18n/da.ts
  36. 3 0
      packages/app/src/i18n/de.ts
  37. 3 0
      packages/app/src/i18n/en.ts
  38. 3 0
      packages/app/src/i18n/es.ts
  39. 3 0
      packages/app/src/i18n/fr.ts
  40. 3 0
      packages/app/src/i18n/ja.ts
  41. 3 0
      packages/app/src/i18n/ko.ts
  42. 3 0
      packages/app/src/i18n/no.ts
  43. 3 0
      packages/app/src/i18n/pl.ts
  44. 3 0
      packages/app/src/i18n/ru.ts
  45. 17 14
      packages/app/src/i18n/th.ts
  46. 3 0
      packages/app/src/i18n/zh.ts
  47. 3 0
      packages/app/src/i18n/zht.ts
  48. 14 2
      packages/app/src/pages/layout.tsx
  49. 14 0
      packages/app/src/pages/session.tsx
  50. 4 3
      packages/console/app/src/routes/zen/index.tsx
  51. 2 0
      packages/opencode/src/agent/agent.ts
  52. 2 2
      packages/opencode/src/cli/cmd/auth.ts
  53. 34 0
      packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx
  54. 2 1
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  55. 23 0
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  56. 31 18
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  57. 1 1
      packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
  58. 5 5
      packages/opencode/src/cli/cmd/tui/util/transcript.ts
  59. 3 0
      packages/opencode/src/command/index.ts
  60. 5 0
      packages/opencode/src/config/config.ts
  61. 190 22
      packages/opencode/src/file/index.ts
  62. 1 1
      packages/opencode/src/file/ripgrep.ts
  63. 2 2
      packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts
  64. 17 2
      packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts
  65. 5 41
      packages/opencode/src/provider/transform.ts
  66. 9 6
      packages/opencode/src/server/server.ts
  67. 13 7
      packages/opencode/src/session/index.ts
  68. 11 6
      packages/opencode/src/session/instruction.ts
  69. 6 13
      packages/opencode/src/session/llm.ts
  70. 9 1
      packages/opencode/src/session/processor.ts
  71. 56 28
      packages/opencode/src/session/prompt.ts
  72. 7 1
      packages/opencode/src/tool/batch.ts
  73. 0 4
      packages/opencode/src/tool/read.ts
  74. 1 1
      packages/opencode/src/tool/tool.ts
  75. 25 5
      packages/opencode/src/worktree/index.ts
  76. 27 2
      packages/opencode/test/cli/tui/transcript.test.ts
  77. 31 0
      packages/opencode/test/config/config.test.ts
  78. 39 0
      packages/opencode/test/file/ripgrep.test.ts
  79. 44 0
      packages/opencode/test/plugin/auth-override.test.ts
  80. 29 2
      packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts
  81. 35 0
      packages/opencode/test/provider/copilot/copilot-chat-model.test.ts
  82. 1 132
      packages/opencode/test/provider/transform.test.ts
  83. 20 0
      packages/opencode/test/session/instruction.test.ts
  84. 60 0
      packages/opencode/test/session/prompt-variant.test.ts
  85. 62 0
      packages/opencode/test/session/prompt.test.ts
  86. 8 2
      packages/plugin/package.json
  87. 17 0
      packages/script/src/index.ts
  88. 1 1
      packages/sdk/js/src/gen/types.gen.ts
  89. 6 1
      packages/sdk/js/src/v2/gen/types.gen.ts
  90. 8 1
      packages/sdk/openapi.json
  91. 15 14
      packages/ui/src/components/button.css
  92. 1 1
      packages/ui/src/components/button.tsx
  93. 49 0
      packages/ui/src/components/cycle-label.css
  94. 135 0
      packages/ui/src/components/cycle-label.tsx
  95. 27 18
      packages/ui/src/components/dropdown-menu.css
  96. 5 2
      packages/ui/src/components/icon.tsx
  97. 17 33
      packages/ui/src/components/list.css
  98. 3 2
      packages/ui/src/components/list.tsx
  99. 3 3
      packages/ui/src/components/message-part.tsx
  100. 10 0
      packages/ui/src/components/morph-chevron.css

+ 1 - 0
.github/workflows/beta.yml

@@ -10,6 +10,7 @@ jobs:
     runs-on: blacksmith-4vcpu-ubuntu-2404
     permissions:
       contents: write
+      pull-requests: write
     steps:
       - name: Checkout repository
         uses: actions/checkout@v4

+ 76 - 18
.github/workflows/close-stale-prs.yml

@@ -28,40 +28,98 @@ jobs:
             const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
             const { owner, repo } = context.repo
             const dryRun = context.payload.inputs?.dryRun === "true"
-            const stalePrs = []
 
             core.info(`Dry run mode: ${dryRun}`)
+            core.info(`Cutoff date: ${cutoff.toISOString()}`)
 
-            const prs = await github.paginate(github.rest.pulls.list, {
-              owner,
-              repo,
-              state: "open",
-              per_page: 100,
-              sort: "updated",
-              direction: "asc",
-            })
-
-            for (const pr of prs) {
-              const lastUpdated = new Date(pr.updated_at)
-              if (lastUpdated > cutoff) {
-                core.info(`PR ${pr.number} is fresh`)
-                continue
+            const query = `
+              query($owner: String!, $repo: String!, $cursor: String) {
+                repository(owner: $owner, name: $repo) {
+                  pullRequests(first: 100, states: OPEN, after: $cursor) {
+                    pageInfo {
+                      hasNextPage
+                      endCursor
+                    }
+                    nodes {
+                      number
+                      title
+                      author {
+                        login
+                      }
+                      createdAt
+                      commits(last: 1) {
+                        nodes {
+                          commit {
+                            committedDate
+                          }
+                        }
+                      }
+                      comments(last: 1) {
+                        nodes {
+                          createdAt
+                        }
+                      }
+                      reviews(last: 1) {
+                        nodes {
+                          createdAt
+                        }
+                      }
+                    }
+                  }
+                }
               }
+            `
+
+            const allPrs = []
+            let cursor = null
+            let hasNextPage = true
+
+            while (hasNextPage) {
+              const result = await github.graphql(query, {
+                owner,
+                repo,
+                cursor,
+              })
 
-              stalePrs.push(pr)
+              allPrs.push(...result.repository.pullRequests.nodes)
+              hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
+              cursor = result.repository.pullRequests.pageInfo.endCursor
             }
 
+            core.info(`Found ${allPrs.length} open pull requests`)
+
+            const stalePrs = allPrs.filter((pr) => {
+              const dates = [
+                new Date(pr.createdAt),
+                pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
+                pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
+                pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
+              ].filter((d) => d !== null)
+
+              const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
+
+              if (!lastActivity || lastActivity > cutoff) {
+                core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
+                return false
+              }
+
+              core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
+              return true
+            })
+
             if (!stalePrs.length) {
               core.info("No stale pull requests found.")
               return
             }
 
+            core.info(`Found ${stalePrs.length} stale pull requests`)
+
             for (const pr of stalePrs) {
               const issue_number = pr.number
               const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
 
               if (dryRun) {
-                core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`)
+                core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
                 continue
               }
 
@@ -79,5 +137,5 @@ jobs:
                 state: "closed",
               })
 
-              core.info(`Closed PR #${issue_number} from ${pr.user.login}`)
+              core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
             }

+ 3 - 1
README.ar.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.br.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.da.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.de.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.es.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.fr.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.it.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.ja.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.ko.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 2 - 1
README.md

@@ -30,7 +30,8 @@
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
-  <a href="README.th.md">ไทย</a>
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.no.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.pl.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 3 - 1
README.ru.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 2 - 1
README.th.md

@@ -30,7 +30,8 @@
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
   <a href="README.br.md">Português (Brasil)</a> |
-  <a href="README.th.md">ไทย</a>
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 135 - 0
README.tr.md

@@ -0,0 +1,135 @@
+<p align="center">
+  <a href="https://opencode.ai">
+    <picture>
+      <source srcset="packages/console/app/src/asset/logo-ornate-dark.svg" media="(prefers-color-scheme: dark)">
+      <source srcset="packages/console/app/src/asset/logo-ornate-light.svg" media="(prefers-color-scheme: light)">
+      <img src="packages/console/app/src/asset/logo-ornate-light.svg" alt="OpenCode logo">
+    </picture>
+  </a>
+</p>
+<p align="center">Açık kaynaklı yapay zeka kodlama asistanı.</p>
+<p align="center">
+  <a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
+  <a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
+  <a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
+</p>
+
+<p align="center">
+  <a href="README.md">English</a> |
+  <a href="README.zh.md">简体中文</a> |
+  <a href="README.zht.md">繁體中文</a> |
+  <a href="README.ko.md">한국어</a> |
+  <a href="README.de.md">Deutsch</a> |
+  <a href="README.es.md">Español</a> |
+  <a href="README.fr.md">Français</a> |
+  <a href="README.it.md">Italiano</a> |
+  <a href="README.da.md">Dansk</a> |
+  <a href="README.ja.md">日本語</a> |
+  <a href="README.pl.md">Polski</a> |
+  <a href="README.ru.md">Русский</a> |
+  <a href="README.ar.md">العربية</a> |
+  <a href="README.no.md">Norsk</a> |
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
+</p>
+
+[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+
+---
+
+### Kurulum
+
+```bash
+# YOLO
+curl -fsSL https://opencode.ai/install | bash
+
+# Paket yöneticileri
+npm i -g opencode-ai@latest        # veya bun/pnpm/yarn
+scoop install opencode             # Windows
+choco install opencode             # Windows
+brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
+brew install opencode              # macOS ve Linux (resmi brew formülü, daha az güncellenir)
+paru -S opencode-bin               # Arch Linux
+mise use -g opencode               # Tüm işletim sistemleri
+nix run nixpkgs#opencode           # veya en güncel geliştirme dalı için github:anomalyco/opencode
+```
+
+> [!TIP]
+> Kurulumdan önce 0.1.x'ten eski sürümleri kaldırın.
+
+### Masaüstü Uygulaması (BETA)
+
+OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz.
+
+| Platform              | İndirme                               |
+| --------------------- | ------------------------------------- |
+| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
+| macOS (Intel)         | `opencode-desktop-darwin-x64.dmg`     |
+| Windows               | `opencode-desktop-windows-x64.exe`    |
+| Linux                 | `.deb`, `.rpm` veya AppImage          |
+
+```bash
+# macOS (Homebrew)
+brew install --cask opencode-desktop
+# Windows (Scoop)
+scoop bucket add extras; scoop install extras/opencode-desktop
+```
+
+#### Kurulum Dizini (Installation Directory)
+
+Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder:
+
+1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini
+2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol
+3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa)
+4. `$HOME/.opencode/bin` - Varsayılan yedek konum
+
+```bash
+# Örnekler
+OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
+XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
+```
+
+### Ajanlar
+
+OpenCode, `Tab` tuşuyla aralarında geçiş yapabileceğiniz iki yerleşik (built-in) ajan içerir.
+
+- **build** - Varsayılan, geliştirme çalışmaları için tam erişimli ajan
+- **plan** - Analiz ve kod keşfi için salt okunur ajan
+  - Varsayılan olarak dosya düzenlemelerini reddeder
+  - Bash komutlarını çalıştırmadan önce izin ister
+  - Tanımadığınız kod tabanlarını keşfetmek veya değişiklikleri planlamak için ideal
+
+Ayrıca, karmaşık aramalar ve çok adımlı görevler için bir **genel** alt ajan bulunmaktadır.
+Bu dahili olarak kullanılır ve mesajlarda `@general` ile çağrılabilir.
+
+[Ajanlar](https://opencode.ai/docs/agents) hakkında daha fazla bilgi edinin.
+
+### Dokümantasyon
+
+OpenCode'u nasıl yapılandıracağınız hakkında daha fazla bilgi için [**dokümantasyonumuza göz atın**](https://opencode.ai/docs).
+
+### Katkıda Bulunma
+
+OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermeden önce [katkıda bulunma dokümanlarımızı](./CONTRIBUTING.md) okuyun.
+
+### OpenCode Üzerine Geliştirme
+
+OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
+
+### SSS
+
+#### Bu Claude Code'dan nasıl farklı?
+
+Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
+
+- %100 açık kaynak
+- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
+- Kurulum gerektirmeyen hazır LSP desteği
+- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
+- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
+
+---
+
+**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

+ 3 - 1
README.zh.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 4 - 2
README.zht.md

@@ -29,7 +29,9 @@
   <a href="README.ru.md">Русский</a> |
   <a href="README.ar.md">العربية</a> |
   <a href="README.no.md">Norsk</a> |
-  <a href="README.br.md">Português (Brasil)</a>
+  <a href="README.br.md">Português (Brasil)</a> |
+  <a href="README.th.md">ไทย</a> |
+  <a href="README.tr.md">Türkçe</a>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
@@ -124,7 +126,7 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
 - 100% 開源。
 - 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
 - 內建 LSP (語言伺服器協定) 支援。
-- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造我們將不斷挑戰終端機介面的極限。
+- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造我們將不斷挑戰終端機介面的極限。
 - 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
 
 ---

+ 3 - 3
nix/desktop.nix

@@ -45,8 +45,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
     rustc
     jq
     makeWrapper
-  ]
-  ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
+  ] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
 
   buildInputs = lib.optionals stdenv.isLinux [
     dbus
@@ -61,6 +60,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
     gst_all_1.gstreamer
     gst_all_1.gst-plugins-base
     gst_all_1.gst-plugins-good
+    gst_all_1.gst-plugins-bad
   ];
 
   strictDeps = true;
@@ -97,4 +97,4 @@ rustPlatform.buildRustPackage (finalAttrs: {
     mainProgram = "opencode-desktop";
     inherit (opencode.meta) platforms;
   };
-})
+})

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-LGI4XJj9WhBwnnqCXVNOTygrB0rBFIIcMjMm1ZuqIQI=",
-    "aarch64-linux": "sha256-0L89lS1RcFmiz9qBRHftdtAZVOtoTG6X0RgEpaLI1sQ=",
-    "aarch64-darwin": "sha256-QdwEcYDtgo/5HIK5WPpV8cf/aZrH9ref/Fh2vS3m/CU=",
-    "x86_64-darwin": "sha256-YLMPQzo0hnSo722WbC+Cp88Db6oyQ+o9NQM8z/7t4uw="
+    "x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=",
+    "aarch64-linux": "sha256-88Qai5RkSenCZkakOg52b6xU2ok+h/Ns4/5L3+55sFY=",
+    "aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=",
+    "x86_64-darwin": "sha256-FkLDqorfIfOw+tB7SW5vgyhOIoI0IV9lqPW1iEmvUiI="
   }
 }

+ 4 - 3
nix/node_modules.nix

@@ -46,15 +46,16 @@ stdenvNoCC.mkDerivation {
 
   buildPhase = ''
     runHook preBuild
-    export HOME=$(mktemp -d)
     export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
     bun install \
       --cpu="${bunCpu}" \
       --os="${bunOs}" \
+      --filter '!./' \
+      --filter './packages/opencode' \
+      --filter './packages/desktop' \
       --frozen-lockfile \
       --ignore-scripts \
-      --no-progress \
-      --linker=isolated
+      --no-progress
     bun --bun ${./scripts/canonicalize-node-modules.ts}
     bun --bun ${./scripts/normalize-bun-binaries.ts}
     runHook postBuild

+ 70 - 0
packages/app/e2e/actions.ts

@@ -8,11 +8,15 @@ import {
   sessionItemSelector,
   dropdownMenuTriggerSelector,
   dropdownMenuContentSelector,
+  projectMenuTriggerSelector,
+  projectWorkspacesToggleSelector,
   titlebarRightSelector,
   popoverBodySelector,
   listItemSelector,
   listItemKeySelector,
   listItemKeyStartsWithSelector,
+  workspaceItemSelector,
+  workspaceMenuTriggerSelector,
 } from "./selectors"
 import type { createSdk } from "./utils"
 
@@ -291,3 +295,69 @@ export async function openStatusPopover(page: Page) {
 
   return { rightSection, popoverBody }
 }
+
+export async function openProjectMenu(page: Page, projectSlug: string) {
+  const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
+  await expect(trigger).toHaveCount(1)
+
+  await trigger.focus()
+  await page.keyboard.press("Enter")
+
+  const menu = page.locator(dropdownMenuContentSelector).first()
+  const opened = await menu
+    .waitFor({ state: "visible", timeout: 1500 })
+    .then(() => true)
+    .catch(() => false)
+
+  if (opened) {
+    const viewport = page.viewportSize()
+    const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
+    const y = viewport ? Math.max(viewport.height - 5, 0) : 800
+    await page.mouse.move(x, y)
+    return menu
+  }
+
+  await trigger.click({ force: true })
+
+  await expect(menu).toBeVisible()
+
+  const viewport = page.viewportSize()
+  const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
+  const y = viewport ? Math.max(viewport.height - 5, 0) : 800
+  await page.mouse.move(x, y)
+  return menu
+}
+
+export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
+  const current = await page
+    .getByRole("button", { name: "New workspace" })
+    .first()
+    .isVisible()
+    .then((x) => x)
+    .catch(() => false)
+
+  if (current === enabled) return
+
+  await openProjectMenu(page, projectSlug)
+
+  const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
+  await expect(toggle).toBeVisible()
+  await toggle.click({ force: true })
+
+  const expected = enabled ? "New workspace" : "New session"
+  await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
+}
+
+export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
+  const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
+  await expect(item).toBeVisible()
+  await item.hover()
+
+  const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
+  await expect(trigger).toBeVisible()
+  await trigger.click({ force: true })
+
+  const menu = page.locator(dropdownMenuContentSelector).first()
+  await expect(menu).toBeVisible()
+  return menu
+}

+ 391 - 0
packages/app/e2e/projects/workspaces.spec.ts

@@ -0,0 +1,391 @@
+import { base64Decode } from "@opencode-ai/util/encode"
+import fs from "node:fs/promises"
+import path from "node:path"
+import type { Page } from "@playwright/test"
+
+import { test, expect } from "../fixtures"
+
+test.describe.configure({ mode: "serial" })
+import {
+  cleanupTestProject,
+  clickMenuItem,
+  confirmDialog,
+  createTestProject,
+  openSidebar,
+  openWorkspaceMenu,
+  seedProjects,
+  setWorkspacesEnabled,
+} from "../actions"
+import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors"
+import { dirSlug } from "../utils"
+
+function slugFromUrl(url: string) {
+  return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
+}
+
+async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise<void>) {
+  const project = await createTestProject()
+  const rootSlug = dirSlug(project)
+  await seedProjects(page, { directory, extra: [project] })
+
+  await gotoSession()
+  await openSidebar(page)
+
+  const target = page.locator(projectSwitchSelector(rootSlug)).first()
+  await expect(target).toBeVisible()
+  await target.click()
+  await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+
+  await openSidebar(page)
+  await setWorkspacesEnabled(page, rootSlug, true)
+
+  await page.getByRole("button", { name: "New workspace" }).first().click()
+  await expect
+    .poll(
+      () => {
+        const slug = slugFromUrl(page.url())
+        return slug.length > 0 && slug !== rootSlug
+      },
+      { timeout: 45_000 },
+    )
+    .toBe(true)
+
+  const slug = slugFromUrl(page.url())
+  const dir = base64Decode(slug)
+
+  await openSidebar(page)
+
+  await expect
+    .poll(
+      async () => {
+        const item = page.locator(workspaceItemSelector(slug)).first()
+        try {
+          await item.hover({ timeout: 500 })
+          return true
+        } catch {
+          return false
+        }
+      },
+      { timeout: 60_000 },
+    )
+    .toBe(true)
+
+  return { project, rootSlug, slug, directory: dir }
+}
+
+test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const project = await createTestProject()
+  const slug = dirSlug(project)
+  await seedProjects(page, { directory, extra: [project] })
+
+  try {
+    await gotoSession()
+    await openSidebar(page)
+
+    const target = page.locator(projectSwitchSelector(slug)).first()
+    await expect(target).toBeVisible()
+    await target.click()
+    await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
+
+    await openSidebar(page)
+
+    await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+    await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
+
+    await setWorkspacesEnabled(page, slug, true)
+    await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+    await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
+
+    await setWorkspacesEnabled(page, slug, false)
+    await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+    await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
+  } finally {
+    await cleanupTestProject(project)
+  }
+})
+
+test("can create a workspace", async ({ page, directory, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const project = await createTestProject()
+  const slug = dirSlug(project)
+  await seedProjects(page, { directory, extra: [project] })
+
+  try {
+    await gotoSession()
+    await openSidebar(page)
+
+    const target = page.locator(projectSwitchSelector(slug)).first()
+    await expect(target).toBeVisible()
+    await target.click()
+    await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
+
+    await openSidebar(page)
+    await setWorkspacesEnabled(page, slug, true)
+
+    await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+
+    await page.getByRole("button", { name: "New workspace" }).first().click()
+
+    await expect
+      .poll(
+        () => {
+          const currentSlug = slugFromUrl(page.url())
+          return currentSlug.length > 0 && currentSlug !== slug
+        },
+        { timeout: 45_000 },
+      )
+      .toBe(true)
+
+    const workspaceSlug = slugFromUrl(page.url())
+    const workspaceDir = base64Decode(workspaceSlug)
+
+    await openSidebar(page)
+
+    await expect
+      .poll(
+        async () => {
+          const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
+          try {
+            await item.hover({ timeout: 500 })
+            return true
+          } catch {
+            return false
+          }
+        },
+        { timeout: 60_000 },
+      )
+      .toBe(true)
+
+    await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
+
+    await cleanupTestProject(workspaceDir)
+  } finally {
+    await cleanupTestProject(project)
+  }
+})
+
+test("can rename a workspace", async ({ page, directory, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession)
+
+  try {
+    const rename = `e2e workspace ${Date.now()}`
+    const menu = await openWorkspaceMenu(page, slug)
+    await clickMenuItem(menu, /^Rename$/i, { force: true })
+
+    await expect(menu).toHaveCount(0)
+
+    const item = page.locator(workspaceItemSelector(slug)).first()
+    await expect(item).toBeVisible()
+    const input = item.locator(inlineInputSelector).first()
+    await expect(input).toBeVisible()
+    await input.fill(rename)
+    await input.press("Enter")
+    await expect(item).toContainText(rename)
+  } finally {
+    await cleanupTestProject(project)
+  }
+})
+
+test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession)
+
+  try {
+    const readme = path.join(createdDir, "README.md")
+    const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
+    const original = await fs.readFile(readme, "utf8")
+    const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
+    await fs.writeFile(readme, dirty, "utf8")
+    await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
+
+    await expect
+      .poll(async () => {
+        return await fs
+          .stat(extra)
+          .then(() => true)
+          .catch(() => false)
+      })
+      .toBe(true)
+
+    await expect
+      .poll(async () => {
+        const files = await sdk.file
+          .status({ directory: createdDir })
+          .then((r) => r.data ?? [])
+          .catch(() => [])
+        return files.length
+      })
+      .toBeGreaterThan(0)
+
+    const menu = await openWorkspaceMenu(page, slug)
+    await clickMenuItem(menu, /^Reset$/i, { force: true })
+    await confirmDialog(page, /^Reset workspace$/i)
+
+    await expect
+      .poll(
+        async () => {
+          const files = await sdk.file
+            .status({ directory: createdDir })
+            .then((r) => r.data ?? [])
+            .catch(() => [])
+          return files.length
+        },
+        { timeout: 60_000 },
+      )
+      .toBe(0)
+
+    await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
+
+    await expect
+      .poll(async () => {
+        return await fs
+          .stat(extra)
+          .then(() => true)
+          .catch(() => false)
+      })
+      .toBe(false)
+  } finally {
+    await cleanupTestProject(project)
+  }
+})
+
+test("can delete a workspace", async ({ page, directory, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession)
+
+  try {
+    const menu = await openWorkspaceMenu(page, slug)
+    await clickMenuItem(menu, /^Delete$/i, { force: true })
+    await confirmDialog(page, /^Delete workspace$/i)
+
+    await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+    await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
+    await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
+  } finally {
+    await cleanupTestProject(project)
+  }
+})
+
+test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => {
+  await page.setViewportSize({ width: 1400, height: 800 })
+
+  const project = await createTestProject()
+  const rootSlug = dirSlug(project)
+  await seedProjects(page, { directory, extra: [project] })
+
+  const workspaces = [] as { directory: string; slug: string }[]
+
+  const listSlugs = async () => {
+    const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
+    const slugs = await nodes.evaluateAll((els) => {
+      return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
+    })
+    return slugs
+  }
+
+  const waitReady = async (slug: string) => {
+    await expect
+      .poll(
+        async () => {
+          const item = page.locator(workspaceItemSelector(slug)).first()
+          try {
+            await item.hover({ timeout: 500 })
+            return true
+          } catch {
+            return false
+          }
+        },
+        { timeout: 60_000 },
+      )
+      .toBe(true)
+  }
+
+  const drag = async (from: string, to: string) => {
+    const src = page.locator(workspaceItemSelector(from)).first()
+    const dst = page.locator(workspaceItemSelector(to)).first()
+
+    await src.scrollIntoViewIfNeeded()
+    await dst.scrollIntoViewIfNeeded()
+
+    const a = await src.boundingBox()
+    const b = await dst.boundingBox()
+    if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
+
+    await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
+    await page.mouse.down()
+    await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
+    await page.mouse.up()
+  }
+
+  try {
+    await gotoSession()
+    await openSidebar(page)
+
+    const target = page.locator(projectSwitchSelector(rootSlug)).first()
+    await expect(target).toBeVisible()
+    await target.click()
+    await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+
+    await openSidebar(page)
+    await setWorkspacesEnabled(page, rootSlug, true)
+
+    for (const _ of [0, 1]) {
+      const prev = slugFromUrl(page.url())
+      await page.getByRole("button", { name: "New workspace" }).first().click()
+      await expect
+        .poll(
+          () => {
+            const slug = slugFromUrl(page.url())
+            return slug.length > 0 && slug !== rootSlug && slug !== prev
+          },
+          { timeout: 45_000 },
+        )
+        .toBe(true)
+
+      const slug = slugFromUrl(page.url())
+      const dir = base64Decode(slug)
+      workspaces.push({ slug, directory: dir })
+
+      await openSidebar(page)
+    }
+
+    if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
+
+    const a = workspaces[0].slug
+    const b = workspaces[1].slug
+
+    await waitReady(a)
+    await waitReady(b)
+
+    const list = async () => {
+      const slugs = await listSlugs()
+      return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
+    }
+
+    await expect
+      .poll(async () => {
+        const slugs = await list()
+        return slugs.length === 2
+      })
+      .toBe(true)
+
+    const before = await list()
+    const from = before[1]
+    const to = before[0]
+    if (!from || !to) throw new Error("Failed to resolve initial workspace order")
+
+    await drag(from, to)
+
+    await expect.poll(async () => await list()).toEqual([from, to])
+  } finally {
+    await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
+    await cleanupTestProject(project)
+  }
+})

+ 9 - 0
packages/app/e2e/selectors.ts

@@ -27,6 +27,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
 
 export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
 
+export const projectWorkspacesToggleSelector = (slug: string) =>
+  `[data-action="project-workspaces-toggle"][data-project="${slug}"]`
+
 export const titlebarRightSelector = "#opencode-titlebar-right"
 
 export const popoverBodySelector = '[data-slot="popover-body"]'
@@ -39,6 +42,12 @@ export const inlineInputSelector = '[data-component="inline-input"]'
 
 export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
 
+export const workspaceItemSelector = (slug: string) =>
+  `${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
+
+export const workspaceMenuTriggerSelector = (slug: string) =>
+  `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
+
 export const listItemSelector = '[data-slot="list-item"]'
 
 export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`

+ 3 - 1
packages/app/playwright.config.ts

@@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
 const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
 const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
 const reuse = !process.env.CI
+const win = process.platform === "win32"
 
 export default defineConfig({
   testDir: "./e2e",
@@ -14,7 +15,8 @@ export default defineConfig({
   expect: {
     timeout: 10_000,
   },
-  fullyParallel: true,
+  fullyParallel: !win,
+  workers: win ? 1 : undefined,
   forbidOnly: !!process.env.CI,
   retries: process.env.CI ? 2 : 0,
   reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],

+ 4 - 3
packages/app/src/components/dialog-select-model.tsx

@@ -90,9 +90,10 @@ const ModelList: Component<{
 
 export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
   provider?: string
-  children?: JSX.Element
+  children?: JSX.Element | ((open: boolean) => JSX.Element)
   triggerAs?: T
   triggerProps?: ComponentProps<T>
+  gutter?: number
 }) {
   const [store, setStore] = createStore<{
     open: boolean
@@ -175,14 +176,14 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
       }}
       modal={false}
       placement="top-start"
-      gutter={8}
+      gutter={props.gutter ?? 8}
     >
       <Kobalte.Trigger
         ref={(el) => setStore("trigger", el)}
         as={props.triggerAs ?? "div"}
         {...(props.triggerProps as any)}
       >
-        {props.children}
+        {typeof props.children === "function" ? props.children(store.open) : props.children}
       </Kobalte.Trigger>
       <Kobalte.Portal>
         <Kobalte.Content

+ 93 - 46
packages/app/src/components/prompt-input.tsx

@@ -32,7 +32,9 @@ import { useNavigate, useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
 import { useComments } from "@/context/comments"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
 import { Button } from "@opencode-ai/ui/button"
+import { CycleLabel } from "@opencode-ai/ui/cycle-label"
 import { Icon } from "@opencode-ai/ui/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import type { IconName } from "@opencode-ai/ui/icons/provider"
@@ -42,6 +44,7 @@ import { Select } from "@opencode-ai/ui/select"
 import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
+import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
 import { ModelSelectorPopover } from "@/components/dialog-select-model"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
@@ -112,6 +115,7 @@ interface SlashCommand {
   description?: string
   keybind?: string
   type: "builtin" | "custom"
+  source?: "command" | "mcp" | "skill"
 }
 
 export const PromptInput: Component<PromptInputProps> = (props) => {
@@ -517,6 +521,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       title: cmd.name,
       description: cmd.description,
       type: "custom" as const,
+      source: cmd.source,
     }))
 
     return [...custom, ...builtin]
@@ -1252,7 +1257,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       clearInput()
       client.session
         .shell({
-          sessionID: session.id,
+          sessionID: session?.id || "",
           agent,
           model,
           command: text,
@@ -1275,7 +1280,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         clearInput()
         client.session
           .command({
-            sessionID: session.id,
+            sessionID: session?.id || "",
             command: commandName,
             arguments: args.join(" "),
             agent,
@@ -1431,13 +1436,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     const optimisticParts = requestParts.map((part) => ({
       ...part,
-      sessionID: session.id,
+      sessionID: session?.id || "",
       messageID,
     })) as unknown as Part[]
 
     const optimisticMessage: Message = {
       id: messageID,
-      sessionID: session.id,
+      sessionID: session?.id || "",
       role: "user",
       time: { created: Date.now() },
       agent,
@@ -1448,9 +1453,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       if (sessionDirectory === projectDirectory) {
         sync.set(
           produce((draft) => {
-            const messages = draft.message[session.id]
+            const messages = draft.message[session?.id || ""]
             if (!messages) {
-              draft.message[session.id] = [optimisticMessage]
+              draft.message[session?.id || ""] = [optimisticMessage]
             } else {
               const result = Binary.search(messages, messageID, (m) => m.id)
               messages.splice(result.index, 0, optimisticMessage)
@@ -1466,9 +1471,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
       globalSync.child(sessionDirectory)[1](
         produce((draft) => {
-          const messages = draft.message[session.id]
+          const messages = draft.message[session?.id || ""]
           if (!messages) {
-            draft.message[session.id] = [optimisticMessage]
+            draft.message[session?.id || ""] = [optimisticMessage]
           } else {
             const result = Binary.search(messages, messageID, (m) => m.id)
             messages.splice(result.index, 0, optimisticMessage)
@@ -1485,7 +1490,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       if (sessionDirectory === projectDirectory) {
         sync.set(
           produce((draft) => {
-            const messages = draft.message[session.id]
+            const messages = draft.message[session?.id || ""]
             if (messages) {
               const result = Binary.search(messages, messageID, (m) => m.id)
               if (result.found) messages.splice(result.index, 1)
@@ -1498,7 +1503,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
       globalSync.child(sessionDirectory)[1](
         produce((draft) => {
-          const messages = draft.message[session.id]
+          const messages = draft.message[session?.id || ""]
           if (messages) {
             const result = Binary.search(messages, messageID, (m) => m.id)
             if (result.found) messages.splice(result.index, 1)
@@ -1519,15 +1524,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const worktree = WorktreeState.get(sessionDirectory)
       if (!worktree || worktree.status !== "pending") return true
 
-      if (sessionDirectory === projectDirectory) {
-        sync.set("session_status", session.id, { type: "busy" })
+      if (sessionDirectory === projectDirectory && session?.id) {
+        sync.set("session_status", session?.id, { type: "busy" })
       }
 
       const controller = new AbortController()
 
       const cleanup = () => {
-        if (sessionDirectory === projectDirectory) {
-          sync.set("session_status", session.id, { type: "idle" })
+        if (sessionDirectory === projectDirectory && session?.id) {
+          sync.set("session_status", session?.id, { type: "idle" })
         }
         removeOptimisticMessage()
         for (const item of commentItems) {
@@ -1544,7 +1549,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         restoreInput()
       }
 
-      pending.set(session.id, { abort: controller, cleanup })
+      pending.set(session?.id || "", { abort: controller, cleanup })
 
       const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
         if (controller.signal.aborted) {
@@ -1572,7 +1577,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         if (timer.id === undefined) return
         clearTimeout(timer.id)
       })
-      pending.delete(session.id)
+      pending.delete(session?.id || "")
       if (controller.signal.aborted) return false
       if (result.status === "failed") throw new Error(result.message)
       return true
@@ -1582,7 +1587,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const ok = await waitForWorktree()
       if (!ok) return
       await client.session.prompt({
-        sessionID: session.id,
+        sessionID: session?.id || "",
         agent,
         model,
         messageID,
@@ -1592,9 +1597,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
 
     void send().catch((err) => {
-      pending.delete(session.id)
-      if (sessionDirectory === projectDirectory) {
-        sync.set("session_status", session.id, { type: "idle" })
+      pending.delete(session?.id || "")
+      if (sessionDirectory === projectDirectory && session?.id) {
+        sync.set("session_status", session?.id, { type: "idle" })
       }
       showToast({
         title: language.t("prompt.toast.promptSendFailed.title"),
@@ -1616,6 +1621,28 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     })
   }
 
+  const currrentModelVariant = createMemo(() => {
+    const modelVariant = local.model.variant.current() ?? ""
+    return modelVariant === "xhigh"
+      ? "xHigh"
+      : modelVariant.length > 0
+        ? modelVariant[0].toUpperCase() + modelVariant.slice(1)
+        : "Default"
+  })
+
+  const reasoningPercentage = createMemo(() => {
+    const variants = local.model.variant.list()
+    const current = local.model.variant.current()
+    const totalEntries = variants.length + 1
+
+    if (totalEntries <= 2 || current === "Default") {
+      return 0
+    }
+
+    const currentIndex = current ? variants.indexOf(current) + 1 : 0
+    return ((currentIndex + 1) / totalEntries) * 100
+  }, [local.model.variant])
+
   return (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
       <Show when={store.popover}>
@@ -1668,7 +1695,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                           </>
                         }
                       >
-                        <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
+                        <Icon name="brain" size="normal" class="text-icon-info-active shrink-0" />
                         <span class="text-14-regular text-text-strong whitespace-nowrap">
                           @{(item as { type: "agent"; name: string }).name}
                         </span>
@@ -1701,9 +1728,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                         </Show>
                       </div>
                       <div class="flex items-center gap-2 shrink-0">
-                        <Show when={cmd.type === "custom"}>
+                        <Show when={cmd.type === "custom" && cmd.source !== "command"}>
                           <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
-                            {language.t("prompt.slash.badge.custom")}
+                            {cmd.source === "skill"
+                              ? language.t("prompt.slash.badge.skill")
+                              : cmd.source === "mcp"
+                                ? language.t("prompt.slash.badge.mcp")
+                                : language.t("prompt.slash.badge.custom")}
                           </span>
                         </Show>
                         <Show when={command.keybind(cmd.id)}>
@@ -1729,9 +1760,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         }}
       >
         <Show when={store.dragging}>
-          <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
+          <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 mr-1 pointer-events-none">
             <div class="flex flex-col items-center gap-2 text-text-weak">
-              <Icon name="photo" class="size-8" />
+              <Icon name="photo" size={18} class="text-icon-base stroke-1.5" />
               <span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
             </div>
           </div>
@@ -1770,7 +1801,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       }}
                     >
                       <div class="flex items-center gap-1.5">
-                        <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
+                        <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-7" />
                         <div class="flex items-center text-11-regular min-w-0 font-medium">
                           <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
                           <Show when={item.selection}>
@@ -1787,7 +1818,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                           type="button"
                           icon="close-small"
                           variant="ghost"
-                          class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all"
+                          class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all"
                           onClick={(e) => {
                             e.stopPropagation()
                             if (item.commentID) comments.remove(item.path, item.commentID)
@@ -1817,7 +1848,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     when={attachment.mime.startsWith("image/")}
                     fallback={
                       <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
-                        <Icon name="folder" class="size-6 text-text-weak" />
+                        <Icon name="folder" size="normal" class="size-6 text-text-base" />
                       </div>
                     }
                   >
@@ -1891,7 +1922,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           </Show>
         </div>
         <div class="relative p-3 flex items-center justify-between">
-          <div class="flex items-center justify-start gap-0.5">
+          <div class="flex items-center justify-start gap-2">
             <Switch>
               <Match when={store.mode === "shell"}>
                 <div class="flex items-center gap-2 px-2 h-6">
@@ -1912,6 +1943,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     onSelect={local.agent.set}
                     class="capitalize"
                     variant="ghost"
+                    gutter={12}
                   />
                 </TooltipKeybind>
                 <Show
@@ -1922,12 +1954,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       title={language.t("command.model.choose")}
                       keybind={command.keybind("model.choose")}
                     >
-                      <Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
+                      <Button
+                        as="div"
+                        variant="ghost"
+                        class="px-2"
+                        onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
+                      >
                         <Show when={local.model.current()?.provider?.id}>
                           <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
                         </Show>
                         {local.model.current()?.name ?? language.t("dialog.model.select.title")}
-                        <Icon name="chevron-down" size="small" />
+                        <MorphChevron
+                          expanded={!!dialog.active?.id && dialog.active.id.startsWith("select-model-unpaid")}
+                        />
                       </Button>
                     </TooltipKeybind>
                   }
@@ -1937,12 +1976,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     title={language.t("command.model.choose")}
                     keybind={command.keybind("model.choose")}
                   >
-                    <ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }}>
-                      <Show when={local.model.current()?.provider?.id}>
-                        <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
-                      </Show>
-                      {local.model.current()?.name ?? language.t("dialog.model.select.title")}
-                      <Icon name="chevron-down" size="small" />
+                    <ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }} gutter={12}>
+                      {(open) => (
+                        <>
+                          <Show when={local.model.current()?.provider?.id}>
+                            <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
+                          </Show>
+                          {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+                          <MorphChevron expanded={open} class="text-text-weak" />
+                        </>
+                      )}
                     </ModelSelectorPopover>
                   </TooltipKeybind>
                 </Show>
@@ -1955,10 +1998,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     <Button
                       data-action="model-variant-cycle"
                       variant="ghost"
-                      class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
+                      class="text-text-strong text-12-regular"
                       onClick={() => local.model.variant.cycle()}
                     >
-                      {local.model.variant.current() ?? language.t("common.default")}
+                      <Show when={local.model.variant.list().length > 1}>
+                        <ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
+                      </Show>
+                      <CycleLabel value={currrentModelVariant()} />
                     </Button>
                   </TooltipKeybind>
                 </Show>
@@ -1972,7 +2018,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       variant="ghost"
                       onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
                       classList={{
-                        "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
+                        "_hidden group-hover/prompt-input:flex items-center justify-center": true,
                         "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
                         "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
                       }}
@@ -1994,7 +2040,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               </Match>
             </Switch>
           </div>
-          <div class="flex items-center gap-3 absolute right-3 bottom-3">
+          <div class="flex items-center gap-1 absolute right-3 bottom-3">
             <input
               ref={fileInputRef}
               type="file"
@@ -2006,18 +2052,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 e.currentTarget.value = ""
               }}
             />
-            <div class="flex items-center gap-2">
+            <div class="flex items-center gap-1.5 mr-1.5">
               <SessionContextUsage />
               <Show when={store.mode === "normal"}>
                 <Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
                   <Button
                     type="button"
                     variant="ghost"
-                    class="size-6"
+                    size="small"
+                    class="px-1"
                     onClick={() => fileInputRef.click()}
                     aria-label={language.t("prompt.action.attachFile")}
                   >
-                    <Icon name="photo" class="size-4.5" />
+                    <Icon name="photo" class="size-6 text-icon-base" />
                   </Button>
                 </Tooltip>
               </Show>
@@ -2036,7 +2083,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   <Match when={true}>
                     <div class="flex items-center gap-2">
                       <span>{language.t("prompt.action.send")}</span>
-                      <Icon name="enter" size="small" class="text-icon-base" />
+                      <Icon name="enter" size="normal" class="text-icon-base" />
                     </div>
                   </Match>
                 </Switch>
@@ -2047,7 +2094,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 disabled={!prompt.dirty() && !working()}
                 icon={working() ? "stop" : "arrow-up"}
                 variant="primary"
-                class="h-6 w-4.5"
+                class="h-6 w-5.5"
                 aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
               />
             </Tooltip>

+ 9 - 3
packages/app/src/components/settings-general.tsx

@@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select"
 import { Switch } from "@opencode-ai/ui/switch"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
 import { showToast } from "@opencode-ai/ui/toast"
+import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
 import { useLanguage } from "@/context/language"
 import { usePlatform } from "@/context/platform"
 import { useSettings, monoFontFamily } from "@/context/settings"
@@ -130,7 +131,12 @@ export const SettingsGeneral: Component = () => {
   const soundOptions = [...SOUND_OPTIONS]
 
   return (
-    <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
+    <ScrollFade
+      direction="vertical"
+      fadeStartSize={0}
+      fadeEndSize={16}
+      class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
+    >
       <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
         <div class="flex flex-col gap-1 pt-6 pb-8">
           <h2 class="text-16-medium text-text-strong">{language.t("settings.tab.general")}</h2>
@@ -226,7 +232,7 @@ export const SettingsGeneral: Component = () => {
                 variant="secondary"
                 size="small"
                 triggerVariant="settings"
-                triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
+                triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }}
               >
                 {(option) => (
                   <span style={{ "font-family": monoFontFamily(option?.value) }}>
@@ -411,7 +417,7 @@ export const SettingsGeneral: Component = () => {
           </div>
         </div>
       </div>
-    </div>
+    </ScrollFade>
   )
 }
 

+ 8 - 2
packages/app/src/components/settings-keybinds.tsx

@@ -5,6 +5,7 @@ import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
+import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
 import fuzzysort from "fuzzysort"
 import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
 import { useLanguage } from "@/context/language"
@@ -352,7 +353,12 @@ export const SettingsKeybinds: Component = () => {
   })
 
   return (
-    <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
+    <ScrollFade
+      direction="vertical"
+      fadeStartSize={0}
+      fadeEndSize={16}
+      class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
+    >
       <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
         <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
           <div class="flex items-center justify-between gap-4">
@@ -430,6 +436,6 @@ export const SettingsKeybinds: Component = () => {
           </div>
         </Show>
       </div>
-    </div>
+    </ScrollFade>
   )
 }

+ 8 - 2
packages/app/src/components/settings-models.tsx

@@ -9,6 +9,7 @@ import { type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { useModels } from "@/context/models"
 import { popularProviders } from "@/hooks/use-providers"
+import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
 
 type ModelItem = ReturnType<ReturnType<typeof useModels>["list"]>[number]
 
@@ -39,7 +40,12 @@ export const SettingsModels: Component = () => {
   })
 
   return (
-    <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
+    <ScrollFade
+      direction="vertical"
+      fadeStartSize={0}
+      fadeEndSize={16}
+      class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
+    >
       <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
         <div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
           <h2 class="text-16-medium text-text-strong">{language.t("settings.models.title")}</h2>
@@ -125,6 +131,6 @@ export const SettingsModels: Component = () => {
           </Show>
         </Show>
       </div>
-    </div>
+    </ScrollFade>
   )
 }

+ 8 - 2
packages/app/src/components/settings-providers.tsx

@@ -12,6 +12,7 @@ import { useGlobalSync } from "@/context/global-sync"
 import { DialogConnectProvider } from "./dialog-connect-provider"
 import { DialogSelectProvider } from "./dialog-select-provider"
 import { DialogCustomProvider } from "./dialog-custom-provider"
+import { ScrollFade } from "@opencode-ai/ui/scroll-fade"
 
 type ProviderSource = "env" | "api" | "config" | "custom"
 type ProviderMeta = { source?: ProviderSource }
@@ -115,7 +116,12 @@ export const SettingsProviders: Component = () => {
   }
 
   return (
-    <div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
+    <ScrollFade
+      direction="vertical"
+      fadeStartSize={0}
+      fadeEndSize={16}
+      class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10"
+    >
       <div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-raised-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
         <div class="flex flex-col gap-1 pt-6 pb-8 max-w-[720px]">
           <h2 class="text-16-medium text-text-strong">{language.t("settings.providers.title")}</h2>
@@ -261,6 +267,6 @@ export const SettingsProviders: Component = () => {
           </Button>
         </div>
       </div>
-    </div>
+    </ScrollFade>
   )
 }

+ 3 - 0
packages/app/src/i18n/ar.ts

@@ -210,6 +210,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة",
   "prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا",
   "prompt.slash.badge.custom": "مخصص",
+  "prompt.slash.badge.skill": "مهارة",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "نشط",
   "prompt.context.includeActiveFile": "تضمين الملف النشط",
   "prompt.context.removeActiveFile": "إزالة الملف النشط من السياق",
@@ -432,6 +434,7 @@ export const dict = {
   "session.review.noChanges": "لا توجد تغييرات",
   "session.files.selectToOpen": "اختر ملفًا لفتحه",
   "session.files.all": "كل الملفات",
+  "session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
   "session.messages.renderEarlier": "عرض الرسائل السابقة",
   "session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
   "session.messages.loadEarlier": "تحميل الرسائل السابقة",

+ 3 - 0
packages/app/src/i18n/br.ts

@@ -210,6 +210,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "Nenhum comando correspondente",
   "prompt.dropzone.label": "Solte imagens ou PDFs aqui",
   "prompt.slash.badge.custom": "personalizado",
+  "prompt.slash.badge.skill": "skill",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "ativo",
   "prompt.context.includeActiveFile": "Incluir arquivo ativo",
   "prompt.context.removeActiveFile": "Remover arquivo ativo do contexto",
@@ -433,6 +435,7 @@ export const dict = {
   "session.review.noChanges": "Sem alterações",
   "session.files.selectToOpen": "Selecione um arquivo para abrir",
   "session.files.all": "Todos os arquivos",
+  "session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
   "session.messages.renderEarlier": "Renderizar mensagens anteriores",
   "session.messages.loadingEarlier": "Carregando mensagens anteriores...",
   "session.messages.loadEarlier": "Carregar mensagens anteriores",

+ 3 - 0
packages/app/src/i18n/da.ts

@@ -210,6 +210,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "Ingen matchende kommandoer",
   "prompt.dropzone.label": "Slip billeder eller PDF'er her",
   "prompt.slash.badge.custom": "brugerdefineret",
+  "prompt.slash.badge.skill": "skill",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "aktiv",
   "prompt.context.includeActiveFile": "Inkluder aktiv fil",
   "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
@@ -434,6 +436,7 @@ export const dict = {
   "session.review.noChanges": "Ingen ændringer",
   "session.files.selectToOpen": "Vælg en fil at åbne",
   "session.files.all": "Alle filer",
+  "session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
   "session.messages.renderEarlier": "Vis tidligere beskeder",
   "session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
   "session.messages.loadEarlier": "Indlæs tidligere beskeder",

+ 3 - 0
packages/app/src/i18n/de.ts

@@ -214,6 +214,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "Keine passenden Befehle",
   "prompt.dropzone.label": "Bilder oder PDFs hier ablegen",
   "prompt.slash.badge.custom": "benutzerdefiniert",
+  "prompt.slash.badge.skill": "skill",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "aktiv",
   "prompt.context.includeActiveFile": "Aktive Datei einbeziehen",
   "prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen",
@@ -442,6 +444,7 @@ export const dict = {
   "session.review.noChanges": "Keine Änderungen",
   "session.files.selectToOpen": "Datei zum Öffnen auswählen",
   "session.files.all": "Alle Dateien",
+  "session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)",
   "session.messages.renderEarlier": "Frühere Nachrichten rendern",
   "session.messages.loadingEarlier": "Lade frühere Nachrichten...",
   "session.messages.loadEarlier": "Frühere Nachrichten laden",

+ 3 - 0
packages/app/src/i18n/en.ts

@@ -216,6 +216,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "No matching commands",
   "prompt.dropzone.label": "Drop images or PDFs here",
   "prompt.slash.badge.custom": "custom",
+  "prompt.slash.badge.skill": "skill",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "active",
   "prompt.context.includeActiveFile": "Include active file",
   "prompt.context.removeActiveFile": "Remove active file from context",
@@ -441,6 +443,7 @@ export const dict = {
 
   "session.files.selectToOpen": "Select a file to open",
   "session.files.all": "All files",
+  "session.files.binaryContent": "Binary file (content cannot be displayed)",
 
   "session.messages.renderEarlier": "Render earlier messages",
   "session.messages.loadingEarlier": "Loading earlier messages...",

+ 3 - 0
packages/app/src/i18n/es.ts

@@ -210,6 +210,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "Sin comandos coincidentes",
   "prompt.dropzone.label": "Suelta imágenes o PDFs aquí",
   "prompt.slash.badge.custom": "personalizado",
+  "prompt.slash.badge.skill": "skill",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "activo",
   "prompt.context.includeActiveFile": "Incluir archivo activo",
   "prompt.context.removeActiveFile": "Eliminar archivo activo del contexto",
@@ -436,6 +438,7 @@ export const dict = {
   "session.review.noChanges": "Sin cambios",
   "session.files.selectToOpen": "Selecciona un archivo para abrir",
   "session.files.all": "Todos los archivos",
+  "session.files.binaryContent": "Archivo binario (el contenido no puede ser mostrado)",
   "session.messages.renderEarlier": "Renderizar mensajes anteriores",
   "session.messages.loadingEarlier": "Cargando mensajes anteriores...",
   "session.messages.loadEarlier": "Cargar mensajes anteriores",

+ 3 - 0
packages/app/src/i18n/fr.ts

@@ -210,6 +210,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "Aucune commande correspondante",
   "prompt.dropzone.label": "Déposez des images ou des PDF ici",
   "prompt.slash.badge.custom": "personnalisé",
+  "prompt.slash.badge.skill": "skill",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "actif",
   "prompt.context.includeActiveFile": "Inclure le fichier actif",
   "prompt.context.removeActiveFile": "Retirer le fichier actif du contexte",
@@ -441,6 +443,7 @@ export const dict = {
   "session.review.noChanges": "Aucune modification",
   "session.files.selectToOpen": "Sélectionnez un fichier à ouvrir",
   "session.files.all": "Tous les fichiers",
+  "session.files.binaryContent": "Fichier binaire (le contenu ne peut pas être affiché)",
   "session.messages.renderEarlier": "Afficher les messages précédents",
   "session.messages.loadingEarlier": "Chargement des messages précédents...",
   "session.messages.loadEarlier": "Charger les messages précédents",

+ 3 - 0
packages/app/src/i18n/ja.ts

@@ -209,6 +209,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "一致するコマンドがありません",
   "prompt.dropzone.label": "画像またはPDFをここにドロップ",
   "prompt.slash.badge.custom": "カスタム",
+  "prompt.slash.badge.skill": "スキル",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "アクティブ",
   "prompt.context.includeActiveFile": "アクティブなファイルを含める",
   "prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除",
@@ -433,6 +435,7 @@ export const dict = {
   "session.review.noChanges": "変更なし",
   "session.files.selectToOpen": "開くファイルを選択",
   "session.files.all": "すべてのファイル",
+  "session.files.binaryContent": "バイナリファイル(内容を表示できません)",
   "session.messages.renderEarlier": "以前のメッセージを表示",
   "session.messages.loadingEarlier": "以前のメッセージを読み込み中...",
   "session.messages.loadEarlier": "以前のメッセージを読み込む",

+ 3 - 0
packages/app/src/i18n/ko.ts

@@ -213,6 +213,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "일치하는 명령어 없음",
   "prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요",
   "prompt.slash.badge.custom": "사용자 지정",
+  "prompt.slash.badge.skill": "스킬",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "활성",
   "prompt.context.includeActiveFile": "활성 파일 포함",
   "prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거",
@@ -435,6 +437,7 @@ export const dict = {
   "session.review.noChanges": "변경 없음",
   "session.files.selectToOpen": "열 파일을 선택하세요",
   "session.files.all": "모든 파일",
+  "session.files.binaryContent": "바이너리 파일 (내용을 표시할 수 없음)",
   "session.messages.renderEarlier": "이전 메시지 렌더링",
   "session.messages.loadingEarlier": "이전 메시지 로드 중...",
   "session.messages.loadEarlier": "이전 메시지 로드",

+ 3 - 0
packages/app/src/i18n/no.ts

@@ -213,6 +213,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "Ingen matchende kommandoer",
   "prompt.dropzone.label": "Slipp bilder eller PDF-er her",
   "prompt.slash.badge.custom": "egendefinert",
+  "prompt.slash.badge.skill": "skill",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "aktiv",
   "prompt.context.includeActiveFile": "Inkluder aktiv fil",
   "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst",
@@ -436,6 +438,7 @@ export const dict = {
   "session.review.noChanges": "Ingen endringer",
   "session.files.selectToOpen": "Velg en fil å åpne",
   "session.files.all": "Alle filer",
+  "session.files.binaryContent": "Binær fil (innhold kan ikke vises)",
   "session.messages.renderEarlier": "Vis tidligere meldinger",
   "session.messages.loadingEarlier": "Laster inn tidligere meldinger...",
   "session.messages.loadEarlier": "Last inn tidligere meldinger",

+ 3 - 0
packages/app/src/i18n/pl.ts

@@ -210,6 +210,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "Brak pasujących poleceń",
   "prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj",
   "prompt.slash.badge.custom": "własne",
+  "prompt.slash.badge.skill": "skill",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "aktywny",
   "prompt.context.includeActiveFile": "Dołącz aktywny plik",
   "prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu",
@@ -435,6 +437,7 @@ export const dict = {
   "session.review.noChanges": "Brak zmian",
   "session.files.selectToOpen": "Wybierz plik do otwarcia",
   "session.files.all": "Wszystkie pliki",
+  "session.files.binaryContent": "Plik binarny (zawartość nie może być wyświetlona)",
   "session.messages.renderEarlier": "Renderuj wcześniejsze wiadomości",
   "session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...",
   "session.messages.loadEarlier": "Załaduj wcześniejsze wiadomości",

+ 3 - 0
packages/app/src/i18n/ru.ts

@@ -210,6 +210,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "Нет совпадающих команд",
   "prompt.dropzone.label": "Перетащите изображения или PDF сюда",
   "prompt.slash.badge.custom": "своё",
+  "prompt.slash.badge.skill": "навык",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "активно",
   "prompt.context.includeActiveFile": "Включить активный файл",
   "prompt.context.removeActiveFile": "Удалить активный файл из контекста",
@@ -437,6 +439,7 @@ export const dict = {
   "session.review.noChanges": "Нет изменений",
   "session.files.selectToOpen": "Выберите файл, чтобы открыть",
   "session.files.all": "Все файлы",
+  "session.files.binaryContent": "Двоичный файл (содержимое не может быть отображено)",
   "session.messages.renderEarlier": "Показать предыдущие сообщения",
   "session.messages.loadingEarlier": "Загрузка предыдущих сообщений...",
   "session.messages.loadEarlier": "Загрузить предыдущие сообщения",

+ 17 - 14
packages/app/src/i18n/th.ts

@@ -215,6 +215,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน",
   "prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่",
   "prompt.slash.badge.custom": "กำหนดเอง",
+  "prompt.slash.badge.skill": "skill",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "ใช้งานอยู่",
   "prompt.context.includeActiveFile": "รวมไฟล์ที่ใช้งานอยู่",
   "prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท",
@@ -322,20 +324,20 @@ export const dict = {
   "context.usage.clickToView": "คลิกเพื่อดูบริบท",
   "context.usage.view": "ดูการใช้บริบท",
 
-  "language.en": "อังกฤษ",
-  "language.zh": "จีนตัวย่อ",
-  "language.zht": "จีนตัวเต็ม",
-  "language.ko": "เกาหลี",
-  "language.de": "เยอรมัน",
-  "language.es": "สเปน",
-  "language.fr": "ฝรั่งเศส",
-  "language.da": "เดนมาร์ก",
-  "language.ja": "ญี่ปุ่น",
-  "language.pl": "โปแลนด์",
-  "language.ru": "รัสเซีย",
-  "language.ar": "อาหรับ",
-  "language.no": "นอร์เวย์",
-  "language.br": "โปรตุเกส (บราซิล)",
+  "language.en": "English",
+  "language.zh": "简体中文",
+  "language.zht": "繁體中文",
+  "language.ko": "한국어",
+  "language.de": "Deutsch",
+  "language.es": "Español",
+  "language.fr": "Français",
+  "language.da": "Dansk",
+  "language.ja": "日本語",
+  "language.pl": "Polski",
+  "language.ru": "Русский",
+  "language.ar": "العربية",
+  "language.no": "Norsk",
+  "language.br": "Português (Brasil)",
   "language.th": "ไทย",
 
   "toast.language.title": "ภาษา",
@@ -438,6 +440,7 @@ export const dict = {
 
   "session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด",
   "session.files.all": "ไฟล์ทั้งหมด",
+  "session.files.binaryContent": "ไฟล์ไบนารี (ไม่สามารถแสดงเนื้อหาได้)",
 
   "session.messages.renderEarlier": "แสดงข้อความก่อนหน้า",
   "session.messages.loadingEarlier": "กำลังโหลดข้อความก่อนหน้า...",

+ 3 - 0
packages/app/src/i18n/zh.ts

@@ -214,6 +214,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "没有匹配的命令",
   "prompt.dropzone.label": "将图片或 PDF 拖到这里",
   "prompt.slash.badge.custom": "自定义",
+  "prompt.slash.badge.skill": "技能",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "当前",
   "prompt.context.includeActiveFile": "包含当前文件",
   "prompt.context.removeActiveFile": "从上下文移除活动文件",
@@ -434,6 +436,7 @@ export const dict = {
   "session.review.noChanges": "无更改",
   "session.files.selectToOpen": "选择要打开的文件",
   "session.files.all": "所有文件",
+  "session.files.binaryContent": "二进制文件(无法显示内容)",
   "session.messages.renderEarlier": "显示更早的消息",
   "session.messages.loadingEarlier": "正在加载更早的消息...",
   "session.messages.loadEarlier": "加载更早的消息",

+ 3 - 0
packages/app/src/i18n/zht.ts

@@ -211,6 +211,8 @@ export const dict = {
   "prompt.popover.emptyCommands": "沒有符合的命令",
   "prompt.dropzone.label": "將圖片或 PDF 拖到這裡",
   "prompt.slash.badge.custom": "自訂",
+  "prompt.slash.badge.skill": "技能",
+  "prompt.slash.badge.mcp": "mcp",
   "prompt.context.active": "作用中",
   "prompt.context.includeActiveFile": "包含作用中檔案",
   "prompt.context.removeActiveFile": "從上下文移除目前檔案",
@@ -431,6 +433,7 @@ export const dict = {
   "session.review.noChanges": "沒有變更",
   "session.files.selectToOpen": "選取要開啟的檔案",
   "session.files.all": "所有檔案",
+  "session.files.binaryContent": "二進位檔案(無法顯示內容)",
   "session.messages.renderEarlier": "顯示更早的訊息",
   "session.messages.loadingEarlier": "正在載入更早的訊息...",
   "session.messages.loadEarlier": "載入更早的訊息",

+ 14 - 2
packages/app/src/pages/layout.tsx

@@ -2114,12 +2114,20 @@ export default function Layout(props: ParentProps) {
       >
         <Collapsible variant="ghost" open={open()} class="shrink-0" onOpenChange={openWrapper}>
           <div class="px-2 py-1">
-            <div class="group/workspace relative">
+            <div
+              class="group/workspace relative"
+              data-component="workspace-item"
+              data-workspace={base64Encode(props.directory)}
+            >
               <div class="flex items-center gap-1">
                 <Show
                   when={workspaceEditActive()}
                   fallback={
-                    <Collapsible.Trigger class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover">
+                    <Collapsible.Trigger
+                      class="flex items-center justify-between w-full pl-2 pr-16 py-1.5 rounded-md hover:bg-surface-raised-base-hover"
+                      data-action="workspace-toggle"
+                      data-workspace={base64Encode(props.directory)}
+                    >
                       {header()}
                     </Collapsible.Trigger>
                   }
@@ -2146,6 +2154,8 @@ export default function Layout(props: ParentProps) {
                         icon="dot-grid"
                         variant="ghost"
                         class="size-6 rounded-md"
+                        data-action="workspace-menu"
+                        data-workspace={base64Encode(props.directory)}
                         aria-label={language.t("common.moreOptions")}
                       />
                     </Tooltip>
@@ -2592,6 +2602,8 @@ export default function Layout(props: ParentProps) {
                           <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
                         <DropdownMenu.Item
+                          data-action="project-workspaces-toggle"
+                          data-project={base64Encode(p.worktree)}
                           disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
                           onSelect={() => {
                             const enabled = layout.sidebar.workspaces(p.worktree)()

+ 14 - 0
packages/app/src/pages/session.tsx

@@ -2342,6 +2342,7 @@ export default function Page() {
                             const c = state()?.content
                             return c?.mimeType === "image/svg+xml"
                           })
+                          const isBinary = createMemo(() => state()?.content?.type === "binary")
                           const svgContent = createMemo(() => {
                             if (!isSvg()) return
                             const c = state()?.content
@@ -2794,6 +2795,19 @@ export default function Page() {
                                     </Show>
                                   </div>
                                 </Match>
+                                <Match when={state()?.loaded && isBinary()}>
+                                  <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
+                                    <Mark class="w-14 opacity-10" />
+                                    <div class="flex flex-col gap-2 max-w-md">
+                                      <div class="text-14-semibold text-text-strong truncate">
+                                        {path()?.split("/").pop()}
+                                      </div>
+                                      <div class="text-14-regular text-text-weak">
+                                        {language.t("session.files.binaryContent")}
+                                      </div>
+                                    </div>
+                                  </div>
+                                </Match>
                                 <Match when={state()?.loaded}>{renderCode(contents(), "pb-40")}</Match>
                                 <Match when={state()?.loading}>
                                   <div class="px-6 py-4 text-text-weak">{language.t("common.loading")}...</div>

Разлика између датотеке није приказан због своје велике величине
+ 4 - 3
packages/console/app/src/routes/zen/index.tsx


+ 2 - 0
packages/opencode/src/agent/agent.ts

@@ -37,6 +37,7 @@ export namespace Agent {
           providerID: z.string(),
         })
         .optional(),
+      variant: z.string().optional(),
       prompt: z.string().optional(),
       options: z.record(z.string(), z.any()),
       steps: z.number().int().positive().optional(),
@@ -214,6 +215,7 @@ export namespace Agent {
           native: false,
         }
       if (value.model) item.model = Provider.parseModel(value.model)
+      item.variant = value.variant ?? item.variant
       item.prompt = value.prompt ?? item.prompt
       item.description = value.description ?? item.description
       item.temperature = value.temperature ?? item.temperature

+ 2 - 2
packages/opencode/src/cli/cmd/auth.ts

@@ -307,7 +307,7 @@ export const AuthLoginCommand = cmd({
 
         if (prompts.isCancel(provider)) throw new UI.CancelledError()
 
-        const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
+        const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
         if (plugin && plugin.auth) {
           const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
           if (handled) return
@@ -323,7 +323,7 @@ export const AuthLoginCommand = cmd({
           if (prompts.isCancel(provider)) throw new UI.CancelledError()
 
           // Check if a plugin provides auth for this custom provider
-          const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
+          const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
           if (customPlugin && customPlugin.auth) {
             const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
             if (handled) return

+ 34 - 0
packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx

@@ -0,0 +1,34 @@
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
+import { createResource, createMemo } from "solid-js"
+import { useDialog } from "@tui/ui/dialog"
+import { useSDK } from "@tui/context/sdk"
+
+export type DialogSkillProps = {
+  onSelect: (skill: string) => void
+}
+
+export function DialogSkill(props: DialogSkillProps) {
+  const dialog = useDialog()
+  const sdk = useSDK()
+
+  const [skills] = createResource(async () => {
+    const result = await sdk.client.app.skills()
+    return result.data ?? []
+  })
+
+  const options = createMemo<DialogSelectOption<string>[]>(() => {
+    const list = skills() ?? []
+    return list.map((skill) => ({
+      title: skill.name,
+      description: skill.description,
+      value: skill.name,
+      category: "Skills",
+      onSelect: () => {
+        props.onSelect(skill.name)
+        dialog.clear()
+      },
+    }))
+  })
+
+  return <DialogSelect title="Skills" placeholder="Search skills..." options={options()} />
+}

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

@@ -345,7 +345,8 @@ export function Autocomplete(props: {
     const results: AutocompleteOption[] = [...command.slashes()]
 
     for (const serverCommand of sync.data.command) {
-      const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : ""
+      if (serverCommand.source === "skill") continue
+      const label = serverCommand.source === "mcp" ? ":mcp" : ""
       results.push({
         display: "/" + serverCommand.name + label,
         description: serverCommand.description,

+ 23 - 0
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -31,6 +31,7 @@ import { DialogAlert } from "../../ui/dialog-alert"
 import { useToast } from "../../ui/toast"
 import { useKV } from "../../context/kv"
 import { useTextareaKeybindings } from "../textarea-keybindings"
+import { DialogSkill } from "../dialog-skill"
 
 export type PromptProps = {
   sessionID?: string
@@ -315,6 +316,28 @@ export function Prompt(props: PromptProps) {
           input.cursorOffset = Bun.stringWidth(content)
         },
       },
+      {
+        title: "Skills",
+        value: "prompt.skills",
+        category: "Prompt",
+        slash: {
+          name: "skills",
+        },
+        onSelect: () => {
+          dialog.replace(() => (
+            <DialogSkill
+              onSelect={(skill) => {
+                input.setText(`/${skill} `)
+                setStore("prompt", {
+                  input: `/${skill} `,
+                  parts: [],
+                })
+                input.gotoBufferEnd()
+              }}
+            />
+          ))
+        },
+      },
     ]
   })
 

+ 31 - 18
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -43,6 +43,7 @@ import type { ApplyPatchTool } from "@/tool/apply_patch"
 import type { WebFetchTool } from "@/tool/webfetch"
 import type { TaskTool } from "@/tool/task"
 import type { QuestionTool } from "@/tool/question"
+import type { SkillTool } from "@/tool/skill"
 import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
 import { useSDK } from "@tui/context/sdk"
 import { useCommandDialog } from "@tui/component/dialog-command"
@@ -1447,6 +1448,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
         <Match when={props.part.tool === "question"}>
           <Question {...toolprops} />
         </Match>
+        <Match when={props.part.tool === "skill"}>
+          <Skill {...toolprops} />
+        </Match>
         <Match when={true}>
           <GenericTool {...toolprops} />
         </Match>
@@ -1636,7 +1640,9 @@ function Bash(props: ToolProps<typeof BashTool>) {
         >
           <box gap={1}>
             <text fg={theme.text}>$ {props.input.command}</text>
-            <text fg={theme.text}>{limited()}</text>
+            <Show when={output()}>
+              <text fg={theme.text}>{limited()}</text>
+            </Show>
             <Show when={overflow()}>
               <text fg={theme.textMuted}>{expanded() ? "Click to collapse" : "Click to expand"}</text>
             </Show>
@@ -1701,7 +1707,9 @@ function Glob(props: ToolProps<typeof GlobTool>) {
   return (
     <InlineTool icon="✱" pending="Finding files..." complete={props.input.pattern} part={props.part}>
       Glob "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
-      <Show when={props.metadata.count}>({props.metadata.count} matches)</Show>
+      <Show when={props.metadata.count}>
+        ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
+      </Show>
     </InlineTool>
   )
 }
@@ -1737,7 +1745,9 @@ function Grep(props: ToolProps<typeof GrepTool>) {
   return (
     <InlineTool icon="✱" pending="Searching content..." complete={props.input.pattern} part={props.part}>
       Grep "{props.input.pattern}" <Show when={props.input.path}>in {normalizePath(props.input.path)} </Show>
-      <Show when={props.metadata.matches}>({props.metadata.matches} matches)</Show>
+      <Show when={props.metadata.matches}>
+        ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
+      </Show>
     </InlineTool>
   )
 }
@@ -1795,7 +1805,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
 
   return (
     <Switch>
-      <Match when={props.metadata.summary?.length}>
+      <Match when={props.input.description || props.input.subagent_type}>
         <BlockTool
           title={"# " + Locale.titlecase(props.input.subagent_type ?? "unknown") + " Task"}
           onClick={
@@ -1807,7 +1817,7 @@ function Task(props: ToolProps<typeof TaskTool>) {
         >
           <box>
             <text style={{ fg: theme.textMuted }}>
-              {props.input.description} ({props.metadata.summary?.length} toolcalls)
+              {props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls)
             </text>
             <Show when={current()}>
               <text style={{ fg: current()!.state.status === "error" ? theme.error : theme.textMuted }}>
@@ -1816,22 +1826,17 @@ function Task(props: ToolProps<typeof TaskTool>) {
               </text>
             </Show>
           </box>
-          <text fg={theme.text}>
-            {keybind.print("session_child_cycle")}
-            <span style={{ fg: theme.textMuted }}> view subagents</span>
-          </text>
+          <Show when={props.metadata.sessionId}>
+            <text fg={theme.text}>
+              {keybind.print("session_child_cycle")}
+              <span style={{ fg: theme.textMuted }}> view subagents</span>
+            </text>
+          </Show>
         </BlockTool>
       </Match>
       <Match when={true}>
-        <InlineTool
-          icon="◉"
-          iconColor={color()}
-          pending="Delegating..."
-          complete={props.input.subagent_type ?? props.input.description}
-          part={props.part}
-        >
-          <span style={{ fg: theme.text }}>{Locale.titlecase(props.input.subagent_type ?? "unknown")}</span> Task "
-          {props.input.description}"
+        <InlineTool icon="#" pending="Delegating..." complete={props.input.subagent_type} part={props.part}>
+          {props.input.subagent_type} Task {props.input.description}
         </InlineTool>
       </Match>
     </Switch>
@@ -2036,6 +2041,14 @@ function Question(props: ToolProps<typeof QuestionTool>) {
   )
 }
 
+function Skill(props: ToolProps<typeof SkillTool>) {
+  return (
+    <InlineTool icon="→" pending="Loading skill..." complete={props.input.name} part={props.part}>
+      Skill "{props.input.name}"
+    </InlineTool>
+  )
+}
+
 function normalizePath(input?: string) {
   if (!input) return ""
   if (path.isAbsolute(input)) {

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

@@ -228,7 +228,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           </text>
           <text fg={theme.textMuted}>esc</text>
         </box>
-        <box paddingTop={1} paddingBottom={1}>
+        <box paddingTop={1}>
           <input
             onInput={(e) => {
               batch(() => {

+ 5 - 5
packages/opencode/src/cli/cmd/tui/util/transcript.ts

@@ -80,17 +80,17 @@ export function formatPart(part: Part, options: TranscriptOptions): string {
   }
 
   if (part.type === "tool") {
-    let result = `\`\`\`\nTool: ${part.tool}\n`
+    let result = `**Tool: ${part.tool}**\n`
     if (options.toolDetails && part.state.input) {
-      result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
+      result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n`
     }
     if (options.toolDetails && part.state.status === "completed" && part.state.output) {
-      result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
+      result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n`
     }
     if (options.toolDetails && part.state.status === "error" && part.state.error) {
-      result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
+      result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n`
     }
-    result += `\n\`\`\`\n\n`
+    result += `\n`
     return result
   }
 

+ 3 - 0
packages/opencode/src/command/index.ts

@@ -63,6 +63,7 @@ export namespace Command {
       [Default.INIT]: {
         name: Default.INIT,
         description: "create/update AGENTS.md",
+        source: "command",
         get template() {
           return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
         },
@@ -71,6 +72,7 @@ export namespace Command {
       [Default.REVIEW]: {
         name: Default.REVIEW,
         description: "review changes [commit|branch|pr], defaults to uncommitted",
+        source: "command",
         get template() {
           return PROMPT_REVIEW.replace("${path}", Instance.worktree)
         },
@@ -85,6 +87,7 @@ export namespace Command {
         agent: command.agent,
         model: command.model,
         description: command.description,
+        source: "command",
         get template() {
           return command.template
         },

+ 5 - 0
packages/opencode/src/config/config.ts

@@ -593,6 +593,10 @@ export namespace Config {
   export const Agent = z
     .object({
       model: z.string().optional(),
+      variant: z
+        .string()
+        .optional()
+        .describe("Default model variant for this agent (applies only when using the agent's configured model)."),
       temperature: z.number().optional(),
       top_p: z.number().optional(),
       prompt: z.string().optional(),
@@ -624,6 +628,7 @@ export namespace Config {
       const knownKeys = new Set([
         "name",
         "model",
+        "variant",
         "prompt",
         "description",
         "temperature",

+ 190 - 22
packages/opencode/src/file/index.ts

@@ -44,7 +44,7 @@ export namespace File {
 
   export const Content = z
     .object({
-      type: z.literal("text"),
+      type: z.enum(["text", "binary"]),
       content: z.string(),
       diff: z.string().optional(),
       patch: z
@@ -73,6 +73,174 @@ export namespace File {
     })
   export type Content = z.infer<typeof Content>
 
+  const binaryExtensions = new Set([
+    "exe",
+    "dll",
+    "pdb",
+    "bin",
+    "so",
+    "dylib",
+    "o",
+    "a",
+    "lib",
+    "wav",
+    "mp3",
+    "ogg",
+    "oga",
+    "ogv",
+    "ogx",
+    "flac",
+    "aac",
+    "wma",
+    "m4a",
+    "weba",
+    "mp4",
+    "avi",
+    "mov",
+    "wmv",
+    "flv",
+    "webm",
+    "mkv",
+    "zip",
+    "tar",
+    "gz",
+    "gzip",
+    "bz",
+    "bz2",
+    "bzip",
+    "bzip2",
+    "7z",
+    "rar",
+    "xz",
+    "lz",
+    "z",
+    "pdf",
+    "doc",
+    "docx",
+    "ppt",
+    "pptx",
+    "xls",
+    "xlsx",
+    "dmg",
+    "iso",
+    "img",
+    "vmdk",
+    "ttf",
+    "otf",
+    "woff",
+    "woff2",
+    "eot",
+    "sqlite",
+    "db",
+    "mdb",
+    "apk",
+    "ipa",
+    "aab",
+    "xapk",
+    "app",
+    "pkg",
+    "deb",
+    "rpm",
+    "snap",
+    "flatpak",
+    "appimage",
+    "msi",
+    "msp",
+    "jar",
+    "war",
+    "ear",
+    "class",
+    "kotlin_module",
+    "dex",
+    "vdex",
+    "odex",
+    "oat",
+    "art",
+    "wasm",
+    "wat",
+    "bc",
+    "ll",
+    "s",
+    "ko",
+    "sys",
+    "drv",
+    "efi",
+    "rom",
+    "com",
+    "bat",
+    "cmd",
+    "ps1",
+    "sh",
+    "bash",
+    "zsh",
+    "fish",
+  ])
+
+  const imageExtensions = new Set([
+    "png",
+    "jpg",
+    "jpeg",
+    "gif",
+    "bmp",
+    "webp",
+    "ico",
+    "tif",
+    "tiff",
+    "svg",
+    "svgz",
+    "avif",
+    "apng",
+    "jxl",
+    "heic",
+    "heif",
+    "raw",
+    "cr2",
+    "nef",
+    "arw",
+    "dng",
+    "orf",
+    "raf",
+    "pef",
+    "x3f",
+  ])
+
+  function isImageByExtension(filepath: string): boolean {
+    const ext = path.extname(filepath).toLowerCase().slice(1)
+    return imageExtensions.has(ext)
+  }
+
+  function getImageMimeType(filepath: string): string {
+    const ext = path.extname(filepath).toLowerCase().slice(1)
+    const mimeTypes: Record<string, string> = {
+      png: "image/png",
+      jpg: "image/jpeg",
+      jpeg: "image/jpeg",
+      gif: "image/gif",
+      bmp: "image/bmp",
+      webp: "image/webp",
+      ico: "image/x-icon",
+      tif: "image/tiff",
+      tiff: "image/tiff",
+      svg: "image/svg+xml",
+      svgz: "image/svg+xml",
+      avif: "image/avif",
+      apng: "image/apng",
+      jxl: "image/jxl",
+      heic: "image/heic",
+      heif: "image/heif",
+    }
+    return mimeTypes[ext] || "image/" + ext
+  }
+
+  function isBinaryByExtension(filepath: string): boolean {
+    const ext = path.extname(filepath).toLowerCase().slice(1)
+    return binaryExtensions.has(ext)
+  }
+
+  function isImage(mimeType: string): boolean {
+    return mimeType.startsWith("image/")
+  }
+
   async function shouldEncode(file: BunFile): Promise<boolean> {
     const type = file.type?.toLowerCase()
     log.info("shouldEncode", { type })
@@ -83,30 +251,10 @@ export namespace File {
 
     const parts = type.split("/", 2)
     const top = parts[0]
-    const rest = parts[1] ?? ""
-    const sub = rest.split(";", 1)[0]
 
     const tops = ["image", "audio", "video", "font", "model", "multipart"]
     if (tops.includes(top)) return true
 
-    const bins = [
-      "zip",
-      "gzip",
-      "bzip",
-      "compressed",
-      "binary",
-      "pdf",
-      "msword",
-      "powerpoint",
-      "excel",
-      "ogg",
-      "exe",
-      "dmg",
-      "iso",
-      "rar",
-    ]
-    if (bins.some((mark) => sub.includes(mark))) return true
-
     return false
   }
 
@@ -287,6 +435,22 @@ export namespace File {
       throw new Error(`Access denied: path escapes project directory`)
     }
 
+    // Fast path: check extension before any filesystem operations
+    if (isImageByExtension(file)) {
+      const bunFile = Bun.file(full)
+      if (await bunFile.exists()) {
+        const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
+        const content = Buffer.from(buffer).toString("base64")
+        const mimeType = getImageMimeType(file)
+        return { type: "text", content, mimeType, encoding: "base64" }
+      }
+      return { type: "text", content: "" }
+    }
+
+    if (isBinaryByExtension(file)) {
+      return { type: "binary", content: "" }
+    }
+
     const bunFile = Bun.file(full)
 
     if (!(await bunFile.exists())) {
@@ -294,11 +458,15 @@ export namespace File {
     }
 
     const encode = await shouldEncode(bunFile)
+    const mimeType = bunFile.type || "application/octet-stream"
+
+    if (encode && !isImage(mimeType)) {
+      return { type: "binary", content: "", mimeType }
+    }
 
     if (encode) {
       const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
       const content = Buffer.from(buffer).toString("base64")
-      const mimeType = bunFile.type || "application/octet-stream"
       return { type: "text", content, mimeType, encoding: "base64" }
     }
 

+ 1 - 1
packages/opencode/src/file/ripgrep.ts

@@ -215,7 +215,7 @@ export namespace Ripgrep {
 
     const args = [await filepath(), "--files", "--glob=!.git/*"]
     if (input.follow) args.push("--follow")
-    if (input.hidden) args.push("--hidden")
+    if (input.hidden !== false) args.push("--hidden")
     if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
     if (input.glob) {
       for (const g of input.glob) {

+ 2 - 2
packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts

@@ -100,7 +100,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
               break
             }
             case "reasoning": {
-              reasoningText = part.text
+              if (part.text) reasoningText = part.text
               break
             }
             case "tool-call": {
@@ -122,7 +122,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
           role: "assistant",
           content: text || null,
           tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
-          reasoning_text: reasoningText,
+          reasoning_text: reasoningOpaque ? reasoningText : undefined,
           reasoning_opaque: reasoningOpaque,
           ...metadata,
         })

+ 17 - 2
packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts

@@ -219,7 +219,13 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
     // text content:
     const text = choice.message.content
     if (text != null && text.length > 0) {
-      content.push({ type: "text", text })
+      content.push({
+        type: "text",
+        text,
+        providerMetadata: choice.message.reasoning_opaque
+          ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
+          : undefined,
+      })
     }
 
     // reasoning content (Copilot uses reasoning_text):
@@ -243,6 +249,9 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
           toolCallId: toolCall.id ?? generateId(),
           toolName: toolCall.function.name,
           input: toolCall.function.arguments!,
+          providerMetadata: choice.message.reasoning_opaque
+            ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
+            : undefined,
         })
       }
     }
@@ -478,7 +487,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
               }
 
               if (!isActiveText) {
-                controller.enqueue({ type: "text-start", id: "txt-0" })
+                controller.enqueue({
+                  type: "text-start",
+                  id: "txt-0",
+                  providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
+                })
                 isActiveText = true
               }
 
@@ -559,6 +572,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
                         toolCallId: toolCall.id ?? generateId(),
                         toolName: toolCall.function.name,
                         input: toolCall.function.arguments,
+                        providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
                       })
                       toolCall.hasFinished = true
                     }
@@ -601,6 +615,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
                     toolCallId: toolCall.id ?? generateId(),
                     toolName: toolCall.function.name,
                     input: toolCall.function.arguments,
+                    providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
                   })
                   toolCall.hasFinished = true
                 }

+ 5 - 41
packages/opencode/src/provider/transform.ts

@@ -179,7 +179,7 @@ export namespace ProviderTransform {
         cacheControl: { type: "ephemeral" },
       },
       bedrock: {
-        cachePoint: { type: "ephemeral" },
+        cachePoint: { type: "default" },
       },
       openaiCompatible: {
         cache_control: { type: "ephemeral" },
@@ -190,7 +190,8 @@ export namespace ProviderTransform {
     }
 
     for (const msg of unique([...system, ...final])) {
-      const shouldUseContentOptions = providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0
+      const useMessageLevelOptions = providerID === "anthropic" || providerID.includes("bedrock")
+      const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
 
       if (shouldUseContentOptions) {
         const lastContent = msg.content[msg.content.length - 1]
@@ -394,31 +395,6 @@ export namespace ProviderTransform {
       case "@ai-sdk/deepinfra":
       // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra
       case "@ai-sdk/openai-compatible":
-        // When using openai-compatible SDK with Claude/Anthropic models,
-        // we must use snake_case (budget_tokens) as the SDK doesn't convert parameter names
-        // and the OpenAI-compatible API spec uses snake_case
-        if (
-          model.providerID === "anthropic" ||
-          model.api.id.includes("anthropic") ||
-          model.api.id.includes("claude") ||
-          model.id.includes("anthropic") ||
-          model.id.includes("claude")
-        ) {
-          return {
-            high: {
-              thinking: {
-                type: "enabled",
-                budget_tokens: 16000,
-              },
-            },
-            max: {
-              thinking: {
-                type: "enabled",
-                budget_tokens: 31999,
-              },
-            },
-          }
-        }
         return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
 
       case "@ai-sdk/azure":
@@ -718,21 +694,9 @@ export namespace ProviderTransform {
     const modelCap = modelLimit || globalLimit
     const standardLimit = Math.min(modelCap, globalLimit)
 
-    // Handle thinking mode for @ai-sdk/anthropic, @ai-sdk/google-vertex/anthropic (budgetTokens)
-    // and @ai-sdk/openai-compatible with Claude (budget_tokens)
-    if (
-      npm === "@ai-sdk/anthropic" ||
-      npm === "@ai-sdk/google-vertex/anthropic" ||
-      npm === "@ai-sdk/openai-compatible"
-    ) {
+    if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
       const thinking = options?.["thinking"]
-      // Support both camelCase (for @ai-sdk/anthropic) and snake_case (for openai-compatible)
-      const budgetTokens =
-        typeof thinking?.["budgetTokens"] === "number"
-          ? thinking["budgetTokens"]
-          : typeof thinking?.["budget_tokens"] === "number"
-            ? thinking["budget_tokens"]
-            : 0
+      const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0
       const enabled = thinking?.["type"] === "enabled"
       if (enabled && budgetTokens > 0) {
         // Return text tokens so that text + thinking <= model cap, preferring 32k text when possible.

+ 9 - 6
packages/opencode/src/server/server.ts

@@ -185,12 +185,15 @@ export namespace Server {
           },
         )
         .use(async (c, next) => {
-          let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
-          try {
-            directory = decodeURIComponent(directory)
-          } catch {
-            // fallback to original value
-          }
+          if (c.req.path === "/log") return next()
+          const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
+          const directory = (() => {
+            try {
+              return decodeURIComponent(raw)
+            } catch {
+              return raw
+            }
+          })()
           return Instance.provide({
             directory,
             init: InstanceBootstrap,

+ 13 - 7
packages/opencode/src/session/index.ts

@@ -505,17 +505,23 @@ export namespace Session {
 
   export function* list() {
     const project = Instance.project
-    const rows = Database.use((db) =>
-      db.select().from(SessionTable).where(eq(SessionTable.project_id, project.id)).all(),
-    )
-    for (const row of rows) {
-      yield fromRow(row)
+    for (const item of await Storage.list(["session", project.id])) {
+      const session = await Storage.read<Info>(item).catch(() => undefined)
+      if (!session) continue
+      yield session
     }
   }
 
   export const children = fn(Identifier.schema("session"), async (parentID) => {
-    const rows = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.parent_id, parentID)).all())
-    return rows.map((row) => fromRow(row))
+    const project = Instance.project
+    const result = [] as Session.Info[]
+    for (const item of await Storage.list(["session", project.id])) {
+      const session = await Storage.read<Info>(item).catch(() => undefined)
+      if (!session) continue
+      if (session.parentID !== parentID) continue
+      result.push(session)
+    }
+    return result
   })
 
   export const remove = fn(Identifier.schema("session"), async (sessionID) => {

+ 11 - 6
packages/opencode/src/session/instruction.ts

@@ -75,7 +75,9 @@ export namespace InstructionPrompt {
       for (const file of FILES) {
         const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
         if (matches.length > 0) {
-          matches.forEach((p) => paths.add(path.resolve(p)))
+          matches.forEach((p) => {
+            paths.add(path.resolve(p))
+          })
           break
         }
       }
@@ -103,7 +105,9 @@ export namespace InstructionPrompt {
               }),
             ).catch(() => [])
           : await resolveRelative(instruction)
-        matches.forEach((p) => paths.add(path.resolve(p)))
+        matches.forEach((p) => {
+          paths.add(path.resolve(p))
+        })
       }
     }
 
@@ -168,12 +172,14 @@ export namespace InstructionPrompt {
     const already = loaded(messages)
     const results: { filepath: string; content: string }[] = []
 
-    let current = path.dirname(path.resolve(filepath))
+    const target = path.resolve(filepath)
+    let current = path.dirname(target)
     const root = path.resolve(Instance.directory)
 
-    while (current.startsWith(root)) {
+    while (current.startsWith(root) && current !== root) {
       const found = await find(current)
-      if (found && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
+
+      if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
         claim(messageID, found)
         const content = await Bun.file(found)
           .text()
@@ -182,7 +188,6 @@ export namespace InstructionPrompt {
           results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
         }
       }
-      if (current === root) break
       current = path.dirname(current)
     }
 

+ 6 - 13
packages/opencode/src/session/llm.ts

@@ -233,19 +233,12 @@ export namespace LLM {
       },
       maxRetries: input.retries ?? 0,
       messages: [
-        ...(isCodex
-          ? [
-              {
-                role: "user",
-                content: system.join("\n\n"),
-              } as ModelMessage,
-            ]
-          : system.map(
-              (x): ModelMessage => ({
-                role: "system",
-                content: x,
-              }),
-            )),
+        ...system.map(
+          (x): ModelMessage => ({
+            role: "system",
+            content: x,
+          }),
+        ),
         ...input.messages,
       ],
       model: wrapLanguageModel({

+ 9 - 1
packages/opencode/src/session/processor.ts

@@ -180,6 +180,14 @@ export namespace SessionProcessor {
                 case "tool-result": {
                   const match = toolcalls[value.toolCallId]
                   if (match && match.state.status === "running") {
+                    const attachments = value.output.attachments?.map(
+                      (attachment: Omit<MessageV2.FilePart, "id" | "messageID" | "sessionID">) => ({
+                        ...attachment,
+                        id: Identifier.ascending("part"),
+                        messageID: match.messageID,
+                        sessionID: match.sessionID,
+                      }),
+                    )
                     await Session.updatePart({
                       ...match,
                       state: {
@@ -192,7 +200,7 @@ export namespace SessionProcessor {
                           start: match.state.time.start,
                           end: Date.now(),
                         },
-                        attachments: value.output.attachments,
+                        attachments,
                       },
                     })
 

+ 56 - 28
packages/opencode/src/session/prompt.ts

@@ -185,13 +185,17 @@ export namespace SessionPrompt {
         text: template,
       },
     ]
-    const files = ConfigMarkdown.files(template)
+    const matches = ConfigMarkdown.files(template)
     const seen = new Set<string>()
-    await Promise.all(
-      files.map(async (match) => {
-        const name = match[1]
-        if (seen.has(name)) return
+    const names = matches
+      .map((match) => match[1])
+      .filter((name) => {
+        if (seen.has(name)) return false
         seen.add(name)
+        return true
+      })
+    const resolved = await Promise.all(
+      names.map(async (name) => {
         const filepath = name.startsWith("~/")
           ? path.join(os.homedir(), name.slice(2))
           : path.resolve(Instance.worktree, name)
@@ -199,33 +203,34 @@ export namespace SessionPrompt {
         const stats = await fs.stat(filepath).catch(() => undefined)
         if (!stats) {
           const agent = await Agent.get(name)
-          if (agent) {
-            parts.push({
-              type: "agent",
-              name: agent.name,
-            })
-          }
-          return
+          if (!agent) return undefined
+          return {
+            type: "agent",
+            name: agent.name,
+          } satisfies PromptInput["parts"][number]
         }
 
         if (stats.isDirectory()) {
-          parts.push({
+          return {
             type: "file",
             url: `file://${filepath}`,
             filename: name,
             mime: "application/x-directory",
-          })
-          return
+          } satisfies PromptInput["parts"][number]
         }
 
-        parts.push({
+        return {
           type: "file",
           url: `file://${filepath}`,
           filename: name,
           mime: "text/plain",
-        })
+        } satisfies PromptInput["parts"][number]
       }),
     )
+    for (const item of resolved) {
+      if (!item) continue
+      parts.push(item)
+    }
     return parts
   }
 
@@ -422,6 +427,12 @@ export namespace SessionPrompt {
         assistantMessage.time.completed = Date.now()
         await Session.updateMessage(assistantMessage)
         if (result && part.state.status === "running") {
+          const attachments = result.attachments?.map((attachment) => ({
+            ...attachment,
+            id: Identifier.ascending("part"),
+            messageID: assistantMessage.id,
+            sessionID: assistantMessage.sessionID,
+          }))
           await Session.updatePart({
             ...part,
             state: {
@@ -430,7 +441,7 @@ export namespace SessionPrompt {
               title: result.title,
               metadata: result.metadata,
               output: result.output,
-              attachments: result.attachments,
+              attachments,
               time: {
                 ...part.state.time,
                 end: Date.now(),
@@ -769,16 +780,13 @@ export namespace SessionPrompt {
         )
 
         const textParts: string[] = []
-        const attachments: MessageV2.FilePart[] = []
+        const attachments: Omit<MessageV2.FilePart, "id" | "messageID" | "sessionID">[] = []
 
         for (const contentItem of result.content) {
           if (contentItem.type === "text") {
             textParts.push(contentItem.text)
           } else if (contentItem.type === "image") {
             attachments.push({
-              id: Identifier.ascending("part"),
-              sessionID: input.session.id,
-              messageID: input.processor.message.id,
               type: "file",
               mime: contentItem.mimeType,
               url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
@@ -790,9 +798,6 @@ export namespace SessionPrompt {
             }
             if (resource.blob) {
               attachments.push({
-                id: Identifier.ascending("part"),
-                sessionID: input.session.id,
-                messageID: input.processor.message.id,
                 type: "file",
                 mime: resource.mimeType ?? "application/octet-stream",
                 url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
@@ -825,6 +830,17 @@ export namespace SessionPrompt {
 
   async function createUserMessage(input: PromptInput) {
     const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
+
+    const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
+    const variant =
+      input.variant ??
+      (agent.variant &&
+      agent.model &&
+      model.providerID === agent.model.providerID &&
+      model.modelID === agent.model.modelID
+        ? agent.variant
+        : undefined)
+
     const info: MessageV2.Info = {
       id: input.messageID ?? Identifier.ascending("message"),
       role: "user",
@@ -834,9 +850,9 @@ export namespace SessionPrompt {
       },
       tools: input.tools,
       agent: agent.name,
-      model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
+      model,
       system: input.system,
-      variant: input.variant,
+      variant,
     }
     using _ = defer(() => InstructionPrompt.clear(info.id))
 
@@ -1030,6 +1046,7 @@ export namespace SessionPrompt {
                       pieces.push(
                         ...result.attachments.map((attachment) => ({
                           ...attachment,
+                          id: Identifier.ascending("part"),
                           synthetic: true,
                           filename: attachment.filename ?? part.filename,
                           messageID: info.id,
@@ -1167,7 +1184,18 @@ export namespace SessionPrompt {
           },
         ]
       }),
-    ).then((x) => x.flat())
+    )
+      .then((x) => x.flat())
+      .then((drafts) =>
+        drafts.map(
+          (part): MessageV2.Part => ({
+            ...part,
+            id: Identifier.ascending("part"),
+            messageID: info.id,
+            sessionID: input.sessionID,
+          }),
+        ),
+      )
 
     await Plugin.trigger(
       "chat.message",

+ 7 - 1
packages/opencode/src/tool/batch.ts

@@ -77,6 +77,12 @@ export const BatchTool = Tool.define("batch", async () => {
           })
 
           const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
+          const attachments = result.attachments?.map((attachment) => ({
+            ...attachment,
+            id: Identifier.ascending("part"),
+            messageID: ctx.messageID,
+            sessionID: ctx.sessionID,
+          }))
 
           await Session.updatePart({
             id: partID,
@@ -91,7 +97,7 @@ export const BatchTool = Tool.define("batch", async () => {
               output: result.output,
               title: result.title,
               metadata: result.metadata,
-              attachments: result.attachments,
+              attachments,
               time: {
                 start: callStartTime,
                 end: Date.now(),

+ 0 - 4
packages/opencode/src/tool/read.ts

@@ -6,7 +6,6 @@ import { LSP } from "../lsp"
 import { FileTime } from "../file/time"
 import DESCRIPTION from "./read.txt"
 import { Instance } from "../project/instance"
-import { Identifier } from "../id/id"
 import { assertExternalDirectory } from "./external-directory"
 import { InstructionPrompt } from "../session/instruction"
 
@@ -79,9 +78,6 @@ export const ReadTool = Tool.define("read", {
         },
         attachments: [
           {
-            id: Identifier.ascending("part"),
-            sessionID: ctx.sessionID,
-            messageID: ctx.messageID,
             type: "file",
             mime,
             url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`,

+ 1 - 1
packages/opencode/src/tool/tool.ts

@@ -36,7 +36,7 @@ export namespace Tool {
         title: string
         metadata: M
         output: string
-        attachments?: MessageV2.FilePart[]
+        attachments?: Omit<MessageV2.FilePart, "id" | "sessionID" | "messageID">[]
       }>
       formatValidationError?(error: z.ZodError): string
     }>

+ 25 - 5
packages/opencode/src/worktree/index.ts

@@ -220,6 +220,13 @@ export namespace Worktree {
     return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
   }
 
+  async function canonical(input: string) {
+    const abs = path.resolve(input)
+    const real = await fs.realpath(abs).catch(() => abs)
+    const normalized = path.normalize(real)
+    return process.platform === "win32" ? normalized.toLowerCase() : normalized
+  }
+
   async function candidate(root: string, base?: string) {
     for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
       const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
@@ -376,7 +383,7 @@ export namespace Worktree {
       throw new NotGitError({ message: "Worktrees are only supported for git projects" })
     }
 
-    const directory = path.resolve(input.directory)
+    const directory = await canonical(input.directory)
     const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
     if (list.exitCode !== 0) {
       throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
@@ -399,7 +406,13 @@ export namespace Worktree {
       return acc
     }, [])
 
-    const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
+    const entry = await (async () => {
+      for (const item of entries) {
+        if (!item.path) continue
+        const key = await canonical(item.path)
+        if (key === directory) return item
+      }
+    })()
     if (!entry?.path) {
       throw new RemoveFailedError({ message: "Worktree not found" })
     }
@@ -425,8 +438,9 @@ export namespace Worktree {
       throw new NotGitError({ message: "Worktrees are only supported for git projects" })
     }
 
-    const directory = path.resolve(input.directory)
-    if (directory === path.resolve(Instance.worktree)) {
+    const directory = await canonical(input.directory)
+    const primary = await canonical(Instance.worktree)
+    if (directory === primary) {
       throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
     }
 
@@ -452,7 +466,13 @@ export namespace Worktree {
       return acc
     }, [])
 
-    const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
+    const entry = await (async () => {
+      for (const item of entries) {
+        if (!item.path) continue
+        const key = await canonical(item.path)
+        if (key === directory) return item
+      }
+    })()
     if (!entry?.path) {
       throw new ResetFailedError({ message: "Worktree not found" })
     }

+ 27 - 2
packages/opencode/test/cli/tui/transcript.test.ts

@@ -119,13 +119,38 @@ describe("transcript", () => {
         },
       }
       const result = formatPart(part, options)
-      expect(result).toContain("Tool: bash")
+      expect(result).toContain("**Tool: bash**")
       expect(result).toContain("**Input:**")
       expect(result).toContain('"command": "ls"')
       expect(result).toContain("**Output:**")
       expect(result).toContain("file1.txt")
     })
 
+    test("formats tool output containing triple backticks without breaking markdown", () => {
+      const part: Part = {
+        id: "part_1",
+        sessionID: "ses_123",
+        messageID: "msg_123",
+        type: "tool",
+        callID: "call_1",
+        tool: "bash",
+        state: {
+          status: "completed",
+          input: { command: "echo '```hello```'" },
+          output: "```hello```",
+          title: "Echo backticks",
+          metadata: {},
+          time: { start: 1000, end: 1100 },
+        },
+      }
+      const result = formatPart(part, options)
+      // The tool header should not be inside a code block
+      expect(result).toStartWith("**Tool: bash**\n")
+      // Input and output should each be in their own code blocks
+      expect(result).toContain("**Input:**\n```json")
+      expect(result).toContain("**Output:**\n```\n```hello```\n```")
+    })
+
     test("formats tool part without details when disabled", () => {
       const part: Part = {
         id: "part_1",
@@ -144,7 +169,7 @@ describe("transcript", () => {
         },
       }
       const result = formatPart(part, { ...options, toolDetails: false })
-      expect(result).toContain("Tool: bash")
+      expect(result).toContain("**Tool: bash**")
       expect(result).not.toContain("**Input:**")
       expect(result).not.toContain("**Output:**")
     })

+ 31 - 0
packages/opencode/test/config/config.test.ts

@@ -255,6 +255,37 @@ test("handles agent configuration", async () => {
   })
 })
 
+test("treats agent variant as model-scoped setting (not provider option)", async () => {
+  await using tmp = await tmpdir({
+    init: async (dir) => {
+      await writeConfig(dir, {
+        $schema: "https://opencode.ai/config.json",
+        agent: {
+          test_agent: {
+            model: "openai/gpt-5.2",
+            variant: "xhigh",
+            max_tokens: 123,
+          },
+        },
+      })
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const config = await Config.get()
+      const agent = config.agent?.["test_agent"]
+
+      expect(agent?.variant).toBe("xhigh")
+      expect(agent?.options).toMatchObject({
+        max_tokens: 123,
+      })
+      expect(agent?.options).not.toHaveProperty("variant")
+    },
+  })
+})
+
 test("handles command configuration", async () => {
   await using tmp = await tmpdir({
     init: async (dir) => {

+ 39 - 0
packages/opencode/test/file/ripgrep.test.ts

@@ -0,0 +1,39 @@
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { tmpdir } from "../fixture/fixture"
+import { Ripgrep } from "../../src/file/ripgrep"
+
+describe("file.ripgrep", () => {
+  test("defaults to include hidden", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "visible.txt"), "hello")
+        await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
+        await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
+      },
+    })
+
+    const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
+    const hasVisible = files.includes("visible.txt")
+    const hasHidden = files.includes(path.join(".opencode", "thing.json"))
+    expect(hasVisible).toBe(true)
+    expect(hasHidden).toBe(true)
+  })
+
+  test("hidden false excludes hidden", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "visible.txt"), "hello")
+        await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
+        await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
+      },
+    })
+
+    const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, hidden: false }))
+    const hasVisible = files.includes("visible.txt")
+    const hasHidden = files.includes(path.join(".opencode", "thing.json"))
+    expect(hasVisible).toBe(true)
+    expect(hasHidden).toBe(false)
+  })
+})

+ 44 - 0
packages/opencode/test/plugin/auth-override.test.ts

@@ -0,0 +1,44 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { tmpdir } from "../fixture/fixture"
+import { Instance } from "../../src/project/instance"
+import { ProviderAuth } from "../../src/provider/auth"
+
+describe("plugin.auth-override", () => {
+  test("user plugin overrides built-in github-copilot auth", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        const pluginDir = path.join(dir, ".opencode", "plugin")
+        await fs.mkdir(pluginDir, { recursive: true })
+
+        await Bun.write(
+          path.join(pluginDir, "custom-copilot-auth.ts"),
+          [
+            "export default async () => ({",
+            "  auth: {",
+            '    provider: "github-copilot",',
+            "    methods: [",
+            '      { type: "api", label: "Test Override Auth" },',
+            "    ],",
+            "    loader: async () => ({ access: 'test-token' }),",
+            "  },",
+            "})",
+            "",
+          ].join("\n"),
+        )
+      },
+    })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const methods = await ProviderAuth.methods()
+        const copilot = methods["github-copilot"]
+        expect(copilot).toBeDefined()
+        expect(copilot.length).toBe(1)
+        expect(copilot[0].label).toBe("Test Override Auth")
+      },
+    })
+  }, 30000) // Increased timeout for plugin installation
+})

+ 29 - 2
packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts

@@ -354,7 +354,7 @@ describe("tool calls", () => {
 })
 
 describe("reasoning (copilot-specific)", () => {
-  test("should include reasoning_text from reasoning part", () => {
+  test("should omit reasoning_text without reasoning_opaque", () => {
     const result = convertToCopilotMessages([
       {
         role: "assistant",
@@ -370,7 +370,7 @@ describe("reasoning (copilot-specific)", () => {
         role: "assistant",
         content: "The answer is 42.",
         tool_calls: undefined,
-        reasoning_text: "Let me think about this...",
+        reasoning_text: undefined,
         reasoning_opaque: undefined,
       },
     ])
@@ -404,6 +404,33 @@ describe("reasoning (copilot-specific)", () => {
     ])
   })
 
+  test("should include reasoning_opaque from text part providerOptions", () => {
+    const result = convertToCopilotMessages([
+      {
+        role: "assistant",
+        content: [
+          {
+            type: "text",
+            text: "Done!",
+            providerOptions: {
+              copilot: { reasoningOpaque: "opaque-text-456" },
+            },
+          },
+        ],
+      },
+    ])
+
+    expect(result).toEqual([
+      {
+        role: "assistant",
+        content: "Done!",
+        tool_calls: undefined,
+        reasoning_text: undefined,
+        reasoning_opaque: "opaque-text-456",
+      },
+    ])
+  })
+
   test("should handle reasoning-only assistant message", () => {
     const result = convertToCopilotMessages([
       {

+ 35 - 0
packages/opencode/test/provider/copilot/copilot-chat-model.test.ts

@@ -65,6 +65,12 @@ const FIXTURES = {
     `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"code\\":\\"1 + 1\\"}","name":"project_eval"},"id":"call_MHw3RDhmT1J5Z3B6WlhpVjlveTc","index":0,"type":"function"}],"reasoning_opaque":"ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":12,"prompt_tokens":8677,"prompt_tokens_details":{"cached_tokens":3692},"total_tokens":8768,"reasoning_tokens":79},"model":"gemini-3-pro-preview"}`,
     `data: [DONE]`,
   ],
+
+  reasoningOpaqueWithToolCallsNoReasoningText: [
+    `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only","index":0,"type":"function"}],"reasoning_opaque":"opaque-xyz"}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`,
+    `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only_2","index":1,"type":"function"}]}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":12,"prompt_tokens":123,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":135,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`,
+    `data: [DONE]`,
+  ],
 }
 
 function createMockFetch(chunks: string[]) {
@@ -447,6 +453,35 @@ describe("doStream", () => {
     })
   })
 
+  test("should attach reasoning_opaque to tool calls without reasoning_text", async () => {
+    const mockFetch = createMockFetch(FIXTURES.reasoningOpaqueWithToolCallsNoReasoningText)
+    const model = createModel(mockFetch)
+
+    const { stream } = await model.doStream({
+      prompt: TEST_PROMPT,
+      includeRawChunks: false,
+    })
+
+    const parts = await convertReadableStreamToArray(stream)
+    const reasoningParts = parts.filter(
+      (p) => p.type === "reasoning-start" || p.type === "reasoning-delta" || p.type === "reasoning-end",
+    )
+
+    expect(reasoningParts).toHaveLength(0)
+
+    const toolCall = parts.find((p) => p.type === "tool-call" && p.toolCallId === "call_reasoning_only")
+    expect(toolCall).toMatchObject({
+      type: "tool-call",
+      toolCallId: "call_reasoning_only",
+      toolName: "read_file",
+      providerMetadata: {
+        copilot: {
+          reasoningOpaque: "opaque-xyz",
+        },
+      },
+    })
+  })
+
   test("should include response metadata from first chunk", async () => {
     const mockFetch = createMockFetch(FIXTURES.basicText)
     const model = createModel(mockFetch)

+ 1 - 132
packages/opencode/test/provider/transform.test.ts

@@ -267,76 +267,6 @@ describe("ProviderTransform.maxOutputTokens", () => {
       expect(result).toBe(OUTPUT_TOKEN_MAX)
     })
   })
-
-  describe("openai-compatible with thinking options (snake_case)", () => {
-    test("returns 32k when budget_tokens + 32k <= modelLimit", () => {
-      const modelLimit = 100000
-      const options = {
-        thinking: {
-          type: "enabled",
-          budget_tokens: 10000,
-        },
-      }
-      const result = ProviderTransform.maxOutputTokens(
-        "@ai-sdk/openai-compatible",
-        options,
-        modelLimit,
-        OUTPUT_TOKEN_MAX,
-      )
-      expect(result).toBe(OUTPUT_TOKEN_MAX)
-    })
-
-    test("returns modelLimit - budget_tokens when budget_tokens + 32k > modelLimit", () => {
-      const modelLimit = 50000
-      const options = {
-        thinking: {
-          type: "enabled",
-          budget_tokens: 30000,
-        },
-      }
-      const result = ProviderTransform.maxOutputTokens(
-        "@ai-sdk/openai-compatible",
-        options,
-        modelLimit,
-        OUTPUT_TOKEN_MAX,
-      )
-      expect(result).toBe(20000)
-    })
-
-    test("returns 32k when thinking type is not enabled", () => {
-      const modelLimit = 100000
-      const options = {
-        thinking: {
-          type: "disabled",
-          budget_tokens: 10000,
-        },
-      }
-      const result = ProviderTransform.maxOutputTokens(
-        "@ai-sdk/openai-compatible",
-        options,
-        modelLimit,
-        OUTPUT_TOKEN_MAX,
-      )
-      expect(result).toBe(OUTPUT_TOKEN_MAX)
-    })
-
-    test("returns 32k when budget_tokens is 0", () => {
-      const modelLimit = 100000
-      const options = {
-        thinking: {
-          type: "enabled",
-          budget_tokens: 0,
-        },
-      }
-      const result = ProviderTransform.maxOutputTokens(
-        "@ai-sdk/openai-compatible",
-        options,
-        modelLimit,
-        OUTPUT_TOKEN_MAX,
-      )
-      expect(result).toBe(OUTPUT_TOKEN_MAX)
-    })
-  })
 })
 
 describe("ProviderTransform.schema - gemini array items", () => {
@@ -1166,7 +1096,7 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile"
     expect(result[0].providerOptions?.bedrock).toEqual(
       expect.objectContaining({
         cachePoint: {
-          type: "ephemeral",
+          type: "default",
         },
       }),
     )
@@ -1564,67 +1494,6 @@ describe("ProviderTransform.variants", () => {
       expect(result.low).toEqual({ reasoningEffort: "low" })
       expect(result.high).toEqual({ reasoningEffort: "high" })
     })
-
-    test("Claude via LiteLLM returns thinking with snake_case budget_tokens", () => {
-      const model = createMockModel({
-        id: "anthropic/claude-sonnet-4-5",
-        providerID: "anthropic",
-        api: {
-          id: "claude-sonnet-4-5-20250929",
-          url: "http://localhost:4000",
-          npm: "@ai-sdk/openai-compatible",
-        },
-      })
-      const result = ProviderTransform.variants(model)
-      expect(Object.keys(result)).toEqual(["high", "max"])
-      expect(result.high).toEqual({
-        thinking: {
-          type: "enabled",
-          budget_tokens: 16000,
-        },
-      })
-      expect(result.max).toEqual({
-        thinking: {
-          type: "enabled",
-          budget_tokens: 31999,
-        },
-      })
-    })
-
-    test("Claude model (by model.id) via openai-compatible uses snake_case", () => {
-      const model = createMockModel({
-        id: "litellm/claude-3-opus",
-        providerID: "litellm",
-        api: {
-          id: "claude-3-opus-20240229",
-          url: "http://localhost:4000",
-          npm: "@ai-sdk/openai-compatible",
-        },
-      })
-      const result = ProviderTransform.variants(model)
-      expect(Object.keys(result)).toEqual(["high", "max"])
-      expect(result.high).toEqual({
-        thinking: {
-          type: "enabled",
-          budget_tokens: 16000,
-        },
-      })
-    })
-
-    test("Anthropic model (by model.api.id) via openai-compatible uses snake_case", () => {
-      const model = createMockModel({
-        id: "custom/my-model",
-        providerID: "custom",
-        api: {
-          id: "anthropic.claude-sonnet",
-          url: "http://localhost:4000",
-          npm: "@ai-sdk/openai-compatible",
-        },
-      })
-      const result = ProviderTransform.variants(model)
-      expect(Object.keys(result)).toEqual(["high", "max"])
-      expect(result.high.thinking.budget_tokens).toBe(16000)
-    })
   })
 
   describe("@ai-sdk/azure", () => {

+ 20 - 0
packages/opencode/test/session/instruction.test.ts

@@ -47,4 +47,24 @@ describe("InstructionPrompt.resolve", () => {
       },
     })
   })
+
+  test("doesn't reload AGENTS.md when reading it directly", async () => {
+    await using tmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
+        await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
+      },
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
+        const system = await InstructionPrompt.systemPaths()
+        expect(system.has(filepath)).toBe(false)
+
+        const results = await InstructionPrompt.resolve([], filepath, "test-message-2")
+        expect(results).toEqual([])
+      },
+    })
+  })
 })

+ 60 - 0
packages/opencode/test/session/prompt-variant.test.ts

@@ -0,0 +1,60 @@
+import { describe, expect, test } from "bun:test"
+import { Instance } from "../../src/project/instance"
+import { Session } from "../../src/session"
+import { SessionPrompt } from "../../src/session/prompt"
+import { tmpdir } from "../fixture/fixture"
+
+describe("session.prompt agent variant", () => {
+  test("applies agent variant only when using agent model", async () => {
+    await using tmp = await tmpdir({
+      git: true,
+      config: {
+        agent: {
+          build: {
+            model: "openai/gpt-5.2",
+            variant: "xhigh",
+          },
+        },
+      },
+    })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const session = await Session.create({})
+
+        const other = await SessionPrompt.prompt({
+          sessionID: session.id,
+          agent: "build",
+          model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
+          noReply: true,
+          parts: [{ type: "text", text: "hello" }],
+        })
+        if (other.info.role !== "user") throw new Error("expected user message")
+        expect(other.info.variant).toBeUndefined()
+
+        const match = await SessionPrompt.prompt({
+          sessionID: session.id,
+          agent: "build",
+          noReply: true,
+          parts: [{ type: "text", text: "hello again" }],
+        })
+        if (match.info.role !== "user") throw new Error("expected user message")
+        expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
+        expect(match.info.variant).toBe("xhigh")
+
+        const override = await SessionPrompt.prompt({
+          sessionID: session.id,
+          agent: "build",
+          noReply: true,
+          variant: "high",
+          parts: [{ type: "text", text: "hello third" }],
+        })
+        if (override.info.role !== "user") throw new Error("expected user message")
+        expect(override.info.variant).toBe("high")
+
+        await Session.remove(session.id)
+      },
+    })
+  })
+})

+ 62 - 0
packages/opencode/test/session/prompt.test.ts

@@ -0,0 +1,62 @@
+import path from "path"
+import { describe, expect, test } from "bun:test"
+import { Session } from "../../src/session"
+import { SessionPrompt } from "../../src/session/prompt"
+import { MessageV2 } from "../../src/session/message-v2"
+import { Instance } from "../../src/project/instance"
+import { Log } from "../../src/util/log"
+import { tmpdir } from "../fixture/fixture"
+
+Log.init({ print: false })
+
+describe("SessionPrompt ordering", () => {
+  test("keeps @file order with read output parts", async () => {
+    await using tmp = await tmpdir({
+      git: true,
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "a.txt"), "28\n")
+        await Bun.write(path.join(dir, "b.txt"), "42\n")
+      },
+    })
+
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const session = await Session.create({})
+        const template = "What numbers are written in files @a.txt and @b.txt ?"
+        const parts = await SessionPrompt.resolvePromptParts(template)
+        const fileParts = parts.filter((part) => part.type === "file")
+
+        expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"])
+
+        const message = await SessionPrompt.prompt({
+          sessionID: session.id,
+          parts,
+          noReply: true,
+        })
+        const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id })
+        const items = stored.parts
+        const aPath = path.join(tmp.path, "a.txt")
+        const bPath = path.join(tmp.path, "b.txt")
+        const sequence = items.flatMap((part) => {
+          if (part.type === "text") {
+            if (part.text.includes(aPath)) return ["input:a"]
+            if (part.text.includes(bPath)) return ["input:b"]
+            if (part.text.includes("00001| 28")) return ["output:a"]
+            if (part.text.includes("00001| 42")) return ["output:b"]
+            return []
+          }
+          if (part.type === "file") {
+            if (part.filename === "a.txt") return ["file:a"]
+            if (part.filename === "b.txt") return ["file:b"]
+          }
+          return []
+        })
+
+        expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"])
+
+        await Session.remove(session.id)
+      },
+    })
+  })
+})

+ 8 - 2
packages/plugin/package.json

@@ -9,8 +9,14 @@
     "build": "tsc"
   },
   "exports": {
-    ".": "./src/index.ts",
-    "./tool": "./src/tool.ts"
+    ".": {
+      "types": "./dist/index.d.ts",
+      "import": "./dist/index.js"
+    },
+    "./tool": {
+      "types": "./dist/tool.d.ts",
+      "import": "./dist/tool.js"
+    }
   },
   "files": [
     "dist"

+ 17 - 0
packages/script/src/index.ts

@@ -46,6 +46,20 @@ const VERSION = await (async () => {
   return `${major}.${minor}.${patch + 1}`
 })()
 
+const team = [
+  "actions-user",
+  "opencode",
+  "rekram1-node",
+  "thdxr",
+  "kommander",
+  "jayair",
+  "fwang",
+  "adamdotdevin",
+  "iamdavidhill",
+  "opencode-agent[bot]",
+  "R44VC0RP",
+]
+
 export const Script = {
   get channel() {
     return CHANNEL
@@ -59,5 +73,8 @@ export const Script = {
   get release() {
     return env.OPENCODE_RELEASE
   },
+  get team() {
+    return team
+  },
 }
 console.log(`opencode script`, JSON.stringify(Script, null, 2))

+ 1 - 1
packages/sdk/js/src/gen/types.gen.ts

@@ -1554,7 +1554,7 @@ export type FileNode = {
 }
 
 export type FileContent = {
-  type: "text"
+  type: "text" | "binary"
   content: string
   diff?: string
   patch?: {

+ 6 - 1
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -1378,6 +1378,10 @@ export type PermissionConfig =
 
 export type AgentConfig = {
   model?: string
+  /**
+   * Default model variant for this agent (applies only when using the agent's configured model).
+   */
+  variant?: string
   temperature?: number
   top_p?: number
   prompt?: string
@@ -2049,7 +2053,7 @@ export type FileNode = {
 }
 
 export type FileContent = {
-  type: "text"
+  type: "text" | "binary"
   content: string
   diff?: string
   patch?: {
@@ -2143,6 +2147,7 @@ export type Agent = {
     modelID: string
     providerID: string
   }
+  variant?: string
   prompt?: string
   options: {
     [key: string]: unknown

+ 8 - 1
packages/sdk/openapi.json

@@ -9044,6 +9044,10 @@
           "model": {
             "type": "string"
           },
+          "variant": {
+            "description": "Default model variant for this agent (applies only when using the agent's configured model).",
+            "type": "string"
+          },
           "temperature": {
             "type": "number"
           },
@@ -10591,7 +10595,7 @@
         "properties": {
           "type": {
             "type": "string",
-            "const": "text"
+            "enum": ["text", "binary"]
           },
           "content": {
             "type": "string"
@@ -10869,6 +10873,9 @@
             },
             "required": ["modelID", "providerID"]
           },
+          "variant": {
+            "type": "string"
+          },
           "prompt": {
             "type": "string"
           },

+ 15 - 14
packages/ui/src/components/button.css

@@ -9,7 +9,13 @@
   user-select: none;
   cursor: default;
   outline: none;
+  padding: 4px 8px;
   white-space: nowrap;
+  transition-property: background-color, border-color, color, box-shadow, opacity;
+  transition-duration: var(--transition-duration);
+  transition-timing-function: var(--transition-easing);
+  outline: none;
+  line-height: 20px;
 
   &[data-variant="primary"] {
     background-color: var(--button-primary-base);
@@ -94,7 +100,6 @@
     &:active:not(:disabled) {
       background-color: var(--button-secondary-base);
       scale: 0.99;
-      transition: all 150ms ease-out;
     }
     &:disabled {
       border-color: var(--border-disabled);
@@ -109,34 +114,31 @@
   }
 
   &[data-size="small"] {
-    height: 22px;
-    padding: 0 8px;
+    padding: 4px 8px;
     &[data-icon] {
-      padding: 0 12px 0 4px;
+      padding: 4px 12px 4px 4px;
     }
 
-    font-size: var(--font-size-small);
-    line-height: var(--line-height-large);
     gap: 4px;
 
     /* text-12-medium */
     font-family: var(--font-family-sans);
-    font-size: var(--font-size-small);
+    font-size: var(--font-size-base);
     font-style: normal;
     font-weight: var(--font-weight-medium);
-    line-height: var(--line-height-large); /* 166.667% */
     letter-spacing: var(--letter-spacing-normal);
   }
 
   &[data-size="normal"] {
-    height: 24px;
-    line-height: 24px;
-    padding: 0 6px;
+    padding: 4px 6px;
     &[data-icon] {
-      padding: 0 12px 0 4px;
+      padding: 4px 12px 4px 4px;
+    }
+
+    &[aria-haspopup] {
+      padding: 4px 6px 4px 8px;
     }
 
-    font-size: var(--font-size-small);
     gap: 6px;
 
     /* text-12-medium */
@@ -148,7 +150,6 @@
   }
 
   &[data-size="large"] {
-    height: 32px;
     padding: 6px 12px;
 
     &[data-icon] {

+ 1 - 1
packages/ui/src/components/button.tsx

@@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon"
 
 export interface ButtonProps
   extends ComponentProps<typeof Kobalte>,
-    Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
+    Pick<ComponentProps<"button">, "class" | "classList" | "children" | "style"> {
   size?: "small" | "normal" | "large"
   variant?: "primary" | "secondary" | "ghost"
   icon?: IconProps["name"]

+ 49 - 0
packages/ui/src/components/cycle-label.css

@@ -0,0 +1,49 @@
+.cycle-label {
+  --c-duration: 200ms;
+  --c-stagger: 30ms;
+  --c-opacity-start: 0;
+  --c-opacity-end: 1;
+  --c-blur-start: 0px;
+  --c-blur-end: 0px;
+  --c-skew: 10deg;
+
+  display: inline-flex;
+  position: relative;
+
+  transform-style: preserve-3d;
+  perspective: 500px;
+  transition: width var(--transition-duration) var(--transition-easing);
+  will-change: width;
+  overflow: hidden;
+
+  .cycle-char {
+    display: inline-block;
+    transform-style: preserve-3d;
+    min-width: 0.25em;
+    backface-visibility: hidden;
+
+    transition-property: transform, opacity, filter;
+    transition-duration: var(--transition-duration);
+    transition-timing-function: var(--transition-easing);
+    transition-delay: calc(var(--i, 0) * var(--c-stagger));
+
+    &.enter {
+      opacity: var(--c-opacity-end);
+      filter: blur(var(--c-blur-end));
+      transform: translateY(0) rotateX(0) skewX(0);
+    }
+
+    &.exit {
+      opacity: var(--c-opacity-start);
+      filter: blur(var(--c-blur-start));
+      transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew));
+    }
+
+    &.pre {
+      opacity: var(--c-opacity-start);
+      filter: blur(var(--c-blur-start));
+      transition: none;
+      transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1));
+    }
+  }
+}

+ 135 - 0
packages/ui/src/components/cycle-label.tsx

@@ -0,0 +1,135 @@
+import "./cycle-label.css"
+import { createEffect, createSignal, JSX, on } from "solid-js"
+
+export interface CycleLabelProps extends JSX.HTMLAttributes<HTMLSpanElement> {
+  value: string
+  onValueChange?: (value: string) => void
+  duration?: number | ((value: string) => number)
+  stagger?: number
+  opacity?: [number, number]
+  blur?: [number, number]
+  skewX?: number
+  onAnimationStart?: () => void
+  onAnimationEnd?: () => void
+}
+
+const segmenter =
+  typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null
+
+const getChars = (text: string): string[] =>
+  segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("")
+
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
+
+export function CycleLabel(props: CycleLabelProps) {
+  const getDuration = (text: string) => {
+    const d =
+      props.duration ??
+      Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ??
+      200
+    return typeof d === "function" ? d(text) : d
+  }
+  const stagger = () => props?.stagger ?? 30
+  const opacity = () => props?.opacity ?? [0, 1]
+  const blur = () => props?.blur ?? [0, 0]
+  const skewX = () => props?.skewX ?? 10
+
+  let containerRef: HTMLSpanElement | undefined
+  let isAnimating = false
+  const [currentText, setCurrentText] = createSignal(props.value)
+
+  const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => {
+    el.innerHTML = ""
+    const chars = getChars(text)
+    chars.forEach((char, i) => {
+      const span = document.createElement("span")
+      span.textContent = char === " " ? "\u00A0" : char
+      span.className = `cycle-char ${state}`
+      span.style.setProperty("--i", String(i))
+      el.appendChild(span)
+    })
+  }
+
+  const animateToText = async (newText: string) => {
+    if (!containerRef || isAnimating) return
+    if (newText === currentText()) return
+
+    isAnimating = true
+    props.onAnimationStart?.()
+
+    const dur = getDuration(newText)
+    const stag = stagger()
+
+    containerRef.style.width = containerRef.offsetWidth + "px"
+
+    const oldChars = containerRef.querySelectorAll(".cycle-char")
+    oldChars.forEach((c) => c.classList.replace("enter", "exit"))
+
+    const clone = containerRef.cloneNode(false) as HTMLElement
+    Object.assign(clone.style, {
+      position: "absolute",
+      visibility: "hidden",
+      width: "auto",
+      transition: "none",
+    })
+    setChars(clone, newText)
+    document.body.appendChild(clone)
+    const nextWidth = clone.offsetWidth
+    clone.remove()
+
+    const exitTime = oldChars.length * stag + dur
+    await wait(exitTime * 0.3)
+
+    containerRef.style.width = nextWidth + "px"
+
+    const widthDur = 200
+    await wait(widthDur * 0.3)
+
+    setChars(containerRef, newText, "pre")
+    containerRef.offsetWidth
+
+    Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter"))
+    setCurrentText(newText)
+    props.onValueChange?.(newText)
+
+    const enterTime = getChars(newText).length * stag + dur
+    await wait(enterTime)
+
+    containerRef.style.width = ""
+    isAnimating = false
+    props.onAnimationEnd?.()
+  }
+
+  createEffect(
+    on(
+      () => props.value,
+      (newValue) => {
+        if (newValue !== currentText()) {
+          animateToText(newValue)
+        }
+      },
+    ),
+  )
+
+  const initRef = (el: HTMLSpanElement) => {
+    containerRef = el
+    setChars(el, props.value)
+  }
+
+  return (
+    <span
+      ref={initRef}
+      class={`cycle-label ${props.class ?? ""}`}
+      style={{
+        "--c-duration": `${getDuration(currentText())}ms`,
+        "--c-stagger": `${stagger()}ms`,
+        "--c-opacity-start": opacity()[0],
+        "--c-opacity-end": opacity()[1],
+        "--c-blur-start": `${blur()[0]}px`,
+        "--c-blur-end": `${blur()[1]}px`,
+        "--c-skew": `${skewX()}deg`,
+        ...(typeof props.style === "object" ? props.style : {}),
+      }}
+    />
+  )
+}

+ 27 - 18
packages/ui/src/components/dropdown-menu.css

@@ -2,26 +2,29 @@
 [data-component="dropdown-menu-sub-content"] {
   min-width: 8rem;
   overflow: hidden;
+  border: none;
   border-radius: var(--radius-md);
-  border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
+  box-shadow: var(--shadow-xs-border);
   background-clip: padding-box;
   background-color: var(--surface-raised-stronger-non-alpha);
   padding: 4px;
-  box-shadow: var(--shadow-md);
-  z-index: 50;
+  z-index: 100;
   transform-origin: var(--kb-menu-content-transform-origin);
 
-  &:focus,
-  &:focus-visible {
+  &:focus-within,
+  &:focus {
     outline: none;
   }
 
-  &[data-closed] {
-    animation: dropdown-menu-close 0.15s ease-out;
+  animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards;
+
+  @starting-style {
+    animation: none;
   }
 
   &[data-expanded] {
-    animation: dropdown-menu-open 0.15s ease-out;
+    pointer-events: auto;
+    animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards;
   }
 }
 
@@ -38,18 +41,22 @@
     padding: 4px 8px;
     border-radius: var(--radius-sm);
     cursor: default;
-    user-select: none;
     outline: none;
 
     font-family: var(--font-family-sans);
-    font-size: var(--font-size-small);
+    font-size: var(--font-size-base);
     font-weight: var(--font-weight-medium);
     line-height: var(--line-height-large);
     letter-spacing: var(--letter-spacing-normal);
     color: var(--text-strong);
 
-    &[data-highlighted] {
-      background: var(--surface-raised-base-hover);
+    transition-property: background-color, color;
+    transition-duration: var(--transition-duration);
+    transition-timing-function: var(--transition-easing);
+    user-select: none;
+
+    &:hover {
+      background-color: var(--surface-raised-base-hover);
     }
 
     &[data-disabled] {
@@ -61,6 +68,8 @@
   [data-slot="dropdown-menu-sub-trigger"] {
     &[data-expanded] {
       background: var(--surface-raised-base-hover);
+      outline: none;
+      border: none;
     }
   }
 
@@ -102,24 +111,24 @@
   }
 }
 
-@keyframes dropdown-menu-open {
+@keyframes dropdownMenuContentShow {
   from {
     opacity: 0;
-    transform: scale(0.96);
+    transform: scaleY(0.95);
   }
   to {
     opacity: 1;
-    transform: scale(1);
+    transform: scaleY(1);
   }
 }
 
-@keyframes dropdown-menu-close {
+@keyframes dropdownMenuContentHide {
   from {
     opacity: 1;
-    transform: scale(1);
+    transform: scaleY(1);
   }
   to {
     opacity: 0;
-    transform: scale(0.96);
+    transform: scaleY(0.95);
   }
 }

+ 5 - 2
packages/ui/src/components/icon.tsx

@@ -80,13 +80,16 @@ const icons = {
 
 export interface IconProps extends ComponentProps<"svg"> {
   name: keyof typeof icons
-  size?: "small" | "normal" | "medium" | "large"
+  size?: "small" | "normal" | "medium" | "large" | number
 }
 
 export function Icon(props: IconProps) {
   const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
   return (
-    <div data-component="icon" data-size={local.size || "normal"}>
+    <div
+      data-component="icon"
+      data-size={typeof local.size !== "number" ? local.size || "normal" : `size-[${local.size}px]`}
+    >
       <svg
         data-slot="icon-svg"
         classList={{

+ 17 - 33
packages/ui/src/components/list.css

@@ -1,25 +1,7 @@
-@property --bottom-fade {
-  syntax: "<length>";
-  inherits: false;
-  initial-value: 0px;
-}
-
-@keyframes scroll {
-  0% {
-    --bottom-fade: 20px;
-  }
-  90% {
-    --bottom-fade: 20px;
-  }
-  100% {
-    --bottom-fade: 0;
-  }
-}
-
 [data-component="list"] {
   display: flex;
   flex-direction: column;
-  gap: 12px;
+  gap: 8px;
   overflow: hidden;
   padding: 0 12px;
 
@@ -37,7 +19,9 @@
       flex-shrink: 0;
       background-color: transparent;
       opacity: 0.5;
-      transition: opacity 0.15s ease;
+      transition-property: opacity;
+      transition-duration: var(--transition-duration);
+      transition-timing-function: var(--transition-easing);
 
       &:hover:not(:disabled),
       &:focus-visible:not(:disabled),
@@ -88,7 +72,9 @@
       height: 20px;
       background-color: transparent;
       opacity: 0.5;
-      transition: opacity 0.15s ease;
+      transition-property: opacity;
+      transition-duration: var(--transition-duration);
+      transition-timing-function: var(--transition-easing);
 
       &:hover:not(:disabled),
       &:focus-visible:not(:disabled),
@@ -131,15 +117,6 @@
     gap: 12px;
     overflow-y: auto;
     overscroll-behavior: contain;
-    mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000);
-    animation: scroll;
-    animation-timeline: --scroll;
-    scroll-timeline: --scroll y;
-    scrollbar-width: none;
-    -ms-overflow-style: none;
-    &::-webkit-scrollbar {
-      display: none;
-    }
 
     [data-slot="list-empty-state"] {
       display: flex;
@@ -215,7 +192,9 @@
           background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent);
           pointer-events: none;
           opacity: 0;
-          transition: opacity 0.15s ease;
+          transition-property: opacity;
+          transition-duration: var(--transition-duration);
+          transition-timing-function: var(--transition-easing);
         }
 
         &[data-stuck="true"]::after {
@@ -251,17 +230,22 @@
             align-items: center;
             justify-content: center;
             flex-shrink: 0;
-            aspect-ratio: 1/1;
+            aspect-ratio: 1 / 1;
             [data-component="icon"] {
               color: var(--icon-strong-base);
             }
           }
+
+          [name="check"] {
+            color: var(--icon-strong-base);
+          }
+
           [data-slot="list-item-active-icon"] {
             display: none;
             align-items: center;
             justify-content: center;
             flex-shrink: 0;
-            aspect-ratio: 1/1;
+            aspect-ratio: 1 / 1;
             [data-component="icon"] {
               color: var(--icon-strong-base);
             }

+ 3 - 2
packages/ui/src/components/list.tsx

@@ -5,6 +5,7 @@ import { useI18n } from "../context/i18n"
 import { Icon, type IconProps } from "./icon"
 import { IconButton } from "./icon-button"
 import { TextField } from "./text-field"
+import { ScrollFade } from "./scroll-fade"
 
 function findByKey(container: HTMLElement, key: string) {
   const nodes = container.querySelectorAll<HTMLElement>('[data-slot="list-item"][data-key]')
@@ -267,7 +268,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
           {searchAction()}
         </div>
       </Show>
-      <div ref={setScrollRef} data-slot="list-scroll">
+      <ScrollFade ref={setScrollRef} direction="vertical" fadeStartSize={0} fadeEndSize={20} data-slot="list-scroll">
         <Show
           when={flat().length > 0 || showAdd()}
           fallback={
@@ -339,7 +340,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
             </div>
           </Show>
         </Show>
-      </div>
+      </ScrollFade>
     </div>
   )
 }

+ 3 - 3
packages/ui/src/components/message-part.tsx

@@ -42,13 +42,13 @@ import { Checkbox } from "./checkbox"
 import { DiffChanges } from "./diff-changes"
 import { Markdown } from "./markdown"
 import { ImagePreview } from "./image-preview"
-import { findLast } from "@opencode-ai/util/array"
 import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
 import { checksum } from "@opencode-ai/util/encode"
 import { Tooltip } from "./tooltip"
 import { IconButton } from "./icon-button"
 import { createAutoScroll } from "../hooks"
 import { createResizeObserver } from "@solid-primitives/resize-observer"
+import { MorphChevron } from "./morph-chevron"
 
 interface Diagnostic {
   range: {
@@ -415,7 +415,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
               toggleExpanded()
             }}
           >
-            <Icon name="chevron-down" size="small" />
+            <MorphChevron expanded={expanded()} />
           </button>
           <div data-slot="user-message-copy-wrapper">
             <Tooltip
@@ -898,7 +898,7 @@ ToolRegistry.register({
       if (!sessionId) return undefined
       // Find the tool part that matches the permission's callID
       const messages = data.store.message[sessionId] ?? []
-      const message = findLast(messages, (m) => m.id === perm.tool!.messageID)
+      const message = messages.findLast((m) => m.id === perm.tool!.messageID)
       if (!message) return undefined
       const parts = data.store.part[message.id] ?? []
       for (const part of parts) {

+ 10 - 0
packages/ui/src/components/morph-chevron.css

@@ -0,0 +1,10 @@
+[data-slot="morph-chevron-svg"] {
+  width: 16px;
+  height: 16px;
+  display: block;
+  fill: none;
+  stroke-width: 1.5;
+  stroke: currentcolor;
+  stroke-linecap: round;
+  stroke-linejoin: round;
+}

Неке датотеке нису приказане због велике количине промена