Browse Source

Merge dev

Aiden Cline 3 tháng trước cách đây
mục cha
commit
f20709f392
100 tập tin đã thay đổi với 2458 bổ sung828 xóa
  1. 70 0
      .github/workflows/docs-update.yml
  2. 10 2
      .github/workflows/publish.yml
  3. 3 0
      .github/workflows/stats.yml
  4. 6 0
      .opencode/skill/test-skill/SKILL.md
  5. 1 28
      AGENTS.md
  6. 2 0
      STATS.md
  7. 216 203
      bun.lock
  8. 3 3
      flake.lock
  9. 4 1
      github/index.ts
  10. 1 1
      nix/hashes.json
  11. 9 1
      package.json
  12. 1 1
      packages/console/app/package.json
  13. 4 4
      packages/console/app/src/config.ts
  14. 1 1
      packages/console/core/package.json
  15. 1 1
      packages/console/function/package.json
  16. 1 1
      packages/console/mail/package.json
  17. 1 1
      packages/desktop/package.json
  18. 11 7
      packages/desktop/src/app.tsx
  19. 19 17
      packages/desktop/src/components/dialog-connect-provider.tsx
  20. 14 7
      packages/desktop/src/components/dialog-manage-models.tsx
  21. 5 6
      packages/desktop/src/components/dialog-select-file.tsx
  22. 11 20
      packages/desktop/src/components/dialog-select-model-unpaid.tsx
  23. 1 2
      packages/desktop/src/components/dialog-select-model.tsx
  24. 2 12
      packages/desktop/src/components/dialog-select-provider.tsx
  25. 79 27
      packages/desktop/src/components/header.tsx
  26. 46 22
      packages/desktop/src/components/prompt-input.tsx
  27. 64 0
      packages/desktop/src/components/session-context-usage.tsx
  28. 5 1
      packages/desktop/src/context/command.tsx
  29. 19 2
      packages/desktop/src/context/layout.tsx
  30. 2 2
      packages/desktop/src/context/local.tsx
  31. 25 5
      packages/desktop/src/pages/error.tsx
  32. 208 112
      packages/desktop/src/pages/layout.tsx
  33. 196 80
      packages/desktop/src/pages/session.tsx
  34. 1 0
      packages/desktop/vite.config.ts
  35. 1 1
      packages/enterprise/package.json
  36. 23 11
      packages/enterprise/src/routes/share/[shareID].tsx
  37. 6 6
      packages/extensions/zed/extension.toml
  38. 1 1
      packages/function/package.json
  39. 5 3
      packages/opencode/package.json
  40. 1 1
      packages/opencode/script/publish.ts
  41. 32 0
      packages/opencode/src/agent/agent.ts
  42. 10 4
      packages/opencode/src/agent/prompt/summary.txt
  43. 2 0
      packages/opencode/src/cli/cmd/debug/index.ts
  44. 15 0
      packages/opencode/src/cli/cmd/debug/skill.ts
  45. 141 51
      packages/opencode/src/cli/cmd/github.ts
  46. 16 7
      packages/opencode/src/cli/cmd/tui/app.tsx
  47. 22 0
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  48. 1 2
      packages/opencode/src/cli/cmd/tui/context/sdk.tsx
  49. 1 1
      packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx
  50. 1 1
      packages/opencode/src/cli/cmd/tui/routes/session/dialog-subagent.tsx
  51. 3 0
      packages/opencode/src/cli/cmd/tui/routes/session/header.tsx
  52. 82 21
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  53. 31 7
      packages/opencode/src/command/template/review.txt
  54. 5 0
      packages/opencode/src/config/config.ts
  55. 1 0
      packages/opencode/src/flag/flag.ts
  56. 7 8
      packages/opencode/src/installation/index.ts
  57. 114 21
      packages/opencode/src/lsp/index.ts
  58. 4 0
      packages/opencode/src/lsp/language.ts
  59. 153 7
      packages/opencode/src/lsp/server.ts
  60. 74 43
      packages/opencode/src/mcp/index.ts
  61. 20 0
      packages/opencode/src/provider/provider.ts
  62. 10 6
      packages/opencode/src/provider/transform.ts
  63. 12 5
      packages/opencode/src/server/server.ts
  64. 4 0
      packages/opencode/src/session/compaction.ts
  65. 14 0
      packages/opencode/src/session/processor.ts
  66. 1 1
      packages/opencode/src/session/prompt.ts
  67. 1 1
      packages/opencode/src/session/system.ts
  68. 1 0
      packages/opencode/src/skill/index.ts
  69. 71 0
      packages/opencode/src/skill/skill.ts
  70. 87 0
      packages/opencode/src/tool/lsp.ts
  71. 19 0
      packages/opencode/src/tool/lsp.txt
  72. 5 3
      packages/opencode/src/tool/read.ts
  73. 8 0
      packages/opencode/src/tool/registry.ts
  74. 84 0
      packages/opencode/src/tool/skill.ts
  75. 131 0
      packages/opencode/test/skill/skill.test.ts
  76. 42 0
      packages/opencode/test/tool/read.test.ts
  77. 1 1
      packages/plugin/package.json
  78. 1 1
      packages/sdk/js/package.json
  79. 8 0
      packages/sdk/js/src/gen/types.gen.ts
  80. 2 0
      packages/sdk/js/src/v2/gen/sdk.gen.ts
  81. 37 0
      packages/sdk/js/src/v2/gen/types.gen.ts
  82. 66 1
      packages/sdk/openapi.json
  83. 1 1
      packages/slack/package.json
  84. 1 1
      packages/tauri/index.html
  85. 1 1
      packages/tauri/package.json
  86. 4 10
      packages/tauri/scripts/prepare.ts
  87. 5 0
      packages/tauri/scripts/utils.ts
  88. 8 1
      packages/tauri/src-tauri/src/lib.rs
  89. 1 1
      packages/tauri/src-tauri/tauri.conf.json
  90. 1 1
      packages/ui/package.json
  91. 0 1
      packages/ui/src/assets/icons/provider/aihubmix.svg
  92. 2 2
      packages/ui/src/assets/icons/provider/alibaba-cn.svg
  93. 2 2
      packages/ui/src/assets/icons/provider/alibaba.svg
  94. 1 3
      packages/ui/src/assets/icons/provider/amazon-bedrock.svg
  95. 3 7
      packages/ui/src/assets/icons/provider/anthropic.svg
  96. 2 3
      packages/ui/src/assets/icons/provider/azure.svg
  97. 0 1
      packages/ui/src/assets/icons/provider/bailing.svg
  98. 3 1
      packages/ui/src/assets/icons/provider/baseten.svg
  99. 1 2
      packages/ui/src/assets/icons/provider/cerebras.svg
  100. 3 5
      packages/ui/src/assets/icons/provider/cloudflare-ai-gateway.svg

+ 70 - 0
.github/workflows/docs-update.yml

@@ -0,0 +1,70 @@
+name: Docs Update
+
+on:
+  schedule:
+    # Run every 4 hours
+    - cron: "0 */4 * * *"
+  workflow_dispatch: # Allow manual trigger for testing
+
+jobs:
+  update-docs:
+    if: github.repository == 'sst/opencode'
+    runs-on: blacksmith-4vcpu-ubuntu-2404
+    permissions:
+      id-token: write
+      contents: write
+      pull-requests: write
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 0 # Fetch full history to access commits
+
+      - name: Setup Bun
+        uses: ./.github/actions/setup-bun
+
+      - name: Get recent commits
+        id: commits
+        run: |
+          COMMITS=$(git log --since="4 hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
+          if [ -z "$COMMITS" ]; then
+            echo "No commits in the last 4 hours"
+            echo "has_commits=false" >> $GITHUB_OUTPUT
+          else
+            echo "has_commits=true" >> $GITHUB_OUTPUT
+            {
+              echo "list<<EOF"
+              echo "$COMMITS"
+              echo "EOF"
+            } >> $GITHUB_OUTPUT
+          fi
+
+      - name: Run opencode
+        if: steps.commits.outputs.has_commits == 'true'
+        uses: sst/opencode/github@latest
+        env:
+          OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
+        with:
+          model: opencode/gpt-5.2
+          agent: docs
+          prompt: |
+            Review the following commits from the last 4 hours and identify any new features that may need documentation.
+
+            <recent_commits>
+            ${{ steps.commits.outputs.list }}
+            </recent_commits>
+
+            Steps:
+            1. For each commit that looks like a new feature or significant change:
+               - Read the changed files to understand what was added
+               - Check if the feature is already documented in packages/web/src/content/docs/*
+            2. If you find undocumented features:
+               - Update the relevant documentation files in packages/web/src/content/docs/*
+               - Follow the existing documentation style and structure
+               - Make sure to document the feature clearly with examples where appropriate
+            3. If all new features are already documented, report that no updates are needed
+            4. If you are creating a new documentation file be sure to update packages/web/astro.config.mjs too.
+
+            Focus on user-facing features and API changes. Skip internal refactors, bug fixes, and test updates unless they affect user-facing behavior.
+            Don't feel the need to document every little thing. It is perfectly okay to make 0 changes at all.
+            Try to keep documentation only for large features or changes that already have a good spot to be documented.

+ 10 - 2
.github/workflows/publish.yml

@@ -79,6 +79,12 @@ jobs:
           AUR_KEY: ${{ secrets.AUR_KEY }}
           GITHUB_TOKEN: ${{ secrets.SST_GITHUB_TOKEN }}
           NPM_CONFIG_PROVENANCE: false
+
+      - uses: actions/upload-artifact@v4
+        with:
+          name: opencode-cli
+          path: packages/opencode/dist
+
     outputs:
       release: ${{ steps.publish.outputs.release }}
       tag: ${{ steps.publish.outputs.tag }}
@@ -99,6 +105,8 @@ jobs:
             target: x86_64-pc-windows-msvc
           - host: blacksmith-4vcpu-ubuntu-2404
             target: x86_64-unknown-linux-gnu
+          - host: blacksmith-4vcpu-ubuntu-2404-arm
+            target: aarch64-unknown-linux-gnu
     runs-on: ${{ matrix.settings.host }}
     steps:
       - uses: actions/checkout@v3
@@ -147,7 +155,6 @@ jobs:
           shared-key: ${{ matrix.settings.target }}
 
       - name: Prepare
-        if: inputs.bump || inputs.version
         run: |
           cd packages/tauri
           bun ./scripts/prepare.ts
@@ -159,6 +166,7 @@ jobs:
           OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
           RUST_TARGET: ${{ matrix.settings.target }}
           GH_TOKEN: ${{ github.token }}
+          GITHUB_RUN_ID: ${{ github.run_id }}
 
       # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
       - name: Install tauri-cli from portable appimage branch
@@ -186,7 +194,7 @@ jobs:
           projectPath: packages/tauri
           uploadWorkflowArtifacts: true
           tauriScript: ${{ (contains(matrix.settings.host, 'ubuntu') && 'cargo tauri') || '' }}
-          args: --target ${{ matrix.settings.target }} --config src-tauri/tauri.prod.conf.json
+          args: --target ${{ matrix.settings.target }} --config ./src-tauri/tauri.prod.conf.json --verbose
           updaterJsonPreferNsis: true
           releaseId: ${{ needs.publish.outputs.release }}
           tagName: ${{ needs.publish.outputs.tag }}

+ 3 - 0
.github/workflows/stats.yml

@@ -5,8 +5,11 @@ on:
     - cron: "0 12 * * *" # Run daily at 12:00 UTC
   workflow_dispatch: # Allow manual trigger
 
+concurrency: ${{ github.workflow }}-${{ github.ref }}
+
 jobs:
   stats:
+    if: github.repository == 'sst/opencode'
     runs-on: blacksmith-4vcpu-ubuntu-2404
     permissions:
       contents: write

+ 6 - 0
.opencode/skill/test-skill/SKILL.md

@@ -0,0 +1,6 @@
+---
+name: test-skill
+description: use this when asked to test skill
+---
+
+woah this is a test skill

+ 1 - 28
AGENTS.md

@@ -4,31 +4,4 @@
 
 ## Tool Calling
 
-- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. Here is an example illustrating how to execute 3 parallel file reads in this chat environment:
-
-json
-{
-"recipient_name": "multi_tool_use.parallel",
-"parameters": {
-"tool_uses": [
-{
-"recipient_name": "functions.read",
-"parameters": {
-"filePath": "path/to/file.tsx"
-}
-},
-{
-"recipient_name": "functions.read",
-"parameters": {
-"filePath": "path/to/file.ts"
-}
-},
-{
-"recipient_name": "functions.read",
-"parameters": {
-"filePath": "path/to/file.md"
-}
-}
-]
-}
-}
+- ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.

+ 2 - 0
STATS.md

@@ -176,3 +176,5 @@
 | 2025-12-18 | 1,178,658 (+27,591) | 1,113,418 (+15,757) | 2,292,076 (+43,348) |
 | 2025-12-19 | 1,203,485 (+24,827) | 1,129,698 (+16,280) | 2,333,183 (+41,107) |
 | 2025-12-20 | 1,223,000 (+19,515) | 1,146,258 (+16,560) | 2,369,258 (+36,075) |
+| 2025-12-21 | 1,242,675 (+19,675) | 1,158,909 (+12,651) | 2,401,584 (+32,326) |
+| 2025-12-22 | 1,262,522 (+19,847) | 1,169,121 (+10,212) | 2,431,643 (+30,059) |

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 216 - 203
bun.lock


+ 3 - 3
flake.lock

@@ -2,11 +2,11 @@
   "nodes": {
     "nixpkgs": {
       "locked": {
-        "lastModified": 1766125104,
-        "narHash": "sha256-l/YGrEpLromL4viUo5GmFH3K5M1j0Mb9O+LiaeCPWEM=",
+        "lastModified": 1766314097,
+        "narHash": "sha256-laJftWbghBehazn/zxVJ8NdENVgjccsWAdAqKXhErrM=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "7d853e518814cca2a657b72eeba67ae20ebf7059",
+        "rev": "306ea70f9eb0fb4e040f8540e2deab32ed7e2055",
         "type": "github"
       },
       "original": {

+ 4 - 1
github/index.ts

@@ -574,10 +574,13 @@ async function subscribeSessionEvents() {
 }
 
 async function summarize(response: string) {
-  const payload = useContext().payload as IssueCommentEvent
   try {
     return await chat(`Summarize the following in less than 40 characters:\n\n${response}`)
   } catch (e) {
+    if (isScheduleEvent()) {
+      return "Scheduled task changes"
+    }
+    const payload = useContext().payload as IssueCommentEvent
     return `Fix issue: ${payload.issue.title}`
   }
 }

+ 1 - 1
nix/hashes.json

@@ -1,3 +1,3 @@
 {
-  "nodeModules": "sha256-cpXmqJQJeFj3eED/aOb4YLUdkZFV//7u4f0STBxzUhk="
+  "nodeModules": "sha256-brPDbHqdp4/U8AIXtp75uDRI6K3e2FH2b7V/QNb07us="
 }

+ 9 - 1
package.json

@@ -56,6 +56,7 @@
     }
   },
   "devDependencies": {
+    "@actions/artifact": "5.0.1",
     "@tsconfig/bun": "catalog:",
     "husky": "9.1.7",
     "prettier": "3.6.2",
@@ -63,7 +64,15 @@
     "turbo": "2.5.6"
   },
   "dependencies": {
+    "@ai-sdk/cerebras": "1.0.33",
+    "@ai-sdk/cohere": "2.0.21",
+    "@ai-sdk/deepinfra": "1.0.30",
+    "@ai-sdk/gateway": "2.0.23",
+    "@ai-sdk/groq": "2.0.33",
+    "@ai-sdk/perplexity": "2.0.22",
+    "@ai-sdk/togetherai": "1.0.30",
     "@aws-sdk/client-s3": "3.933.0",
+    "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/script": "workspace:*",
     "@opencode-ai/sdk": "workspace:*",
     "typescript": "catalog:"
@@ -80,7 +89,6 @@
   "trustedDependencies": [
     "esbuild",
     "protobufjs",
-    "sharp",
     "tree-sitter",
     "tree-sitter-bash",
     "web-tree-sitter"

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

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/console-app",
-  "version": "1.0.182",
+  "version": "1.0.190",
   "type": "module",
   "scripts": {
     "typecheck": "tsgo --noEmit",

+ 4 - 4
packages/console/app/src/config.ts

@@ -9,8 +9,8 @@ export const config = {
   github: {
     repoUrl: "https://github.com/sst/opencode",
     starsFormatted: {
-      compact: "38K",
-      full: "38,000",
+      compact: "41K",
+      full: "41,000",
     },
   },
 
@@ -22,8 +22,8 @@ export const config = {
 
   // Static stats (used on landing page)
   stats: {
-    contributors: "400",
-    commits: "5,000",
+    contributors: "450",
+    commits: "6,000",
     monthlyUsers: "400,000",
   },
 } as const

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

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

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

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/console-function",
-  "version": "1.0.182",
+  "version": "1.0.190",
   "$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.0.182",
+  "version": "1.0.190",
   "dependencies": {
     "@jsx-email/all": "2.2.3",
     "@jsx-email/cli": "1.4.3",

+ 1 - 1
packages/desktop/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/desktop",
-  "version": "1.0.182",
+  "version": "1.0.190",
   "description": "",
   "type": "module",
   "exports": {

+ 11 - 7
packages/desktop/src/app.tsx

@@ -21,6 +21,7 @@ import Home from "@/pages/home"
 import DirectoryLayout from "@/pages/directory-layout"
 import Session from "@/pages/session"
 import { ErrorPage } from "./pages/error"
+import { iife } from "@opencode-ai/util/iife"
 
 declare global {
   interface Window {
@@ -28,14 +29,17 @@ declare global {
   }
 }
 
-const host = import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "127.0.0.1"
-const port = window.__OPENCODE__?.port ?? import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"
+const url = iife(() => {
+  const param = new URLSearchParams(document.location.search).get("url")
+  if (param) return param
 
-const url =
-  new URLSearchParams(document.location.search).get("url") ||
-  (location.hostname.includes("opencode.ai") || location.hostname.includes("localhost")
-    ? `http://${host}:${port}`
-    : "/")
+  if (location.hostname.includes("opencode.ai")) return "http://localhost:4096"
+  if (window.__OPENCODE__) return `http://127.0.0.1:${window.__OPENCODE__.port}`
+  if (import.meta.env.VITE_OPENCODE_SERVER)
+    return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}`
+
+  return "/"
+})
 
 export function App() {
   return (

+ 19 - 17
packages/desktop/src/components/dialog-connect-provider.tsx

@@ -1,24 +1,24 @@
-import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
-import { createStore, produce } from "solid-js/store"
+import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
+import { Button } from "@opencode-ai/ui/button"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { useGlobalSync } from "@/context/global-sync"
-import { useGlobalSDK } from "@/context/global-sdk"
-import { usePlatform } from "@/context/platform"
-import { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client"
 import { Dialog } from "@opencode-ai/ui/dialog"
-import { List, ListRef } from "@opencode-ai/ui/list"
-import { Button } from "@opencode-ai/ui/button"
+import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import { TextField } from "@opencode-ai/ui/text-field"
+import type { IconName } from "@opencode-ai/ui/icons/provider"
+import { List, type ListRef } from "@opencode-ai/ui/list"
+import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import { Spinner } from "@opencode-ai/ui/spinner"
-import { Icon } from "@opencode-ai/ui/icon"
+import { TextField } from "@opencode-ai/ui/text-field"
 import { showToast } from "@opencode-ai/ui/toast"
-import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { IconName } from "@opencode-ai/ui/icons/provider"
 import { iife } from "@opencode-ai/util/iife"
+import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js"
+import { createStore, produce } from "solid-js/store"
 import { Link } from "@/components/link"
-import { DialogSelectProvider } from "./dialog-select-provider"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
+import { usePlatform } from "@/context/platform"
 import { DialogSelectModel } from "./dialog-select-model"
+import { DialogSelectProvider } from "./dialog-select-provider"
 
 export function DialogConnectProvider(props: { provider: string }) {
   const dialog = useDialog()
@@ -154,7 +154,9 @@ export function DialogConnectProvider(props: { provider: string }) {
               <div class="text-14-regular text-text-base">Select login method for {provider().name}.</div>
               <div class="">
                 <List
-                  ref={(ref) => (listRef = ref)}
+                  ref={(ref) => {
+                    listRef = ref
+                  }}
                   items={methods}
                   key={(m) => m?.label}
                   onSelect={async (method, index) => {
@@ -163,7 +165,7 @@ export function DialogConnectProvider(props: { provider: string }) {
                   }}
                 >
                   {(i) => (
-                    <div class="w-full flex items-center gap-x-4">
+                    <div class="w-full flex items-center gap-x-2">
                       <div class="w-4 h-2 rounded-[1px] bg-input-base shadow-xs-border-base flex items-center justify-center">
                         <div class="w-2.5 h-0.5 bg-icon-strong-base hidden" data-slot="list-item-extra-icon" />
                       </div>
@@ -175,7 +177,7 @@ export function DialogConnectProvider(props: { provider: string }) {
             </Match>
             <Match when={store.state === "pending"}>
               <div class="text-14-regular text-text-base">
-                <div class="flex items-center gap-x-4">
+                <div class="flex items-center gap-x-2">
                   <Spinner />
                   <span>Authorization in progress...</span>
                 </div>
@@ -183,7 +185,7 @@ export function DialogConnectProvider(props: { provider: string }) {
             </Match>
             <Match when={store.state === "error"}>
               <div class="text-14-regular text-text-base">
-                <div class="flex items-center gap-x-4">
+                <div class="flex items-center gap-x-2">
                   <Icon name="circle-ban-sign" class="text-icon-critical-base" />
                   <span>Authorization failed: {store.error}</span>
                 </div>

+ 14 - 7
packages/desktop/src/components/dialog-manage-models.tsx

@@ -1,16 +1,15 @@
-import { Component } from "solid-js"
-import { useLocal } from "@/context/local"
-import { popularProviders } from "@/hooks/use-providers"
 import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { Switch } from "@opencode-ai/ui/switch"
+import type { Component } from "solid-js"
+import { useLocal } from "@/context/local"
+import { popularProviders } from "@/hooks/use-providers"
 
 export const DialogManageModels: Component = () => {
   const local = useLocal()
   return (
     <Dialog title="Manage models" description="Customize which models appear in the model selector.">
       <List
-        class="px-2.5"
         search={{ placeholder: "Search models", autofocus: true }}
         emptyMessage="No model results"
         key={(x) => `${x?.provider?.id}:${x?.id}`}
@@ -27,16 +26,24 @@ export const DialogManageModels: Component = () => {
         }}
         onSelect={(x) => {
           if (!x) return
-          const visible = local.model.visible({ modelID: x.id, providerID: x.provider.id })
+          const visible = local.model.visible({
+            modelID: x.id,
+            providerID: x.provider.id,
+          })
           local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, !visible)
         }}
       >
         {(i) => (
-          <div class="w-full flex items-center justify-between gap-x-2.5">
+          <div class="w-full flex items-center justify-between gap-x-3">
             <span>{i.name}</span>
             <div onClick={(e) => e.stopPropagation()}>
               <Switch
-                checked={!!local.model.visible({ modelID: i.id, providerID: i.provider.id })}
+                checked={
+                  !!local.model.visible({
+                    modelID: i.id,
+                    providerID: i.provider.id,
+                  })
+                }
                 onChange={(checked) => {
                   local.model.setVisibility({ modelID: i.id, providerID: i.provider.id }, checked)
                 }}

+ 5 - 6
packages/desktop/src/components/dialog-select-file.tsx

@@ -1,12 +1,12 @@
-import { useLocal } from "@/context/local"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
-import { List } from "@opencode-ai/ui/list"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { List } from "@opencode-ai/ui/list"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useLayout } from "@/context/layout"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useParams } from "@solidjs/router"
 import { createMemo } from "solid-js"
+import { useLayout } from "@/context/layout"
+import { useLocal } from "@/context/local"
 
 export function DialogSelectFile() {
   const layout = useLayout()
@@ -18,7 +18,6 @@ export function DialogSelectFile() {
   return (
     <Dialog title="Select file">
       <List
-        class="px-2.5"
         search={{ placeholder: "Search files", autofocus: true }}
         emptyMessage="No files found"
         items={local.file.searchFiles}
@@ -32,7 +31,7 @@ export function DialogSelectFile() {
       >
         {(i) => (
           <div class="w-full flex items-center justify-between rounded-md">
-            <div class="flex items-center gap-x-2 grow min-w-0">
+            <div class="flex items-center gap-x-3 grow min-w-0">
               <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
               <div class="flex items-center text-14-regular">
                 <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">

+ 11 - 20
packages/desktop/src/components/dialog-select-model-unpaid.tsx

@@ -1,15 +1,15 @@
-import { Component, onCleanup, onMount, Show } from "solid-js"
-import { useLocal } from "@/context/local"
-import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { Button } from "@opencode-ai/ui/button"
-import { Tag } from "@opencode-ai/ui/tag"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { Dialog } from "@opencode-ai/ui/dialog"
-import { List, ListRef } from "@opencode-ai/ui/list"
+import type { IconName } from "@opencode-ai/ui/icons/provider"
+import { List, type ListRef } from "@opencode-ai/ui/list"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
-import { IconName } from "@opencode-ai/ui/icons/provider"
-import { DialogSelectProvider } from "./dialog-select-provider"
+import { Tag } from "@opencode-ai/ui/tag"
+import { type Component, onCleanup, onMount, Show } from "solid-js"
+import { useLocal } from "@/context/local"
+import { popularProviders, useProviders } from "@/hooks/use-providers"
 import { DialogConnectProvider } from "./dialog-connect-provider"
+import { DialogSelectProvider } from "./dialog-select-provider"
 
 export const DialogSelectModelUnpaid: Component = () => {
   const local = useLocal()
@@ -64,7 +64,7 @@ export const DialogSelectModelUnpaid: Component = () => {
             <div class="px-2 text-14-medium text-text-base">Add more models from popular providers</div>
             <div class="w-full">
               <List
-                class="w-full"
+                class="w-full px-0"
                 key={(x) => x?.id}
                 items={providers.popular}
                 activeIcon="plus-small"
@@ -79,17 +79,8 @@ export const DialogSelectModelUnpaid: Component = () => {
                 }}
               >
                 {(i) => (
-                  <div class="w-full flex items-center gap-x-4">
-                    <ProviderIcon
-                      data-slot="list-item-extra-icon"
-                      id={i.id as IconName}
-                      // TODO: clean this up after we update icon in models.dev
-                      classList={{
-                        "text-icon-weak-base": true,
-                        "size-4 mx-0.5": i.id === "opencode",
-                        "size-5": i.id !== "opencode",
-                      }}
-                    />
+                  <div class="w-full flex items-center gap-x-3">
+                    <ProviderIcon data-slot="list-item-extra-icon" id={i.id as IconName} />
                     <span>{i.name}</span>
                     <Show when={i.id === "opencode"}>
                       <Tag>Recommended</Tag>

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

@@ -35,7 +35,6 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
       }
     >
       <List
-        class="px-2.5"
         search={{ placeholder: "Search models", autofocus: true }}
         emptyMessage="No model results"
         key={(x) => `${x.provider.id}:${x.id}`}
@@ -61,7 +60,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
         }}
       >
         {(i) => (
-          <div class="w-full flex items-center gap-x-2.5">
+          <div class="w-full flex items-center gap-x-3">
             <span>{i.name}</span>
             <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
               <Tag>Free</Tag>

+ 2 - 12
packages/desktop/src/components/dialog-select-provider.tsx

@@ -15,7 +15,6 @@ export const DialogSelectProvider: Component = () => {
   return (
     <Dialog title="Connect provider">
       <List
-        class="px-2.5"
         search={{ placeholder: "Search providers", autofocus: true }}
         activeIcon="plus-small"
         key={(x) => x?.id}
@@ -38,17 +37,8 @@ export const DialogSelectProvider: Component = () => {
         }}
       >
         {(i) => (
-          <div class="px-1.25 w-full flex items-center gap-x-4">
-            <ProviderIcon
-              data-slot="list-item-extra-icon"
-              id={i.id as IconName}
-              // TODO: clean this up after we update icon in models.dev
-              classList={{
-                "text-icon-weak-base": true,
-                "size-4 mx-0.5": i.id === "opencode",
-                "size-5": i.id !== "opencode",
-              }}
-            />
+          <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} />
             <span>{i.name}</span>
             <Show when={i.id === "opencode"}>
               <Tag>Recommended</Tag>

+ 79 - 27
packages/desktop/src/components/header.tsx

@@ -10,6 +10,7 @@ import { Select } from "@opencode-ai/ui/select"
 import { TextField } from "@opencode-ai/ui/text-field"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { base64Decode } from "@opencode-ai/util/encode"
+import { useCommand } from "@/context/command"
 import { getFilename } from "@opencode-ai/util/path"
 import { A, useParams } from "@solidjs/router"
 import { createMemo, createResource, Show } from "solid-js"
@@ -19,19 +20,29 @@ import { iife } from "@opencode-ai/util/iife"
 export function Header(props: {
   navigateToProject: (directory: string) => void
   navigateToSession: (session: Session | undefined) => void
+  onMobileMenuToggle?: () => void
 }) {
   const globalSync = useGlobalSync()
   const globalSDK = useGlobalSDK()
   const layout = useLayout()
   const params = useParams()
+  const command = useCommand()
 
   return (
     <header class="h-12 shrink-0 bg-background-base border-b border-border-weak-base flex" data-tauri-drag-region>
+      <button
+        type="button"
+        class="xl:hidden w-12 shrink-0 flex items-center justify-center border-r border-border-weak-base hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active transition-colors"
+        onClick={props.onMobileMenuToggle}
+      >
+        <Icon name="menu" size="small" />
+      </button>
       <A
         href="/"
         classList={{
+          "hidden xl:flex": true,
           "w-12 shrink-0 px-4 py-3.5": true,
-          "flex items-center justify-start self-stretch": true,
+          "items-center justify-start self-stretch": true,
           "border-r border-border-weak-base": true,
         }}
         style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
@@ -44,30 +55,32 @@ export function Header(props: {
           {(directory) => {
             const currentDirectory = createMemo(() => base64Decode(directory()))
             const store = createMemo(() => globalSync.child(currentDirectory())[0])
-            const sessions = createMemo(() => store().session ?? [])
+            const sessions = createMemo(() => (store().session ?? []).filter((s) => !s.parentID))
             const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
             const shareEnabled = createMemo(() => store().config.share !== "disabled")
             return (
               <>
-                <div class="flex items-center gap-3">
-                  <div class="flex items-center gap-2">
-                    <Select
-                      options={layout.projects.list().map((project) => project.worktree)}
-                      current={currentDirectory()}
-                      label={(x) => getFilename(x)}
-                      onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
-                      class="text-14-regular text-text-base"
-                      variant="ghost"
-                    >
-                      {/* @ts-ignore */}
-                      {(i) => (
-                        <div class="flex items-center gap-2">
-                          <Icon name="folder" size="small" />
-                          <div class="text-text-strong">{getFilename(i)}</div>
-                        </div>
-                      )}
-                    </Select>
-                    <div class="text-text-weaker">/</div>
+                <div class="flex items-center gap-3 min-w-0">
+                  <div class="flex items-center gap-2 min-w-0">
+                    <div class="hidden xl:flex items-center gap-2">
+                      <Select
+                        options={layout.projects.list().map((project) => project.worktree)}
+                        current={currentDirectory()}
+                        label={(x) => getFilename(x)}
+                        onSelect={(x) => (x ? props.navigateToProject(x) : undefined)}
+                        class="text-14-regular text-text-base"
+                        variant="ghost"
+                      >
+                        {/* @ts-ignore */}
+                        {(i) => (
+                          <div class="flex items-center gap-2">
+                            <Icon name="folder" size="small" />
+                            <div class="text-text-strong">{getFilename(i)}</div>
+                          </div>
+                        )}
+                      </Select>
+                      <div class="text-text-weaker">/</div>
+                    </div>
                     <Select
                       options={sessions()}
                       current={currentSession()}
@@ -75,23 +88,62 @@ export function Header(props: {
                       label={(x) => x.title}
                       value={(x) => x.id}
                       onSelect={props.navigateToSession}
-                      class="text-14-regular text-text-base max-w-md"
+                      class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
                       variant="ghost"
                     />
                   </div>
                   <Show when={currentSession()}>
-                    <Button as={A} href={`/${params.dir}/session`} icon="plus-small">
-                      New session
-                    </Button>
+                    <Tooltip
+                      class="hidden xl:block"
+                      value={
+                        <div class="flex items-center gap-2">
+                          <span>New session</span>
+                          <span class="text-icon-base text-12-medium">{command.keybind("session.new")}</span>
+                        </div>
+                      }
+                    >
+                      <Button as={A} href={`/${params.dir}/session`} icon="plus-small">
+                        New session
+                      </Button>
+                    </Tooltip>
                   </Show>
                 </div>
                 <div class="flex items-center gap-4">
                   <Tooltip
-                    class="shrink-0"
+                    class="hidden md:block shrink-0"
+                    value={
+                      <div class="flex items-center gap-2">
+                        <span>Toggle review</span>
+                        <span class="text-icon-base text-12-medium">{command.keybind("review.toggle")}</span>
+                      </div>
+                    }
+                  >
+                    <Button variant="ghost" class="group/review-toggle size-6 p-0" onClick={layout.review.toggle}>
+                      <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                        <Icon
+                          size="small"
+                          name={layout.review.opened() ? "layout-right-full" : "layout-right"}
+                          class="group-hover/review-toggle:hidden"
+                        />
+                        <Icon
+                          size="small"
+                          name="layout-right-partial"
+                          class="hidden group-hover/review-toggle:inline-block"
+                        />
+                        <Icon
+                          size="small"
+                          name={layout.review.opened() ? "layout-right" : "layout-right-full"}
+                          class="hidden group-active/review-toggle:inline-block"
+                        />
+                      </div>
+                    </Button>
+                  </Tooltip>
+                  <Tooltip
+                    class="hidden md:block shrink-0"
                     value={
                       <div class="flex items-center gap-2">
                         <span>Toggle terminal</span>
-                        <span class="text-icon-base text-12-medium">Ctrl `</span>
+                        <span class="text-icon-base text-12-medium">{command.keybind("terminal.toggle")}</span>
                       </div>
                     }
                   >

+ 46 - 22
packages/desktop/src/components/prompt-input.tsx

@@ -19,9 +19,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectModel } from "@/components/dialog-select-model"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
-import { useCommand, formatKeybind } from "@/context/command"
+import { useCommand } from "@/context/command"
 import { persisted } from "@/utils/persist"
 import { Identifier } from "@/utils/id"
+import { SessionContextUsage } from "@/components/session-context-usage"
 
 const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
 const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -889,8 +890,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                             custom
                           </span>
                         </Show>
-                        <Show when={cmd.keybind}>
-                          <span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
+                        <Show when={command.keybind(cmd.id)}>
+                          <span class="text-12-regular text-text-subtle">{command.keybind(cmd.id)}</span>
                         </Show>
                       </div>
                     </button>
@@ -972,7 +973,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             }}
           />
           <Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
-            <div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
+            <div class="absolute top-0 inset-x-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none whitespace-nowrap truncate">
               {store.mode === "shell"
                 ? "Enter shell command..."
                 : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
@@ -990,28 +991,51 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 </div>
               </Match>
               <Match when={store.mode === "normal"}>
-                <Select
-                  options={local.agent.list().map((agent) => agent.name)}
-                  current={local.agent.current().name}
-                  onSelect={local.agent.set}
-                  class="capitalize"
-                  variant="ghost"
-                />
-                <Button
-                  as="div"
-                  variant="ghost"
-                  onClick={() =>
-                    dialog.show(() =>
-                      providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
-                    )
+                <Tooltip
+                  placement="top"
+                  value={
+                    <div class="flex items-center gap-2">
+                      <span>Cycle agent</span>
+                      <span class="text-icon-base text-12-medium">{command.keybind("agent.cycle")}</span>
+                    </div>
                   }
                 >
-                  {local.model.current()?.name ?? "Select model"}
-                  <span class="ml-0.5 text-text-weak text-12-regular">{local.model.current()?.provider.name}</span>
-                  <Icon name="chevron-down" size="small" />
-                </Button>
+                  <Select
+                    options={local.agent.list().map((agent) => agent.name)}
+                    current={local.agent.current().name}
+                    onSelect={local.agent.set}
+                    class="capitalize"
+                    variant="ghost"
+                  />
+                </Tooltip>
+                <Tooltip
+                  placement="top"
+                  value={
+                    <div class="flex items-center gap-2">
+                      <span>Choose model</span>
+                      <span class="text-icon-base text-12-medium">{command.keybind("model.choose")}</span>
+                    </div>
+                  }
+                >
+                  <Button
+                    as="div"
+                    variant="ghost"
+                    onClick={() =>
+                      dialog.show(() =>
+                        providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
+                      )
+                    }
+                  >
+                    {local.model.current()?.name ?? "Select model"}
+                    <span class="hidden md:block ml-0.5 text-text-weak text-12-regular">
+                      {local.model.current()?.provider.name}
+                    </span>
+                    <Icon name="chevron-down" size="small" />
+                  </Button>
+                </Tooltip>
               </Match>
             </Switch>
+            <SessionContextUsage />
           </div>
           <div class="flex items-center gap-1 absolute right-2 bottom-2">
             <input

+ 64 - 0
packages/desktop/src/components/session-context-usage.tsx

@@ -0,0 +1,64 @@
+import { createMemo, Show } from "solid-js"
+import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
+import { useSync } from "@/context/sync"
+import { useParams } from "@solidjs/router"
+import { AssistantMessage } from "@opencode-ai/sdk/v2"
+
+export function SessionContextUsage() {
+  const sync = useSync()
+  const params = useParams()
+  const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+
+  const cost = createMemo(() => {
+    const total = messages().reduce((sum, x) => sum + (x.role === "assistant" ? x.cost : 0), 0)
+    return new Intl.NumberFormat("en-US", {
+      style: "currency",
+      currency: "USD",
+    }).format(total)
+  })
+
+  const context = createMemo(() => {
+    const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
+    if (!last) return
+    const total =
+      last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
+    const model = sync.data.provider.all.find((x) => x.id === last.providerID)?.models[last.modelID]
+    return {
+      tokens: total.toLocaleString(),
+      percentage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
+    }
+  })
+
+  return (
+    <Show when={context?.()}>
+      {(ctx) => (
+        <Tooltip
+          openDelay={300}
+          value={
+            <div class="flex flex-col gap-1 p-2">
+              <div class="flex justify-between gap-4">
+                <span class="text-text-weaker">Tokens</span>
+                <span class="text-text-strong">{ctx().tokens}</span>
+              </div>
+              <div class="flex justify-between gap-4">
+                <span class="text-text-weaker">Usage</span>
+                <span class="text-text-strong">{ctx().percentage ?? 0}%</span>
+              </div>
+              <div class="flex justify-between gap-4">
+                <span class="text-text-weaker">Cost</span>
+                <span class="text-text-strong">{cost()}</span>
+              </div>
+            </div>
+          }
+          placement="top"
+        >
+          <div class="flex items-center gap-1">
+            <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span>
+            <ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
+          </div>
+        </Tooltip>
+      )}
+    </Show>
+  )
+}

+ 5 - 1
packages/desktop/src/context/command.tsx

@@ -119,7 +119,6 @@ function DialogCommand(props: { options: CommandOption[] }) {
   return (
     <Dialog title="Commands">
       <List
-        class="px-2.5"
         search={{ placeholder: "Search commands", autofocus: true }}
         emptyMessage="No commands found"
         items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
@@ -226,6 +225,11 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
           }
         }
       },
+      keybind(id: string) {
+        const option = options().find((x) => x.id === id || x.id === "suggested." + id)
+        if (!option?.keybind) return ""
+        return formatKeybind(option.keybind)
+      },
       show: showPalette,
       keybinds(enabled: boolean) {
         setSuspendCount((count) => count + (enabled ? -1 : 1))

+ 19 - 2
packages/desktop/src/context/layout.tsx

@@ -46,6 +46,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           opened: false,
           height: 280,
         },
+        review: {
+          opened: true,
+        },
         session: {
           width: 600,
         },
@@ -108,10 +111,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
         },
         expand(directory: string) {
-          setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: true } : x)))
+          const index = store.projects.findIndex((x) => x.worktree === directory)
+          if (index !== -1) setStore("projects", index, "expanded", true)
         },
         collapse(directory: string) {
-          setStore("projects", (x) => x.map((x) => (x.worktree === directory ? { ...x, expanded: false } : x)))
+          const index = store.projects.findIndex((x) => x.worktree === directory)
+          if (index !== -1) setStore("projects", index, "expanded", false)
         },
         move(directory: string, toIndex: number) {
           setStore("projects", (projects) => {
@@ -156,6 +161,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setStore("terminal", "height", height)
         },
       },
+      review: {
+        opened: createMemo(() => store.review?.opened ?? true),
+        open() {
+          setStore("review", "opened", true)
+        },
+        close() {
+          setStore("review", "opened", false)
+        },
+        toggle() {
+          setStore("review", "opened", (x) => !x)
+        },
+      },
       session: {
         width: createMemo(() => store.session?.width ?? 600),
         resize(width: number) {

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

@@ -360,7 +360,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       const init = async (path: string) => {
         const relativePath = relative(path)
         if (!store.node[relativePath]) await fetch(path)
-        if (store.node[relativePath].loaded) return
+        if (store.node[relativePath]?.loaded) return
         return load(relativePath)
       }
 
@@ -380,7 +380,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         context.addActive()
         if (options?.pinned) setStore("node", path, "pinned", true)
         if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view)
-        if (store.node[relativePath].loaded) return
+        if (store.node[relativePath]?.loaded) return
         return load(relativePath)
       }
 

+ 25 - 5
packages/desktop/src/pages/error.tsx

@@ -62,12 +62,32 @@ function formatInitError(error: InitError): string {
   }
 }
 
-function formatError(error: unknown): string {
+function formatErrorChain(error: unknown, depth = 0): string {
   if (!error) return "Unknown error"
-  if (isInitError(error)) return formatInitError(error)
-  if (error instanceof Error) return `${error.name}: ${error.message}\n\n${error.stack}`
-  if (typeof error === "string") return error
-  return JSON.stringify(error, null, 2)
+
+  const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
+
+  if (isInitError(error)) {
+    return indent + formatInitError(error)
+  }
+
+  if (error instanceof Error) {
+    const parts = [indent + `${error.name}: ${error.message}`]
+    if (error.stack) {
+      parts.push(error.stack)
+    }
+    if (error.cause) {
+      parts.push(formatErrorChain(error.cause, depth + 1))
+    }
+    return parts.join("\n\n")
+  }
+
+  if (typeof error === "string") return indent + error
+  return indent + JSON.stringify(error, null, 2)
+}
+
+function formatError(error: unknown): string {
+  return formatErrorChain(error, 0)
 }
 
 interface ErrorPageProps {

+ 208 - 112
packages/desktop/src/pages/layout.tsx

@@ -1,4 +1,16 @@
-import { createEffect, createMemo, For, Match, onMount, ParentProps, Show, Switch, type JSX } from "solid-js"
+import {
+  createEffect,
+  createMemo,
+  createSignal,
+  For,
+  Match,
+  onCleanup,
+  onMount,
+  ParentProps,
+  Show,
+  Switch,
+  type JSX,
+} from "solid-js"
 import { DateTime } from "luxon"
 import { A, useNavigate, useParams } from "@solidjs/router"
 import { useLayout, getAvatarColors, LocalProject } from "@/context/layout"
@@ -42,9 +54,29 @@ export default function Layout(props: ParentProps) {
   const [store, setStore] = createStore({
     lastSession: {} as { [directory: string]: string },
     activeDraggable: undefined as string | undefined,
+    mobileSidebarOpen: false,
+    mobileProjectsExpanded: {} as Record<string, boolean>,
   })
 
+  const mobileSidebar = {
+    open: () => store.mobileSidebarOpen,
+    show: () => setStore("mobileSidebarOpen", true),
+    hide: () => setStore("mobileSidebarOpen", false),
+    toggle: () => setStore("mobileSidebarOpen", (x) => !x),
+  }
+
+  const mobileProjects = {
+    expanded: (directory: string) => store.mobileProjectsExpanded[directory] ?? true,
+    expand: (directory: string) => setStore("mobileProjectsExpanded", directory, true),
+    collapse: (directory: string) => setStore("mobileProjectsExpanded", directory, false),
+  }
+
   let scrollContainerRef: HTMLDivElement | undefined
+  const xlQuery = window.matchMedia("(min-width: 1280px)")
+  const [isLargeViewport, setIsLargeViewport] = createSignal(xlQuery.matches)
+  const handleViewportChange = (e: MediaQueryListEvent) => setIsLargeViewport(e.matches)
+  xlQuery.addEventListener("change", handleViewportChange)
+  onCleanup(() => xlQuery.removeEventListener("change", handleViewportChange))
 
   const params = useParams()
   const globalSDK = useGlobalSDK()
@@ -259,11 +291,13 @@ export default function Layout(props: ParentProps) {
     if (!directory) return
     const lastSession = store.lastSession[directory]
     navigate(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
+    mobileSidebar.hide()
   }
 
   function navigateToSession(session: Session | undefined) {
     if (!session) return
     navigate(`/${params.dir}/session/${session?.id}`)
+    mobileSidebar.hide()
   }
 
   function openProject(directory: string, navigate = true) {
@@ -302,8 +336,12 @@ export default function Layout(props: ParentProps) {
   })
 
   createEffect(() => {
-    const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
-    document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
+    if (isLargeViewport()) {
+      const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48
+      document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`)
+    } else {
+      document.documentElement.style.setProperty("--dialog-left-margin", "0px")
+    }
   })
 
   function getDraggableId(event: unknown): string | undefined {
@@ -419,6 +457,7 @@ export default function Layout(props: ParentProps) {
     project: LocalProject
     depth?: number
     childrenMap: Map<string, Session[]>
+    mobile?: boolean
   }): JSX.Element => {
     const notification = useNotification()
     const depth = props.depth ?? 0
@@ -439,7 +478,7 @@ export default function Layout(props: ParentProps) {
                  hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
           style={{ "padding-left": `${16 + depth * 12}px` }}
         >
-          <Tooltip placement="right" value={props.session.title} gutter={10}>
+          <Tooltip placement={props.mobile ? "bottom" : "right"} value={props.session.title} gutter={10}>
             <A
               href={`${props.slug}/session/${props.session.id}`}
               class="flex flex-col min-w-0 text-left w-full focus:outline-none"
@@ -486,7 +525,7 @@ export default function Layout(props: ParentProps) {
             </A>
           </Tooltip>
           <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
-            <Tooltip placement="right" value="Archive session">
+            <Tooltip placement={props.mobile ? "bottom" : "right"} value="Archive session">
               <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
             </Tooltip>
           </div>
@@ -499,6 +538,7 @@ export default function Layout(props: ParentProps) {
               project={props.project}
               depth={depth + 1}
               childrenMap={props.childrenMap}
+              mobile={props.mobile}
             />
           )}
         </For>
@@ -506,8 +546,9 @@ export default function Layout(props: ParentProps) {
     )
   }
 
-  const SortableProject = (props: { project: LocalProject }): JSX.Element => {
+  const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => {
     const sortable = createSortable(props.project.worktree)
+    const showExpanded = createMemo(() => props.mobile || layout.sidebar.opened())
     const slug = createMemo(() => base64Encode(props.project.worktree))
     const name = createMemo(() => getFilename(props.project.worktree))
     const [store, setProjectStore] = globalSync.child(props.project.worktree)
@@ -531,21 +572,24 @@ export default function Layout(props: ParentProps) {
       setProjectStore("limit", (limit) => limit + 5)
       await globalSync.project.loadSessions(props.project.worktree)
     }
+    const isExpanded = createMemo(() =>
+      props.mobile ? mobileProjects.expanded(props.project.worktree) : props.project.expanded,
+    )
     const handleOpenChange = (open: boolean) => {
-      if (open) layout.projects.expand(props.project.worktree)
-      else layout.projects.collapse(props.project.worktree)
+      if (props.mobile) {
+        if (open) mobileProjects.expand(props.project.worktree)
+        else mobileProjects.collapse(props.project.worktree)
+      } else {
+        if (open) layout.projects.expand(props.project.worktree)
+        else layout.projects.collapse(props.project.worktree)
+      }
     }
     return (
       // @ts-ignore
       <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
         <Switch>
-          <Match when={layout.sidebar.opened()}>
-            <Collapsible
-              variant="ghost"
-              open={props.project.expanded}
-              class="gap-2 shrink-0"
-              onOpenChange={handleOpenChange}
-            >
+          <Match when={showExpanded()}>
+            <Collapsible variant="ghost" open={isExpanded()} class="gap-2 shrink-0" onOpenChange={handleOpenChange}>
               <Button
                 as={"div"}
                 variant="ghost"
@@ -556,7 +600,7 @@ export default function Layout(props: ParentProps) {
                     project={props.project}
                     class="group-hover/session:hidden"
                     expandable
-                    notify={!props.project.expanded}
+                    notify={!isExpanded()}
                   />
                   <span class="truncate text-14-medium text-text-strong">{name()}</span>
                 </Collapsible.Trigger>
@@ -585,6 +629,7 @@ export default function Layout(props: ParentProps) {
                         slug={slug()}
                         project={props.project}
                         childrenMap={childSessionsByParent()}
+                        mobile={props.mobile}
                       />
                     )}
                   </For>
@@ -595,7 +640,7 @@ export default function Layout(props: ParentProps) {
                     >
                       <div class="flex items-center self-stretch w-full">
                         <div class="flex-1 min-w-0">
-                          <Tooltip placement="right" value="New session">
+                          <Tooltip placement={props.mobile ? "bottom" : "right"} value="New session">
                             <A
                               href={`${slug()}/session`}
                               class="flex flex-col gap-1 min-w-0 text-left w-full focus:outline-none"
@@ -650,31 +695,23 @@ export default function Layout(props: ParentProps) {
     )
   }
 
-  return (
-    <div class="relative flex-1 min-h-0 flex flex-col">
-      <Header navigateToProject={navigateToProject} navigateToSession={navigateToSession} />
-      <div class="flex-1 min-h-0 flex">
-        <div
-          classList={{
-            "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
-            "flex flex-col gap-5.5 items-start self-stretch justify-between": true,
-            "border-r border-border-weak-base contain-strict": true,
-          }}
-          style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
-        >
-          <Show when={layout.sidebar.opened()}>
-            <ResizeHandle
-              direction="horizontal"
-              size={layout.sidebar.width()}
-              min={150}
-              max={window.innerWidth * 0.3}
-              collapseThreshold={80}
-              onResize={layout.sidebar.resize}
-              onCollapse={layout.sidebar.close}
-            />
-          </Show>
-          <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
-            <Tooltip class="shrink-0" placement="right" value="Toggle sidebar" inactive={layout.sidebar.opened()}>
+  const SidebarContent = (sidebarProps: { mobile?: boolean }) => {
+    const expanded = () => sidebarProps.mobile || layout.sidebar.opened()
+    return (
+      <>
+        <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
+          <Show when={!sidebarProps.mobile}>
+            <Tooltip
+              class="shrink-0"
+              placement="right"
+              value={
+                <div class="flex items-center gap-2">
+                  <span>Toggle sidebar</span>
+                  <span class="text-icon-base text-12-medium">{command.keybind("sidebar.toggle")}</span>
+                </div>
+              }
+              inactive={expanded()}
+            >
               <Button
                 variant="ghost"
                 size="large"
@@ -705,101 +742,160 @@ export default function Layout(props: ParentProps) {
                 </Show>
               </Button>
             </Tooltip>
-            <DragDropProvider
-              onDragStart={handleDragStart}
-              onDragEnd={handleDragEnd}
-              onDragOver={handleDragOver}
-              collisionDetector={closestCenter}
+          </Show>
+          <DragDropProvider
+            onDragStart={handleDragStart}
+            onDragEnd={handleDragEnd}
+            onDragOver={handleDragOver}
+            collisionDetector={closestCenter}
+          >
+            <DragDropSensors />
+            <ConstrainDragXAxis />
+            <div
+              ref={sidebarProps.mobile ? undefined : scrollContainerRef}
+              class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
             >
-              <DragDropSensors />
-              <ConstrainDragXAxis />
-              <div
-                ref={scrollContainerRef}
-                class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
-              >
-                <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
-                  <For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For>
-                </SortableProvider>
-              </div>
-              <DragOverlay>
-                <ProjectDragOverlay />
-              </DragOverlay>
-            </DragDropProvider>
-          </div>
-          <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
-            <Switch>
-              <Match when={!providers.paid().length && layout.sidebar.opened()}>
-                <div class="rounded-md bg-background-stronger shadow-xs-border-base">
-                  <div class="p-3 flex flex-col gap-2">
-                    <div class="text-12-medium text-text-strong">Getting started</div>
-                    <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
-                    <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
-                  </div>
-                  <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
-                    <Button
-                      class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
-                      size="large"
-                      icon="plus"
-                      onClick={connectProvider}
-                    >
-                      <Show when={layout.sidebar.opened()}>Connect provider</Show>
-                    </Button>
-                  </Tooltip>
+              <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
+                <For each={layout.projects.list()}>
+                  {(project) => <SortableProject project={project} mobile={sidebarProps.mobile} />}
+                </For>
+              </SortableProvider>
+            </div>
+            <DragOverlay>
+              <ProjectDragOverlay />
+            </DragOverlay>
+          </DragDropProvider>
+        </div>
+        <div class="flex flex-col gap-1.5 self-stretch items-start shrink-0 px-2 py-3">
+          <Switch>
+            <Match when={!providers.paid().length && expanded()}>
+              <div class="rounded-md bg-background-stronger shadow-xs-border-base">
+                <div class="p-3 flex flex-col gap-2">
+                  <div class="text-12-medium text-text-strong">Getting started</div>
+                  <div class="text-text-base">OpenCode includes free models so you can start immediately.</div>
+                  <div class="text-text-base">Connect any provider to use models, inc. Claude, GPT, Gemini etc.</div>
                 </div>
-              </Match>
-              <Match when={true}>
-                <Tooltip placement="right" value="Connect provider" inactive={layout.sidebar.opened()}>
+                <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
                   <Button
-                    class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
-                    variant="ghost"
+                    class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-lg rounded-t-none shadow-none border-t border-border-weak-base pl-2.25 pb-px"
                     size="large"
                     icon="plus"
                     onClick={connectProvider}
                   >
-                    <Show when={layout.sidebar.opened()}>Connect provider</Show>
+                    Connect provider
                   </Button>
                 </Tooltip>
-              </Match>
-            </Switch>
-            <Show when={platform.openDirectoryPickerDialog}>
-              <Tooltip placement="right" value="Open project" inactive={layout.sidebar.opened()}>
+              </div>
+            </Match>
+            <Match when={true}>
+              <Tooltip placement="right" value="Connect provider" inactive={expanded()}>
                 <Button
                   class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
                   variant="ghost"
                   size="large"
-                  icon="folder-add-left"
-                  onClick={chooseProject}
+                  icon="plus"
+                  onClick={connectProvider}
                 >
-                  <Show when={layout.sidebar.opened()}>Open project</Show>
+                  <Show when={expanded()}>Connect provider</Show>
                 </Button>
               </Tooltip>
-            </Show>
-            {/* <Tooltip placement="right" value="Settings" inactive={layout.sidebar.opened()}> */}
-            {/*   <Button */}
-            {/*     disabled */}
-            {/*     class="flex w-full text-left justify-start text-12-medium text-text-base stroke-[1.5px] rounded-lg px-2" */}
-            {/*     variant="ghost" */}
-            {/*     size="large" */}
-            {/*     icon="settings-gear" */}
-            {/*   > */}
-            {/*     <Show when={layout.sidebar.opened()}>Settings</Show> */}
-            {/*   </Button> */}
-            {/* </Tooltip> */}
-            <Tooltip placement="right" value="Share feedback" inactive={layout.sidebar.opened()}>
+            </Match>
+          </Switch>
+          <Show when={platform.openDirectoryPickerDialog}>
+            <Tooltip
+              placement="right"
+              value={
+                <div class="flex items-center gap-2">
+                  <span>Open project</span>
+                  <Show when={!sidebarProps.mobile}>
+                    <span class="text-icon-base text-12-medium">{command.keybind("project.open")}</span>
+                  </Show>
+                </div>
+              }
+              inactive={expanded()}
+            >
               <Button
-                as={"a"}
-                href="https://opencode.ai/desktop-feedback"
-                target="_blank"
                 class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
                 variant="ghost"
                 size="large"
-                icon="bubble-5"
+                icon="folder-add-left"
+                onClick={chooseProject}
               >
-                <Show when={layout.sidebar.opened()}>Share feedback</Show>
+                <Show when={expanded()}>Open project</Show>
               </Button>
             </Tooltip>
+          </Show>
+          <Tooltip placement="right" value="Share feedback" inactive={expanded()}>
+            <Button
+              as={"a"}
+              href="https://opencode.ai/desktop-feedback"
+              target="_blank"
+              class="flex w-full text-left justify-start text-text-base stroke-[1.5px] rounded-lg px-2"
+              variant="ghost"
+              size="large"
+              icon="bubble-5"
+            >
+              <Show when={expanded()}>Share feedback</Show>
+            </Button>
+          </Tooltip>
+        </div>
+      </>
+    )
+  }
+
+  return (
+    <div class="relative flex-1 min-h-0 flex flex-col">
+      <Header
+        navigateToProject={navigateToProject}
+        navigateToSession={navigateToSession}
+        onMobileMenuToggle={mobileSidebar.toggle}
+      />
+      <div class="flex-1 min-h-0 flex">
+        <div
+          classList={{
+            "hidden xl:flex": true,
+            "relative @container w-12 pb-5 shrink-0 bg-background-base": true,
+            "flex-col gap-5.5 items-start self-stretch justify-between": true,
+            "border-r border-border-weak-base contain-strict": true,
+          }}
+          style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
+        >
+          <Show when={layout.sidebar.opened()}>
+            <ResizeHandle
+              direction="horizontal"
+              size={layout.sidebar.width()}
+              min={150}
+              max={window.innerWidth * 0.3}
+              collapseThreshold={80}
+              onResize={layout.sidebar.resize}
+              onCollapse={layout.sidebar.close}
+            />
+          </Show>
+          <SidebarContent />
+        </div>
+        <div class="xl:hidden">
+          <div
+            classList={{
+              "fixed inset-0 bg-black/50 z-40 transition-opacity duration-200": true,
+              "opacity-100 pointer-events-auto": mobileSidebar.open(),
+              "opacity-0 pointer-events-none": !mobileSidebar.open(),
+            }}
+            onClick={(e) => {
+              if (e.target === e.currentTarget) mobileSidebar.hide()
+            }}
+          />
+          <div
+            classList={{
+              "@container fixed inset-y-0 left-0 z-50 w-72 bg-background-base border-r border-border-weak-base flex flex-col gap-5.5 items-start self-stretch justify-between pt-12 pb-5 transition-transform duration-200 ease-out": true,
+              "translate-x-0": mobileSidebar.open(),
+              "-translate-x-full": !mobileSidebar.open(),
+            }}
+            onClick={(e) => e.stopPropagation()}
+          >
+            <SidebarContent mobile />
           </div>
         </div>
+
         <main class="size-full overflow-x-hidden flex flex-col items-start contain-strict">{props.children}</main>
       </div>
       <Toast.Region />

+ 196 - 80
packages/desktop/src/pages/session.tsx

@@ -12,6 +12,7 @@ import {
   createRenderEffect,
   batch,
 } from "solid-js"
+
 import { Dynamic } from "solid-js/web"
 import { useLocal, type LocalFile } from "@/context/local"
 import { createStore } from "solid-js/store"
@@ -26,6 +27,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { useCodeComponent } from "@opencode-ai/ui/context/code"
 import { SessionTurn } from "@opencode-ai/ui/session-turn"
+import { createAutoScroll } from "@opencode-ai/ui/hooks"
 import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail"
 import { SessionReview } from "@opencode-ai/ui/session-review"
 import {
@@ -70,7 +72,6 @@ export default function Page() {
 
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const tabs = createMemo(() => layout.tabs(sessionKey()))
-
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const revertMessageID = createMemo(() => info()?.revert?.messageID)
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
@@ -79,7 +80,6 @@ export default function Page() {
       .filter((m) => m.role === "user")
       .sort((a, b) => a.id.localeCompare(b.id)),
   )
-  // Visible user messages excludes reverted messages (those >= revertMessageID)
   const visibleUserMessages = createMemo(() => {
     const revert = revertMessageID()
     if (!revert) return userMessages()
@@ -87,15 +87,24 @@ export default function Page() {
   })
   const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
 
-  const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({})
+  const [store, setStore] = createStore({
+    clickTimer: undefined as number | undefined,
+    activeDraggable: undefined as string | undefined,
+    activeTerminalDraggable: undefined as string | undefined,
+    userInteracted: false,
+    stepsExpanded: true,
+    mobileStepsExpanded: {} as Record<string, boolean>,
+    messageId: undefined as string | undefined,
+  })
+
   const activeMessage = createMemo(() => {
-    if (!messageStore.messageId) return lastUserMessage()
+    if (!store.messageId) return lastUserMessage()
     // If the stored message is no longer visible (e.g., was reverted), fall back to last visible
-    const found = visibleUserMessages()?.find((m) => m.id === messageStore.messageId)
+    const found = visibleUserMessages()?.find((m) => m.id === store.messageId)
     return found ?? lastUserMessage()
   })
   const setActiveMessage = (message: UserMessage | undefined) => {
-    setMessageStore("messageId", message?.id)
+    setStore("messageId", message?.id)
   }
 
   function navigateMessageByOffset(offset: number) {
@@ -119,13 +128,6 @@ export default function Page() {
 
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
 
-  const [store, setStore] = createStore({
-    clickTimer: undefined as number | undefined,
-    activeDraggable: undefined as string | undefined,
-    activeTerminalDraggable: undefined as string | undefined,
-    userInteracted: false,
-    stepsExpanded: true,
-  })
   let inputRef!: HTMLDivElement
 
   createEffect(() => {
@@ -146,7 +148,7 @@ export default function Page() {
       () => visibleUserMessages().at(-1)?.id,
       (lastId, prevLastId) => {
         if (lastId && prevLastId && lastId > prevLastId) {
-          setMessageStore("messageId", undefined)
+          setStore("messageId", undefined)
         }
       },
       { defer: true },
@@ -219,6 +221,15 @@ export default function Page() {
       slash: "terminal",
       onSelect: () => layout.terminal.toggle(),
     },
+    {
+      id: "review.toggle",
+      title: "Toggle review",
+      description: "Show or hide the review panel",
+      category: "View",
+      keybind: "mod+b",
+      slash: "review",
+      onSelect: () => layout.review.toggle(),
+    },
     {
       id: "terminal.new",
       title: "New terminal",
@@ -531,74 +542,163 @@ export default function Page() {
     )
   }
 
-  const showTabs = createMemo(() => diffs().length > 0 || tabs().all().length > 0)
+  const showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0))
+
+  const mobileWorking = createMemo(() => status().type !== "idle")
+  const mobileAutoScroll = createAutoScroll({
+    working: mobileWorking,
+    onUserInteracted: () => setStore("userInteracted", true),
+  })
+
+  const MobileTurns = () => (
+    <div
+      ref={mobileAutoScroll.scrollRef}
+      onScroll={mobileAutoScroll.handleScroll}
+      onClick={mobileAutoScroll.handleInteraction}
+      class="relative mt-2 min-w-0 w-full h-full overflow-y-auto no-scrollbar pb-12"
+    >
+      <div ref={mobileAutoScroll.contentRef} class="flex flex-col gap-45 items-start justify-start mt-4">
+        <For each={visibleUserMessages()}>
+          {(message) => (
+            <SessionTurn
+              sessionID={params.id!}
+              messageID={message.id}
+              stepsExpanded={store.mobileStepsExpanded[message.id] ?? false}
+              onStepsExpandedToggle={() => setStore("mobileStepsExpanded", message.id, (x) => !x)}
+              onUserInteracted={() => setStore("userInteracted", true)}
+              classes={{
+                root: "min-w-0 w-full relative",
+                content:
+                  "flex flex-col justify-between !overflow-visible [&_[data-slot=session-turn-message-header]]:top-[-32px]",
+                container: "px-4",
+              }}
+            />
+          )}
+        </For>
+      </div>
+    </div>
+  )
+
+  const NewSessionView = () => (
+    <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
+      <div class="text-20-medium text-text-weaker">New session</div>
+      <div class="flex justify-center items-center gap-3">
+        <Icon name="folder" size="small" />
+        <div class="text-12-medium text-text-weak">
+          {getDirectory(sync.data.path.directory)}
+          <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
+        </div>
+      </div>
+      <Show when={sync.project}>
+        {(project) => (
+          <div class="flex justify-center items-center gap-3">
+            <Icon name="pencil-line" size="small" />
+            <div class="text-12-medium text-text-weak">
+              Last modified&nbsp;
+              <span class="text-text-strong">
+                {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
+              </span>
+            </div>
+          </div>
+        )}
+      </Show>
+    </div>
+  )
+
+  const DesktopSessionContent = () => (
+    <Switch>
+      <Match when={params.id}>
+        <div class="flex items-start justify-start h-full min-h-0">
+          <SessionMessageRail
+            messages={visibleUserMessages()}
+            current={activeMessage()}
+            onMessageSelect={setActiveMessage}
+            wide={!showTabs()}
+          />
+          <Show when={activeMessage()}>
+            <SessionTurn
+              sessionID={params.id!}
+              messageID={activeMessage()!.id}
+              stepsExpanded={store.stepsExpanded}
+              onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
+              onUserInteracted={() => setStore("userInteracted", true)}
+              classes={{
+                root: "pb-20 flex-1 min-w-0",
+                content: "pb-20",
+                container:
+                  "w-full " +
+                  (!showTabs() ? "max-w-200 mx-auto px-6" : visibleUserMessages().length > 1 ? "pr-6 pl-18" : "px-6"),
+              }}
+            />
+          </Show>
+        </div>
+      </Match>
+      <Match when={true}>
+        <NewSessionView />
+      </Match>
+    </Switch>
+  )
 
   return (
-    <div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
-      <div class="min-h-0 grow w-full flex">
-        {/* Session pane - always visible */}
+    <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
+      <div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
+        <Switch>
+          <Match when={!params.id}>
+            <div class="flex-1 min-h-0 overflow-hidden">
+              <NewSessionView />
+            </div>
+          </Match>
+          <Match when={diffs().length > 0}>
+            <Tabs class="flex-1 min-h-0 flex flex-col pb-28">
+              <Tabs.List>
+                <Tabs.Trigger value="session" class="w-1/2" classes={{ button: "w-full" }}>
+                  Session
+                </Tabs.Trigger>
+                <Tabs.Trigger value="review" class="w-1/2 !border-r-0" classes={{ button: "w-full" }}>
+                  {diffs().length} Files Changed
+                </Tabs.Trigger>
+              </Tabs.List>
+              <Tabs.Content value="session" class="flex-1 !overflow-hidden">
+                <MobileTurns />
+              </Tabs.Content>
+              <Tabs.Content forceMount value="review" class="flex-1 !overflow-hidden hidden data-[selected]:block">
+                <div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
+                  <SessionReview
+                    diffs={diffs()}
+                    classes={{
+                      root: "pb-32",
+                      header: "px-4",
+                      container: "px-4",
+                    }}
+                  />
+                </div>
+              </Tabs.Content>
+            </Tabs>
+          </Match>
+          <Match when={true}>
+            <div class="flex-1 min-h-0 overflow-hidden">
+              <MobileTurns />
+            </div>
+          </Match>
+        </Switch>
+        <div class="absolute inset-x-0 bottom-4 flex flex-col justify-center items-center z-50 px-4">
+          <div class="w-full">
+            <PromptInput
+              ref={(el) => {
+                inputRef = el
+              }}
+            />
+          </div>
+        </div>
+      </div>
+
+      <div class="hidden md:flex min-h-0 grow w-full">
         <div
           class="@container relative shrink-0 py-3 flex flex-col gap-6 min-h-0 h-full bg-background-stronger"
           style={{ width: showTabs() ? `${layout.session.width()}px` : "100%" }}
         >
           <div class="flex-1 min-h-0 overflow-hidden">
-            <Switch>
-              <Match when={params.id}>
-                <div class="flex items-start justify-start h-full min-h-0">
-                  <SessionMessageRail
-                    messages={visibleUserMessages()}
-                    current={activeMessage()}
-                    onMessageSelect={setActiveMessage}
-                    wide={!showTabs()}
-                  />
-                  <Show when={activeMessage()}>
-                    <SessionTurn
-                      sessionID={params.id!}
-                      messageID={activeMessage()!.id}
-                      stepsExpanded={store.stepsExpanded}
-                      onStepsExpandedToggle={() => setStore("stepsExpanded", (x) => !x)}
-                      onUserInteracted={() => setStore("userInteracted", true)}
-                      classes={{
-                        root: "pb-20 flex-1 min-w-0",
-                        content: "pb-20",
-                        container:
-                          "w-full " +
-                          (!showTabs()
-                            ? "max-w-200 mx-auto px-6"
-                            : visibleUserMessages().length > 1
-                              ? "pr-6 pl-18"
-                              : "px-6"),
-                      }}
-                    />
-                  </Show>
-                </div>
-              </Match>
-              <Match when={true}>
-                <div class="size-full flex flex-col pb-45 justify-end items-start gap-4 flex-[1_0_0] self-stretch max-w-200 mx-auto px-6">
-                  <div class="text-20-medium text-text-weaker">New session</div>
-                  <div class="flex justify-center items-center gap-3">
-                    <Icon name="folder" size="small" />
-                    <div class="text-12-medium text-text-weak">
-                      {getDirectory(sync.data.path.directory)}
-                      <span class="text-text-strong">{getFilename(sync.data.path.directory)}</span>
-                    </div>
-                  </div>
-                  <Show when={sync.project}>
-                    {(project) => (
-                      <div class="flex justify-center items-center gap-3">
-                        <Icon name="pencil-line" size="small" />
-                        <div class="text-12-medium text-text-weak">
-                          Last modified&nbsp;
-                          <span class="text-text-strong">
-                            {DateTime.fromMillis(project().time.updated ?? project().time.created).toRelative()}
-                          </span>
-                        </div>
-                      </div>
-                    )}
-                  </Show>
-                </div>
-              </Match>
-            </Switch>
+            <DesktopSessionContent />
           </div>
           <div class="absolute inset-x-0 bottom-8 flex flex-col justify-center items-center z-50">
             <div
@@ -625,7 +725,6 @@ export default function Page() {
           </Show>
         </div>
 
-        {/* Tabs pane - visible when there are diffs or file tabs */}
         <Show when={showTabs()}>
           <div class="relative flex-1 min-w-0 h-full border-l border-border-weak-base">
             <DragDropProvider
@@ -662,7 +761,15 @@ export default function Page() {
                       </For>
                     </SortableProvider>
                     <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
-                      <Tooltip value="Open file" class="flex items-center">
+                      <Tooltip
+                        value={
+                          <div class="flex items-center gap-2">
+                            <span>Open file</span>
+                            <span class="text-icon-base text-12-medium">{command.keybind("file.open")}</span>
+                          </div>
+                        }
+                        class="flex items-center"
+                      >
                         <IconButton
                           icon="plus-small"
                           variant="ghost"
@@ -675,7 +782,7 @@ export default function Page() {
                 </div>
                 <Show when={diffs().length}>
                   <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
-                    <div class="relative pt-3 flex-1 min-h-0 overflow-hidden">
+                    <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
                       <SessionReview
                         classes={{
                           root: "pb-40",
@@ -746,9 +853,10 @@ export default function Page() {
           </div>
         </Show>
       </div>
+
       <Show when={layout.terminal.opened()}>
         <div
-          class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
+          class="hidden md:flex relative w-full flex-col shrink-0 border-t border-border-weak-base"
           style={{ height: `${layout.terminal.height()}px` }}
         >
           <ResizeHandle
@@ -774,7 +882,15 @@ export default function Page() {
                   <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
                 </SortableProvider>
                 <div class="h-full flex items-center justify-center">
-                  <Tooltip value="New Terminal" class="flex items-center">
+                  <Tooltip
+                    value={
+                      <div class="flex items-center gap-2">
+                        <span>New terminal</span>
+                        <span class="text-icon-base text-12-medium">{command.keybind("terminal.new")}</span>
+                      </div>
+                    }
+                    class="flex items-center"
+                  >
                     <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
                   </Tooltip>
                 </div>

+ 1 - 0
packages/desktop/vite.config.ts

@@ -10,5 +10,6 @@ export default defineConfig({
   },
   build: {
     target: "esnext",
+    sourcemap: true,
   },
 })

+ 1 - 1
packages/enterprise/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/enterprise",
-  "version": "1.0.182",
+  "version": "1.0.190",
   "private": true,
   "type": "module",
   "scripts": {

+ 23 - 11
packages/enterprise/src/routes/share/[shareID].tsx

@@ -212,6 +212,7 @@ export default function () {
                       {iife(() => {
                         const [store, setStore] = createStore({
                           messageId: undefined as string | undefined,
+                          expandedSteps: {} as Record<string, boolean>,
                         })
                         const messages = createMemo(() =>
                           data().sessionID
@@ -253,20 +254,22 @@ export default function () {
 
                         const title = () => (
                           <div class="flex flex-col gap-4">
-                            <div class="h-8 flex gap-4 items-center justify-start self-stretch">
-                              <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base">
+                            <div class="flex flex-col gap-2 sm:flex-row sm:gap-4 sm:items-center sm:h-8 justify-start self-stretch">
+                              <div class="pl-[2.5px] pr-2 flex items-center gap-1.75 bg-surface-strong shadow-xs-border-base w-fit">
                                 <Mark class="shrink-0 w-3 my-0.5" />
                                 <div class="text-12-mono text-text-base">v{info().version}</div>
                               </div>
-                              <div class="flex gap-2 items-center">
-                                <ProviderIcon
-                                  id={provider() as IconName}
-                                  class="size-3.5 shrink-0 text-icon-strong-base"
-                                />
-                                <div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
-                              </div>
-                              <div class="text-12-regular text-text-weaker">
-                                {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
+                              <div class="flex gap-4 items-center">
+                                <div class="flex gap-2 items-center">
+                                  <ProviderIcon
+                                    id={provider() as IconName}
+                                    class="size-3.5 shrink-0 text-icon-strong-base"
+                                  />
+                                  <div class="text-12-regular text-text-base">{model()?.name ?? modelID()}</div>
+                                </div>
+                                <div class="text-12-regular text-text-weaker">
+                                  {DateTime.fromMillis(info().time.created).toFormat("dd MMM yyyy, HH:mm")}
+                                </div>
                               </div>
                             </div>
                             <div class="text-left text-16-medium text-text-strong">{info().title}</div>
@@ -282,6 +285,8 @@ export default function () {
                                   <SessionTurn
                                     sessionID={data().sessionID}
                                     messageID={message.id}
+                                    stepsExpanded={store.expandedSteps[message.id] ?? false}
+                                    onStepsExpandedToggle={() => setStore("expandedSteps", message.id, (v) => !v)}
                                     classes={{
                                       root: "min-w-0 w-full relative",
                                       content:
@@ -359,6 +364,13 @@ export default function () {
                                     <SessionTurn
                                       sessionID={data().sessionID}
                                       messageID={store.messageId ?? firstUserMessage()!.id!}
+                                      stepsExpanded={
+                                        store.expandedSteps[store.messageId ?? firstUserMessage()!.id!] ?? false
+                                      }
+                                      onStepsExpandedToggle={() => {
+                                        const id = store.messageId ?? firstUserMessage()!.id!
+                                        setStore("expandedSteps", id, (v) => !v)
+                                      }}
                                       classes={{
                                         root: "grow",
                                         content: "flex flex-col justify-between",

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

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

+ 1 - 1
packages/function/package.json

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

+ 5 - 3
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.0.182",
+  "version": "1.0.190",
   "name": "opencode",
   "type": "module",
   "private": true,
@@ -55,10 +55,12 @@
     "@ai-sdk/google": "2.0.44",
     "@ai-sdk/google-vertex": "3.0.81",
     "@ai-sdk/mcp": "0.0.8",
+    "@ai-sdk/mistral": "2.0.26",
     "@ai-sdk/openai": "2.0.71",
     "@ai-sdk/openai-compatible": "1.0.27",
     "@ai-sdk/provider": "2.0.0",
     "@ai-sdk/provider-utils": "3.0.18",
+    "@ai-sdk/xai": "2.0.42",
     "@clack/prompts": "1.0.0-alpha.1",
     "@hono/standard-validator": "0.1.5",
     "@hono/zod-validator": "catalog:",
@@ -71,8 +73,8 @@
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/util": "workspace:*",
     "@openrouter/ai-sdk-provider": "1.5.2",
-    "@opentui/core": "0.1.62",
-    "@opentui/solid": "0.1.62",
+    "@opentui/core": "0.1.63",
+    "@opentui/solid": "0.1.63",
     "@parcel/watcher": "2.5.1",
     "@pierre/diffs": "catalog:",
     "@solid-primitives/event-bus": "1.1.2",

+ 1 - 1
packages/opencode/script/publish.ts

@@ -40,7 +40,7 @@ const tags = [Script.channel]
 
 const tasks = Object.entries(binaries).map(async ([name]) => {
   if (process.platform !== "win32") {
-    await $`chmod 755 -R .`.cwd(`./dist/${name}`)
+    await $`chmod -R 755 .`.cwd(`./dist/${name}`)
   }
   await $`bun pm pack`.cwd(`./dist/${name}`)
   for (const tag of tags) {

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

@@ -30,6 +30,7 @@ export namespace Agent {
       permission: z.object({
         edit: Config.Permission,
         bash: z.record(z.string(), Config.Permission),
+        skill: z.record(z.string(), Config.Permission),
         webfetch: Config.Permission.optional(),
         doom_loop: Config.Permission.optional(),
         external_directory: Config.Permission.optional(),
@@ -58,6 +59,9 @@ export namespace Agent {
       bash: {
         "*": "allow",
       },
+      skill: {
+        "*": "allow",
+      },
       webfetch: "allow",
       doom_loop: "ask",
       external_directory: "ask",
@@ -337,6 +341,17 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
       "*": overridePermission.bash,
     }
   }
+
+  if (typeof basePermission.skill === "string") {
+    basePermission.skill = {
+      "*": basePermission.skill,
+    }
+  }
+  if (typeof overridePermission.skill === "string") {
+    overridePermission.skill = {
+      "*": overridePermission.skill,
+    }
+  }
   const merged = mergeDeep(basePermission ?? {}, overridePermission ?? {}) as any
   let mergedBash
   if (merged.bash) {
@@ -354,10 +369,27 @@ function mergeAgentPermissions(basePermission: any, overridePermission: any): Ag
     }
   }
 
+  let mergedSkill
+  if (merged.skill) {
+    if (typeof merged.skill === "string") {
+      mergedSkill = {
+        "*": merged.skill,
+      }
+    } else if (typeof merged.skill === "object") {
+      mergedSkill = mergeDeep(
+        {
+          "*": "allow",
+        },
+        merged.skill,
+      )
+    }
+  }
+
   const result: Agent.Info["permission"] = {
     edit: merged.edit ?? "allow",
     webfetch: merged.webfetch ?? "allow",
     bash: mergedBash ?? { "*": "allow" },
+    skill: mergedSkill ?? { "*": "allow" },
     doom_loop: merged.doom_loop,
     external_directory: merged.external_directory,
   }

+ 10 - 4
packages/opencode/src/agent/prompt/summary.txt

@@ -1,4 +1,10 @@
-Summarize the following conversation into 2 sentences MAX explaining what the
-assistant did and why
-Do not explain the user's input.
-Do not speak in the third person about the assistant.
+Summarize what was done in this conversation. Write like a pull request description.
+
+Rules:
+- 2-3 sentences max
+- Describe the changes made, not the process
+- Do not mention running tests, builds, or other validation steps
+- Do not explain what the user asked for
+- Write in first person (I added..., I fixed...)
+- Never ask questions or add new questions
+- Only exception: if the conversation ends with an unanswered question to the user, preserve that exact question

+ 2 - 0
packages/opencode/src/cli/cmd/debug/index.ts

@@ -6,6 +6,7 @@ import { FileCommand } from "./file"
 import { LSPCommand } from "./lsp"
 import { RipgrepCommand } from "./ripgrep"
 import { ScrapCommand } from "./scrap"
+import { SkillCommand } from "./skill"
 import { SnapshotCommand } from "./snapshot"
 
 export const DebugCommand = cmd({
@@ -17,6 +18,7 @@ export const DebugCommand = cmd({
       .command(RipgrepCommand)
       .command(FileCommand)
       .command(ScrapCommand)
+      .command(SkillCommand)
       .command(SnapshotCommand)
       .command(PathsCommand)
       .command({

+ 15 - 0
packages/opencode/src/cli/cmd/debug/skill.ts

@@ -0,0 +1,15 @@
+import { EOL } from "os"
+import { Skill } from "../../../skill"
+import { bootstrap } from "../../bootstrap"
+import { cmd } from "../cmd"
+
+export const SkillCommand = cmd({
+  command: "skill",
+  builder: (yargs) => yargs,
+  async handler() {
+    await bootstrap(process.cwd(), async () => {
+      const skills = await Skill.all()
+      process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
+    })
+  },
+})

+ 141 - 51
packages/opencode/src/cli/cmd/github.ts

@@ -7,7 +7,7 @@ import { graphql } from "@octokit/graphql"
 import * as core from "@actions/core"
 import * as github from "@actions/github"
 import type { Context } from "@actions/github/lib/context"
-import type { IssueCommentEvent, PullRequestReviewCommentEvent } from "@octokit/webhooks-types"
+import type { IssueCommentEvent, PullRequestReviewCommentEvent, PullRequestEvent } from "@octokit/webhooks-types"
 import { UI } from "../ui"
 import { cmd } from "./cmd"
 import { ModelsDev } from "../../provider/models"
@@ -127,6 +127,7 @@ type IssueQueryResponse = {
 const AGENT_USERNAME = "opencode-agent[bot]"
 const AGENT_REACTION = "eyes"
 const WORKFLOW_FILE = ".github/workflows/opencode.yml"
+const SUPPORTED_EVENTS = ["issue_comment", "pull_request_review_comment", "schedule", "pull_request"] as const
 
 // Parses GitHub remote URLs in various formats:
 // - https://github.com/owner/repo.git
@@ -387,24 +388,30 @@ export const GithubRunCommand = cmd({
       const isMock = args.token || args.event
 
       const context = isMock ? (JSON.parse(args.event!) as Context) : github.context
-      if (context.eventName !== "issue_comment" && context.eventName !== "pull_request_review_comment") {
+      if (!SUPPORTED_EVENTS.includes(context.eventName as (typeof SUPPORTED_EVENTS)[number])) {
         core.setFailed(`Unsupported event type: ${context.eventName}`)
         process.exit(1)
       }
+      const isCommentEvent = ["issue_comment", "pull_request_review_comment"].includes(context.eventName)
+      const isScheduleEvent = context.eventName === "schedule"
 
       const { providerID, modelID } = normalizeModel()
       const runId = normalizeRunId()
       const share = normalizeShare()
       const oidcBaseUrl = normalizeOidcBaseUrl()
       const { owner, repo } = context.repo
-      const payload = context.payload as IssueCommentEvent | PullRequestReviewCommentEvent
-      const issueEvent = isIssueCommentEvent(payload) ? payload : undefined
-      const actor = context.actor
-
-      const issueId =
-        context.eventName === "pull_request_review_comment"
-          ? (payload as PullRequestReviewCommentEvent).pull_request.number
-          : (payload as IssueCommentEvent).issue.number
+      // For schedule events, payload has no issue/comment data
+      const payload = isCommentEvent
+        ? (context.payload as IssueCommentEvent | PullRequestReviewCommentEvent)
+        : undefined
+      const issueEvent = payload && isIssueCommentEvent(payload) ? payload : undefined
+      const actor = isScheduleEvent ? undefined : context.actor
+
+      const issueId = isScheduleEvent
+        ? undefined
+        : context.eventName === "issue_comment"
+          ? (payload as IssueCommentEvent).issue.number
+          : (payload as PullRequestEvent | PullRequestReviewCommentEvent).pull_request.number
       const runUrl = `/${owner}/${repo}/actions/runs/${runId}`
       const shareBaseUrl = isMock ? "https://dev.opencode.ai" : "https://opencode.ai"
 
@@ -416,9 +423,13 @@ export const GithubRunCommand = cmd({
       let shareId: string | undefined
       let exitCode = 0
       type PromptFiles = Awaited<ReturnType<typeof getUserPrompt>>["promptFiles"]
-      const triggerCommentId = payload.comment.id
+      const triggerCommentId = payload?.comment.id
       const useGithubToken = normalizeUseGithubToken()
-      const commentType = context.eventName === "pull_request_review_comment" ? "pr_review" : "issue"
+      const commentType = isCommentEvent
+        ? context.eventName === "pull_request_review_comment"
+          ? "pr_review"
+          : "issue"
+        : undefined
 
       try {
         if (useGithubToken) {
@@ -442,9 +453,11 @@ export const GithubRunCommand = cmd({
         if (!useGithubToken) {
           await configureGit(appToken)
         }
-        await assertPermissions()
-
-        await addReaction(commentType)
+        // Skip permission check for schedule events (no actor to check)
+        if (!isScheduleEvent) {
+          await assertPermissions()
+          await addReaction(commentType)
+        }
 
         // Setup opencode session
         const repoData = await fetchRepo()
@@ -458,11 +471,34 @@ export const GithubRunCommand = cmd({
         })()
         console.log("opencode session", session.id)
 
-        // Handle 3 cases
-        // 1. Issue
-        // 2. Local PR
-        // 3. Fork PR
-        if (context.eventName === "pull_request_review_comment" || issueEvent?.issue.pull_request) {
+        // Handle 4 cases
+        // 1. Schedule (no issue/PR context)
+        // 2. Issue
+        // 3. Local PR
+        // 4. Fork PR
+        if (isScheduleEvent) {
+          // Schedule event - no issue/PR context, output goes to logs
+          const branch = await checkoutNewBranch("schedule")
+          const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
+          const response = await chat(userPrompt, promptFiles)
+          const { dirty, uncommittedChanges } = await branchIsDirty(head)
+          if (dirty) {
+            const summary = await summarize(response)
+            await pushToNewBranch(summary, branch, uncommittedChanges, true)
+            const pr = await createPR(
+              repoData.data.default_branch,
+              branch,
+              summary,
+              `${response}\n\nTriggered by scheduled workflow${footer({ image: true })}`,
+            )
+            console.log(`Created PR #${pr}`)
+          } else {
+            console.log("Response:", response)
+          }
+        } else if (
+          ["pull_request", "pull_request_review_comment"].includes(context.eventName) ||
+          issueEvent?.issue.pull_request
+        ) {
           const prData = await fetchPR()
           // Local PR
           if (prData.headRepository.nameWithOwner === prData.baseRepository.nameWithOwner) {
@@ -497,7 +533,7 @@ export const GithubRunCommand = cmd({
         }
         // Issue
         else {
-          const branch = await checkoutNewBranch()
+          const branch = await checkoutNewBranch("issue")
           const head = (await $`git rev-parse HEAD`).stdout.toString().trim()
           const issueData = await fetchIssue()
           const dataPrompt = buildPromptDataForIssue(issueData)
@@ -505,7 +541,7 @@ export const GithubRunCommand = cmd({
           const { dirty, uncommittedChanges } = await branchIsDirty(head)
           if (dirty) {
             const summary = await summarize(response)
-            await pushToNewBranch(summary, branch, uncommittedChanges)
+            await pushToNewBranch(summary, branch, uncommittedChanges, false)
             const pr = await createPR(
               repoData.data.default_branch,
               branch,
@@ -528,8 +564,10 @@ export const GithubRunCommand = cmd({
         } else if (e instanceof Error) {
           msg = e.message
         }
-        await createComment(`${msg}${footer()}`)
-        await removeReaction(commentType)
+        if (!isScheduleEvent) {
+          await createComment(`${msg}${footer()}`)
+          await removeReaction(commentType)
+        }
         core.setFailed(msg)
         // Also output the clean error message for the action to capture
         //core.setOutput("prepare_error", e.message);
@@ -605,6 +643,14 @@ export const GithubRunCommand = cmd({
 
       async function getUserPrompt() {
         const customPrompt = process.env["PROMPT"]
+        // For schedule events, PROMPT is required since there's no comment to extract from
+        if (isScheduleEvent) {
+          if (!customPrompt) {
+            throw new Error("PROMPT input is required for scheduled events")
+          }
+          return { userPrompt: customPrompt, promptFiles: [] }
+        }
+
         if (customPrompt) {
           return { userPrompt: customPrompt, promptFiles: [] }
         }
@@ -615,7 +661,10 @@ export const GithubRunCommand = cmd({
           .map((m) => m.trim().toLowerCase())
           .filter(Boolean)
         let prompt = (() => {
-          const body = payload.comment.body.trim()
+          if (!isCommentEvent) {
+            return "Review this pull request"
+          }
+          const body = payload!.comment.body.trim()
           const bodyLower = body.toLowerCase()
           if (mentions.some((m) => bodyLower === m)) {
             if (reviewContext) {
@@ -865,9 +914,9 @@ export const GithubRunCommand = cmd({
         await $`git config --local ${config} "${gitConfig}"`
       }
 
-      async function checkoutNewBranch() {
+      async function checkoutNewBranch(type: "issue" | "schedule") {
         console.log("Checking out new branch...")
-        const branch = generateBranchName("issue")
+        const branch = generateBranchName(type)
         await $`git checkout -b ${branch}`
         return branch
       }
@@ -894,23 +943,32 @@ export const GithubRunCommand = cmd({
         await $`git checkout -b ${localBranch} fork/${remoteBranch}`
       }
 
-      function generateBranchName(type: "issue" | "pr") {
+      function generateBranchName(type: "issue" | "pr" | "schedule") {
         const timestamp = new Date()
           .toISOString()
           .replace(/[:-]/g, "")
           .replace(/\.\d{3}Z/, "")
           .split("T")
           .join("")
+        if (type === "schedule") {
+          const hex = crypto.randomUUID().slice(0, 6)
+          return `opencode/scheduled-${hex}-${timestamp}`
+        }
         return `opencode/${type}${issueId}-${timestamp}`
       }
 
-      async function pushToNewBranch(summary: string, branch: string, commit: boolean) {
+      async function pushToNewBranch(summary: string, branch: string, commit: boolean, isSchedule: boolean) {
         console.log("Pushing to new branch...")
         if (commit) {
           await $`git add .`
-          await $`git commit -m "${summary}
+          if (isSchedule) {
+            // No co-author for scheduled events - the schedule is operating as the repo
+            await $`git commit -m "${summary}"`
+          } else {
+            await $`git commit -m "${summary}
 
 Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
+          }
         }
         await $`git push -u origin ${branch}`
       }
@@ -958,6 +1016,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
       }
 
       async function assertPermissions() {
+        // Only called for non-schedule events, so actor is defined
         console.log(`Asserting permissions for user ${actor}...`)
 
         let permission
@@ -965,7 +1024,7 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
           const response = await octoRest.repos.getCollaboratorPermissionLevel({
             owner,
             repo,
-            username: actor,
+            username: actor!,
           })
 
           permission = response.data.permission
@@ -978,70 +1037,99 @@ Co-authored-by: ${actor} <${actor}@users.noreply.github.com>"`
         if (!["admin", "write"].includes(permission)) throw new Error(`User ${actor} does not have write permissions`)
       }
 
-      async function addReaction(commentType: "issue" | "pr_review") {
+      async function addReaction(commentType?: "issue" | "pr_review") {
+        // Only called for non-schedule events, so triggerCommentId is defined
         console.log("Adding reaction...")
-        if (commentType === "pr_review") {
-          return await octoRest.rest.reactions.createForPullRequestReviewComment({
+        if (triggerCommentId) {
+          if (commentType === "pr_review") {
+            return await octoRest.rest.reactions.createForPullRequestReviewComment({
+              owner,
+              repo,
+              comment_id: triggerCommentId!,
+              content: AGENT_REACTION,
+            })
+          }
+          return await octoRest.rest.reactions.createForIssueComment({
             owner,
             repo,
-            comment_id: triggerCommentId,
+            comment_id: triggerCommentId!,
             content: AGENT_REACTION,
           })
         }
-        return await octoRest.rest.reactions.createForIssueComment({
+        return await octoRest.rest.reactions.createForIssue({
           owner,
           repo,
-          comment_id: triggerCommentId,
+          issue_number: issueId!,
           content: AGENT_REACTION,
         })
       }
 
-      async function removeReaction(commentType: "issue" | "pr_review") {
+      async function removeReaction(commentType?: "issue" | "pr_review") {
+        // Only called for non-schedule events, so triggerCommentId is defined
         console.log("Removing reaction...")
-        if (commentType === "pr_review") {
-          const reactions = await octoRest.rest.reactions.listForPullRequestReviewComment({
+        if (triggerCommentId) {
+          if (commentType === "pr_review") {
+            const reactions = await octoRest.rest.reactions.listForPullRequestReviewComment({
+              owner,
+              repo,
+              comment_id: triggerCommentId!,
+              content: AGENT_REACTION,
+            })
+
+            const eyesReaction = reactions.data.find((r) => r.user?.login === AGENT_USERNAME)
+            if (!eyesReaction) return
+
+            return await octoRest.rest.reactions.deleteForPullRequestComment({
+              owner,
+              repo,
+              comment_id: triggerCommentId!,
+              reaction_id: eyesReaction.id,
+            })
+          }
+
+          const reactions = await octoRest.rest.reactions.listForIssueComment({
             owner,
             repo,
-            comment_id: triggerCommentId,
+            comment_id: triggerCommentId!,
             content: AGENT_REACTION,
           })
 
           const eyesReaction = reactions.data.find((r) => r.user?.login === AGENT_USERNAME)
           if (!eyesReaction) return
 
-          await octoRest.rest.reactions.deleteForPullRequestComment({
+          return await octoRest.rest.reactions.deleteForIssueComment({
             owner,
             repo,
-            comment_id: triggerCommentId,
+            comment_id: triggerCommentId!,
             reaction_id: eyesReaction.id,
           })
-          return
         }
 
-        const reactions = await octoRest.rest.reactions.listForIssueComment({
+        const reactions = await octoRest.rest.reactions.listForIssue({
           owner,
           repo,
-          comment_id: triggerCommentId,
+          issue_number: issueId!,
           content: AGENT_REACTION,
         })
 
         const eyesReaction = reactions.data.find((r) => r.user?.login === AGENT_USERNAME)
         if (!eyesReaction) return
 
-        await octoRest.rest.reactions.deleteForIssueComment({
+        await octoRest.rest.reactions.deleteForIssue({
           owner,
           repo,
-          comment_id: triggerCommentId,
+          issue_number: issueId!,
           reaction_id: eyesReaction.id,
         })
       }
 
       async function createComment(body: string) {
+        // Only called for non-schedule events, so issueId is defined
         console.log("Creating comment...")
         return await octoRest.rest.issues.createComment({
           owner,
           repo,
-          issue_number: issueId,
+          issue_number: issueId!,
           body,
         })
       }
@@ -1119,10 +1207,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
       }
 
       function buildPromptDataForIssue(issue: GitHubIssue) {
+        // Only called for non-schedule events, so payload is defined
         const comments = (issue.comments?.nodes || [])
           .filter((c) => {
             const id = parseInt(c.databaseId)
-            return id !== payload.comment.id
+            return id !== triggerCommentId
           })
           .map((c) => `  - ${c.author.login} at ${c.createdAt}: ${c.body}`)
 
@@ -1246,10 +1335,11 @@ query($owner: String!, $repo: String!, $number: Int!) {
       }
 
       function buildPromptDataForPR(pr: GitHubPullRequest) {
+        // Only called for non-schedule events, so payload is defined
         const comments = (pr.comments?.nodes || [])
           .filter((c) => {
             const id = parseInt(c.databaseId)
-            return id !== payload.comment.id
+            return id !== triggerCommentId
           })
           .map((c) => `- ${c.author.login} at ${c.createdAt}: ${c.body}`)
 

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

@@ -169,7 +169,7 @@ function App() {
   const local = useLocal()
   const kv = useKV()
   const command = useCommandDialog()
-  const { event } = useSDK()
+  const sdk = useSDK()
   const toast = useToast()
   const { theme, mode, setMode } = useTheme()
   const sync = useSync()
@@ -417,6 +417,15 @@ function App() {
       },
       category: "System",
     },
+    {
+      title: "Open WebUI",
+      value: "webui.open",
+      onSelect: () => {
+        open(sdk.url).catch(() => {})
+        dialog.clear()
+      },
+      category: "System",
+    },
     {
       title: "Exit the app",
       value: "app.exit",
@@ -487,11 +496,11 @@ function App() {
     }
   })
 
-  event.on(TuiEvent.CommandExecute.type, (evt) => {
+  sdk.event.on(TuiEvent.CommandExecute.type, (evt) => {
     command.trigger(evt.properties.command)
   })
 
-  event.on(TuiEvent.ToastShow.type, (evt) => {
+  sdk.event.on(TuiEvent.ToastShow.type, (evt) => {
     toast.show({
       title: evt.properties.title,
       message: evt.properties.message,
@@ -500,7 +509,7 @@ function App() {
     })
   })
 
-  event.on(SessionApi.Event.Deleted.type, (evt) => {
+  sdk.event.on(SessionApi.Event.Deleted.type, (evt) => {
     if (route.data.type === "session" && route.data.sessionID === evt.properties.info.id) {
       route.navigate({ type: "home" })
       toast.show({
@@ -510,7 +519,7 @@ function App() {
     }
   })
 
-  event.on(SessionApi.Event.Error.type, (evt) => {
+  sdk.event.on(SessionApi.Event.Error.type, (evt) => {
     const error = evt.properties.error
     const message = (() => {
       if (!error) return "An error occured"
@@ -531,7 +540,7 @@ function App() {
     })
   })
 
-  event.on(Installation.Event.Updated.type, (evt) => {
+  sdk.event.on(Installation.Event.Updated.type, (evt) => {
     toast.show({
       variant: "success",
       title: "Update Complete",
@@ -540,7 +549,7 @@ function App() {
     })
   })
 
-  event.on(Installation.Event.UpdateAvailable.type, (evt) => {
+  sdk.event.on(Installation.Event.UpdateAvailable.type, (evt) => {
     toast.show({
       variant: "info",
       title: "Update Available",

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

@@ -313,6 +313,11 @@ export function Prompt(props: PromptProps) {
 
   sdk.event.on(TuiEvent.PromptAppend.type, (evt) => {
     input.insertText(evt.properties.text)
+    setTimeout(() => {
+      input.getLayoutNode().markDirty()
+      input.gotoBufferEnd()
+      renderer.requestRender()
+    }, 0)
   })
 
   createEffect(() => {
@@ -734,6 +739,23 @@ export function Prompt(props: PromptProps) {
                   e.preventDefault()
                   return
                 }
+                // Handle clipboard paste (Ctrl+V) - check for images first on Windows
+                // This is needed because Windows terminal doesn't properly send image data
+                // through bracketed paste, so we need to intercept the keypress and
+                // directly read from clipboard before the terminal handles it
+                if (keybind.match("input_paste", e)) {
+                  const content = await Clipboard.read()
+                  if (content?.mime.startsWith("image/")) {
+                    e.preventDefault()
+                    await pasteImage({
+                      filename: "clipboard",
+                      mime: content.mime,
+                      content: content.data,
+                    })
+                    return
+                  }
+                  // If no image, let the default paste behavior continue
+                }
                 if (keybind.match("input_clear", e) && store.prompt.input !== "") {
                   input.clear()
                   input.extmarks.clear()

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

@@ -2,7 +2,6 @@ import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
 import { createSimpleContext } from "./helper"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
 import { batch, onCleanup, onMount } from "solid-js"
-import { iife } from "@/util/iife"
 
 export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
   name: "SDK",
@@ -70,6 +69,6 @@ export const { use: useSDK, provider: SDKProvider } = createSimpleContext({
       abort.abort()
     })
 
-    return { client: sdk, event: emitter }
+    return { client: sdk, event: emitter, url: props.url }
   },
 })

+ 1 - 1
packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx

@@ -54,7 +54,7 @@ export function DialogMessage(props: {
         {
           title: "Copy",
           value: "message.copy",
-          description: "copy message text to clipboard",
+          description: "message text to clipboard",
           onSelect: async (dialog) => {
             const msg = message()
             if (!msg) return

+ 1 - 1
packages/opencode/src/cli/cmd/tui/routes/session/dialog-subagent.tsx

@@ -11,7 +11,7 @@ export function DialogSubagent(props: { sessionID: string }) {
         {
           title: "Open",
           value: "subagent.view",
-          description: "open the subagent's session",
+          description: "the subagent's session",
           onSelect: (dialog) => {
             route.navigate({
               type: "session",

+ 3 - 0
packages/opencode/src/cli/cmd/tui/routes/session/header.tsx

@@ -80,6 +80,9 @@ export function Header() {
                 <text fg={theme.text}>
                   <b>Subagent session</b>
                 </text>
+                <text fg={theme.text}>
+                  Parent <span style={{ fg: theme.textMuted }}>{keybind.print("session_parent")}</span>
+                </text>
                 <text fg={theme.text}>
                   Prev <span style={{ fg: theme.textMuted }}>{keybind.print("session_child_cycle_reverse")}</span>
                 </text>

+ 82 - 21
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

@@ -204,6 +204,52 @@ export function Session() {
   let prompt: PromptRef
   const keybind = useKeybind()
 
+  // Helper: Find next visible message boundary in direction
+  const findNextVisibleMessage = (direction: "next" | "prev"): string | null => {
+    const children = scroll.getChildren()
+    const messagesList = messages()
+    const scrollTop = scroll.y
+
+    // Get visible messages sorted by position, filtering for valid non-synthetic, non-ignored content
+    const visibleMessages = children
+      .filter((c) => {
+        if (!c.id) return false
+        const message = messagesList.find((m) => m.id === c.id)
+        if (!message) return false
+
+        // Check if message has valid non-synthetic, non-ignored text parts
+        const parts = sync.data.part[message.id]
+        if (!parts || !Array.isArray(parts)) return false
+
+        return parts.some((part) => part && part.type === "text" && !part.synthetic && !part.ignored)
+      })
+      .sort((a, b) => a.y - b.y)
+
+    if (visibleMessages.length === 0) return null
+
+    if (direction === "next") {
+      // Find first message below current position
+      return visibleMessages.find((c) => c.y > scrollTop + 10)?.id ?? null
+    }
+    // Find last message above current position
+    return [...visibleMessages].reverse().find((c) => c.y < scrollTop - 10)?.id ?? null
+  }
+
+  // Helper: Scroll to message in direction or fallback to page scroll
+  const scrollToMessage = (direction: "next" | "prev", dialog: ReturnType<typeof useDialog>) => {
+    const targetID = findNextVisibleMessage(direction)
+
+    if (!targetID) {
+      scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
+      dialog.clear()
+      return
+    }
+
+    const child = scroll.getChildren().find((c) => c.id === targetID)
+    if (child) scroll.scrollBy(child.y - scroll.y - 1)
+    dialog.clear()
+  }
+
   useKeyboard((evt) => {
     if (dialog.stack.length > 0) return
 
@@ -637,6 +683,22 @@ export function Session() {
         }
       },
     },
+    {
+      title: "Next message",
+      value: "session.message.next",
+      keybind: "messages_next",
+      category: "Session",
+      disabled: true,
+      onSelect: (dialog) => scrollToMessage("next", dialog),
+    },
+    {
+      title: "Previous message",
+      value: "session.message.previous",
+      keybind: "messages_previous",
+      category: "Session",
+      disabled: true,
+      onSelect: (dialog) => scrollToMessage("prev", dialog),
+    },
     {
       title: "Copy last assistant message",
       value: "messages.copy",
@@ -811,6 +873,23 @@ export function Session() {
         dialog.clear()
       },
     },
+    {
+      title: "Go to parent session",
+      value: "session.parent",
+      keybind: "session_parent",
+      category: "Session",
+      disabled: true,
+      onSelect: (dialog) => {
+        const parentID = session()?.parentID
+        if (parentID) {
+          navigate({
+            type: "session",
+            sessionID: parentID,
+          })
+        }
+        dialog.clear()
+      },
+    },
   ])
 
   const revertInfo = createMemo(() => session()?.revert)
@@ -1588,33 +1667,15 @@ ToolRegistry.register<typeof ListTool>({
 
 ToolRegistry.register<typeof TaskTool>({
   name: "task",
-  container: "inline",
+  container: "block",
   render(props) {
     const { theme } = useTheme()
     const keybind = useKeybind()
     const dialog = useDialog()
     const renderer = useRenderer()
-    const [hover, setHover] = createSignal(false)
 
     return (
-      <box
-        border={["left"]}
-        customBorderChars={SplitBorder.customBorderChars}
-        borderColor={theme.background}
-        paddingTop={1}
-        paddingBottom={1}
-        paddingLeft={2}
-        marginTop={1}
-        gap={1}
-        backgroundColor={hover() ? theme.backgroundElement : theme.backgroundPanel}
-        onMouseOver={() => setHover(true)}
-        onMouseOut={() => setHover(false)}
-        onMouseUp={() => {
-          const id = props.metadata.sessionId
-          if (renderer.getSelection()?.getSelectedText() || !id) return
-          dialog.replace(() => <DialogSubagent sessionID={id} />)
-        }}
-      >
+      <>
         <ToolTitle icon="◉" fallback="Delegating..." when={props.input.subagent_type ?? props.input.description}>
           {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "{props.input.description}"
         </ToolTitle>
@@ -1637,7 +1698,7 @@ ToolRegistry.register<typeof TaskTool>({
           {keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
           <span style={{ fg: theme.textMuted }}> to navigate between subagent sessions</span>
         </text>
-      </box>
+      </>
     )
   },
 })

+ 31 - 7
packages/opencode/src/command/template/review.txt

@@ -28,31 +28,53 @@ Use best judgement when processing input.
 
 ---
 
+## Gathering Context
+
+**Diffs alone are not enough.** After getting the diff, read the entire file(s) being modified to understand the full context. Code that looks wrong in isolation may be correct given surrounding logic—and vice versa.
+
+- Use the diff to identify which files changed
+- Read the full file to understand existing patterns, control flow, and error handling
+- Check for existing style guide or conventions files (CONVENTIONS.md, AGENTS.md, .editorconfig, etc.)
+
+---
+
 ## What to Look For
 
 **Bugs** - Your primary focus.
 - Logic errors, off-by-one mistakes, incorrect conditionals
-- Edge cases: null/empty inputs, error conditions, race conditions
+- If-else guards: missing guards, incorrect branching, unreachable code paths
+- Edge cases: null/empty/undefined inputs, error conditions, race conditions
 - Security issues: injection, auth bypass, data exposure
-- Broken error handling that swallows failures
+- Broken error handling that swallows failures, throws unexpectedly or returns error types that are not caught.
 
 **Structure** - Does the code fit the codebase?
 - Does it follow existing patterns and conventions?
 - Are there established abstractions it should use but doesn't?
+- Excessive nesting that could be flattened with early returns or extraction
 
 **Performance** - Only flag if obviously problematic.
 - O(n²) on unbounded data, N+1 queries, blocking I/O on hot paths
 
+---
+
 ## Before You Flag Something
 
-Be certain. If you're going to call something a bug, you need to be confident it actually is one.
+**Be certain.** If you're going to call something a bug, you need to be confident it actually is one.
 
 - Only review the changes - do not review pre-existing code that wasn't modified
 - Don't flag something as a bug if you're unsure - investigate first
-- Don't flag style preferences as issues
 - Don't invent hypothetical problems - if an edge case matters, explain the realistic scenario where it breaks
 - If you need more context to be sure, use the tools below to get it
 
+**Don't be a zealot about style.** When checking code against conventions:
+
+- Verify the code is *actually* in violation. Don't complain about else statements if early returns are already being used correctly.
+- Some "violations" are acceptable when they're the simplest option. A `let` statement is fine if the alternative is convoluted.
+- Excessive nesting is a legitimate concern regardless of other style choices.
+- Don't flag style preferences as issues unless they clearly violate established project conventions.
+
+---
+
 ## Tools
 
 Use these to inform your review:
@@ -63,11 +85,13 @@ Use these to inform your review:
 
 If you're uncertain about something and can't verify it with these tools, say "I'm not sure about X" rather than flagging it as a definite issue.
 
-## Tone and Approach
+---
+
+## Output
 
 1. If there is a bug, be direct and clear about why it is a bug.
-2. You should clearly communicate severity of issues, do not claim issues are more severe than they actually are.
+2. Clearly communicate severity of issues. Do not overstate severity.
 3. Critiques should clearly and explicitly communicate the scenarios, environments, or inputs that are necessary for the bug to arise. The comment should immediately indicate that the issue's severity depends on these factors.
 4. Your tone should be matter-of-fact and not accusatory or overly positive. It should read as a helpful AI assistant suggestion without sounding too much like a human reviewer.
-5. Write in a manner that allows reader to quickly understand issue without reading too closely.
+5. Write so the reader can quickly understand the issue without reading too closely.
 6. AVOID flattery, do not give any comments that are not helpful to the reader. Avoid phrasing like "Great job ...", "Thanks for ...".

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

@@ -414,6 +414,7 @@ export namespace Config {
         .object({
           edit: Permission.optional(),
           bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
+          skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
           webfetch: Permission.optional(),
           doom_loop: Permission.optional(),
           external_directory: Permission.optional(),
@@ -456,6 +457,8 @@ export namespace Config {
         .describe("Scroll messages down by half page"),
       messages_first: z.string().optional().default("ctrl+g,home").describe("Navigate to first message"),
       messages_last: z.string().optional().default("ctrl+alt+g,end").describe("Navigate to last message"),
+      messages_next: z.string().optional().default("none").describe("Navigate to next message"),
+      messages_previous: z.string().optional().default("none").describe("Navigate to previous message"),
       messages_last_user: z.string().optional().default("none").describe("Navigate to last user message"),
       messages_copy: z.string().optional().default("<leader>y").describe("Copy message"),
       messages_undo: z.string().optional().default("<leader>u").describe("Undo message"),
@@ -560,6 +563,7 @@ export namespace Config {
       history_next: z.string().optional().default("down").describe("Next history item"),
       session_child_cycle: z.string().optional().default("<leader>right").describe("Next child session"),
       session_child_cycle_reverse: z.string().optional().default("<leader>left").describe("Previous child session"),
+      session_parent: z.string().optional().default("<leader>up").describe("Go to parent session"),
       terminal_suspend: z.string().optional().default("ctrl+z").describe("Suspend terminal"),
       terminal_title_toggle: z.string().optional().default("none").describe("Toggle terminal title"),
     })
@@ -761,6 +765,7 @@ export namespace Config {
         .object({
           edit: Permission.optional(),
           bash: z.union([Permission, z.record(z.string(), Permission)]).optional(),
+          skill: z.union([Permission, z.record(z.string(), Permission)]).optional(),
           webfetch: Permission.optional(),
           doom_loop: Permission.optional(),
           external_directory: Permission.optional(),

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

@@ -30,6 +30,7 @@ export namespace Flag {
   export const OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX = number("OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX")
   export const OPENCODE_EXPERIMENTAL_OXFMT = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_OXFMT")
   export const OPENCODE_EXPERIMENTAL_LSP_TY = truthy("OPENCODE_EXPERIMENTAL_LSP_TY")
+  export const OPENCODE_EXPERIMENTAL_LSP_TOOL = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_LSP_TOOL")
 
   function truthy(key: string) {
     const value = process.env[key]?.toLowerCase()

+ 7 - 8
packages/opencode/src/installation/index.ts

@@ -1,5 +1,4 @@
 import { BusEvent } from "@/bus/bus-event"
-import { Bus } from "@/bus"
 import path from "path"
 import { $ } from "bun"
 import z from "zod"
@@ -66,23 +65,23 @@ export namespace Installation {
     const checks = [
       {
         name: "npm" as const,
-        command: () => $`npm list -g --depth=0`.throws(false).text(),
+        command: () => $`npm list -g --depth=0`.throws(false).quiet().text(),
       },
       {
         name: "yarn" as const,
-        command: () => $`yarn global list`.throws(false).text(),
+        command: () => $`yarn global list`.throws(false).quiet().text(),
       },
       {
         name: "pnpm" as const,
-        command: () => $`pnpm list -g --depth=0`.throws(false).text(),
+        command: () => $`pnpm list -g --depth=0`.throws(false).quiet().text(),
       },
       {
         name: "bun" as const,
-        command: () => $`bun pm ls -g`.throws(false).text(),
+        command: () => $`bun pm ls -g`.throws(false).quiet().text(),
       },
       {
         name: "brew" as const,
-        command: () => $`brew list --formula opencode`.throws(false).text(),
+        command: () => $`brew list --formula opencode`.throws(false).quiet().text(),
       },
     ]
 
@@ -112,9 +111,9 @@ export namespace Installation {
   )
 
   async function getBrewFormula() {
-    const tapFormula = await $`brew list --formula sst/tap/opencode`.throws(false).text()
+    const tapFormula = await $`brew list --formula sst/tap/opencode`.throws(false).quiet().text()
     if (tapFormula.includes("opencode")) return "sst/tap/opencode"
-    const coreFormula = await $`brew list --formula opencode`.throws(false).text()
+    const coreFormula = await $`brew list --formula opencode`.throws(false).quiet().text()
     if (coreFormula.includes("opencode")) return "opencode"
     return "opencode"
   }

+ 114 - 21
packages/opencode/src/lsp/index.ts

@@ -261,23 +261,36 @@ export namespace LSP {
     return result
   }
 
+  export async function hasClients(file: string) {
+    const s = await state()
+    const extension = path.parse(file).ext || file
+    for (const server of Object.values(s.servers)) {
+      if (server.extensions.length && !server.extensions.includes(extension)) continue
+      const root = await server.root(file)
+      if (!root) continue
+      if (s.broken.has(root + server.id)) continue
+      return true
+    }
+    return false
+  }
+
   export async function touchFile(input: string, waitForDiagnostics?: boolean) {
     log.info("touching file", { file: input })
     const clients = await getClients(input)
-    await run(async (client) => {
-      if (!clients.includes(client)) return
-      const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
-      await client.notify.open({ path: input })
-
-      return wait
-    }).catch((err) => {
+    await Promise.all(
+      clients.map(async (client) => {
+        const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
+        await client.notify.open({ path: input })
+        return wait
+      }),
+    ).catch((err) => {
       log.error("failed to touch file", { err, file: input })
     })
   }
 
   export async function diagnostics() {
     const results: Record<string, LSPClient.Diagnostic[]> = {}
-    for (const result of await run(async (client) => client.diagnostics)) {
+    for (const result of await runAll(async (client) => client.diagnostics)) {
       for (const [path, diagnostics] of result.entries()) {
         const arr = results[path] || []
         arr.push(...diagnostics)
@@ -288,16 +301,18 @@ export namespace LSP {
   }
 
   export async function hover(input: { file: string; line: number; character: number }) {
-    return run((client) => {
-      return client.connection.sendRequest("textDocument/hover", {
-        textDocument: {
-          uri: pathToFileURL(input.file).href,
-        },
-        position: {
-          line: input.line,
-          character: input.character,
-        },
-      })
+    return run(input.file, (client) => {
+      return client.connection
+        .sendRequest("textDocument/hover", {
+          textDocument: {
+            uri: pathToFileURL(input.file).href,
+          },
+          position: {
+            line: input.line,
+            character: input.character,
+          },
+        })
+        .catch(() => null)
     })
   }
 
@@ -342,7 +357,7 @@ export namespace LSP {
   ]
 
   export async function workspaceSymbol(query: string) {
-    return run((client) =>
+    return runAll((client) =>
       client.connection
         .sendRequest("workspace/symbol", {
           query,
@@ -354,7 +369,8 @@ export namespace LSP {
   }
 
   export async function documentSymbol(uri: string) {
-    return run((client) =>
+    const file = new URL(uri).pathname
+    return run(file, (client) =>
       client.connection
         .sendRequest("textDocument/documentSymbol", {
           textDocument: {
@@ -367,12 +383,89 @@ export namespace LSP {
       .then((result) => result.filter(Boolean))
   }
 
-  async function run<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
+  export async function definition(input: { file: string; line: number; character: number }) {
+    return run(input.file, (client) =>
+      client.connection
+        .sendRequest("textDocument/definition", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+        })
+        .catch(() => null),
+    ).then((result) => result.flat().filter(Boolean))
+  }
+
+  export async function references(input: { file: string; line: number; character: number }) {
+    return run(input.file, (client) =>
+      client.connection
+        .sendRequest("textDocument/references", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+          context: { includeDeclaration: true },
+        })
+        .catch(() => []),
+    ).then((result) => result.flat().filter(Boolean))
+  }
+
+  export async function implementation(input: { file: string; line: number; character: number }) {
+    return run(input.file, (client) =>
+      client.connection
+        .sendRequest("textDocument/implementation", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+        })
+        .catch(() => null),
+    ).then((result) => result.flat().filter(Boolean))
+  }
+
+  export async function prepareCallHierarchy(input: { file: string; line: number; character: number }) {
+    return run(input.file, (client) =>
+      client.connection
+        .sendRequest("textDocument/prepareCallHierarchy", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+        })
+        .catch(() => []),
+    ).then((result) => result.flat().filter(Boolean))
+  }
+
+  export async function incomingCalls(input: { file: string; line: number; character: number }) {
+    return run(input.file, async (client) => {
+      const items = (await client.connection
+        .sendRequest("textDocument/prepareCallHierarchy", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+        })
+        .catch(() => [])) as any[]
+      if (!items?.length) return []
+      return client.connection.sendRequest("callHierarchy/incomingCalls", { item: items[0] }).catch(() => [])
+    }).then((result) => result.flat().filter(Boolean))
+  }
+
+  export async function outgoingCalls(input: { file: string; line: number; character: number }) {
+    return run(input.file, async (client) => {
+      const items = (await client.connection
+        .sendRequest("textDocument/prepareCallHierarchy", {
+          textDocument: { uri: pathToFileURL(input.file).href },
+          position: { line: input.line, character: input.character },
+        })
+        .catch(() => [])) as any[]
+      if (!items?.length) return []
+      return client.connection.sendRequest("callHierarchy/outgoingCalls", { item: items[0] }).catch(() => [])
+    }).then((result) => result.flat().filter(Boolean))
+  }
+
+  async function runAll<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
     const clients = await state().then((x) => x.clients)
     const tasks = clients.map((x) => input(x))
     return Promise.all(tasks)
   }
 
+  async function run<T>(file: string, input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
+    const clients = await getClients(file)
+    const tasks = clients.map((x) => input(x))
+    return Promise.all(tasks)
+  }
+
   export namespace Diagnostic {
     export function pretty(diagnostic: LSPClient.Diagnostic) {
       const severityMap = {

+ 4 - 0
packages/opencode/src/lsp/language.ts

@@ -25,6 +25,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
   ".ex": "elixir",
   ".exs": "elixir",
   ".erl": "erlang",
+  ".ets": "typescript",
   ".hrl": "erlang",
   ".fs": "fsharp",
   ".fsi": "fsharp",
@@ -109,4 +110,7 @@ export const LANGUAGE_EXTENSIONS: Record<string, string> = {
   ".tf": "terraform",
   ".tfvars": "terraform-vars",
   ".hcl": "hcl",
+  ".nix": "nix",
+  ".typ": "typst",
+  ".typc": "typst",
 } as const

+ 153 - 7
packages/opencode/src/lsp/server.ts

@@ -697,7 +697,7 @@ export namespace LSPServer {
             })
           if (!ok) return
         } else {
-          await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).nothrow()
+          await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
         }
 
         await fs.rm(tempPath, { force: true })
@@ -710,7 +710,7 @@ export namespace LSPServer {
         }
 
         if (platform !== "win32") {
-          await $`chmod +x ${bin}`.nothrow()
+          await $`chmod +x ${bin}`.quiet().nothrow()
         }
 
         log.info(`installed zls`, { bin })
@@ -1003,7 +1003,7 @@ export namespace LSPServer {
         if (!ok) return
       }
       if (tar) {
-        await $`tar -xf ${archive}`.cwd(Global.Path.bin).nothrow()
+        await $`tar -xf ${archive}`.cwd(Global.Path.bin).quiet().nothrow()
       }
       await fs.rm(archive, { force: true })
 
@@ -1014,7 +1014,7 @@ export namespace LSPServer {
       }
 
       if (platform !== "win32") {
-        await $`chmod +x ${bin}`.nothrow()
+        await $`chmod +x ${bin}`.quiet().nothrow()
       }
 
       await fs.unlink(path.join(Global.Path.bin, "clangd")).catch(() => {})
@@ -1580,7 +1580,7 @@ export namespace LSPServer {
         }
 
         if (platform !== "win32") {
-          await $`chmod +x ${bin}`.nothrow()
+          await $`chmod +x ${bin}`.quiet().nothrow()
         }
 
         log.info(`installed terraform-ls`, { bin })
@@ -1663,7 +1663,7 @@ export namespace LSPServer {
           if (!ok) return
         }
         if (ext === "tar.gz") {
-          await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).nothrow()
+          await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).quiet().nothrow()
         }
 
         await fs.rm(tempPath, { force: true })
@@ -1676,7 +1676,7 @@ export namespace LSPServer {
         }
 
         if (platform !== "win32") {
-          await $`chmod +x ${bin}`.nothrow()
+          await $`chmod +x ${bin}`.quiet().nothrow()
         }
 
         log.info("installed texlab", { bin })
@@ -1746,4 +1746,150 @@ export namespace LSPServer {
       }
     },
   }
+
+  export const Clojure: Info = {
+    id: "clojure-lsp",
+    extensions: [".clj", ".cljs", ".cljc", ".edn"],
+    root: NearestRoot(["deps.edn", "project.clj", "shadow-cljs.edn", "bb.edn", "build.boot"]),
+    async spawn(root) {
+      let bin = Bun.which("clojure-lsp")
+      if (!bin && process.platform === "win32") {
+        bin = Bun.which("clojure-lsp.exe")
+      }
+      if (!bin) {
+        log.info("clojure-lsp not found, please install clojure-lsp first")
+        return
+      }
+      return {
+        process: spawn(bin, ["listen"], {
+          cwd: root,
+        }),
+      }
+    },
+  }
+
+  export const Nixd: Info = {
+    id: "nixd",
+    extensions: [".nix"],
+    root: async (file) => {
+      // First, look for flake.nix - the most reliable Nix project root indicator
+      const flakeRoot = await NearestRoot(["flake.nix"])(file)
+      if (flakeRoot && flakeRoot !== Instance.directory) return flakeRoot
+
+      // If no flake.nix, fall back to git repository root
+      if (Instance.worktree && Instance.worktree !== Instance.directory) return Instance.worktree
+
+      // Finally, use the instance directory as fallback
+      return Instance.directory
+    },
+    async spawn(root) {
+      const nixd = Bun.which("nixd")
+      if (!nixd) {
+        log.info("nixd not found, please install nixd first")
+        return
+      }
+      return {
+        process: spawn(nixd, [], {
+          cwd: root,
+          env: {
+            ...process.env,
+          },
+        }),
+      }
+    },
+  }
+
+  export const Tinymist: Info = {
+    id: "tinymist",
+    extensions: [".typ", ".typc"],
+    root: NearestRoot(["typst.toml"]),
+    async spawn(root) {
+      let bin = Bun.which("tinymist", {
+        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
+      })
+
+      if (!bin) {
+        if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+        log.info("downloading tinymist from GitHub releases")
+
+        const response = await fetch("https://api.github.com/repos/Myriad-Dreamin/tinymist/releases/latest")
+        if (!response.ok) {
+          log.error("Failed to fetch tinymist release info")
+          return
+        }
+
+        const release = (await response.json()) as {
+          tag_name?: string
+          assets?: { name?: string; browser_download_url?: string }[]
+        }
+
+        const platform = process.platform
+        const arch = process.arch
+
+        const tinymistArch = arch === "arm64" ? "aarch64" : "x86_64"
+        let tinymistPlatform: string
+        let ext: string
+
+        if (platform === "darwin") {
+          tinymistPlatform = "apple-darwin"
+          ext = "tar.gz"
+        } else if (platform === "win32") {
+          tinymistPlatform = "pc-windows-msvc"
+          ext = "zip"
+        } else {
+          tinymistPlatform = "unknown-linux-gnu"
+          ext = "tar.gz"
+        }
+
+        const assetName = `tinymist-${tinymistArch}-${tinymistPlatform}.${ext}`
+
+        const assets = release.assets ?? []
+        const asset = assets.find((a) => a.name === assetName)
+        if (!asset?.browser_download_url) {
+          log.error(`Could not find asset ${assetName} in tinymist release`)
+          return
+        }
+
+        const downloadResponse = await fetch(asset.browser_download_url)
+        if (!downloadResponse.ok) {
+          log.error("Failed to download tinymist")
+          return
+        }
+
+        const tempPath = path.join(Global.Path.bin, assetName)
+        await Bun.file(tempPath).write(downloadResponse)
+
+        if (ext === "zip") {
+          const ok = await Archive.extractZip(tempPath, Global.Path.bin)
+            .then(() => true)
+            .catch((error) => {
+              log.error("Failed to extract tinymist archive", { error })
+              return false
+            })
+          if (!ok) return
+        } else {
+          await $`tar -xzf ${tempPath} --strip-components=1`.cwd(Global.Path.bin).quiet().nothrow()
+        }
+
+        await fs.rm(tempPath, { force: true })
+
+        bin = path.join(Global.Path.bin, "tinymist" + (platform === "win32" ? ".exe" : ""))
+
+        if (!(await Bun.file(bin).exists())) {
+          log.error("Failed to extract tinymist binary")
+          return
+        }
+
+        if (platform !== "win32") {
+          await $`chmod +x ${bin}`.quiet().nothrow()
+        }
+
+        log.info("installed tinymist", { bin })
+      }
+
+      return {
+        process: spawn(bin, { cwd: root }),
+      }
+    },
+  }
 }

+ 74 - 43
packages/opencode/src/mcp/index.ts

@@ -1,14 +1,16 @@
-import { type Tool } from "ai"
-import { experimental_createMCPClient } from "@ai-sdk/mcp"
+import { dynamicTool, type Tool, jsonSchema, type JSONSchema7 } from "ai"
+import { Client } from "@modelcontextprotocol/sdk/client/index.js"
 import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
 import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"
 import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
 import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js"
+import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js"
 import { Config } from "../config/config"
 import { Log } from "../util/log"
 import { NamedError } from "@opencode-ai/util/error"
 import z from "zod/v4"
 import { Instance } from "../project/instance"
+import { Installation } from "../installation"
 import { withTimeout } from "@/util/timeout"
 import { McpOAuthProvider } from "./oauth-provider"
 import { McpOAuthCallback } from "./oauth-callback"
@@ -25,7 +27,7 @@ export namespace MCP {
     }),
   )
 
-  type Client = Awaited<ReturnType<typeof experimental_createMCPClient>>
+  type MCPClient = Client
 
   export const Status = z
     .discriminatedUnion("status", [
@@ -71,7 +73,30 @@ export namespace MCP {
       ref: "MCPStatus",
     })
   export type Status = z.infer<typeof Status>
-  type MCPClient = Awaited<ReturnType<typeof experimental_createMCPClient>>
+
+  // Convert MCP tool definition to AI SDK Tool type
+  function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Tool {
+    const inputSchema = mcpTool.inputSchema
+
+    // Spread first, then override type to ensure it's always "object"
+    const schema: JSONSchema7 = {
+      ...(inputSchema as JSONSchema7),
+      type: "object",
+      properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
+      additionalProperties: false,
+    }
+
+    return dynamicTool({
+      description: mcpTool.description ?? "",
+      inputSchema: jsonSchema(schema),
+      execute: async (args: unknown) => {
+        return client.callTool({
+          name: mcpTool.name,
+          arguments: args as Record<string, unknown>,
+        })
+      },
+    })
+  }
 
   // Store transports for OAuth servers to allow finishing auth
   type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
@@ -81,7 +106,7 @@ export namespace MCP {
     async () => {
       const cfg = await Config.get()
       const config = cfg.mcp ?? {}
-      const clients: Record<string, Client> = {}
+      const clients: Record<string, MCPClient> = {}
       const status: Record<string, Status> = {}
 
       await Promise.all(
@@ -204,10 +229,12 @@ export namespace MCP {
       let lastError: Error | undefined
       for (const { name, transport } of transports) {
         try {
-          mcpClient = await experimental_createMCPClient({
+          const client = new Client({
             name: "opencode",
-            transport,
+            version: Installation.VERSION,
           })
+          await client.connect(transport)
+          mcpClient = client
           log.info("connected", { key, transport: name })
           status = { status: "connected" }
           break
@@ -248,36 +275,38 @@ export namespace MCP {
 
     if (mcp.type === "local") {
       const [cmd, ...args] = mcp.command
-      await experimental_createMCPClient({
-        name: "opencode",
-        transport: new StdioClientTransport({
-          stderr: "ignore",
-          command: cmd,
-          args,
-          env: {
-            ...process.env,
-            ...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
-            ...mcp.environment,
-          },
-        }),
+      const transport = new StdioClientTransport({
+        stderr: "ignore",
+        command: cmd,
+        args,
+        env: {
+          ...process.env,
+          ...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
+          ...mcp.environment,
+        },
       })
-        .then((client) => {
-          mcpClient = client
-          status = {
-            status: "connected",
-          }
+
+      try {
+        const client = new Client({
+          name: "opencode",
+          version: Installation.VERSION,
         })
-        .catch((error) => {
-          log.error("local mcp startup failed", {
-            key,
-            command: mcp.command,
-            error: error instanceof Error ? error.message : String(error),
-          })
-          status = {
-            status: "failed" as const,
-            error: error instanceof Error ? error.message : String(error),
-          }
+        await client.connect(transport)
+        mcpClient = client
+        status = {
+          status: "connected",
+        }
+      } catch (error) {
+        log.error("local mcp startup failed", {
+          key,
+          command: mcp.command,
+          error: error instanceof Error ? error.message : String(error),
         })
+        status = {
+          status: "failed" as const,
+          error: error instanceof Error ? error.message : String(error),
+        }
+      }
     }
 
     if (!status) {
@@ -294,7 +323,7 @@ export namespace MCP {
       }
     }
 
-    const result = await withTimeout(mcpClient.tools(), mcp.timeout ?? 5000).catch((err) => {
+    const result = await withTimeout(mcpClient.listTools(), mcp.timeout ?? 5000).catch((err) => {
       log.error("failed to get tools from client", { key, error: err })
       return undefined
     })
@@ -317,7 +346,7 @@ export namespace MCP {
       }
     }
 
-    log.info("create() successfully created client", { key, toolCount: Object.keys(result).length })
+    log.info("create() successfully created client", { key, toolCount: result.tools.length })
     return {
       mcpClient,
       status,
@@ -392,7 +421,7 @@ export namespace MCP {
         continue
       }
 
-      const tools = await client.tools().catch((e) => {
+      const toolsResult = await client.listTools().catch((e) => {
         log.error("failed to get tools", { clientName, error: e.message })
         const failedStatus = {
           status: "failed" as const,
@@ -400,14 +429,15 @@ export namespace MCP {
         }
         s.status[clientName] = failedStatus
         delete s.clients[clientName]
+        return undefined
       })
-      if (!tools) {
+      if (!toolsResult) {
         continue
       }
-      for (const [toolName, tool] of Object.entries(tools)) {
+      for (const mcpTool of toolsResult.tools) {
         const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
-        const sanitizedToolName = toolName.replace(/[^a-zA-Z0-9_-]/g, "_")
-        result[sanitizedClientName + "_" + sanitizedToolName] = tool
+        const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
+        result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client)
       }
     }
     return result
@@ -469,10 +499,11 @@ export namespace MCP {
 
     // Try to connect - this will trigger the OAuth flow
     try {
-      await experimental_createMCPClient({
+      const client = new Client({
         name: "opencode",
-        transport,
+        version: Installation.VERSION,
       })
+      await client.connect(transport)
       // If we get here, we're already authenticated
       return { authorizationUrl: "" }
     } catch (error) {

+ 20 - 0
packages/opencode/src/provider/provider.ts

@@ -25,6 +25,15 @@ import { createOpenAI } from "@ai-sdk/openai"
 import { createOpenAICompatible } from "@ai-sdk/openai-compatible"
 import { createOpenRouter, type LanguageModelV2 } from "@openrouter/ai-sdk-provider"
 import { createOpenaiCompatible as createGitHubCopilotOpenAICompatible } from "./sdk/openai-compatible/src"
+import { createXai } from "@ai-sdk/xai"
+import { createMistral } from "@ai-sdk/mistral"
+import { createGroq } from "@ai-sdk/groq"
+import { createDeepInfra } from "@ai-sdk/deepinfra"
+import { createCerebras } from "@ai-sdk/cerebras"
+import { createCohere } from "@ai-sdk/cohere"
+import { createGateway } from "@ai-sdk/gateway"
+import { createTogetherAI } from "@ai-sdk/togetherai"
+import { createPerplexity } from "@ai-sdk/perplexity"
 
 export namespace Provider {
   const log = Log.create({ service: "provider" })
@@ -39,6 +48,15 @@ export namespace Provider {
     "@ai-sdk/openai": createOpenAI,
     "@ai-sdk/openai-compatible": createOpenAICompatible,
     "@openrouter/ai-sdk-provider": createOpenRouter,
+    "@ai-sdk/xai": createXai,
+    "@ai-sdk/mistral": createMistral,
+    "@ai-sdk/groq": createGroq,
+    "@ai-sdk/deepinfra": createDeepInfra,
+    "@ai-sdk/cerebras": createCerebras,
+    "@ai-sdk/cohere": createCohere,
+    "@ai-sdk/gateway": createGateway,
+    "@ai-sdk/togetherai": createTogetherAI,
+    "@ai-sdk/perplexity": createPerplexity,
     // @ts-ignore (TODO: kill this code so we dont have to maintain it)
     "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
   }
@@ -67,6 +85,8 @@ export namespace Provider {
         const env = Env.all()
         if (input.env.some((item) => env[item])) return true
         if (await Auth.get(input.id)) return true
+        const config = await Config.get()
+        if (config.provider?.["opencode"]?.options?.apiKey) return true
         return false
       })()
 

+ 10 - 6
packages/opencode/src/provider/transform.ts

@@ -216,24 +216,28 @@ export namespace ProviderTransform {
     if (id.includes("claude")) return undefined
     if (id.includes("gemini-3-pro")) return 1.0
     if (id.includes("glm-4.6")) return 1.0
+    if (id.includes("glm-4.7")) return 1.0
     if (id.includes("minimax-m2")) return 1.0
-    // if (id.includes("kimi-k2")) {
-    //   if (id.includes("thinking")) return 1.0
-    //   return 0.6
-    // }
+    if (id.includes("kimi-k2")) {
+      if (id.includes("thinking")) return 1.0
+      return 0.6
+    }
     return undefined
   }
 
   export function topP(model: Provider.Model) {
     const id = model.id.toLowerCase()
     if (id.includes("qwen")) return 1
-    if (id.includes("minimax-m2")) return 0.95
+    if (id.includes("minimax-m2")) {
+      if (id.includes("m2.1")) return 0.9
+      return 0.95
+    }
     return undefined
   }
 
   export function topK(model: Provider.Model) {
     const id = model.id.toLowerCase()
-    if (id.includes("minimax-m2")) return 40
+    if (id.includes("minimax-m2")) return 20
     return undefined
   }
 

+ 12 - 5
packages/opencode/src/server/server.ts

@@ -1054,6 +1054,7 @@ export namespace Server {
           z.object({
             providerID: z.string(),
             modelID: z.string(),
+            auto: z.boolean().optional().default(false),
           }),
         ),
         async (c) => {
@@ -1075,7 +1076,7 @@ export namespace Server {
               providerID: body.providerID,
               modelID: body.modelID,
             },
-            auto: false,
+            auto: body.auto,
           })
           await SessionPrompt.loop(sessionID)
           return c.json(true)
@@ -2601,13 +2602,19 @@ export namespace Server {
   }
 
   export function listen(opts: { port: number; hostname: string }) {
-    const server = Bun.serve({
-      port: opts.port,
+    const args = {
       hostname: opts.hostname,
       idleTimeout: 0,
       fetch: App().fetch,
       websocket: websocket,
-    })
-    return server
+    } as const
+    if (opts.port === 0) {
+      try {
+        return Bun.serve({ ...args, port: 4096 })
+      } catch {
+        // port 4096 not available, fall through to use port 0
+      }
+    }
+    return Bun.serve({ ...args, port: opts.port })
   }
 }

+ 4 - 0
packages/opencode/src/session/compaction.ts

@@ -40,6 +40,8 @@ export namespace SessionCompaction {
   export const PRUNE_MINIMUM = 20_000
   export const PRUNE_PROTECT = 40_000
 
+  const PRUNE_PROTECTED_TOOLS = ["skill"]
+
   // goes backwards through parts until there are 40_000 tokens worth of tool
   // calls. then erases output of previous tool calls. idea is to throw away old
   // tool calls that are no longer relevant.
@@ -61,6 +63,8 @@ export namespace SessionCompaction {
         const part = msg.parts[partIndex]
         if (part.type === "tool")
           if (part.state.status === "completed") {
+            if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
+
             if (part.state.time.compacted) break loop
             const estimate = Token.estimate(part.state.output)
             total += estimate

+ 14 - 0
packages/opencode/src/session/processor.ts

@@ -365,6 +365,20 @@ export namespace SessionProcessor {
               error: input.assistantMessage.error,
             })
           }
+          if (snapshot) {
+            const patch = await Snapshot.patch(snapshot)
+            if (patch.files.length) {
+              await Session.updatePart({
+                id: Identifier.ascending("part"),
+                messageID: input.assistantMessage.id,
+                sessionID: input.sessionID,
+                type: "patch",
+                hash: patch.hash,
+                files: patch.files,
+              })
+            }
+            snapshot = undefined
+          }
           const p = await MessageV2.parts(input.assistantMessage.id)
           for (const part of p) {
             if (part.type === "tool" && part.state.status !== "completed" && part.state.status !== "error") {

+ 1 - 1
packages/opencode/src/session/prompt.ts

@@ -1309,7 +1309,7 @@ export namespace SessionPrompt {
       const results = await Promise.all(
         shell.map(async ([, cmd]) => {
           try {
-            return await $`${{ raw: cmd }}`.nothrow().text()
+            return await $`${{ raw: cmd }}`.quiet().nothrow().text()
           } catch (error) {
             return `Error executing command: ${error instanceof Error ? error.message : String(error)}`
           }

+ 1 - 1
packages/opencode/src/session/system.ts

@@ -2,6 +2,7 @@ import { Ripgrep } from "../file/ripgrep"
 import { Global } from "../global"
 import { Filesystem } from "../util/filesystem"
 import { Config } from "../config/config"
+import { Skill } from "../skill"
 
 import { Instance } from "../project/instance"
 import path from "path"
@@ -13,7 +14,6 @@ import PROMPT_POLARIS from "./prompt/polaris.txt"
 import PROMPT_BEAST from "./prompt/beast.txt"
 import PROMPT_GEMINI from "./prompt/gemini.txt"
 import PROMPT_ANTHROPIC_SPOOF from "./prompt/anthropic_spoof.txt"
-import PROMPT_COMPACTION from "./prompt/compaction.txt"
 
 import PROMPT_CODEX from "./prompt/codex.txt"
 import type { Provider } from "@/provider/provider"

+ 1 - 0
packages/opencode/src/skill/index.ts

@@ -0,0 +1 @@
+export * from "./skill"

+ 71 - 0
packages/opencode/src/skill/skill.ts

@@ -0,0 +1,71 @@
+import z from "zod"
+import { Config } from "../config/config"
+import { Instance } from "../project/instance"
+import { NamedError } from "@opencode-ai/util/error"
+import { ConfigMarkdown } from "../config/markdown"
+
+export namespace Skill {
+  export const Info = z.object({
+    name: z.string(),
+    description: z.string(),
+    location: z.string(),
+  })
+  export type Info = z.infer<typeof Info>
+
+  export const InvalidError = NamedError.create(
+    "SkillInvalidError",
+    z.object({
+      path: z.string(),
+      message: z.string().optional(),
+      issues: z.custom<z.core.$ZodIssue[]>().optional(),
+    }),
+  )
+
+  export const NameMismatchError = NamedError.create(
+    "SkillNameMismatchError",
+    z.object({
+      path: z.string(),
+      expected: z.string(),
+      actual: z.string(),
+    }),
+  )
+
+  const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
+
+  export const state = Instance.state(async () => {
+    const directories = await Config.directories()
+    const skills: Record<string, Info> = {}
+
+    for (const dir of directories) {
+      for await (const match of SKILL_GLOB.scan({
+        cwd: dir,
+        absolute: true,
+        onlyFiles: true,
+        followSymlinks: true,
+      })) {
+        const md = await ConfigMarkdown.parse(match)
+        if (!md) {
+          continue
+        }
+
+        const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
+        if (!parsed.success) continue
+        skills[parsed.data.name] = {
+          name: parsed.data.name,
+          description: parsed.data.description,
+          location: match,
+        }
+      }
+    }
+
+    return skills
+  })
+
+  export async function get(name: string) {
+    return state().then((x) => x[name])
+  }
+
+  export async function all() {
+    return state().then((x) => Object.values(x))
+  }
+}

+ 87 - 0
packages/opencode/src/tool/lsp.ts

@@ -0,0 +1,87 @@
+import z from "zod"
+import { Tool } from "./tool"
+import path from "path"
+import { LSP } from "../lsp"
+import DESCRIPTION from "./lsp.txt"
+import { Instance } from "../project/instance"
+import { pathToFileURL } from "url"
+
+const operations = [
+  "goToDefinition",
+  "findReferences",
+  "hover",
+  "documentSymbol",
+  "workspaceSymbol",
+  "goToImplementation",
+  "prepareCallHierarchy",
+  "incomingCalls",
+  "outgoingCalls",
+] as const
+
+export const LspTool = Tool.define("lsp", {
+  description: DESCRIPTION,
+  parameters: z.object({
+    operation: z.enum(operations).describe("The LSP operation to perform"),
+    filePath: z.string().describe("The absolute or relative path to the file"),
+    line: z.number().int().min(1).describe("The line number (1-based, as shown in editors)"),
+    character: z.number().int().min(1).describe("The character offset (1-based, as shown in editors)"),
+  }),
+  execute: async (args) => {
+    const file = path.isAbsolute(args.filePath) ? args.filePath : path.join(Instance.directory, args.filePath)
+    const uri = pathToFileURL(file).href
+    const position = {
+      file,
+      line: args.line - 1,
+      character: args.character - 1,
+    }
+
+    const relPath = path.relative(Instance.worktree, file)
+    const title = `${args.operation} ${relPath}:${args.line}:${args.character}`
+
+    const exists = await Bun.file(file).exists()
+    if (!exists) {
+      throw new Error(`File not found: ${file}`)
+    }
+
+    const available = await LSP.hasClients(file)
+    if (!available) {
+      throw new Error("No LSP server available for this file type.")
+    }
+
+    await LSP.touchFile(file, true)
+
+    const result: unknown[] = await (async () => {
+      switch (args.operation) {
+        case "goToDefinition":
+          return LSP.definition(position)
+        case "findReferences":
+          return LSP.references(position)
+        case "hover":
+          return LSP.hover(position)
+        case "documentSymbol":
+          return LSP.documentSymbol(uri)
+        case "workspaceSymbol":
+          return LSP.workspaceSymbol("")
+        case "goToImplementation":
+          return LSP.implementation(position)
+        case "prepareCallHierarchy":
+          return LSP.prepareCallHierarchy(position)
+        case "incomingCalls":
+          return LSP.incomingCalls(position)
+        case "outgoingCalls":
+          return LSP.outgoingCalls(position)
+      }
+    })()
+
+    const output = (() => {
+      if (result.length === 0) return `No results found for ${args.operation}`
+      return JSON.stringify(result, null, 2)
+    })()
+
+    return {
+      title,
+      metadata: { result },
+      output,
+    }
+  },
+})

+ 19 - 0
packages/opencode/src/tool/lsp.txt

@@ -0,0 +1,19 @@
+Interact with Language Server Protocol (LSP) servers to get code intelligence features.
+
+Supported operations:
+- goToDefinition: Find where a symbol is defined
+- findReferences: Find all references to a symbol
+- hover: Get hover information (documentation, type info) for a symbol
+- documentSymbol: Get all symbols (functions, classes, variables) in a document
+- workspaceSymbol: Search for symbols across the entire workspace
+- goToImplementation: Find implementations of an interface or abstract method
+- prepareCallHierarchy: Get call hierarchy item at a position (functions/methods)
+- incomingCalls: Find all functions/methods that call the function at a position
+- outgoingCalls: Find all functions/methods called by the function at a position
+
+All operations require:
+- filePath: The file to operate on
+- line: The line number (1-based, as shown in editors)
+- character: The character offset (1-based, as shown in editors)
+
+Note: LSP servers must be configured for the file type. If no server is available, an error will be returned.

+ 5 - 3
packages/opencode/src/tool/read.ts

@@ -60,10 +60,12 @@ export const ReadTool = Tool.define("read", {
     }
 
     const block = iife(() => {
-      const whitelist = [".env.sample", ".example"]
+      const basename = path.basename(filepath)
+      const whitelist = [".env.sample", ".env.example", ".example", ".env.template"]
 
-      if (whitelist.some((w) => filepath.endsWith(w))) return false
-      if (filepath.includes(".env")) return true
+      if (whitelist.some((w) => basename.endsWith(w))) return false
+      // Block .env, .env.local, .env.production, etc. but not .envrc
+      if (/^\.env(\.|$)/.test(basename)) return true
 
       return false
     })

+ 8 - 0
packages/opencode/src/tool/registry.ts

@@ -10,6 +10,7 @@ import { TodoWriteTool, TodoReadTool } from "./todo"
 import { WebFetchTool } from "./webfetch"
 import { WriteTool } from "./write"
 import { InvalidTool } from "./invalid"
+import { SkillTool } from "./skill"
 import type { Agent } from "../agent/agent"
 import { Tool } from "./tool"
 import { Instance } from "../project/instance"
@@ -22,6 +23,7 @@ import { WebSearchTool } from "./websearch"
 import { CodeSearchTool } from "./codesearch"
 import { Flag } from "@/flag/flag"
 import { Log } from "@/util/log"
+import { LspTool } from "./lsp"
 
 export namespace ToolRegistry {
   const log = Log.create({ service: "tool.registry" })
@@ -102,6 +104,8 @@ export namespace ToolRegistry {
       TodoReadTool,
       WebSearchTool,
       CodeSearchTool,
+      SkillTool,
+      ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
       ...(config.experimental?.batch_tool === true ? [BatchTool] : []),
       ...custom,
     ]
@@ -148,6 +152,10 @@ export namespace ToolRegistry {
       result["codesearch"] = false
       result["websearch"] = false
     }
+    // Disable skill tool if all skills are denied
+    if (agent.permission.skill["*"] === "deny" && Object.keys(agent.permission.skill).length === 1) {
+      result["skill"] = false
+    }
 
     return result
   }

+ 84 - 0
packages/opencode/src/tool/skill.ts

@@ -0,0 +1,84 @@
+import path from "path"
+import z from "zod"
+import { Tool } from "./tool"
+import { Skill } from "../skill"
+import { Agent } from "../agent/agent"
+import { Permission } from "../permission"
+import { Wildcard } from "../util/wildcard"
+import { ConfigMarkdown } from "../config/markdown"
+
+export const SkillTool = Tool.define("skill", async () => {
+  const skills = await Skill.all()
+  return {
+    description: [
+      "Load a skill to get detailed instructions for a specific task.",
+      "Skills provide specialized knowledge and step-by-step guidance.",
+      "Use this when a task matches an available skill's description.",
+      "<available_skills>",
+      ...skills.flatMap((skill) => [
+        `  <skill>`,
+        `    <name>${skill.name}</name>`,
+        `    <description>${skill.description}</description>`,
+        `  </skill>`,
+      ]),
+      "</available_skills>",
+    ].join(" "),
+    parameters: z.object({
+      name: z
+        .string()
+        .describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
+    }),
+    async execute(params, ctx) {
+      const agent = await Agent.get(ctx.agent)
+
+      const skill = await Skill.get(params.name)
+
+      if (!skill) {
+        const available = Skill.all().then((x) => Object.keys(x).join(", "))
+        throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
+      }
+
+      // Check permission using Wildcard.all on the skill ID
+      const permissions = agent.permission.skill
+      const action = Wildcard.all(params.name, permissions)
+
+      if (action === "deny") {
+        throw new Permission.RejectedError(
+          ctx.sessionID,
+          "skill",
+          ctx.callID,
+          { skill: params.name },
+          `Access to skill "${params.name}" is denied for agent "${agent.name}".`,
+        )
+      }
+
+      if (action === "ask") {
+        await Permission.ask({
+          type: "skill",
+          pattern: params.name,
+          sessionID: ctx.sessionID,
+          messageID: ctx.messageID,
+          callID: ctx.callID,
+          title: `Load skill: ${skill.name}`,
+          metadata: { id: params.name, name: skill.name, description: skill.description },
+        })
+      }
+
+      // Load and parse skill content
+      const parsed = await ConfigMarkdown.parse(skill.location)
+      const dir = path.dirname(skill.location)
+
+      // Format output similar to plugin pattern
+      const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")
+
+      return {
+        title: `Loaded skill: ${skill.name}`,
+        output,
+        metadata: {
+          name: skill.name,
+          dir,
+        },
+      }
+    },
+  }
+})

+ 131 - 0
packages/opencode/test/skill/skill.test.ts

@@ -0,0 +1,131 @@
+import { test, expect } from "bun:test"
+import { Skill } from "../../src/skill"
+import { SystemPrompt } from "../../src/session/system"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+import path from "path"
+
+test("discovers skills from .opencode/skill/ directory", async () => {
+  await using tmp = await tmpdir({
+    git: true,
+    init: async (dir) => {
+      const skillDir = path.join(dir, ".opencode", "skill", "test-skill")
+      await Bun.write(
+        path.join(skillDir, "SKILL.md"),
+        `---
+name: test-skill
+description: A test skill for verification.
+---
+
+# Test Skill
+
+Instructions here.
+`,
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const skills = await Skill.all()
+      expect(skills.length).toBe(1)
+      expect(skills[0].name).toBe("test-skill")
+      expect(skills[0].description).toBe("A test skill for verification.")
+      expect(skills[0].location).toContain("skill/test-skill/SKILL.md")
+    },
+  })
+})
+
+test("discovers multiple skills from .opencode/skill/ directory", async () => {
+  await using tmp = await tmpdir({
+    git: true,
+    init: async (dir) => {
+      const skillDir = path.join(dir, ".opencode", "skill", "my-skill")
+      await Bun.write(
+        path.join(skillDir, "SKILL.md"),
+        `---
+name: my-skill
+description: Another test skill.
+---
+
+# My Skill
+`,
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const skills = await Skill.all()
+      expect(skills.length).toBe(1)
+      expect(skills[0].name).toBe("my-skill")
+    },
+  })
+})
+
+test("skips skills with missing frontmatter", async () => {
+  await using tmp = await tmpdir({
+    git: true,
+    init: async (dir) => {
+      const skillDir = path.join(dir, ".opencode", "skill", "no-frontmatter")
+      await Bun.write(
+        path.join(skillDir, "SKILL.md"),
+        `# No Frontmatter
+
+Just some content without YAML frontmatter.
+`,
+      )
+    },
+  })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const skills = await Skill.all()
+      expect(skills).toEqual([])
+    },
+  })
+})
+
+test("returns empty array when no skills exist", async () => {
+  await using tmp = await tmpdir({ git: true })
+
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      const skills = await Skill.all()
+      expect(skills).toEqual([])
+    },
+  })
+})
+
+// test("discovers skills from .claude/skills/ directory", async () => {
+//   await using tmp = await tmpdir({
+//     git: true,
+//     init: async (dir) => {
+//       const skillDir = path.join(dir, ".claude", "skills", "claude-skill")
+//       await Bun.write(
+//         path.join(skillDir, "SKILL.md"),
+//         `---
+// name: claude-skill
+// description: A skill in the .claude/skills directory.
+// ---
+
+// # Claude Skill
+// `,
+//       )
+//     },
+//   })
+
+//   await Instance.provide({
+//     directory: tmp.path,
+//     fn: async () => {
+//       const skills = await Skill.all()
+//       expect(skills.length).toBe(1)
+//       expect(skills[0].name).toBe("claude-skill")
+//       expect(skills[0].location).toContain(".claude/skills/claude-skill/SKILL.md")
+//     },
+//   })
+// })

+ 42 - 0
packages/opencode/test/tool/read.test.ts

@@ -0,0 +1,42 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import { ReadTool } from "../../src/tool/read"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+const ctx = {
+  sessionID: "test",
+  messageID: "",
+  callID: "",
+  agent: "build",
+  abort: AbortSignal.any([]),
+  metadata: () => {},
+}
+
+describe("tool.read env file blocking", () => {
+  test.each([
+    [".env", true],
+    [".env.local", true],
+    [".env.production", true],
+    [".env.sample", false],
+    [".env.example", false],
+    [".envrc", false],
+    ["environment.ts", false],
+  ])("%s blocked=%s", async (filename, blocked) => {
+    await using tmp = await tmpdir({
+      init: (dir) => Bun.write(path.join(dir, filename), "content"),
+    })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const read = await ReadTool.init()
+        const promise = read.execute({ filePath: path.join(tmp.path, filename) }, ctx)
+        if (blocked) {
+          await expect(promise).rejects.toThrow("blocked")
+        } else {
+          expect((await promise).output).toContain("content")
+        }
+      },
+    })
+  })
+})

+ 1 - 1
packages/plugin/package.json

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/plugin",
-  "version": "1.0.182",
+  "version": "1.0.190",
   "type": "module",
   "scripts": {
     "typecheck": "tsgo --noEmit",

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

@@ -1,7 +1,7 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
   "name": "@opencode-ai/sdk",
-  "version": "1.0.182",
+  "version": "1.0.190",
   "type": "module",
   "scripts": {
     "typecheck": "tsgo --noEmit",

+ 8 - 0
packages/sdk/js/src/gen/types.gen.ts

@@ -858,6 +858,14 @@ export type KeybindsConfig = {
    * Navigate to last message
    */
   messages_last?: string
+  /**
+   * Navigate to next message
+   */
+  messages_next?: string
+  /**
+   * Navigate to previous message
+   */
+  messages_previous?: string
   /**
    * Navigate to last user message
    */

+ 2 - 0
packages/sdk/js/src/v2/gen/sdk.gen.ts

@@ -1132,6 +1132,7 @@ export class Session extends HeyApiClient {
       directory?: string
       providerID?: string
       modelID?: string
+      auto?: boolean
     },
     options?: Options<never, ThrowOnError>,
   ) {
@@ -1144,6 +1145,7 @@ export class Session extends HeyApiClient {
             { in: "query", key: "directory" },
             { in: "body", key: "providerID" },
             { in: "body", key: "modelID" },
+            { in: "body", key: "auto" },
           ],
         },
       ],

+ 37 - 0
packages/sdk/js/src/v2/gen/types.gen.ts

@@ -890,6 +890,14 @@ export type KeybindsConfig = {
    * Navigate to last message
    */
   messages_last?: string
+  /**
+   * Navigate to next message
+   */
+  messages_next?: string
+  /**
+   * Navigate to previous message
+   */
+  messages_previous?: string
   /**
    * Navigate to last user message
    */
@@ -1114,6 +1122,10 @@ export type KeybindsConfig = {
    * Previous child session
    */
   session_child_cycle_reverse?: string
+  /**
+   * Go to parent session
+   */
+  session_parent?: string
   /**
    * Suspend terminal
    */
@@ -1155,6 +1167,13 @@ export type AgentConfig = {
       | {
           [key: string]: "ask" | "allow" | "deny"
         }
+    skill?:
+      | "ask"
+      | "allow"
+      | "deny"
+      | {
+          [key: string]: "ask" | "allow" | "deny"
+        }
     webfetch?: "ask" | "allow" | "deny"
     doom_loop?: "ask" | "allow" | "deny"
     external_directory?: "ask" | "allow" | "deny"
@@ -1181,6 +1200,13 @@ export type AgentConfig = {
           | {
               [key: string]: "ask" | "allow" | "deny"
             }
+        skill?:
+          | "ask"
+          | "allow"
+          | "deny"
+          | {
+              [key: string]: "ask" | "allow" | "deny"
+            }
         webfetch?: "ask" | "allow" | "deny"
         doom_loop?: "ask" | "allow" | "deny"
         external_directory?: "ask" | "allow" | "deny"
@@ -1500,6 +1526,13 @@ export type Config = {
       | {
           [key: string]: "ask" | "allow" | "deny"
         }
+    skill?:
+      | "ask"
+      | "allow"
+      | "deny"
+      | {
+          [key: string]: "ask" | "allow" | "deny"
+        }
     webfetch?: "ask" | "allow" | "deny"
     doom_loop?: "ask" | "allow" | "deny"
     external_directory?: "ask" | "allow" | "deny"
@@ -1780,6 +1813,9 @@ export type Agent = {
     bash: {
       [key: string]: "ask" | "allow" | "deny"
     }
+    skill: {
+      [key: string]: "ask" | "allow" | "deny"
+    }
     webfetch?: "ask" | "allow" | "deny"
     doom_loop?: "ask" | "allow" | "deny"
     external_directory?: "ask" | "allow" | "deny"
@@ -2750,6 +2786,7 @@ export type SessionSummarizeData = {
   body?: {
     providerID: string
     modelID: string
+    auto?: boolean
   }
   path: {
     /**

+ 66 - 1
packages/sdk/openapi.json

@@ -1804,6 +1804,10 @@
                   },
                   "modelID": {
                     "type": "string"
+                  },
+                  "auto": {
+                    "default": false,
+                    "type": "boolean"
                   }
                 },
                 "required": ["providerID", "modelID"]
@@ -7304,6 +7308,16 @@
             "default": "ctrl+alt+g,end",
             "type": "string"
           },
+          "messages_next": {
+            "description": "Navigate to next message",
+            "default": "none",
+            "type": "string"
+          },
+          "messages_previous": {
+            "description": "Navigate to previous message",
+            "default": "none",
+            "type": "string"
+          },
           "messages_last_user": {
             "description": "Navigate to last user message",
             "default": "none",
@@ -7584,6 +7598,11 @@
             "default": "<leader>left",
             "type": "string"
           },
+          "session_parent": {
+            "description": "Go to parent session",
+            "default": "<leader>up",
+            "type": "string"
+          },
           "terminal_suspend": {
             "description": "Suspend terminal",
             "default": "ctrl+z",
@@ -7668,6 +7687,24 @@
                   }
                 ]
               },
+              "skill": {
+                "anyOf": [
+                  {
+                    "type": "string",
+                    "enum": ["ask", "allow", "deny"]
+                  },
+                  {
+                    "type": "object",
+                    "propertyNames": {
+                      "type": "string"
+                    },
+                    "additionalProperties": {
+                      "type": "string",
+                      "enum": ["ask", "allow", "deny"]
+                    }
+                  }
+                ]
+              },
               "webfetch": {
                 "type": "string",
                 "enum": ["ask", "allow", "deny"]
@@ -8376,6 +8413,24 @@
                   }
                 ]
               },
+              "skill": {
+                "anyOf": [
+                  {
+                    "type": "string",
+                    "enum": ["ask", "allow", "deny"]
+                  },
+                  {
+                    "type": "object",
+                    "propertyNames": {
+                      "type": "string"
+                    },
+                    "additionalProperties": {
+                      "type": "string",
+                      "enum": ["ask", "allow", "deny"]
+                    }
+                  }
+                ]
+              },
               "webfetch": {
                 "type": "string",
                 "enum": ["ask", "allow", "deny"]
@@ -9185,6 +9240,16 @@
                   "enum": ["ask", "allow", "deny"]
                 }
               },
+              "skill": {
+                "type": "object",
+                "propertyNames": {
+                  "type": "string"
+                },
+                "additionalProperties": {
+                  "type": "string",
+                  "enum": ["ask", "allow", "deny"]
+                }
+              },
               "webfetch": {
                 "type": "string",
                 "enum": ["ask", "allow", "deny"]
@@ -9198,7 +9263,7 @@
                 "enum": ["ask", "allow", "deny"]
               }
             },
-            "required": ["edit", "bash"]
+            "required": ["edit", "bash", "skill"]
           },
           "model": {
             "type": "object",

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/slack",
-  "version": "1.0.182",
+  "version": "1.0.190",
   "type": "module",
   "scripts": {
     "dev": "bun run src/index.ts",

+ 1 - 1
packages/tauri/index.html

@@ -14,7 +14,7 @@
     <meta property="og:image" content="/social-share.png" />
     <meta property="twitter:image" content="/social-share.png" />
   </head>
-  <body class="antialiased overscroll-none select-none text-12-regular overflow-hidden">
+  <body class="antialiased overscroll-none text-12-regular overflow-hidden">
     <script>
       ;(function () {
         const savedTheme = localStorage.getItem("theme") || "oc-1"

+ 1 - 1
packages/tauri/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@opencode-ai/tauri",
   "private": true,
-  "version": "1.0.182",
+  "version": "1.0.190",
   "type": "module",
   "scripts": {
     "typecheck": "tsgo -b",

+ 4 - 10
packages/tauri/scripts/prepare.ts

@@ -1,21 +1,15 @@
 #!/usr/bin/env bun
-
 import { $ } from "bun"
 
 import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"
-import { Script } from "@opencode-ai/script"
 
 const sidecarConfig = getCurrentSidecar()
 
 const dir = "src-tauri/target/opencode-binaries"
 
 await $`mkdir -p ${dir}`
-await $`gh release download v${Script.version} --pattern ${sidecarConfig.ocBinary}.${sidecarConfig.assetExt} --repo sst/opencode --skip-existing --dir ${dir}`
-
-if (sidecarConfig.assetExt === "tar.gz") {
-  await $`tar -xvzf ${dir}/${sidecarConfig.ocBinary}.${sidecarConfig.assetExt} -C ${dir}`
-} else {
-  await $`unzip -o ${dir}/${sidecarConfig.ocBinary}.${sidecarConfig.assetExt} -d ${dir}`
-}
+await $`gh run download ${Bun.env.GITHUB_RUN_ID} -n opencode-cli`.cwd(dir)
 
-await copyBinaryToSidecarFolder(`${dir}/opencode${process.platform === "win32" ? ".exe" : ""}`)
+await copyBinaryToSidecarFolder(
+  `${dir}/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`,
+)

+ 5 - 0
packages/tauri/scripts/utils.ts

@@ -21,6 +21,11 @@ export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; ass
     ocBinary: "opencode-linux-x64",
     assetExt: "tar.gz",
   },
+  {
+    rustTarget: "aarch64-unknown-linux-gnu",
+    ocBinary: "opencode-linux-arm64",
+    assetExt: "tar.gz",
+  },
 ]
 
 export const RUST_TARGET = Bun.env.RUST_TARGET

+ 8 - 1
packages/tauri/src-tauri/src/lib.rs

@@ -6,7 +6,7 @@ use std::{
     sync::{Arc, Mutex},
     time::{Duration, Instant},
 };
-use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow};
+use tauri::{AppHandle, LogicalSize, Manager, RunEvent, WebviewUrl, WebviewWindow, path::BaseDirectory};
 use tauri_plugin_clipboard_manager::ClipboardExt;
 use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
 use tauri_plugin_shell::process::{CommandChild, CommandEvent};
@@ -97,6 +97,11 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
     let log_state = app.state::<LogState>();
     let log_state_clone = log_state.inner().clone();
 
+    let state_dir = app
+        .path()
+        .resolve("", BaseDirectory::AppLocalData)
+        .expect("Failed to resolve app local data dir");
+
     #[cfg(target_os = "windows")]
     let (mut rx, child) = app
         .shell()
@@ -104,6 +109,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
         .unwrap()
         .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
         .env("OPENCODE_CLIENT", "desktop")
+        .env("XDG_STATE_HOME", &state_dir)
         .args(["serve", &format!("--port={port}")])
         .spawn()
         .expect("Failed to spawn opencode");
@@ -120,6 +126,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32) -> CommandChild {
             .command(&shell)
             .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
             .env("OPENCODE_CLIENT", "desktop")
+            .env("XDG_STATE_HOME", &state_dir)
             .args([
                 "-il",
                 "-c",

+ 1 - 1
packages/tauri/src-tauri/tauri.conf.json

@@ -26,7 +26,7 @@
       "icons/dev/icon.ico"
     ],
     "active": true,
-    "targets": ["deb", "rpm", "dmg", "nsis", "app"],
+    "targets": ["deb", "rpm", "dmg", "nsis", "app", "appimage"],
     "externalBin": ["sidecars/opencode-cli"],
     "macOS": {
       "entitlements": "./entitlements.plist"

+ 1 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/ui",
-  "version": "1.0.182",
+  "version": "1.0.190",
   "type": "module",
   "exports": {
     "./*": "./src/components/*.tsx",

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 1
packages/ui/src/assets/icons/provider/aihubmix.svg


+ 2 - 2
packages/ui/src/assets/icons/provider/alibaba-cn.svg

@@ -1,3 +1,3 @@
-<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
-  <path d="M24 14.014c-2.8 1.512-5.62 2.896-8.759 3.524-.7.139-1.476.139-2.187.043-.678-.085-1.017-.682-.776-1.31.23-.585.536-1.181.93-1.671.852-1.065 1.814-2.034 2.678-3.088a15.75 15.75 0 001.422-2.054c.306-.511.164-1.129-.372-1.384-.897-.437-1.859-.745-2.81-1.075-.11-.043-.274.074-.492.149.273.244.47.425.743.67-2.821.48-5.49 1.16-8.08 2.098-.012.053-.033.095-.023.117.383.585.208 1.032-.35 1.394a2.365 2.365 0 00-.568.522c1.706.5 3.226.213 4.68-.735-.087-.127-.175-.244-.262-.372.546.096.874.394.918.862.011.107-.054.213-.087.32-.077-.086-.175-.17-.24-.267-.045-.064-.056-.138-.088-.245-1.728 1.15-3.587 1.438-5.632.842 0 .404-.022.745.011 1.075.022.287-.098.415-.36.564-.591.362-1.204.735-1.696 1.214-.59.585-.371 1.299.427 1.597.907.34 1.859.35 2.81.234 1.126-.139 2.23-.32 3.456-.49-1.433.67-2.844 1.14-4.33 1.33-1.04.14-2.078.214-3.106-.084-1.476-.415-2.133-1.501-1.75-2.96.361-1.363 1.236-2.449 2.176-3.45 3.139-3.332 7.108-5.024 11.7-5.365 1.072-.074 2.155.064 3.16.511 1.411.639 2.002 1.99 1.313 3.354-.448.905-1.072 1.735-1.695 2.555-.612.809-1.301 1.554-1.946 2.331-.186.234-.361.48-.503.745-.274.5-.088.83.492.778 1.213-.118 2.45-.213 3.62-.511 1.716-.437 3.389-1.054 5.084-1.597.175-.043.339-.107.492-.17z"></path>
+<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
+<path d="M38 23.021C33.8 25.289 29.5701 27.365 24.8616 28.307C23.8116 28.5154 22.6476 28.5154 21.5811 28.3715C20.5641 28.244 20.0557 27.3485 20.4172 26.4065C20.7621 25.529 21.2211 24.635 21.8121 23.9C23.0901 22.3025 24.5331 20.849 25.8291 19.268C26.6206 18.2991 27.3338 17.2689 27.9621 16.1871C28.4211 15.4206 28.2081 14.4936 27.4041 14.1111C26.0586 13.4556 24.6156 12.9936 23.1891 12.4986C23.0241 12.4341 22.7781 12.6096 22.4511 12.7221C22.8606 13.0881 23.1561 13.3596 23.5656 13.7271C19.3342 14.4471 15.3307 15.4671 11.4457 16.8741C11.4277 16.9536 11.3962 17.0166 11.4112 17.0496C11.9857 17.927 11.7232 18.5975 10.8862 19.1405C10.5613 19.3531 10.2735 19.6177 10.0342 19.9235C12.5932 20.6735 14.8732 20.243 17.0542 18.821C16.9237 18.6305 16.7917 18.455 16.6612 18.263C17.4802 18.407 17.9722 18.854 18.0382 19.556C18.0547 19.7165 17.9572 19.8755 17.9077 20.036C17.7922 19.907 17.6452 19.781 17.5477 19.6355C17.4802 19.5395 17.4637 19.4285 17.4157 19.268C14.8237 20.993 12.0352 21.425 8.96775 20.531C8.96775 21.137 8.93475 21.6485 8.98425 22.1435C9.01725 22.574 8.83725 22.766 8.44426 22.9895C7.55776 23.5325 6.63827 24.092 5.90028 24.8105C5.01528 25.688 5.34378 26.759 6.54077 27.206C7.90126 27.716 9.32925 27.731 10.7557 27.557C12.4447 27.3485 14.1007 27.077 15.9397 26.822C13.7902 27.827 11.6737 28.5319 9.44475 28.8169C7.88476 29.0269 6.32777 29.1379 4.78579 28.6909C2.57181 28.0685 1.58631 26.4395 2.16081 24.251C2.7023 22.2065 4.01479 20.5775 5.42478 19.076C10.1332 14.0781 16.0867 11.5401 22.9746 11.0286C24.5826 10.9176 26.2071 11.1246 27.7146 11.7951C29.8311 12.7536 30.7176 14.7801 29.6841 16.8261C29.0121 18.1835 28.0761 19.4285 27.1416 20.6585C26.2236 21.872 25.1901 22.9895 24.2226 24.155C23.9436 24.506 23.6811 24.875 23.4681 25.2725C23.0571 26.0225 23.3361 26.5175 24.2061 26.4395C26.0256 26.2625 27.8811 26.12 29.6361 25.673C32.2101 25.0175 34.7195 24.092 37.262 23.2775C37.5245 23.213 37.7705 23.117 38 23.0225V23.021Z" fill="currentColor"/>
 </svg>

+ 2 - 2
packages/ui/src/assets/icons/provider/alibaba.svg

@@ -1,3 +1,3 @@
-<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
-  <path d="M24 14.014c-2.8 1.512-5.62 2.896-8.759 3.524-.7.139-1.476.139-2.187.043-.678-.085-1.017-.682-.776-1.31.23-.585.536-1.181.93-1.671.852-1.065 1.814-2.034 2.678-3.088a15.75 15.75 0 001.422-2.054c.306-.511.164-1.129-.372-1.384-.897-.437-1.859-.745-2.81-1.075-.11-.043-.274.074-.492.149.273.244.47.425.743.67-2.821.48-5.49 1.16-8.08 2.098-.012.053-.033.095-.023.117.383.585.208 1.032-.35 1.394a2.365 2.365 0 00-.568.522c1.706.5 3.226.213 4.68-.735-.087-.127-.175-.244-.262-.372.546.096.874.394.918.862.011.107-.054.213-.087.32-.077-.086-.175-.17-.24-.267-.045-.064-.056-.138-.088-.245-1.728 1.15-3.587 1.438-5.632.842 0 .404-.022.745.011 1.075.022.287-.098.415-.36.564-.591.362-1.204.735-1.696 1.214-.59.585-.371 1.299.427 1.597.907.34 1.859.35 2.81.234 1.126-.139 2.23-.32 3.456-.49-1.433.67-2.844 1.14-4.33 1.33-1.04.14-2.078.214-3.106-.084-1.476-.415-2.133-1.501-1.75-2.96.361-1.363 1.236-2.449 2.176-3.45 3.139-3.332 7.108-5.024 11.7-5.365 1.072-.074 2.155.064 3.16.511 1.411.639 2.002 1.99 1.313 3.354-.448.905-1.072 1.735-1.695 2.555-.612.809-1.301 1.554-1.946 2.331-.186.234-.361.48-.503.745-.274.5-.088.83.492.778 1.213-.118 2.45-.213 3.62-.511 1.716-.437 3.389-1.054 5.084-1.597.175-.043.339-.107.492-.17z"></path>
+<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
+<path d="M37.9998 23.021C33.7998 25.2889 29.5698 27.3649 24.8614 28.3069C23.8114 28.5154 22.6474 28.5154 21.5809 28.3714C20.5639 28.2439 20.0554 27.3484 20.4169 26.4064C20.7619 25.5289 21.2209 24.635 21.8119 23.9C23.0899 22.3025 24.5329 20.849 25.8289 19.268C26.6203 18.2991 27.3335 17.2689 27.9618 16.187C28.4208 15.4205 28.2078 14.4935 27.4038 14.111C26.0584 13.4556 24.6154 12.9936 23.1889 12.4986C23.0239 12.4341 22.7779 12.6096 22.4509 12.7221C22.8604 13.0881 23.1559 13.3596 23.5654 13.727C19.3339 14.447 15.3305 15.467 11.4455 16.874C11.4275 16.9535 11.396 17.0165 11.411 17.0495C11.9855 17.927 11.723 18.5975 10.886 19.1405C10.5611 19.3531 10.2732 19.6176 10.034 19.9235C12.593 20.6735 14.873 20.243 17.0539 18.821C16.9234 18.6305 16.7914 18.455 16.6609 18.263C17.4799 18.407 17.9719 18.854 18.0379 19.556C18.0544 19.7165 17.9569 19.8755 17.9074 20.036C17.7919 19.907 17.6449 19.781 17.5474 19.6355C17.4799 19.5395 17.4634 19.4285 17.4154 19.268C14.8235 20.993 12.035 21.425 8.96751 20.531C8.96751 21.137 8.93451 21.6485 8.98401 22.1435C9.01701 22.574 8.83701 22.766 8.44401 22.9895C7.55752 23.5325 6.63803 24.092 5.90003 24.8105C5.01504 25.6879 5.34354 26.7589 6.54053 27.2059C7.90102 27.7159 9.329 27.7309 10.7555 27.5569C12.4445 27.3484 14.1005 27.0769 15.9394 26.8219C13.79 27.8269 11.6735 28.5319 9.4445 28.8169C7.88452 29.0269 6.32753 29.1379 4.78554 28.6909C2.57156 28.0684 1.58607 26.4394 2.16057 24.251C2.70206 22.2065 4.01455 20.5775 5.42454 19.076C10.133 14.078 16.0864 11.5401 22.9744 11.0286C24.5824 10.9176 26.2069 11.1246 27.7143 11.7951C29.8308 12.7536 30.7173 14.78 29.6838 16.826C29.0118 18.1835 28.0758 19.4285 27.1413 20.6585C26.2234 21.872 25.1899 22.9895 24.2224 24.155C23.9434 24.506 23.6809 24.875 23.4679 25.2724C23.0569 26.0224 23.3359 26.5174 24.2059 26.4394C26.0254 26.2624 27.8808 26.1199 29.6358 25.6729C32.2098 25.0174 34.7193 24.092 37.2618 23.2775C37.5243 23.213 37.7703 23.117 37.9998 23.0225V23.021Z" fill="currentColor"/>
 </svg>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 1 - 3
packages/ui/src/assets/icons/provider/amazon-bedrock.svg


+ 3 - 7
packages/ui/src/assets/icons/provider/anthropic.svg

@@ -1,7 +1,3 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
-  <!-- https://icones.js.org/collection/ri?s=anthropic&icon=ri:anthropic-fill -->
-  <path
-    fill="currentColor"
-    d="M16.765 5h-3.308l5.923 15h3.23zM7.226 5L1.38 20h3.308l1.307-3.154h6.154l1.23 3.077h3.309L10.688 5zm-.308 9.077l2-5.308l2.077 5.308z"
-  />
-</svg>
+<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
+<path d="M26.9568 9.88184H22.1265L30.7753 31.7848H35.4917L26.9568 9.88184ZM13.028 9.88184L4.4917 31.7848H9.32203L11.2305 27.1793H20.2166L22.0126 31.6724H26.8444L18.0832 9.88184H13.028ZM12.5783 23.1361L15.4987 15.3853L18.5315 23.1361H12.5783Z" fill="currentColor"/>
+</svg>

+ 2 - 3
packages/ui/src/assets/icons/provider/azure.svg

@@ -1,4 +1,3 @@
-<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
-  <!-- https://api.iconify.design/mdi:microsoft-azure.svg -->
-  <path fill="currentColor" d="M13.05 4.24L6.56 18.05L2 18l5.09-8.76zm.7 1.09L22 19.76H6.74l9.3-1.66l-4.87-5.79z"/>
+<svg width="24" height="24" viewBox="0 0 40 40" xmlns="http://www.w3.org/2000/svg">
+<path d="M21.68 7.58398L11.296 29.68L4 29.6L12.144 15.584L21.68 7.58398ZM22.8 9.32798L36 32.416H11.584L26.464 29.76L18.672 20.496L22.8 9.32798Z" fill="currentColor"/>
 </svg>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 1
packages/ui/src/assets/icons/provider/bailing.svg


+ 3 - 1
packages/ui/src/assets/icons/provider/baseten.svg

@@ -1 +1,3 @@
-<svg fill="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Baseten</title><path d="M2.316 4.8h14.682v4.8H7.31a.302.302 0 00-.308.3v4.2c0 .171.14.3.308.3h9.688v4.8h-4.686a.302.302 0 00-.308.3v4.2c0 .171.141.3.308.3h4.378a.297.297 0 00.308-.3v-4.5h4.694a.302.302 0 00.308-.3v-4.2c0-.171-.14-.3-.308-.3h-4.694V9.6h4.694A.302.302 0 0022 9.3V5.1c0-.171-.14-.3-.308-.3h-4.694V.3c0-.171-.14-.3-.308-.3H2.316A.31.31 0 002 .3v4.2c0 .171.14.3.316.3z"></path></svg>
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M4.97934 6.78009H15.6237V10.26H8.59995C8.57096 10.2595 8.54214 10.2647 8.51516 10.2753C8.48819 10.286 8.4636 10.3019 8.44283 10.3221C8.42205 10.3423 8.40551 10.3665 8.39415 10.3932C8.38279 10.4199 8.37684 10.4485 8.37665 10.4775V13.5225C8.37665 13.6465 8.47815 13.74 8.59995 13.74H15.6237V17.22H12.2264C12.1974 17.2194 12.1685 17.2246 12.1416 17.2352C12.1146 17.2459 12.09 17.2618 12.0692 17.282C12.0485 17.3023 12.0319 17.3264 12.0206 17.3531C12.0092 17.3798 12.0032 17.4085 12.0031 17.4375V20.4824C12.0031 20.6064 12.1053 20.6999 12.2264 20.6999H15.4004C15.4295 20.701 15.4585 20.6962 15.4857 20.6857C15.5129 20.6752 15.5377 20.6593 15.5586 20.639C15.5795 20.6187 15.596 20.5943 15.6072 20.5674C15.6184 20.5405 15.624 20.5116 15.6237 20.4824V17.22H19.0268C19.0558 17.2205 19.0846 17.2154 19.1116 17.2047C19.1385 17.194 19.1631 17.1781 19.1839 17.1579C19.2047 17.1377 19.2212 17.1135 19.2326 17.0868C19.2439 17.0601 19.2499 17.0315 19.2501 17.0025V13.9575C19.2501 13.8335 19.1486 13.74 19.0268 13.74H15.6237V10.26H19.0268C19.0558 10.2606 19.0846 10.2554 19.1116 10.2448C19.1385 10.2341 19.1631 10.2182 19.1839 10.198C19.2047 10.1777 19.2212 10.1536 19.2326 10.1269C19.2439 10.1002 19.2499 10.0715 19.2501 10.0425V6.99758C19.2501 6.87361 19.1486 6.78009 19.0268 6.78009H15.6237V3.51762C15.6237 3.39365 15.5222 3.30012 15.4004 3.30012H4.97934C4.92022 3.29895 4.86302 3.32112 4.82014 3.36183C4.77725 3.40255 4.75214 3.45852 4.75024 3.51762V6.56259C4.75024 6.68656 4.85174 6.78009 4.97934 6.78009Z" fill="currentColor"/>
+</svg>

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 1 - 2
packages/ui/src/assets/icons/provider/cerebras.svg


+ 3 - 5
packages/ui/src/assets/icons/provider/cloudflare-ai-gateway.svg

@@ -1,6 +1,4 @@
-<svg xmlns="http://www.w3.org/2000/svg" fill="none" width="256" height="256" viewBox="0 0 256 256">
-    <g>
-        <path fill="currentColor" d="M205.52 119.813C204.662 119.813 203.815 119.843 202.969 119.871C202.832 119.878 202.697 119.911 202.571 119.965C202.347 120.042 202.146 120.173 201.985 120.346C201.825 120.52 201.71 120.73 201.651 120.959L198.023 133.631C196.458 139.08 197.04 144.111 199.669 147.805C202.079 151.221 206.089 153.226 210.958 153.46L230.637 154.654C231.222 154.684 231.729 154.966 232.037 155.43C232.198 155.678 232.3 155.96 232.335 156.253C232.37 156.547 232.336 156.844 232.237 157.122C232.079 157.575 231.793 157.973 231.415 158.268C231.036 158.562 230.581 158.742 230.103 158.784L209.655 159.977C198.545 160.492 186.593 169.557 182.4 180.61L180.926 184.51C180.863 184.672 180.839 184.847 180.856 185.019C180.874 185.192 180.932 185.358 181.027 185.504C181.121 185.65 181.249 185.771 181.4 185.857C181.551 185.943 181.72 185.993 181.893 186H252.318C252.728 186.002 253.126 185.87 253.453 185.623C253.78 185.377 254.017 185.03 254.128 184.635C255.379 180.14 256.009 175.495 256 170.828C256 142.668 233.418 119.844 205.551 119.844" />
-        <path fill="currentColor" d="M174.782 184.362L176.085 179.779C177.653 174.33 177.072 169.299 174.446 165.606C172.028 162.189 168.022 160.184 163.15 159.95L70.838 158.757C70.5509 158.752 70.269 158.679 70.0155 158.544C69.7619 158.409 69.5438 158.216 69.379 157.981C69.217 157.734 69.1143 157.452 69.0791 157.158C69.0439 156.865 69.0771 156.567 69.176 156.288C69.3358 155.832 69.6243 155.433 70.0067 155.138C70.389 154.843 70.8487 154.665 71.33 154.626L164.503 153.433C175.566 152.922 187.518 143.853 191.711 132.8L197.024 118.76C197.238 118.164 197.294 117.522 197.18 116.9C191.126 89.51 166.91 69 137.96 69C111.269 69 88.626 86.403 80.5 110.596C75.0295 106.437 68.1787 104.52 61.344 105.237C48.549 106.524 38.25 116.946 36.979 129.88C36.6502 133.11 36.8878 136.373 37.681 139.522C16.773 140.145 0 157.454 0 178.726C0 180.649 0.137 182.544 0.413 184.393C0.528 185.29 1.292 185.963 2.196 185.961H172.676C173.156 185.953 173.62 185.79 174 185.496C174.38 185.202 174.654 184.794 174.782 184.331" />
-    </g>
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M17.6323 11.3946C17.57 11.3946 17.5085 11.3968 17.447 11.3988C17.437 11.3993 17.4272 11.4017 17.4181 11.4057C17.4018 11.4113 17.3872 11.4208 17.3755 11.4333C17.3639 11.446 17.3555 11.4612 17.3512 11.4779L17.0876 12.3986C16.9739 12.7945 17.0162 13.16 17.2072 13.4284C17.3823 13.6766 17.6737 13.8223 18.0274 13.8393L19.4572 13.926C19.4998 13.9282 19.5366 13.9487 19.559 13.9824C19.5707 14.0004 19.5781 14.0209 19.5806 14.0422C19.5832 14.0636 19.5807 14.0852 19.5735 14.1054C19.562 14.1383 19.5412 14.1672 19.5138 14.1886C19.4862 14.21 19.4532 14.2231 19.4185 14.2261L17.9328 14.3128C17.1256 14.3502 16.2572 15.0088 15.9525 15.8119L15.8454 16.0953C15.8409 16.107 15.8391 16.1197 15.8404 16.1322C15.8417 16.1448 15.8459 16.1569 15.8528 16.1675C15.8596 16.1781 15.8689 16.1869 15.8799 16.1931C15.8908 16.1994 15.9031 16.203 15.9157 16.2035H21.0325C21.0623 16.2037 21.0912 16.1941 21.115 16.1761C21.1387 16.1583 21.156 16.133 21.164 16.1043C21.2549 15.7778 21.3007 15.4403 21.3 15.1012C21.3 13.0552 19.6593 11.3969 17.6346 11.3969" fill="currentColor"/>
+<path d="M15.3989 16.0845L15.4936 15.7515C15.6075 15.3556 15.5653 14.9901 15.3745 14.7218C15.1989 14.4735 14.9078 14.3278 14.5538 14.3108L7.84677 14.2241C7.82591 14.2238 7.80543 14.2185 7.78701 14.2087C7.76859 14.1988 7.75274 14.1848 7.74077 14.1678C7.729 14.1498 7.72153 14.1293 7.71898 14.108C7.71642 14.0867 7.71883 14.065 7.72602 14.0447C7.73763 14.0116 7.75859 13.9826 7.78637 13.9612C7.81415 13.9398 7.84755 13.9268 7.88252 13.924L14.6521 13.8373C15.4559 13.8002 16.3243 13.1413 16.6289 12.3382L17.015 11.3181C17.0305 11.2748 17.0346 11.2282 17.0263 11.183C16.5864 9.19291 14.827 7.70273 12.7236 7.70273C10.7843 7.70273 9.13918 8.96716 8.54878 10.7249C8.15131 10.4228 7.65356 10.2835 7.15697 10.3356C6.22734 10.4291 5.47905 11.1863 5.38671 12.126C5.36282 12.3607 5.38008 12.5978 5.43771 12.8266C3.91861 12.8719 2.69995 14.1295 2.69995 15.675C2.69995 15.8147 2.70991 15.9524 2.72996 16.0867C2.73831 16.1519 2.79382 16.2008 2.8595 16.2007H15.2459C15.2808 16.2001 15.3145 16.1882 15.3421 16.1669C15.3697 16.1455 15.3896 16.1159 15.3989 16.0822" fill="currentColor"/>
 </svg>

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác