Browse Source

Merge branch 'dev' into sqlite2

Dax Raad 2 months ago
parent
commit
30a918e9d4
85 changed files with 2978 additions and 909 deletions
  1. 2 1
      README.md
  2. 134 0
      README.th.md
  3. 1 0
      STATS.md
  4. 15 15
      bun.lock
  5. 1 1
      packages/app/package.json
  6. 424 0
      packages/app/src/components/dialog-custom-provider.tsx
  7. 18 1
      packages/app/src/components/dialog-manage-models.tsx
  8. 3 3
      packages/app/src/components/dialog-select-file.tsx
  9. 1 1
      packages/app/src/components/dialog-select-model.tsx
  10. 20 3
      packages/app/src/components/dialog-select-provider.tsx
  11. 1 23
      packages/app/src/components/file-tree.tsx
  12. 7 3
      packages/app/src/components/prompt-input.tsx
  13. 1 0
      packages/app/src/components/session-context-usage.tsx
  14. 3 6
      packages/app/src/components/session/session-header.tsx
  15. 72 4
      packages/app/src/components/settings-providers.tsx
  16. 84 24
      packages/app/src/context/global-sync.tsx
  17. 39 2
      packages/app/src/context/language.tsx
  18. 3 2
      packages/app/src/context/terminal.tsx
  19. 1 0
      packages/app/src/i18n/ar.ts
  20. 1 0
      packages/app/src/i18n/br.ts
  21. 1 0
      packages/app/src/i18n/da.ts
  22. 1 0
      packages/app/src/i18n/de.ts
  23. 1 0
      packages/app/src/i18n/en.ts
  24. 1 0
      packages/app/src/i18n/es.ts
  25. 1 0
      packages/app/src/i18n/fr.ts
  26. 1 0
      packages/app/src/i18n/ja.ts
  27. 1 0
      packages/app/src/i18n/ko.ts
  28. 1 0
      packages/app/src/i18n/no.ts
  29. 1 0
      packages/app/src/i18n/pl.ts
  30. 1 0
      packages/app/src/i18n/ru.ts
  31. 718 0
      packages/app/src/i18n/th.ts
  32. 34 27
      packages/app/src/i18n/zh.ts
  33. 1 0
      packages/app/src/i18n/zht.ts
  34. 180 98
      packages/app/src/pages/session.tsx
  35. 1 1
      packages/console/app/package.json
  36. 1 1
      packages/console/core/package.json
  37. 1 1
      packages/console/function/package.json
  38. 1 1
      packages/console/mail/package.json
  39. 1 1
      packages/desktop/package.json
  40. 1 1
      packages/enterprise/package.json
  41. 6 6
      packages/extensions/zed/extension.toml
  42. 1 1
      packages/function/package.json
  43. 1 1
      packages/opencode/package.json
  44. 2 5
      packages/opencode/src/auth/index.ts
  45. 4 7
      packages/opencode/src/cli/cmd/tui/component/logo.tsx
  46. 22 9
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  47. 6 0
      packages/opencode/src/cli/logo.ts
  48. 43 14
      packages/opencode/src/cli/ui.ts
  49. 43 29
      packages/opencode/src/config/config.ts
  50. 1 0
      packages/opencode/src/flag/flag.ts
  51. 1 1
      packages/opencode/src/global/index.ts
  52. 2 5
      packages/opencode/src/mcp/auth.ts
  53. 85 1
      packages/opencode/src/plugin/codex.ts
  54. 24 1
      packages/opencode/src/plugin/copilot.ts
  55. 1 1
      packages/opencode/src/plugin/index.ts
  56. 30 7
      packages/opencode/src/project/instance.ts
  57. 7 3
      packages/opencode/src/project/state.ts
  58. 49 1
      packages/opencode/src/server/routes/global.ts
  59. 62 62
      packages/opencode/src/server/server.ts
  60. 29 2
      packages/opencode/src/session/instruction.ts
  61. 2 0
      packages/opencode/src/session/message-v2.ts
  62. 2 0
      packages/opencode/src/session/prompt.ts
  63. 1 1
      packages/opencode/src/tool/read.ts
  64. 3 1
      packages/opencode/src/tool/task.ts
  65. 6 2
      packages/opencode/test/session/instruction.test.ts
  66. 1 1
      packages/plugin/package.json
  67. 1 1
      packages/sdk/js/package.json
  68. 117 84
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  69. 148 109
      packages/sdk/js/src/v2/gen/types.gen.ts
  70. 342 286
      packages/sdk/openapi.json
  71. 1 1
      packages/slack/package.json
  72. 1 1
      packages/ui/package.json
  73. 3 1
      packages/ui/src/components/dialog.tsx
  74. 1 1
      packages/ui/src/components/list.css
  75. 3 1
      packages/ui/src/components/session-turn.tsx
  76. 1 0
      packages/ui/src/context/marked.tsx
  77. 102 0
      packages/ui/src/i18n/th.ts
  78. 2 2
      packages/ui/src/i18n/zh.ts
  79. 1 1
      packages/util/package.json
  80. 1 1
      packages/web/package.json
  81. 32 31
      packages/web/src/content/docs/ecosystem.mdx
  82. 1 1
      packages/web/src/content/docs/zen.mdx
  83. 4 4
      script/publish-complete.ts
  84. 2 2
      script/publish-start.ts
  85. 1 1
      sdks/vscode/package.json

+ 2 - 1
README.md

@@ -29,7 +29,8 @@
   <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>
 </p>
 
 [![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)

+ 134 - 0
README.th.md

@@ -0,0 +1,134 @@
+<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">เอเจนต์การเขียนโค้ดด้วย AI แบบโอเพนซอร์ส</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="สถานะการสร้าง" 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>
+</p>
+
+[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai)
+
+---
+
+### การติดตั้ง
+
+```bash
+# YOLO
+curl -fsSL https://opencode.ai/install | bash
+
+# ตัวจัดการแพ็กเกจ
+npm i -g opencode-ai@latest        # หรือ bun/pnpm/yarn
+scoop install opencode             # Windows
+choco install opencode             # Windows
+brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
+brew install opencode              # macOS และ Linux (brew formula อย่างเป็นทางการ อัปเดตน้อยกว่า)
+paru -S opencode-bin               # Arch Linux
+mise use -g opencode               # ระบบปฏิบัติการใดก็ได้
+nix run nixpkgs#opencode           # หรือ github:anomalyco/opencode สำหรับสาขาพัฒนาล่าสุด
+```
+
+> [!TIP]
+> ลบเวอร์ชันที่เก่ากว่า 0.1.x ก่อนติดตั้ง
+
+### แอปพลิเคชันเดสก์ท็อป (เบต้า)
+
+OpenCode มีให้ใช้งานเป็นแอปพลิเคชันเดสก์ท็อป ดาวน์โหลดโดยตรงจาก [หน้ารุ่น](https://github.com/anomalyco/opencode/releases) หรือ [opencode.ai/download](https://opencode.ai/download)
+
+| แพลตฟอร์ม             | ดาวน์โหลด                             |
+| --------------------- | ------------------------------------- |
+| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
+| macOS (Intel)         | `opencode-desktop-darwin-x64.dmg`     |
+| Windows               | `opencode-desktop-windows-x64.exe`    |
+| Linux                 | `.deb`, `.rpm`, หรือ AppImage         |
+
+```bash
+# macOS (Homebrew)
+brew install --cask opencode-desktop
+# Windows (Scoop)
+scoop bucket add extras; scoop install extras/opencode-desktop
+```
+
+#### ไดเรกทอรีการติดตั้ง
+
+สคริปต์การติดตั้งจะใช้ลำดับความสำคัญตามเส้นทางการติดตั้ง:
+
+1. `$OPENCODE_INSTALL_DIR` - ไดเรกทอรีการติดตั้งที่กำหนดเอง
+2. `$XDG_BIN_DIR` - เส้นทางที่สอดคล้องกับ XDG Base Directory Specification
+3. `$HOME/bin` - ไดเรกทอรีไบนารีผู้ใช้มาตรฐาน (หากมีอยู่หรือสามารถสร้างได้)
+4. `$HOME/.opencode/bin` - ค่าสำรองเริ่มต้น
+
+```bash
+# ตัวอย่าง
+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
+```
+
+### เอเจนต์
+
+OpenCode รวมเอเจนต์ในตัวสองตัวที่คุณสามารถสลับได้ด้วยปุ่ม `Tab`
+
+- **build** - เอเจนต์เริ่มต้น มีสิทธิ์เข้าถึงแบบเต็มสำหรับงานพัฒนา
+- **plan** - เอเจนต์อ่านอย่างเดียวสำหรับการวิเคราะห์และการสำรวจโค้ด
+  - ปฏิเสธการแก้ไขไฟล์โดยค่าเริ่มต้น
+  - ขอสิทธิ์ก่อนเรียกใช้คำสั่ง bash
+  - เหมาะสำหรับสำรวจโค้ดเบสที่ไม่คุ้นเคยหรือวางแผนการเปลี่ยนแปลง
+
+นอกจากนี้ยังมีเอเจนต์ย่อย **general** สำหรับการค้นหาที่ซับซ้อนและงานหลายขั้นตอน
+ใช้ภายในและสามารถเรียกใช้ได้โดยใช้ `@general` ในข้อความ
+
+เรียนรู้เพิ่มเติมเกี่ยวกับ [เอเจนต์](https://opencode.ai/docs/agents)
+
+### เอกสารประกอบ
+
+สำหรับข้อมูลเพิ่มเติมเกี่ยวกับวิธีกำหนดค่า OpenCode [**ไปที่เอกสารของเรา**](https://opencode.ai/docs)
+
+### การมีส่วนร่วม
+
+หากคุณสนใจที่จะมีส่วนร่วมใน OpenCode โปรดอ่าน [เอกสารการมีส่วนร่วม](./CONTRIBUTING.md) ก่อนส่ง Pull Request
+
+### การสร้างบน OpenCode
+
+หากคุณทำงานในโปรเจกต์ที่เกี่ยวข้องกับ OpenCode และใช้ "opencode" เป็นส่วนหนึ่งของชื่อ เช่น "opencode-dashboard" หรือ "opencode-mobile" โปรดเพิ่มหมายเหตุใน README ของคุณเพื่อชี้แจงว่าไม่ได้สร้างโดยทีม OpenCode และไม่ได้เกี่ยวข้องกับเราในทางใด
+
+### คำถามที่พบบ่อย
+
+#### ต่างจาก Claude Code อย่างไร?
+
+คล้ายกับ Claude Code มากในแง่ความสามารถ นี่คือความแตกต่างหลัก:
+
+- โอเพนซอร์ส 100%
+- ไม่ผูกมัดกับผู้ให้บริการใดๆ แม้ว่าเราจะแนะนำโมเดลที่เราจัดหาให้ผ่าน [OpenCode Zen](https://opencode.ai/zen) OpenCode สามารถใช้กับ Claude, OpenAI, Google หรือแม้กระทั่งโมเดลในเครื่องได้ เมื่อโมเดลพัฒนาช่องว่างระหว่างพวกมันจะปิดลงและราคาจะลดลง ดังนั้นการไม่ผูกมัดกับผู้ให้บริการจึงสำคัญ
+- รองรับ LSP ใช้งานได้ทันทีหลังการติดตั้งโดยไม่ต้องปรับแต่งหรือเปลี่ยนแปลงฟังก์ชันการทำงานใด ๆ
+- เน้นที่ TUI OpenCode สร้างโดยผู้ใช้ neovim และผู้สร้าง [terminal.shop](https://terminal.shop) เราจะผลักดันขีดจำกัดของสิ่งที่เป็นไปได้ในเทอร์มินัล
+- สถาปัตยกรรมไคลเอนต์/เซิร์ฟเวอร์ ตัวอย่างเช่น อาจอนุญาตให้ OpenCode ทำงานบนคอมพิวเตอร์ของคุณ ในขณะที่คุณสามารถขับเคลื่อนจากระยะไกลผ่านแอปมือถือ หมายความว่า TUI frontend เป็นหนึ่งในไคลเอนต์ที่เป็นไปได้เท่านั้น
+
+---
+
+**ร่วมชุมชนของเรา** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)

+ 1 - 0
STATS.md

@@ -213,3 +213,4 @@
 | 2026-01-25 | 6,639,082 (+268,063) | 2,187,853 (+30,983)  | 8,826,935 (+299,046) |
 | 2026-01-26 | 6,941,620 (+302,538) | 2,232,115 (+44,262)  | 9,173,735 (+346,800) |
 | 2026-01-27 | 7,208,093 (+266,473) | 2,280,762 (+48,647)  | 9,488,855 (+315,120) |
+| 2026-01-28 | 7,489,370 (+281,277) | 2,314,849 (+34,087)  | 9,804,219 (+315,364) |

+ 15 - 15
bun.lock

@@ -23,7 +23,7 @@
     },
     "packages/app": {
       "name": "@opencode-ai/app",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
     },
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
     },
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
     },
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@ai-sdk/anthropic": "2.0.0",
         "@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
     },
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
     },
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -212,7 +212,7 @@
     },
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/util": "workspace:*",
@@ -241,7 +241,7 @@
     },
     "packages/function": {
       "name": "@opencode-ai/function",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
@@ -257,7 +257,7 @@
     },
     "packages/opencode": {
       "name": "opencode",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -364,7 +364,7 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "zod": "catalog:",
@@ -384,7 +384,7 @@
     },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "devDependencies": {
         "@hey-api/openapi-ts": "0.90.10",
         "@tsconfig/node22": "catalog:",
@@ -395,7 +395,7 @@
     },
     "packages/slack": {
       "name": "@opencode-ai/slack",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
@@ -408,7 +408,7 @@
     },
     "packages/ui": {
       "name": "@opencode-ai/ui",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -450,7 +450,7 @@
     },
     "packages/util": {
       "name": "@opencode-ai/util",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "zod": "catalog:",
       },
@@ -461,7 +461,7 @@
     },
     "packages/web": {
       "name": "@opencode-ai/web",
-      "version": "1.1.36",
+      "version": "1.1.40",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",

+ 1 - 1
packages/app/package.json

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

+ 424 - 0
packages/app/src/components/dialog-custom-provider.tsx

@@ -0,0 +1,424 @@
+import { Button } from "@opencode-ai/ui/button"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { showToast } from "@opencode-ai/ui/toast"
+import { For } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { Link } from "@/components/link"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
+import { useLanguage } from "@/context/language"
+import { DialogSelectProvider } from "./dialog-select-provider"
+
+const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/
+const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible"
+
+type Props = {
+  back?: "providers" | "close"
+}
+
+export function DialogCustomProvider(props: Props) {
+  const dialog = useDialog()
+  const globalSync = useGlobalSync()
+  const globalSDK = useGlobalSDK()
+  const language = useLanguage()
+
+  const [form, setForm] = createStore({
+    providerID: "",
+    name: "",
+    baseURL: "",
+    apiKey: "",
+    models: [{ id: "", name: "" }],
+    headers: [{ key: "", value: "" }],
+    saving: false,
+  })
+
+  const [errors, setErrors] = createStore({
+    providerID: undefined as string | undefined,
+    name: undefined as string | undefined,
+    baseURL: undefined as string | undefined,
+    models: [{} as { id?: string; name?: string }],
+    headers: [{} as { key?: string; value?: string }],
+  })
+
+  const goBack = () => {
+    if (props.back === "close") {
+      dialog.close()
+      return
+    }
+    dialog.show(() => <DialogSelectProvider />)
+  }
+
+  const addModel = () => {
+    setForm(
+      "models",
+      produce((draft) => {
+        draft.push({ id: "", name: "" })
+      }),
+    )
+    setErrors(
+      "models",
+      produce((draft) => {
+        draft.push({})
+      }),
+    )
+  }
+
+  const removeModel = (index: number) => {
+    if (form.models.length <= 1) return
+    setForm(
+      "models",
+      produce((draft) => {
+        draft.splice(index, 1)
+      }),
+    )
+    setErrors(
+      "models",
+      produce((draft) => {
+        draft.splice(index, 1)
+      }),
+    )
+  }
+
+  const addHeader = () => {
+    setForm(
+      "headers",
+      produce((draft) => {
+        draft.push({ key: "", value: "" })
+      }),
+    )
+    setErrors(
+      "headers",
+      produce((draft) => {
+        draft.push({})
+      }),
+    )
+  }
+
+  const removeHeader = (index: number) => {
+    if (form.headers.length <= 1) return
+    setForm(
+      "headers",
+      produce((draft) => {
+        draft.splice(index, 1)
+      }),
+    )
+    setErrors(
+      "headers",
+      produce((draft) => {
+        draft.splice(index, 1)
+      }),
+    )
+  }
+
+  const validate = () => {
+    const providerID = form.providerID.trim()
+    const name = form.name.trim()
+    const baseURL = form.baseURL.trim()
+    const apiKey = form.apiKey.trim()
+
+    const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim()
+    const key = apiKey && !env ? apiKey : undefined
+
+    const idError = !providerID
+      ? "Provider ID is required"
+      : !PROVIDER_ID.test(providerID)
+        ? "Use lowercase letters, numbers, hyphens, or underscores"
+        : undefined
+
+    const nameError = !name ? "Display name is required" : undefined
+    const urlError = !baseURL
+      ? "Base URL is required"
+      : !/^https?:\/\//.test(baseURL)
+        ? "Must start with http:// or https://"
+        : undefined
+
+    const disabled = (globalSync.data.config.disabled_providers ?? []).includes(providerID)
+    const existingProvider = globalSync.data.provider.all.find((p) => p.id === providerID)
+    const existsError = idError
+      ? undefined
+      : existingProvider && !disabled
+        ? "That provider ID already exists"
+        : undefined
+
+    const seenModels = new Set<string>()
+    const modelErrors = form.models.map((m) => {
+      const id = m.id.trim()
+      const modelIdError = !id
+        ? "Required"
+        : seenModels.has(id)
+          ? "Duplicate"
+          : (() => {
+              seenModels.add(id)
+              return undefined
+            })()
+      const modelNameError = !m.name.trim() ? "Required" : undefined
+      return { id: modelIdError, name: modelNameError }
+    })
+    const modelsValid = modelErrors.every((m) => !m.id && !m.name)
+    const models = Object.fromEntries(form.models.map((m) => [m.id.trim(), { name: m.name.trim() }]))
+
+    const seenHeaders = new Set<string>()
+    const headerErrors = form.headers.map((h) => {
+      const key = h.key.trim()
+      const value = h.value.trim()
+
+      if (!key && !value) return {}
+      const keyError = !key
+        ? "Required"
+        : seenHeaders.has(key.toLowerCase())
+          ? "Duplicate"
+          : (() => {
+              seenHeaders.add(key.toLowerCase())
+              return undefined
+            })()
+      const valueError = !value ? "Required" : undefined
+      return { key: keyError, value: valueError }
+    })
+    const headersValid = headerErrors.every((h) => !h.key && !h.value)
+    const headers = Object.fromEntries(
+      form.headers
+        .map((h) => ({ key: h.key.trim(), value: h.value.trim() }))
+        .filter((h) => !!h.key && !!h.value)
+        .map((h) => [h.key, h.value]),
+    )
+
+    setErrors(
+      produce((draft) => {
+        draft.providerID = idError ?? existsError
+        draft.name = nameError
+        draft.baseURL = urlError
+        draft.models = modelErrors
+        draft.headers = headerErrors
+      }),
+    )
+
+    const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid
+    if (!ok) return
+
+    const options = {
+      baseURL,
+      ...(Object.keys(headers).length ? { headers } : {}),
+    }
+
+    return {
+      providerID,
+      name,
+      key,
+      config: {
+        npm: OPENAI_COMPATIBLE,
+        name,
+        ...(env ? { env: [env] } : {}),
+        options,
+        models,
+      },
+    }
+  }
+
+  const save = async (e: SubmitEvent) => {
+    e.preventDefault()
+    if (form.saving) return
+
+    const result = validate()
+    if (!result) return
+
+    setForm("saving", true)
+
+    const disabledProviders = globalSync.data.config.disabled_providers ?? []
+    const nextDisabled = disabledProviders.filter((id) => id !== result.providerID)
+
+    const auth = result.key
+      ? globalSDK.client.auth.set({
+          providerID: result.providerID,
+          auth: {
+            type: "api",
+            key: result.key,
+          },
+        })
+      : Promise.resolve()
+
+    auth
+      .then(() =>
+        globalSync.updateConfig({ provider: { [result.providerID]: result.config }, disabled_providers: nextDisabled }),
+      )
+      .then(() => {
+        dialog.close()
+        showToast({
+          variant: "success",
+          icon: "circle-check",
+          title: language.t("provider.connect.toast.connected.title", { provider: result.name }),
+          description: language.t("provider.connect.toast.connected.description", { provider: result.name }),
+        })
+      })
+      .catch((err: unknown) => {
+        const message = err instanceof Error ? err.message : String(err)
+        showToast({ title: language.t("common.requestFailed"), description: message })
+      })
+      .finally(() => {
+        setForm("saving", false)
+      })
+  }
+
+  return (
+    <Dialog
+      title={
+        <IconButton
+          tabIndex={-1}
+          icon="arrow-left"
+          variant="ghost"
+          onClick={goBack}
+          aria-label={language.t("common.goBack")}
+        />
+      }
+      transition
+    >
+      <div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
+        <div class="px-2.5 flex gap-4 items-center">
+          <ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
+          <div class="text-16-medium text-text-strong">Custom provider</div>
+        </div>
+
+        <form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
+          <p class="text-14-regular text-text-base">
+            Configure an OpenAI-compatible provider. See the{" "}
+            <Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
+              provider config docs
+            </Link>
+            .
+          </p>
+
+          <div class="flex flex-col gap-4">
+            <TextField
+              autofocus
+              label="Provider ID"
+              placeholder="myprovider"
+              description="Lowercase letters, numbers, hyphens, or underscores"
+              value={form.providerID}
+              onChange={setForm.bind(null, "providerID")}
+              validationState={errors.providerID ? "invalid" : undefined}
+              error={errors.providerID}
+            />
+            <TextField
+              label="Display name"
+              placeholder="My AI Provider"
+              value={form.name}
+              onChange={setForm.bind(null, "name")}
+              validationState={errors.name ? "invalid" : undefined}
+              error={errors.name}
+            />
+            <TextField
+              label="Base URL"
+              placeholder="https://api.myprovider.com/v1"
+              value={form.baseURL}
+              onChange={setForm.bind(null, "baseURL")}
+              validationState={errors.baseURL ? "invalid" : undefined}
+              error={errors.baseURL}
+            />
+            <TextField
+              label="API key"
+              placeholder="API key"
+              description="Optional. Leave empty if you manage auth via headers."
+              value={form.apiKey}
+              onChange={setForm.bind(null, "apiKey")}
+            />
+          </div>
+
+          <div class="flex flex-col gap-3">
+            <label class="text-12-medium text-text-weak">Models</label>
+            <For each={form.models}>
+              {(m, i) => (
+                <div class="flex gap-2 items-start">
+                  <div class="flex-1">
+                    <TextField
+                      label="ID"
+                      hideLabel
+                      placeholder="model-id"
+                      value={m.id}
+                      onChange={(v) => setForm("models", i(), "id", v)}
+                      validationState={errors.models[i()]?.id ? "invalid" : undefined}
+                      error={errors.models[i()]?.id}
+                    />
+                  </div>
+                  <div class="flex-1">
+                    <TextField
+                      label="Name"
+                      hideLabel
+                      placeholder="Display Name"
+                      value={m.name}
+                      onChange={(v) => setForm("models", i(), "name", v)}
+                      validationState={errors.models[i()]?.name ? "invalid" : undefined}
+                      error={errors.models[i()]?.name}
+                    />
+                  </div>
+                  <IconButton
+                    type="button"
+                    icon="trash"
+                    variant="ghost"
+                    class="mt-1.5"
+                    onClick={() => removeModel(i())}
+                    disabled={form.models.length <= 1}
+                    aria-label="Remove model"
+                  />
+                </div>
+              )}
+            </For>
+            <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
+              Add model
+            </Button>
+          </div>
+
+          <div class="flex flex-col gap-3">
+            <label class="text-12-medium text-text-weak">Headers (optional)</label>
+            <For each={form.headers}>
+              {(h, i) => (
+                <div class="flex gap-2 items-start">
+                  <div class="flex-1">
+                    <TextField
+                      label="Header"
+                      hideLabel
+                      placeholder="Header-Name"
+                      value={h.key}
+                      onChange={(v) => setForm("headers", i(), "key", v)}
+                      validationState={errors.headers[i()]?.key ? "invalid" : undefined}
+                      error={errors.headers[i()]?.key}
+                    />
+                  </div>
+                  <div class="flex-1">
+                    <TextField
+                      label="Value"
+                      hideLabel
+                      placeholder="value"
+                      value={h.value}
+                      onChange={(v) => setForm("headers", i(), "value", v)}
+                      validationState={errors.headers[i()]?.value ? "invalid" : undefined}
+                      error={errors.headers[i()]?.value}
+                    />
+                  </div>
+                  <IconButton
+                    type="button"
+                    icon="trash"
+                    variant="ghost"
+                    class="mt-1.5"
+                    onClick={() => removeHeader(i())}
+                    disabled={form.headers.length <= 1}
+                    aria-label="Remove header"
+                  />
+                </div>
+              )}
+            </For>
+            <Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
+              Add header
+            </Button>
+          </div>
+
+          <Button class="w-auto self-start" type="submit" size="large" variant="primary" disabled={form.saving}>
+            {form.saving ? "Saving..." : language.t("common.submit")}
+          </Button>
+        </form>
+      </div>
+    </Dialog>
+  )
+}

+ 18 - 1
packages/app/src/components/dialog-manage-models.tsx

@@ -1,16 +1,33 @@
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { Switch } from "@opencode-ai/ui/switch"
+import { Button } from "@opencode-ai/ui/button"
 import type { Component } from "solid-js"
 import { useLocal } from "@/context/local"
 import { popularProviders } from "@/hooks/use-providers"
 import { useLanguage } from "@/context/language"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { DialogSelectProvider } from "./dialog-select-provider"
 
 export const DialogManageModels: Component = () => {
   const local = useLocal()
   const language = useLanguage()
+  const dialog = useDialog()
+
+  const handleConnectProvider = () => {
+    dialog.show(() => <DialogSelectProvider />)
+  }
+
   return (
-    <Dialog title={language.t("dialog.model.manage")} description={language.t("dialog.model.manage.description")}>
+    <Dialog
+      title={language.t("dialog.model.manage")}
+      description={language.t("dialog.model.manage.description")}
+      action={
+        <Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={handleConnectProvider}>
+          {language.t("command.provider.connect")}
+        </Button>
+      }
+    >
       <List
         search={{ placeholder: language.t("dialog.model.search.placeholder"), autofocus: true }}
         emptyMessage={language.t("dialog.model.empty")}

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

@@ -44,7 +44,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
     "session.previous",
     "session.next",
     "terminal.toggle",
-    "fileTree.toggle",
+    "review.toggle",
   ]
   const limit = 5
 
@@ -162,6 +162,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
     const value = file.tab(path)
     tabs().open(value)
     file.load(path)
+    layout.fileTree.open()
     layout.fileTree.setTab("all")
     props.onOpenFile?.(path)
   }
@@ -195,7 +196,6 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
             : language.t("palette.search.placeholder"),
           autofocus: true,
           hideIcon: true,
-          class: "pl-3 pr-2 !mb-0",
         }}
         emptyMessage={language.t("palette.empty")}
         loadingMessage={language.t("common.loading")}
@@ -223,7 +223,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
               </div>
             }
           >
-            <div class="w-full flex items-center justify-between gap-4 pl-1">
+            <div class="w-full flex items-center justify-between gap-4">
               <div class="flex items-center gap-2 min-w-0">
                 <span class="text-14-regular text-text-strong whitespace-nowrap">{item.title}</span>
                 <Show when={item.description}>

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

@@ -187,7 +187,7 @@ export function ModelSelectorPopover<T extends ValidComponent = "div">(props: {
       <Kobalte.Portal>
         <Kobalte.Content
           ref={(el) => setStore("content", el)}
-          class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
+          class="w-72 h-80 flex flex-col p-2 rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none overflow-hidden"
           onEscapeKeyDown={(event) => {
             setStore("dismiss", "escape")
             setStore("open", false)

+ 20 - 3
packages/app/src/components/dialog-select-provider.tsx

@@ -5,9 +5,17 @@ import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { Tag } from "@opencode-ai/ui/tag"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { IconName } from "@opencode-ai/ui/icons/provider"
+import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
 import { DialogConnectProvider } from "./dialog-connect-provider"
 import { useLanguage } from "@/context/language"
+import { DialogCustomProvider } from "./dialog-custom-provider"
+
+const CUSTOM_ID = "_custom"
+
+function icon(id: string): IconName {
+  if (iconNames.includes(id as IconName)) return id as IconName
+  return "synthetic"
+}
 
 export const DialogSelectProvider: Component = () => {
   const dialog = useDialog()
@@ -26,11 +34,13 @@ export const DialogSelectProvider: Component = () => {
         key={(x) => x?.id}
         items={() => {
           language.locale()
-          return providers.all()
+          return [{ id: CUSTOM_ID, name: "Custom provider" }, ...providers.all()]
         }}
         filterKeys={["id", "name"]}
         groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
         sortBy={(a, b) => {
+          if (a.id === CUSTOM_ID) return -1
+          if (b.id === CUSTOM_ID) return 1
           if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
             return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
           return a.name.localeCompare(b.name)
@@ -43,13 +53,20 @@ export const DialogSelectProvider: Component = () => {
         }}
         onSelect={(x) => {
           if (!x) return
+          if (x.id === CUSTOM_ID) {
+            dialog.show(() => <DialogCustomProvider back="providers" />)
+            return
+          }
           dialog.show(() => <DialogConnectProvider provider={x.id} />)
         }}
       >
         {(i) => (
           <div class="px-1.25 w-full flex items-center gap-x-3">
-            <ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
+            <ProviderIcon data-slot="list-item-extra-icon" id={icon(i.id)} />
             <span>{i.name}</span>
+            <Show when={i.id === CUSTOM_ID}>
+              <Tag>{language.t("settings.providers.tag.custom")}</Tag>
+            </Show>
             <Show when={i.id === "opencode"}>
               <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
             </Show>

+ 1 - 23
packages/app/src/components/file-tree.tsx

@@ -8,7 +8,6 @@ import {
   createMemo,
   For,
   Match,
-  onCleanup,
   Show,
   splitProps,
   Switch,
@@ -124,28 +123,7 @@ export default function FileTree(props: {
 
   createEffect(() => {
     const path = props.path
-    const state = { cancelled: false, timer: undefined as number | undefined }
-
-    const load = (attempt: number) => {
-      if (state.cancelled) return
-      if (file.tree.state(path)?.loaded) return
-
-      void untrack(() => file.tree.list(path)).finally(() => {
-        if (state.cancelled) return
-        if (file.tree.state(path)?.loaded) return
-        if (attempt >= 2) return
-
-        const wait = Math.min(2000, 250 * 2 ** attempt)
-        state.timer = window.setTimeout(() => load(attempt + 1), wait)
-      })
-    }
-
-    load(0)
-
-    onCleanup(() => {
-      state.cancelled = true
-      if (state.timer !== undefined) clearTimeout(state.timer)
-    })
+    untrack(() => void file.tree.list(path))
   })
 
   const nodes = createMemo(() => {

+ 7 - 3
packages/app/src/components/prompt-input.tsx

@@ -189,11 +189,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
     if (wantsReview) {
+      layout.fileTree.open()
       layout.fileTree.setTab("changes")
       requestAnimationFrame(() => comments.setFocus(focus))
       return
     }
 
+    layout.fileTree.open()
     layout.fileTree.setTab("all")
     const tab = files.tab(item.path)
     tabs().open(tab)
@@ -1036,13 +1038,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       return
     }
 
+    const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
+
     if (store.popover) {
       if (event.key === "Tab") {
         selectPopoverActive()
         event.preventDefault()
         return
       }
-      if (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter") {
+      const nav = event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter"
+      const ctrlNav = ctrl && (event.key === "n" || event.key === "p")
+      if (nav || ctrlNav) {
         if (store.popover === "at") {
           atOnKeyDown(event)
           event.preventDefault()
@@ -1056,8 +1062,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       }
     }
 
-    const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
-
     if (ctrl && event.code === "KeyG") {
       if (store.popover) {
         setStore("popover", null)

+ 1 - 0
packages/app/src/components/session-context-usage.tsx

@@ -57,6 +57,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
 
   const openContext = () => {
     if (!params.id) return
+    layout.fileTree.open()
     layout.fileTree.setTab("all")
     tabs().open("context")
     tabs().setActive("context")

+ 3 - 6
packages/app/src/components/session/session-header.tsx

@@ -280,17 +280,14 @@ export function SessionHeader() {
                 </TooltipKeybind>
               </div>
               <div class="hidden md:block shrink-0">
-                <TooltipKeybind
-                  title={language.t("command.fileTree.toggle")}
-                  keybind={command.keybind("fileTree.toggle")}
-                >
+                <TooltipKeybind title={language.t("command.review.toggle")} keybind={command.keybind("review.toggle")}>
                   <Button
                     variant="ghost"
                     class="group/file-tree-toggle size-6 p-0"
                     onClick={() => layout.fileTree.toggle()}
-                    aria-label={language.t("command.fileTree.toggle")}
+                    aria-label={language.t("command.review.toggle")}
                     aria-expanded={layout.fileTree.opened()}
-                    aria-controls="file-tree-panel"
+                    aria-controls="review-panel"
                   >
                     <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
                       <Icon

+ 72 - 4
packages/app/src/components/settings-providers.tsx

@@ -3,13 +3,15 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Tag } from "@opencode-ai/ui/tag"
 import { showToast } from "@opencode-ai/ui/toast"
-import type { IconName } from "@opencode-ai/ui/icons/provider"
+import { iconNames, type IconName } from "@opencode-ai/ui/icons/provider"
 import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { createMemo, type Component, For, Show } from "solid-js"
 import { useLanguage } from "@/context/language"
 import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
 import { DialogConnectProvider } from "./dialog-connect-provider"
 import { DialogSelectProvider } from "./dialog-select-provider"
+import { DialogCustomProvider } from "./dialog-custom-provider"
 
 type ProviderSource = "env" | "api" | "config" | "custom"
 type ProviderMeta = { source?: ProviderSource }
@@ -18,8 +20,14 @@ export const SettingsProviders: Component = () => {
   const dialog = useDialog()
   const language = useLanguage()
   const globalSDK = useGlobalSDK()
+  const globalSync = useGlobalSync()
   const providers = useProviders()
 
+  const icon = (id: string): IconName => {
+    if (iconNames.includes(id as IconName)) return id as IconName
+    return "synthetic"
+  }
+
   const connected = createMemo(() => {
     return providers
       .connected()
@@ -42,14 +50,53 @@ export const SettingsProviders: Component = () => {
     const current = source(item)
     if (current === "env") return language.t("settings.providers.tag.environment")
     if (current === "api") return language.t("provider.connect.method.apiKey")
-    if (current === "config") return language.t("settings.providers.tag.config")
+    if (current === "config") {
+      const id = (item as { id?: string }).id
+      if (id && isConfigCustom(id)) return language.t("settings.providers.tag.custom")
+      return language.t("settings.providers.tag.config")
+    }
     if (current === "custom") return language.t("settings.providers.tag.custom")
     return language.t("settings.providers.tag.other")
   }
 
   const canDisconnect = (item: unknown) => source(item) !== "env"
 
+  const isConfigCustom = (providerID: string) => {
+    const provider = globalSync.data.config.provider?.[providerID]
+    if (!provider) return false
+    if (provider.npm !== "@ai-sdk/openai-compatible") return false
+    if (!provider.models || Object.keys(provider.models).length === 0) return false
+    return true
+  }
+
+  const disableProvider = async (providerID: string, name: string) => {
+    const before = globalSync.data.config.disabled_providers ?? []
+    const next = before.includes(providerID) ? before : [...before, providerID]
+    globalSync.set("config", "disabled_providers", next)
+
+    await globalSync
+      .updateConfig({ disabled_providers: next })
+      .then(() => {
+        showToast({
+          variant: "success",
+          icon: "circle-check",
+          title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }),
+          description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }),
+        })
+      })
+      .catch((err: unknown) => {
+        globalSync.set("config", "disabled_providers", before)
+        const message = err instanceof Error ? err.message : String(err)
+        showToast({ title: language.t("common.requestFailed"), description: message })
+      })
+  }
+
   const disconnect = async (providerID: string, name: string) => {
+    if (isConfigCustom(providerID)) {
+      await globalSDK.client.auth.remove({ providerID }).catch(() => undefined)
+      await disableProvider(providerID, name)
+      return
+    }
     await globalSDK.client.auth
       .remove({ providerID })
       .then(async () => {
@@ -91,7 +138,7 @@ export const SettingsProviders: Component = () => {
                 {(item) => (
                   <div class="group flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none">
                     <div class="flex items-center gap-3 min-w-0">
-                      <ProviderIcon id={item.id as IconName} class="size-5 shrink-0 icon-strong-base" />
+                      <ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
                       <span class="text-14-medium text-text-strong truncate">{item.name}</span>
                       <Tag>{type(item)}</Tag>
                     </div>
@@ -122,7 +169,7 @@ export const SettingsProviders: Component = () => {
                 <div class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none">
                   <div class="flex flex-col min-w-0">
                     <div class="flex items-center gap-x-3">
-                      <ProviderIcon id={item.id as IconName} class="size-5 shrink-0 icon-strong-base" />
+                      <ProviderIcon id={icon(item.id)} class="size-5 shrink-0 icon-strong-base" />
                       <span class="text-14-medium text-text-strong">{item.name}</span>
                       <Show when={item.id === "opencode"}>
                         <Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
@@ -177,6 +224,27 @@ export const SettingsProviders: Component = () => {
                 </div>
               )}
             </For>
+
+            <div class="flex items-center justify-between gap-4 h-16 border-b border-border-weak-base last:border-none">
+              <div class="flex flex-col min-w-0">
+                <div class="flex items-center gap-x-3">
+                  <ProviderIcon id={icon("synthetic")} class="size-5 shrink-0 icon-strong-base" />
+                  <span class="text-14-medium text-text-strong">Custom provider</span>
+                  <Tag>{language.t("settings.providers.tag.custom")}</Tag>
+                </div>
+                <span class="text-12-regular text-text-weak pl-8">Add an OpenAI-compatible provider by base URL.</span>
+              </div>
+              <Button
+                size="large"
+                variant="secondary"
+                icon="plus-small"
+                onClick={() => {
+                  dialog.show(() => <DialogCustomProvider back="close" />)
+                }}
+              >
+                {language.t("common.connect")}
+              </Button>
+            </div>
           </div>
 
           <Button

+ 84 - 24
packages/app/src/context/global-sync.tsx

@@ -188,7 +188,74 @@ function createGlobalSync() {
     config: {},
     reload: undefined,
   })
-  let bootstrapQueue: string[] = []
+
+  const queued = new Set<string>()
+  let root = false
+  let running = false
+  let timer: ReturnType<typeof setTimeout> | undefined
+
+  const paused = () => untrack(() => globalStore.reload) !== undefined
+
+  const tick = () => new Promise<void>((resolve) => setTimeout(resolve, 0))
+
+  const take = (count: number) => {
+    if (queued.size === 0) return [] as string[]
+    const items: string[] = []
+    for (const item of queued) {
+      queued.delete(item)
+      items.push(item)
+      if (items.length >= count) break
+    }
+    return items
+  }
+
+  const schedule = () => {
+    if (timer) return
+    timer = setTimeout(() => {
+      timer = undefined
+      void drain()
+    }, 0)
+  }
+
+  const push = (directory: string) => {
+    if (!directory) return
+    queued.add(directory)
+    if (paused()) return
+    schedule()
+  }
+
+  const refresh = () => {
+    root = true
+    if (paused()) return
+    schedule()
+  }
+
+  async function drain() {
+    if (running) return
+    running = true
+    try {
+      while (true) {
+        if (paused()) return
+
+        if (root) {
+          root = false
+          await bootstrap()
+          await tick()
+          continue
+        }
+
+        const dirs = take(2)
+        if (dirs.length === 0) return
+
+        await Promise.all(dirs.map((dir) => bootstrapInstance(dir)))
+        await tick()
+      }
+    } finally {
+      running = false
+      if (paused()) return
+      if (root || queued.size) schedule()
+    }
+  }
 
   createEffect(() => {
     if (!projectCacheReady()) return
@@ -210,14 +277,8 @@ function createGlobalSync() {
 
   createEffect(() => {
     if (globalStore.reload !== "complete") return
-    if (bootstrapQueue.length) {
-      for (const directory of bootstrapQueue) {
-        bootstrapInstance(directory)
-      }
-      bootstrap()
-    }
-    bootstrapQueue = []
     setGlobalStore("reload", undefined)
+    refresh()
   })
 
   const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
@@ -584,9 +645,8 @@ function createGlobalSync() {
     if (directory === "global") {
       switch (event?.type) {
         case "global.disposed": {
-          if (globalStore.reload) return
-          bootstrap()
-          break
+          refresh()
+          return
         }
         case "project.updated": {
           const result = Binary.search(globalStore.project, event.properties.id, (s) => s.id)
@@ -647,12 +707,8 @@ function createGlobalSync() {
 
     switch (event.type) {
       case "server.instance.disposed": {
-        if (globalStore.reload) {
-          bootstrapQueue.push(directory)
-          return
-        }
-        bootstrapInstance(directory)
-        break
+        push(directory)
+        return
       }
       case "session.created": {
         const info = event.properties.info
@@ -893,6 +949,10 @@ function createGlobalSync() {
     }
   })
   onCleanup(unsub)
+  onCleanup(() => {
+    if (!timer) return
+    clearTimeout(timer)
+  })
 
   async function bootstrap() {
     const health = await globalSDK.client.global
@@ -916,7 +976,7 @@ function createGlobalSync() {
         }),
       ),
       retry(() =>
-        globalSDK.client.config.get().then((x) => {
+        globalSDK.client.global.config.get().then((x) => {
           setGlobalStore("config", x.data!)
         }),
       ),
@@ -999,13 +1059,13 @@ function createGlobalSync() {
     },
     child,
     bootstrap,
-    updateConfig: async (config: Config) => {
+    updateConfig: (config: Config) => {
       setGlobalStore("reload", "pending")
-      const response = await globalSDK.client.config.update({ config })
-      setTimeout(() => {
-        setGlobalStore("reload", "complete")
-      }, 1000)
-      return response
+      return globalSDK.client.global.config.update({ config }).finally(() => {
+        setTimeout(() => {
+          setGlobalStore("reload", "complete")
+        }, 1000)
+      })
     },
     project: {
       loadSessions,

+ 39 - 2
packages/app/src/context/language.tsx

@@ -17,6 +17,7 @@ import { dict as ru } from "@/i18n/ru"
 import { dict as ar } from "@/i18n/ar"
 import { dict as no } from "@/i18n/no"
 import { dict as br } from "@/i18n/br"
+import { dict as th } from "@/i18n/th"
 import { dict as uiEn } from "@opencode-ai/ui/i18n/en"
 import { dict as uiZh } from "@opencode-ai/ui/i18n/zh"
 import { dict as uiZht } from "@opencode-ai/ui/i18n/zht"
@@ -31,13 +32,45 @@ import { dict as uiRu } from "@opencode-ai/ui/i18n/ru"
 import { dict as uiAr } from "@opencode-ai/ui/i18n/ar"
 import { dict as uiNo } from "@opencode-ai/ui/i18n/no"
 import { dict as uiBr } from "@opencode-ai/ui/i18n/br"
+import { dict as uiTh } from "@opencode-ai/ui/i18n/th"
 
-export type Locale = "en" | "zh" | "zht" | "ko" | "de" | "es" | "fr" | "da" | "ja" | "pl" | "ru" | "ar" | "no" | "br"
+export type Locale =
+  | "en"
+  | "zh"
+  | "zht"
+  | "ko"
+  | "de"
+  | "es"
+  | "fr"
+  | "da"
+  | "ja"
+  | "pl"
+  | "ru"
+  | "ar"
+  | "no"
+  | "br"
+  | "th"
 
 type RawDictionary = typeof en & typeof uiEn
 type Dictionary = i18n.Flatten<RawDictionary>
 
-const LOCALES: readonly Locale[] = ["en", "zh", "zht", "ko", "de", "es", "fr", "da", "ja", "pl", "ru", "ar", "no", "br"]
+const LOCALES: readonly Locale[] = [
+  "en",
+  "zh",
+  "zht",
+  "ko",
+  "de",
+  "es",
+  "fr",
+  "da",
+  "ja",
+  "pl",
+  "ru",
+  "ar",
+  "no",
+  "br",
+  "th",
+]
 
 function detectLocale(): Locale {
   if (typeof navigator !== "object") return "en"
@@ -65,6 +98,7 @@ function detectLocale(): Locale {
     )
       return "no"
     if (language.toLowerCase().startsWith("pt")) return "br"
+    if (language.toLowerCase().startsWith("th")) return "th"
   }
 
   return "en"
@@ -94,6 +128,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
       if (store.locale === "ar") return "ar"
       if (store.locale === "no") return "no"
       if (store.locale === "br") return "br"
+      if (store.locale === "th") return "th"
       return "en"
     })
 
@@ -118,6 +153,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
       if (locale() === "ar") return { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }
       if (locale() === "no") return { ...base, ...i18n.flatten({ ...no, ...uiNo }) }
       if (locale() === "br") return { ...base, ...i18n.flatten({ ...br, ...uiBr }) }
+      if (locale() === "th") return { ...base, ...i18n.flatten({ ...th, ...uiTh }) }
       return { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }
     })
 
@@ -138,6 +174,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
       ar: "language.ar",
       no: "language.no",
       br: "language.br",
+      th: "language.th",
     }
 
     const label = (value: Locale) => t(labelKey[value])

+ 3 - 2
packages/app/src/context/terminal.tsx

@@ -155,8 +155,9 @@ function createTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, sess
 
       batch(() => {
         setStore("all", index, {
-          ...pty,
-          ...clone.data,
+          id: clone.data.id,
+          title: clone.data.title ?? pty.title,
+          titleNumber: pty.titleNumber,
         })
         if (active) {
           setStore("active", clone.data.id)

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

@@ -331,6 +331,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "لغة",
   "toast.language.description": "تم التبديل إلى {{language}}",

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

@@ -330,6 +330,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "Idioma",
   "toast.language.description": "Alterado para {{language}}",

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

@@ -332,6 +332,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "Sprog",
   "toast.language.description": "Skiftede til {{language}}",

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

@@ -338,6 +338,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "Sprache",
   "toast.language.description": "Zu {{language}} gewechselt",

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

@@ -337,6 +337,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "Language",
   "toast.language.description": "Switched to {{language}}",

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

@@ -333,6 +333,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "Idioma",
   "toast.language.description": "Cambiado a {{language}}",

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

@@ -333,6 +333,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "Langue",
   "toast.language.description": "Passé à {{language}}",

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

@@ -331,6 +331,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "言語",
   "toast.language.description": "{{language}}に切り替えました",

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

@@ -334,6 +334,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "언어",
   "toast.language.description": "{{language}}(으)로 전환됨",

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

@@ -334,6 +334,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "Språk",
   "toast.language.description": "Byttet til {{language}}",

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

@@ -332,6 +332,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "Język",
   "toast.language.description": "Przełączono na {{language}}",

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

@@ -333,6 +333,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "Язык",
   "toast.language.description": "Переключено на {{language}}",

+ 718 - 0
packages/app/src/i18n/th.ts

@@ -0,0 +1,718 @@
+export const dict = {
+  "command.category.suggested": "แนะนำ",
+  "command.category.view": "มุมมอง",
+  "command.category.project": "โปรเจกต์",
+  "command.category.provider": "ผู้ให้บริการ",
+  "command.category.server": "เซิร์ฟเวอร์",
+  "command.category.session": "เซสชัน",
+  "command.category.theme": "ธีม",
+  "command.category.language": "ภาษา",
+  "command.category.file": "ไฟล์",
+  "command.category.context": "บริบท",
+  "command.category.terminal": "เทอร์มินัล",
+  "command.category.model": "โมเดล",
+  "command.category.mcp": "MCP",
+  "command.category.agent": "เอเจนต์",
+  "command.category.permissions": "สิทธิ์",
+  "command.category.workspace": "พื้นที่ทำงาน",
+  "command.category.settings": "การตั้งค่า",
+
+  "theme.scheme.system": "ระบบ",
+  "theme.scheme.light": "สว่าง",
+  "theme.scheme.dark": "มืด",
+
+  "command.sidebar.toggle": "สลับแถบข้าง",
+  "command.project.open": "เปิดโปรเจกต์",
+  "command.provider.connect": "เชื่อมต่อผู้ให้บริการ",
+  "command.server.switch": "สลับเซิร์ฟเวอร์",
+  "command.settings.open": "เปิดการตั้งค่า",
+  "command.session.previous": "เซสชันก่อนหน้า",
+  "command.session.next": "เซสชันถัดไป",
+  "command.session.archive": "จัดเก็บเซสชัน",
+
+  "command.palette": "คำสั่งค้นหา",
+
+  "command.theme.cycle": "เปลี่ยนธีม",
+  "command.theme.set": "ใช้ธีม: {{theme}}",
+  "command.theme.scheme.cycle": "เปลี่ยนโทนสี",
+  "command.theme.scheme.set": "ใช้โทนสี: {{scheme}}",
+
+  "command.language.cycle": "เปลี่ยนภาษา",
+  "command.language.set": "ใช้ภาษา: {{language}}",
+
+  "command.session.new": "เซสชันใหม่",
+  "command.file.open": "เปิดไฟล์",
+  "command.file.open.description": "ค้นหาไฟล์และคำสั่ง",
+  "command.context.addSelection": "เพิ่มส่วนที่เลือกไปยังบริบท",
+  "command.context.addSelection.description": "เพิ่มบรรทัดที่เลือกจากไฟล์ปัจจุบัน",
+  "command.terminal.toggle": "สลับเทอร์มินัล",
+  "command.fileTree.toggle": "สลับต้นไม้ไฟล์",
+  "command.review.toggle": "สลับการตรวจสอบ",
+  "command.terminal.new": "เทอร์มินัลใหม่",
+  "command.terminal.new.description": "สร้างแท็บเทอร์มินัลใหม่",
+  "command.steps.toggle": "สลับขั้นตอน",
+  "command.steps.toggle.description": "แสดงหรือซ่อนขั้นตอนสำหรับข้อความปัจจุบัน",
+  "command.message.previous": "ข้อความก่อนหน้า",
+  "command.message.previous.description": "ไปที่ข้อความผู้ใช้ก่อนหน้า",
+  "command.message.next": "ข้อความถัดไป",
+  "command.message.next.description": "ไปที่ข้อความผู้ใช้ถัดไป",
+  "command.model.choose": "เลือกโมเดล",
+  "command.model.choose.description": "เลือกโมเดลอื่น",
+  "command.mcp.toggle": "สลับ MCPs",
+  "command.mcp.toggle.description": "สลับ MCPs",
+  "command.agent.cycle": "เปลี่ยนเอเจนต์",
+  "command.agent.cycle.description": "สลับไปยังเอเจนต์ถัดไป",
+  "command.agent.cycle.reverse": "เปลี่ยนเอเจนต์ย้อนกลับ",
+  "command.agent.cycle.reverse.description": "สลับไปยังเอเจนต์ก่อนหน้า",
+  "command.model.variant.cycle": "เปลี่ยนความพยายามในการคิด",
+  "command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
+  "command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
+  "command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
+  "command.session.undo": "ยกเลิก",
+  "command.session.undo.description": "ยกเลิกข้อความล่าสุด",
+  "command.session.redo": "ทำซ้ำ",
+  "command.session.redo.description": "ทำซ้ำข้อความที่ถูกยกเลิกล่าสุด",
+  "command.session.compact": "บีบอัดเซสชัน",
+  "command.session.compact.description": "สรุปเซสชันเพื่อลดขนาดบริบท",
+  "command.session.fork": "แตกแขนงจากข้อความ",
+  "command.session.fork.description": "สร้างเซสชันใหม่จากข้อความก่อนหน้า",
+  "command.session.share": "แชร์เซสชัน",
+  "command.session.share.description": "แชร์เซสชันนี้และคัดลอก URL ไปยังคลิปบอร์ด",
+  "command.session.unshare": "ยกเลิกการแชร์เซสชัน",
+  "command.session.unshare.description": "หยุดการแชร์เซสชันนี้",
+
+  "palette.search.placeholder": "ค้นหาไฟล์และคำสั่ง",
+  "palette.empty": "ไม่พบผลลัพธ์",
+  "palette.group.commands": "คำสั่ง",
+  "palette.group.files": "ไฟล์",
+
+  "dialog.provider.search.placeholder": "ค้นหาผู้ให้บริการ",
+  "dialog.provider.empty": "ไม่พบผู้ให้บริการ",
+  "dialog.provider.group.popular": "ยอดนิยม",
+  "dialog.provider.group.other": "อื่น ๆ",
+  "dialog.provider.tag.recommended": "แนะนำ",
+  "dialog.provider.opencode.note": "โมเดลที่คัดสรร รวมถึง Claude, GPT, Gemini และอื่น ๆ",
+  "dialog.provider.anthropic.note": "เข้าถึงโมเดล Claude โดยตรง รวมถึง Pro และ Max",
+  "dialog.provider.copilot.note": "โมเดล Claude สำหรับการช่วยเหลือในการเขียนโค้ด",
+  "dialog.provider.openai.note": "โมเดล GPT สำหรับงาน AI ทั่วไปที่รวดเร็วและมีความสามารถ",
+  "dialog.provider.google.note": "โมเดล Gemini สำหรับการตอบสนองที่รวดเร็วและมีโครงสร้าง",
+  "dialog.provider.openrouter.note": "เข้าถึงโมเดลที่รองรับทั้งหมดจากผู้ให้บริการเดียว",
+  "dialog.provider.vercel.note": "การเข้าถึงโมเดล AI แบบรวมด้วยการกำหนดเส้นทางอัจฉริยะ",
+
+  "dialog.model.select.title": "เลือกโมเดล",
+  "dialog.model.search.placeholder": "ค้นหาโมเดล",
+  "dialog.model.empty": "ไม่พบผลลัพธ์โมเดล",
+  "dialog.model.manage": "จัดการโมเดล",
+  "dialog.model.manage.description": "ปรับแต่งโมเดลที่จะปรากฏในตัวเลือกโมเดล",
+
+  "dialog.model.unpaid.freeModels.title": "โมเดลฟรีที่จัดหาให้โดย OpenCode",
+  "dialog.model.unpaid.addMore.title": "เพิ่มโมเดลเพิ่มเติมจากผู้ให้บริการยอดนิยม",
+
+  "dialog.provider.viewAll": "แสดงผู้ให้บริการเพิ่มเติม",
+
+  "provider.connect.title": "เชื่อมต่อ {{provider}}",
+  "provider.connect.title.anthropicProMax": "เข้าสู่ระบบด้วย Claude Pro/Max",
+  "provider.connect.selectMethod": "เลือกวิธีการเข้าสู่ระบบสำหรับ {{provider}}",
+  "provider.connect.method.apiKey": "คีย์ API",
+  "provider.connect.status.inProgress": "กำลังอนุญาต...",
+  "provider.connect.status.waiting": "รอการอนุญาต...",
+  "provider.connect.status.failed": "การอนุญาตล้มเหลว: {{error}}",
+  "provider.connect.apiKey.description":
+    "ป้อนคีย์ API ของ {{provider}} เพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน OpenCode",
+  "provider.connect.apiKey.label": "คีย์ API ของ {{provider}}",
+  "provider.connect.apiKey.placeholder": "คีย์ API",
+  "provider.connect.apiKey.required": "ต้องใช้คีย์ API",
+  "provider.connect.opencodeZen.line1":
+    "OpenCode Zen ให้คุณเข้าถึงชุดโมเดลที่เชื่อถือได้และปรับแต่งแล้วสำหรับเอเจนต์การเขียนโค้ด",
+  "provider.connect.opencodeZen.line2":
+    "ด้วยคีย์ API เดียวคุณจะได้รับการเข้าถึงโมเดล เช่น Claude, GPT, Gemini, GLM และอื่น ๆ",
+  "provider.connect.opencodeZen.visit.prefix": "เยี่ยมชม ",
+  "provider.connect.opencodeZen.visit.link": "opencode.ai/zen",
+  "provider.connect.opencodeZen.visit.suffix": " เพื่อรวบรวมคีย์ API ของคุณ",
+  "provider.connect.oauth.code.visit.prefix": "เยี่ยมชม ",
+  "provider.connect.oauth.code.visit.link": "ลิงก์นี้",
+  "provider.connect.oauth.code.visit.suffix":
+    " เพื่อรวบรวมรหัสการอนุญาตของคุณเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน OpenCode",
+  "provider.connect.oauth.code.label": "รหัสการอนุญาต {{method}}",
+  "provider.connect.oauth.code.placeholder": "รหัสการอนุญาต",
+  "provider.connect.oauth.code.required": "ต้องใช้รหัสการอนุญาต",
+  "provider.connect.oauth.code.invalid": "รหัสการอนุญาตไม่ถูกต้อง",
+  "provider.connect.oauth.auto.visit.prefix": "เยี่ยมชม ",
+  "provider.connect.oauth.auto.visit.link": "ลิงก์นี้",
+  "provider.connect.oauth.auto.visit.suffix":
+    " และป้อนรหัสด้านล่างเพื่อเชื่อมต่อบัญชีและใช้โมเดล {{provider}} ใน OpenCode",
+  "provider.connect.oauth.auto.confirmationCode": "รหัสยืนยัน",
+  "provider.connect.toast.connected.title": "{{provider}} ที่เชื่อมต่อแล้ว",
+  "provider.connect.toast.connected.description": "โมเดล {{provider}} พร้อมใช้งานแล้ว",
+
+  "provider.disconnect.toast.disconnected.title": "{{provider}} ที่ยกเลิกการเชื่อมต่อแล้ว",
+  "provider.disconnect.toast.disconnected.description": "โมเดล {{provider}} ไม่พร้อมใช้งานอีกต่อไป",
+
+  "model.tag.free": "ฟรี",
+  "model.tag.latest": "ล่าสุด",
+  "model.provider.anthropic": "Anthropic",
+  "model.provider.openai": "OpenAI",
+  "model.provider.google": "Google",
+  "model.provider.xai": "xAI",
+  "model.provider.meta": "Meta",
+  "model.input.text": "ข้อความ",
+  "model.input.image": "รูปภาพ",
+  "model.input.audio": "เสียง",
+  "model.input.video": "วิดีโอ",
+  "model.input.pdf": "pdf",
+  "model.tooltip.allows": "อนุญาต: {{inputs}}",
+  "model.tooltip.reasoning.allowed": "อนุญาตการใช้เหตุผล",
+  "model.tooltip.reasoning.none": "ไม่มีการใช้เหตุผล",
+  "model.tooltip.context": "ขีดจำกัดบริบท {{limit}}",
+
+  "common.search.placeholder": "ค้นหา",
+  "common.goBack": "ย้อนกลับ",
+  "common.loading": "กำลังโหลด",
+  "common.loading.ellipsis": "...",
+  "common.cancel": "ยกเลิก",
+  "common.connect": "เชื่อมต่อ",
+  "common.disconnect": "ยกเลิกการเชื่อมต่อ",
+  "common.submit": "ส่ง",
+  "common.save": "บันทึก",
+  "common.saving": "กำลังบันทึก...",
+  "common.default": "ค่าเริ่มต้น",
+  "common.attachment": "ไฟล์แนบ",
+
+  "prompt.placeholder.shell": "ป้อนคำสั่งเชลล์...",
+  "prompt.placeholder.normal": 'ถามอะไรก็ได้... "{{example}}"',
+  "prompt.placeholder.summarizeComments": "สรุปความคิดเห็น…",
+  "prompt.placeholder.summarizeComment": "สรุปความคิดเห็น…",
+  "prompt.mode.shell": "เชลล์",
+  "prompt.mode.shell.exit": "กด esc เพื่อออก",
+
+  "prompt.example.1": "แก้ไข TODO ในโค้ดเบส",
+  "prompt.example.2": "เทคโนโลยีของโปรเจกต์นี้คืออะไร?",
+  "prompt.example.3": "แก้ไขการทดสอบที่เสีย",
+  "prompt.example.4": "อธิบายวิธีการทำงานของการตรวจสอบสิทธิ์",
+  "prompt.example.5": "ค้นหาและแก้ไขช่องโหว่ความปลอดภัย",
+  "prompt.example.6": "เพิ่มการทดสอบหน่วยสำหรับบริการผู้ใช้",
+  "prompt.example.7": "ปรับโครงสร้างฟังก์ชันนี้ให้อ่านง่ายขึ้น",
+  "prompt.example.8": "ข้อผิดพลาดนี้หมายความว่าอะไร?",
+  "prompt.example.9": "ช่วยฉันดีบักปัญหานี้",
+  "prompt.example.10": "สร้างเอกสาร API",
+  "prompt.example.11": "ปรับปรุงการสืบค้นฐานข้อมูล",
+  "prompt.example.12": "เพิ่มการตรวจสอบข้อมูลนำเข้า",
+  "prompt.example.13": "สร้างคอมโพเนนต์ใหม่สำหรับ...",
+  "prompt.example.14": "ฉันจะทำให้โปรเจกต์นี้ทำงานได้อย่างไร?",
+  "prompt.example.15": "ตรวจสอบโค้ดของฉันเพื่อแนวทางปฏิบัติที่ดีที่สุด",
+  "prompt.example.16": "เพิ่มการจัดการข้อผิดพลาดในฟังก์ชันนี้",
+  "prompt.example.17": "อธิบายรูปแบบ regex นี้",
+  "prompt.example.18": "แปลงสิ่งนี้เป็น TypeScript",
+  "prompt.example.19": "เพิ่มการบันทึกทั่วทั้งโค้ดเบส",
+  "prompt.example.20": "มีการพึ่งพาอะไรที่ล้าสมัยอยู่?",
+  "prompt.example.21": "ช่วยฉันเขียนสคริปต์การย้ายข้อมูล",
+  "prompt.example.22": "ใช้งานแคชสำหรับจุดสิ้นสุดนี้",
+  "prompt.example.23": "เพิ่มการแบ่งหน้าในรายการนี้",
+  "prompt.example.24": "สร้างคำสั่ง CLI สำหรับ...",
+  "prompt.example.25": "ตัวแปรสภาพแวดล้อมทำงานอย่างไรที่นี่?",
+
+  "prompt.popover.emptyResults": "ไม่พบผลลัพธ์ที่ตรงกัน",
+  "prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน",
+  "prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่",
+  "prompt.slash.badge.custom": "กำหนดเอง",
+  "prompt.context.active": "ใช้งานอยู่",
+  "prompt.context.includeActiveFile": "รวมไฟล์ที่ใช้งานอยู่",
+  "prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท",
+  "prompt.context.removeFile": "เอาไฟล์ออกจากบริบท",
+  "prompt.action.attachFile": "แนบไฟล์",
+  "prompt.attachment.remove": "เอาไฟล์แนบออก",
+  "prompt.action.send": "ส่ง",
+  "prompt.action.stop": "หยุด",
+
+  "prompt.toast.pasteUnsupported.title": "การวางไม่รองรับ",
+  "prompt.toast.pasteUnsupported.description": "สามารถวางรูปภาพหรือ PDF เท่านั้น",
+  "prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล",
+  "prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์",
+  "prompt.toast.worktreeCreateFailed.title": "ไม่สามารถสร้าง worktree",
+  "prompt.toast.sessionCreateFailed.title": "ไม่สามารถสร้างเซสชัน",
+  "prompt.toast.shellSendFailed.title": "ไม่สามารถส่งคำสั่งเชลล์",
+  "prompt.toast.commandSendFailed.title": "ไม่สามารถส่งคำสั่ง",
+  "prompt.toast.promptSendFailed.title": "ไม่สามารถส่งพร้อมท์",
+
+  "dialog.mcp.title": "MCPs",
+  "dialog.mcp.description": "{{enabled}} จาก {{total}} ที่เปิดใช้งาน",
+  "dialog.mcp.empty": "ไม่มี MCP ที่กำหนดค่า",
+
+  "dialog.lsp.empty": "LSPs ตรวจจับอัตโนมัติจากประเภทไฟล์",
+  "dialog.plugins.empty": "ปลั๊กอินที่กำหนดค่าใน opencode.json",
+
+  "mcp.status.connected": "เชื่อมต่อแล้ว",
+  "mcp.status.failed": "ล้มเหลว",
+  "mcp.status.needs_auth": "ต้องการการตรวจสอบสิทธิ์",
+  "mcp.status.disabled": "ปิดใช้งาน",
+
+  "dialog.fork.empty": "ไม่มีข้อความให้แตกแขนง",
+
+  "dialog.directory.search.placeholder": "ค้นหาโฟลเดอร์",
+  "dialog.directory.empty": "ไม่พบโฟลเดอร์",
+
+  "dialog.server.title": "เซิร์ฟเวอร์",
+  "dialog.server.description": "สลับเซิร์ฟเวอร์ OpenCode ที่แอปนี้เชื่อมต่อด้วย",
+  "dialog.server.search.placeholder": "ค้นหาเซิร์ฟเวอร์",
+  "dialog.server.empty": "ยังไม่มีเซิร์ฟเวอร์",
+  "dialog.server.add.title": "เพิ่มเซิร์ฟเวอร์",
+  "dialog.server.add.url": "URL เซิร์ฟเวอร์",
+  "dialog.server.add.placeholder": "http://localhost:4096",
+  "dialog.server.add.error": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์",
+  "dialog.server.add.checking": "กำลังตรวจสอบ...",
+  "dialog.server.add.button": "เพิ่มเซิร์ฟเวอร์",
+  "dialog.server.default.title": "เซิร์ฟเวอร์เริ่มต้น",
+  "dialog.server.default.description":
+    "เชื่อมต่อกับเซิร์ฟเวอร์นี้เมื่อเปิดแอปแทนการเริ่มเซิร์ฟเวอร์ในเครื่อง ต้องรีสตาร์ท",
+  "dialog.server.default.none": "ไม่ได้เลือกเซิร์ฟเวอร์",
+  "dialog.server.default.set": "ตั้งเซิร์ฟเวอร์ปัจจุบันเป็นค่าเริ่มต้น",
+  "dialog.server.default.clear": "ล้าง",
+  "dialog.server.action.remove": "เอาเซิร์ฟเวอร์ออก",
+
+  "dialog.server.menu.edit": "แก้ไข",
+  "dialog.server.menu.default": "ตั้งเป็นค่าเริ่มต้น",
+  "dialog.server.menu.defaultRemove": "เอาค่าเริ่มต้นออก",
+  "dialog.server.menu.delete": "ลบ",
+  "dialog.server.current": "เซิร์ฟเวอร์ปัจจุบัน",
+  "dialog.server.status.default": "ค่าเริ่มต้น",
+
+  "dialog.project.edit.title": "แก้ไขโปรเจกต์",
+  "dialog.project.edit.name": "ชื่อ",
+  "dialog.project.edit.icon": "ไอคอน",
+  "dialog.project.edit.icon.alt": "ไอคอนโปรเจกต์",
+  "dialog.project.edit.icon.hint": "คลิกหรือลากรูปภาพ",
+  "dialog.project.edit.icon.recommended": "แนะนำ: 128x128px",
+  "dialog.project.edit.color": "สี",
+  "dialog.project.edit.color.select": "เลือกสี {{color}}",
+  "dialog.project.edit.worktree.startup": "สคริปต์เริ่มต้นพื้นที่ทำงาน",
+  "dialog.project.edit.worktree.startup.description": "ทำงานหลังจากสร้างพื้นที่ทำงานใหม่ (worktree)",
+  "dialog.project.edit.worktree.startup.placeholder": "เช่น bun install",
+
+  "context.breakdown.title": "การแบ่งบริบท",
+  "context.breakdown.note": 'การแบ่งโดยประมาณของโทเค็นนำเข้า "อื่น ๆ" รวมถึงคำนิยามเครื่องมือและโอเวอร์เฮด',
+  "context.breakdown.system": "ระบบ",
+  "context.breakdown.user": "ผู้ใช้",
+  "context.breakdown.assistant": "ผู้ช่วย",
+  "context.breakdown.tool": "การเรียกเครื่องมือ",
+  "context.breakdown.other": "อื่น ๆ",
+
+  "context.systemPrompt.title": "พร้อมท์ระบบ",
+  "context.rawMessages.title": "ข้อความดิบ",
+
+  "context.stats.session": "เซสชัน",
+  "context.stats.messages": "ข้อความ",
+  "context.stats.provider": "ผู้ให้บริการ",
+  "context.stats.model": "โมเดล",
+  "context.stats.limit": "ขีดจำกัดบริบท",
+  "context.stats.totalTokens": "โทเค็นทั้งหมด",
+  "context.stats.usage": "การใช้งาน",
+  "context.stats.inputTokens": "โทเค็นนำเข้า",
+  "context.stats.outputTokens": "โทเค็นส่งออก",
+  "context.stats.reasoningTokens": "โทเค็นการใช้เหตุผล",
+  "context.stats.cacheTokens": "โทเค็นแคช (อ่าน/เขียน)",
+  "context.stats.userMessages": "ข้อความผู้ใช้",
+  "context.stats.assistantMessages": "ข้อความผู้ช่วย",
+  "context.stats.totalCost": "ต้นทุนทั้งหมด",
+  "context.stats.sessionCreated": "สร้างเซสชันเมื่อ",
+  "context.stats.lastActivity": "กิจกรรมล่าสุด",
+
+  "context.usage.tokens": "โทเค็น",
+  "context.usage.usage": "การใช้งาน",
+  "context.usage.cost": "ต้นทุน",
+  "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.th": "ไทย",
+
+  "toast.language.title": "ภาษา",
+  "toast.language.description": "สลับไปที่ {{language}}",
+
+  "toast.theme.title": "สลับธีมแล้ว",
+  "toast.scheme.title": "โทนสี",
+
+  "toast.permissions.autoaccept.on.title": "กำลังยอมรับการแก้ไขโดยอัตโนมัติ",
+  "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและเขียนจะได้รับการอนุมัติโดยอัตโนมัติ",
+  "toast.permissions.autoaccept.off.title": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
+  "toast.permissions.autoaccept.off.description": "สิทธิ์การแก้ไขและเขียนจะต้องได้รับการอนุมัติ",
+
+  "toast.model.none.title": "ไม่ได้เลือกโมเดล",
+  "toast.model.none.description": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้",
+
+  "toast.file.loadFailed.title": "ไม่สามารถโหลดไฟล์",
+  "toast.file.listFailed.title": "ไม่สามารถแสดงรายการไฟล์",
+
+  "toast.context.noLineSelection.title": "ไม่มีการเลือกบรรทัด",
+  "toast.context.noLineSelection.description": "เลือกช่วงบรรทัดในแท็บไฟล์ก่อน",
+
+  "toast.session.share.copyFailed.title": "ไม่สามารถคัดลอก URL ไปยังคลิปบอร์ด",
+  "toast.session.share.success.title": "แชร์เซสชันแล้ว",
+  "toast.session.share.success.description": "คัดลอก URL แชร์ไปยังคลิปบอร์ดแล้ว!",
+  "toast.session.share.failed.title": "ไม่สามารถแชร์เซสชัน",
+  "toast.session.share.failed.description": "เกิดข้อผิดพลาดระหว่างการแชร์เซสชัน",
+
+  "toast.session.unshare.success.title": "ยกเลิกการแชร์เซสชันแล้ว",
+  "toast.session.unshare.success.description": "ยกเลิกการแชร์เซสชันสำเร็จ!",
+  "toast.session.unshare.failed.title": "ไม่สามารถยกเลิกการแชร์เซสชัน",
+  "toast.session.unshare.failed.description": "เกิดข้อผิดพลาดระหว่างการยกเลิกการแชร์เซสชัน",
+
+  "toast.session.listFailed.title": "ไม่สามารถโหลดเซสชันสำหรับ {{project}}",
+
+  "toast.update.title": "มีการอัปเดต",
+  "toast.update.description": "เวอร์ชันใหม่ของ OpenCode ({{version}}) พร้อมใช้งานสำหรับติดตั้ง",
+  "toast.update.action.installRestart": "ติดตั้งและรีสตาร์ท",
+  "toast.update.action.notYet": "ยังไม่",
+
+  "error.page.title": "เกิดข้อผิดพลาด",
+  "error.page.description": "เกิดข้อผิดพลาดระหว่างการโหลดแอปพลิเคชัน",
+  "error.page.details.label": "รายละเอียดข้อผิดพลาด",
+  "error.page.action.restart": "รีสตาร์ท",
+  "error.page.action.checking": "กำลังตรวจสอบ...",
+  "error.page.action.checkUpdates": "ตรวจสอบการอัปเดต",
+  "error.page.action.updateTo": "อัปเดตเป็น {{version}}",
+  "error.page.report.prefix": "โปรดรายงานข้อผิดพลาดนี้ให้ทีม OpenCode",
+  "error.page.report.discord": "บน Discord",
+  "error.page.version": "เวอร์ชัน: {{version}}",
+
+  "error.dev.rootNotFound": "ไม่พบองค์ประกอบรูท คุณลืมเพิ่มใน index.html หรือบางทีแอตทริบิวต์ id อาจสะกดผิด?",
+
+  "error.globalSync.connectFailed": "ไม่สามารถเชื่อมต่อกับเซิร์ฟเวอร์ มีเซิร์ฟเวอร์ทำงานอยู่ที่ `{{url}}` หรือไม่?",
+
+  "error.chain.unknown": "ข้อผิดพลาดที่ไม่รู้จัก",
+  "error.chain.causedBy": "สาเหตุ:",
+  "error.chain.apiError": "ข้อผิดพลาด API",
+  "error.chain.status": "สถานะ: {{status}}",
+  "error.chain.retryable": "สามารถลองใหม่: {{retryable}}",
+  "error.chain.responseBody": "เนื้อหาการตอบสนอง:\n{{body}}",
+  "error.chain.didYouMean": "คุณหมายถึง: {{suggestions}}",
+  "error.chain.modelNotFound": "ไม่พบโมเดล: {{provider}}/{{model}}",
+  "error.chain.checkConfig": "ตรวจสอบการกำหนดค่าของคุณ (opencode.json) ชื่อผู้ให้บริการ/โมเดล",
+  "error.chain.mcpFailed": 'เซิร์ฟเวอร์ MCP "{{name}}" ล้มเหลว โปรดทราบว่า OpenCode ยังไม่รองรับการตรวจสอบสิทธิ์ MCP',
+  "error.chain.providerAuthFailed": "การตรวจสอบสิทธิ์ผู้ให้บริการล้มเหลว ({{provider}}): {{message}}",
+  "error.chain.providerInitFailed": 'ไม่สามารถเริ่มต้นผู้ให้บริการ "{{provider}}" ตรวจสอบข้อมูลรับรองและการกำหนดค่า',
+  "error.chain.configJsonInvalid": "ไฟล์กำหนดค่าที่ {{path}} ไม่ใช่ JSON(C) ที่ถูกต้อง",
+  "error.chain.configJsonInvalidWithMessage": "ไฟล์กำหนดค่าที่ {{path}} ไม่ใช่ JSON(C) ที่ถูกต้อง: {{message}}",
+  "error.chain.configDirectoryTypo":
+    'ไดเรกทอรี "{{dir}}" ใน {{path}} ไม่ถูกต้อง เปลี่ยนชื่อไดเรกทอรีเป็น "{{suggestion}}" หรือเอาออก นี่เป็นการสะกดผิดทั่วไป',
+  "error.chain.configFrontmatterError": "ไม่สามารถแยกวิเคราะห์ frontmatter ใน {{path}}:\n{{message}}",
+  "error.chain.configInvalid": "ไฟล์กำหนดค่าที่ {{path}} ไม่ถูกต้อง",
+  "error.chain.configInvalidWithMessage": "ไฟล์กำหนดค่าที่ {{path}} ไม่ถูกต้อง: {{message}}",
+
+  "notification.permission.title": "ต้องการสิทธิ์",
+  "notification.permission.description": "{{sessionTitle}} ใน {{projectName}} ต้องการสิทธิ์",
+  "notification.question.title": "คำถาม",
+  "notification.question.description": "{{sessionTitle}} ใน {{projectName}} มีคำถาม",
+  "notification.action.goToSession": "ไปที่เซสชัน",
+
+  "notification.session.responseReady.title": "การตอบสนองพร้อม",
+  "notification.session.error.title": "ข้อผิดพลาดเซสชัน",
+  "notification.session.error.fallbackDescription": "เกิดข้อผิดพลาด",
+
+  "home.recentProjects": "โปรเจกต์ล่าสุด",
+  "home.empty.title": "ไม่มีโปรเจกต์ล่าสุด",
+  "home.empty.description": "เริ่มต้นโดยเปิดโปรเจกต์ในเครื่อง",
+
+  "session.tab.session": "เซสชัน",
+  "session.tab.review": "ตรวจสอบ",
+  "session.tab.context": "บริบท",
+  "session.panel.reviewAndFiles": "ตรวจสอบและไฟล์",
+  "session.review.filesChanged": "{{count}} ไฟล์ที่เปลี่ยนแปลง",
+  "session.review.change.one": "การเปลี่ยนแปลง",
+  "session.review.change.other": "การเปลี่ยนแปลง",
+  "session.review.loadingChanges": "กำลังโหลดการเปลี่ยนแปลง...",
+  "session.review.empty": "ยังไม่มีการเปลี่ยนแปลงในเซสชันนี้",
+  "session.review.noChanges": "ไม่มีการเปลี่ยนแปลง",
+
+  "session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด",
+  "session.files.all": "ไฟล์ทั้งหมด",
+
+  "session.messages.renderEarlier": "แสดงข้อความก่อนหน้า",
+  "session.messages.loadingEarlier": "กำลังโหลดข้อความก่อนหน้า...",
+  "session.messages.loadEarlier": "โหลดข้อความก่อนหน้า",
+  "session.messages.loading": "กำลังโหลดข้อความ...",
+  "session.messages.jumpToLatest": "ไปที่ล่าสุด",
+
+  "session.context.addToContext": "เพิ่ม {{selection}} ไปยังบริบท",
+
+  "session.new.worktree.main": "สาขาหลัก",
+  "session.new.worktree.mainWithBranch": "สาขาหลัก ({{branch}})",
+  "session.new.worktree.create": "สร้าง worktree ใหม่",
+  "session.new.lastModified": "แก้ไขล่าสุด",
+
+  "session.header.search.placeholder": "ค้นหา {{project}}",
+  "session.header.searchFiles": "ค้นหาไฟล์",
+
+  "status.popover.trigger": "สถานะ",
+  "status.popover.ariaLabel": "การกำหนดค่าเซิร์ฟเวอร์",
+  "status.popover.tab.servers": "เซิร์ฟเวอร์",
+  "status.popover.tab.mcp": "MCP",
+  "status.popover.tab.lsp": "LSP",
+  "status.popover.tab.plugins": "ปลั๊กอิน",
+  "status.popover.action.manageServers": "จัดการเซิร์ฟเวอร์",
+
+  "session.share.popover.title": "เผยแพร่บนเว็บ",
+  "session.share.popover.description.shared": "เซสชันนี้เป็นสาธารณะบนเว็บ สามารถเข้าถึงได้โดยผู้ที่มีลิงก์",
+  "session.share.popover.description.unshared": "แชร์เซสชันสาธารณะบนเว็บ จะเข้าถึงได้โดยผู้ที่มีลิงก์",
+  "session.share.action.share": "แชร์",
+  "session.share.action.publish": "เผยแพร่",
+  "session.share.action.publishing": "กำลังเผยแพร่...",
+  "session.share.action.unpublish": "ยกเลิกการเผยแพร่",
+  "session.share.action.unpublishing": "กำลังยกเลิกการเผยแพร่...",
+  "session.share.action.view": "ดู",
+  "session.share.copy.copied": "คัดลอกแล้ว",
+  "session.share.copy.copyLink": "คัดลอกลิงก์",
+
+  "lsp.tooltip.none": "ไม่มีเซิร์ฟเวอร์ LSP",
+  "lsp.label.connected": "{{count}} LSP",
+
+  "prompt.loading": "กำลังโหลดพร้อมท์...",
+  "terminal.loading": "กำลังโหลดเทอร์มินัล...",
+  "terminal.title": "เทอร์มินัล",
+  "terminal.title.numbered": "เทอร์มินัล {{number}}",
+  "terminal.close": "ปิดเทอร์มินัล",
+  "terminal.connectionLost.title": "การเชื่อมต่อขาดหาย",
+  "terminal.connectionLost.description": "การเชื่อมต่อเทอร์มินัลถูกขัดจังหวะ อาจเกิดขึ้นเมื่อเซิร์ฟเวอร์รีสตาร์ท",
+
+  "common.closeTab": "ปิดแท็บ",
+  "common.dismiss": "ปิด",
+  "common.requestFailed": "คำขอล้มเหลว",
+  "common.moreOptions": "ตัวเลือกเพิ่มเติม",
+  "common.learnMore": "เรียนรู้เพิ่มเติม",
+  "common.rename": "เปลี่ยนชื่อ",
+  "common.reset": "รีเซ็ต",
+  "common.archive": "จัดเก็บ",
+  "common.delete": "ลบ",
+  "common.close": "ปิด",
+  "common.edit": "แก้ไข",
+  "common.loadMore": "โหลดเพิ่มเติม",
+  "common.key.esc": "ESC",
+
+  "sidebar.menu.toggle": "สลับเมนู",
+  "sidebar.nav.projectsAndSessions": "โปรเจกต์และเซสชัน",
+  "sidebar.settings": "การตั้งค่า",
+  "sidebar.help": "ช่วยเหลือ",
+  "sidebar.workspaces.enable": "เปิดใช้งานพื้นที่ทำงาน",
+  "sidebar.workspaces.disable": "ปิดใช้งานพื้นที่ทำงาน",
+  "sidebar.gettingStarted.title": "เริ่มต้นใช้งาน",
+  "sidebar.gettingStarted.line1": "OpenCode รวมถึงโมเดลฟรีเพื่อให้คุณเริ่มต้นได้ทันที",
+  "sidebar.gettingStarted.line2": "เชื่อมต่อผู้ให้บริการใด ๆ เพื่อใช้โมเดล รวมถึง Claude, GPT, Gemini ฯลฯ",
+  "sidebar.project.recentSessions": "เซสชันล่าสุด",
+  "sidebar.project.viewAllSessions": "ดูเซสชันทั้งหมด",
+
+  "app.name.desktop": "OpenCode Desktop",
+
+  "settings.section.desktop": "เดสก์ท็อป",
+  "settings.section.server": "เซิร์ฟเวอร์",
+  "settings.tab.general": "ทั่วไป",
+  "settings.tab.shortcuts": "ทางลัด",
+
+  "settings.general.section.appearance": "รูปลักษณ์",
+  "settings.general.section.notifications": "การแจ้งเตือนระบบ",
+  "settings.general.section.updates": "การอัปเดต",
+  "settings.general.section.sounds": "เสียงเอฟเฟกต์",
+
+  "settings.general.row.language.title": "ภาษา",
+  "settings.general.row.language.description": "เปลี่ยนภาษาที่แสดงสำหรับ OpenCode",
+  "settings.general.row.appearance.title": "รูปลักษณ์",
+  "settings.general.row.appearance.description": "ปรับแต่งวิธีการที่ OpenCode มีลักษณะบนอุปกรณ์ของคุณ",
+  "settings.general.row.theme.title": "ธีม",
+  "settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม",
+  "settings.general.row.font.title": "ฟอนต์",
+  "settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
+
+  "settings.general.row.releaseNotes.title": "บันทึกการอัปเดต",
+  "settings.general.row.releaseNotes.description": "แสดงป๊อปอัพ What's New หลังจากอัปเดต",
+  "font.option.ibmPlexMono": "IBM Plex Mono",
+  "font.option.cascadiaCode": "Cascadia Code",
+  "font.option.firaCode": "Fira Code",
+  "font.option.hack": "Hack",
+  "font.option.inconsolata": "Inconsolata",
+  "font.option.intelOneMono": "Intel One Mono",
+  "font.option.iosevka": "Iosevka",
+  "font.option.jetbrainsMono": "JetBrains Mono",
+  "font.option.mesloLgs": "Meslo LGS",
+  "font.option.robotoMono": "Roboto Mono",
+  "font.option.sourceCodePro": "Source Code Pro",
+  "font.option.ubuntuMono": "Ubuntu Mono",
+  "sound.option.alert01": "เสียงเตือน 01",
+  "sound.option.alert02": "เสียงเตือน 02",
+  "sound.option.alert03": "เสียงเตือน 03",
+  "sound.option.alert04": "เสียงเตือน 04",
+  "sound.option.alert05": "เสียงเตือน 05",
+  "sound.option.alert06": "เสียงเตือน 06",
+  "sound.option.alert07": "เสียงเตือน 07",
+  "sound.option.alert08": "เสียงเตือน 08",
+  "sound.option.alert09": "เสียงเตือน 09",
+  "sound.option.alert10": "เสียงเตือน 10",
+  "sound.option.bipbop01": "Bip-bop 01",
+  "sound.option.bipbop02": "Bip-bop 02",
+  "sound.option.bipbop03": "Bip-bop 03",
+  "sound.option.bipbop04": "Bip-bop 04",
+  "sound.option.bipbop05": "Bip-bop 05",
+  "sound.option.bipbop06": "Bip-bop 06",
+  "sound.option.bipbop07": "Bip-bop 07",
+  "sound.option.bipbop08": "Bip-bop 08",
+  "sound.option.bipbop09": "Bip-bop 09",
+  "sound.option.bipbop10": "Bip-bop 10",
+  "sound.option.staplebops01": "Staplebops 01",
+  "sound.option.staplebops02": "Staplebops 02",
+  "sound.option.staplebops03": "Staplebops 03",
+  "sound.option.staplebops04": "Staplebops 04",
+  "sound.option.staplebops05": "Staplebops 05",
+  "sound.option.staplebops06": "Staplebops 06",
+  "sound.option.staplebops07": "Staplebops 07",
+  "sound.option.nope01": "Nope 01",
+  "sound.option.nope02": "Nope 02",
+  "sound.option.nope03": "Nope 03",
+  "sound.option.nope04": "Nope 04",
+  "sound.option.nope05": "Nope 05",
+  "sound.option.nope06": "Nope 06",
+  "sound.option.nope07": "Nope 07",
+  "sound.option.nope08": "Nope 08",
+  "sound.option.nope09": "Nope 09",
+  "sound.option.nope10": "Nope 10",
+  "sound.option.nope11": "Nope 11",
+  "sound.option.nope12": "Nope 12",
+  "sound.option.yup01": "Yup 01",
+  "sound.option.yup02": "Yup 02",
+  "sound.option.yup03": "Yup 03",
+  "sound.option.yup04": "Yup 04",
+  "sound.option.yup05": "Yup 05",
+  "sound.option.yup06": "Yup 06",
+
+  "settings.general.notifications.agent.title": "เอเจนต์",
+  "settings.general.notifications.agent.description": "แสดงการแจ้งเตือนระบบเมื่อเอเจนต์เสร็จสิ้นหรือต้องการความสนใจ",
+  "settings.general.notifications.permissions.title": "สิทธิ์",
+  "settings.general.notifications.permissions.description": "แสดงการแจ้งเตือนระบบเมื่อต้องการสิทธิ์",
+  "settings.general.notifications.errors.title": "ข้อผิดพลาด",
+  "settings.general.notifications.errors.description": "แสดงการแจ้งเตือนระบบเมื่อเกิดข้อผิดพลาด",
+
+  "settings.general.sounds.agent.title": "เอเจนต์",
+  "settings.general.sounds.agent.description": "เล่นเสียงเมื่อเอเจนต์เสร็จสิ้นหรือต้องการความสนใจ",
+  "settings.general.sounds.permissions.title": "สิทธิ์",
+  "settings.general.sounds.permissions.description": "เล่นเสียงเมื่อต้องการสิทธิ์",
+  "settings.general.sounds.errors.title": "ข้อผิดพลาด",
+  "settings.general.sounds.errors.description": "เล่นเสียงเมื่อเกิดข้อผิดพลาด",
+
+  "settings.shortcuts.title": "ทางลัดแป้นพิมพ์",
+  "settings.shortcuts.reset.button": "รีเซ็ตเป็นค่าเริ่มต้น",
+  "settings.shortcuts.reset.toast.title": "รีเซ็ตทางลัดแล้ว",
+  "settings.shortcuts.reset.toast.description": "รีเซ็ตทางลัดแป้นพิมพ์เป็นค่าเริ่มต้นแล้ว",
+  "settings.shortcuts.conflict.title": "ทางลัดใช้งานอยู่แล้ว",
+  "settings.shortcuts.conflict.description": "{{keybind}} ถูกกำหนดให้กับ {{titles}} แล้ว",
+  "settings.shortcuts.unassigned": "ไม่ได้กำหนด",
+  "settings.shortcuts.pressKeys": "กดปุ่ม",
+  "settings.shortcuts.search.placeholder": "ค้นหาทางลัด",
+  "settings.shortcuts.search.empty": "ไม่พบทางลัด",
+
+  "settings.shortcuts.group.general": "ทั่วไป",
+  "settings.shortcuts.group.session": "เซสชัน",
+  "settings.shortcuts.group.navigation": "การนำทาง",
+  "settings.shortcuts.group.modelAndAgent": "โมเดลและเอเจนต์",
+  "settings.shortcuts.group.terminal": "เทอร์มินัล",
+  "settings.shortcuts.group.prompt": "พร้อมท์",
+
+  "settings.providers.title": "ผู้ให้บริการ",
+  "settings.providers.description": "การตั้งค่าผู้ให้บริการจะสามารถกำหนดค่าได้ที่นี่",
+  "settings.providers.section.connected": "ผู้ให้บริการที่เชื่อมต่อ",
+  "settings.providers.connected.empty": "ไม่มีผู้ให้บริการที่เชื่อมต่อ",
+  "settings.providers.section.popular": "ผู้ให้บริการยอดนิยม",
+  "settings.providers.tag.environment": "สภาพแวดล้อม",
+  "settings.providers.tag.config": "กำหนดค่า",
+  "settings.providers.tag.custom": "กำหนดเอง",
+  "settings.providers.tag.other": "อื่น ๆ",
+  "settings.models.title": "โมเดล",
+  "settings.models.description": "การตั้งค่าโมเดลจะสามารถกำหนดค่าได้ที่นี่",
+  "settings.agents.title": "เอเจนต์",
+  "settings.agents.description": "การตั้งค่าเอเจนต์จะสามารถกำหนดค่าได้ที่นี่",
+  "settings.commands.title": "คำสั่ง",
+  "settings.commands.description": "การตั้งค่าคำสั่งจะสามารถกำหนดค่าได้ที่นี่",
+  "settings.mcp.title": "MCP",
+  "settings.mcp.description": "การตั้งค่า MCP จะสามารถกำหนดค่าได้ที่นี่",
+
+  "settings.permissions.title": "สิทธิ์",
+  "settings.permissions.description": "ควบคุมเครื่องมือที่เซิร์ฟเวอร์สามารถใช้โดยค่าเริ่มต้น",
+  "settings.permissions.section.tools": "เครื่องมือ",
+  "settings.permissions.toast.updateFailed.title": "ไม่สามารถอัปเดตสิทธิ์",
+
+  "settings.permissions.action.allow": "อนุญาต",
+  "settings.permissions.action.ask": "ถาม",
+  "settings.permissions.action.deny": "ปฏิเสธ",
+
+  "settings.permissions.tool.read.title": "อ่าน",
+  "settings.permissions.tool.read.description": "อ่านไฟล์ (ตรงกับเส้นทางไฟล์)",
+  "settings.permissions.tool.edit.title": "แก้ไข",
+  "settings.permissions.tool.edit.description": "แก้ไขไฟล์ รวมถึงการแก้ไข เขียน แพตช์ และแก้ไขหลายรายการ",
+  "settings.permissions.tool.glob.title": "Glob",
+  "settings.permissions.tool.glob.description": "จับคู่ไฟล์โดยใช้รูปแบบ glob",
+  "settings.permissions.tool.grep.title": "Grep",
+  "settings.permissions.tool.grep.description": "ค้นหาเนื้อหาไฟล์โดยใช้นิพจน์ทั่วไป",
+  "settings.permissions.tool.list.title": "รายการ",
+  "settings.permissions.tool.list.description": "แสดงรายการไฟล์ภายในไดเรกทอรี",
+  "settings.permissions.tool.bash.title": "Bash",
+  "settings.permissions.tool.bash.description": "เรียกใช้คำสั่งเชลล์",
+  "settings.permissions.tool.task.title": "งาน",
+  "settings.permissions.tool.task.description": "เปิดเอเจนต์ย่อย",
+  "settings.permissions.tool.skill.title": "ทักษะ",
+  "settings.permissions.tool.skill.description": "โหลดทักษะตามชื่อ",
+  "settings.permissions.tool.lsp.title": "LSP",
+  "settings.permissions.tool.lsp.description": "เรียกใช้การสืบค้นเซิร์ฟเวอร์ภาษา",
+  "settings.permissions.tool.todoread.title": "อ่านรายการงาน",
+  "settings.permissions.tool.todoread.description": "อ่านรายการงาน",
+  "settings.permissions.tool.todowrite.title": "เขียนรายการงาน",
+  "settings.permissions.tool.todowrite.description": "อัปเดตรายการงาน",
+  "settings.permissions.tool.webfetch.title": "ดึงข้อมูลจากเว็บ",
+  "settings.permissions.tool.webfetch.description": "ดึงเนื้อหาจาก URL",
+  "settings.permissions.tool.websearch.title": "ค้นหาเว็บ",
+  "settings.permissions.tool.websearch.description": "ค้นหาบนเว็บ",
+  "settings.permissions.tool.codesearch.title": "ค้นหาโค้ด",
+  "settings.permissions.tool.codesearch.description": "ค้นหาโค้ดบนเว็บ",
+  "settings.permissions.tool.external_directory.title": "ไดเรกทอรีภายนอก",
+  "settings.permissions.tool.external_directory.description": "เข้าถึงไฟล์นอกไดเรกทอรีโปรเจกต์",
+  "settings.permissions.tool.doom_loop.title": "Doom Loop",
+  "settings.permissions.tool.doom_loop.description": "ตรวจจับการเรียกเครื่องมือซ้ำด้วยข้อมูลนำเข้าเหมือนกัน",
+
+  "session.delete.failed.title": "ไม่สามารถลบเซสชัน",
+  "session.delete.title": "ลบเซสชัน",
+  "session.delete.confirm": 'ลบเซสชัน "{{name}}" หรือไม่?',
+  "session.delete.button": "ลบเซสชัน",
+
+  "workspace.new": "พื้นที่ทำงานใหม่",
+  "workspace.type.local": "ในเครื่อง",
+  "workspace.type.sandbox": "แซนด์บ็อกซ์",
+  "workspace.create.failed.title": "ไม่สามารถสร้างพื้นที่ทำงาน",
+  "workspace.delete.failed.title": "ไม่สามารถลบพื้นที่ทำงาน",
+  "workspace.resetting.title": "กำลังรีเซ็ตพื้นที่ทำงาน",
+  "workspace.resetting.description": "อาจใช้เวลาประมาณหนึ่งนาที",
+  "workspace.reset.failed.title": "ไม่สามารถรีเซ็ตพื้นที่ทำงาน",
+  "workspace.reset.success.title": "รีเซ็ตพื้นที่ทำงานแล้ว",
+  "workspace.reset.success.description": "พื้นที่ทำงานตรงกับสาขาเริ่มต้นแล้ว",
+  "workspace.error.stillPreparing": "พื้นที่ทำงานกำลังเตรียมอยู่",
+  "workspace.status.checking": "กำลังตรวจสอบการเปลี่ยนแปลงที่ไม่ได้ผสาน...",
+  "workspace.status.error": "ไม่สามารถตรวจสอบสถานะ git",
+  "workspace.status.clean": "ไม่ตรวจพบการเปลี่ยนแปลงที่ไม่ได้ผสาน",
+  "workspace.status.dirty": "ตรวจพบการเปลี่ยนแปลงที่ไม่ได้ผสานในพื้นที่ทำงานนี้",
+  "workspace.delete.title": "ลบพื้นที่ทำงาน",
+  "workspace.delete.confirm": 'ลบพื้นที่ทำงาน "{{name}}" หรือไม่?',
+  "workspace.delete.button": "ลบพื้นที่ทำงาน",
+  "workspace.reset.title": "รีเซ็ตพื้นที่ทำงาน",
+  "workspace.reset.confirm": 'รีเซ็ตพื้นที่ทำงาน "{{name}}" หรือไม่?',
+  "workspace.reset.button": "รีเซ็ตพื้นที่ทำงาน",
+  "workspace.reset.archived.none": "ไม่มีเซสชันที่ใช้งานอยู่จะถูกจัดเก็บ",
+  "workspace.reset.archived.one": "1 เซสชันจะถูกจัดเก็บ",
+  "workspace.reset.archived.many": "{{count}} เซสชันจะถูกจัดเก็บ",
+  "workspace.reset.note": "สิ่งนี้จะรีเซ็ตพื้นที่ทำงานให้ตรงกับสาขาเริ่มต้น",
+}

+ 34 - 27
packages/app/src/i18n/zh.ts

@@ -37,12 +37,12 @@ export const dict = {
   "command.palette": "命令面板",
 
   "command.theme.cycle": "切换主题",
-  "command.theme.set": "使用主题: {{theme}}",
+  "command.theme.set": "使用主题{{theme}}",
   "command.theme.scheme.cycle": "切换配色方案",
-  "command.theme.scheme.set": "使用配色方案: {{scheme}}",
+  "command.theme.scheme.set": "使用配色方案{{scheme}}",
 
   "command.language.cycle": "切换语言",
-  "command.language.set": "使用语言: {{language}}",
+  "command.language.set": "使用语言{{language}}",
 
   "command.session.new": "新建会话",
   "command.file.open": "打开文件",
@@ -98,6 +98,10 @@ export const dict = {
   "dialog.provider.anthropic.note": "使用 Claude Pro/Max 或 API 密钥连接",
   "dialog.provider.openai.note": "使用 ChatGPT Pro/Plus 或 API 密钥连接",
   "dialog.provider.copilot.note": "使用 Copilot 或 API 密钥连接",
+  "dialog.provider.opencode.note": "使用 OpenCode Zen 或 API 密钥连接",
+  "dialog.provider.google.note": "使用 Google 账号或 API 密钥连接",
+  "dialog.provider.openrouter.note": "使用 OpenRouter 账号或 API 密钥连接",
+  "dialog.provider.vercel.note": "使用 Vercel 账号或 API 密钥连接",
 
   "dialog.model.select.title": "选择模型",
   "dialog.model.search.placeholder": "搜索模型",
@@ -116,7 +120,7 @@ export const dict = {
   "provider.connect.method.apiKey": "API 密钥",
   "provider.connect.status.inProgress": "正在授权...",
   "provider.connect.status.waiting": "等待授权...",
-  "provider.connect.status.failed": "授权失败: {{error}}",
+  "provider.connect.status.failed": "授权失败{{error}}",
   "provider.connect.apiKey.description":
     "输入你的 {{provider}} API 密钥以连接帐户,并在 OpenCode 中使用 {{provider}} 模型。",
   "provider.connect.apiKey.label": "{{provider}} API 密钥",
@@ -156,7 +160,7 @@ export const dict = {
   "model.input.audio": "音频",
   "model.input.video": "视频",
   "model.input.pdf": "pdf",
-  "model.tooltip.allows": "支持: {{inputs}}",
+  "model.tooltip.allows": "支持{{inputs}}",
   "model.tooltip.reasoning.allowed": "支持推理",
   "model.tooltip.reasoning.none": "不支持推理",
   "model.tooltip.context": "上下文上限 {{limit}}",
@@ -181,30 +185,30 @@ export const dict = {
   "prompt.mode.shell.exit": "按 esc 退出",
 
   "prompt.example.1": "修复代码库中的一个 TODO",
-  "prompt.example.2": "这个项目的技术栈是什么?",
+  "prompt.example.2": "这个项目的技术栈是什么",
   "prompt.example.3": "修复失败的测试",
   "prompt.example.4": "解释认证是如何工作的",
   "prompt.example.5": "查找并修复安全漏洞",
   "prompt.example.6": "为用户服务添加单元测试",
   "prompt.example.7": "重构这个函数,让它更易读",
-  "prompt.example.8": "这个错误是什么意思?",
+  "prompt.example.8": "这个错误是什么意思",
   "prompt.example.9": "帮我调试这个问题",
   "prompt.example.10": "生成 API 文档",
   "prompt.example.11": "优化数据库查询",
   "prompt.example.12": "添加输入校验",
   "prompt.example.13": "创建一个新的组件用于...",
-  "prompt.example.14": "我该如何部署这个项目?",
+  "prompt.example.14": "我该如何部署这个项目",
   "prompt.example.15": "审查我的代码并给出最佳实践建议",
   "prompt.example.16": "为这个函数添加错误处理",
   "prompt.example.17": "解释这个正则表达式",
   "prompt.example.18": "把它转换成 TypeScript",
   "prompt.example.19": "在整个代码库中添加日志",
-  "prompt.example.20": "哪些依赖已经过期?",
+  "prompt.example.20": "哪些依赖已经过期",
   "prompt.example.21": "帮我写一个迁移脚本",
   "prompt.example.22": "为这个接口实现缓存",
   "prompt.example.23": "给这个列表添加分页",
   "prompt.example.24": "创建一个 CLI 命令用于...",
-  "prompt.example.25": "这里的环境变量是怎么工作的?",
+  "prompt.example.25": "这里的环境变量是怎么工作的",
 
   "prompt.popover.emptyResults": "没有匹配的结果",
   "prompt.popover.emptyCommands": "没有匹配的命令",
@@ -330,6 +334,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "语言",
   "toast.language.description": "已切换到{{language}}",
@@ -377,31 +382,31 @@ export const dict = {
   "error.page.action.updateTo": "更新到 {{version}}",
   "error.page.report.prefix": "请将此错误报告给 OpenCode 团队",
   "error.page.report.discord": "在 Discord 上",
-  "error.page.version": "版本: {{version}}",
+  "error.page.version": "版本{{version}}",
 
-  "error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html? 或者 id 属性拼写错了?",
+  "error.dev.rootNotFound": "未找到根元素。你是不是忘了把它添加到 index.html?或者 id 属性拼写错了?",
 
-  "error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行?",
+  "error.globalSync.connectFailed": "无法连接到服务器。是否有服务器正在 `{{url}}` 运行",
 
   "error.chain.unknown": "未知错误",
-  "error.chain.causedBy": "原因:",
+  "error.chain.causedBy": "原因",
   "error.chain.apiError": "API 错误",
-  "error.chain.status": "状态: {{status}}",
-  "error.chain.retryable": "可重试: {{retryable}}",
-  "error.chain.responseBody": "响应内容:\n{{body}}",
-  "error.chain.didYouMean": "你是不是想输入: {{suggestions}}",
-  "error.chain.modelNotFound": "未找到模型: {{provider}}/{{model}}",
+  "error.chain.status": "状态{{status}}",
+  "error.chain.retryable": "可重试{{retryable}}",
+  "error.chain.responseBody": "响应内容\n{{body}}",
+  "error.chain.didYouMean": "你是不是想输入{{suggestions}}",
+  "error.chain.modelNotFound": "未找到模型{{provider}}/{{model}}",
   "error.chain.checkConfig": "请检查你的配置 (opencode.json) 中的 provider/model 名称",
   "error.chain.mcpFailed": 'MCP 服务器 "{{name}}" 启动失败。注意: OpenCode 暂不支持 MCP 认证。',
-  "error.chain.providerAuthFailed": "提供商认证失败 ({{provider}}): {{message}}",
+  "error.chain.providerAuthFailed": "提供商认证失败({{provider}}):{{message}}",
   "error.chain.providerInitFailed": '无法初始化提供商 "{{provider}}"。请检查凭据和配置。',
   "error.chain.configJsonInvalid": "配置文件 {{path}} 不是有效的 JSON(C)",
-  "error.chain.configJsonInvalidWithMessage": "配置文件 {{path}} 不是有效的 JSON(C): {{message}}",
+  "error.chain.configJsonInvalidWithMessage": "配置文件 {{path}} 不是有效的 JSON(C){{message}}",
   "error.chain.configDirectoryTypo":
     '{{path}} 中的目录 "{{dir}}" 无效。请将目录重命名为 "{{suggestion}}" 或移除它。这是一个常见拼写错误。',
-  "error.chain.configFrontmatterError": "无法解析 {{path}} 中的 frontmatter:\n{{message}}",
+  "error.chain.configFrontmatterError": "无法解析 {{path}} 中的 frontmatter\n{{message}}",
   "error.chain.configInvalid": "配置文件 {{path}} 无效",
-  "error.chain.configInvalidWithMessage": "配置文件 {{path}} 无效: {{message}}",
+  "error.chain.configInvalidWithMessage": "配置文件 {{path}} 无效{{message}}",
 
   "notification.permission.title": "需要权限",
   "notification.permission.description": "{{sessionTitle}}({{projectName}})需要权限",
@@ -438,7 +443,7 @@ export const dict = {
   "session.context.addToContext": "将 {{selection}} 添加到上下文",
 
   "session.new.worktree.main": "主分支",
-  "session.new.worktree.mainWithBranch": "主分支 ({{branch}})",
+  "session.new.worktree.mainWithBranch": "主分支({{branch}})",
   "session.new.worktree.create": "创建新的 worktree",
   "session.new.lastModified": "最后修改",
 
@@ -521,6 +526,8 @@ export const dict = {
   "settings.general.row.theme.description": "自定义 OpenCode 的主题。",
   "settings.general.row.font.title": "字体",
   "settings.general.row.font.description": "自定义代码块使用的等宽字体",
+  "settings.general.row.releaseNotes.title": "发行说明",
+  "settings.general.row.releaseNotes.description": "更新后显示“新功能”弹窗",
 
   "settings.general.row.releaseNotes.title": "发行说明",
   "settings.general.row.releaseNotes.description": "更新后显示“新功能”弹窗",
@@ -685,7 +692,7 @@ export const dict = {
 
   "session.delete.failed.title": "删除会话失败",
   "session.delete.title": "删除会话",
-  "session.delete.confirm": '删除会话 "{{name}}"?',
+  "session.delete.confirm": '删除会话 "{{name}}"',
   "session.delete.button": "删除会话",
 
   "workspace.new": "新建工作区",
@@ -704,10 +711,10 @@ export const dict = {
   "workspace.status.clean": "未检测到未合并的更改。",
   "workspace.status.dirty": "检测到未合并的更改。",
   "workspace.delete.title": "删除工作区",
-  "workspace.delete.confirm": '删除工作区 "{{name}}"?',
+  "workspace.delete.confirm": '删除工作区 "{{name}}"',
   "workspace.delete.button": "删除工作区",
   "workspace.reset.title": "重置工作区",
-  "workspace.reset.confirm": '重置工作区 "{{name}}"?',
+  "workspace.reset.confirm": '重置工作区 "{{name}}"',
   "workspace.reset.button": "重置工作区",
   "workspace.reset.archived.none": "不会归档任何活跃会话。",
   "workspace.reset.archived.one": "将归档 1 个会话。",

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

@@ -331,6 +331,7 @@ export const dict = {
   "language.ar": "العربية",
   "language.no": "Norsk",
   "language.br": "Português (Brasil)",
+  "language.th": "ไทย",
 
   "toast.language.title": "語言",
   "toast.language.description": "已切換到 {{language}}",

+ 180 - 98
packages/app/src/pages/session.tsx

@@ -324,6 +324,7 @@ export default function Page() {
   }
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
+  const centered = createMemo(() => isDesktop() && !layout.fileTree.opened())
 
   function normalizeTab(tab: string) {
     if (!tab.startsWith("file://")) return tab
@@ -478,6 +479,12 @@ export default function Page() {
     const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset
     if (targetIndex < 0 || targetIndex >= msgs.length) return
 
+    if (targetIndex === msgs.length - 1) {
+      resumeScroll()
+      return
+    }
+
+    autoScroll.pause()
     scrollToMessage(msgs[targetIndex], "auto")
   }
 
@@ -524,14 +531,7 @@ export default function Page() {
 
   const scrollGestureWindowMs = 250
 
-  const scrollIgnoreWindowMs = 250
-  let scrollIgnore = 0
-
-  const markScrollIgnore = () => {
-    scrollIgnore = Date.now()
-  }
-
-  const hasScrollIgnore = () => Date.now() - scrollIgnore < scrollIgnoreWindowMs
+  let touchGesture: number | undefined
 
   const markScrollGesture = (target?: EventTarget | null) => {
     const root = scroller
@@ -730,8 +730,8 @@ export default function Page() {
       onSelect: () => view().terminal.toggle(),
     },
     {
-      id: "fileTree.toggle",
-      title: language.t("command.fileTree.toggle"),
+      id: "review.toggle",
+      title: language.t("command.review.toggle"),
       description: "",
       category: language.t("command.category.view"),
       keybind: "mod+shift+r",
@@ -1127,6 +1127,46 @@ export default function Page() {
     setFileTreeTab("all")
   }
 
+  const reviewPanel = () => (
+    <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
+      <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+        <Switch>
+          <Match when={hasReview()}>
+            <Show
+              when={diffsReady()}
+              fallback={<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>}
+            >
+              <SessionReviewTab
+                diffs={diffs}
+                view={view}
+                diffStyle={layout.review.diffStyle()}
+                onDiffStyleChange={layout.review.setDiffStyle}
+                onScrollRef={setReviewScroll}
+                focusedFile={activeDiff()}
+                onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
+                comments={comments.all()}
+                focusedComment={comments.focus()}
+                onFocusedCommentChange={comments.setFocus}
+                onViewFile={(path) => {
+                  showAllFiles()
+                  const value = file.tab(path)
+                  tabs().open(value)
+                  file.load(path)
+                }}
+              />
+            </Show>
+          </Match>
+          <Match when={true}>
+            <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
+              <Mark class="w-14 opacity-10" />
+              <div class="text-14-regular text-text-weak max-w-56">{language.t("session.review.empty")}</div>
+            </div>
+          </Match>
+        </Switch>
+      </div>
+    </div>
+  )
+
   createEffect(
     on(
       () => tabs().active(),
@@ -1252,37 +1292,21 @@ export default function Page() {
     const id = params.id
     if (!id) return
 
-    const wants = isDesktop() ? fileTreeTab() === "changes" : store.mobileTab === "changes"
+    const wants = isDesktop() ? layout.fileTree.opened() && fileTreeTab() === "changes" : store.mobileTab === "changes"
     if (!wants) return
     if (sync.data.session_diff[id] !== undefined) return
+    if (sync.status === "loading") return
 
-    const state = {
-      cancelled: false,
-      attempt: 0,
-      timer: undefined as number | undefined,
-    }
-
-    const load = () => {
-      if (state.cancelled) return
-      const pending = sync.session.diff(id)
-      if (!pending) return
-      pending.catch(() => {
-        if (state.cancelled) return
-        const attempt = state.attempt + 1
-        state.attempt = attempt
-        if (attempt > 5) return
-        if (state.timer !== undefined) clearTimeout(state.timer)
-        const wait = Math.min(10000, 250 * 2 ** (attempt - 1))
-        state.timer = window.setTimeout(load, wait)
-      })
-    }
+    void sync.session.diff(id)
+  })
 
-    load()
+  createEffect(() => {
+    if (!isDesktop()) return
+    if (!layout.fileTree.opened()) return
+    if (sync.status === "loading") return
 
-    onCleanup(() => {
-      state.cancelled = true
-      if (state.timer !== undefined) clearTimeout(state.timer)
-    })
+    fileTreeTab()
+    void file.tree.list("")
   })
 
   const autoScroll = createAutoScroll({
@@ -1290,9 +1314,15 @@ export default function Page() {
     overflowAnchor: "dynamic",
   })
 
+  const clearMessageHash = () => {
+    if (!window.location.hash) return
+    window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
+  }
+
   const resumeScroll = () => {
     setStore("messageId", undefined)
     autoScroll.forceScrollToBottom()
+    clearMessageHash()
   }
 
   // When the user returns to the bottom, treat the active message as "latest".
@@ -1302,6 +1332,7 @@ export default function Page() {
       (scrolled) => {
         if (scrolled) return
         setStore("messageId", undefined)
+        clearMessageHash()
       },
       { defer: true },
     ),
@@ -1377,7 +1408,6 @@ export default function Page() {
     requestAnimationFrame(() => {
       const delta = el.scrollHeight - beforeHeight
       if (!delta) return
-      markScrollIgnore()
       el.scrollTop = beforeTop + delta
     })
 
@@ -1415,7 +1445,6 @@ export default function Page() {
 
       if (stick && el) {
         requestAnimationFrame(() => {
-          markScrollIgnore()
           el.scrollTo({ top: el.scrollHeight, behavior: "auto" })
         })
       }
@@ -1510,6 +1539,7 @@ export default function Page() {
 
     const match = hash.match(/^message-(.+)$/)
     if (match) {
+      autoScroll.pause()
       const msg = visibleUserMessages().find((m) => m.id === match[1])
       if (msg) {
         scrollToMessage(msg, behavior)
@@ -1523,6 +1553,7 @@ export default function Page() {
 
     const target = document.getElementById(hash)
     if (target) {
+      autoScroll.pause()
       scrollToElement(target, behavior)
       return
     }
@@ -1619,6 +1650,7 @@ export default function Page() {
     const msg = visibleUserMessages().find((m) => m.id === targetId)
     if (!msg) return
     if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined)
+    autoScroll.pause()
     requestAnimationFrame(() => scrollToMessage(msg, "auto"))
   })
 
@@ -1729,10 +1761,11 @@ export default function Page() {
         <div
           classList={{
             "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
-            "flex-1 md:flex-none pt-6 md:pt-3": true,
+            "flex-1 pt-6 md:pt-3": true,
+            "md:flex-none": layout.fileTree.opened(),
           }}
           style={{
-            width: isDesktop() ? `${layout.session.width()}px` : "100%",
+            width: isDesktop() && layout.fileTree.opened() ? `${layout.session.width()}px` : "100%",
             "--prompt-height": store.promptHeight ? `${store.promptHeight}px` : undefined,
           }}
         >
@@ -1799,28 +1832,102 @@ export default function Page() {
                       >
                         <button
                           class="pointer-events-auto size-8 flex items-center justify-center rounded-full bg-background-base border border-border-base shadow-sm text-text-base hover:bg-background-stronger transition-colors"
-                          onClick={() => {
-                            setStore("messageId", undefined)
-                            autoScroll.forceScrollToBottom()
-                            window.history.replaceState(null, "", window.location.href.replace(/#.*$/, ""))
-                          }}
+                          onClick={resumeScroll}
                         >
                           <Icon name="arrow-down-to-line" />
                         </button>
                       </div>
                       <div
                         ref={setScrollRef}
-                        onWheel={(e) => markScrollGesture(e.target)}
-                        onTouchMove={(e) => markScrollGesture(e.target)}
+                        onWheel={(e) => {
+                          const root = e.currentTarget
+                          const target = e.target instanceof Element ? e.target : undefined
+                          const nested = target?.closest("[data-scrollable]")
+                          if (!nested || nested === root) {
+                            markScrollGesture(root)
+                            return
+                          }
+
+                          if (!(nested instanceof HTMLElement)) {
+                            markScrollGesture(root)
+                            return
+                          }
+
+                          const max = nested.scrollHeight - nested.clientHeight
+                          if (max <= 1) {
+                            markScrollGesture(root)
+                            return
+                          }
+
+                          const delta =
+                            e.deltaMode === 1
+                              ? e.deltaY * 40
+                              : e.deltaMode === 2
+                                ? e.deltaY * root.clientHeight
+                                : e.deltaY
+                          if (!delta) return
+
+                          if (delta < 0) {
+                            if (nested.scrollTop + delta <= 0) markScrollGesture(root)
+                            return
+                          }
+
+                          const remaining = max - nested.scrollTop
+                          if (delta > remaining) markScrollGesture(root)
+                        }}
+                        onTouchStart={(e) => {
+                          touchGesture = e.touches[0]?.clientY
+                        }}
+                        onTouchMove={(e) => {
+                          const next = e.touches[0]?.clientY
+                          const prev = touchGesture
+                          touchGesture = next
+                          if (next === undefined || prev === undefined) return
+
+                          const delta = prev - next
+                          if (!delta) return
+
+                          const root = e.currentTarget
+                          const target = e.target instanceof Element ? e.target : undefined
+                          const nested = target?.closest("[data-scrollable]")
+                          if (!nested || nested === root) {
+                            markScrollGesture(root)
+                            return
+                          }
+
+                          if (!(nested instanceof HTMLElement)) {
+                            markScrollGesture(root)
+                            return
+                          }
+
+                          const max = nested.scrollHeight - nested.clientHeight
+                          if (max <= 1) {
+                            markScrollGesture(root)
+                            return
+                          }
+
+                          if (delta < 0) {
+                            if (nested.scrollTop + delta <= 0) markScrollGesture(root)
+                            return
+                          }
+
+                          const remaining = max - nested.scrollTop
+                          if (delta > remaining) markScrollGesture(root)
+                        }}
+                        onTouchEnd={() => {
+                          touchGesture = undefined
+                        }}
+                        onTouchCancel={() => {
+                          touchGesture = undefined
+                        }}
                         onPointerDown={(e) => {
                           if (e.target !== e.currentTarget) return
-                          markScrollGesture(e.target)
+                          markScrollGesture(e.currentTarget)
                         }}
                         onScroll={(e) => {
-                          const gesture = hasScrollGesture()
-                          if (!hasScrollIgnore() || gesture) autoScroll.handleScroll()
-                          if (!gesture) return
-                          markScrollGesture(e.target)
+                          if (!hasScrollGesture()) return
+                          autoScroll.handleScroll()
+                          markScrollGesture(e.currentTarget)
                           if (isDesktop()) scheduleScrollSpy(e.currentTarget)
                         }}
                         onClick={autoScroll.handleInteraction}
@@ -1833,6 +1940,7 @@ export default function Page() {
                               "sticky top-0 z-30 bg-background-stronger": true,
                               "w-full": true,
                               "px-4 md:px-6": true,
+                              "md:max-w-200 md:mx-auto": centered(),
                             }}
                           >
                             <div class="h-10 flex items-center gap-1">
@@ -1857,7 +1965,13 @@ export default function Page() {
                         <div
                           ref={autoScroll.contentRef}
                           role="log"
-                          class="flex flex-col gap-32 items-start justify-start w-full mt-0 pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
+                          class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
+                          classList={{
+                            "w-full": true,
+                            "md:max-w-200 md:mx-auto": centered(),
+                            "mt-0.5": centered(),
+                            "mt-0": !centered(),
+                          }}
                         >
                           <Show when={store.turnStart > 0}>
                             <div class="w-full flex justify-center">
@@ -1905,7 +2019,10 @@ export default function Page() {
                                 <div
                                   id={anchor(message.id)}
                                   data-message-id={message.id}
-                                  class="min-w-0 w-full max-w-full"
+                                  classList={{
+                                    "min-w-0 w-full max-w-full": true,
+                                    "md:max-w-200": centered(),
+                                  }}
                                 >
                                   <SessionTurn
                                     sessionID={params.id!}
@@ -1958,7 +2075,12 @@ export default function Page() {
             ref={(el) => (promptDock = el)}
             class="absolute inset-x-0 bottom-0 pt-12 pb-4 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none"
           >
-            <div class="w-full px-4 pointer-events-auto">
+            <div
+              classList={{
+                "w-full px-4 pointer-events-auto": true,
+                "md:max-w-200 md:mx-auto": centered(),
+              }}
+            >
               <Show when={request()} keyed>
                 {(perm) => (
                   <div data-component="tool-part-wrapper" data-permission="true" class="mb-3">
@@ -2029,7 +2151,7 @@ export default function Page() {
             </div>
           </div>
 
-          <Show when={isDesktop()}>
+          <Show when={isDesktop() && layout.fileTree.opened()}>
             <ResizeHandle
               direction="horizontal"
               size={layout.session.width()}
@@ -2041,7 +2163,7 @@ export default function Page() {
         </div>
 
         {/* Desktop side panel - hidden on mobile */}
-        <Show when={isDesktop()}>
+        <Show when={isDesktop() && layout.fileTree.opened()}>
           <aside
             id="review-panel"
             aria-label={language.t("session.panel.reviewAndFiles")}
@@ -2645,47 +2767,7 @@ export default function Page() {
                   </DragDropProvider>
                 }
               >
-                <div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
-                  <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                    <Switch>
-                      <Match when={hasReview()}>
-                        <Show
-                          when={diffsReady()}
-                          fallback={
-                            <div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
-                          }
-                        >
-                          <SessionReviewTab
-                            diffs={diffs}
-                            view={view}
-                            diffStyle={layout.review.diffStyle()}
-                            onDiffStyleChange={layout.review.setDiffStyle}
-                            onScrollRef={setReviewScroll}
-                            focusedFile={activeDiff()}
-                            onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
-                            comments={comments.all()}
-                            focusedComment={comments.focus()}
-                            onFocusedCommentChange={comments.setFocus}
-                            onViewFile={(path) => {
-                              showAllFiles()
-                              const value = file.tab(path)
-                              tabs().open(value)
-                              file.load(path)
-                            }}
-                          />
-                        </Show>
-                      </Match>
-                      <Match when={true}>
-                        <div class="h-full px-6 pb-30 flex flex-col items-center justify-center text-center gap-6">
-                          <Mark class="w-14 opacity-10" />
-                          <div class="text-14-regular text-text-weak max-w-56">
-                            {language.t("session.review.empty")}
-                          </div>
-                        </div>
-                      </Match>
-                    </Switch>
-                  </div>
-                </div>
+                {reviewPanel()}
               </Show>
             </div>
 

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

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

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

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

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

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

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

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

+ 1 - 1
packages/desktop/package.json

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

+ 1 - 1
packages/enterprise/package.json

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

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

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

+ 1 - 1
packages/function/package.json

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

+ 1 - 1
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.1.36",
+  "version": "1.1.40",
   "name": "opencode",
   "type": "module",
   "license": "MIT",

+ 2 - 5
packages/opencode/src/auth/index.ts

@@ -1,6 +1,5 @@
 import path from "path"
 import { Global } from "../global"
-import fs from "fs/promises"
 import z from "zod"
 
 export const OAUTH_DUMMY_KEY = "opencode-oauth-dummy-key"
@@ -59,15 +58,13 @@ export namespace Auth {
   export async function set(key: string, info: Info) {
     const file = Bun.file(filepath)
     const data = await all()
-    await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2))
-    await fs.chmod(file.name!, 0o600)
+    await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2), { mode: 0o600 })
   }
 
   export async function remove(key: string) {
     const file = Bun.file(filepath)
     const data = await all()
     delete data[key]
-    await Bun.write(file, JSON.stringify(data, null, 2))
-    await fs.chmod(file.name!, 0o600)
+    await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 })
   }
 }

+ 4 - 7
packages/opencode/src/cli/cmd/tui/component/logo.tsx

@@ -1,16 +1,13 @@
 import { TextAttributes, RGBA } from "@opentui/core"
 import { For, type JSX } from "solid-js"
 import { useTheme, tint } from "@tui/context/theme"
+import { logo, marks } from "@/cli/logo"
 
 // Shadow markers (rendered chars in parens):
 // _ = full shadow cell (space with bg=shadow)
 // ^ = letter top, shadow bottom (▀ with fg=letter, bg=shadow)
 // ~ = shadow top only (▀ with fg=shadow)
-const SHADOW_MARKER = /[_^~]/
-
-const LOGO_LEFT = [`                   `, `█▀▀█ █▀▀█ █▀▀█ █▀▀▄`, `█__█ █__█ █^^^ █__█`, `▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀`]
-
-const LOGO_RIGHT = [`             ▄     `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`, `█___ █__█ █__█ █^^^`, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`]
+const SHADOW_MARKER = new RegExp(`[${marks}]`)
 
 export function Logo() {
   const { theme } = useTheme()
@@ -75,11 +72,11 @@ export function Logo() {
 
   return (
     <box>
-      <For each={LOGO_LEFT}>
+      <For each={logo.left}>
         {(line, index) => (
           <box flexDirection="row" gap={1}>
             <box flexDirection="row">{renderLine(line, theme.textMuted, false)}</box>
-            <box flexDirection="row">{renderLine(LOGO_RIGHT[index()], theme.text, true)}</box>
+            <box flexDirection="row">{renderLine(logo.right[index()], theme.text, true)}</box>
           </box>
         )}
       </For>

+ 22 - 9
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -58,6 +58,7 @@ import { DialogTimeline } from "./dialog-timeline"
 import { DialogForkFromTimeline } from "./dialog-fork-from-timeline"
 import { DialogSessionRename } from "../../component/dialog-session-rename"
 import { Sidebar } from "./sidebar"
+import { Flag } from "@/flag/flag"
 import { LANGUAGE_EXTENSIONS } from "@/lsp/language"
 import parsers from "../../../../../../parsers-config.ts"
 import { Clipboard } from "../../util/clipboard"
@@ -1338,15 +1339,27 @@ function TextPart(props: { last: boolean; part: TextPart; message: AssistantMess
   return (
     <Show when={props.part.text.trim()}>
       <box id={"text-" + props.part.id} paddingLeft={3} marginTop={1} flexShrink={0}>
-        <code
-          filetype="markdown"
-          drawUnstyledText={false}
-          streaming={true}
-          syntaxStyle={syntax()}
-          content={props.part.text.trim()}
-          conceal={ctx.conceal()}
-          fg={theme.text}
-        />
+        <Switch>
+          <Match when={Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
+            <markdown
+              syntaxStyle={syntax()}
+              streaming={true}
+              content={props.part.text.trim()}
+              conceal={ctx.conceal()}
+            />
+          </Match>
+          <Match when={!Flag.OPENCODE_EXPERIMENTAL_MARKDOWN}>
+            <code
+              filetype="markdown"
+              drawUnstyledText={false}
+              streaming={true}
+              syntaxStyle={syntax()}
+              content={props.part.text.trim()}
+              conceal={ctx.conceal()}
+              fg={theme.text}
+            />
+          </Match>
+        </Switch>
       </box>
     </Show>
   )

+ 6 - 0
packages/opencode/src/cli/logo.ts

@@ -0,0 +1,6 @@
+export const logo = {
+  left: ["                   ", "█▀▀█ █▀▀█ █▀▀█ █▀▀▄", "█__█ █__█ █^^^ █__█", "▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀~~▀"],
+  right: ["             ▄     ", "█▀▀▀ █▀▀█ █▀▀█ █▀▀█", "█___ █__█ █__█ █^^^", "▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀"],
+}
+
+export const marks = "_^~"

+ 43 - 14
packages/opencode/src/cli/ui.ts

@@ -1,15 +1,9 @@
 import z from "zod"
 import { EOL } from "os"
 import { NamedError } from "@opencode-ai/util/error"
+import { logo as glyphs } from "./logo"
 
 export namespace UI {
-  const LOGO = [
-    [`                    `, `             ▄     `],
-    [`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ `, `█▀▀▀ █▀▀█ █▀▀█ █▀▀█`],
-    [`█░░█ █░░█ █▀▀▀ █░░█ `, `█░░░ █░░█ █░░█ █▀▀▀`],
-    [`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀  ▀ `, `▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`],
-  ]
-
   export const CancelledError = NamedError.create("UICancelledError", z.void())
 
   export const Style = {
@@ -47,15 +41,50 @@ export namespace UI {
   }
 
   export function logo(pad?: string) {
-    const result = []
-    for (const row of LOGO) {
+    const result: string[] = []
+    const reset = "\x1b[0m"
+    const left = {
+      fg: Bun.color("gray", "ansi") ?? "",
+      shadow: "\x1b[38;5;235m",
+      bg: "\x1b[48;5;235m",
+    }
+    const right = {
+      fg: reset,
+      shadow: "\x1b[38;5;238m",
+      bg: "\x1b[48;5;238m",
+    }
+    const gap = " "
+    const draw = (line: string, fg: string, shadow: string, bg: string) => {
+      const parts: string[] = []
+      for (const char of line) {
+        if (char === "_") {
+          parts.push(bg, " ", reset)
+          continue
+        }
+        if (char === "^") {
+          parts.push(fg, bg, "▀", reset)
+          continue
+        }
+        if (char === "~") {
+          parts.push(shadow, "▀", reset)
+          continue
+        }
+        if (char === " ") {
+          parts.push(" ")
+          continue
+        }
+        parts.push(fg, char, reset)
+      }
+      return parts.join("")
+    }
+    glyphs.left.forEach((row, index) => {
       if (pad) result.push(pad)
-      result.push(Bun.color("gray", "ansi"))
-      result.push(row[0])
-      result.push("\x1b[0m")
-      result.push(row[1])
+      result.push(draw(row, left.fg, left.shadow, left.bg))
+      result.push(gap)
+      const other = glyphs.right[index] ?? ""
+      result.push(draw(other, right.fg, right.shadow, right.bg))
       result.push(EOL)
-    }
+    })
     return result.join("").trimEnd()
   }
 

+ 43 - 29
packages/opencode/src/config/config.ts

@@ -1104,20 +1104,23 @@ export namespace Config {
       mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
     )
 
-    await import(path.join(Global.Path.config, "config"), {
-      with: {
-        type: "toml",
-      },
-    })
-      .then(async (mod) => {
-        const { provider, model, ...rest } = mod.default
-        if (provider && model) result.model = `${provider}/${model}`
-        result["$schema"] = "https://opencode.ai/config.json"
-        result = mergeDeep(result, rest)
-        await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
-        await fs.unlink(path.join(Global.Path.config, "config"))
+    const legacy = path.join(Global.Path.config, "config")
+    if (existsSync(legacy)) {
+      await import(pathToFileURL(legacy).href, {
+        with: {
+          type: "toml",
+        },
       })
-      .catch(() => {})
+        .then(async (mod) => {
+          const { provider, model, ...rest } = mod.default
+          if (provider && model) result.model = `${provider}/${model}`
+          result["$schema"] = "https://opencode.ai/config.json"
+          result = mergeDeep(result, rest)
+          await Bun.write(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
+          await fs.unlink(legacy)
+        })
+        .catch(() => {})
+    }
 
     return result
   })
@@ -1341,24 +1344,35 @@ export namespace Config {
         throw new JsonError({ path: filepath }, { cause: err })
       })
 
-    if (!filepath.endsWith(".jsonc")) {
-      const existing = parseConfig(before, filepath)
-      await Bun.write(filepath, JSON.stringify(mergeDeep(existing, config), null, 2))
-    } else {
-      const next = patchJsonc(before, config)
-      parseConfig(next, filepath)
-      await Bun.write(filepath, next)
-    }
+    const next = await (async () => {
+      if (!filepath.endsWith(".jsonc")) {
+        const existing = parseConfig(before, filepath)
+        const merged = mergeDeep(existing, config)
+        await Bun.write(filepath, JSON.stringify(merged, null, 2))
+        return merged
+      }
+
+      const updated = patchJsonc(before, config)
+      const merged = parseConfig(updated, filepath)
+      await Bun.write(filepath, updated)
+      return merged
+    })()
 
     global.reset()
-    await Instance.disposeAll()
-    GlobalBus.emit("event", {
-      directory: "global",
-      payload: {
-        type: Event.Disposed.type,
-        properties: {},
-      },
-    })
+
+    void Instance.disposeAll()
+      .catch(() => undefined)
+      .finally(() => {
+        GlobalBus.emit("event", {
+          directory: "global",
+          payload: {
+            type: Event.Disposed.type,
+            properties: {},
+          },
+        })
+      })
+
+    return next
   }
 
   export async function directories() {

+ 1 - 0
packages/opencode/src/flag/flag.ts

@@ -46,6 +46,7 @@ export namespace Flag {
   export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
   export const OPENCODE_DISABLE_FILETIME_CHECK = truthy("OPENCODE_DISABLE_FILETIME_CHECK")
   export const OPENCODE_EXPERIMENTAL_PLAN_MODE = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_PLAN_MODE")
+  export const OPENCODE_EXPERIMENTAL_MARKDOWN = truthy("OPENCODE_EXPERIMENTAL_MARKDOWN")
   export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
 
   function number(key: string) {

+ 1 - 1
packages/opencode/src/global/index.ts

@@ -33,7 +33,7 @@ await Promise.all([
   fs.mkdir(Global.Path.bin, { recursive: true }),
 ])
 
-const CACHE_VERSION = "19"
+const CACHE_VERSION = "21"
 
 const version = await Bun.file(path.join(Global.Path.cache, "version"))
   .text()

+ 2 - 5
packages/opencode/src/mcp/auth.ts

@@ -1,5 +1,4 @@
 import path from "path"
-import fs from "fs/promises"
 import z from "zod"
 import { Global } from "../global"
 
@@ -65,16 +64,14 @@ export namespace McpAuth {
     if (serverUrl) {
       entry.serverUrl = serverUrl
     }
-    await Bun.write(file, JSON.stringify({ ...data, [mcpName]: entry }, null, 2))
-    await fs.chmod(file.name!, 0o600)
+    await Bun.write(file, JSON.stringify({ ...data, [mcpName]: entry }, null, 2), { mode: 0o600 })
   }
 
   export async function remove(mcpName: string): Promise<void> {
     const file = Bun.file(filepath)
     const data = await all()
     delete data[mcpName]
-    await Bun.write(file, JSON.stringify(data, null, 2))
-    await fs.chmod(file.name!, 0o600)
+    await Bun.write(file, JSON.stringify(data, null, 2), { mode: 0o600 })
   }
 
   export async function updateTokens(mcpName: string, tokens: Tokens, serverUrl?: string): Promise<void> {

+ 85 - 1
packages/opencode/src/plugin/codex.ts

@@ -10,6 +10,7 @@ const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
 const ISSUER = "https://auth.openai.com"
 const CODEX_API_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses"
 const OAUTH_PORT = 1455
+const OAUTH_POLLING_SAFETY_MARGIN_MS = 3000
 
 interface PkceCodes {
   verifier: string
@@ -461,7 +462,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
       },
       methods: [
         {
-          label: "ChatGPT Pro/Plus",
+          label: "ChatGPT Pro/Plus (browser)",
           type: "oauth",
           authorize: async () => {
             const { redirectUri } = await startOAuthServer()
@@ -490,6 +491,89 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
             }
           },
         },
+        {
+          label: "ChatGPT Pro/Plus (headless)",
+          type: "oauth",
+          authorize: async () => {
+            const deviceResponse = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
+              method: "POST",
+              headers: {
+                "Content-Type": "application/json",
+                "User-Agent": `opencode/${Installation.VERSION}`,
+              },
+              body: JSON.stringify({ client_id: CLIENT_ID }),
+            })
+
+            if (!deviceResponse.ok) throw new Error("Failed to initiate device authorization")
+
+            const deviceData = (await deviceResponse.json()) as {
+              device_auth_id: string
+              user_code: string
+              interval: string
+            }
+            const interval = Math.max(parseInt(deviceData.interval) || 5, 1) * 1000
+
+            return {
+              url: `${ISSUER}/codex/device`,
+              instructions: `Enter code: ${deviceData.user_code}`,
+              method: "auto" as const,
+              async callback() {
+                while (true) {
+                  const response = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, {
+                    method: "POST",
+                    headers: {
+                      "Content-Type": "application/json",
+                      "User-Agent": `opencode/${Installation.VERSION}`,
+                    },
+                    body: JSON.stringify({
+                      device_auth_id: deviceData.device_auth_id,
+                      user_code: deviceData.user_code,
+                    }),
+                  })
+
+                  if (response.ok) {
+                    const data = (await response.json()) as {
+                      authorization_code: string
+                      code_verifier: string
+                    }
+
+                    const tokenResponse = await fetch(`${ISSUER}/oauth/token`, {
+                      method: "POST",
+                      headers: { "Content-Type": "application/x-www-form-urlencoded" },
+                      body: new URLSearchParams({
+                        grant_type: "authorization_code",
+                        code: data.authorization_code,
+                        redirect_uri: `${ISSUER}/deviceauth/callback`,
+                        client_id: CLIENT_ID,
+                        code_verifier: data.code_verifier,
+                      }).toString(),
+                    })
+
+                    if (!tokenResponse.ok) {
+                      throw new Error(`Token exchange failed: ${tokenResponse.status}`)
+                    }
+
+                    const tokens: TokenResponse = await tokenResponse.json()
+
+                    return {
+                      type: "success" as const,
+                      refresh: tokens.refresh_token,
+                      access: tokens.access_token,
+                      expires: Date.now() + (tokens.expires_in ?? 3600) * 1000,
+                      accountId: extractAccountId(tokens),
+                    }
+                  }
+
+                  if (response.status !== 403 && response.status !== 404) {
+                    return { type: "failed" as const }
+                  }
+
+                  await Bun.sleep(interval + OAUTH_POLLING_SAFETY_MARGIN_MS)
+                }
+              },
+            }
+          },
+        },
         {
           label: "Manually enter API Key",
           type: "api",

+ 24 - 1
packages/opencode/src/plugin/copilot.ts

@@ -61,12 +61,13 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
             const info = await getAuth()
             if (info.type !== "oauth") return fetch(request, init)
 
+            const url = request instanceof URL ? request.href : request.toString()
             const { isVision, isAgent } = iife(() => {
               try {
                 const body = typeof init?.body === "string" ? JSON.parse(init.body) : init?.body
 
                 // Completions API
-                if (body?.messages) {
+                if (body?.messages && url.includes("completions")) {
                   const last = body.messages[body.messages.length - 1]
                   return {
                     isVision: body.messages.some(
@@ -88,6 +89,28 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
                     isAgent: last?.role !== "user",
                   }
                 }
+
+                // Messages API
+                if (body?.messages) {
+                  const last = body.messages[body.messages.length - 1]
+                  const hasNonToolCalls =
+                    Array.isArray(last?.content) && last.content.some((part: any) => part?.type !== "tool_result")
+                  return {
+                    isVision: body.messages.some(
+                      (item: any) =>
+                        Array.isArray(item?.content) &&
+                        item.content.some(
+                          (part: any) =>
+                            part?.type === "image" ||
+                            // images can be nested inside tool_result content
+                            (part?.type === "tool_result" &&
+                              Array.isArray(part?.content) &&
+                              part.content.some((nested: any) => nested?.type === "image")),
+                        ),
+                    ),
+                    isAgent: !(last?.role === "user" && hasNonToolCalls),
+                  }
+                }
               } catch {}
               return { isVision: false, isAgent: false }
             })

+ 1 - 1
packages/opencode/src/plugin/index.ts

@@ -15,7 +15,7 @@ import { CopilotAuthPlugin } from "./copilot"
 export namespace Plugin {
   const log = Log.create({ service: "plugin" })
 
-  const BUILTIN = ["[email protected]0", "@gitlab/[email protected]"]
+  const BUILTIN = ["[email protected]3", "@gitlab/[email protected]"]
 
   // Built-in plugins that are directly imported (not installed from npm)
   const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin]

+ 30 - 7
packages/opencode/src/project/instance.ts

@@ -14,6 +14,10 @@ interface Context {
 const context = Context.create<Context>("instance")
 const cache = new Map<string, Promise<Context>>()
 
+const disposal = {
+  all: undefined as Promise<void> | undefined,
+}
+
 export const Instance = {
   async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
     let existing = cache.get(input.directory)
@@ -77,15 +81,34 @@ export const Instance = {
     })
   },
   async disposeAll() {
-    Log.Default.info("disposing all instances")
-    for (const [_key, value] of cache) {
-      const awaited = await value.catch(() => {})
-      if (awaited) {
-        await context.provide(await value, async () => {
+    if (disposal.all) return disposal.all
+
+    disposal.all = iife(async () => {
+      Log.Default.info("disposing all instances")
+      const entries = [...cache.entries()]
+      for (const [key, value] of entries) {
+        if (cache.get(key) !== value) continue
+
+        const ctx = await value.catch((error) => {
+          Log.Default.warn("instance dispose failed", { key, error })
+          return undefined
+        })
+
+        if (!ctx) {
+          if (cache.get(key) === value) cache.delete(key)
+          continue
+        }
+
+        if (cache.get(key) !== value) continue
+
+        await context.provide(ctx, async () => {
           await Instance.dispose()
         })
       }
-    }
-    cache.clear()
+    }).finally(() => {
+      disposal.all = undefined
+    })
+
+    return disposal.all
   },
 }

+ 7 - 3
packages/opencode/src/project/state.ts

@@ -46,20 +46,24 @@ export namespace State {
     }, 10000).unref()
 
     const tasks: Promise<void>[] = []
-    for (const entry of entries.values()) {
+    for (const [init, entry] of entries) {
       if (!entry.dispose) continue
 
+      const label = typeof init === "function" ? init.name : String(init)
+
       const task = Promise.resolve(entry.state)
         .then((state) => entry.dispose!(state))
         .catch((error) => {
-          log.error("Error while disposing state:", { error, key })
+          log.error("Error while disposing state:", { error, key, init: label })
         })
 
       tasks.push(task)
     }
+    await Promise.all(tasks)
+
     entries.clear()
     recordsByKey.delete(key)
-    await Promise.all(tasks)
+
     disposalFinished = true
     log.info("state disposal completed", { key })
   }

+ 49 - 1
packages/opencode/src/server/routes/global.ts

@@ -1,5 +1,5 @@
 import { Hono } from "hono"
-import { describeRoute, resolver } from "hono-openapi"
+import { describeRoute, resolver, validator } from "hono-openapi"
 import { streamSSE } from "hono/streaming"
 import z from "zod"
 import { BusEvent } from "@/bus/bus-event"
@@ -8,6 +8,8 @@ import { Instance } from "../../project/instance"
 import { Installation } from "@/installation"
 import { Log } from "../../util/log"
 import { lazy } from "../../util/lazy"
+import { Config } from "../../config/config"
+import { errors } from "../error"
 
 const log = Log.create({ service: "server" })
 
@@ -103,6 +105,52 @@ export const GlobalRoutes = lazy(() =>
         })
       },
     )
+    .get(
+      "/config",
+      describeRoute({
+        summary: "Get global configuration",
+        description: "Retrieve the current global OpenCode configuration settings and preferences.",
+        operationId: "global.config.get",
+        responses: {
+          200: {
+            description: "Get global config info",
+            content: {
+              "application/json": {
+                schema: resolver(Config.Info),
+              },
+            },
+          },
+        },
+      }),
+      async (c) => {
+        return c.json(await Config.getGlobal())
+      },
+    )
+    .patch(
+      "/config",
+      describeRoute({
+        summary: "Update global configuration",
+        description: "Update global OpenCode configuration settings and preferences.",
+        operationId: "global.config.update",
+        responses: {
+          200: {
+            description: "Successfully updated global config",
+            content: {
+              "application/json": {
+                schema: resolver(Config.Info),
+              },
+            },
+          },
+          ...errors(400),
+        },
+      }),
+      validator("json", Config.Info),
+      async (c) => {
+        const config = c.req.valid("json")
+        const next = await Config.updateGlobal(config)
+        return c.json(next)
+      },
+    )
     .post(
       "/dispose",
       describeRoute({

+ 62 - 62
packages/opencode/src/server/server.ts

@@ -122,6 +122,68 @@ export namespace Server {
           }),
         )
         .route("/global", GlobalRoutes())
+        .put(
+          "/auth/:providerID",
+          describeRoute({
+            summary: "Set auth credentials",
+            description: "Set authentication credentials",
+            operationId: "auth.set",
+            responses: {
+              200: {
+                description: "Successfully set authentication credentials",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              providerID: z.string(),
+            }),
+          ),
+          validator("json", Auth.Info),
+          async (c) => {
+            const providerID = c.req.valid("param").providerID
+            const info = c.req.valid("json")
+            await Auth.set(providerID, info)
+            return c.json(true)
+          },
+        )
+        .delete(
+          "/auth/:providerID",
+          describeRoute({
+            summary: "Remove auth credentials",
+            description: "Remove authentication credentials",
+            operationId: "auth.remove",
+            responses: {
+              200: {
+                description: "Successfully removed authentication credentials",
+                content: {
+                  "application/json": {
+                    schema: resolver(z.boolean()),
+                  },
+                },
+              },
+              ...errors(400),
+            },
+          }),
+          validator(
+            "param",
+            z.object({
+              providerID: z.string(),
+            }),
+          ),
+          async (c) => {
+            const providerID = c.req.valid("param").providerID
+            await Auth.remove(providerID)
+            return c.json(true)
+          },
+        )
         .use(async (c, next) => {
           let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
           try {
@@ -409,68 +471,6 @@ export namespace Server {
             return c.json(await Format.status())
           },
         )
-        .put(
-          "/auth/:providerID",
-          describeRoute({
-            summary: "Set auth credentials",
-            description: "Set authentication credentials",
-            operationId: "auth.set",
-            responses: {
-              200: {
-                description: "Successfully set authentication credentials",
-                content: {
-                  "application/json": {
-                    schema: resolver(z.boolean()),
-                  },
-                },
-              },
-              ...errors(400),
-            },
-          }),
-          validator(
-            "param",
-            z.object({
-              providerID: z.string(),
-            }),
-          ),
-          validator("json", Auth.Info),
-          async (c) => {
-            const providerID = c.req.valid("param").providerID
-            const info = c.req.valid("json")
-            await Auth.set(providerID, info)
-            return c.json(true)
-          },
-        )
-        .delete(
-          "/auth/:providerID",
-          describeRoute({
-            summary: "Remove auth credentials",
-            description: "Remove authentication credentials",
-            operationId: "auth.remove",
-            responses: {
-              200: {
-                description: "Successfully removed authentication credentials",
-                content: {
-                  "application/json": {
-                    schema: resolver(z.boolean()),
-                  },
-                },
-              },
-              ...errors(400),
-            },
-          }),
-          validator(
-            "param",
-            z.object({
-              providerID: z.string(),
-            }),
-          ),
-          async (c) => {
-            const providerID = c.req.valid("param").providerID
-            await Auth.remove(providerID)
-            return c.json(true)
-          },
-        )
         .get(
           "/event",
           describeRoute({

+ 29 - 2
packages/opencode/src/session/instruction.ts

@@ -41,6 +41,32 @@ async function resolveRelative(instruction: string): Promise<string[]> {
 }
 
 export namespace InstructionPrompt {
+  const state = Instance.state(() => {
+    return {
+      claims: new Map<string, Set<string>>(),
+    }
+  })
+
+  function isClaimed(messageID: string, filepath: string) {
+    const claimed = state().claims.get(messageID)
+    if (!claimed) return false
+    return claimed.has(filepath)
+  }
+
+  function claim(messageID: string, filepath: string) {
+    const current = state()
+    let claimed = current.claims.get(messageID)
+    if (!claimed) {
+      claimed = new Set()
+      current.claims.set(messageID, claimed)
+    }
+    claimed.add(filepath)
+  }
+
+  export function clear(messageID: string) {
+    state().claims.delete(messageID)
+  }
+
   export async function systemPaths() {
     const config = await Config.get()
     const paths = new Set<string>()
@@ -137,7 +163,7 @@ export namespace InstructionPrompt {
     }
   }
 
-  export async function resolve(messages: MessageV2.WithParts[], filepath: string) {
+  export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) {
     const system = await systemPaths()
     const already = loaded(messages)
     const results: { filepath: string; content: string }[] = []
@@ -147,7 +173,8 @@ export namespace InstructionPrompt {
 
     while (current.startsWith(root)) {
       const found = await find(current)
-      if (found && !system.has(found) && !already.has(found)) {
+      if (found && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
+        claim(messageID, found)
         const content = await Bun.file(found)
           .text()
           .catch(() => undefined)

+ 2 - 0
packages/opencode/src/session/message-v2.ts

@@ -177,6 +177,8 @@ export namespace MessageV2 {
       })
       .optional(),
     command: z.string().optional(),
+  }).meta({
+    ref: "SubtaskPart",
   })
   export type SubtaskPart = z.infer<typeof SubtaskPart>
 

+ 2 - 0
packages/opencode/src/session/prompt.ts

@@ -549,6 +549,7 @@ export namespace SessionPrompt {
         model,
         abort,
       })
+      using _ = defer(() => InstructionPrompt.clear(processor.message.id))
 
       // Check if user explicitly invoked an agent via @ in this turn
       const lastUserMsg = msgs.findLast((m) => m.info.role === "user")
@@ -837,6 +838,7 @@ export namespace SessionPrompt {
       system: input.system,
       variant: input.variant,
     }
+    using _ = defer(() => InstructionPrompt.clear(info.id))
 
     const parts = await Promise.all(
       input.parts.map(async (part): Promise<MessageV2.Part[]> => {

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

@@ -60,7 +60,7 @@ export const ReadTool = Tool.define("read", {
       throw new Error(`File not found: ${filepath}`)
     }
 
-    const instructions = await InstructionPrompt.resolve(ctx.messages, filepath)
+    const instructions = await InstructionPrompt.resolve(ctx.messages, filepath, ctx.messageID)
 
     // Exclude SVG (XML-based) and vnd.fastbidsheet (.fbs extension, commonly FlatBuffers schema files)
     const isImage =

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

@@ -159,8 +159,10 @@ export const TaskTool = Tool.define("task", async (ctx) => {
           ...Object.fromEntries((config.experimental?.primary_tools ?? []).map((t) => [t, false])),
         },
         parts: promptParts,
+      }).finally(() => {
+        unsub()
       })
-      unsub()
+
       const messages = await Session.messages({ sessionID: session.id })
       const summary = messages
         .filter((x) => x.info.role === "assistant")

+ 6 - 2
packages/opencode/test/session/instruction.test.ts

@@ -18,7 +18,7 @@ describe("InstructionPrompt.resolve", () => {
         const system = await InstructionPrompt.systemPaths()
         expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
 
-        const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"))
+        const results = await InstructionPrompt.resolve([], path.join(tmp.path, "src", "file.ts"), "test-message-1")
         expect(results).toEqual([])
       },
     })
@@ -37,7 +37,11 @@ describe("InstructionPrompt.resolve", () => {
         const system = await InstructionPrompt.systemPaths()
         expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
 
-        const results = await InstructionPrompt.resolve([], path.join(tmp.path, "subdir", "nested", "file.ts"))
+        const results = await InstructionPrompt.resolve(
+          [],
+          path.join(tmp.path, "subdir", "nested", "file.ts"),
+          "test-message-2",
+        )
         expect(results.length).toBe(1)
         expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
       },

+ 1 - 1
packages/plugin/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/plugin",
-  "version": "1.1.36",
+  "version": "1.1.40",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/sdk/js/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/sdk",
-  "version": "1.1.36",
+  "version": "1.1.40",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 117 - 84
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -14,7 +14,7 @@ import type {
   AuthSetErrors,
   AuthSetResponses,
   CommandListResponses,
-  Config as Config2,
+  Config as Config3,
   ConfigGetResponses,
   ConfigProvidersResponses,
   ConfigUpdateErrors,
@@ -34,6 +34,9 @@ import type {
   FindSymbolsResponses,
   FindTextResponses,
   FormatterStatusResponses,
+  GlobalConfigGetResponses,
+  GlobalConfigUpdateErrors,
+  GlobalConfigUpdateResponses,
   GlobalDisposeResponses,
   GlobalEventResponses,
   GlobalHealthResponses,
@@ -215,6 +218,44 @@ class HeyApiRegistry<T> {
   }
 }
 
+export class Config extends HeyApiClient {
+  /**
+   * Get global configuration
+   *
+   * Retrieve the current global OpenCode configuration settings and preferences.
+   */
+  public get<ThrowOnError extends boolean = false>(options?: Options<never, ThrowOnError>) {
+    return (options?.client ?? this.client).get<GlobalConfigGetResponses, unknown, ThrowOnError>({
+      url: "/global/config",
+      ...options,
+    })
+  }
+
+  /**
+   * Update global configuration
+   *
+   * Update global OpenCode configuration settings and preferences.
+   */
+  public update<ThrowOnError extends boolean = false>(
+    parameters?: {
+      config?: Config3
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ key: "config", map: "body" }] }])
+    return (options?.client ?? this.client).patch<GlobalConfigUpdateResponses, GlobalConfigUpdateErrors, ThrowOnError>({
+      url: "/global/config",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
+}
+
 export class Global extends HeyApiClient {
   /**
    * Get health
@@ -251,6 +292,67 @@ export class Global extends HeyApiClient {
       ...options,
     })
   }
+
+  private _config?: Config
+  get config(): Config {
+    return (this._config ??= new Config({ client: this.client }))
+  }
+}
+
+export class Auth extends HeyApiClient {
+  /**
+   * Remove auth credentials
+   *
+   * Remove authentication credentials
+   */
+  public remove<ThrowOnError extends boolean = false>(
+    parameters: {
+      providerID: string
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "providerID" }] }])
+    return (options?.client ?? this.client).delete<AuthRemoveResponses, AuthRemoveErrors, ThrowOnError>({
+      url: "/auth/{providerID}",
+      ...options,
+      ...params,
+    })
+  }
+
+  /**
+   * Set auth credentials
+   *
+   * Set authentication credentials
+   */
+  public set<ThrowOnError extends boolean = false>(
+    parameters: {
+      providerID: string
+      auth?: Auth3
+    },
+    options?: Options<never, ThrowOnError>,
+  ) {
+    const params = buildClientParams(
+      [parameters],
+      [
+        {
+          args: [
+            { in: "path", key: "providerID" },
+            { key: "auth", map: "body" },
+          ],
+        },
+      ],
+    )
+    return (options?.client ?? this.client).put<AuthSetResponses, AuthSetErrors, ThrowOnError>({
+      url: "/auth/{providerID}",
+      ...options,
+      ...params,
+      headers: {
+        "Content-Type": "application/json",
+        ...options?.headers,
+        ...params.headers,
+      },
+    })
+  }
 }
 
 export class Project extends HeyApiClient {
@@ -541,7 +643,7 @@ export class Pty extends HeyApiClient {
   }
 }
 
-export class Config extends HeyApiClient {
+export class Config2 extends HeyApiClient {
   /**
    * Get configuration
    *
@@ -569,7 +671,7 @@ export class Config extends HeyApiClient {
   public update<ThrowOnError extends boolean = false>(
     parameters?: {
       directory?: string
-      config?: Config2
+      config?: Config3
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -2238,7 +2340,7 @@ export class File extends HeyApiClient {
   }
 }
 
-export class Auth extends HeyApiClient {
+export class Auth2 extends HeyApiClient {
   /**
    * Remove MCP OAuth
    *
@@ -2482,9 +2584,9 @@ export class Mcp extends HeyApiClient {
     })
   }
 
-  private _auth?: Auth
-  get auth(): Auth {
-    return (this._auth ??= new Auth({ client: this.client }))
+  private _auth?: Auth2
+  get auth(): Auth2 {
+    return (this._auth ??= new Auth2({ client: this.client }))
   }
 }
 
@@ -3055,75 +3157,6 @@ export class Formatter extends HeyApiClient {
   }
 }
 
-export class Auth2 extends HeyApiClient {
-  /**
-   * Remove auth credentials
-   *
-   * Remove authentication credentials
-   */
-  public remove<ThrowOnError extends boolean = false>(
-    parameters: {
-      providerID: string
-      directory?: string
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "path", key: "providerID" },
-            { in: "query", key: "directory" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).delete<AuthRemoveResponses, AuthRemoveErrors, ThrowOnError>({
-      url: "/auth/{providerID}",
-      ...options,
-      ...params,
-    })
-  }
-
-  /**
-   * Set auth credentials
-   *
-   * Set authentication credentials
-   */
-  public set<ThrowOnError extends boolean = false>(
-    parameters: {
-      providerID: string
-      directory?: string
-      auth?: Auth3
-    },
-    options?: Options<never, ThrowOnError>,
-  ) {
-    const params = buildClientParams(
-      [parameters],
-      [
-        {
-          args: [
-            { in: "path", key: "providerID" },
-            { in: "query", key: "directory" },
-            { key: "auth", map: "body" },
-          ],
-        },
-      ],
-    )
-    return (options?.client ?? this.client).put<AuthSetResponses, AuthSetErrors, ThrowOnError>({
-      url: "/auth/{providerID}",
-      ...options,
-      ...params,
-      headers: {
-        "Content-Type": "application/json",
-        ...options?.headers,
-        ...params.headers,
-      },
-    })
-  }
-}
-
 export class Event extends HeyApiClient {
   /**
    * Subscribe to events
@@ -3158,6 +3191,11 @@ export class OpencodeClient extends HeyApiClient {
     return (this._global ??= new Global({ client: this.client }))
   }
 
+  private _auth?: Auth
+  get auth(): Auth {
+    return (this._auth ??= new Auth({ client: this.client }))
+  }
+
   private _project?: Project
   get project(): Project {
     return (this._project ??= new Project({ client: this.client }))
@@ -3168,9 +3206,9 @@ export class OpencodeClient extends HeyApiClient {
     return (this._pty ??= new Pty({ client: this.client }))
   }
 
-  private _config?: Config
-  get config(): Config {
-    return (this._config ??= new Config({ client: this.client }))
+  private _config?: Config2
+  get config(): Config2 {
+    return (this._config ??= new Config2({ client: this.client }))
   }
 
   private _tool?: Tool
@@ -3268,11 +3306,6 @@ export class OpencodeClient extends HeyApiClient {
     return (this._formatter ??= new Formatter({ client: this.client }))
   }
 
-  private _auth?: Auth2
-  get auth(): Auth2 {
-    return (this._auth ??= new Auth2({ client: this.client }))
-  }
-
   private _event?: Event
   get event(): Event {
     return (this._event ??= new Event({ client: this.client }))

+ 148 - 109
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -233,6 +233,21 @@ export type TextPart = {
   }
 }
 
+export type SubtaskPart = {
+  id: string
+  sessionID: string
+  messageID: string
+  type: "subtask"
+  prompt: string
+  description: string
+  agent: string
+  model?: {
+    providerID: string
+    modelID: string
+  }
+  command?: string
+}
+
 export type ReasoningPart = {
   id: string
   sessionID: string
@@ -449,20 +464,7 @@ export type CompactionPart = {
 
 export type Part =
   | TextPart
-  | {
-      id: string
-      sessionID: string
-      messageID: string
-      type: "subtask"
-      prompt: string
-      description: string
-      agent: string
-      model?: {
-        providerID: string
-        modelID: string
-      }
-      command?: string
-    }
+  | SubtaskPart
   | ReasoningPart
   | FilePart
   | ToolPart
@@ -930,21 +932,6 @@ export type GlobalEvent = {
   payload: Event
 }
 
-export type BadRequestError = {
-  data: unknown
-  errors: Array<{
-    [key: string]: unknown
-  }>
-  success: false
-}
-
-export type NotFoundError = {
-  name: "NotFoundError"
-  data: {
-    message: string
-  }
-}
-
 /**
  * Custom keybind configurations
  */
@@ -1826,6 +1813,43 @@ export type Config = {
   }
 }
 
+export type BadRequestError = {
+  data: unknown
+  errors: Array<{
+    [key: string]: unknown
+  }>
+  success: false
+}
+
+export type OAuth = {
+  type: "oauth"
+  refresh: string
+  access: string
+  expires: number
+  accountId?: string
+  enterpriseUrl?: string
+}
+
+export type ApiAuth = {
+  type: "api"
+  key: string
+}
+
+export type WellKnownAuth = {
+  type: "wellknown"
+  key: string
+  token: string
+}
+
+export type Auth = OAuth | ApiAuth | WellKnownAuth
+
+export type NotFoundError = {
+  name: "NotFoundError"
+  data: {
+    message: string
+  }
+}
+
 export type Model = {
   id: string
   providerID: string
@@ -2142,28 +2166,6 @@ export type FormatterStatus = {
   enabled: boolean
 }
 
-export type OAuth = {
-  type: "oauth"
-  refresh: string
-  access: string
-  expires: number
-  accountId?: string
-  enterpriseUrl?: string
-}
-
-export type ApiAuth = {
-  type: "api"
-  key: string
-}
-
-export type WellKnownAuth = {
-  type: "wellknown"
-  key: string
-  token: string
-}
-
-export type Auth = OAuth | ApiAuth | WellKnownAuth
-
 export type GlobalHealthData = {
   body?: never
   path?: never
@@ -2199,6 +2201,47 @@ export type GlobalEventResponses = {
 
 export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses]
 
+export type GlobalConfigGetData = {
+  body?: never
+  path?: never
+  query?: never
+  url: "/global/config"
+}
+
+export type GlobalConfigGetResponses = {
+  /**
+   * Get global config info
+   */
+  200: Config
+}
+
+export type GlobalConfigGetResponse = GlobalConfigGetResponses[keyof GlobalConfigGetResponses]
+
+export type GlobalConfigUpdateData = {
+  body?: Config
+  path?: never
+  query?: never
+  url: "/global/config"
+}
+
+export type GlobalConfigUpdateErrors = {
+  /**
+   * Bad request
+   */
+  400: BadRequestError
+}
+
+export type GlobalConfigUpdateError = GlobalConfigUpdateErrors[keyof GlobalConfigUpdateErrors]
+
+export type GlobalConfigUpdateResponses = {
+  /**
+   * Successfully updated global config
+   */
+  200: Config
+}
+
+export type GlobalConfigUpdateResponse = GlobalConfigUpdateResponses[keyof GlobalConfigUpdateResponses]
+
 export type GlobalDisposeData = {
   body?: never
   path?: never
@@ -2215,6 +2258,60 @@ export type GlobalDisposeResponses = {
 
 export type GlobalDisposeResponse = GlobalDisposeResponses[keyof GlobalDisposeResponses]
 
+export type AuthRemoveData = {
+  body?: never
+  path: {
+    providerID: string
+  }
+  query?: never
+  url: "/auth/{providerID}"
+}
+
+export type AuthRemoveErrors = {
+  /**
+   * Bad request
+   */
+  400: BadRequestError
+}
+
+export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors]
+
+export type AuthRemoveResponses = {
+  /**
+   * Successfully removed authentication credentials
+   */
+  200: boolean
+}
+
+export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses]
+
+export type AuthSetData = {
+  body?: Auth
+  path: {
+    providerID: string
+  }
+  query?: never
+  url: "/auth/{providerID}"
+}
+
+export type AuthSetErrors = {
+  /**
+   * Bad request
+   */
+  400: BadRequestError
+}
+
+export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]
+
+export type AuthSetResponses = {
+  /**
+   * Successfully set authentication credentials
+   */
+  200: boolean
+}
+
+export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]
+
 export type ProjectListData = {
   body?: never
   path?: never
@@ -4867,64 +4964,6 @@ export type FormatterStatusResponses = {
 
 export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]
 
-export type AuthRemoveData = {
-  body?: never
-  path: {
-    providerID: string
-  }
-  query?: {
-    directory?: string
-  }
-  url: "/auth/{providerID}"
-}
-
-export type AuthRemoveErrors = {
-  /**
-   * Bad request
-   */
-  400: BadRequestError
-}
-
-export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors]
-
-export type AuthRemoveResponses = {
-  /**
-   * Successfully removed authentication credentials
-   */
-  200: boolean
-}
-
-export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses]
-
-export type AuthSetData = {
-  body?: Auth
-  path: {
-    providerID: string
-  }
-  query?: {
-    directory?: string
-  }
-  url: "/auth/{providerID}"
-}
-
-export type AuthSetErrors = {
-  /**
-   * Bad request
-   */
-  400: BadRequestError
-}
-
-export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]
-
-export type AuthSetResponses = {
-  /**
-   * Successfully set authentication credentials
-   */
-  200: boolean
-}
-
-export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]
-
 export type EventSubscribeData = {
   body?: never
   path?: never

+ 342 - 286
packages/sdk/openapi.json

@@ -66,6 +66,73 @@
         ]
       }
     },
+    "/global/config": {
+      "get": {
+        "operationId": "global.config.get",
+        "summary": "Get global configuration",
+        "description": "Retrieve the current global OpenCode configuration settings and preferences.",
+        "responses": {
+          "200": {
+            "description": "Get global config info",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Config"
+                }
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.config.get({\n  ...\n})"
+          }
+        ]
+      },
+      "patch": {
+        "operationId": "global.config.update",
+        "summary": "Update global configuration",
+        "description": "Update global OpenCode configuration settings and preferences.",
+        "responses": {
+          "200": {
+            "description": "Successfully updated global config",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Config"
+                }
+              }
+            }
+          },
+          "400": {
+            "description": "Bad request",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/BadRequestError"
+                }
+              }
+            }
+          }
+        },
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/Config"
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.global.config.update({\n  ...\n})"
+          }
+        ]
+      }
+    },
     "/global/dispose": {
       "post": {
         "operationId": "global.dispose",
@@ -91,6 +158,103 @@
         ]
       }
     },
+    "/auth/{providerID}": {
+      "put": {
+        "operationId": "auth.set",
+        "summary": "Set auth credentials",
+        "description": "Set authentication credentials",
+        "responses": {
+          "200": {
+            "description": "Successfully set authentication credentials",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "boolean"
+                }
+              }
+            }
+          },
+          "400": {
+            "description": "Bad request",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/BadRequestError"
+                }
+              }
+            }
+          }
+        },
+        "parameters": [
+          {
+            "in": "path",
+            "name": "providerID",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          }
+        ],
+        "requestBody": {
+          "content": {
+            "application/json": {
+              "schema": {
+                "$ref": "#/components/schemas/Auth"
+              }
+            }
+          }
+        },
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n  ...\n})"
+          }
+        ]
+      },
+      "delete": {
+        "operationId": "auth.remove",
+        "summary": "Remove auth credentials",
+        "description": "Remove authentication credentials",
+        "responses": {
+          "200": {
+            "description": "Successfully removed authentication credentials",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "boolean"
+                }
+              }
+            }
+          },
+          "400": {
+            "description": "Bad request",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/BadRequestError"
+                }
+              }
+            }
+          }
+        },
+        "parameters": [
+          {
+            "in": "path",
+            "name": "providerID",
+            "schema": {
+              "type": "string"
+            },
+            "required": true
+          }
+        ],
+        "x-codeSamples": [
+          {
+            "lang": "js",
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n  ...\n})"
+          }
+        ]
+      }
+    },
     "/project": {
       "get": {
         "operationId": "project.list",
@@ -5650,9 +5814,9 @@
         ]
       }
     },
-    "/auth/{providerID}": {
-      "put": {
-        "operationId": "auth.set",
+    "/event": {
+      "get": {
+        "operationId": "event.subscribe",
         "parameters": [
           {
             "in": "query",
@@ -5660,159 +5824,48 @@
             "schema": {
               "type": "string"
             }
-          },
-          {
-            "in": "path",
-            "name": "providerID",
-            "schema": {
-              "type": "string"
-            },
-            "required": true
           }
         ],
-        "summary": "Set auth credentials",
-        "description": "Set authentication credentials",
+        "summary": "Subscribe to events",
+        "description": "Get events",
         "responses": {
           "200": {
-            "description": "Successfully set authentication credentials",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "boolean"
-                }
-              }
-            }
-          },
-          "400": {
-            "description": "Bad request",
+            "description": "Event stream",
             "content": {
-              "application/json": {
+              "text/event-stream": {
                 "schema": {
-                  "$ref": "#/components/schemas/BadRequestError"
+                  "$ref": "#/components/schemas/Event"
                 }
               }
             }
           }
         },
-        "requestBody": {
-          "content": {
-            "application/json": {
-              "schema": {
-                "$ref": "#/components/schemas/Auth"
-              }
-            }
-          }
-        },
         "x-codeSamples": [
           {
             "lang": "js",
-            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n  ...\n})"
+            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n  ...\n})"
           }
         ]
-      },
-      "delete": {
-        "operationId": "auth.remove",
-        "parameters": [
-          {
-            "in": "query",
-            "name": "directory",
-            "schema": {
-              "type": "string"
-            }
-          },
-          {
-            "in": "path",
-            "name": "providerID",
-            "schema": {
-              "type": "string"
-            },
-            "required": true
-          }
-        ],
-        "summary": "Remove auth credentials",
-        "description": "Remove authentication credentials",
-        "responses": {
-          "200": {
-            "description": "Successfully removed authentication credentials",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "type": "boolean"
-                }
-              }
-            }
+      }
+    }
+  },
+  "components": {
+    "schemas": {
+      "Event.installation.updated": {
+        "type": "object",
+        "properties": {
+          "type": {
+            "type": "string",
+            "const": "installation.updated"
           },
-          "400": {
-            "description": "Bad request",
-            "content": {
-              "application/json": {
-                "schema": {
-                  "$ref": "#/components/schemas/BadRequestError"
-                }
+          "properties": {
+            "type": "object",
+            "properties": {
+              "version": {
+                "type": "string"
               }
-            }
-          }
-        },
-        "x-codeSamples": [
-          {
-            "lang": "js",
-            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n  ...\n})"
-          }
-        ]
-      }
-    },
-    "/event": {
-      "get": {
-        "operationId": "event.subscribe",
-        "parameters": [
-          {
-            "in": "query",
-            "name": "directory",
-            "schema": {
-              "type": "string"
-            }
-          }
-        ],
-        "summary": "Subscribe to events",
-        "description": "Get events",
-        "responses": {
-          "200": {
-            "description": "Event stream",
-            "content": {
-              "text/event-stream": {
-                "schema": {
-                  "$ref": "#/components/schemas/Event"
-                }
-              }
-            }
-          }
-        },
-        "x-codeSamples": [
-          {
-            "lang": "js",
-            "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n  ...\n})"
-          }
-        ]
-      }
-    }
-  },
-  "components": {
-    "schemas": {
-      "Event.installation.updated": {
-        "type": "object",
-        "properties": {
-          "type": {
-            "type": "string",
-            "const": "installation.updated"
-          },
-          "properties": {
-            "type": "object",
-            "properties": {
-              "version": {
-                "type": "string"
-              }
-            },
-            "required": ["version"]
+            },
+            "required": ["version"]
           }
         },
         "required": ["type", "properties"]
@@ -6449,6 +6502,49 @@
         },
         "required": ["id", "sessionID", "messageID", "type", "text"]
       },
+      "SubtaskPart": {
+        "type": "object",
+        "properties": {
+          "id": {
+            "type": "string"
+          },
+          "sessionID": {
+            "type": "string"
+          },
+          "messageID": {
+            "type": "string"
+          },
+          "type": {
+            "type": "string",
+            "const": "subtask"
+          },
+          "prompt": {
+            "type": "string"
+          },
+          "description": {
+            "type": "string"
+          },
+          "agent": {
+            "type": "string"
+          },
+          "model": {
+            "type": "object",
+            "properties": {
+              "providerID": {
+                "type": "string"
+              },
+              "modelID": {
+                "type": "string"
+              }
+            },
+            "required": ["providerID", "modelID"]
+          },
+          "command": {
+            "type": "string"
+          }
+        },
+        "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"]
+      },
       "ReasoningPart": {
         "type": "object",
         "properties": {
@@ -7072,47 +7168,7 @@
             "$ref": "#/components/schemas/TextPart"
           },
           {
-            "type": "object",
-            "properties": {
-              "id": {
-                "type": "string"
-              },
-              "sessionID": {
-                "type": "string"
-              },
-              "messageID": {
-                "type": "string"
-              },
-              "type": {
-                "type": "string",
-                "const": "subtask"
-              },
-              "prompt": {
-                "type": "string"
-              },
-              "description": {
-                "type": "string"
-              },
-              "agent": {
-                "type": "string"
-              },
-              "model": {
-                "type": "object",
-                "properties": {
-                  "providerID": {
-                    "type": "string"
-                  },
-                  "modelID": {
-                    "type": "string"
-                  }
-                },
-                "required": ["providerID", "modelID"]
-              },
-              "command": {
-                "type": "string"
-              }
-            },
-            "required": ["id", "sessionID", "messageID", "type", "prompt", "description", "agent"]
+            "$ref": "#/components/schemas/SubtaskPart"
           },
           {
             "$ref": "#/components/schemas/ReasoningPart"
@@ -8352,46 +8408,6 @@
         },
         "required": ["directory", "payload"]
       },
-      "BadRequestError": {
-        "type": "object",
-        "properties": {
-          "data": {},
-          "errors": {
-            "type": "array",
-            "items": {
-              "type": "object",
-              "propertyNames": {
-                "type": "string"
-              },
-              "additionalProperties": {}
-            }
-          },
-          "success": {
-            "type": "boolean",
-            "const": false
-          }
-        },
-        "required": ["data", "errors", "success"]
-      },
-      "NotFoundError": {
-        "type": "object",
-        "properties": {
-          "name": {
-            "type": "string",
-            "const": "NotFoundError"
-          },
-          "data": {
-            "type": "object",
-            "properties": {
-              "message": {
-                "type": "string"
-              }
-            },
-            "required": ["message"]
-          }
-        },
-        "required": ["name", "data"]
-      },
       "KeybindsConfig": {
         "description": "Custom keybind configurations",
         "type": "object",
@@ -9898,6 +9914,113 @@
         },
         "additionalProperties": false
       },
+      "BadRequestError": {
+        "type": "object",
+        "properties": {
+          "data": {},
+          "errors": {
+            "type": "array",
+            "items": {
+              "type": "object",
+              "propertyNames": {
+                "type": "string"
+              },
+              "additionalProperties": {}
+            }
+          },
+          "success": {
+            "type": "boolean",
+            "const": false
+          }
+        },
+        "required": ["data", "errors", "success"]
+      },
+      "OAuth": {
+        "type": "object",
+        "properties": {
+          "type": {
+            "type": "string",
+            "const": "oauth"
+          },
+          "refresh": {
+            "type": "string"
+          },
+          "access": {
+            "type": "string"
+          },
+          "expires": {
+            "type": "number"
+          },
+          "accountId": {
+            "type": "string"
+          },
+          "enterpriseUrl": {
+            "type": "string"
+          }
+        },
+        "required": ["type", "refresh", "access", "expires"]
+      },
+      "ApiAuth": {
+        "type": "object",
+        "properties": {
+          "type": {
+            "type": "string",
+            "const": "api"
+          },
+          "key": {
+            "type": "string"
+          }
+        },
+        "required": ["type", "key"]
+      },
+      "WellKnownAuth": {
+        "type": "object",
+        "properties": {
+          "type": {
+            "type": "string",
+            "const": "wellknown"
+          },
+          "key": {
+            "type": "string"
+          },
+          "token": {
+            "type": "string"
+          }
+        },
+        "required": ["type", "key", "token"]
+      },
+      "Auth": {
+        "anyOf": [
+          {
+            "$ref": "#/components/schemas/OAuth"
+          },
+          {
+            "$ref": "#/components/schemas/ApiAuth"
+          },
+          {
+            "$ref": "#/components/schemas/WellKnownAuth"
+          }
+        ]
+      },
+      "NotFoundError": {
+        "type": "object",
+        "properties": {
+          "name": {
+            "type": "string",
+            "const": "NotFoundError"
+          },
+          "data": {
+            "type": "object",
+            "properties": {
+              "message": {
+                "type": "string"
+              }
+            },
+            "required": ["message"]
+          }
+        },
+        "required": ["name", "data"]
+      },
       "Model": {
         "type": "object",
         "properties": {
@@ -10824,73 +10947,6 @@
           }
         },
         "required": ["name", "extensions", "enabled"]
-      },
-      "OAuth": {
-        "type": "object",
-        "properties": {
-          "type": {
-            "type": "string",
-            "const": "oauth"
-          },
-          "refresh": {
-            "type": "string"
-          },
-          "access": {
-            "type": "string"
-          },
-          "expires": {
-            "type": "number"
-          },
-          "accountId": {
-            "type": "string"
-          },
-          "enterpriseUrl": {
-            "type": "string"
-          }
-        },
-        "required": ["type", "refresh", "access", "expires"]
-      },
-      "ApiAuth": {
-        "type": "object",
-        "properties": {
-          "type": {
-            "type": "string",
-            "const": "api"
-          },
-          "key": {
-            "type": "string"
-          }
-        },
-        "required": ["type", "key"]
-      },
-      "WellKnownAuth": {
-        "type": "object",
-        "properties": {
-          "type": {
-            "type": "string",
-            "const": "wellknown"
-          },
-          "key": {
-            "type": "string"
-          },
-          "token": {
-            "type": "string"
-          }
-        },
-        "required": ["type", "key", "token"]
-      },
-      "Auth": {
-        "anyOf": [
-          {
-            "$ref": "#/components/schemas/OAuth"
-          },
-          {
-            "$ref": "#/components/schemas/ApiAuth"
-          },
-          {
-            "$ref": "#/components/schemas/WellKnownAuth"
-          }
-        ]
       }
     }
   }

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/slack",
-  "version": "1.1.36",
+  "version": "1.1.40",
   "type": "module",
   "license": "MIT",
   "scripts": {

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/ui",
-  "version": "1.1.36",
+  "version": "1.1.40",
   "type": "module",
   "license": "MIT",
   "exports": {

+ 3 - 1
packages/ui/src/components/dialog.tsx

@@ -60,7 +60,9 @@ export function Dialog(props: DialogProps) {
             </div>
           </Show>
           <Show when={props.description}>
-            <Kobalte.Description data-slot="dialog-description">{props.description}</Kobalte.Description>
+            <Kobalte.Description data-slot="dialog-description" style={{ "margin-left": "-4px" }}>
+              {props.description}
+            </Kobalte.Description>
           </Show>
           <div data-slot="dialog-body">{props.children}</div>
         </Kobalte.Content>

+ 1 - 1
packages/ui/src/components/list.css

@@ -187,7 +187,7 @@
       [data-slot="list-header"] {
         display: flex;
         z-index: 10;
-        padding: 8px 12px 8px 12px;
+        padding: 8px 12px 8px 8px;
         justify-content: space-between;
         align-items: center;
         align-self: stretch;

+ 3 - 1
packages/ui/src/components/session-turn.tsx

@@ -390,12 +390,14 @@ export function SessionTurn(
     const interval = Interval.fromDateTimes(from, to)
     const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
 
-    return interval.toDuration(unit).normalize().reconfigure({ locale: i18n.locale() }).toHuman({
+    const locale = i18n.locale()
+    const human = interval.toDuration(unit).normalize().reconfigure({ locale }).toHuman({
       notation: "compact",
       unitDisplay: "narrow",
       compactDisplay: "short",
       showZeros: false,
     })
+    return locale.startsWith("zh") ? human.replaceAll("、", "") : human
   }
 
   const autoScroll = createAutoScroll({

+ 1 - 0
packages/ui/src/context/marked.tsx

@@ -475,6 +475,7 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
       },
       markedKatex({
         throwOnError: false,
+        nonStandard: true,
       }),
       markedShiki({
         async highlight(code, lang) {

+ 102 - 0
packages/ui/src/i18n/th.ts

@@ -0,0 +1,102 @@
+export const dict = {
+  "ui.sessionReview.title": "การเปลี่ยนแปลงเซสชัน",
+  "ui.sessionReview.diffStyle.unified": "แบบรวม",
+  "ui.sessionReview.diffStyle.split": "แบบแยก",
+  "ui.sessionReview.expandAll": "ขยายทั้งหมด",
+  "ui.sessionReview.collapseAll": "ย่อทั้งหมด",
+  "ui.sessionReview.change.added": "เพิ่ม",
+  "ui.sessionReview.change.removed": "ลบ",
+
+  "ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ",
+  "ui.lineComment.label.suffix": "",
+  "ui.lineComment.editorLabel.prefix": "กำลังแสดงความคิดเห็นบน ",
+  "ui.lineComment.editorLabel.suffix": "",
+  "ui.lineComment.placeholder": "เพิ่มความคิดเห็น",
+  "ui.lineComment.submit": "แสดงความคิดเห็น",
+
+  "ui.sessionTurn.steps.show": "แสดงขั้นตอน",
+  "ui.sessionTurn.steps.hide": "ซ่อนขั้นตอน",
+  "ui.sessionTurn.summary.response": "การตอบสนอง",
+  "ui.sessionTurn.diff.showMore": "แสดงการเปลี่ยนแปลงเพิ่มเติม ({{count}})",
+
+  "ui.sessionTurn.retry.retrying": "กำลังลองใหม่",
+  "ui.sessionTurn.retry.inSeconds": "ใน {{seconds}}วิ",
+
+  "ui.sessionTurn.status.delegating": "มอบหมายงาน",
+  "ui.sessionTurn.status.planning": "วางแผนขั้นตอนถัดไป",
+  "ui.sessionTurn.status.gatheringContext": "รวบรวมบริบท",
+  "ui.sessionTurn.status.searchingCodebase": "กำลังค้นหาโค้ดเบส",
+  "ui.sessionTurn.status.searchingWeb": "กำลังค้นหาบนเว็บ",
+  "ui.sessionTurn.status.makingEdits": "กำลังแก้ไข",
+  "ui.sessionTurn.status.runningCommands": "กำลังเรียกใช้คำสั่ง",
+  "ui.sessionTurn.status.thinking": "กำลังคิด",
+  "ui.sessionTurn.status.thinkingWithTopic": "กำลังคิด - {{topic}}",
+  "ui.sessionTurn.status.gatheringThoughts": "รวบรวมความคิด",
+  "ui.sessionTurn.status.consideringNextSteps": "พิจารณาขั้นตอนถัดไป",
+
+  "ui.messagePart.diagnostic.error": "ข้อผิดพลาด",
+  "ui.messagePart.title.edit": "แก้ไข",
+  "ui.messagePart.title.write": "เขียน",
+  "ui.messagePart.option.typeOwnAnswer": "พิมพ์คำตอบของคุณเอง",
+  "ui.messagePart.review.title": "ตรวจสอบคำตอบของคุณ",
+
+  "ui.list.loading": "กำลังโหลด",
+  "ui.list.empty": "ไม่มีผลลัพธ์",
+  "ui.list.clearFilter": "ล้างตัวกรอง",
+  "ui.list.emptyWithFilter.prefix": "ไม่มีผลลัพธ์สำหรับ",
+  "ui.list.emptyWithFilter.suffix": "",
+
+  "ui.messageNav.newMessage": "ข้อความใหม่",
+
+  "ui.textField.copyToClipboard": "คัดลอกไปยังคลิปบอร์ด",
+  "ui.textField.copyLink": "คัดลอกลิงก์",
+  "ui.textField.copied": "คัดลอกแล้ว",
+
+  "ui.imagePreview.alt": "ตัวอย่างรูปภาพ",
+
+  "ui.tool.read": "อ่าน",
+  "ui.tool.list": "รายการ",
+  "ui.tool.glob": "Glob",
+  "ui.tool.grep": "Grep",
+  "ui.tool.webfetch": "ดึงจากเว็บ",
+  "ui.tool.shell": "เชลล์",
+  "ui.tool.patch": "แพตช์",
+  "ui.tool.todos": "รายการงาน",
+  "ui.tool.todos.read": "อ่านรายการงาน",
+  "ui.tool.questions": "คำถาม",
+  "ui.tool.agent": "เอเจนต์ {{type}}",
+
+  "ui.common.file.one": "ไฟล์",
+  "ui.common.file.other": "ไฟล์",
+  "ui.common.question.one": "คำถาม",
+  "ui.common.question.other": "คำถาม",
+
+  "ui.common.add": "เพิ่ม",
+  "ui.common.cancel": "ยกเลิก",
+  "ui.common.confirm": "ยืนยัน",
+  "ui.common.dismiss": "ปิด",
+  "ui.common.close": "ปิด",
+  "ui.common.next": "ถัดไป",
+  "ui.common.submit": "ส่ง",
+
+  "ui.permission.deny": "ปฏิเสธ",
+  "ui.permission.allowAlways": "อนุญาตเสมอ",
+  "ui.permission.allowOnce": "อนุญาตครั้งเดียว",
+
+  "ui.message.expand": "ขยายข้อความ",
+  "ui.message.collapse": "ย่อข้อความ",
+  "ui.message.copy": "คัดลอก",
+  "ui.message.copied": "คัดลอกแล้ว!",
+  "ui.message.attachment.alt": "ไฟล์แนบ",
+
+  "ui.patch.action.deleted": "ลบ",
+  "ui.patch.action.created": "สร้าง",
+  "ui.patch.action.moved": "ย้าย",
+  "ui.patch.action.patched": "แพตช์",
+
+  "ui.question.subtitle.answered": "{{count}} ตอบแล้ว",
+  "ui.question.answer.none": "(ไม่มีคำตอบ)",
+  "ui.question.review.notAnswered": "(ไม่ได้ตอบ)",
+  "ui.question.multiHint": "(เลือกทั้งหมดที่ใช้)",
+  "ui.question.custom.placeholder": "พิมพ์คำตอบของคุณ...",
+}

+ 2 - 2
packages/ui/src/i18n/zh.ts

@@ -20,7 +20,7 @@ export const dict = {
   "ui.sessionTurn.steps.show": "显示步骤",
   "ui.sessionTurn.steps.hide": "隐藏步骤",
   "ui.sessionTurn.summary.response": "回复",
-  "ui.sessionTurn.diff.showMore": "显示更多更改 ({{count}})",
+  "ui.sessionTurn.diff.showMore": "显示更多更改({{count}})",
 
   "ui.sessionTurn.retry.retrying": "重试中",
   "ui.sessionTurn.retry.inSeconds": "{{seconds}} 秒后",
@@ -33,7 +33,7 @@ export const dict = {
   "ui.sessionTurn.status.makingEdits": "正在修改",
   "ui.sessionTurn.status.runningCommands": "正在运行命令",
   "ui.sessionTurn.status.thinking": "思考中",
-  "ui.sessionTurn.status.thinkingWithTopic": "思考 - {{topic}}",
+  "ui.sessionTurn.status.thinkingWithTopic": "思考{{topic}}",
   "ui.sessionTurn.status.gatheringThoughts": "正在整理思路",
   "ui.sessionTurn.status.consideringNextSteps": "正在考虑下一步",
 

+ 1 - 1
packages/util/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/util",
-  "version": "1.1.36",
+  "version": "1.1.40",
   "private": true,
   "type": "module",
   "license": "MIT",

+ 1 - 1
packages/web/package.json

@@ -2,7 +2,7 @@
   "name": "@opencode-ai/web",
   "type": "module",
   "license": "MIT",
-  "version": "1.1.36",
+  "version": "1.1.40",
   "scripts": {
     "dev": "astro dev",
     "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

+ 32 - 31
packages/web/src/content/docs/ecosystem.mdx

@@ -15,37 +15,38 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
 
 ## Plugins
 
-| Name                                                                                               | Description                                                                                    |
-| -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
-| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session)                  | Automatically inject Helicone session headers for request grouping                             |
-| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject)                            | Auto-inject TypeScript/Svelte types into file reads with lookup tools                          |
-| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth)             | Use your ChatGPT Plus/Pro subscription instead of API credits                                  |
-| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth)                            | Use your existing Gemini plan instead of API billing                                           |
-| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth)                | Use Antigravity's free models instead of API billing                                           |
-| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers)                         | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports                |
-| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth)   | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling  |
-| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning)  | Optimize token usage by pruning obsolete tool outputs                                          |
-| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git)                 | Add native websearch support for supported providers with Google grounded style                |
-| [opencode-pty](https://github.com/shekohex/opencode-pty.git)                                       | Enables AI agents to run background processes in a PTY, send interactive input to them.        |
-| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy)                     | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
-| [opencode-wakatime](https://github.com/angristan/opencode-wakatime)                                | Track OpenCode usage with Wakatime                                                             |
-| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main)    | Clean up markdown tables produced by LLMs                                                      |
-| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply)                 | 10x faster code editing with Morph Fast Apply API and lazy edit markers                        |
-| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode)                                   | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible         |
-| [opencode-notificator](https://github.com/panta82/opencode-notificator)                            | Desktop notifications and sound alerts for OpenCode sessions                                   |
-| [opencode-notifier](https://github.com/mohak34/opencode-notifier)                                  | Desktop notifications and sound alerts for permission, completion, and error events            |
-| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer)                            | AI-powered automatic Zellij session naming based on OpenCode context                           |
-| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful)                                | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection        |
-| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory)                      | Persistent memory across sessions using Supermemory                                            |
-| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing                     |
-| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2)                              | Extend opencode /commands into a powerful orchestration system with granular flow control      |
-| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler)                           | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax                |
-| [micode](https://github.com/vtemian/micode)                                                        | Structured Brainstorm → Plan → Implement workflow with session continuity                      |
-| [octto](https://github.com/vtemian/octto)                                                          | Interactive browser UI for AI brainstorming with multi-question forms                          |
-| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents)              | Claude Code-style background agents with async delegation and context persistence              |
-| [opencode-notify](https://github.com/kdcokenny/opencode-notify)                                    | Native OS notifications for OpenCode – know when tasks complete                                |
-| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace)                              | Bundled multi-agent orchestration harness – 16 components, one install                         |
-| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree)                                | Zero-friction git worktrees for OpenCode                                                       |
+| Name                                                                                               | Description                                                                                       |
+| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
+| [opencode-daytona](https://github.com/jamesmurdza/daytona/tree/main/libs/opencode-plugin)          | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews |
+| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session)                  | Automatically inject Helicone session headers for request grouping                                |
+| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject)                            | Auto-inject TypeScript/Svelte types into file reads with lookup tools                             |
+| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth)             | Use your ChatGPT Plus/Pro subscription instead of API credits                                     |
+| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth)                            | Use your existing Gemini plan instead of API billing                                              |
+| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth)                | Use Antigravity's free models instead of API billing                                              |
+| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers)                         | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports                   |
+| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth)   | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling     |
+| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning)  | Optimize token usage by pruning obsolete tool outputs                                             |
+| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git)                 | Add native websearch support for supported providers with Google grounded style                   |
+| [opencode-pty](https://github.com/shekohex/opencode-pty.git)                                       | Enables AI agents to run background processes in a PTY, send interactive input to them.           |
+| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy)                     | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations    |
+| [opencode-wakatime](https://github.com/angristan/opencode-wakatime)                                | Track OpenCode usage with Wakatime                                                                |
+| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main)    | Clean up markdown tables produced by LLMs                                                         |
+| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply)                 | 10x faster code editing with Morph Fast Apply API and lazy edit markers                           |
+| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode)                                   | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible            |
+| [opencode-notificator](https://github.com/panta82/opencode-notificator)                            | Desktop notifications and sound alerts for OpenCode sessions                                      |
+| [opencode-notifier](https://github.com/mohak34/opencode-notifier)                                  | Desktop notifications and sound alerts for permission, completion, and error events               |
+| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer)                            | AI-powered automatic Zellij session naming based on OpenCode context                              |
+| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful)                                | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection           |
+| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory)                      | Persistent memory across sessions using Supermemory                                               |
+| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing                        |
+| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2)                              | Extend opencode /commands into a powerful orchestration system with granular flow control         |
+| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler)                           | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax                   |
+| [micode](https://github.com/vtemian/micode)                                                        | Structured Brainstorm → Plan → Implement workflow with session continuity                         |
+| [octto](https://github.com/vtemian/octto)                                                          | Interactive browser UI for AI brainstorming with multi-question forms                             |
+| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents)              | Claude Code-style background agents with async delegation and context persistence                 |
+| [opencode-notify](https://github.com/kdcokenny/opencode-notify)                                    | Native OS notifications for OpenCode – know when tasks complete                                   |
+| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace)                              | Bundled multi-agent orchestration harness – 16 components, one install                            |
+| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree)                                | Zero-friction git worktrees for OpenCode                                                          |
 
 ---
 

+ 1 - 1
packages/web/src/content/docs/zen.mdx

@@ -116,7 +116,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
 | MiniMax M2.1                      | $0.30  | $1.20  | $0.10       | -            |
 | GLM 4.7                           | $0.60  | $2.20  | $0.10       | -            |
 | GLM 4.6                           | $0.60  | $2.20  | $0.10       | -            |
-| Kimi K2.5                         | $1.20  | $1.20  | $0.60       | -            |
+| Kimi K2.5                         | $0.60  | $3.00  | $0.10       | -            |
 | Kimi K2 Thinking                  | $0.40  | $2.50  | -           | -            |
 | Kimi K2                           | $0.40  | $2.50  | -           | -            |
 | Qwen3 Coder 480B                  | $0.45  | $1.50  | -           | -            |

+ 4 - 4
script/publish-complete.ts

@@ -1,11 +1,11 @@
 #!/usr/bin/env bun
 
-// import { Script } from "@opencode-ai/script"
+import { Script } from "@opencode-ai/script"
 import { $ } from "bun"
 
-// if (!Script.preview) {
-// await $`gh release edit v${Script.version} --draft=false`
-// }
+if (!Script.preview) {
+  await $`gh release edit v${Script.version} --draft=false`
+}
 
 await $`bun install`
 

+ 2 - 2
script/publish-start.ts

@@ -6,7 +6,7 @@ import { buildNotes, getLatestRelease } from "./changelog"
 
 const highlightsTemplate = `## Highlights
 
-<!-- 
+<!--
 Add highlights before publishing. Delete this section if no highlights.
 
 - For multiple highlights, use multiple <highlight> tags
@@ -40,7 +40,7 @@ console.log("=== publishing ===\n")
 if (!Script.preview) {
   const previous = await getLatestRelease()
   notes = await buildNotes(previous, "HEAD")
-  notes.unshift(highlightsTemplate)
+  // notes.unshift(highlightsTemplate)
 }
 
 const pkgjsons = await Array.fromAsync(

+ 1 - 1
sdks/vscode/package.json

@@ -2,7 +2,7 @@
   "name": "opencode",
   "displayName": "opencode",
   "description": "opencode for VS Code",
-  "version": "1.1.36",
+  "version": "1.1.40",
   "publisher": "sst-dev",
   "repository": {
     "type": "git",