Explorar el Código

Merge public/dev and resolve conflicts (.gitignore, bun.lock, vite.config.ts)

paviko hace 3 meses
padre
commit
dc7d17f3a9
Se han modificado 100 ficheros con 4079 adiciones y 1227 borrados
  1. 6 3
      .github/workflows/docs-update.yml
  2. 9 5
      .github/workflows/stale-issues.yml
  3. 1 0
      .github/workflows/sync-zed-extension.yml
  4. 5 1
      .gitignore
  5. 4 0
      AGENTS.md
  6. 30 0
      CONTRIBUTING.md
  7. 1 1
      README.md
  8. 3 0
      STATS.md
  9. 193 184
      bun.lock
  10. 3 3
      flake.lock
  11. 3 3
      flake.nix
  12. 1 1
      nix/hashes.json
  13. 18 10
      nix/node-modules.nix
  14. 10 7
      nix/opencode.nix
  15. 7 3
      nix/scripts/patch-wasm.ts
  16. 1 1
      packages/app/package.json
  17. 30 13
      packages/app/src/app.tsx
  18. 114 0
      packages/app/src/components/dialog-select-directory.tsx
  19. 72 40
      packages/app/src/components/dialog-select-model.tsx
  20. 179 0
      packages/app/src/components/dialog-select-server.tsx
  21. 0 215
      packages/app/src/components/header.tsx
  22. 508 115
      packages/app/src/components/prompt-input.tsx
  23. 70 25
      packages/app/src/components/session-context-usage.tsx
  24. 4 6
      packages/app/src/components/session-lsp-indicator.tsx
  25. 4 6
      packages/app/src/components/session-mcp-indicator.tsx
  26. 0 32
      packages/app/src/components/status-bar.tsx
  27. 1 0
      packages/app/src/components/terminal.tsx
  28. 15 8
      packages/app/src/context/global-sdk.tsx
  29. 14 7
      packages/app/src/context/global-sync.tsx
  30. 63 40
      packages/app/src/context/layout.tsx
  31. 71 10
      packages/app/src/context/local.tsx
  32. 16 9
      packages/app/src/context/notification.tsx
  33. 123 0
      packages/app/src/context/permission.tsx
  34. 4 1
      packages/app/src/context/platform.tsx
  35. 10 1
      packages/app/src/context/prompt.tsx
  36. 185 0
      packages/app/src/context/server.tsx
  37. 12 3
      packages/app/src/context/sync.tsx
  38. 30 0
      packages/app/src/entry.tsx
  39. 8 7
      packages/app/src/pages/directory-layout.tsx
  40. 122 23
      packages/app/src/pages/error.tsx
  41. 48 19
      packages/app/src/pages/home.tsx
  42. 132 99
      packages/app/src/pages/layout.tsx
  43. 696 64
      packages/app/src/pages/session.tsx
  44. 1 1
      packages/app/vite.config.ts
  45. 1 1
      packages/console/app/package.json
  46. 9 3
      packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx
  47. 2 0
      packages/console/app/src/routes/zen/util/handler.ts
  48. 1 1
      packages/console/core/package.json
  49. 1 1
      packages/console/function/package.json
  50. 1 1
      packages/console/mail/package.json
  51. 2 1
      packages/desktop/package.json
  52. 1 1
      packages/desktop/scripts/predev.ts
  53. 58 0
      packages/desktop/src-tauri/Cargo.lock
  54. 1 0
      packages/desktop/src-tauri/Cargo.toml
  55. 5 0
      packages/desktop/src-tauri/capabilities/default.json
  56. 1 0
      packages/desktop/src-tauri/src/lib.rs
  57. 105 16
      packages/desktop/src/index.tsx
  58. 3 2
      packages/desktop/vite.config.ts
  59. 1 1
      packages/enterprise/package.json
  60. 8 5
      packages/enterprise/src/app.tsx
  61. 14 5
      packages/enterprise/src/routes/share/[shareID].tsx
  62. 6 6
      packages/extensions/zed/extension.toml
  63. 1 1
      packages/function/package.json
  64. 1 0
      packages/opencode/bunfig.toml
  65. 10 10
      packages/opencode/package.json
  66. 14 0
      packages/opencode/parsers-config.ts
  67. 6 0
      packages/opencode/src/cli/cmd/auth.ts
  68. 51 0
      packages/opencode/src/cli/cmd/debug/agent.ts
  69. 2 0
      packages/opencode/src/cli/cmd/debug/index.ts
  70. 89 2
      packages/opencode/src/cli/cmd/stats.ts
  71. 9 0
      packages/opencode/src/cli/cmd/tui/app.tsx
  72. 1 1
      packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx
  73. 1 1
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  74. 44 0
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  75. 1 1
      packages/opencode/src/cli/cmd/tui/component/tips.ts
  76. 76 23
      packages/opencode/src/cli/cmd/tui/context/local.tsx
  77. 2 2
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  78. 67 56
      packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
  79. 11 1
      packages/opencode/src/cli/network.ts
  80. 57 6
      packages/opencode/src/command/index.ts
  81. 30 24
      packages/opencode/src/config/config.ts
  82. 76 8
      packages/opencode/src/file/index.ts
  83. 11 2
      packages/opencode/src/file/ripgrep.ts
  84. 26 12
      packages/opencode/src/file/watcher.ts
  85. 18 0
      packages/opencode/src/format/formatter.ts
  86. 5 2
      packages/opencode/src/global/index.ts
  87. 23 0
      packages/opencode/src/lsp/client.ts
  88. 18 0
      packages/opencode/src/lsp/server.ts
  89. 94 7
      packages/opencode/src/mcp/index.ts
  90. 1 0
      packages/opencode/src/plugin/index.ts
  91. 1 0
      packages/opencode/src/provider/models.ts
  92. 34 4
      packages/opencode/src/provider/provider.ts
  93. 177 12
      packages/opencode/src/provider/transform.ts
  94. 1 20
      packages/opencode/src/pty/index.ts
  95. 1 1
      packages/opencode/src/server/mdns.ts
  96. 41 6
      packages/opencode/src/server/server.ts
  97. 20 8
      packages/opencode/src/session/llm.ts
  98. 2 3
      packages/opencode/src/session/message-v2.ts
  99. 8 2
      packages/opencode/src/session/prompt.ts
  100. 2 2
      packages/opencode/src/session/prompt/codex.txt

+ 6 - 3
.github/workflows/docs-update.yml

@@ -5,6 +5,9 @@ on:
     - cron: "0 */12 * * *"
   workflow_dispatch:
 
+env:
+  LOOKBACK_HOURS: 4
+
 jobs:
   update-docs:
     if: github.repository == 'sst/opencode'
@@ -25,9 +28,9 @@ jobs:
       - name: Get recent commits
         id: commits
         run: |
-          COMMITS=$(git log --since="4 hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
+          COMMITS=$(git log --since="${{ env.LOOKBACK_HOURS }} hours ago" --pretty=format:"- %h %s" 2>/dev/null || echo "")
           if [ -z "$COMMITS" ]; then
-            echo "No commits in the last 4 hours"
+            echo "No commits in the last ${{ env.LOOKBACK_HOURS }} hours"
             echo "has_commits=false" >> $GITHUB_OUTPUT
           else
             echo "has_commits=true" >> $GITHUB_OUTPUT
@@ -47,7 +50,7 @@ jobs:
           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.
+            Review the following commits from the last ${{ env.LOOKBACK_HOURS }} hours and identify any new features that may need documentation.
 
             <recent_commits>
             ${{ steps.commits.outputs.list }}

+ 9 - 5
.github/workflows/stale-issues.yml

@@ -5,6 +5,10 @@ on:
     - cron: "30 1 * * *" # Daily at 1:30 AM
   workflow_dispatch:
 
+env:
+  DAYS_BEFORE_STALE: 90
+  DAYS_BEFORE_CLOSE: 7
+
 jobs:
   stale:
     runs-on: ubuntu-latest
@@ -13,17 +17,17 @@ jobs:
     steps:
       - uses: actions/stale@v10
         with:
-          days-before-stale: 90
-          days-before-close: 7
+          days-before-stale: ${{ env.DAYS_BEFORE_STALE }}
+          days-before-close: ${{ env.DAYS_BEFORE_CLOSE }}
           stale-issue-label: "stale"
           close-issue-message: |
-            [automated] Closing due to 90+ days of inactivity.
+            [automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity.
 
             Feel free to reopen if you still need this!
           stale-issue-message: |
-            [automated] This issue has had no activity for 90 days.
+            [automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days.
 
-            It will be closed in 7 days if there's no new activity.
+            It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity.
           remove-stale-when-updated: true
           exempt-issue-labels: "pinned,security,feature-request,on-hold"
           start-date: "2025-12-27"

+ 1 - 0
.github/workflows/sync-zed-extension.yml

@@ -32,3 +32,4 @@ jobs:
           ./script/sync-zed.ts ${{ steps.get_tag.outputs.tag }}
         env:
           ZED_EXTENSIONS_PAT: ${{ secrets.ZED_EXTENSIONS_PAT }}
+          ZED_PR_PAT: ${{ secrets.ZED_PR_PAT }}

+ 5 - 1
.gitignore

@@ -68,4 +68,8 @@ backend/rovo-*
 *.out
 coverage/
 coverage.*
-.scripts
+.scripts
+
+# Local dev files
+opencode-dev
+logs/

+ 4 - 0
AGENTS.md

@@ -2,6 +2,10 @@
 
 - To test opencode in the `packages/opencode` directory you can run `bun dev`
 
+## SDK
+
+To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts
+
 ## Tool Calling
 
 - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE.

+ 30 - 0
CONTRIBUTING.md

@@ -34,6 +34,36 @@ Want to take on an issue? Leave a comment and a maintainer may assign it to you
   bun dev
   ```
 
+### Running against a different directory
+
+By default, `bun dev` runs OpenCode in the `packages/opencode` directory. To run it against a different directory or repository:
+
+```bash
+bun dev <directory>
+```
+
+To run OpenCode in the root of the opencode repo itself:
+
+```bash
+bun dev .
+```
+
+### Building a "localcode"
+
+To compile a standalone executable:
+
+```bash
+./packages/opencode/script/build.ts --single
+```
+
+Then run it with:
+
+```bash
+./packages/opencode/dist/opencode-<platform>/bin/opencode
+```
+
+Replace `<platform>` with your platform (e.g., `darwin-arm64`, `linux-x64`).
+
 - Core pieces:
   - `packages/opencode`: OpenCode core business logic & server.
   - `packages/opencode/src/cli/cmd/tui/`: The TUI code, written in SolidJS with [opentui](https://github.com/sst/opentui)

+ 1 - 1
README.md

@@ -40,7 +40,7 @@ scoop bucket add extras; scoop install extras/opencode  # Windows
 choco install opencode             # Windows
 brew install opencode              # macOS and Linux
 paru -S opencode-bin               # Arch Linux
-mise use -g github:sst/opencode # Any OS
+mise use -g opencode               # Any OS
 nix run nixpkgs#opencode           # or github:sst/opencode for latest dev branch
 ```
 

+ 3 - 0
STATS.md

@@ -185,3 +185,6 @@
 | 2025-12-27 | 1,371,771 (+19,360) | 1,238,236 (+10,621) | 2,610,007 (+29,981) |
 | 2025-12-28 | 1,390,388 (+18,617) | 1,245,690 (+7,454)  | 2,636,078 (+26,071) |
 | 2025-12-29 | 1,415,560 (+25,172) | 1,257,101 (+11,411) | 2,672,661 (+36,583) |
+| 2025-12-30 | 1,445,450 (+29,890) | 1,272,689 (+15,588) | 2,718,139 (+45,478) |
+| 2025-12-31 | 1,479,598 (+34,148) | 1,293,235 (+20,546) | 2,772,833 (+54,694) |
+| 2026-01-01 | 1,508,883 (+29,285) | 1,309,874 (+16,639) | 2,818,757 (+45,924) |

La diferencia del archivo ha sido suprimido porque es demasiado grande
+ 193 - 184
bun.lock


+ 3 - 3
flake.lock

@@ -2,11 +2,11 @@
   "nodes": {
     "nixpkgs": {
       "locked": {
-        "lastModified": 1766870016,
-        "narHash": "sha256-fHmxAesa6XNqnIkcS6+nIHuEmgd/iZSP/VXxweiEuQw=",
+        "lastModified": 1767151656,
+        "narHash": "sha256-ujL2AoYBnJBN262HD95yer7QYUmYp5kFZGYbyCCKxq8=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "5c2bc52fb9f8c264ed6c93bd20afa2ff5e763dce",
+        "rev": "f665af0cdb70ed27e1bd8f9fdfecaf451260fc55",
         "type": "github"
       },
       "original": {

+ 3 - 3
flake.nix

@@ -17,7 +17,7 @@
         "aarch64-darwin"
         "x86_64-darwin"
       ];
-      lib = nixpkgs.lib;
+      inherit (nixpkgs) lib;
       forEachSystem = lib.genAttrs systems;
       pkgsFor = system: nixpkgs.legacyPackages.${system};
       packageJson = builtins.fromJSON (builtins.readFile ./packages/opencode/package.json);
@@ -70,12 +70,12 @@
         in
         {
           default = mkPackage {
-            version = packageJson.version;
+            inherit (packageJson) version;
             src = ./.;
             scripts = ./nix/scripts;
             target = bunTarget.${system};
             modelsDev = "${modelsDev.${system}}/dist/_api.json";
-            mkNodeModules = mkNodeModules;
+            inherit mkNodeModules;
           };
         }
       );

+ 1 - 1
nix/hashes.json

@@ -1,3 +1,3 @@
 {
-  "nodeModules": "sha256-SB9slGD8Vd1hgvm1AsuPzUi3yBPUCDGeha0CABjZdCY="
+  "nodeModules": "sha256-uJDhOieOdMQLORyuOWtgtjLoMnNEQPrDcyij9TX0aTw="
 }

+ 18 - 10
nix/node-modules.nix

@@ -1,18 +1,26 @@
-{ hash, lib, stdenvNoCC, bun, cacert, curl }:
+{
+  hash,
+  lib,
+  stdenvNoCC,
+  bun,
+  cacert,
+  curl,
+}:
 args:
 stdenvNoCC.mkDerivation {
   pname = "opencode-node_modules";
-  version = args.version;
-  src = args.src;
+  inherit (args) version src;
 
-  impureEnvVars =
-    lib.fetchers.proxyImpureEnvVars
-    ++ [
-      "GIT_PROXY_COMMAND"
-      "SOCKS_SERVER"
-    ];
+  impureEnvVars = lib.fetchers.proxyImpureEnvVars ++ [
+    "GIT_PROXY_COMMAND"
+    "SOCKS_SERVER"
+  ];
 
-  nativeBuildInputs = [ bun cacert curl ];
+  nativeBuildInputs = [
+    bun
+    cacert
+    curl
+  ];
 
   dontConfigure = true;
 

+ 10 - 7
nix/opencode.nix

@@ -1,7 +1,13 @@
-{ lib, stdenvNoCC, bun, ripgrep, makeBinaryWrapper }:
+{
+  lib,
+  stdenvNoCC,
+  bun,
+  ripgrep,
+  makeBinaryWrapper,
+}:
 args:
 let
-  scripts = args.scripts;
+  inherit (args) scripts;
   mkModules =
     attrs:
     args.mkNodeModules (
@@ -14,13 +20,10 @@ let
 in
 stdenvNoCC.mkDerivation (finalAttrs: {
   pname = "opencode";
-  version = args.version;
-
-  src = args.src;
+  inherit (args) version src;
 
   node_modules = mkModules {
-    version = finalAttrs.version;
-    src = finalAttrs.src;
+    inherit (finalAttrs) version src;
   };
 
   nativeBuildInputs = [

+ 7 - 3
nix/scripts/patch-wasm.ts

@@ -31,9 +31,13 @@ for (const [name, wasmPath] of byName) {
 next = next.replaceAll("tree-sitter.wasm", mainWasm).replaceAll("web-tree-sitter/tree-sitter.wasm", mainWasm)
 
 // Collapse any relative prefixes before absolute store paths (e.g., "../../../..//nix/store/...")
+const nixStorePrefix = process.env.NIX_STORE || "/nix/store"
 next = next.replace(/(\.\/)+/g, "./")
-next = next.replace(/(\.\.\/)+\/?(\/nix\/store[^"']+)/g, "/$2")
-next = next.replace(/(["'])\/{2,}(\/nix\/store[^"']+)(["'])/g, "$1/$2$3")
-next = next.replace(/(["'])\/\/(nix\/store[^"']+)(["'])/g, "$1/$2$3")
+next = next.replace(
+  new RegExp(`(\\.\\.\\/)+\\/{1,2}(${nixStorePrefix.replace(/^\//, "").replace(/\//g, "\\/")}[^"']+)`, "g"),
+  "/$2",
+)
+next = next.replace(new RegExp(`(["'])\\/{2,}(\\/${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
+next = next.replace(new RegExp(`(["'])\\/\\/(${nixStorePrefix.replace(/\//g, "\\/")}[^"']+)(["'])`, "g"), "$1$2$3")
 
 if (next !== content) fs.writeFileSync(file, next)

+ 1 - 1
packages/app/package.json

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

+ 30 - 13
packages/app/src/app.tsx

@@ -1,5 +1,5 @@
 import "@/index.css"
-import { ErrorBoundary, Show } from "solid-js"
+import { ErrorBoundary, Show, type ParentProps } from "solid-js"
 import { Router, Route, Navigate } from "@solidjs/router"
 import { MetaProvider } from "@solidjs/meta"
 import { Font } from "@opencode-ai/ui/font"
@@ -10,8 +10,10 @@ import { Diff } from "@opencode-ai/ui/diff"
 import { Code } from "@opencode-ai/ui/code"
 import { ThemeProvider } from "@opencode-ai/ui/theme"
 import { GlobalSyncProvider } from "@/context/global-sync"
+import { PermissionProvider } from "@/context/permission"
 import { LayoutProvider } from "@/context/layout"
 import { GlobalSDKProvider } from "@/context/global-sdk"
+import { ServerProvider, useServer } from "@/context/server"
 import { TerminalProvider } from "@/context/terminal"
 import { PromptProvider } from "@/context/prompt"
 import { NotificationProvider } from "@/context/notification"
@@ -30,7 +32,7 @@ declare global {
   }
 }
 
-const url = iife(() => {
+const defaultServerUrl = iife(() => {
   const param = new URLSearchParams(document.location.search).get("url")
   if (param) return param
 
@@ -42,6 +44,15 @@ const url = iife(() => {
   return window.location.origin
 })
 
+function ServerKey(props: ParentProps) {
+  const server = useServer()
+  return (
+    <Show when={server.url} keyed>
+      {props.children}
+    </Show>
+  )
+}
+
 export function App() {
   return (
     <MetaProvider>
@@ -52,15 +63,21 @@ export function App() {
             <MarkedProvider>
               <DiffComponentProvider component={Diff}>
                 <CodeComponentProvider component={Code}>
-                  <GlobalSDKProvider url={url}>
-                    <GlobalSyncProvider>
-                      <LayoutProvider>
-                        <NotificationProvider>
+                  <ServerProvider defaultUrl={defaultServerUrl}>
+                    <ServerKey>
+                      <GlobalSDKProvider>
+                        <GlobalSyncProvider>
                           <Router
                             root={(props) => (
-                              <CommandProvider>
-                                <Layout>{props.children}</Layout>
-                              </CommandProvider>
+                              <PermissionProvider>
+                                <LayoutProvider>
+                                  <NotificationProvider>
+                                    <CommandProvider>
+                                      <Layout>{props.children}</Layout>
+                                    </CommandProvider>
+                                  </NotificationProvider>
+                                </LayoutProvider>
+                              </PermissionProvider>
                             )}
                           >
                             <Route path="/" component={Home} />
@@ -80,10 +97,10 @@ export function App() {
                               />
                             </Route>
                           </Router>
-                        </NotificationProvider>
-                      </LayoutProvider>
-                    </GlobalSyncProvider>
-                  </GlobalSDKProvider>
+                        </GlobalSyncProvider>
+                      </GlobalSDKProvider>
+                    </ServerKey>
+                  </ServerProvider>
                 </CodeComponentProvider>
               </DiffComponentProvider>
             </MarkedProvider>

+ 114 - 0
packages/app/src/components/dialog-select-directory.tsx

@@ -0,0 +1,114 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { FileIcon } from "@opencode-ai/ui/file-icon"
+import { List } from "@opencode-ai/ui/list"
+import { getDirectory, getFilename } from "@opencode-ai/util/path"
+import { createMemo } from "solid-js"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "@/context/global-sync"
+
+interface DialogSelectDirectoryProps {
+  title?: string
+  multiple?: boolean
+  onSelect: (result: string | string[] | null) => void
+}
+
+export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
+  const sync = useGlobalSync()
+  const sdk = useGlobalSDK()
+  const dialog = useDialog()
+
+  const home = createMemo(() => sync.data.path.home)
+  const root = createMemo(() => sync.data.path.home || sync.data.path.directory)
+
+  function join(base: string | undefined, rel: string) {
+    const b = (base ?? "").replace(/[\\/]+$/, "")
+    const r = rel.replace(/^[\\/]+/, "").replace(/[\\/]+$/, "")
+    if (!b) return r
+    if (!r) return b
+    return b + "/" + r
+  }
+
+  function display(rel: string) {
+    const full = join(root(), rel)
+    const h = home()
+    if (!h) return full
+    if (full === h) return "~"
+    if (full.startsWith(h + "/") || full.startsWith(h + "\\")) {
+      return "~" + full.slice(h.length)
+    }
+    return full
+  }
+
+  function normalizeQuery(query: string) {
+    const h = home()
+
+    if (!query) return query
+    if (query.startsWith("~/")) return query.slice(2)
+
+    if (h) {
+      const lc = query.toLowerCase()
+      const hc = h.toLowerCase()
+      if (lc === hc || lc.startsWith(hc + "/") || lc.startsWith(hc + "\\")) {
+        return query.slice(h.length).replace(/^[\\/]+/, "")
+      }
+    }
+
+    return query
+  }
+
+  async function fetchDirs(query: string) {
+    const directory = root()
+    if (!directory) return [] as string[]
+
+    const results = await sdk.client.find
+      .files({ directory, query, type: "directory", limit: 50 })
+      .then((x) => x.data ?? [])
+      .catch(() => [])
+
+    return results.map((x) => x.replace(/[\\/]+$/, ""))
+  }
+
+  const directories = async (filter: string) => {
+    const query = normalizeQuery(filter.trim())
+    return fetchDirs(query)
+  }
+
+  function resolve(rel: string) {
+    const absolute = join(root(), rel)
+    props.onSelect(props.multiple ? [absolute] : absolute)
+    dialog.close()
+  }
+
+  return (
+    <Dialog title={props.title ?? "Open project"}>
+      <List
+        search={{ placeholder: "Search folders", autofocus: true }}
+        emptyMessage="No folders found"
+        items={directories}
+        key={(x) => x}
+        onSelect={(path) => {
+          if (!path) return
+          resolve(path)
+        }}
+      >
+        {(rel) => {
+          const path = display(rel)
+          return (
+            <div class="w-full flex items-center justify-between rounded-md">
+              <div class="flex items-center gap-x-3 grow min-w-0">
+                <FileIcon node={{ path: rel, type: "directory" }} class="shrink-0 size-4" />
+                <div class="flex items-center text-14-regular min-w-0">
+                  <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
+                    {getDirectory(path)}
+                  </span>
+                  <span class="text-text-strong whitespace-nowrap">{getFilename(path)}</span>
+                </div>
+              </div>
+            </div>
+          )
+        }}
+      </List>
+    </Dialog>
+  )
+}

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

@@ -1,4 +1,5 @@
-import { Component, createMemo, Show } from "solid-js"
+import { Popover as Kobalte } from "@kobalte/core/popover"
+import { Component, createMemo, createSignal, JSX, Show } from "solid-js"
 import { useLocal } from "@/context/local"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { popularProviders } from "@/hooks/use-providers"
@@ -9,9 +10,12 @@ import { List } from "@opencode-ai/ui/list"
 import { DialogSelectProvider } from "./dialog-select-provider"
 import { DialogManageModels } from "./dialog-manage-models"
 
-export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
+const ModelList: Component<{
+  provider?: string
+  class?: string
+  onSelect: () => void
+}> = (props) => {
   const local = useLocal()
-  const dialog = useDialog()
 
   const models = createMemo(() =>
     local.model
@@ -20,6 +24,70 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
       .filter((m) => (props.provider ? m.provider.id === props.provider : true)),
   )
 
+  return (
+    <List
+      class={`flex-1 min-h-0 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0 ${props.class ?? ""}`}
+      search={{ placeholder: "Search models", autofocus: true }}
+      emptyMessage="No model results"
+      key={(x) => `${x.provider.id}:${x.id}`}
+      items={models}
+      current={local.model.current()}
+      filterKeys={["provider.name", "name", "id"]}
+      sortBy={(a, b) => a.name.localeCompare(b.name)}
+      groupBy={(x) => x.provider.name}
+      sortGroupsBy={(a, b) => {
+        if (a.category === "Recent" && b.category !== "Recent") return -1
+        if (b.category === "Recent" && a.category !== "Recent") return 1
+        const aProvider = a.items[0].provider.id
+        const bProvider = b.items[0].provider.id
+        if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
+        if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
+        return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
+      }}
+      onSelect={(x) => {
+        local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
+          recent: true,
+        })
+        props.onSelect()
+      }}
+    >
+      {(i) => (
+        <div class="w-full flex items-center gap-x-2 text-13-regular">
+          <span class="truncate">{i.name}</span>
+          <Show when={i.provider.id === "opencode" && (!i.cost || i.cost?.input === 0)}>
+            <Tag>Free</Tag>
+          </Show>
+          <Show when={i.latest}>
+            <Tag>Latest</Tag>
+          </Show>
+        </div>
+      )}
+    </List>
+  )
+}
+
+export const ModelSelectorPopover: Component<{
+  provider?: string
+  children: JSX.Element
+}> = (props) => {
+  const [open, setOpen] = createSignal(false)
+
+  return (
+    <Kobalte open={open()} onOpenChange={setOpen} placement="top-start" gutter={8}>
+      <Kobalte.Trigger as="div">{props.children}</Kobalte.Trigger>
+      <Kobalte.Portal>
+        <Kobalte.Content class="w-72 h-80 flex flex-col rounded-md border border-border-base bg-surface-raised-stronger-non-alpha shadow-md z-50 outline-none">
+          <Kobalte.Title class="sr-only">Select model</Kobalte.Title>
+          <ModelList provider={props.provider} onSelect={() => setOpen(false)} class="p-1" />
+        </Kobalte.Content>
+      </Kobalte.Portal>
+    </Kobalte>
+  )
+}
+
+export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
+  const dialog = useDialog()
+
   return (
     <Dialog
       title="Select model"
@@ -34,43 +102,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
         </Button>
       }
     >
-      <List
-        search={{ placeholder: "Search models", autofocus: true }}
-        emptyMessage="No model results"
-        key={(x) => `${x.provider.id}:${x.id}`}
-        items={models}
-        current={local.model.current()}
-        filterKeys={["provider.name", "name", "id"]}
-        sortBy={(a, b) => a.name.localeCompare(b.name)}
-        groupBy={(x) => x.provider.name}
-        sortGroupsBy={(a, b) => {
-          if (a.category === "Recent" && b.category !== "Recent") return -1
-          if (b.category === "Recent" && a.category !== "Recent") return 1
-          const aProvider = a.items[0].provider.id
-          const bProvider = b.items[0].provider.id
-          if (popularProviders.includes(aProvider) && !popularProviders.includes(bProvider)) return -1
-          if (!popularProviders.includes(aProvider) && popularProviders.includes(bProvider)) return 1
-          return popularProviders.indexOf(aProvider) - popularProviders.indexOf(bProvider)
-        }}
-        onSelect={(x) => {
-          local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
-            recent: true,
-          })
-          dialog.close()
-        }}
-      >
-        {(i) => (
-          <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>
-            </Show>
-            <Show when={i.latest}>
-              <Tag>Latest</Tag>
-            </Show>
-          </div>
-        )}
-      </List>
+      <ModelList provider={props.provider} onSelect={() => dialog.close()} />
       <Button
         variant="ghost"
         class="ml-3 mt-5 mb-6 text-text-base self-start"

+ 179 - 0
packages/app/src/components/dialog-select-server.tsx

@@ -0,0 +1,179 @@
+import { createEffect, createMemo, onCleanup } from "solid-js"
+import { createStore, reconcile } from "solid-js/store"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { Button } from "@opencode-ai/ui/button"
+import { normalizeServerUrl, serverDisplayName, useServer } from "@/context/server"
+import { usePlatform } from "@/context/platform"
+import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+import { useNavigate } from "@solidjs/router"
+
+type ServerStatus = { healthy: boolean; version?: string }
+
+async function checkHealth(url: string, fetch?: typeof globalThis.fetch): Promise<ServerStatus> {
+  const sdk = createOpencodeClient({
+    baseUrl: url,
+    fetch,
+    signal: AbortSignal.timeout(3000),
+  })
+  return sdk.global
+    .health()
+    .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version }))
+    .catch(() => ({ healthy: false }))
+}
+
+export function DialogSelectServer() {
+  const navigate = useNavigate()
+  const dialog = useDialog()
+  const server = useServer()
+  const platform = usePlatform()
+  const [store, setStore] = createStore({
+    url: "",
+    adding: false,
+    error: "",
+    status: {} as Record<string, ServerStatus | undefined>,
+  })
+
+  const items = createMemo(() => {
+    const current = server.url
+    const list = server.list
+    if (!current) return list
+    if (!list.includes(current)) return [current, ...list]
+    return [current, ...list.filter((x) => x !== current)]
+  })
+
+  const current = createMemo(() => items().find((x) => x === server.url) ?? items()[0])
+
+  const sortedItems = createMemo(() => {
+    const list = items()
+    if (!list.length) return list
+    const active = current()
+    const order = new Map(list.map((url, index) => [url, index] as const))
+    const rank = (value?: ServerStatus) => {
+      if (value?.healthy === true) return 0
+      if (value?.healthy === false) return 2
+      return 1
+    }
+    return list.slice().sort((a, b) => {
+      if (a === active) return -1
+      if (b === active) return 1
+      const diff = rank(store.status[a]) - rank(store.status[b])
+      if (diff !== 0) return diff
+      return (order.get(a) ?? 0) - (order.get(b) ?? 0)
+    })
+  })
+
+  async function refreshHealth() {
+    const results: Record<string, ServerStatus> = {}
+    await Promise.all(
+      items().map(async (url) => {
+        results[url] = await checkHealth(url, platform.fetch)
+      }),
+    )
+    setStore("status", reconcile(results))
+  }
+
+  createEffect(() => {
+    items()
+    refreshHealth()
+    const interval = setInterval(refreshHealth, 10_000)
+    onCleanup(() => clearInterval(interval))
+  })
+
+  function select(value: string, persist?: boolean) {
+    if (!persist && store.status[value]?.healthy === false) return
+    dialog.close()
+    if (persist) {
+      server.add(value)
+      navigate("/")
+      return
+    }
+    server.setActive(value)
+    navigate("/")
+  }
+
+  async function handleSubmit(e: SubmitEvent) {
+    e.preventDefault()
+    const value = normalizeServerUrl(store.url)
+    if (!value) return
+
+    setStore("adding", true)
+    setStore("error", "")
+
+    const result = await checkHealth(value, platform.fetch)
+    setStore("adding", false)
+
+    if (!result.healthy) {
+      setStore("error", "Could not connect to server")
+      return
+    }
+
+    setStore("url", "")
+    select(value, true)
+  }
+
+  return (
+    <Dialog title="Servers" description="Switch which OpenCode server this app connects to.">
+      <div class="flex flex-col gap-4 pb-4">
+        <List
+          search={{ placeholder: "Search servers", autofocus: true }}
+          emptyMessage="No servers yet"
+          items={sortedItems}
+          key={(x) => x}
+          current={current()}
+          onSelect={(x) => {
+            if (x) select(x)
+          }}
+        >
+          {(i) => (
+            <div
+              class="flex items-center gap-2 min-w-0 flex-1"
+              classList={{ "opacity-50": store.status[i]?.healthy === false }}
+            >
+              <div
+                classList={{
+                  "size-1.5 rounded-full shrink-0": true,
+                  "bg-icon-success-base": store.status[i]?.healthy === true,
+                  "bg-icon-critical-base": store.status[i]?.healthy === false,
+                  "bg-border-weak-base": store.status[i] === undefined,
+                }}
+              />
+              <span class="truncate">{serverDisplayName(i)}</span>
+              <span class="text-text-weak">{store.status[i]?.version}</span>
+            </div>
+          )}
+        </List>
+
+        <div class="mt-6 px-3 flex flex-col gap-1.5">
+          <div class="px-3">
+            <h3 class="text-14-regular text-text-weak">Add a server</h3>
+          </div>
+          <form onSubmit={handleSubmit}>
+            <div class="flex items-start gap-2">
+              <div class="flex-1 min-w-0 h-auto">
+                <TextField
+                  type="text"
+                  label="Server URL"
+                  hideLabel
+                  placeholder="http://localhost:4096"
+                  value={store.url}
+                  onChange={(v) => {
+                    setStore("url", v)
+                    setStore("error", "")
+                  }}
+                  validationState={store.error ? "invalid" : "valid"}
+                  error={store.error}
+                />
+              </div>
+              <Button type="submit" variant="secondary" icon="plus-small" size="large" disabled={store.adding}>
+                {store.adding ? "Checking..." : "Add"}
+              </Button>
+            </div>
+          </form>
+        </div>
+      </div>
+    </Dialog>
+  )
+}

+ 0 - 215
packages/app/src/components/header.tsx

@@ -1,215 +0,0 @@
-import { useGlobalSync } from "@/context/global-sync"
-import { useGlobalSDK } from "@/context/global-sdk"
-import { useLayout } from "@/context/layout"
-import { Session } from "@opencode-ai/sdk/v2/client"
-import { Button } from "@opencode-ai/ui/button"
-import { Icon } from "@opencode-ai/ui/icon"
-import { Mark } from "@opencode-ai/ui/logo"
-import { Popover } from "@opencode-ai/ui/popover"
-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"
-import { IconButton } from "@opencode-ai/ui/icon-button"
-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,
-          "items-center justify-start self-stretch": true,
-          "border-r border-border-weak-base": true,
-        }}
-        style={{ width: layout.sidebar.opened() ? `${layout.sidebar.width()}px` : undefined }}
-        data-tauri-drag-region
-      >
-        <Mark class="shrink-0" />
-      </A>
-      <div class="pl-4 px-6 flex items-center justify-between gap-4 w-full">
-        <Show when={layout.projects.list().length > 0 && params.dir}>
-          {(directory) => {
-            const currentDirectory = createMemo(() => base64Decode(directory()))
-            const store = createMemo(() => globalSync.child(currentDirectory())[0])
-            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 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()}
-                      placeholder="New session"
-                      label={(x) => x.title}
-                      value={(x) => x.id}
-                      onSelect={props.navigateToSession}
-                      class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
-                      variant="ghost"
-                    />
-                  </div>
-                  <Show when={currentSession()}>
-                    <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">
-                  <Show when={currentSession()?.summary?.files}>
-                    <Tooltip
-                      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
-                            name={layout.review.opened() ? "layout-right" : "layout-left"}
-                            size="small"
-                            class="group-hover/review-toggle:hidden"
-                          />
-                          <Icon
-                            name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
-                            size="small"
-                            class="hidden group-hover/review-toggle:inline-block"
-                          />
-                          <Icon
-                            name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
-                            size="small"
-                            class="hidden group-active/review-toggle:inline-block"
-                          />
-                        </div>
-                      </Button>
-                    </Tooltip>
-                  </Show>
-                  <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">{command.keybind("terminal.toggle")}</span>
-                      </div>
-                    }
-                  >
-                    <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
-                      <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                        <Icon
-                          size="small"
-                          name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
-                          class="group-hover/terminal-toggle:hidden"
-                        />
-                        <Icon
-                          size="small"
-                          name="layout-bottom-partial"
-                          class="hidden group-hover/terminal-toggle:inline-block"
-                        />
-                        <Icon
-                          size="small"
-                          name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
-                          class="hidden group-active/terminal-toggle:inline-block"
-                        />
-                      </div>
-                    </Button>
-                  </Tooltip>
-                  <Show when={shareEnabled() && currentSession()}>
-                    <Popover
-                      title="Share session"
-                      trigger={
-                        <Tooltip class="shrink-0" value="Share session">
-                          <IconButton icon="share" variant="ghost" class="" />
-                        </Tooltip>
-                      }
-                    >
-                      {iife(() => {
-                        const [url] = createResource(
-                          () => currentSession(),
-                          async (session) => {
-                            if (!session) return
-                            let shareURL = session.share?.url
-                            if (!shareURL) {
-                              shareURL = await globalSDK.client.session
-                                .share({ sessionID: session.id, directory: currentDirectory() })
-                                .then((r) => r.data?.share?.url)
-                                .catch((e) => {
-                                  console.error("Failed to share session", e)
-                                  return undefined
-                                })
-                            }
-                            return shareURL
-                          },
-                        )
-                        return (
-                          <Show when={url()}>
-                            {(url) => <TextField value={url()} readOnly copyable class="w-72" />}
-                          </Show>
-                        )
-                      })}
-                    </Popover>
-                  </Show>
-                </div>
-              </>
-            )
-          }}
-        </Show>
-      </div>
-    </header>
-  )
-}

+ 508 - 115
packages/app/src/components/prompt-input.tsx

@@ -3,7 +3,15 @@ import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Mat
 import { createStore, produce } from "solid-js/store"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
-import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
+import {
+  ContentPart,
+  DEFAULT_PROMPT,
+  isPromptEqual,
+  Prompt,
+  usePrompt,
+  ImageAttachmentPart,
+  AgentPart,
+} from "@/context/prompt"
 import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
 import { useNavigate, useParams } from "@solidjs/router"
@@ -11,18 +19,19 @@ import { useSync } from "@/context/sync"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Select } from "@opencode-ai/ui/select"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
-import { DialogSelectModel } from "@/components/dialog-select-model"
+import { ModelSelectorPopover } from "@/components/dialog-select-model"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
 import { useCommand } from "@/context/command"
 import { persisted } from "@/utils/persist"
 import { Identifier } from "@/utils/id"
 import { SessionContextUsage } from "@/components/session-context-usage"
+import { usePermission } from "@/context/permission"
 
 const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
 const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
@@ -80,6 +89,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const dialog = useDialog()
   const providers = useProviders()
   const command = useCommand()
+  const permission = usePermission()
   let editorRef!: HTMLDivElement
   let fileInputRef!: HTMLInputElement
   let scrollRef!: HTMLDivElement
@@ -126,7 +136,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const working = createMemo(() => status()?.type !== "idle")
 
   const [store, setStore] = createStore<{
-    popover: "file" | "slash" | null
+    popover: "at" | "slash" | null
     historyIndex: number
     savedPrompt: Prompt | null
     placeholder: number
@@ -134,6 +144,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     imageAttachments: ImageAttachmentPart[]
     mode: "normal" | "shell"
     applyingHistory: boolean
+    killBuffer: string
   }>({
     popover: null,
     historyIndex: -1,
@@ -143,6 +154,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     imageAttachments: [],
     mode: "normal",
     applyingHistory: false,
+    killBuffer: "",
   })
 
   const MAX_HISTORY = 100
@@ -167,6 +179,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     prompt.map((part) => {
       if (part.type === "text") return { ...part }
       if (part.type === "image") return { ...part }
+      if (part.type === "agent") return { ...part }
       return {
         ...part,
         selection: part.selection ? { ...part.selection } : undefined,
@@ -317,15 +330,43 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!isFocused()) setStore("popover", null)
   })
 
-  const handleFileSelect = (path: string | undefined) => {
-    if (!path) return
-    addPart({ type: "file", path, content: "@" + path, start: 0, end: 0 })
+  type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string }
+
+  const agentList = createMemo(() =>
+    sync.data.agent
+      .filter((agent) => !agent.hidden && agent.mode !== "primary")
+      .map((agent): AtOption => ({ type: "agent", name: agent.name, display: agent.name })),
+  )
+
+  const handleAtSelect = (option: AtOption | undefined) => {
+    if (!option) return
+    if (option.type === "agent") {
+      addPart({ type: "agent", name: option.name, content: "@" + option.name, start: 0, end: 0 })
+    } else {
+      addPart({ type: "file", path: option.path, content: "@" + option.path, start: 0, end: 0 })
+    }
   }
 
-  const { flat, active, onInput, onKeyDown } = useFilteredList<string>({
-    items: local.file.searchFilesAndDirectories,
-    key: (x) => x,
-    onSelect: handleFileSelect,
+  const atKey = (x: AtOption | undefined) => {
+    if (!x) return ""
+    return x.type === "agent" ? `agent:${x.name}` : `file:${x.path}`
+  }
+
+  const {
+    flat: atFlat,
+    active: atActive,
+    onInput: atOnInput,
+    onKeyDown: atOnKeyDown,
+  } = useFilteredList<AtOption>({
+    items: async (query) => {
+      const agents = agentList()
+      const files = await local.file.searchFilesAndDirectories(query)
+      const fileOptions: AtOption[] = files.map((path) => ({ type: "file", path, display: path }))
+      return [...agents, ...fileOptions]
+    },
+    key: atKey,
+    filterKeys: ["display"],
+    onSelect: handleAtSelect,
   })
 
   const slashCommands = createMemo<SlashCommand[]>(() => {
@@ -411,6 +452,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           if (node.nodeType !== Node.ELEMENT_NODE) return false
           const el = node as HTMLElement
           if (el.dataset.type === "file") return true
+          if (el.dataset.type === "agent") return true
           return el.tagName === "BR"
         })
         if (normalized && isPromptEqual(currentParts, domParts)) return
@@ -434,6 +476,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             pill.style.userSelect = "text"
             pill.style.cursor = "default"
             editorRef.appendChild(pill)
+          } else if (part.type === "agent") {
+            const pill = document.createElement("span")
+            pill.textContent = part.content
+            pill.setAttribute("data-type", "agent")
+            pill.setAttribute("data-name", part.name)
+            pill.setAttribute("contenteditable", "false")
+            pill.style.userSelect = "text"
+            pill.style.cursor = "default"
+            editorRef.appendChild(pill)
           }
         })
 
@@ -469,6 +520,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       position += content.length
     }
 
+    const pushAgent = (agent: HTMLElement) => {
+      const content = agent.textContent ?? ""
+      parts.push({
+        type: "agent",
+        name: agent.dataset.name!,
+        content,
+        start: position,
+        end: position + content.length,
+      })
+      position += content.length
+    }
+
     const visit = (node: Node) => {
       if (node.nodeType === Node.TEXT_NODE) {
         buffer += node.textContent ?? ""
@@ -482,6 +545,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         pushFile(el)
         return
       }
+      if (el.dataset.type === "agent") {
+        flushText()
+        pushAgent(el)
+        return
+      }
       if (el.tagName === "BR") {
         buffer += "\n"
         return
@@ -535,8 +603,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const slashMatch = rawText.match(/^\/(\S*)$/)
 
       if (atMatch) {
-        onInput(atMatch[1])
-        setStore("popover", "file")
+        atOnInput(atMatch[1])
+        setStore("popover", "at")
       } else if (slashMatch) {
         slashOnInput(slashMatch[1])
         setStore("popover", "slash")
@@ -556,6 +624,36 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     queueScroll()
   }
 
+  const setRangeEdge = (range: Range, edge: "start" | "end", offset: number) => {
+    let remaining = offset
+    const nodes = Array.from(editorRef.childNodes)
+
+    for (const node of nodes) {
+      const length = getNodeLength(node)
+      const isText = node.nodeType === Node.TEXT_NODE
+      const isPill =
+        node.nodeType === Node.ELEMENT_NODE &&
+        ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
+      const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+      if (isText && remaining <= length) {
+        if (edge === "start") range.setStart(node, remaining)
+        if (edge === "end") range.setEnd(node, remaining)
+        return
+      }
+
+      if ((isPill || isBreak) && remaining <= length) {
+        if (edge === "start" && remaining === 0) range.setStartBefore(node)
+        if (edge === "start" && remaining > 0) range.setStartAfter(node)
+        if (edge === "end" && remaining === 0) range.setEndBefore(node)
+        if (edge === "end" && remaining > 0) range.setEndAfter(node)
+        return
+      }
+
+      remaining -= length
+    }
+  }
+
   const addPart = (part: ContentPart) => {
     const selection = window.getSelection()
     if (!selection || selection.rangeCount === 0) return
@@ -578,38 +676,35 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const gap = document.createTextNode(" ")
       const range = selection.getRangeAt(0)
 
-      const setEdge = (edge: "start" | "end", offset: number) => {
-        let remaining = offset
-        const nodes = Array.from(editorRef.childNodes)
-
-        for (const node of nodes) {
-          const length = getNodeLength(node)
-          const isText = node.nodeType === Node.TEXT_NODE
-          const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
-          const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
-
-          if (isText && remaining <= length) {
-            if (edge === "start") range.setStart(node, remaining)
-            if (edge === "end") range.setEnd(node, remaining)
-            return
-          }
+      if (atMatch) {
+        const start = atMatch.index ?? cursorPosition - atMatch[0].length
+        setRangeEdge(range, "start", start)
+        setRangeEdge(range, "end", cursorPosition)
+      }
 
-          if ((isFile || isBreak) && remaining <= length) {
-            if (edge === "start" && remaining === 0) range.setStartBefore(node)
-            if (edge === "start" && remaining > 0) range.setStartAfter(node)
-            if (edge === "end" && remaining === 0) range.setEndBefore(node)
-            if (edge === "end" && remaining > 0) range.setEndAfter(node)
-            return
-          }
+      range.deleteContents()
+      range.insertNode(gap)
+      range.insertNode(pill)
+      range.setStartAfter(gap)
+      range.collapse(true)
+      selection.removeAllRanges()
+      selection.addRange(range)
+    } else if (part.type === "agent") {
+      const pill = document.createElement("span")
+      pill.textContent = part.content
+      pill.setAttribute("data-type", "agent")
+      pill.setAttribute("data-name", part.name)
+      pill.setAttribute("contenteditable", "false")
+      pill.style.userSelect = "text"
+      pill.style.cursor = "default"
 
-          remaining -= length
-        }
-      }
+      const gap = document.createTextNode(" ")
+      const range = selection.getRangeAt(0)
 
       if (atMatch) {
         const start = atMatch.index ?? cursorPosition - atMatch[0].length
-        setEdge("start", start)
-        setEdge("end", cursorPosition)
+        setRangeEdge(range, "start", start)
+        setRangeEdge(range, "end", cursorPosition)
       }
 
       range.deleteContents()
@@ -648,6 +743,77 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setStore("popover", null)
   }
 
+  const setSelectionOffsets = (start: number, end: number) => {
+    const selection = window.getSelection()
+    if (!selection) return false
+
+    const length = promptLength(prompt.current())
+    const a = Math.max(0, Math.min(start, length))
+    const b = Math.max(0, Math.min(end, length))
+    const rangeStart = Math.min(a, b)
+    const rangeEnd = Math.max(a, b)
+
+    const range = document.createRange()
+    range.selectNodeContents(editorRef)
+
+    const setEdge = (edge: "start" | "end", offset: number) => {
+      let remaining = offset
+      const nodes = Array.from(editorRef.childNodes)
+
+      for (const node of nodes) {
+        const length = getNodeLength(node)
+        const isText = node.nodeType === Node.TEXT_NODE
+        const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+        const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
+
+        if (isText && remaining <= length) {
+          if (edge === "start") range.setStart(node, remaining)
+          if (edge === "end") range.setEnd(node, remaining)
+          return
+        }
+
+        if ((isFile || isBreak) && remaining <= length) {
+          if (edge === "start" && remaining === 0) range.setStartBefore(node)
+          if (edge === "start" && remaining > 0) range.setStartAfter(node)
+          if (edge === "end" && remaining === 0) range.setEndBefore(node)
+          if (edge === "end" && remaining > 0) range.setEndAfter(node)
+          return
+        }
+
+        remaining -= length
+      }
+
+      const last = editorRef.lastChild
+      if (!last) {
+        if (edge === "start") range.setStart(editorRef, 0)
+        if (edge === "end") range.setEnd(editorRef, 0)
+        return
+      }
+      if (edge === "start") range.setStartAfter(last)
+      if (edge === "end") range.setEndAfter(last)
+    }
+
+    setEdge("start", rangeStart)
+    setEdge("end", rangeEnd)
+    selection.removeAllRanges()
+    selection.addRange(range)
+    return true
+  }
+
+  const replaceOffsets = (start: number, end: number, content: string) => {
+    if (!setSelectionOffsets(start, end)) return false
+    addPart({ type: "text", content, start: 0, end: 0 })
+    return true
+  }
+
+  const killText = (start: number, end: number) => {
+    if (start === end) return
+    const current = prompt.current()
+    if (!current.every((part) => part.type === "text")) return
+    const text = current.map((part) => part.content).join("")
+    setStore("killBuffer", text.slice(start, end))
+  }
+
   const abort = () =>
     sdk.client.session
       .abort({
@@ -759,8 +925,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
 
     if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
-      if (store.popover === "file") {
-        onKeyDown(event)
+      if (store.popover === "at") {
+        atOnKeyDown(event)
       } else {
         slashOnKeyDown(event)
       }
@@ -768,6 +934,164 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       return
     }
 
+    const ctrl = event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey
+    const alt = event.altKey && !event.metaKey && !event.ctrlKey && !event.shiftKey
+
+    if (ctrl && event.code === "KeyG") {
+      if (store.popover) {
+        setStore("popover", null)
+        event.preventDefault()
+        return
+      }
+      if (working()) {
+        abort()
+        event.preventDefault()
+      }
+      return
+    }
+
+    if (ctrl || alt) {
+      const { collapsed, cursorPosition, textLength } = getCaretState()
+      if (collapsed) {
+        const current = prompt.current()
+        const text = current.map((part) => ("content" in part ? part.content : "")).join("")
+
+        if (ctrl) {
+          if (event.code === "KeyA") {
+            const pos = text.lastIndexOf("\n", cursorPosition - 1) + 1
+            setCursorPosition(editorRef, pos)
+            event.preventDefault()
+            queueScroll()
+            return
+          }
+
+          if (event.code === "KeyE") {
+            const next = text.indexOf("\n", cursorPosition)
+            const pos = next === -1 ? textLength : next
+            setCursorPosition(editorRef, pos)
+            event.preventDefault()
+            queueScroll()
+            return
+          }
+
+          if (event.code === "KeyB") {
+            const pos = Math.max(0, cursorPosition - 1)
+            setCursorPosition(editorRef, pos)
+            event.preventDefault()
+            queueScroll()
+            return
+          }
+
+          if (event.code === "KeyF") {
+            const pos = Math.min(textLength, cursorPosition + 1)
+            setCursorPosition(editorRef, pos)
+            event.preventDefault()
+            queueScroll()
+            return
+          }
+
+          if (event.code === "KeyD") {
+            if (store.mode === "shell" && cursorPosition === 0 && textLength === 0) {
+              setStore("mode", "normal")
+              event.preventDefault()
+              return
+            }
+            if (cursorPosition >= textLength) return
+            replaceOffsets(cursorPosition, cursorPosition + 1, "")
+            event.preventDefault()
+            return
+          }
+
+          if (event.code === "KeyK") {
+            const next = text.indexOf("\n", cursorPosition)
+            const lineEnd = next === -1 ? textLength : next
+            const end = lineEnd === cursorPosition && lineEnd < textLength ? lineEnd + 1 : lineEnd
+            if (end === cursorPosition) return
+            killText(cursorPosition, end)
+            replaceOffsets(cursorPosition, end, "")
+            event.preventDefault()
+            return
+          }
+
+          if (event.code === "KeyU") {
+            const start = text.lastIndexOf("\n", cursorPosition - 1) + 1
+            if (start === cursorPosition) return
+            killText(start, cursorPosition)
+            replaceOffsets(start, cursorPosition, "")
+            event.preventDefault()
+            return
+          }
+
+          if (event.code === "KeyW") {
+            let start = cursorPosition
+            while (start > 0 && /\s/.test(text[start - 1])) start -= 1
+            while (start > 0 && !/\s/.test(text[start - 1])) start -= 1
+            if (start === cursorPosition) return
+            killText(start, cursorPosition)
+            replaceOffsets(start, cursorPosition, "")
+            event.preventDefault()
+            return
+          }
+
+          if (event.code === "KeyY") {
+            if (!store.killBuffer) return
+            addPart({ type: "text", content: store.killBuffer, start: 0, end: 0 })
+            event.preventDefault()
+            return
+          }
+
+          if (event.code === "KeyT") {
+            if (!current.every((part) => part.type === "text")) return
+            if (textLength < 2) return
+            if (cursorPosition === 0) return
+
+            const atEnd = cursorPosition === textLength
+            const first = atEnd ? cursorPosition - 2 : cursorPosition - 1
+            const second = atEnd ? cursorPosition - 1 : cursorPosition
+
+            if (text[first] === "\n" || text[second] === "\n") return
+
+            replaceOffsets(first, second + 1, `${text[second]}${text[first]}`)
+            event.preventDefault()
+            return
+          }
+        }
+
+        if (alt) {
+          if (event.code === "KeyB") {
+            let pos = cursorPosition
+            while (pos > 0 && /\s/.test(text[pos - 1])) pos -= 1
+            while (pos > 0 && !/\s/.test(text[pos - 1])) pos -= 1
+            setCursorPosition(editorRef, pos)
+            event.preventDefault()
+            queueScroll()
+            return
+          }
+
+          if (event.code === "KeyF") {
+            let pos = cursorPosition
+            while (pos < textLength && /\s/.test(text[pos])) pos += 1
+            while (pos < textLength && !/\s/.test(text[pos])) pos += 1
+            setCursorPosition(editorRef, pos)
+            event.preventDefault()
+            queueScroll()
+            return
+          }
+
+          if (event.code === "KeyD") {
+            let end = cursorPosition
+            while (end < textLength && /\s/.test(text[end])) end += 1
+            while (end < textLength && !/\s/.test(text[end])) end += 1
+            if (end === cursorPosition) return
+            killText(cursorPosition, end)
+            replaceOffsets(cursorPosition, end, "")
+            event.preventDefault()
+            return
+          }
+        }
+      }
+    }
+
     if (event.key === "ArrowUp" || event.key === "ArrowDown") {
       if (event.altKey || event.ctrlKey || event.metaKey) return
       const { collapsed } = getCaretState()
@@ -842,11 +1166,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!existing) return
 
     const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
-    const attachments = currentPrompt.filter(
+    const fileAttachments = currentPrompt.filter(
       (part) => part.type === "file",
     ) as import("@/context/prompt").FileAttachmentPart[]
+    const agentAttachments = currentPrompt.filter((part) => part.type === "agent") as AgentPart[]
 
-    const fileAttachmentParts = attachments.map((attachment) => {
+    const fileAttachmentParts = fileAttachments.map((attachment) => {
       const absolute = toAbsolutePath(attachment.path)
       const query = attachment.selection
         ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
@@ -869,6 +1194,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       }
     })
 
+    const agentAttachmentParts = agentAttachments.map((attachment) => ({
+      id: Identifier.ascending("part"),
+      type: "agent" as const,
+      name: attachment.name,
+      source: {
+        value: attachment.content,
+        start: attachment.start,
+        end: attachment.end,
+      },
+    }))
+
     const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
       id: Identifier.ascending("part"),
       type: "file" as const,
@@ -884,11 +1220,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     setStore("imageAttachments", [])
     setStore("mode", "normal")
 
+    const currentModel = local.model.current()
+    const currentAgent = local.agent.current()
+    if (!currentModel || !currentAgent) {
+      console.warn("No agent or model available for prompt submission")
+      return
+    }
     const model = {
-      modelID: local.model.current()!.id,
-      providerID: local.model.current()!.provider.id,
+      modelID: currentModel.id,
+      providerID: currentModel.provider.id,
     }
-    const agent = local.agent.current()!.name
+    const agent = currentAgent.name
+    const variant = local.model.variant.current()
 
     if (isShellMode) {
       sdk.client.session
@@ -916,6 +1259,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             arguments: args.join(" "),
             agent,
             model: `${model.providerID}/${model.modelID}`,
+            variant,
           })
           .catch((e) => {
             console.error("Failed to send command", e)
@@ -930,7 +1274,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       type: "text" as const,
       text,
     }
-    const requestParts = [textPart, ...fileAttachmentParts, ...imageAttachmentParts]
+    const requestParts = [textPart, ...fileAttachmentParts, ...agentAttachmentParts, ...imageAttachmentParts]
     const optimisticParts = requestParts.map((part) => ({
       ...part,
       sessionID: existing.id,
@@ -952,6 +1296,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         model,
         messageID,
         parts: requestParts,
+        variant,
       })
       .catch((e) => {
         console.error("Failed to send prompt", e)
@@ -967,24 +1312,46 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                  border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
         >
           <Switch>
-            <Match when={store.popover === "file"}>
-              <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}>
-                <For each={flat()}>
-                  {(i) => (
+            <Match when={store.popover === "at"}>
+              <Show
+                when={atFlat().length > 0}
+                fallback={<div class="text-text-weak px-2 py-1">No matching results</div>}
+              >
+                <For each={atFlat().slice(0, 10)}>
+                  {(item) => (
                     <button
                       classList={{
                         "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
-                        "bg-surface-raised-base-hover": active() === i,
+                        "bg-surface-raised-base-hover": atActive() === atKey(item),
                       }}
-                      onClick={() => handleFileSelect(i)}
+                      onClick={() => handleAtSelect(item)}
                     >
-                      <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
-                      <div class="flex items-center text-14-regular min-w-0">
-                        <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span>
-                        <Show when={!i.endsWith("/")}>
-                          <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
-                        </Show>
-                      </div>
+                      <Show
+                        when={item.type === "agent"}
+                        fallback={
+                          <>
+                            <FileIcon
+                              node={{ path: (item as { type: "file"; path: string }).path, type: "file" }}
+                              class="shrink-0 size-4"
+                            />
+                            <div class="flex items-center text-14-regular min-w-0">
+                              <span class="text-text-weak whitespace-nowrap truncate min-w-0">
+                                {getDirectory((item as { type: "file"; path: string }).path)}
+                              </span>
+                              <Show when={!(item as { type: "file"; path: string }).path.endsWith("/")}>
+                                <span class="text-text-strong whitespace-nowrap">
+                                  {getFilename((item as { type: "file"; path: string }).path)}
+                                </span>
+                              </Show>
+                            </div>
+                          </>
+                        }
+                      >
+                        <Icon name="brain" size="small" class="text-icon-info-active shrink-0" />
+                        <span class="text-14-regular text-text-strong whitespace-nowrap">
+                          @{(item as { type: "agent"; name: string }).name}
+                        </span>
+                      </Show>
                     </button>
                   )}
                 </For>
@@ -1031,6 +1398,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       <form
         onSubmit={handleSubmit}
         classList={{
+          "group/prompt-input": true,
           "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
           "rounded-md overflow-clip focus-within:shadow-xs-border": true,
           "border-icon-info-active border-dashed": store.dragging,
@@ -1090,8 +1458,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             onInput={handleInput}
             onKeyDown={handleKeyDown}
             classList={{
+              "select-text": true,
               "w-full px-5 py-3 pr-12 text-14-regular text-text-strong focus:outline-none whitespace-pre-wrap": true,
-              "[&_[data-type=file]]:text-icon-info-active": true,
+              "[&_[data-type=file]]:text-syntax-property": true,
+              "[&_[data-type=agent]]:text-syntax-type": true,
               "font-mono!": store.mode === "shell",
             }}
           />
@@ -1102,12 +1472,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 : `Ask anything... "${PLACEHOLDERS[store.placeholder]}"`}
             </div>
           </Show>
-          <div class="absolute top-4.5 right-4">
-            <SessionContextUsage />
-          </div>
         </div>
         <div class="relative p-3 flex items-center justify-between">
-          <div class="flex items-center justify-start gap-1">
+          <div class="flex items-center justify-start gap-0.5">
             <Switch>
               <Match when={store.mode === "shell"}>
                 <div class="flex items-center gap-2 px-2 h-6">
@@ -1117,52 +1484,77 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 </div>
               </Match>
               <Match when={store.mode === "normal"}>
-                <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>
-                  }
-                >
+                <TooltipKeybind placement="top" title="Cycle agent" keybind={command.keybind("agent.cycle")}>
                   <Select
                     options={local.agent.list().map((agent) => agent.name)}
-                    current={local.agent.current().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>
+                </TooltipKeybind>
+                <Show
+                  when={providers.paid().length > 0}
+                  fallback={
+                    <TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
+                      <Button as="div" variant="ghost" onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}>
+                        {local.model.current()?.name ?? "Select model"}
+                        <Icon name="chevron-down" size="small" />
+                      </Button>
+                    </TooltipKeybind>
                   }
                 >
-                  <Button
-                    as="div"
-                    variant="ghost"
-                    onClick={() =>
-                      dialog.show(() =>
-                        providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />,
-                      )
-                    }
+                  <ModelSelectorPopover>
+                    <TooltipKeybind placement="top" title="Choose model" keybind={command.keybind("model.choose")}>
+                      <Button as="div" variant="ghost">
+                        {local.model.current()?.name ?? "Select model"}
+                        <Icon name="chevron-down" size="small" />
+                      </Button>
+                    </TooltipKeybind>
+                  </ModelSelectorPopover>
+                </Show>
+                <Show when={local.model.variant.list().length > 0}>
+                  <TooltipKeybind
+                    placement="top"
+                    title="Thinking effort"
+                    keybind={command.keybind("model.variant.cycle")}
                   >
-                    {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>
+                    <Button
+                      variant="ghost"
+                      class="text-text-base _hidden group-hover/prompt-input:inline-block"
+                      onClick={() => local.model.variant.cycle()}
+                    >
+                      <span class="capitalize text-12-regular">{local.model.variant.current() ?? "Default"}</span>
+                    </Button>
+                  </TooltipKeybind>
+                </Show>
+                <Show when={permission.permissionsEnabled() && params.id}>
+                  <TooltipKeybind
+                    placement="top"
+                    title="Auto-accept edits"
+                    keybind={command.keybind("permissions.autoaccept")}
+                  >
+                    <Button
+                      variant="ghost"
+                      onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
+                      classList={{
+                        "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
+                        "text-text-base": !permission.isAutoAccepting(params.id!),
+                        "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!),
+                      }}
+                    >
+                      <Icon
+                        name="chevron-double-right"
+                        size="small"
+                        classList={{ "text-icon-success-base": permission.isAutoAccepting(params.id!) }}
+                      />
+                    </Button>
+                  </TooltipKeybind>
+                </Show>
               </Match>
             </Switch>
           </div>
-          <div class="flex items-center gap-1 absolute right-2 bottom-2">
+          <div class="flex items-center gap-3 absolute right-2 bottom-2">
             <input
               ref={fileInputRef}
               type="file"
@@ -1174,17 +1566,16 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 e.currentTarget.value = ""
               }}
             />
-            <Show when={store.mode === "normal"}>
-              <Tooltip placement="top" value="Attach image">
-                <IconButton
-                  type="button"
-                  icon="photo"
-                  variant="ghost"
-                  class="h-10 w-8"
-                  onClick={() => fileInputRef.click()}
-                />
-              </Tooltip>
-            </Show>
+            <div class="flex items-center gap-2">
+              <SessionContextUsage />
+              <Show when={store.mode === "normal"}>
+                <Tooltip placement="top" value="Attach image">
+                  <Button type="button" variant="ghost" class="size-6" onClick={() => fileInputRef.click()}>
+                    <Icon name="photo" class="size-4.5" />
+                  </Button>
+                </Tooltip>
+              </Show>
+            </div>
             <Tooltip
               placement="top"
               inactive={!prompt.dirty() && !working()}
@@ -1210,7 +1601,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
                 icon={working() ? "stop" : "arrow-up"}
                 variant="primary"
-                class="h-10 w-8"
+                class="h-6 w-4.5"
               />
             </Tooltip>
           </div>
@@ -1268,7 +1659,9 @@ function setCursorPosition(parent: HTMLElement, position: number) {
   while (node) {
     const length = getNodeLength(node)
     const isText = node.nodeType === Node.TEXT_NODE
-    const isFile = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).dataset.type === "file"
+    const isPill =
+      node.nodeType === Node.ELEMENT_NODE &&
+      ((node as HTMLElement).dataset.type === "file" || (node as HTMLElement).dataset.type === "agent")
     const isBreak = node.nodeType === Node.ELEMENT_NODE && (node as HTMLElement).tagName === "BR"
 
     if (isText && remaining <= length) {
@@ -1281,13 +1674,13 @@ function setCursorPosition(parent: HTMLElement, position: number) {
       return
     }
 
-    if ((isFile || isBreak) && remaining <= length) {
+    if ((isPill || isBreak) && remaining <= length) {
       const range = document.createRange()
       const selection = window.getSelection()
       if (remaining === 0) {
         range.setStartBefore(node)
       }
-      if (remaining > 0 && isFile) {
+      if (remaining > 0 && isPill) {
         range.setStartAfter(node)
       }
       if (remaining > 0 && isBreak) {

+ 70 - 25
packages/app/src/components/session-context-usage.tsx

@@ -1,13 +1,25 @@
-import { createMemo, Show } from "solid-js"
+import { Match, Show, Switch, createMemo } from "solid-js"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { ProgressCircle } from "@opencode-ai/ui/progress-circle"
-import { useSync } from "@/context/sync"
+import { Button } from "@opencode-ai/ui/button"
 import { useParams } from "@solidjs/router"
 import { AssistantMessage } from "@opencode-ai/sdk/v2/client"
 
-export function SessionContextUsage() {
+import { useLayout } from "@/context/layout"
+import { useSync } from "@/context/sync"
+
+interface SessionContextUsageProps {
+  variant?: "button" | "indicator"
+}
+
+export function SessionContextUsage(props: SessionContextUsageProps) {
   const sync = useSync()
   const params = useParams()
+  const layout = useLayout()
+
+  const variant = createMemo(() => props.variant ?? "button")
+  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+  const tabs = createMemo(() => layout.tabs(sessionKey()))
   const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
 
   const cost = createMemo(() => {
@@ -19,7 +31,11 @@ export function SessionContextUsage() {
   })
 
   const context = createMemo(() => {
-    const last = messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage
+    const last = messages().findLast((x) => {
+      if (x.role !== "assistant") return false
+      const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
+      return total > 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
@@ -30,28 +46,57 @@ export function SessionContextUsage() {
     }
   })
 
-  return (
-    <Show when={context?.()}>
-      {(ctx) => (
-        <Tooltip
-          value={
-            <div class="grid grid-cols-2 gap-x-3 gap-y-1">
-              <span class="opacity-70 text-right">Tokens</span>
-              <span class="text-left">{ctx().tokens}</span>
-              <span class="opacity-70 text-right">Usage</span>
-              <span class="text-left">{ctx().percentage ?? 0}%</span>
-              <span class="opacity-70 text-right">Cost</span>
-              <span class="text-left">{cost()}</span>
+  const openContext = () => {
+    if (!params.id) return
+    layout.review.open()
+    tabs().open("context")
+    tabs().setActive("context")
+  }
+
+  const circle = () => (
+    <div class="p-1">
+      <ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
+    </div>
+  )
+
+  const tooltipValue = () => (
+    <div>
+      <Show when={context()}>
+        {(ctx) => (
+          <>
+            <div class="flex items-center gap-2">
+              <span class="text-text-invert-strong">{ctx().tokens}</span>
+              <span class="text-text-invert-base">Tokens</span>
+            </div>
+            <div class="flex items-center gap-2">
+              <span class="text-text-invert-strong">{ctx().percentage ?? 0}%</span>
+              <span class="text-text-invert-base">Usage</span>
             </div>
-          }
-          placement="top"
-        >
-          <div class="flex items-center gap-1.5">
-            <ProgressCircle size={16} strokeWidth={2} percentage={ctx().percentage ?? 0} />
-            {/* <span class="text-12-medium text-text-weak">{`${ctx().percentage ?? 0}%`}</span> */}
-          </div>
-        </Tooltip>
-      )}
+          </>
+        )}
+      </Show>
+      <div class="flex items-center gap-2">
+        <span class="text-text-invert-strong">{cost()}</span>
+        <span class="text-text-invert-base">Cost</span>
+      </div>
+      <Show when={variant() === "button"}>
+        <div class="text-11-regular text-text-invert-base mt-1">Click to view context</div>
+      </Show>
+    </div>
+  )
+
+  return (
+    <Show when={params.id}>
+      <Tooltip value={tooltipValue()} placement="top">
+        <Switch>
+          <Match when={variant() === "indicator"}>{circle()}</Match>
+          <Match when={true}>
+            <Button type="button" variant="ghost" class="size-6" onClick={openContext}>
+              {circle()}
+            </Button>
+          </Match>
+        </Switch>
+      </Tooltip>
     </Show>
   )
 }

+ 4 - 6
packages/app/src/components/session-lsp-indicator.tsx

@@ -1,5 +1,4 @@
 import { createMemo, Show } from "solid-js"
-import { Icon } from "@opencode-ai/ui/icon"
 import { useSync } from "@/context/sync"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 
@@ -24,12 +23,11 @@ export function SessionLspIndicator() {
     <Show when={lspStats().total > 0}>
       <Tooltip placement="top" value={tooltipContent()}>
         <div class="flex items-center gap-1 px-2 cursor-default select-none">
-          <Icon
-            name="code"
-            size="small"
+          <div
             classList={{
-              "text-icon-critical-base": lspStats().hasError,
-              "text-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
+              "size-1.5 rounded-full": true,
+              "bg-icon-critical-base": lspStats().hasError,
+              "bg-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
             }}
           />
           <span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span>

+ 4 - 6
packages/app/src/components/session-mcp-indicator.tsx

@@ -1,6 +1,5 @@
 import { createMemo, Show } from "solid-js"
 import { Button } from "@opencode-ai/ui/button"
-import { Icon } from "@opencode-ai/ui/icon"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useSync } from "@/context/sync"
 import { DialogSelectMcp } from "@/components/dialog-select-mcp"
@@ -21,12 +20,11 @@ export function SessionMcpIndicator() {
   return (
     <Show when={mcpStats().total > 0}>
       <Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
-        <Icon
-          name="mcp"
-          size="small"
+        <div
           classList={{
-            "text-icon-critical-base": mcpStats().failed,
-            "text-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
+            "size-1.5 rounded-full": true,
+            "bg-icon-critical-base": mcpStats().failed,
+            "bg-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
           }}
         />
         <span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>

+ 0 - 32
packages/app/src/components/status-bar.tsx

@@ -1,32 +0,0 @@
-import { createMemo, Show, type ParentProps } from "solid-js"
-import { usePlatform } from "@/context/platform"
-import { useSync } from "@/context/sync"
-import { useGlobalSync } from "@/context/global-sync"
-
-export function StatusBar(props: ParentProps) {
-  const platform = usePlatform()
-  const sync = useSync()
-  const globalSync = useGlobalSync()
-
-  const directoryDisplay = createMemo(() => {
-    const directory = sync.data.path.directory || ""
-    const home = globalSync.data.path.home || ""
-    const short = home && directory.startsWith(home) ? directory.replace(home, "~") : directory
-    const branch = sync.data.vcs?.branch
-    return branch ? `${short}:${branch}` : short
-  })
-
-  return (
-    <div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
-      <div class="flex items-center gap-3">
-        <Show when={platform.version}>
-          <span class="text-12-regular text-text-weak">v{platform.version}</span>
-        </Show>
-        <Show when={directoryDisplay()}>
-          <span class="text-12-regular text-text-weak">{directoryDisplay()}</span>
-        </Show>
-      </div>
-      <div class="flex items-center">{props.children}</div>
-    </div>
-  )
-}

+ 1 - 0
packages/app/src/components/terminal.tsx

@@ -233,6 +233,7 @@ export const Terminal = (props: TerminalProps) => {
       style={{ "background-color": terminalColors().background }}
       classList={{
         ...(local.classList ?? {}),
+        "select-text": true,
         "size-full px-6 py-3 font-mono": true,
         [local.class ?? ""]: !!local.class,
       }}

+ 15 - 8
packages/app/src/context/global-sdk.tsx

@@ -1,34 +1,41 @@
 import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2/client"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { createGlobalEmitter } from "@solid-primitives/event-bus"
+import { onCleanup } from "solid-js"
 import { usePlatform } from "./platform"
+import { useServer } from "./server"
 
 export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({
   name: "GlobalSDK",
-  init: (props: { url: string }) => {
+  init: () => {
+    const server = useServer()
+    const abort = new AbortController()
+
     const eventSdk = createOpencodeClient({
-      baseUrl: props.url,
-      // signal: AbortSignal.timeout(1000 * 60 * 10),
+      baseUrl: server.url,
+      signal: abort.signal,
     })
     const emitter = createGlobalEmitter<{
       [key: string]: Event
     }>()
 
-    eventSdk.global.event().then(async (events) => {
+    void (async () => {
+      const events = await eventSdk.global.event()
       for await (const event of events.stream) {
-        // console.log("event", event)
         emitter.emit(event.directory ?? "global", event.payload)
       }
-    })
+    })().catch(() => undefined)
+
+    onCleanup(() => abort.abort())
 
     const platform = usePlatform()
     const sdk = createOpencodeClient({
-      baseUrl: props.url,
+      baseUrl: server.url,
       signal: AbortSignal.timeout(1000 * 60 * 10),
       fetch: platform.fetch,
       throwOnError: true,
     })
 
-    return { url: props.url, client: sdk, event: emitter }
+    return { url: server.url, client: sdk, event: emitter }
   },
 })

+ 14 - 7
packages/app/src/context/global-sync.tsx

@@ -115,13 +115,14 @@ function createGlobalSync() {
       .then((x) => {
         const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
         const nonArchived = (x.data ?? [])
+          .filter((s) => !!s?.id)
+          .filter((s) => !s.time?.archived)
           .slice()
-          .filter((s) => !s.time.archived)
           .sort((a, b) => a.id.localeCompare(b.id))
         // Include up to the limit, plus any updated in the last 4 hours
         const sessions = nonArchived.filter((s, i) => {
           if (i < store.limit) return true
-          const updated = new Date(s.time.updated).getTime()
+          const updated = new Date(s.time?.updated ?? s.time?.created).getTime()
           return updated > fourHoursAgo
         })
         setStore("session", reconcile(sessions, { key: "id" }))
@@ -169,6 +170,7 @@ function createGlobalSync() {
         sdk.permission.list().then((x) => {
           const grouped: Record<string, Permission[]> = {}
           for (const perm of x.data ?? []) {
+            if (!perm?.id || !perm.sessionID) continue
             const existing = grouped[perm.sessionID]
             if (existing) {
               existing.push(perm)
@@ -187,7 +189,10 @@ function createGlobalSync() {
                 "permission",
                 sessionID,
                 reconcile(
-                  permissions.slice().sort((a, b) => a.id.localeCompare(b.id)),
+                  permissions
+                    .filter((p) => !!p?.id)
+                    .slice()
+                    .sort((a, b) => a.id.localeCompare(b.id)),
                   { key: "id" },
                 ),
               )
@@ -414,10 +419,12 @@ function createGlobalSync() {
       ),
       retry(() =>
         globalSDK.client.project.list().then(async (x) => {
-          setGlobalStore(
-            "project",
-            x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
-          )
+          const projects = (x.data ?? [])
+            .filter((p) => !!p?.id)
+            .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
+            .slice()
+            .sort((a, b) => a.id.localeCompare(b.id))
+          setGlobalStore("project", projects)
         }),
       ),
       retry(() =>

+ 63 - 40
packages/app/src/context/layout.tsx

@@ -3,6 +3,7 @@ import { batch, createMemo, onMount } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSync } from "./global-sync"
 import { useGlobalSDK } from "./global-sdk"
+import { useServer } from "./server"
 import { Project } from "@opencode-ai/sdk/v2"
 import { persisted } from "@/utils/persist"
 
@@ -29,15 +30,17 @@ type SessionTabs = {
 
 export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
 
+export type ReviewDiffStyle = "unified" | "split"
+
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
   name: "Layout",
   init: () => {
     const globalSdk = useGlobalSDK()
     const globalSync = useGlobalSync()
+    const server = useServer()
     const [store, setStore, _, ready] = persisted(
-      "layout.v3",
+      "layout.v4",
       createStore({
-        projects: [] as { worktree: string; expanded: boolean }[],
         sidebar: {
           opened: false,
           width: 280,
@@ -48,6 +51,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         },
         review: {
           opened: true,
+          diffStyle: "split" as ReviewDiffStyle,
         },
         session: {
           width: 600,
@@ -86,12 +90,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       return project
     }
 
-    const enriched = createMemo(() => store.projects.flatMap(enrich))
+    const enriched = createMemo(() => server.projects.list().flatMap(enrich))
     const list = createMemo(() => enriched().flatMap(colorize))
 
     onMount(() => {
       Promise.all(
-        store.projects.map((project) => {
+        server.projects.list().map((project) => {
           return globalSync.project.loadSessions(project.worktree)
         }),
       )
@@ -102,32 +106,23 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       projects: {
         list,
         open(directory: string) {
-          if (store.projects.find((x) => x.worktree === directory)) {
+          if (server.projects.list().find((x) => x.worktree === directory)) {
             return
           }
           globalSync.project.loadSessions(directory)
-          setStore("projects", (x) => [{ worktree: directory, expanded: true }, ...x])
+          server.projects.open(directory)
         },
         close(directory: string) {
-          setStore("projects", (x) => x.filter((x) => x.worktree !== directory))
+          server.projects.close(directory)
         },
         expand(directory: string) {
-          const index = store.projects.findIndex((x) => x.worktree === directory)
-          if (index !== -1) setStore("projects", index, "expanded", true)
+          server.projects.expand(directory)
         },
         collapse(directory: string) {
-          const index = store.projects.findIndex((x) => x.worktree === directory)
-          if (index !== -1) setStore("projects", index, "expanded", false)
+          server.projects.collapse(directory)
         },
         move(directory: string, toIndex: number) {
-          setStore("projects", (projects) => {
-            const fromIndex = projects.findIndex((x) => x.worktree === directory)
-            if (fromIndex === -1 || fromIndex === toIndex) return projects
-            const result = [...projects]
-            const [item] = result.splice(fromIndex, 1)
-            result.splice(toIndex, 0, item)
-            return result
-          })
+          server.projects.move(directory, toIndex)
         },
       },
       sidebar: {
@@ -164,6 +159,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
       },
       review: {
         opened: createMemo(() => store.review?.opened ?? true),
+        diffStyle: createMemo(() => store.review?.diffStyle ?? "split"),
+        setDiffStyle(diffStyle: ReviewDiffStyle) {
+          if (!store.review) {
+            setStore("review", { opened: true, diffStyle })
+            return
+          }
+          setStore("review", "diffStyle", diffStyle)
+        },
         open() {
           setStore("review", "opened", true)
         },
@@ -206,38 +209,58 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           },
           async open(tab: string) {
             const current = store.sessionTabs[sessionKey] ?? { all: [] }
-            if (tab !== "review") {
-              if (!current.all.includes(tab)) {
-                if (!store.sessionTabs[sessionKey]) {
-                  setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
-                } else {
-                  setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
-                  setStore("sessionTabs", sessionKey, "active", tab)
-                }
+
+            if (tab === "review") {
+              if (!store.sessionTabs[sessionKey]) {
+                setStore("sessionTabs", sessionKey, { all: [], active: tab })
                 return
               }
+              setStore("sessionTabs", sessionKey, "active", tab)
+              return
             }
-            if (!store.sessionTabs[sessionKey]) {
-              setStore("sessionTabs", sessionKey, { all: [], active: tab })
-            } else {
+
+            if (tab === "context") {
+              const all = [tab, ...current.all.filter((x) => x !== tab)]
+              if (!store.sessionTabs[sessionKey]) {
+                setStore("sessionTabs", sessionKey, { all, active: tab })
+                return
+              }
+              setStore("sessionTabs", sessionKey, "all", all)
               setStore("sessionTabs", sessionKey, "active", tab)
+              return
             }
+
+            if (!current.all.includes(tab)) {
+              if (!store.sessionTabs[sessionKey]) {
+                setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
+                return
+              }
+              setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
+              setStore("sessionTabs", sessionKey, "active", tab)
+              return
+            }
+
+            if (!store.sessionTabs[sessionKey]) {
+              setStore("sessionTabs", sessionKey, { all: current.all, active: tab })
+              return
+            }
+            setStore("sessionTabs", sessionKey, "active", tab)
           },
           close(tab: string) {
             const current = store.sessionTabs[sessionKey]
             if (!current) return
+
+            const all = current.all.filter((x) => x !== tab)
             batch(() => {
-              setStore(
-                "sessionTabs",
-                sessionKey,
-                "all",
-                current.all.filter((x) => x !== tab),
-              )
-              if (current.active === tab) {
-                const index = current.all.findIndex((f) => f === tab)
-                const previous = current.all[Math.max(0, index - 1)]
-                setStore("sessionTabs", sessionKey, "active", previous)
+              setStore("sessionTabs", sessionKey, "all", all)
+              if (current.active !== tab) return
+
+              const index = current.all.findIndex((f) => f === tab)
+              if (index <= 0) {
+                setStore("sessionTabs", sessionKey, "active", undefined)
+                return
               }
+              setStore("sessionTabs", sessionKey, "active", current.all[index - 1])
             })
           },
           move(tab: string, to: number) {

+ 71 - 10
packages/app/src/context/local.tsx

@@ -65,23 +65,40 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
     const agent = (() => {
       const list = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
       const [store, setStore] = createStore<{
-        current: string
+        current?: string
       }>({
-        current: list()[0].name,
+        current: list()[0]?.name,
       })
       return {
         list,
         current() {
-          return list().find((x) => x.name === store.current)!
+          const available = list()
+          if (available.length === 0) return undefined
+          return available.find((x) => x.name === store.current) ?? available[0]
         },
         set(name: string | undefined) {
-          setStore("current", name ?? list()[0].name)
+          const available = list()
+          if (available.length === 0) {
+            setStore("current", undefined)
+            return
+          }
+          if (name && available.some((x) => x.name === name)) {
+            setStore("current", name)
+            return
+          }
+          setStore("current", available[0].name)
         },
         move(direction: 1 | -1) {
-          let next = list().findIndex((x) => x.name === store.current) + direction
-          if (next < 0) next = list().length - 1
-          if (next >= list().length) next = 0
-          const value = list()[next]
+          const available = list()
+          if (available.length === 0) {
+            setStore("current", undefined)
+            return
+          }
+          let next = available.findIndex((x) => x.name === store.current) + direction
+          if (next < 0) next = available.length - 1
+          if (next >= available.length) next = 0
+          const value = available[next]
+          if (!value) return
           setStore("current", value.name)
           if (value.model)
             model.set({
@@ -98,9 +115,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         createStore<{
           user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[]
           recent: ModelKey[]
+          variant?: Record<string, string | undefined>
         }>({
           user: [],
           recent: [],
+          variant: {},
         }),
       )
 
@@ -182,11 +201,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
 
       const current = createMemo(() => {
         const a = agent.current()
+        if (!a) return undefined
         const key = getFirstValidModel(
           () => ephemeral.model[a.name],
           () => a.model,
           fallbackModel,
-        )!
+        )
+        if (!key) return undefined
         return find(key)
       })
 
@@ -232,7 +253,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         cycle,
         set(model: ModelKey | undefined, options?: { recent?: boolean }) {
           batch(() => {
-            setEphemeral("model", agent.current().name, model ?? fallbackModel())
+            const currentAgent = agent.current()
+            if (currentAgent) setEphemeral("model", currentAgent.name, model ?? fallbackModel())
             if (model) updateVisibility(model, "show")
             if (options?.recent && model) {
               const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
@@ -252,6 +274,45 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         setVisibility(model: ModelKey, visible: boolean) {
           updateVisibility(model, visible ? "show" : "hide")
         },
+        variant: {
+          current() {
+            const m = current()
+            if (!m) return undefined
+            const key = `${m.provider.id}/${m.id}`
+            return store.variant?.[key]
+          },
+          list() {
+            const m = current()
+            if (!m) return []
+            if (!m.variants) return []
+            return Object.keys(m.variants)
+          },
+          set(value: string | undefined) {
+            const m = current()
+            if (!m) return
+            const key = `${m.provider.id}/${m.id}`
+            if (!store.variant) {
+              setStore("variant", { [key]: value })
+            } else {
+              setStore("variant", key, value)
+            }
+          },
+          cycle() {
+            const variants = this.list()
+            if (variants.length === 0) return
+            const currentVariant = this.current()
+            if (!currentVariant) {
+              this.set(variants[0])
+              return
+            }
+            const index = variants.indexOf(currentVariant)
+            if (index === -1 || index === variants.length - 1) {
+              this.set(undefined)
+              return
+            }
+            this.set(variants[index + 1])
+          },
+        },
       }
     })()
 

+ 16 - 9
packages/app/src/context/notification.tsx

@@ -2,7 +2,9 @@ import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { useGlobalSDK } from "./global-sdk"
 import { useGlobalSync } from "./global-sync"
+import { usePlatform } from "@/context/platform"
 import { Binary } from "@opencode-ai/util/binary"
+import { base64Encode } from "@opencode-ai/util/encode"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { makeAudioPlayer } from "@solid-primitives/audio"
 import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
@@ -43,6 +45,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
 
     const globalSDK = useGlobalSDK()
     const globalSync = useGlobalSync()
+    const platform = usePlatform()
 
     const [store, setStore, _, ready] = persisted(
       "notification.v1",
@@ -64,8 +67,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
           const sessionID = event.properties.sessionID
           const [syncStore] = globalSync.child(directory)
           const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
-          const isChild = match.found && syncStore.session[match.index].parentID
-          if (isChild) break
+          const session = match.found ? syncStore.session[match.index] : undefined
+          if (session?.parentID) break
           try {
             idlePlayer?.play()
           } catch {}
@@ -74,25 +77,29 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
             type: "turn-complete",
             session: sessionID,
           })
+          const href = `/${base64Encode(directory)}/session/${sessionID}`
+          void platform.notify("Response ready", session?.title ?? sessionID, href)
           break
         }
         case "session.error": {
           const sessionID = event.properties.sessionID
-          if (sessionID) {
-            const [syncStore] = globalSync.child(directory)
-            const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
-            const isChild = match.found && syncStore.session[match.index].parentID
-            if (isChild) break
-          }
+          const [syncStore] = globalSync.child(directory)
+          const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined
+          const session = sessionID && match?.found ? syncStore.session[match.index] : undefined
+          if (session?.parentID) break
           try {
             errorPlayer?.play()
           } catch {}
+          const error = "error" in event.properties ? event.properties.error : undefined
           setStore("list", store.list.length, {
             ...base,
             type: "error",
             session: sessionID ?? "global",
-            error: "error" in event.properties ? event.properties.error : undefined,
+            error,
           })
+          const description = session?.title ?? (typeof error === "string" ? error : "An error occurred")
+          const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
+          void platform.notify("Session error", description, href)
           break
         }
       }

+ 123 - 0
packages/app/src/context/permission.tsx

@@ -0,0 +1,123 @@
+import { createMemo, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import type { Permission } from "@opencode-ai/sdk/v2/client"
+import { persisted } from "@/utils/persist"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { useGlobalSync } from "./global-sync"
+import { useParams } from "@solidjs/router"
+
+type PermissionRespondFn = (input: {
+  sessionID: string
+  permissionID: string
+  response: "once" | "always" | "reject"
+  directory?: string
+}) => void
+
+const AUTO_ACCEPT_TYPES = new Set(["edit", "write"])
+
+function shouldAutoAccept(perm: Permission) {
+  return AUTO_ACCEPT_TYPES.has(perm.type)
+}
+
+export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
+  name: "Permission",
+  init: () => {
+    const params = useParams()
+    const globalSDK = useGlobalSDK()
+    const globalSync = useGlobalSync()
+
+    const permissionsEnabled = createMemo(() => {
+      if (!params.dir) return false
+      const [store] = globalSync.child(params.dir)
+      return store.config.permission !== undefined
+    })
+
+    const [store, setStore, _, ready] = persisted(
+      "permission.v3",
+      createStore({
+        autoAcceptEdits: {} as Record<string, boolean>,
+      }),
+    )
+
+    const responded = new Set<string>()
+
+    const respond: PermissionRespondFn = (input) => {
+      globalSDK.client.permission.respond(input).catch(() => {
+        responded.delete(input.permissionID)
+      })
+    }
+
+    function respondOnce(permission: Permission, directory?: string) {
+      if (responded.has(permission.id)) return
+      responded.add(permission.id)
+      respond({
+        sessionID: permission.sessionID,
+        permissionID: permission.id,
+        response: "once",
+        directory,
+      })
+    }
+
+    function isAutoAccepting(sessionID: string) {
+      return store.autoAcceptEdits[sessionID] ?? false
+    }
+
+    const unsubscribe = globalSDK.event.listen((e) => {
+      const event = e.details
+      if (event?.type !== "permission.updated") return
+
+      const perm = event.properties
+      if (!isAutoAccepting(perm.sessionID)) return
+      if (!shouldAutoAccept(perm)) return
+
+      respondOnce(perm, e.name)
+    })
+    onCleanup(unsubscribe)
+
+    function enable(sessionID: string, directory: string) {
+      setStore("autoAcceptEdits", sessionID, true)
+
+      globalSDK.client.permission
+        .list({ directory })
+        .then((x) => {
+          for (const perm of x.data ?? []) {
+            if (!perm?.id) continue
+            if (perm.sessionID !== sessionID) continue
+            if (!shouldAutoAccept(perm)) continue
+            respondOnce(perm, directory)
+          }
+        })
+        .catch(() => undefined)
+    }
+
+    function disable(sessionID: string) {
+      setStore("autoAcceptEdits", sessionID, false)
+    }
+
+    return {
+      ready,
+      respond,
+      autoResponds(permission: Permission) {
+        return isAutoAccepting(permission.sessionID) && shouldAutoAccept(permission)
+      },
+      isAutoAccepting,
+      toggleAutoAccept(sessionID: string, directory: string) {
+        if (isAutoAccepting(sessionID)) {
+          disable(sessionID)
+          return
+        }
+
+        enable(sessionID, directory)
+      },
+      enableAutoAccept(sessionID: string, directory: string) {
+        if (isAutoAccepting(sessionID)) return
+        enable(sessionID, directory)
+      },
+      disableAutoAccept(sessionID: string) {
+        disable(sessionID)
+      },
+      permissionsEnabled,
+    }
+  },
+})

+ 4 - 1
packages/app/src/context/platform.tsx

@@ -14,7 +14,10 @@ export type Platform = {
   /** Restart the app  */
   restart(): Promise<void>
 
-  /** Open native directory picker dialog (Tauri only) */
+  /** Send a system notification (optional deep link) */
+  notify(title: string, description?: string, href?: string): Promise<void>
+
+  /** Open directory picker dialog (native on Tauri, server-backed on web) */
   openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null>
 
   /** Open native file picker dialog (Tauri only) */

+ 10 - 1
packages/app/src/context/prompt.tsx

@@ -21,6 +21,11 @@ export interface FileAttachmentPart extends PartBase {
   selection?: TextSelection
 }
 
+export interface AgentPart extends PartBase {
+  type: "agent"
+  name: string
+}
+
 export interface ImageAttachmentPart {
   type: "image"
   id: string
@@ -29,7 +34,7 @@ export interface ImageAttachmentPart {
   dataUrl: string
 }
 
-export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
+export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart
 export type Prompt = ContentPart[]
 
 export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
@@ -46,6 +51,9 @@ export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
     if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
       return false
     }
+    if (partA.type === "agent" && partA.name !== (partB as AgentPart).name) {
+      return false
+    }
     if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
       return false
     }
@@ -61,6 +69,7 @@ function cloneSelection(selection?: TextSelection) {
 function clonePart(part: ContentPart): ContentPart {
   if (part.type === "text") return { ...part }
   if (part.type === "image") return { ...part }
+  if (part.type === "agent") return { ...part }
   return {
     ...part,
     selection: cloneSelection(part.selection),

+ 185 - 0
packages/app/src/context/server.tsx

@@ -0,0 +1,185 @@
+import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { batch, createEffect, createMemo, createResource, createSignal, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { usePlatform } from "@/context/platform"
+import { persisted } from "@/utils/persist"
+
+type StoredProject = { worktree: string; expanded: boolean }
+
+export function normalizeServerUrl(input: string) {
+  const trimmed = input.trim()
+  if (!trimmed) return
+  const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
+  const cleaned = withProtocol.replace(/\/+$/, "")
+  return cleaned.replace(/^(https?:\/\/[^/]+).*/, "$1")
+}
+
+export function serverDisplayName(url: string) {
+  if (!url) return ""
+  return url
+    .replace(/^https?:\/\//, "")
+    .replace(/\/+$/, "")
+    .split("/")[0]
+}
+
+function projectsKey(url: string) {
+  if (!url) return ""
+  const host = url.replace(/^https?:\/\//, "").split(":")[0]
+  if (host === "localhost" || host === "127.0.0.1") return "local"
+  return url
+}
+
+export const { use: useServer, provider: ServerProvider } = createSimpleContext({
+  name: "Server",
+  init: (props: { defaultUrl: string }) => {
+    const platform = usePlatform()
+
+    const [store, setStore, _, ready] = persisted(
+      "server.v3",
+      createStore({
+        list: [] as string[],
+        projects: {} as Record<string, StoredProject[]>,
+      }),
+    )
+
+    const [active, setActiveRaw] = createSignal("")
+
+    function setActive(input: string) {
+      const url = normalizeServerUrl(input)
+      if (!url) return
+      setActiveRaw(url)
+    }
+
+    function add(input: string) {
+      const url = normalizeServerUrl(input)
+      if (!url) return
+
+      const fallback = normalizeServerUrl(props.defaultUrl)
+      if (fallback && url === fallback) {
+        setActiveRaw(url)
+        return
+      }
+
+      batch(() => {
+        if (!store.list.includes(url)) {
+          setStore("list", store.list.length, url)
+        }
+        setActiveRaw(url)
+      })
+    }
+
+    function remove(input: string) {
+      const url = normalizeServerUrl(input)
+      if (!url) return
+
+      const list = store.list.filter((x) => x !== url)
+      const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active()
+
+      batch(() => {
+        setStore("list", list)
+        setActiveRaw(next)
+      })
+    }
+
+    createEffect(() => {
+      if (!ready()) return
+      if (active()) return
+      const url = normalizeServerUrl(props.defaultUrl)
+      if (!url) return
+      setActiveRaw(url)
+    })
+
+    const isReady = createMemo(() => ready() && !!active())
+
+    const [healthy, { refetch }] = createResource(
+      () => active() || undefined,
+      async (url) => {
+        if (!url) return
+
+        const sdk = createOpencodeClient({
+          baseUrl: url,
+          fetch: platform.fetch,
+          signal: AbortSignal.timeout(2000),
+        })
+        return sdk.global
+          .health()
+          .then((x) => x.data?.healthy === true)
+          .catch(() => false)
+      },
+    )
+
+    createEffect(() => {
+      if (!active()) return
+      const interval = setInterval(() => refetch(), 10_000)
+      onCleanup(() => clearInterval(interval))
+    })
+
+    const origin = createMemo(() => projectsKey(active()))
+    const projectsList = createMemo(() => store.projects[origin()] ?? [])
+    const isLocal = createMemo(() => origin() === "local")
+
+    return {
+      ready: isReady,
+      healthy,
+      isLocal,
+      get url() {
+        return active()
+      },
+      get name() {
+        return serverDisplayName(active())
+      },
+      get list() {
+        return store.list
+      },
+      setActive,
+      add,
+      remove,
+      projects: {
+        list: projectsList,
+        open(directory: string) {
+          const key = origin()
+          if (!key) return
+          const current = store.projects[key] ?? []
+          if (current.find((x) => x.worktree === directory)) return
+          setStore("projects", key, [{ worktree: directory, expanded: true }, ...current])
+        },
+        close(directory: string) {
+          const key = origin()
+          if (!key) return
+          const current = store.projects[key] ?? []
+          setStore(
+            "projects",
+            key,
+            current.filter((x) => x.worktree !== directory),
+          )
+        },
+        expand(directory: string) {
+          const key = origin()
+          if (!key) return
+          const current = store.projects[key] ?? []
+          const index = current.findIndex((x) => x.worktree === directory)
+          if (index !== -1) setStore("projects", key, index, "expanded", true)
+        },
+        collapse(directory: string) {
+          const key = origin()
+          if (!key) return
+          const current = store.projects[key] ?? []
+          const index = current.findIndex((x) => x.worktree === directory)
+          if (index !== -1) setStore("projects", key, index, "expanded", false)
+        },
+        move(directory: string, toIndex: number) {
+          const key = origin()
+          if (!key) return
+          const current = store.projects[key] ?? []
+          const fromIndex = current.findIndex((x) => x.worktree === directory)
+          if (fromIndex === -1 || fromIndex === toIndex) return
+          const result = [...current]
+          const [item] = result.splice(fromIndex, 1)
+          result.splice(toIndex, 0, item)
+          setStore("projects", key, result)
+        },
+      },
+    }
+  },
+})

+ 12 - 3
packages/app/src/context/sync.tsx

@@ -56,14 +56,17 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
                 const result = Binary.search(messages, input.messageID, (m) => m.id)
                 messages.splice(result.index, 0, message)
               }
-              draft.part[input.messageID] = input.parts.slice().sort((a, b) => a.id.localeCompare(b.id))
+              draft.part[input.messageID] = input.parts
+                .filter((p) => !!p?.id)
+                .slice()
+                .sort((a, b) => a.id.localeCompare(b.id))
             }),
           )
         },
         async sync(sessionID: string, _isRetry = false) {
           const [session, messages, todo, diff] = await Promise.all([
             retry(() => sdk.client.session.get({ sessionID })),
-            retry(() => sdk.client.session.messages({ sessionID, limit: 100 })),
+            retry(() => sdk.client.session.messages({ sessionID, limit: 1000 })),
             retry(() => sdk.client.session.todo({ sessionID })),
             retry(() => sdk.client.session.diff({ sessionID })),
           ])
@@ -88,6 +91,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
               reconcile(
                 (messages.data ?? [])
                   .map((x) => x.info)
+                  .filter((m) => !!m?.id)
                   .slice()
                   .sort((a, b) => a.id.localeCompare(b.id)),
                 { key: "id" },
@@ -95,11 +99,15 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
             )
 
             for (const message of messages.data ?? []) {
+              if (!message?.info?.id) continue
               setStore(
                 "part",
                 message.info.id,
                 reconcile(
-                  message.parts.slice().sort((a, b) => a.id.localeCompare(b.id)),
+                  message.parts
+                    .filter((p) => !!p?.id)
+                    .slice()
+                    .sort((a, b) => a.id.localeCompare(b.id)),
                   { key: "id" },
                 ),
               )
@@ -112,6 +120,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           setStore("limit", (x) => x + count)
           await sdk.client.session.list().then((x) => {
             const sessions = (x.data ?? [])
+              .filter((s) => !!s?.id)
               .slice()
               .sort((a, b) => a.id.localeCompare(b.id))
               .slice(0, store.limit)

+ 30 - 0
packages/app/src/entry.tsx

@@ -20,6 +20,36 @@ const platform: Platform = {
   restart: async () => {
     window.location.reload()
   },
+  notify: async (title, description, href) => {
+    if (!("Notification" in window)) return
+
+    const permission =
+      Notification.permission === "default"
+        ? await Notification.requestPermission().catch(() => "denied")
+        : Notification.permission
+
+    if (permission !== "granted") return
+
+    const inView = document.visibilityState === "visible" && document.hasFocus()
+    if (inView) return
+
+    await Promise.resolve()
+      .then(() => {
+        const notification = new Notification(title, {
+          body: description ?? "",
+          icon: "https://opencode.ai/favicon-96x96.png",
+        })
+        notification.onclick = () => {
+          window.focus()
+          if (href) {
+            window.history.pushState(null, "", href)
+            window.dispatchEvent(new PopStateEvent("popstate"))
+          }
+          notification.close()
+        }
+      })
+      .catch(() => undefined)
+  },
 }
 
 render(

+ 8 - 7
packages/app/src/pages/directory-layout.tsx

@@ -3,6 +3,7 @@ import { useParams } from "@solidjs/router"
 import { SDKProvider, useSDK } from "@/context/sdk"
 import { SyncProvider, useSync } from "@/context/sync"
 import { LocalProvider } from "@/context/local"
+
 import { base64Decode } from "@opencode-ai/util/encode"
 import { DataProvider } from "@opencode-ai/ui/context"
 import { iife } from "@opencode-ai/util/iife"
@@ -19,14 +20,14 @@ export default function Layout(props: ParentProps) {
           {iife(() => {
             const sync = useSync()
             const sdk = useSDK()
+            const respond = (input: {
+              sessionID: string
+              permissionID: string
+              response: "once" | "always" | "reject"
+            }) => sdk.client.permission.respond(input)
+
             return (
-              <DataProvider
-                data={sync.data}
-                directory={directory()}
-                onPermissionRespond={(input) => {
-                  sdk.client.permission.respond(input)
-                }}
-              >
+              <DataProvider data={sync.data} directory={directory()} onPermissionRespond={respond}>
                 <LocalProvider>{props.children}</LocalProvider>
               </DataProvider>
             )

+ 122 - 23
packages/app/src/pages/error.tsx

@@ -2,6 +2,7 @@ import { TextField } from "@opencode-ai/ui/text-field"
 import { Logo } from "@opencode-ai/ui/logo"
 import { Button } from "@opencode-ai/ui/button"
 import { Component, Show } from "solid-js"
+import { createStore } from "solid-js/store"
 import { usePlatform } from "@/context/platform"
 import { Icon } from "@opencode-ai/ui/icon"
 
@@ -20,11 +21,51 @@ function isInitError(error: unknown): error is InitError {
   )
 }
 
+function safeJson(value: unknown): string {
+  const seen = new WeakSet<object>()
+  const json = JSON.stringify(
+    value,
+    (_key, val) => {
+      if (typeof val === "bigint") return val.toString()
+      if (typeof val === "object" && val) {
+        if (seen.has(val)) return "[Circular]"
+        seen.add(val)
+      }
+      return val
+    },
+    2,
+  )
+  return json ?? String(value)
+}
+
 function formatInitError(error: InitError): string {
   const data = error.data
   switch (error.name) {
     case "MCPFailed":
       return `MCP server "${data.name}" failed. Note, opencode does not support MCP authentication yet.`
+    case "ProviderAuthError": {
+      const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
+      const message = typeof data.message === "string" ? data.message : safeJson(data.message)
+      return `Provider authentication failed (${providerID}): ${message}`
+    }
+    case "APIError": {
+      const message = typeof data.message === "string" ? data.message : "API error"
+      const lines: string[] = [message]
+
+      if (typeof data.statusCode === "number") {
+        lines.push(`Status: ${data.statusCode}`)
+      }
+
+      if (typeof data.isRetryable === "boolean") {
+        lines.push(`Retryable: ${data.isRetryable}`)
+      }
+
+      if (typeof data.responseBody === "string" && data.responseBody) {
+        lines.push(`Response body:\n${data.responseBody}`)
+      }
+
+      return lines.join("\n")
+    }
     case "ProviderModelNotFoundError": {
       const { providerID, modelID, suggestions } = data as {
         providerID: string
@@ -37,10 +78,14 @@ function formatInitError(error: InitError): string {
         `Check your config (opencode.json) provider/model names`,
       ].join("\n")
     }
-    case "ProviderInitError":
-      return `Failed to initialize provider "${data.providerID}". Check credentials and configuration.`
-    case "ConfigJsonError":
-      return `Config file at ${data.path} is not valid JSON(C)` + (data.message ? `: ${data.message}` : "")
+    case "ProviderInitError": {
+      const providerID = typeof data.providerID === "string" ? data.providerID : "unknown"
+      return `Failed to initialize provider "${providerID}". Check credentials and configuration.`
+    }
+    case "ConfigJsonError": {
+      const message = typeof data.message === "string" ? data.message : ""
+      return `Config file at ${data.path} is not valid JSON(C)` + (message ? `: ${message}` : "")
+    }
     case "ConfigDirectoryTypoError":
       return `Directory "${data.dir}" in ${data.path} is not valid. Rename the directory to "${data.suggestion}" or remove it. This is a common typo.`
     case "ConfigFrontmatterError":
@@ -51,14 +96,14 @@ function formatInitError(error: InitError): string {
             (issue: { message: string; path: string[] }) => "↳ " + issue.message + " " + issue.path.join("."),
           )
         : []
-      return [`Config file at ${data.path} is invalid` + (data.message ? `: ${data.message}` : ""), ...issues].join(
-        "\n",
-      )
+      const message = typeof data.message === "string" ? data.message : ""
+      return [`Config file at ${data.path} is invalid` + (message ? `: ${message}` : ""), ...issues].join("\n")
     }
     case "UnknownError":
-      return String(data.message)
+      return typeof data.message === "string" ? data.message : safeJson(data)
     default:
-      return data.message ? String(data.message) : JSON.stringify(data, null, 2)
+      if (typeof data.message === "string") return data.message
+      return safeJson(data)
   }
 }
 
@@ -69,7 +114,7 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
     const message = formatInitError(error)
     if (depth > 0 && parentMessage === message) return ""
     const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
-    return indent + message
+    return indent + `${error.name}\n${message}`
   }
 
   if (error instanceof Error) {
@@ -77,15 +122,34 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
     const parts: string[] = []
     const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
 
-    if (!isDuplicate) {
-      // Stack already includes error name and message, so prefer it
-      parts.push(indent + (error.stack ?? `${error.name}: ${error.message}`))
-    } else if (error.stack) {
-      // Duplicate message - only show the stack trace lines (skip message)
-      const trace = error.stack.split("\n").slice(1).join("\n").trim()
-      if (trace) {
-        parts.push(trace)
+    const header = `${error.name}${error.message ? `: ${error.message}` : ""}`
+    const stack = error.stack?.trim()
+
+    if (stack) {
+      const startsWithHeader = stack.startsWith(header)
+
+      if (isDuplicate && startsWithHeader) {
+        const trace = stack.split("\n").slice(1).join("\n").trim()
+        if (trace) {
+          parts.push(indent + trace)
+        }
       }
+
+      if (isDuplicate && !startsWithHeader) {
+        parts.push(indent + stack)
+      }
+
+      if (!isDuplicate && startsWithHeader) {
+        parts.push(indent + stack)
+      }
+
+      if (!isDuplicate && !startsWithHeader) {
+        parts.push(indent + `${header}\n${stack}`)
+      }
+    }
+
+    if (!stack && !isDuplicate) {
+      parts.push(indent + header)
     }
 
     if (error.cause) {
@@ -105,7 +169,7 @@ function formatErrorChain(error: unknown, depth = 0, parentMessage?: string): st
   }
 
   const indent = depth > 0 ? `\n${"─".repeat(40)}\nCaused by:\n` : ""
-  return indent + JSON.stringify(error, null, 2)
+  return indent + safeJson(error)
 }
 
 function formatError(error: unknown): string {
@@ -118,6 +182,25 @@ interface ErrorPageProps {
 
 export const ErrorPage: Component<ErrorPageProps> = (props) => {
   const platform = usePlatform()
+  const [store, setStore] = createStore({
+    checking: false,
+    version: undefined as string | undefined,
+  })
+
+  async function checkForUpdates() {
+    if (!platform.checkUpdate) return
+    setStore("checking", true)
+    const result = await platform.checkUpdate()
+    setStore("checking", false)
+    if (result.updateAvailable && result.version) setStore("version", result.version)
+  }
+
+  async function installUpdate() {
+    if (!platform.update || !platform.restart) return
+    await platform.update()
+    await platform.restart()
+  }
+
   return (
     <div class="relative flex-1 h-screen w-screen min-h-0 flex flex-col items-center justify-center bg-background-base font-sans">
       <div class="w-2/3 max-w-3xl flex flex-col items-center justify-center gap-8">
@@ -131,13 +214,29 @@ export const ErrorPage: Component<ErrorPageProps> = (props) => {
           readOnly
           copyable
           multiline
-          class="max-h-96 w-full font-mono text-xs no-scrollbar whitespace-pre"
+          class="max-h-96 w-full font-mono text-xs no-scrollbar"
           label="Error Details"
           hideLabel
         />
-        <Button size="large" onClick={platform.restart}>
-          Restart
-        </Button>
+        <div class="flex items-center gap-3">
+          <Button size="large" onClick={platform.restart}>
+            Restart
+          </Button>
+          <Show when={platform.checkUpdate}>
+            <Show
+              when={store.version}
+              fallback={
+                <Button size="large" variant="ghost" onClick={checkForUpdates} disabled={store.checking}>
+                  {store.checking ? "Checking..." : "Check for updates"}
+                </Button>
+              }
+            >
+              <Button size="large" onClick={installUpdate}>
+                Update to {store.version}
+              </Button>
+            </Show>
+          </Show>
+        </div>
         <div class="flex flex-col items-center gap-2">
           <div class="flex items-center justify-center gap-1">
             Please report this error to the OpenCode team

+ 48 - 19
packages/app/src/pages/home.tsx

@@ -8,12 +8,18 @@ import { base64Encode } from "@opencode-ai/util/encode"
 import { Icon } from "@opencode-ai/ui/icon"
 import { usePlatform } from "@/context/platform"
 import { DateTime } from "luxon"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { DialogSelectDirectory } from "@/components/dialog-select-directory"
+import { DialogSelectServer } from "@/components/dialog-select-server"
+import { useServer } from "@/context/server"
 
 export default function Home() {
   const sync = useGlobalSync()
   const layout = useLayout()
   const platform = usePlatform()
+  const dialog = useDialog()
   const navigate = useNavigate()
+  const server = useServer()
   const homedir = createMemo(() => sync.data.path.home)
 
   function openProject(directory: string) {
@@ -22,32 +28,57 @@ export default function Home() {
   }
 
   async function chooseProject() {
-    const result = await platform.openDirectoryPickerDialog?.({
-      title: "Open project",
-      multiple: true,
-    })
-    if (Array.isArray(result)) {
-      for (const directory of result) {
-        openProject(directory)
+    function resolve(result: string | string[] | null) {
+      if (Array.isArray(result)) {
+        for (const directory of result) {
+          openProject(directory)
+        }
+      } else if (result) {
+        openProject(result)
       }
-    } else if (result) {
-      openProject(result)
+    }
+
+    if (platform.openDirectoryPickerDialog && server.isLocal()) {
+      const result = await platform.openDirectoryPickerDialog?.({
+        title: "Open project",
+        multiple: true,
+      })
+      resolve(result)
+    } else {
+      dialog.show(
+        () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
+        () => resolve(null),
+      )
     }
   }
 
   return (
     <div class="mx-auto mt-55">
       <Logo class="w-xl opacity-12" />
+      <Button
+        size="large"
+        variant="ghost"
+        class="mt-4 mx-auto text-14-regular text-text-weak"
+        onClick={() => dialog.show(() => <DialogSelectServer />)}
+      >
+        <div
+          classList={{
+            "size-2 rounded-full": true,
+            "bg-icon-success-base": server.healthy() === true,
+            "bg-icon-critical-base": server.healthy() === false,
+            "bg-border-weak-base": server.healthy() === undefined,
+          }}
+        />
+        {server.name}
+      </Button>
       <Switch>
         <Match when={sync.data.project.length > 0}>
           <div class="mt-20 w-full flex flex-col gap-4">
             <div class="flex gap-2 items-center justify-between pl-3">
               <div class="text-14-medium text-text-strong">Recent projects</div>
-              <Show when={platform.openDirectoryPickerDialog}>
-                <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
-                  Open project
-                </Button>
-              </Show>
+              <Button icon="folder-add-left" size="normal" class="pl-2 pr-3" onClick={chooseProject}>
+                Open project
+              </Button>
             </div>
             <ul class="flex flex-col gap-2">
               <For
@@ -80,11 +111,9 @@ export default function Home() {
               <div class="text-12-regular text-text-weak">Get started by opening a local project</div>
             </div>
             <div />
-            <Show when={platform.openDirectoryPickerDialog}>
-              <Button class="px-3" onClick={chooseProject}>
-                Open project
-              </Button>
-            </Show>
+            <Button class="px-3" onClick={chooseProject}>
+              Open project
+            </Button>
           </div>
         </Match>
       </Switch>

+ 132 - 99
packages/app/src/pages/layout.tsx

@@ -22,10 +22,11 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { Collapsible } from "@opencode-ai/ui/collapsible"
 import { DiffChanges } from "@opencode-ai/ui/diff-changes"
 import { Spinner } from "@opencode-ai/ui/spinner"
+import { Mark } from "@opencode-ai/ui/logo"
 import { getFilename } from "@opencode-ai/util/path"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Session } from "@opencode-ai/sdk/v2/client"
@@ -44,14 +45,18 @@ import { useProviders } from "@/hooks/use-providers"
 import { showToast, Toast, toaster } from "@opencode-ai/ui/toast"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useNotification } from "@/context/notification"
+import { usePermission } from "@/context/permission"
 import { Binary } from "@opencode-ai/util/binary"
-import { Header } from "@/components/header"
+
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme"
 import { DialogSelectProvider } from "@/components/dialog-select-provider"
 import { DialogEditProject } from "@/components/dialog-edit-project"
+import { DialogSelectServer } from "@/components/dialog-select-server"
 import { useCommand, type CommandOption } from "@/context/command"
 import { ConstrainDragXAxis } from "@/utils/solid-dnd"
+import { DialogSelectDirectory } from "@/components/dialog-select-directory"
+import { useServer } from "@/context/server"
 
 export default function Layout(props: ParentProps) {
   const [store, setStore] = createStore({
@@ -86,7 +91,9 @@ export default function Layout(props: ParentProps) {
   const globalSync = useGlobalSync()
   const layout = useLayout()
   const platform = usePlatform()
+  const server = useServer()
   const notification = useNotification()
+  const permission = usePermission()
   const navigate = useNavigate()
   const providers = useProviders()
   const dialog = useDialog()
@@ -127,11 +134,15 @@ export default function Layout(props: ParentProps) {
     })
   }
 
-  onMount(async () => {
-    if (platform.checkUpdate && platform.update && platform.restart) {
-      const { updateAvailable, version } = await platform.checkUpdate()
-      if (updateAvailable) {
-        showToast({
+  onMount(() => {
+    if (!platform.checkUpdate || !platform.update || !platform.restart) return
+
+    let toastId: number | undefined
+
+    async function pollUpdate() {
+      const { updateAvailable, version } = await platform.checkUpdate!()
+      if (updateAvailable && toastId === undefined) {
+        toastId = showToast({
           persistent: true,
           icon: "download",
           title: "Update available",
@@ -152,36 +163,59 @@ export default function Layout(props: ParentProps) {
         })
       }
     }
+
+    pollUpdate()
+    const interval = setInterval(pollUpdate, 10 * 60 * 1000)
+    onCleanup(() => clearInterval(interval))
   })
 
   onMount(() => {
-    const seenSessions = new Set<string>()
     const toastBySession = new Map<string, number>()
+    const alertedAtBySession = new Map<string, number>()
+    const permissionAlertCooldownMs = 5000
+
     const unsub = globalSDK.event.listen((e) => {
       if (e.details?.type !== "permission.updated") return
       const directory = e.name
-      const permission = e.details.properties
-      const sessionKey = `${directory}:${permission.sessionID}`
-      if (seenSessions.has(sessionKey)) return
-      seenSessions.add(sessionKey)
-      const currentDir = params.dir ? base64Decode(params.dir) : undefined
-      const currentSession = params.id
-      if (directory === currentDir && permission.sessionID === currentSession) return
+      const perm = e.details.properties
+      if (permission.autoResponds(perm)) return
+
+      const sessionKey = `${directory}:${perm.sessionID}`
       const [store] = globalSync.child(directory)
-      const session = store.session.find((s) => s.id === permission.sessionID)
-      if (directory === currentDir && session?.parentID === currentSession) return
+      const session = store.session.find((s) => s.id === perm.sessionID)
+
       const sessionTitle = session?.title ?? "New session"
       const projectName = getFilename(directory)
+      const description = `${sessionTitle} in ${projectName} needs permission`
+      const href = `/${base64Encode(directory)}/session/${perm.sessionID}`
+
+      const now = Date.now()
+      const lastAlerted = alertedAtBySession.get(sessionKey) ?? 0
+      if (now - lastAlerted < permissionAlertCooldownMs) return
+      alertedAtBySession.set(sessionKey, now)
+
+      void platform.notify("Permission required", description, href)
+
+      const currentDir = params.dir ? base64Decode(params.dir) : undefined
+      const currentSession = params.id
+      if (directory === currentDir && perm.sessionID === currentSession) return
+      if (directory === currentDir && session?.parentID === currentSession) return
+
+      const existingToastId = toastBySession.get(sessionKey)
+      if (existingToastId !== undefined) {
+        toaster.dismiss(existingToastId)
+      }
+
       const toastId = showToast({
         persistent: true,
         icon: "checklist",
         title: "Permission required",
-        description: `${sessionTitle} in ${projectName} needs permission`,
+        description,
         actions: [
           {
             label: "Go to session",
             onClick: () => {
-              navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`)
+              navigate(href)
             },
           },
           {
@@ -203,7 +237,7 @@ export default function Layout(props: ParentProps) {
       if (toastId !== undefined) {
         toaster.dismiss(toastId)
         toastBySession.delete(sessionKey)
-        seenSessions.delete(sessionKey)
+        alertedAtBySession.delete(sessionKey)
       }
       const [store] = globalSync.child(currentDir)
       const childSessions = store.session.filter((s) => s.parentID === currentSession)
@@ -213,7 +247,7 @@ export default function Layout(props: ParentProps) {
         if (childToastId !== undefined) {
           toaster.dismiss(childToastId)
           toastBySession.delete(childKey)
-          seenSessions.delete(childKey)
+          alertedAtBySession.delete(childKey)
         }
       }
     })
@@ -332,23 +366,25 @@ export default function Layout(props: ParentProps) {
         keybind: "mod+b",
         onSelect: () => layout.sidebar.toggle(),
       },
-      ...(platform.openDirectoryPickerDialog
-        ? [
-            {
-              id: "project.open",
-              title: "Open project",
-              category: "Project",
-              keybind: "mod+o",
-              onSelect: () => chooseProject(),
-            },
-          ]
-        : []),
+      {
+        id: "project.open",
+        title: "Open project",
+        category: "Project",
+        keybind: "mod+o",
+        onSelect: () => chooseProject(),
+      },
       {
         id: "provider.connect",
         title: "Connect provider",
         category: "Provider",
         onSelect: () => connectProvider(),
       },
+      {
+        id: "server.switch",
+        title: "Switch server",
+        category: "Server",
+        onSelect: () => openServer(),
+      },
       {
         id: "session.previous",
         title: "Previous session",
@@ -424,6 +460,10 @@ export default function Layout(props: ParentProps) {
     dialog.show(() => <DialogSelectProvider />)
   }
 
+  function openServer() {
+    dialog.show(() => <DialogSelectServer />)
+  }
+
   function navigateToProject(directory: string | undefined) {
     if (!directory) return
     const lastSession = store.lastSession[directory]
@@ -451,17 +491,28 @@ export default function Layout(props: ParentProps) {
   }
 
   async function chooseProject() {
-    const result = await platform.openDirectoryPickerDialog?.({
-      title: "Open project",
-      multiple: true,
-    })
-    if (Array.isArray(result)) {
-      for (const directory of result) {
-        openProject(directory, false)
+    function resolve(result: string | string[] | null) {
+      if (Array.isArray(result)) {
+        for (const directory of result) {
+          openProject(directory, false)
+        }
+        navigateToProject(result[0])
+      } else if (result) {
+        openProject(result)
       }
-      navigateToProject(result[0])
-    } else if (result) {
-      openProject(result)
+    }
+
+    if (platform.openDirectoryPickerDialog && server.isLocal()) {
+      const result = await platform.openDirectoryPickerDialog?.({
+        title: "Open project",
+        multiple: true,
+      })
+      resolve(result)
+    } else {
+      dialog.show(
+        () => <DialogSelectDirectory multiple={true} onSelect={resolve} />,
+        () => resolve(null),
+      )
     }
   }
 
@@ -681,17 +732,13 @@ 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
+            <TooltipKeybind
               placement={props.mobile ? "bottom" : "right"}
-              value={
-                <div class="flex items-center gap-2">
-                  <span>Archive session</span>
-                  <span class="text-icon-base text-12-medium">{command.keybind("session.archive")}</span>
-                </div>
-              }
+              title="Archive session"
+              keybind={command.keybind("session.archive")}
             >
               <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
-            </Tooltip>
+            </TooltipKeybind>
           </div>
         </div>
       </>
@@ -759,17 +806,9 @@ export default function Layout(props: ParentProps) {
                       </DropdownMenu.Content>
                     </DropdownMenu.Portal>
                   </DropdownMenu>
-                  <Tooltip
-                    placement="top"
-                    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>
-                    }
-                  >
+                  <TooltipKeybind placement="top" title="New session" keybind={command.keybind("session.new")}>
                     <IconButton as={A} href={`${slug()}/session`} icon="plus-small" variant="ghost" />
-                  </Tooltip>
+                  </TooltipKeybind>
                 </div>
               </Button>
               <Collapsible.Content>
@@ -847,15 +886,16 @@ export default function Layout(props: ParentProps) {
       <>
         <div class="flex flex-col items-start self-stretch gap-4 p-2 min-h-0 overflow-hidden">
           <Show when={!sidebarProps.mobile}>
-            <Tooltip
+            <A href="/" class="shrink-0 h-8 flex items-center justify-start px-2" data-tauri-drag-region>
+              <Mark class="shrink-0" />
+            </A>
+          </Show>
+          <Show when={!sidebarProps.mobile}>
+            <TooltipKeybind
               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>
-              }
+              title="Toggle sidebar"
+              keybind={command.keybind("sidebar.toggle")}
               inactive={expanded()}
             >
               <Button
@@ -887,7 +927,7 @@ export default function Layout(props: ParentProps) {
                   </div>
                 </Show>
               </Button>
-            </Tooltip>
+            </TooltipKeybind>
           </Show>
           <DragDropProvider
             onDragStart={handleDragStart}
@@ -916,7 +956,7 @@ export default function Layout(props: ParentProps) {
         </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()}>
+            <Match when={providers.all().length > 0 && !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>
@@ -935,7 +975,7 @@ export default function Layout(props: ParentProps) {
                 </Tooltip>
               </div>
             </Match>
-            <Match when={true}>
+            <Match when={providers.all().length > 0}>
               <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"
@@ -949,30 +989,28 @@ export default function Layout(props: ParentProps) {
               </Tooltip>
             </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()}
+          <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
+              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}
             >
-              <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}
-              >
-                <Show when={expanded()}>Open project</Show>
-              </Button>
-            </Tooltip>
-          </Show>
+              <Show when={expanded()}>Open project</Show>
+            </Button>
+          </Tooltip>
           <Tooltip placement="right" value="Share feedback" inactive={expanded()}>
             <Button
               as={"a"}
@@ -992,12 +1030,7 @@ export default function Layout(props: ParentProps) {
   }
 
   return (
-    <div class="relative flex-1 min-h-0 flex flex-col">
-      <Header
-        navigateToProject={navigateToProject}
-        navigateToSession={navigateToSession}
-        onMobileMenuToggle={mobileSidebar.toggle}
-      />
+    <div class="relative flex-1 min-h-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
       <div class="flex-1 min-h-0 flex">
         <div
           classList={{

+ 696 - 64
packages/app/src/pages/session.tsx

@@ -17,11 +17,12 @@ import { Dynamic } from "solid-js/web"
 import { useLocal, type LocalFile } from "@/context/local"
 import { createStore } from "solid-js/store"
 import { PromptInput } from "@/components/prompt-input"
+import { SessionContextUsage } from "@/components/session-context-usage"
 import { DateTime } from "luxon"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Icon } from "@opencode-ai/ui/icon"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { DiffChanges } from "@opencode-ai/ui/diff-changes"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
 import { Tabs } from "@opencode-ai/ui/tabs"
@@ -30,6 +31,10 @@ 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 { Markdown } from "@opencode-ai/ui/markdown"
+import { Accordion } from "@opencode-ai/ui/accordion"
+import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
+import { Code } from "@opencode-ai/ui/code"
 import {
   DragDropProvider,
   DragDropSensors,
@@ -51,15 +56,222 @@ import { DialogSelectFile } from "@/components/dialog-select-file"
 import { DialogSelectModel } from "@/components/dialog-select-model"
 import { DialogSelectMcp } from "@/components/dialog-select-mcp"
 import { useCommand } from "@/context/command"
-import { useNavigate, useParams } from "@solidjs/router"
+import { A, useNavigate, useParams } from "@solidjs/router"
 import { UserMessage } from "@opencode-ai/sdk/v2"
 import { useSDK } from "@/context/sdk"
 import { usePrompt } from "@/context/prompt"
 import { extractPromptFromParts } from "@/utils/prompt"
 import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
-import { StatusBar } from "@/components/status-bar"
-import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
+import { usePermission } from "@/context/permission"
+import { showToast } from "@opencode-ai/ui/toast"
+import { useServer } from "@/context/server"
+import { Button } from "@opencode-ai/ui/button"
+import { DialogSelectServer } from "@/components/dialog-select-server"
 import { SessionLspIndicator } from "@/components/session-lsp-indicator"
+import { SessionMcpIndicator } from "@/components/session-mcp-indicator"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { Popover } from "@opencode-ai/ui/popover"
+import { Select } from "@opencode-ai/ui/select"
+import { TextField } from "@opencode-ai/ui/text-field"
+import { base64Encode } from "@opencode-ai/util/encode"
+import { iife } from "@opencode-ai/util/iife"
+import { AssistantMessage, Session, type Message, type Part } from "@opencode-ai/sdk/v2/client"
+
+function same<T>(a: readonly T[], b: readonly T[]) {
+  if (a === b) return true
+  if (a.length !== b.length) return false
+  return a.every((x, i) => x === b[i])
+}
+
+function Header(props: { onMobileMenuToggle?: () => void }) {
+  const globalSDK = useGlobalSDK()
+  const layout = useLayout()
+  const params = useParams()
+  const navigate = useNavigate()
+  const command = useCommand()
+  const server = useServer()
+  const dialog = useDialog()
+  const sync = useSync()
+
+  const sessions = createMemo(() => (sync.data.session ?? []).filter((s) => !s.parentID))
+  const currentSession = createMemo(() => sessions().find((s) => s.id === params.id))
+  const shareEnabled = createMemo(() => sync.data.config.share !== "disabled")
+  const branch = createMemo(() => sync.data.vcs?.branch)
+
+  function navigateToProject(directory: string) {
+    navigate(`/${base64Encode(directory)}`)
+  }
+
+  function navigateToSession(session: Session | undefined) {
+    if (!session) return
+    navigate(`/${params.dir}/session/${session.id}`)
+  }
+
+  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>
+      <div class="px-4 flex items-center justify-between gap-4 w-full">
+        <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={sync.directory}
+                label={(x) => {
+                  const name = getFilename(x)
+                  const b = x === sync.directory ? branch() : undefined
+                  return b ? `${name}:${b}` : name
+                }}
+                onSelect={(x) => (x ? 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()}
+              placeholder="New session"
+              label={(x) => x.title}
+              value={(x) => x.id}
+              onSelect={navigateToSession}
+              class="text-14-regular text-text-base max-w-[calc(100vw-180px)] md:max-w-md"
+              variant="ghost"
+            />
+          </div>
+          <Show when={currentSession()}>
+            <TooltipKeybind class="hidden xl:block" title="New session" keybind={command.keybind("session.new")}>
+              <IconButton as={A} href={`/${params.dir}/session`} icon="edit-small-2" variant="ghost" />
+            </TooltipKeybind>
+          </Show>
+        </div>
+        <div class="flex items-center gap-3">
+          <div class="hidden md:flex items-center gap-1">
+            <Button
+              size="small"
+              variant="ghost"
+              onClick={() => {
+                dialog.show(() => <DialogSelectServer />)
+              }}
+            >
+              <div
+                classList={{
+                  "size-1.5 rounded-full": true,
+                  "bg-icon-success-base": server.healthy() === true,
+                  "bg-icon-critical-base": server.healthy() === false,
+                  "bg-border-weak-base": server.healthy() === undefined,
+                }}
+              />
+              <Icon name="server" size="small" class="text-icon-weak" />
+              <span class="text-12-regular text-text-weak truncate max-w-[200px]">{server.name}</span>
+            </Button>
+            <SessionLspIndicator />
+            <SessionMcpIndicator />
+          </div>
+          <div class="flex items-center gap-1">
+            <Show when={currentSession()?.summary?.files}>
+              <TooltipKeybind
+                class="hidden md:block shrink-0"
+                title="Toggle review"
+                keybind={command.keybind("review.toggle")}
+              >
+                <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
+                      name={layout.review.opened() ? "layout-right" : "layout-left"}
+                      size="small"
+                      class="group-hover/review-toggle:hidden"
+                    />
+                    <Icon
+                      name={layout.review.opened() ? "layout-right-partial" : "layout-left-partial"}
+                      size="small"
+                      class="hidden group-hover/review-toggle:inline-block"
+                    />
+                    <Icon
+                      name={layout.review.opened() ? "layout-right-full" : "layout-left-full"}
+                      size="small"
+                      class="hidden group-active/review-toggle:inline-block"
+                    />
+                  </div>
+                </Button>
+              </TooltipKeybind>
+            </Show>
+            <TooltipKeybind
+              class="hidden md:block shrink-0"
+              title="Toggle terminal"
+              keybind={command.keybind("terminal.toggle")}
+            >
+              <Button variant="ghost" class="group/terminal-toggle size-6 p-0" onClick={layout.terminal.toggle}>
+                <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                  <Icon
+                    size="small"
+                    name={layout.terminal.opened() ? "layout-bottom-full" : "layout-bottom"}
+                    class="group-hover/terminal-toggle:hidden"
+                  />
+                  <Icon
+                    size="small"
+                    name="layout-bottom-partial"
+                    class="hidden group-hover/terminal-toggle:inline-block"
+                  />
+                  <Icon
+                    size="small"
+                    name={layout.terminal.opened() ? "layout-bottom" : "layout-bottom-full"}
+                    class="hidden group-active/terminal-toggle:inline-block"
+                  />
+                </div>
+              </Button>
+            </TooltipKeybind>
+          </div>
+          <Show when={shareEnabled() && currentSession()}>
+            <Popover
+              title="Share session"
+              trigger={
+                <Tooltip class="shrink-0" value="Share session">
+                  <IconButton icon="share" variant="ghost" class="" />
+                </Tooltip>
+              }
+            >
+              {iife(() => {
+                const [url] = createResource(
+                  () => currentSession(),
+                  async (session) => {
+                    if (!session) return
+                    let shareURL = session.share?.url
+                    if (!shareURL) {
+                      shareURL = await globalSDK.client.session
+                        .share({ sessionID: session.id, directory: sync.directory })
+                        .then((r) => r.data?.share?.url)
+                        .catch((e) => {
+                          console.error("Failed to share session", e)
+                          return undefined
+                        })
+                    }
+                    return shareURL
+                  },
+                )
+                return <Show when={url()}>{(url) => <TextField value={url()} readOnly copyable class="w-72" />}</Show>
+              })}
+            </Popover>
+          </Show>
+        </div>
+      </div>
+    </header>
+  )
+}
 
 export default function Page() {
   const layout = useLayout()
@@ -74,18 +286,28 @@ export default function Page() {
   const sdk = useSDK()
   const prompt = usePrompt()
 
+  const permission = usePermission()
   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] ?? []) : []))
-  const userMessages = createMemo(() => messages().filter((m) => m.role === "user"))
-  const visibleUserMessages = createMemo(() => {
-    const revert = revertMessageID()
-    if (!revert) return userMessages()
-    return userMessages().filter((m) => m.id < revert)
-  })
-  const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
+  const emptyUserMessages: UserMessage[] = []
+  const userMessages = createMemo(
+    () => messages().filter((m) => m.role === "user") as UserMessage[],
+    emptyUserMessages,
+    { equals: same },
+  )
+  const visibleUserMessages = createMemo(
+    () => {
+      const revert = revertMessageID()
+      if (!revert) return userMessages()
+      return userMessages().filter((m) => m.id < revert)
+    },
+    emptyUserMessages,
+    { equals: same },
+  )
+  const lastUserMessage = createMemo(() => visibleUserMessages().at(-1))
 
   createEffect(
     on(
@@ -167,16 +389,37 @@ export default function Page() {
     ),
   )
 
-  createEffect(() => {
-    params.id
-    const status = sync.data.session_status[params.id ?? ""] ?? { type: "idle" }
-    batch(() => {
-      setStore("userInteracted", false)
-      setStore("stepsExpanded", status.type !== "idle")
-    })
-  })
+  const idle = { type: "idle" as const }
+
+  createEffect(
+    on(
+      () => params.id,
+      (id) => {
+        const status = sync.data.session_status[id ?? ""] ?? idle
+        batch(() => {
+          setStore("userInteracted", false)
+          setStore("stepsExpanded", status.type !== "idle")
+        })
+      },
+    ),
+  )
+
+  const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle)
+
+  createEffect(
+    on(
+      () => status().type,
+      (type) => {
+        if (type !== "idle") return
+        batch(() => {
+          setStore("userInteracted", false)
+          setStore("stepsExpanded", false)
+        })
+      },
+      { defer: true },
+    ),
+  )
 
-  const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
   const working = createMemo(() => status().type !== "idle" && activeMessage()?.id === lastUserMessage()?.id)
 
   createRenderEffect((prev) => {
@@ -209,21 +452,6 @@ export default function Page() {
       slash: "open",
       onSelect: () => dialog.show(() => <DialogSelectFile />),
     },
-    // {
-    //   id: "theme.toggle",
-    //   title: "Toggle theme",
-    //   description: "Switch between themes",
-    //   category: "View",
-    //   keybind: "ctrl+t",
-    //   slash: "theme",
-    //   onSelect: () => {
-    //     const currentTheme = localStorage.getItem("theme") ?? "oc-1"
-    //     const themes = ["oc-1", "oc-2-paper"]
-    //     const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
-    //     localStorage.setItem("theme", nextTheme)
-    //     document.documentElement.setAttribute("data-theme", nextTheme)
-    //   },
-    // },
     {
       id: "terminal.toggle",
       title: "Toggle terminal",
@@ -312,6 +540,38 @@ export default function Page() {
       keybind: "shift+mod+.",
       onSelect: () => local.agent.move(-1),
     },
+    {
+      id: "model.variant.cycle",
+      title: "Cycle thinking effort",
+      description: "Switch to the next effort level",
+      category: "Model",
+      keybind: "shift+mod+t",
+      onSelect: () => {
+        local.model.variant.cycle()
+        showToast({
+          title: "Thinking effort changed",
+          description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"),
+        })
+      },
+    },
+    {
+      id: "permissions.autoaccept",
+      title: params.id && permission.isAutoAccepting(params.id) ? "Stop auto-accepting edits" : "Auto-accept edits",
+      category: "Permissions",
+      keybind: "mod+shift+a",
+      disabled: !params.id || !permission.permissionsEnabled(),
+      onSelect: () => {
+        const sessionID = params.id
+        if (!sessionID) return
+        permission.toggleAutoAccept(sessionID, sdk.directory)
+        showToast({
+          title: permission.isAutoAccepting(sessionID) ? "Auto-accepting edits" : "Stopped auto-accepting edits",
+          description: permission.isAutoAccepting(sessionID)
+            ? "Edit and write permissions will be automatically approved"
+            : "Edit and write permissions will require approval",
+        })
+      },
+    },
     {
       id: "session.undo",
       title: "Undo",
@@ -562,7 +822,23 @@ export default function Page() {
     )
   }
 
-  const showTabs = createMemo(() => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0))
+  const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
+  const openedTabs = createMemo(() =>
+    tabs()
+      .all()
+      .filter((tab) => tab !== "context"),
+  )
+
+  const showTabs = createMemo(
+    () => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()),
+  )
+
+  const activeTab = createMemo(() => {
+    const active = tabs().active()
+    if (active) return active
+    if (diffs().length > 0) return "review"
+    return tabs().all()[0] ?? "review"
+  })
 
   const mobileWorking = createMemo(() => status().type !== "idle")
   const mobileAutoScroll = createAutoScroll({
@@ -661,8 +937,350 @@ export default function Page() {
     </Switch>
   )
 
+  const ContextTab = () => {
+    const ctx = createMemo(() => {
+      const last = messages().findLast((x) => {
+        if (x.role !== "assistant") return false
+        const total = x.tokens.input + x.tokens.output + x.tokens.reasoning + x.tokens.cache.read + x.tokens.cache.write
+        return total > 0
+      }) as AssistantMessage
+      if (!last) return
+
+      const provider = sync.data.provider.all.find((x) => x.id === last.providerID)
+      const model = provider?.models[last.modelID]
+      const limit = model?.limit.context
+
+      const input = last.tokens.input
+      const output = last.tokens.output
+      const reasoning = last.tokens.reasoning
+      const cacheRead = last.tokens.cache.read
+      const cacheWrite = last.tokens.cache.write
+      const total = input + output + reasoning + cacheRead + cacheWrite
+      const usage = limit ? Math.round((total / limit) * 100) : null
+
+      return {
+        message: last,
+        provider,
+        model,
+        limit,
+        input,
+        output,
+        reasoning,
+        cacheRead,
+        cacheWrite,
+        total,
+        usage,
+      }
+    })
+
+    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 counts = createMemo(() => {
+      const all = messages()
+      const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
+      const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
+      return {
+        all: all.length,
+        user,
+        assistant,
+      }
+    })
+
+    const systemPrompt = createMemo(() => {
+      const msg = visibleUserMessages().findLast((m) => !!m.system)
+      const system = msg?.system
+      if (!system) return
+      const trimmed = system.trim()
+      if (!trimmed) return
+      return trimmed
+    })
+
+    const number = (value: number | null | undefined) => {
+      if (value === undefined) return "—"
+      if (value === null) return "—"
+      return value.toLocaleString()
+    }
+
+    const percent = (value: number | null | undefined) => {
+      if (value === undefined) return "—"
+      if (value === null) return "—"
+      return value.toString() + "%"
+    }
+
+    const time = (value: number | undefined) => {
+      if (!value) return "—"
+      return DateTime.fromMillis(value).toLocaleString(DateTime.DATETIME_MED)
+    }
+
+    const providerLabel = createMemo(() => {
+      const c = ctx()
+      if (!c) return "—"
+      return c.provider?.name ?? c.message.providerID
+    })
+
+    const modelLabel = createMemo(() => {
+      const c = ctx()
+      if (!c) return "—"
+      if (c.model?.name) return c.model.name
+      return c.message.modelID
+    })
+
+    const breakdown = createMemo(
+      on(
+        () => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
+        () => {
+          const c = ctx()
+          if (!c) return []
+          const input = c.input
+          if (!input) return []
+
+          const out = {
+            system: systemPrompt()?.length ?? 0,
+            user: 0,
+            assistant: 0,
+            tool: 0,
+          }
+
+          for (const msg of messages()) {
+            const parts = (sync.data.part[msg.id] ?? []) as Part[]
+
+            if (msg.role === "user") {
+              for (const part of parts) {
+                if (part.type === "text") out.user += part.text.length
+                if (part.type === "file") out.user += part.source?.text.value.length ?? 0
+                if (part.type === "agent") out.user += part.source?.value.length ?? 0
+              }
+              continue
+            }
+
+            if (msg.role === "assistant") {
+              for (const part of parts) {
+                if (part.type === "text") out.assistant += part.text.length
+                if (part.type === "reasoning") out.assistant += part.text.length
+                if (part.type === "tool") {
+                  out.tool += Object.keys(part.state.input).length * 16
+                  if (part.state.status === "pending") out.tool += part.state.raw.length
+                  if (part.state.status === "completed") out.tool += part.state.output.length
+                  if (part.state.status === "error") out.tool += part.state.error.length
+                }
+              }
+            }
+          }
+
+          const estimateTokens = (chars: number) => Math.ceil(chars / 4)
+          const system = estimateTokens(out.system)
+          const user = estimateTokens(out.user)
+          const assistant = estimateTokens(out.assistant)
+          const tool = estimateTokens(out.tool)
+          const estimated = system + user + assistant + tool
+
+          const pct = (tokens: number) => (tokens / input) * 100
+          const pctLabel = (tokens: number) => (Math.round(pct(tokens) * 10) / 10).toString() + "%"
+
+          const build = (tokens: { system: number; user: number; assistant: number; tool: number; other: number }) => {
+            return [
+              {
+                key: "system",
+                label: "System",
+                tokens: tokens.system,
+                width: pct(tokens.system),
+                percent: pctLabel(tokens.system),
+                color: "var(--syntax-info)",
+              },
+              {
+                key: "user",
+                label: "User",
+                tokens: tokens.user,
+                width: pct(tokens.user),
+                percent: pctLabel(tokens.user),
+                color: "var(--syntax-success)",
+              },
+              {
+                key: "assistant",
+                label: "Assistant",
+                tokens: tokens.assistant,
+                width: pct(tokens.assistant),
+                percent: pctLabel(tokens.assistant),
+                color: "var(--syntax-property)",
+              },
+              {
+                key: "tool",
+                label: "Tool Calls",
+                tokens: tokens.tool,
+                width: pct(tokens.tool),
+                percent: pctLabel(tokens.tool),
+                color: "var(--syntax-warning)",
+              },
+              {
+                key: "other",
+                label: "Other",
+                tokens: tokens.other,
+                width: pct(tokens.other),
+                percent: pctLabel(tokens.other),
+                color: "var(--syntax-comment)",
+              },
+            ].filter((x) => x.tokens > 0)
+          }
+
+          if (estimated <= input) {
+            return build({ system, user, assistant, tool, other: input - estimated })
+          }
+
+          const scale = input / estimated
+          const scaled = {
+            system: Math.floor(system * scale),
+            user: Math.floor(user * scale),
+            assistant: Math.floor(assistant * scale),
+            tool: Math.floor(tool * scale),
+          }
+          const scaledTotal = scaled.system + scaled.user + scaled.assistant + scaled.tool
+          return build({ ...scaled, other: Math.max(0, input - scaledTotal) })
+        },
+      ),
+    )
+
+    function Stat(props: { label: string; value: JSX.Element }) {
+      return (
+        <div class="flex flex-col gap-1">
+          <div class="text-12-regular text-text-weak">{props.label}</div>
+          <div class="text-12-medium text-text-strong">{props.value}</div>
+        </div>
+      )
+    }
+
+    const stats = createMemo(() => {
+      const c = ctx()
+      const count = counts()
+      return [
+        { label: "Session", value: info()?.title ?? params.id ?? "—" },
+        { label: "Messages", value: count.all.toLocaleString() },
+        { label: "Provider", value: providerLabel() },
+        { label: "Model", value: modelLabel() },
+        { label: "Context Limit", value: number(c?.limit) },
+        { label: "Total Tokens", value: number(c?.total) },
+        { label: "Usage", value: percent(c?.usage) },
+        { label: "Input Tokens", value: number(c?.input) },
+        { label: "Output Tokens", value: number(c?.output) },
+        { label: "Reasoning Tokens", value: number(c?.reasoning) },
+        { label: "Cache Tokens (read/write)", value: `${number(c?.cacheRead)} / ${number(c?.cacheWrite)}` },
+        { label: "User Messages", value: count.user.toLocaleString() },
+        { label: "Assistant Messages", value: count.assistant.toLocaleString() },
+        { label: "Total Cost", value: cost() },
+        { label: "Session Created", value: time(info()?.time.created) },
+        { label: "Last Activity", value: time(c?.message.time.created) },
+      ] satisfies { label: string; value: JSX.Element }[]
+    })
+
+    function RawMessageContent(props: { message: Message }) {
+      const file = createMemo(() => {
+        const parts = (sync.data.part[props.message.id] ?? []) as Part[]
+        const contents = JSON.stringify({ message: props.message, parts }, null, 2)
+        return {
+          name: `${props.message.role}-${props.message.id}.json`,
+          contents,
+          cacheKey: checksum(contents),
+        }
+      })
+
+      return <Code file={file()} overflow="wrap" class="select-text" />
+    }
+
+    function RawMessage(props: { message: Message }) {
+      return (
+        <Accordion.Item value={props.message.id}>
+          <StickyAccordionHeader>
+            <Accordion.Trigger>
+              <div class="flex items-center justify-between gap-2 w-full">
+                <div class="min-w-0 truncate">
+                  {props.message.role} <span class="text-text-base">• {props.message.id}</span>
+                </div>
+                <div class="flex items-center gap-3">
+                  <div class="shrink-0 text-12-regular text-text-weak">{time(props.message.time.created)}</div>
+                  <Icon name="chevron-grabber-vertical" size="small" class="shrink-0 text-text-weak" />
+                </div>
+              </div>
+            </Accordion.Trigger>
+          </StickyAccordionHeader>
+          <Accordion.Content class="bg-background-base">
+            <div class="p-3">
+              <RawMessageContent message={props.message} />
+            </div>
+          </Accordion.Content>
+        </Accordion.Item>
+      )
+    }
+
+    return (
+      <div class="@container h-full overflow-y-auto no-scrollbar pb-10">
+        <div class="px-6 pt-4 flex flex-col gap-10">
+          <div class="grid grid-cols-1 @[32rem]:grid-cols-2 gap-4">
+            <For each={stats()}>{(stat) => <Stat label={stat.label} value={stat.value} />}</For>
+          </div>
+
+          <Show when={breakdown().length > 0}>
+            <div class="flex flex-col gap-2">
+              <div class="text-12-regular text-text-weak">Context Breakdown</div>
+              <div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
+                <For each={breakdown()}>
+                  {(segment) => (
+                    <div
+                      class="h-full"
+                      style={{
+                        width: `${segment.width}%`,
+                        "background-color": segment.color,
+                      }}
+                    />
+                  )}
+                </For>
+              </div>
+              <div class="flex flex-wrap gap-x-3 gap-y-1">
+                <For each={breakdown()}>
+                  {(segment) => (
+                    <div class="flex items-center gap-1 text-11-regular text-text-weak">
+                      <div class="size-2 rounded-sm" style={{ "background-color": segment.color }} />
+                      <div>{segment.label}</div>
+                      <div class="text-text-weaker">{segment.percent}</div>
+                    </div>
+                  )}
+                </For>
+              </div>
+              <div class="hidden text-11-regular text-text-weaker">
+                Approximate breakdown of input tokens. "Other" includes tool definitions and overhead.
+              </div>
+            </div>
+          </Show>
+
+          <Show when={systemPrompt()}>
+            {(prompt) => (
+              <div class="flex flex-col gap-2">
+                <div class="text-12-regular text-text-weak">System Prompt</div>
+                <div class="border border-border-base rounded-md bg-surface-base px-3 py-2">
+                  <Markdown text={prompt()} class="text-12-regular" />
+                </div>
+              </div>
+            )}
+          </Show>
+
+          <div class="flex flex-col gap-2">
+            <div class="text-12-regular text-text-weak">Raw messages</div>
+            <Accordion multiple>
+              <For each={messages()}>{(message) => <RawMessage message={message} />}</For>
+            </Accordion>
+          </div>
+        </div>
+      </div>
+    )
+  }
+
   return (
     <div class="relative bg-background-base size-full overflow-hidden flex flex-col">
+      <Header />
       <div class="md:hidden flex-1 min-h-0 flex flex-col bg-background-stronger">
         <Switch>
           <Match when={!params.id}>
@@ -687,6 +1305,8 @@ export default function Page() {
                 <div class="relative h-full mt-6 overflow-y-auto no-scrollbar">
                   <SessionReview
                     diffs={diffs()}
+                    diffStyle={layout.review.diffStyle()}
+                    onDiffStyleChange={layout.review.setDiffStyle}
                     classes={{
                       root: "pb-32",
                       header: "px-4",
@@ -757,7 +1377,7 @@ export default function Page() {
             >
               <DragDropSensors />
               <ConstrainDragYAxis />
-              <Tabs value={tabs().active() ?? "review"} onChange={tabs().open}>
+              <Tabs value={activeTab()} onChange={tabs().open}>
                 <div class="sticky top-0 shrink-0 flex">
                   <Tabs.List>
                     <Show when={diffs().length}>
@@ -777,19 +1397,31 @@ export default function Page() {
                         </div>
                       </Tabs.Trigger>
                     </Show>
-                    <SortableProvider ids={tabs().all() ?? []}>
-                      <For each={tabs().all() ?? []}>
+                    <Show when={contextOpen()}>
+                      <Tabs.Trigger
+                        value="context"
+                        closeButton={
+                          <Tooltip value="Close tab" placement="bottom">
+                            <IconButton icon="close" variant="ghost" onClick={() => tabs().close("context")} />
+                          </Tooltip>
+                        }
+                        hideCloseButton
+                      >
+                        <div class="flex items-center gap-2">
+                          <SessionContextUsage variant="indicator" />
+                          <div>Context</div>
+                        </div>
+                      </Tabs.Trigger>
+                    </Show>
+                    <SortableProvider ids={openedTabs()}>
+                      <For each={openedTabs()}>
                         {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
                       </For>
                     </SortableProvider>
                     <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
-                      <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>
-                        }
+                      <TooltipKeybind
+                        title="Open file"
+                        keybind={command.keybind("file.open")}
                         class="flex items-center"
                       >
                         <IconButton
@@ -798,12 +1430,12 @@ export default function Page() {
                           iconSize="large"
                           onClick={() => dialog.show(() => <DialogSelectFile />)}
                         />
-                      </Tooltip>
+                      </TooltipKeybind>
                     </div>
                   </Tabs.List>
                 </div>
                 <Show when={diffs().length}>
-                  <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden contain-strict">
+                  <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
                     <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
                       <SessionReview
                         classes={{
@@ -812,12 +1444,20 @@ export default function Page() {
                           container: "px-6",
                         }}
                         diffs={diffs()}
-                        split
+                        diffStyle={layout.review.diffStyle()}
+                        onDiffStyleChange={layout.review.setDiffStyle}
                       />
                     </div>
                   </Tabs.Content>
                 </Show>
-                <For each={tabs().all()}>
+                <Show when={contextOpen()}>
+                  <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
+                    <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                      <ContextTab />
+                    </div>
+                  </Tabs.Content>
+                </Show>
+                <For each={openedTabs()}>
                   {(tab) => {
                     const [file] = createResource(
                       () => tab,
@@ -829,7 +1469,7 @@ export default function Page() {
                       },
                     )
                     return (
-                      <Tabs.Content value={tab} class="select-text mt-3">
+                      <Tabs.Content value={tab} class="mt-3">
                         <Switch>
                           <Match when={file()}>
                             {(f) => (
@@ -841,7 +1481,7 @@ export default function Page() {
                                   cacheKey: checksum(f().content?.content ?? ""),
                                 }}
                                 overflow="scroll"
-                                class="pb-40"
+                                class="select-text pb-40"
                               />
                             )}
                           </Match>
@@ -904,17 +1544,13 @@ 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={
-                      <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>
-                    }
+                  <TooltipKeybind
+                    title="New terminal"
+                    keybind={command.keybind("terminal.new")}
                     class="flex items-center"
                   >
                     <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
-                  </Tooltip>
+                  </TooltipKeybind>
                 </div>
               </Tabs.List>
               <For each={terminal.all()}>
@@ -944,10 +1580,6 @@ export default function Page() {
           </DragDropProvider>
         </div>
       </Show>
-      <StatusBar>
-        <SessionLspIndicator />
-        <SessionMcpIndicator />
-      </StatusBar>
     </div>
   )
 }

+ 1 - 1
packages/app/vite.config.ts

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

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

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

+ 9 - 3
packages/console/app/src/routes/workspace/[id]/billing/reload-section.tsx

@@ -1,5 +1,5 @@
 import { json, action, useParams, createAsync, useSubmission } from "@solidjs/router"
-import { createEffect, Show } from "solid-js"
+import { createEffect, Show, createMemo } from "solid-js"
 import { createStore } from "solid-js/store"
 import { withActor } from "~/context/auth.withActor"
 import { Billing } from "@opencode-ai/console-core/billing.js"
@@ -68,6 +68,12 @@ export function ReloadSection() {
     reloadTrigger: "",
   })
 
+  const processingFee = createMemo(() => {
+    const reloadAmount = billingInfo()?.reloadAmount
+    if (!reloadAmount) return "0.00"
+    return (((reloadAmount + 0.3) / 0.956) * 0.044 + 0.3).toFixed(2)
+  })
+
   createEffect(() => {
     if (!setReloadSubmission.pending && setReloadSubmission.result && !(setReloadSubmission.result as any).error) {
       setStore("show", false)
@@ -104,8 +110,8 @@ export function ReloadSection() {
             }
           >
             <p>
-              Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+$1.23 processing fee)
-              when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
+              Auto reload is <b>enabled</b>. We'll reload <b>${billingInfo()?.reloadAmount}</b> (+${processingFee()}{" "}
+              processing fee) when balance reaches <b>${billingInfo()?.reloadTrigger}</b>.
             </p>
           </Show>
           <button data-color="primary" type="button" onClick={() => show()}>

+ 2 - 0
packages/console/app/src/routes/zen/util/handler.ts

@@ -124,6 +124,8 @@ export async function handler(
         res.status !== 200 &&
         // ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
         res.status !== 404 &&
+        // ie. cannot change codex model providers mid-session
+        !modelInfo.stickyProvider &&
         modelInfo.fallbackProvider &&
         providerInfo.id !== modelInfo.fallbackProvider
       ) {

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

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

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

+ 2 - 1
packages/desktop/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@opencode-ai/desktop",
   "private": true,
-  "version": "1.0.207",
+  "version": "1.0.223",
   "type": "module",
   "scripts": {
     "typecheck": "tsgo -b",
@@ -18,6 +18,7 @@
     "@tauri-apps/plugin-dialog": "~2",
     "@tauri-apps/plugin-opener": "^2",
     "@tauri-apps/plugin-os": "~2",
+    "@tauri-apps/plugin-notification": "~2",
     "@tauri-apps/plugin-process": "~2",
     "@tauri-apps/plugin-shell": "~2",
     "@tauri-apps/plugin-store": "~2",

+ 1 - 1
packages/desktop/scripts/predev.ts

@@ -6,7 +6,7 @@ const RUST_TARGET = Bun.env.TAURI_ENV_TARGET_TRIPLE
 
 const sidecarConfig = getCurrentSidecar(RUST_TARGET)
 
-const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`
+const binaryPath = `../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode${process.platform === "win32" ? ".exe" : ""}`
 
 await $`cd ../opencode && bun run build --single`
 

+ 58 - 0
packages/desktop/src-tauri/Cargo.lock

@@ -2210,6 +2210,18 @@ version = "0.1.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
 
+[[package]]
+name = "mac-notification-sys"
+version = "0.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
+dependencies = [
+ "cc",
+ "objc2 0.6.3",
+ "objc2-foundation 0.3.2",
+ "time",
+]
+
 [[package]]
 name = "markup5ever"
 version = "0.14.1"
@@ -2384,6 +2396,20 @@ dependencies = [
  "memchr",
 ]
 
+[[package]]
+name = "notify-rust"
+version = "4.11.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400"
+dependencies = [
+ "futures-lite",
+ "log",
+ "mac-notification-sys",
+ "serde",
+ "tauri-winrt-notification",
+ "zbus",
+]
+
 [[package]]
 name = "num-conv"
 version = "0.1.0"
@@ -2758,6 +2784,7 @@ dependencies = [
  "tauri-plugin-clipboard-manager",
  "tauri-plugin-dialog",
  "tauri-plugin-http",
+ "tauri-plugin-notification",
  "tauri-plugin-opener",
  "tauri-plugin-os",
  "tauri-plugin-process",
@@ -4519,6 +4546,25 @@ dependencies = [
  "urlpattern",
 ]
 
+[[package]]
+name = "tauri-plugin-notification"
+version = "2.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
+dependencies = [
+ "log",
+ "notify-rust",
+ "rand 0.9.2",
+ "serde",
+ "serde_json",
+ "serde_repr",
+ "tauri",
+ "tauri-plugin",
+ "thiserror 2.0.17",
+ "time",
+ "url",
+]
+
 [[package]]
 name = "tauri-plugin-opener"
 version = "2.5.2"
@@ -4754,6 +4800,18 @@ dependencies = [
  "toml 0.9.8",
 ]
 
+[[package]]
+name = "tauri-winrt-notification"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
+dependencies = [
+ "quick-xml 0.37.5",
+ "thiserror 2.0.17",
+ "windows",
+ "windows-version",
+]
+
 [[package]]
 name = "tempfile"
 version = "3.23.0"

+ 1 - 0
packages/desktop/src-tauri/Cargo.toml

@@ -28,6 +28,7 @@ tauri-plugin-store = "2"
 tauri-plugin-window-state = "2"
 tauri-plugin-clipboard-manager = "2"
 tauri-plugin-http = "2"
+tauri-plugin-notification = "2"
 
 serde = { version = "1", features = ["derive"] }
 serde_json = "1"

+ 5 - 0
packages/desktop/src-tauri/capabilities/default.json

@@ -8,6 +8,10 @@
     "opener:default",
     "core:window:allow-start-dragging",
     "core:webview:allow-set-webview-zoom",
+    "core:window:allow-is-focused",
+    "core:window:allow-show",
+    "core:window:allow-unminimize",
+    "core:window:allow-set-focus",
     "shell:default",
     "updater:default",
     "dialog:default",
@@ -15,6 +19,7 @@
     "store:default",
     "window-state:default",
     "os:default",
+    "notification:default",
     {
       "identifier": "http:default",
       "allow": [{ "url": "http://*" }, { "url": "https://*" }, { "url": "http://*:*/*" }]

+ 1 - 0
packages/desktop/src-tauri/src/lib.rs

@@ -198,6 +198,7 @@ pub fn run() {
         .plugin(tauri_plugin_opener::init())
         .plugin(tauri_plugin_clipboard_manager::init())
         .plugin(tauri_plugin_http::init())
+        .plugin(tauri_plugin_notification::init())
         .plugin(PinchZoomDisablePlugin)
         .invoke_handler(tauri::generate_handler![
             kill_sidecar,

+ 105 - 16
packages/desktop/src/index.tsx

@@ -12,6 +12,8 @@ import { UPDATER_ENABLED } from "./updater"
 import { createMenu } from "./menu"
 import { check, Update } from "@tauri-apps/plugin-updater"
 import { invoke } from "@tauri-apps/api/core"
+import { getCurrentWindow } from "@tauri-apps/api/window"
+import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
 import { relaunch } from "@tauri-apps/plugin-process"
 import pkg from "../package.json"
 
@@ -55,19 +57,71 @@ const platform: Platform = {
   },
 
   openLink(url: string) {
-    shellOpen(url)
+    void shellOpen(url).catch(() => undefined)
   },
 
   storage: (name = "default.dat") => {
-    const api: AsyncStorage = {
+    type StoreLike = {
+      get(key: string): Promise<string | null | undefined>
+      set(key: string, value: string): Promise<unknown>
+      delete(key: string): Promise<unknown>
+      clear(): Promise<unknown>
+      keys(): Promise<string[]>
+      length(): Promise<number>
+    }
+
+    const memory = () => {
+      const data = new Map<string, string>()
+      const store: StoreLike = {
+        get: async (key) => data.get(key),
+        set: async (key, value) => {
+          data.set(key, value)
+        },
+        delete: async (key) => {
+          data.delete(key)
+        },
+        clear: async () => {
+          data.clear()
+        },
+        keys: async () => Array.from(data.keys()),
+        length: async () => data.size,
+      }
+      return store
+    }
+
+    const api: AsyncStorage & { _store: Promise<StoreLike> | null; _getStore: () => Promise<StoreLike> } = {
       _store: null,
-      _getStore: async () => api._store || (api._store = Store.load(name)),
-      getItem: async (key: string) => (await (await api._getStore()).get(key)) ?? null,
-      setItem: async (key: string, value: string) => await (await api._getStore()).set(key, value),
-      removeItem: async (key: string) => await (await api._getStore()).delete(key),
-      clear: async () => await (await api._getStore()).clear(),
-      key: async (index: number) => (await (await api._getStore()).keys())[index],
-      getLength: async () => (await api._getStore()).length(),
+      _getStore: async () => {
+        if (api._store) return api._store
+        api._store = Store.load(name).catch(() => memory())
+        return api._store
+      },
+      getItem: async (key: string) => {
+        const store = await api._getStore()
+        const value = await store.get(key).catch(() => null)
+        if (value === undefined) return null
+        return value
+      },
+      setItem: async (key: string, value: string) => {
+        const store = await api._getStore()
+        await store.set(key, value).catch(() => undefined)
+      },
+      removeItem: async (key: string) => {
+        const store = await api._getStore()
+        await store.delete(key).catch(() => undefined)
+      },
+      clear: async () => {
+        const store = await api._getStore()
+        await store.clear().catch(() => undefined)
+      },
+      key: async (index: number) => {
+        const store = await api._getStore()
+        return (await store.keys().catch(() => []))[index]
+      },
+      getLength: async () => {
+        const store = await api._getStore()
+        return await store.length().catch(() => 0)
+      },
       get length() {
         return api.getLength()
       },
@@ -77,23 +131,58 @@ const platform: Platform = {
 
   checkUpdate: async () => {
     if (!UPDATER_ENABLED) return { updateAvailable: false }
-    update = await check()
-    if (!update) return { updateAvailable: false }
-    await update.download()
-    return { updateAvailable: true, version: update.version }
+    const next = await check().catch(() => null)
+    if (!next) return { updateAvailable: false }
+    const ok = await next
+      .download()
+      .then(() => true)
+      .catch(() => false)
+    if (!ok) return { updateAvailable: false }
+    update = next
+    return { updateAvailable: true, version: next.version }
   },
 
   update: async () => {
     if (!UPDATER_ENABLED || !update) return
-    if (ostype() === "windows") await invoke("kill_sidecar")
-    await update.install()
+    if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined)
+    await update.install().catch(() => undefined)
   },
 
   restart: async () => {
-    await invoke("kill_sidecar")
+    await invoke("kill_sidecar").catch(() => undefined)
     await relaunch()
   },
 
+  notify: async (title, description, href) => {
+    const granted = await isPermissionGranted().catch(() => false)
+    const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
+    if (permission !== "granted") return
+
+    const win = getCurrentWindow()
+    const focused = await win.isFocused().catch(() => document.hasFocus())
+    if (focused) return
+
+    await Promise.resolve()
+      .then(() => {
+        const notification = new Notification(title, {
+          body: description ?? "",
+          icon: "https://opencode.ai/favicon-96x96.png",
+        })
+        notification.onclick = () => {
+          const win = getCurrentWindow()
+          void win.show().catch(() => undefined)
+          void win.unminimize().catch(() => undefined)
+          void win.setFocus().catch(() => undefined)
+          if (href) {
+            window.history.pushState(null, "", href)
+            window.dispatchEvent(new PopStateEvent("popstate"))
+          }
+          notification.close()
+        }
+      })
+      .catch(() => undefined)
+  },
+
   // @ts-expect-error
   fetch: tauriFetch,
 }

+ 3 - 2
packages/desktop/vite.config.ts

@@ -10,8 +10,9 @@ export default defineConfig({
   //
   // 1. prevent Vite from obscuring rust errors
   clearScreen: false,
-  build: {
-    sourcemap: true,
+  esbuild: {
+    // Improves production stack traces
+    keepNames: true,
   },
   worker: {
     format: "es",

+ 1 - 1
packages/enterprise/package.json

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

+ 8 - 5
packages/enterprise/src/app.tsx

@@ -3,6 +3,7 @@ import { FileRoutes } from "@solidjs/start/router"
 import { Font } from "@opencode-ai/ui/font"
 import { MetaProvider } from "@solidjs/meta"
 import { MarkedProvider } from "@opencode-ai/ui/context/marked"
+import { DialogProvider } from "@opencode-ai/ui/context/dialog"
 import { Suspense } from "solid-js"
 import "./app.css"
 import { Favicon } from "@opencode-ai/ui/favicon"
@@ -12,11 +13,13 @@ export default function App() {
     <Router
       root={(props) => (
         <MetaProvider>
-          <MarkedProvider>
-            <Favicon />
-            <Font />
-            <Suspense>{props.children}</Suspense>
-          </MarkedProvider>
+          <DialogProvider>
+            <MarkedProvider>
+              <Favicon />
+              <Font />
+              <Suspense>{props.children}</Suspense>
+            </MarkedProvider>
+          </DialogProvider>
         </MetaProvider>
       )}
     >

+ 14 - 5
packages/enterprise/src/routes/share/[shareID].tsx

@@ -33,7 +33,7 @@ const ClientOnlyCode = clientOnly(() => import("@opencode-ai/ui/code").then((m)
 const ClientOnlyWorkerPoolProvider = clientOnly(() =>
   import("@opencode-ai/ui/pierre/worker").then((m) => ({
     default: (props: { children: any }) => (
-      <WorkerPoolProvider pool={m.workerPool}>{props.children}</WorkerPoolProvider>
+      <WorkerPoolProvider pools={m.getWorkerPools()}>{props.children}</WorkerPoolProvider>
     ),
   })),
 )
@@ -162,11 +162,20 @@ export default function () {
 
   return (
     <ErrorBoundary
-      fallback={(e) => {
+      fallback={(error) => {
+        if (SessionDataMissingError.isInstance(error)) {
+          return <NotFound />
+        }
+        console.error(error)
+        const details = error instanceof Error ? (error.stack ?? error.message) : String(error)
         return (
-          <Show when={e.message === "SessionDataMissingError"}>
-            <NotFound />
-          </Show>
+          <div class="min-h-screen w-full bg-background-base text-text-base flex flex-col items-center justify-center gap-4 p-6 text-center">
+            <p class="text-16-medium">Unable to render this share.</p>
+            <p class="text-14-regular text-text-weaker">Check the console for more details.</p>
+            <pre class="text-12-mono text-left whitespace-pre-wrap break-words w-full max-w-200 bg-background-stronger rounded-md p-4">
+              {details}
+            </pre>
+          </div>
         )
       }}
     >

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

@@ -1,7 +1,7 @@
 id = "opencode"
 name = "OpenCode"
 description = "The open source coding agent."
-version = "1.0.207"
+version = "1.0.223"
 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.207/opencode-darwin-arm64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.223/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.207/opencode-darwin-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.223/opencode-darwin-x64.zip"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.207/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.223/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.207/opencode-linux-x64.tar.gz"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.223/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.207/opencode-windows-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.223/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.207",
+  "version": "1.0.223",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "type": "module",

+ 1 - 0
packages/opencode/bunfig.toml

@@ -2,5 +2,6 @@ preload = ["@opentui/solid/preload"]
 
 [test]
 preload = ["./test/preload.ts"]
+timeout = 10000  # 10 seconds (default is 5000ms)
 # Enable code coverage
 coverage = true

+ 10 - 10
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.0.207",
+  "version": "1.0.223",
   "name": "opencode",
   "type": "module",
   "private": true,
@@ -53,23 +53,23 @@
     "@actions/github": "6.0.1",
     "@agentclientprotocol/sdk": "0.5.1",
     "@ai-sdk/amazon-bedrock": "3.0.57",
-    "@ai-sdk/anthropic": "2.0.50",
-    "@ai-sdk/azure": "2.0.73",
+    "@ai-sdk/anthropic": "2.0.56",
+    "@ai-sdk/azure": "2.0.82",
     "@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/google": "2.0.44",
+    "@ai-sdk/google": "2.0.49",
     "@ai-sdk/google-vertex": "3.0.81",
     "@ai-sdk/groq": "2.0.33",
-    "@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/openai-compatible": "1.0.29",
     "@ai-sdk/perplexity": "2.0.22",
     "@ai-sdk/provider": "2.0.0",
-    "@ai-sdk/provider-utils": "3.0.18",
+    "@ai-sdk/provider-utils": "3.0.19",
     "@ai-sdk/togetherai": "1.0.30",
+    "@ai-sdk/vercel": "1.0.31",
     "@ai-sdk/xai": "2.0.42",
     "@clack/prompts": "1.0.0-alpha.1",
     "@hono/standard-validator": "0.1.5",
@@ -84,8 +84,8 @@
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/util": "workspace:*",
     "@openrouter/ai-sdk-provider": "1.5.2",
-    "@opentui/core": "0.1.63",
-    "@opentui/solid": "0.1.63",
+    "@opentui/core": "0.1.67",
+    "@opentui/solid": "0.1.67",
     "@parcel/watcher": "2.5.1",
     "@pierre/diffs": "catalog:",
     "@solid-primitives/event-bus": "1.1.2",
@@ -93,7 +93,7 @@
     "@zip.js/zip.js": "2.7.62",
     "ai": "catalog:",
     "bonjour-service": "1.3.0",
-    "bun-pty": "0.4.2",
+    "bun-pty": "0.4.4",
     "chokidar": "4.0.3",
     "clipboardy": "4.0.0",
     "decimal.js": "10.5.0",

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

@@ -235,5 +235,19 @@ export default {
         ],
       },
     },
+    {
+      filetype: "nix",
+      // TODO: Replace with official tree-sitter-nix WASM when published
+      // See: https://github.com/nix-community/tree-sitter-nix/issues/66
+      wasm: "https://github.com/ast-grep/ast-grep.github.io/raw/40b84530640aa83a0d34a20a2b0623d7b8e5ea97/website/public/parsers/tree-sitter-nix.wasm",
+      queries: {
+        highlights: [
+          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/highlights.scm",
+        ],
+        locals: [
+          "https://raw.githubusercontent.com/nvim-treesitter/nvim-treesitter/refs/heads/master/queries/nix/locals.scm",
+        ],
+      },
+    },
   ],
 }

+ 6 - 0
packages/opencode/src/cli/cmd/auth.ts

@@ -349,6 +349,12 @@ export const AuthLoginCommand = cmd({
           prompts.log.info("You can create an api key at https://vercel.link/ai-gateway-token")
         }
 
+        if (["cloudflare", "cloudflare-ai-gateway"].includes(provider)) {
+          prompts.log.info(
+            "Cloudflare AI Gateway can be configured with CLOUDFLARE_GATEWAY_ID, CLOUDFLARE_ACCOUNT_ID, and CLOUDFLARE_API_TOKEN environment variables. Read more: https://opencode.ai/docs/providers/#cloudflare-ai-gateway",
+          )
+        }
+
         const key = await prompts.password({
           message: "Enter your API key",
           validate: (x) => (x && x.length > 0 ? undefined : "Required"),

+ 51 - 0
packages/opencode/src/cli/cmd/debug/agent.ts

@@ -0,0 +1,51 @@
+import { EOL } from "os"
+import { basename } from "path"
+import { Agent } from "../../../agent/agent"
+import { Provider } from "../../../provider/provider"
+import { ToolRegistry } from "../../../tool/registry"
+import { Wildcard } from "../../../util/wildcard"
+import { bootstrap } from "../../bootstrap"
+import { cmd } from "../cmd"
+
+export const AgentCommand = cmd({
+  command: "agent <name>",
+  builder: (yargs) =>
+    yargs.positional("name", {
+      type: "string",
+      demandOption: true,
+      description: "Agent name",
+    }),
+  async handler(args) {
+    await bootstrap(process.cwd(), async () => {
+      const agentName = args.name as string
+      const agent = await Agent.get(agentName)
+      if (!agent) {
+        process.stderr.write(
+          `Agent ${agentName} not found, run '${basename(process.execPath)} agent list' to get an agent list` + EOL,
+        )
+        process.exit(1)
+      }
+      const resolvedTools = await resolveTools(agent)
+      const output = {
+        ...agent,
+        tools: resolvedTools,
+        toolOverrides: agent.tools,
+      }
+      process.stdout.write(JSON.stringify(output, null, 2) + EOL)
+    })
+  },
+})
+
+async function resolveTools(agent: Agent.Info) {
+  const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID
+  const toolOverrides = {
+    ...agent.tools,
+    ...(await ToolRegistry.enabled(agent)),
+  }
+  const availableTools = await ToolRegistry.tools(providerID, agent)
+  const resolved: Record<string, boolean> = {}
+  for (const tool of availableTools) {
+    resolved[tool.id] = Wildcard.all(tool.id, toolOverrides) !== false
+  }
+  return resolved
+}

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

@@ -8,6 +8,7 @@ import { RipgrepCommand } from "./ripgrep"
 import { ScrapCommand } from "./scrap"
 import { SkillCommand } from "./skill"
 import { SnapshotCommand } from "./snapshot"
+import { AgentCommand } from "./agent"
 
 export const DebugCommand = cmd({
   command: "debug",
@@ -20,6 +21,7 @@ export const DebugCommand = cmd({
       .command(ScrapCommand)
       .command(SkillCommand)
       .command(SnapshotCommand)
+      .command(AgentCommand)
       .command(PathsCommand)
       .command({
         command: "wait",

+ 89 - 2
packages/opencode/src/cli/cmd/stats.ts

@@ -20,6 +20,17 @@ interface SessionStats {
     }
   }
   toolUsage: Record<string, number>
+  modelUsage: Record<
+    string,
+    {
+      messages: number
+      tokens: {
+        input: number
+        output: number
+      }
+      cost: number
+    }
+  >
   dateRange: {
     earliest: number
     latest: number
@@ -43,6 +54,9 @@ export const StatsCommand = cmd({
         describe: "number of tools to show (default: all)",
         type: "number",
       })
+      .option("models", {
+        describe: "show model statistics (default: hidden). Pass a number to show top N, otherwise shows all",
+      })
       .option("project", {
         describe: "filter by project (default: all projects, empty string: current project)",
         type: "string",
@@ -51,7 +65,15 @@ export const StatsCommand = cmd({
   handler: async (args) => {
     await bootstrap(process.cwd(), async () => {
       const stats = await aggregateSessionStats(args.days, args.project)
-      displayStats(stats, args.tools)
+
+      let modelLimit: number | undefined
+      if (args.models === true) {
+        modelLimit = Infinity
+      } else if (typeof args.models === "number") {
+        modelLimit = args.models
+      }
+
+      displayStats(stats, args.tools, modelLimit)
     })
   },
 })
@@ -121,6 +143,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
       },
     },
     toolUsage: {},
+    modelUsage: {},
     dateRange: {
       earliest: Date.now(),
       latest: Date.now(),
@@ -154,17 +177,43 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
       let sessionCost = 0
       let sessionTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
       let sessionToolUsage: Record<string, number> = {}
+      let sessionModelUsage: Record<
+        string,
+        {
+          messages: number
+          tokens: {
+            input: number
+            output: number
+          }
+          cost: number
+        }
+      > = {}
 
       for (const message of messages) {
         if (message.info.role === "assistant") {
           sessionCost += message.info.cost || 0
 
+          const modelKey = `${message.info.providerID}/${message.info.modelID}`
+          if (!sessionModelUsage[modelKey]) {
+            sessionModelUsage[modelKey] = {
+              messages: 0,
+              tokens: { input: 0, output: 0 },
+              cost: 0,
+            }
+          }
+          sessionModelUsage[modelKey].messages++
+          sessionModelUsage[modelKey].cost += message.info.cost || 0
+
           if (message.info.tokens) {
             sessionTokens.input += message.info.tokens.input || 0
             sessionTokens.output += message.info.tokens.output || 0
             sessionTokens.reasoning += message.info.tokens.reasoning || 0
             sessionTokens.cache.read += message.info.tokens.cache?.read || 0
             sessionTokens.cache.write += message.info.tokens.cache?.write || 0
+
+            sessionModelUsage[modelKey].tokens.input += message.info.tokens.input || 0
+            sessionModelUsage[modelKey].tokens.output +=
+              (message.info.tokens.output || 0) + (message.info.tokens.reasoning || 0)
           }
         }
 
@@ -181,6 +230,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
         sessionTokens,
         sessionTotalTokens: sessionTokens.input + sessionTokens.output + sessionTokens.reasoning,
         sessionToolUsage,
+        sessionModelUsage,
         earliestTime: session.time.created,
         latestTime: session.time.updated,
       }
@@ -204,6 +254,20 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
       for (const [tool, count] of Object.entries(result.sessionToolUsage)) {
         stats.toolUsage[tool] = (stats.toolUsage[tool] || 0) + count
       }
+
+      for (const [model, usage] of Object.entries(result.sessionModelUsage)) {
+        if (!stats.modelUsage[model]) {
+          stats.modelUsage[model] = {
+            messages: 0,
+            tokens: { input: 0, output: 0 },
+            cost: 0,
+          }
+        }
+        stats.modelUsage[model].messages += usage.messages
+        stats.modelUsage[model].tokens.input += usage.tokens.input
+        stats.modelUsage[model].tokens.output += usage.tokens.output
+        stats.modelUsage[model].cost += usage.cost
+      }
     }
   }
 
@@ -228,7 +292,7 @@ export async function aggregateSessionStats(days?: number, projectFilter?: strin
   return stats
 }
 
-export function displayStats(stats: SessionStats, toolLimit?: number) {
+export function displayStats(stats: SessionStats, toolLimit?: number, modelLimit?: number) {
   const width = 56
 
   function renderRow(label: string, value: string): string {
@@ -267,6 +331,29 @@ export function displayStats(stats: SessionStats, toolLimit?: number) {
   console.log("└────────────────────────────────────────────────────────┘")
   console.log()
 
+  // Model Usage section
+  if (modelLimit !== undefined && Object.keys(stats.modelUsage).length > 0) {
+    const sortedModels = Object.entries(stats.modelUsage).sort(([, a], [, b]) => b.messages - a.messages)
+    const modelsToDisplay = modelLimit === Infinity ? sortedModels : sortedModels.slice(0, modelLimit)
+
+    console.log("┌────────────────────────────────────────────────────────┐")
+    console.log("│                      MODEL USAGE                       │")
+    console.log("├────────────────────────────────────────────────────────┤")
+
+    for (const [model, usage] of modelsToDisplay) {
+      console.log(`│ ${model.padEnd(54)} │`)
+      console.log(renderRow("  Messages", usage.messages.toLocaleString()))
+      console.log(renderRow("  Input Tokens", formatNumber(usage.tokens.input)))
+      console.log(renderRow("  Output Tokens", formatNumber(usage.tokens.output)))
+      console.log(renderRow("  Cost", `$${usage.cost.toFixed(4)}`))
+      console.log("├────────────────────────────────────────────────────────┤")
+    }
+    // Remove last separator and add bottom border
+    process.stdout.write("\x1B[1A") // Move up one line
+    console.log("└────────────────────────────────────────────────────────┘")
+  }
+  console.log()
+
   // Tool Usage section
   if (Object.keys(stats.toolUsage).length > 0) {
     const sortedTools = Object.entries(stats.toolUsage).sort(([, a], [, b]) => b - a)

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

@@ -372,6 +372,15 @@ function App() {
         local.agent.move(1)
       },
     },
+    {
+      title: "Variant cycle",
+      value: "variant.cycle",
+      keybind: "variant_cycle",
+      category: "Agent",
+      onSelect: () => {
+        local.model.variant.cycle()
+      },
+    },
     {
       title: "Agent cycle reverse",
       value: "agent.cycle.reverse",

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

@@ -150,7 +150,7 @@ export function DialogModel(props: { providerID?: string }) {
               (item) => item.providerID === value.providerID && item.modelID === value.modelID,
             )
             if (inFavorites) return false
-            const inRecents = recents.some(
+            const inRecents = recentList.some(
               (item) => item.providerID === value.providerID && item.modelID === value.modelID,
             )
             if (inRecents) return false

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

@@ -259,7 +259,7 @@ export function Autocomplete(props: {
     const s = session()
     for (const command of sync.data.command) {
       results.push({
-        display: "/" + command.name,
+        display: "/" + command.name + (command.mcp ? " (MCP)" : ""),
         description: command.description,
         onSelect: () => {
           const newText = "/" + command.name + " "

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

@@ -167,6 +167,13 @@ export function Prompt(props: PromptProps) {
     if (!props.disabled) input.cursorColor = theme.text
   })
 
+  const lastUserMessage = createMemo(() => {
+    if (!props.sessionID) return undefined
+    const messages = sync.data.message[props.sessionID]
+    if (!messages) return undefined
+    return messages.findLast((m) => m.role === "user")
+  })
+
   const [store, setStore] = createStore<{
     prompt: PromptInfo
     mode: "normal" | "shell"
@@ -184,6 +191,27 @@ export function Prompt(props: PromptProps) {
     interrupt: 0,
   })
 
+  // Initialize agent/model/variant from last user message when session changes
+  let syncedSessionID: string | undefined
+  createEffect(() => {
+    const sessionID = props.sessionID
+    const msg = lastUserMessage()
+
+    if (sessionID !== syncedSessionID) {
+      if (!sessionID || !msg) return
+
+      syncedSessionID = sessionID
+
+      // Only set agent if it's a primary agent (not a subagent)
+      const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
+      if (msg.agent && isPrimaryAgent) {
+        local.agent.set(msg.agent)
+      }
+      if (msg.model) local.model.set(msg.model)
+      if (msg.variant) local.model.variant.set(msg.variant)
+    }
+  })
+
   command.register(() => {
     return [
       {
@@ -562,6 +590,7 @@ export function Prompt(props: PromptProps) {
 
     // Capture mode before it gets reset
     const currentMode = store.mode
+    const variant = local.model.variant.current()
 
     if (store.mode === "shell") {
       sdk.client.session.shell({
@@ -590,6 +619,7 @@ export function Prompt(props: PromptProps) {
         agent: local.agent.current().name,
         model: `${selectedModel.providerID}/${selectedModel.modelID}`,
         messageID,
+        variant,
       })
     } else {
       sdk.client.session.prompt({
@@ -598,6 +628,7 @@ export function Prompt(props: PromptProps) {
         messageID,
         agent: local.agent.current().name,
         model: selectedModel,
+        variant,
         parts: [
           {
             id: Identifier.ascending("part"),
@@ -718,6 +749,13 @@ export function Prompt(props: PromptProps) {
     return local.agent.color(local.agent.current().name)
   })
 
+  const showVariant = createMemo(() => {
+    const variants = local.model.variant.list()
+    if (variants.length === 0) return false
+    const current = local.model.variant.current()
+    return !!current
+  })
+
   const spinnerDef = createMemo(() => {
     const color = local.agent.color(local.agent.current().name)
     return {
@@ -958,6 +996,12 @@ export function Prompt(props: PromptProps) {
                     {local.model.parsed().model}
                   </text>
                   <text fg={theme.textMuted}>{local.model.parsed().provider}</text>
+                  <Show when={showVariant()}>
+                    <text fg={theme.textMuted}>·</text>
+                    <text>
+                      <span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
+                    </text>
+                  </Show>
                 </box>
               </Show>
             </box>

+ 1 - 1
packages/opencode/src/cli/cmd/tui/component/tips.ts

@@ -28,7 +28,7 @@ export const TIPS = [
   "Press {highlight}Ctrl+C{/highlight} when typing to clear the input field.",
   "Press {highlight}Escape{/highlight} to stop the AI mid-response.",
   "Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes.",
-  "Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents.",
+  "Use {highlight}@<agent-name>{/highlight} in prompts to invoke specialized subagents.",
   "Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions.",
   "Create {highlight}opencode.json{/highlight} in project root for project-specific settings.",
   "Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config.",

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

@@ -33,24 +33,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       }
     }
 
-    // Automatically update model when agent changes
-    createEffect(() => {
-      const value = agent.current()
-      if (value.model) {
-        if (isModelValid(value.model))
-          model.set({
-            providerID: value.model.providerID,
-            modelID: value.model.modelID,
-          })
-        else
-          toast.show({
-            variant: "warning",
-            message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
-            duration: 3000,
-          })
-      }
-    })
-
     const agent = iife(() => {
       const agents = createMemo(() => sync.data.agent.filter((x) => x.mode !== "subagent" && !x.hidden))
       const [agentStore, setAgentStore] = createStore<{
@@ -120,11 +102,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           providerID: string
           modelID: string
         }[]
+        variant: Record<string, string | undefined>
       }>({
         ready: false,
         model: {},
         recent: [],
         favorite: [],
+        variant: {},
       })
 
       const file = Bun.file(path.join(Global.Path.state, "model.json"))
@@ -135,6 +119,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           JSON.stringify({
             recent: modelStore.recent,
             favorite: modelStore.favorite,
+            variant: modelStore.variant,
           }),
         )
       }
@@ -144,6 +129,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         .then((x) => {
           if (Array.isArray(x.recent)) setModelStore("recent", x.recent)
           if (Array.isArray(x.favorite)) setModelStore("favorite", x.favorite)
+          if (typeof x.variant === "object" && x.variant !== null) setModelStore("variant", x.variant)
         })
         .catch(() => {})
         .finally(() => {
@@ -218,6 +204,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
             return {
               provider: "Connect a provider",
               model: "No provider selected",
+              reasoning: false,
             }
           }
           const provider = sync.data.provider.find((x) => x.id === value.providerID)
@@ -225,6 +212,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           return {
             provider: provider?.name ?? value.providerID,
             model: info?.name ?? value.modelID,
+            reasoning: info?.capabilities?.reasoning ?? false,
           }
         }),
         cycle(direction: 1 | -1) {
@@ -265,9 +253,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           const next = favorites[index]
           if (!next) return
           setModelStore("model", agent.current().name, { ...next })
-          const uniq = uniqueBy([next, ...modelStore.recent], (x) => x.providerID + x.modelID)
+          const uniq = uniqueBy([next, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
           if (uniq.length > 10) uniq.pop()
-          setModelStore("recent", uniq)
+          setModelStore(
+            "recent",
+            uniq.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
+          )
           save()
         },
         set(model: { providerID: string; modelID: string }, options?: { recent?: boolean }) {
@@ -282,9 +273,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
             }
             setModelStore("model", agent.current().name, model)
             if (options?.recent) {
-              const uniq = uniqueBy([model, ...modelStore.recent], (x) => x.providerID + x.modelID)
+              const uniq = uniqueBy([model, ...modelStore.recent], (x) => `${x.providerID}/${x.modelID}`)
               if (uniq.length > 10) uniq.pop()
-              setModelStore("recent", uniq)
+              setModelStore(
+                "recent",
+                uniq.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
+              )
               save()
             }
           })
@@ -305,10 +299,51 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
             const next = exists
               ? modelStore.favorite.filter((x) => x.providerID !== model.providerID || x.modelID !== model.modelID)
               : [model, ...modelStore.favorite]
-            setModelStore("favorite", next)
+            setModelStore(
+              "favorite",
+              next.map((x) => ({ providerID: x.providerID, modelID: x.modelID })),
+            )
             save()
           })
         },
+        variant: {
+          current() {
+            const m = currentModel()
+            if (!m) return undefined
+            const key = `${m.providerID}/${m.modelID}`
+            return modelStore.variant[key]
+          },
+          list() {
+            const m = currentModel()
+            if (!m) return []
+            const provider = sync.data.provider.find((x) => x.id === m.providerID)
+            const info = provider?.models[m.modelID]
+            if (!info?.variants) return []
+            return Object.keys(info.variants)
+          },
+          set(value: string | undefined) {
+            const m = currentModel()
+            if (!m) return
+            const key = `${m.providerID}/${m.modelID}`
+            setModelStore("variant", key, value)
+            save()
+          },
+          cycle() {
+            const variants = this.list()
+            if (variants.length === 0) return
+            const current = this.current()
+            if (!current) {
+              this.set(variants[0])
+              return
+            }
+            const index = variants.indexOf(current)
+            if (index === -1 || index === variants.length - 1) {
+              this.set(undefined)
+              return
+            }
+            this.set(variants[index + 1])
+          },
+        },
       }
     })
 
@@ -329,6 +364,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
       },
     }
 
+    // Automatically update model when agent changes
+    createEffect(() => {
+      const value = agent.current()
+      if (value.model) {
+        if (isModelValid(value.model))
+          model.set({
+            providerID: value.model.providerID,
+            modelID: value.model.modelID,
+          })
+        else
+          toast.show({
+            variant: "warning",
+            message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`,
+            duration: 3000,
+          })
+      }
+    })
+
     const result = {
       model,
       agent,

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

@@ -1716,8 +1716,8 @@ ToolRegistry.register<typeof TaskTool>({
           </box>
         </Show>
         <text fg={theme.text}>
-          {keybind.print("session_child_cycle")}, {keybind.print("session_child_cycle_reverse")}
-          <span style={{ fg: theme.textMuted }}> to navigate between subagent sessions</span>
+          {keybind.print("session_child_cycle")}
+          <span style={{ fg: theme.textMuted }}> view subagents</span>
         </text>
       </>
     )

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

@@ -115,11 +115,12 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           setStore("selected", currentIndex)
         }
       }
-      scroll.scrollTo(0)
+      scroll?.scrollTo(0)
     }),
   )
 
   function move(direction: number) {
+    if (flat().length === 0) return
     let next = store.selected + direction
     if (next < 0) next = flat().length - 1
     if (next >= flat().length) next = 0
@@ -129,6 +130,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
   function moveTo(next: number) {
     setStore("selected", next)
     props.onMove?.(selected()!)
+    if (!scroll) return
     const target = scroll.getChildren().find((child) => {
       return child.id === JSON.stringify(selected()?.value)
     })
@@ -172,7 +174,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
     }
   })
 
-  let scroll: ScrollBoxRenderable
+  let scroll: ScrollBoxRenderable | undefined
   const ref: DialogSelectRef<T> = {
     get filter() {
       return store.filter
@@ -213,61 +215,70 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
           />
         </box>
       </box>
-      <scrollbox
-        paddingLeft={1}
-        paddingRight={1}
-        scrollbarOptions={{ visible: false }}
-        ref={(r: ScrollBoxRenderable) => (scroll = r)}
-        maxHeight={height()}
+      <Show
+        when={grouped().length > 0}
+        fallback={
+          <box paddingLeft={4} paddingRight={4} paddingTop={1}>
+            <text fg={theme.textMuted}>No results found</text>
+          </box>
+        }
       >
-        <For each={grouped()}>
-          {([category, options], index) => (
-            <>
-              <Show when={category}>
-                <box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
-                  <text fg={theme.accent} attributes={TextAttributes.BOLD}>
-                    {category}
-                  </text>
-                </box>
-              </Show>
-              <For each={options}>
-                {(option) => {
-                  const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
-                  const current = createMemo(() => isDeepEqual(option.value, props.current))
-                  return (
-                    <box
-                      id={JSON.stringify(option.value)}
-                      flexDirection="row"
-                      onMouseUp={() => {
-                        option.onSelect?.(dialog)
-                        props.onSelect?.(option)
-                      }}
-                      onMouseOver={() => {
-                        const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
-                        if (index === -1) return
-                        moveTo(index)
-                      }}
-                      backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
-                      paddingLeft={current() || option.gutter ? 1 : 3}
-                      paddingRight={3}
-                      gap={1}
-                    >
-                      <Option
-                        title={option.title}
-                        footer={option.footer}
-                        description={option.description !== category ? option.description : undefined}
-                        active={active()}
-                        current={current()}
-                        gutter={option.gutter}
-                      />
-                    </box>
-                  )
-                }}
-              </For>
-            </>
-          )}
-        </For>
-      </scrollbox>
+        <scrollbox
+          paddingLeft={1}
+          paddingRight={1}
+          scrollbarOptions={{ visible: false }}
+          ref={(r: ScrollBoxRenderable) => (scroll = r)}
+          maxHeight={height()}
+        >
+          <For each={grouped()}>
+            {([category, options], index) => (
+              <>
+                <Show when={category}>
+                  <box paddingTop={index() > 0 ? 1 : 0} paddingLeft={3}>
+                    <text fg={theme.accent} attributes={TextAttributes.BOLD}>
+                      {category}
+                    </text>
+                  </box>
+                </Show>
+                <For each={options}>
+                  {(option) => {
+                    const active = createMemo(() => isDeepEqual(option.value, selected()?.value))
+                    const current = createMemo(() => isDeepEqual(option.value, props.current))
+                    return (
+                      <box
+                        id={JSON.stringify(option.value)}
+                        flexDirection="row"
+                        onMouseUp={() => {
+                          option.onSelect?.(dialog)
+                          props.onSelect?.(option)
+                        }}
+                        onMouseOver={() => {
+                          const index = filtered().findIndex((x) => isDeepEqual(x.value, option.value))
+                          if (index === -1) return
+                          moveTo(index)
+                        }}
+                        backgroundColor={active() ? (option.bg ?? theme.primary) : RGBA.fromInts(0, 0, 0, 0)}
+                        paddingLeft={current() || option.gutter ? 1 : 3}
+                        paddingRight={3}
+                        gap={1}
+                      >
+                        <Option
+                          title={option.title}
+                          footer={option.footer}
+                          description={option.description !== category ? option.description : undefined}
+                          active={active()}
+                          current={current()}
+                          gutter={option.gutter}
+                        />
+                      </box>
+                    )
+                  }}
+                </For>
+              </>
+            )}
+          </For>
+        </scrollbox>
+      </Show>
       <Show when={keybinds().length} fallback={<box flexShrink={0} />}>
         <box paddingRight={2} paddingLeft={4} flexDirection="row" gap={2} flexShrink={0} paddingTop={1}>
           <For each={keybinds()}>

+ 11 - 1
packages/opencode/src/cli/network.ts

@@ -17,6 +17,12 @@ const options = {
     describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
     default: false,
   },
+  cors: {
+    type: "string" as const,
+    array: true,
+    describe: "additional domains to allow for CORS",
+    default: [] as string[],
+  },
 }
 
 export type NetworkOptions = InferredOptionTypes<typeof options>
@@ -30,6 +36,7 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
   const portExplicitlySet = process.argv.includes("--port")
   const hostnameExplicitlySet = process.argv.includes("--hostname")
   const mdnsExplicitlySet = process.argv.includes("--mdns")
+  const corsExplicitlySet = process.argv.includes("--cors")
 
   const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
   const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
@@ -38,6 +45,9 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
     : mdns && !config?.server?.hostname
       ? "0.0.0.0"
       : (config?.server?.hostname ?? args.hostname)
+  const configCors = config?.server?.cors ?? []
+  const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
+  const cors = [...configCors, ...argsCors]
 
-  return { hostname, port, mdns }
+  return { hostname, port, mdns, cors }
 }

+ 57 - 6
packages/opencode/src/command/index.ts

@@ -1,4 +1,3 @@
-import { Bus } from "@/bus"
 import { BusEvent } from "@/bus/bus-event"
 import z from "zod"
 import { Config } from "../config/config"
@@ -6,6 +5,7 @@ import { Instance } from "../project/instance"
 import { Identifier } from "../id/id"
 import PROMPT_INITIALIZE from "./template/initialize.txt"
 import PROMPT_REVIEW from "./template/review.txt"
+import { MCP } from "../mcp"
 
 export namespace Command {
   export const Event = {
@@ -26,13 +26,29 @@ export namespace Command {
       description: z.string().optional(),
       agent: z.string().optional(),
       model: z.string().optional(),
-      template: z.string(),
+      mcp: z.boolean().optional(),
+      // workaround for zod not supporting async functions natively so we use getters
+      // https://zod.dev/v4/changelog?id=zfunction
+      template: z.promise(z.string()).or(z.string()),
       subtask: z.boolean().optional(),
+      hints: z.array(z.string()),
     })
     .meta({
       ref: "Command",
     })
-  export type Info = z.infer<typeof Info>
+
+  // for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
+  export type Info = Omit<z.infer<typeof Info>, "template"> & { template: Promise<string> | string }
+
+  export function hints(template: string): string[] {
+    const result: string[] = []
+    const numbered = template.match(/\$\d+/g)
+    if (numbered) {
+      for (const match of [...new Set(numbered)].sort()) result.push(match)
+    }
+    if (template.includes("$ARGUMENTS")) result.push("$ARGUMENTS")
+    return result
+  }
 
   export const Default = {
     INIT: "init",
@@ -46,13 +62,19 @@ export namespace Command {
       [Default.INIT]: {
         name: Default.INIT,
         description: "create/update AGENTS.md",
-        template: PROMPT_INITIALIZE.replace("${path}", Instance.worktree),
+        get template() {
+          return PROMPT_INITIALIZE.replace("${path}", Instance.worktree)
+        },
+        hints: hints(PROMPT_INITIALIZE),
       },
       [Default.REVIEW]: {
         name: Default.REVIEW,
         description: "review changes [commit|branch|pr], defaults to uncommitted",
-        template: PROMPT_REVIEW.replace("${path}", Instance.worktree),
+        get template() {
+          return PROMPT_REVIEW.replace("${path}", Instance.worktree)
+        },
         subtask: true,
+        hints: hints(PROMPT_REVIEW),
       },
     }
 
@@ -62,8 +84,37 @@ export namespace Command {
         agent: command.agent,
         model: command.model,
         description: command.description,
-        template: command.template,
+        get template() {
+          return command.template
+        },
         subtask: command.subtask,
+        hints: hints(command.template),
+      }
+    }
+    for (const [name, prompt] of Object.entries(await MCP.prompts())) {
+      result[name] = {
+        name,
+        mcp: true,
+        description: prompt.description,
+        get template() {
+          // since a getter can't be async we need to manually return a promise here
+          return new Promise<string>(async (resolve, reject) => {
+            const template = await MCP.getPrompt(
+              prompt.client,
+              prompt.name,
+              prompt.arguments
+                ? // substitute each argument with $1, $2, etc.
+                  Object.fromEntries(prompt.arguments?.map((argument, i) => [argument.name, `$${i + 1}`]))
+                : {},
+            ).catch(reject)
+            resolve(
+              template?.messages
+                .map((message) => (message.content.type === "text" ? message.content.text : ""))
+                .join("\n") || "",
+            )
+          })
+        },
+        hints: prompt.arguments?.map((_, i) => `$${i + 1}`) ?? [],
       }
     }
 

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

@@ -92,8 +92,6 @@ export namespace Config {
 
     const promises: Promise<void>[] = []
     for (const dir of unique(directories)) {
-      await assertValid(dir)
-
       if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
         for (const file of ["opencode.jsonc", "opencode.json"]) {
           log.debug(`loading config from ${path.join(dir, file)}`)
@@ -155,23 +153,6 @@ export namespace Config {
     }
   })
 
-  const INVALID_DIRS = new Bun.Glob(`{${["agents", "commands", "plugins", "tools", "skills"].join(",")}}/`)
-  async function assertValid(dir: string) {
-    const invalid = await Array.fromAsync(
-      INVALID_DIRS.scan({
-        onlyFiles: false,
-        cwd: dir,
-      }),
-    )
-    for (const item of invalid) {
-      throw new ConfigDirectoryTypoError({
-        path: dir,
-        dir: item,
-        suggestion: item.substring(0, item.length - 1),
-      })
-    }
-  }
-
   async function installDependencies(dir: string) {
     if (Installation.isLocal()) return
 
@@ -197,7 +178,7 @@ export namespace Config {
     await BunProc.run(["install"], { cwd: dir }).catch(() => {})
   }
 
-  const COMMAND_GLOB = new Bun.Glob("command/**/*.md")
+  const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
   async function loadCommand(dir: string) {
     const result: Record<string, Command> = {}
     for await (const item of COMMAND_GLOB.scan({
@@ -235,7 +216,7 @@ export namespace Config {
     return result
   }
 
-  const AGENT_GLOB = new Bun.Glob("agent/**/*.md")
+  const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md")
   async function loadAgent(dir: string) {
     const result: Record<string, Agent> = {}
 
@@ -278,7 +259,7 @@ export namespace Config {
     return result
   }
 
-  const MODE_GLOB = new Bun.Glob("mode/*.md")
+  const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md")
   async function loadMode(dir: string) {
     const result: Record<string, Agent> = {}
     for await (const item of MODE_GLOB.scan({
@@ -307,7 +288,7 @@ export namespace Config {
     return result
   }
 
-  const PLUGIN_GLOB = new Bun.Glob("plugin/*.{ts,js}")
+  const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}")
   async function loadPlugin(dir: string) {
     const plugins: string[] = []
 
@@ -490,6 +471,7 @@ export namespace Config {
       agent_list: z.string().optional().default("<leader>a").describe("List agents"),
       agent_cycle: z.string().optional().default("tab").describe("Next agent"),
       agent_cycle_reverse: z.string().optional().default("shift+tab").describe("Previous agent"),
+      variant_cycle: z.string().optional().default("ctrl+t").describe("Cycle model variants"),
       input_clear: z.string().optional().default("ctrl+c").describe("Clear input field"),
       input_paste: z.string().optional().default("ctrl+v").describe("Paste from clipboard"),
       input_submit: z.string().optional().default("return").describe("Submit input"),
@@ -604,6 +586,7 @@ export namespace Config {
       port: z.number().int().positive().optional().describe("Port to listen on"),
       hostname: z.string().optional().describe("Hostname to listen on"),
       mdns: z.boolean().optional().describe("Enable mDNS service discovery"),
+      cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
     })
     .strict()
     .meta({
@@ -619,7 +602,24 @@ export namespace Config {
     .extend({
       whitelist: z.array(z.string()).optional(),
       blacklist: z.array(z.string()).optional(),
-      models: z.record(z.string(), ModelsDev.Model.partial()).optional(),
+      models: z
+        .record(
+          z.string(),
+          ModelsDev.Model.partial().extend({
+            variants: z
+              .record(
+                z.string(),
+                z
+                  .object({
+                    disabled: z.boolean().optional().describe("Disable this variant for the model"),
+                  })
+                  .catchall(z.any()),
+              )
+              .optional()
+              .describe("Variant-specific configuration"),
+          }),
+        )
+        .optional(),
       options: z
         .object({
           apiKey: z.string().optional(),
@@ -845,6 +845,12 @@ export namespace Config {
             .optional()
             .describe("Tools that should only be available to primary agents."),
           continue_loop_on_deny: z.boolean().optional().describe("Continue the agent loop when a tool call is denied"),
+          mcp_timeout: z
+            .number()
+            .int()
+            .positive()
+            .optional()
+            .describe("Timeout in milliseconds for model context protocol (MCP) requests"),
         })
         .optional(),
     })

+ 76 - 8
packages/opencode/src/file/index.ts

@@ -11,6 +11,7 @@ import { Filesystem } from "../util/filesystem"
 import { Instance } from "../project/instance"
 import { Ripgrep } from "./ripgrep"
 import fuzzysort from "fuzzysort"
+import { Global } from "../global"
 
 export namespace File {
   const log = Log.create({ service: "file" })
@@ -122,10 +123,49 @@ export namespace File {
     type Entry = { files: string[]; dirs: string[] }
     let cache: Entry = { files: [], dirs: [] }
     let fetching = false
+
+    const isGlobalHome = Instance.directory === Global.Path.home && Instance.project.id === "global"
+
     const fn = async (result: Entry) => {
       // Disable scanning if in root of file system
       if (Instance.directory === path.parse(Instance.directory).root) return
       fetching = true
+
+      if (isGlobalHome) {
+        const dirs = new Set<string>()
+        const ignore = new Set<string>()
+
+        if (process.platform === "darwin") ignore.add("Library")
+        if (process.platform === "win32") ignore.add("AppData")
+
+        const ignoreNested = new Set(["node_modules", "dist", "build", "target", "vendor"])
+        const shouldIgnore = (name: string) => name.startsWith(".") || ignore.has(name)
+        const shouldIgnoreNested = (name: string) => name.startsWith(".") || ignoreNested.has(name)
+
+        const top = await fs.promises
+          .readdir(Instance.directory, { withFileTypes: true })
+          .catch(() => [] as fs.Dirent[])
+
+        for (const entry of top) {
+          if (!entry.isDirectory()) continue
+          if (shouldIgnore(entry.name)) continue
+          dirs.add(entry.name + "/")
+
+          const base = path.join(Instance.directory, entry.name)
+          const children = await fs.promises.readdir(base, { withFileTypes: true }).catch(() => [] as fs.Dirent[])
+          for (const child of children) {
+            if (!child.isDirectory()) continue
+            if (shouldIgnoreNested(child.name)) continue
+            dirs.add(entry.name + "/" + child.name + "/")
+          }
+        }
+
+        result.dirs = Array.from(dirs).toSorted()
+        cache = result
+        fetching = false
+        return
+      }
+
       const set = new Set<string>()
       for await (const file of Ripgrep.files({ cwd: Instance.directory })) {
         result.files.push(file)
@@ -329,15 +369,43 @@ export namespace File {
     })
   }
 
-  export async function search(input: { query: string; limit?: number; dirs?: boolean }) {
-    log.info("search", { query: input.query })
+  export async function search(input: { query: string; limit?: number; dirs?: boolean; type?: "file" | "directory" }) {
+    const query = input.query.trim()
     const limit = input.limit ?? 100
+    const kind = input.type ?? (input.dirs === false ? "file" : "all")
+    log.info("search", { query, kind })
+
     const result = await state().then((x) => x.files())
-    if (!input.query)
-      return input.dirs !== false ? result.dirs.toSorted().slice(0, limit) : result.files.slice(0, limit)
-    const items = input.dirs !== false ? [...result.files, ...result.dirs] : result.files
-    const sorted = fuzzysort.go(input.query, items, { limit: limit }).map((r) => r.target)
-    log.info("search", { query: input.query, results: sorted.length })
-    return sorted
+
+    const hidden = (item: string) => {
+      const normalized = item.replaceAll("\\", "/").replace(/\/+$/, "")
+      return normalized.split("/").some((p) => p.startsWith(".") && p.length > 1)
+    }
+    const preferHidden = query.startsWith(".") || query.includes("/.")
+    const sortHiddenLast = (items: string[]) => {
+      if (preferHidden) return items
+      const visible: string[] = []
+      const hiddenItems: string[] = []
+      for (const item of items) {
+        const isHidden = hidden(item)
+        if (isHidden) hiddenItems.push(item)
+        if (!isHidden) visible.push(item)
+      }
+      return [...visible, ...hiddenItems]
+    }
+    if (!query) {
+      if (kind === "file") return result.files.slice(0, limit)
+      return sortHiddenLast(result.dirs.toSorted()).slice(0, limit)
+    }
+
+    const items =
+      kind === "file" ? result.files : kind === "directory" ? result.dirs : [...result.files, ...result.dirs]
+
+    const searchLimit = kind === "directory" && !preferHidden ? limit * 20 : limit
+    const sorted = fuzzysort.go(query, items, { limit: searchLimit }).map((r) => r.target)
+    const output = kind === "directory" ? sortHiddenLast(sorted).slice(0, limit) : sorted
+
+    log.info("search", { query, kind, results: output.length })
+    return output
   }
 }

+ 11 - 2
packages/opencode/src/file/ripgrep.ts

@@ -205,8 +205,17 @@ export namespace Ripgrep {
     return filepath
   }
 
-  export async function* files(input: { cwd: string; glob?: string[] }) {
-    const args = [await filepath(), "--files", "--follow", "--hidden", "--glob=!.git/*"]
+  export async function* files(input: {
+    cwd: string
+    glob?: string[]
+    hidden?: boolean
+    follow?: boolean
+    maxDepth?: number
+  }) {
+    const args = [await filepath(), "--files", "--glob=!.git/*"]
+    if (input.follow !== false) args.push("--follow")
+    if (input.hidden !== false) args.push("--hidden")
+    if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
     if (input.glob) {
       for (const g of input.glob) {
         args.push(`--glob=${g}`)

+ 26 - 12
packages/opencode/src/file/watcher.ts

@@ -9,9 +9,13 @@ import path from "path"
 // @ts-ignore
 import { createWrapper } from "@parcel/watcher/wrapper"
 import { lazy } from "@/util/lazy"
+import { withTimeout } from "@/util/timeout"
 import type ParcelWatcher from "@parcel/watcher"
 import { $ } from "bun"
 import { Flag } from "@/flag/flag"
+import { readdir } from "fs/promises"
+
+const SUBSCRIBE_TIMEOUT_MS = 10_000
 
 declare const OPENCODE_LIBC: string | undefined
 
@@ -63,12 +67,16 @@ export namespace FileWatcher {
       const cfgIgnores = cfg.watcher?.ignore ?? []
 
       if (Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
-        subs.push(
-          await watcher().subscribe(Instance.directory, subscribe, {
-            ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
-            backend,
-          }),
-        )
+        const pending = watcher().subscribe(Instance.directory, subscribe, {
+          ignore: [...FileIgnore.PATTERNS, ...cfgIgnores],
+          backend,
+        })
+        const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
+          log.error("failed to subscribe to Instance.directory", { error: err })
+          pending.then((s) => s.unsubscribe()).catch(() => {})
+          return undefined
+        })
+        if (sub) subs.push(sub)
       }
 
       const vcsDir = await $`git rev-parse --git-dir`
@@ -78,12 +86,18 @@ export namespace FileWatcher {
         .text()
         .then((x) => path.resolve(Instance.worktree, x.trim()))
       if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
-        subs.push(
-          await watcher().subscribe(vcsDir, subscribe, {
-            ignore: ["hooks", "info", "logs", "objects", "refs", "worktrees", "modules", "lfs"],
-            backend,
-          }),
-        )
+        const gitDirContents = await readdir(vcsDir).catch(() => [])
+        const ignoreList = gitDirContents.filter((entry) => entry !== "HEAD")
+        const pending = watcher().subscribe(vcsDir, subscribe, {
+          ignore: ignoreList,
+          backend,
+        })
+        const sub = await withTimeout(pending, SUBSCRIBE_TIMEOUT_MS).catch((err) => {
+          log.error("failed to subscribe to vcsDir", { error: err })
+          pending.then((s) => s.unsubscribe()).catch(() => {})
+          return undefined
+        })
+        if (sub) subs.push(sub)
       }
 
       return { subs }

+ 18 - 0
packages/opencode/src/format/formatter.ts

@@ -322,3 +322,21 @@ export const shfmt: Info = {
     return Bun.which("shfmt") !== null
   },
 }
+
+export const nixfmt: Info = {
+  name: "nixfmt",
+  command: ["nixfmt", "$FILE"],
+  extensions: [".nix"],
+  async enabled() {
+    return Bun.which("nixfmt") !== null
+  },
+}
+
+export const rustfmt: Info = {
+  name: "rustfmt",
+  command: ["rustfmt", "$FILE"],
+  extensions: [".rs"],
+  async enabled() {
+    return Bun.which("rustfmt") !== null
+  },
+}

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

@@ -12,14 +12,17 @@ const state = path.join(xdgState!, app)
 
 export namespace Global {
   export const Path = {
-    home: os.homedir(),
+    // Allow override via OPENCODE_TEST_HOME for test isolation
+    get home() {
+      return process.env.OPENCODE_TEST_HOME || os.homedir()
+    },
     data,
     bin: path.join(data, "bin"),
     log: path.join(data, "log"),
     cache,
     config,
     state,
-  } as const
+  }
 }
 
 await Promise.all([

+ 23 - 0
packages/opencode/src/lsp/client.ts

@@ -98,6 +98,9 @@ export namespace LSPClient {
           },
           workspace: {
             configuration: true,
+            didChangeWatchedFiles: {
+              dynamicRegistration: true,
+            },
           },
           textDocument: {
             synchronization: {
@@ -151,6 +154,16 @@ export namespace LSPClient {
 
           const version = files[input.path]
           if (version !== undefined) {
+            log.info("workspace/didChangeWatchedFiles", input)
+            await connection.sendNotification("workspace/didChangeWatchedFiles", {
+              changes: [
+                {
+                  uri: pathToFileURL(input.path).href,
+                  type: 2, // Changed
+                },
+              ],
+            })
+
             const next = version + 1
             files[input.path] = next
             log.info("textDocument/didChange", {
@@ -167,6 +180,16 @@ export namespace LSPClient {
             return
           }
 
+          log.info("workspace/didChangeWatchedFiles", input)
+          await connection.sendNotification("workspace/didChangeWatchedFiles", {
+            changes: [
+              {
+                uri: pathToFileURL(input.path).href,
+                type: 1, // Created
+              },
+            ],
+          })
+
           log.info("textDocument/didOpen", input)
           diagnostics.delete(input.path)
           await connection.sendNotification("textDocument/didOpen", {

+ 18 - 0
packages/opencode/src/lsp/server.ts

@@ -1437,6 +1437,24 @@ export namespace LSPServer {
     },
   }
 
+  export const Prisma: Info = {
+    id: "prisma",
+    extensions: [".prisma"],
+    root: NearestRoot(["schema.prisma", "prisma/schema.prisma", "prisma"], ["package.json"]),
+    async spawn(root) {
+      const prisma = Bun.which("prisma")
+      if (!prisma) {
+        log.info("prisma not found, please install prisma")
+        return
+      }
+      return {
+        process: spawn(prisma, ["language-server"], {
+          cwd: root,
+        }),
+      }
+    },
+  }
+
   export const Dart: Info = {
     id: "dart",
     extensions: [".dart"],

+ 94 - 7
packages/opencode/src/mcp/index.ts

@@ -4,7 +4,11 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
 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, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js"
+import {
+  CallToolResultSchema,
+  type Tool as MCPToolDef,
+  ToolListChangedNotificationSchema,
+} from "@modelcontextprotocol/sdk/types.js"
 import { Config } from "../config/config"
 import { Log } from "../util/log"
 import { NamedError } from "@opencode-ai/util/error"
@@ -93,7 +97,7 @@ export namespace MCP {
   }
 
   // Convert MCP tool definition to AI SDK Tool type
-  function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Tool {
+  async function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient): Promise<Tool> {
     const inputSchema = mcpTool.inputSchema
 
     // Spread first, then override type to ensure it's always "object"
@@ -103,15 +107,23 @@ export namespace MCP {
       properties: (inputSchema.properties ?? {}) as JSONSchema7["properties"],
       additionalProperties: false,
     }
+    const config = await Config.get()
 
     return dynamicTool({
       description: mcpTool.description ?? "",
       inputSchema: jsonSchema(schema),
       execute: async (args: unknown) => {
-        return client.callTool({
-          name: mcpTool.name,
-          arguments: args as Record<string, unknown>,
-        })
+        return client.callTool(
+          {
+            name: mcpTool.name,
+            arguments: args as Record<string, unknown>,
+          },
+          CallToolResultSchema,
+          {
+            resetTimeoutOnProgress: true,
+            timeout: config.experimental?.mcp_timeout,
+          },
+        )
       },
     })
   }
@@ -120,6 +132,9 @@ export namespace MCP {
   type TransportWithAuth = StreamableHTTPClientTransport | SSEClientTransport
   const pendingOAuthTransports = new Map<string, TransportWithAuth>()
 
+  // Prompt cache types
+  type PromptInfo = Awaited<ReturnType<MCPClient["listPrompts"]>>["prompts"][number]
+
   const state = Instance.state(
     async () => {
       const cfg = await Config.get()
@@ -164,6 +179,29 @@ export namespace MCP {
     },
   )
 
+  // Helper function to fetch prompts for a specific client
+  async function fetchPromptsForClient(clientName: string, client: Client) {
+    const prompts = await client.listPrompts().catch((e) => {
+      log.error("failed to get prompts", { clientName, error: e.message })
+      return undefined
+    })
+
+    if (!prompts) {
+      return
+    }
+
+    const commands: Record<string, PromptInfo & { client: string }> = {}
+
+    for (const prompt of prompts.prompts) {
+      const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
+      const sanitizedPromptName = prompt.name.replace(/[^a-zA-Z0-9_-]/g, "_")
+      const key = sanitizedClientName + ":" + sanitizedPromptName
+
+      commands[key] = { ...prompt, client: clientName }
+    }
+    return commands
+  }
+
   export async function add(name: string, mcp: Config.Mcp) {
     const s = await state()
     const result = await create(name, mcp)
@@ -474,12 +512,61 @@ export namespace MCP {
       for (const mcpTool of toolsResult.tools) {
         const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
         const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
-        result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client)
+        result[sanitizedClientName + "_" + sanitizedToolName] = await convertMcpTool(mcpTool, client)
       }
     }
     return result
   }
 
+  export async function prompts() {
+    const s = await state()
+    const clientsSnapshot = await clients()
+
+    const prompts = Object.fromEntries<PromptInfo & { client: string }>(
+      (
+        await Promise.all(
+          Object.entries(clientsSnapshot).map(async ([clientName, client]) => {
+            if (s.status[clientName]?.status !== "connected") {
+              return []
+            }
+
+            return Object.entries((await fetchPromptsForClient(clientName, client)) ?? {})
+          }),
+        )
+      ).flat(),
+    )
+
+    return prompts
+  }
+
+  export async function getPrompt(clientName: string, name: string, args?: Record<string, string>) {
+    const clientsSnapshot = await clients()
+    const client = clientsSnapshot[clientName]
+
+    if (!client) {
+      log.warn("client not found for prompt", {
+        clientName,
+      })
+      return undefined
+    }
+
+    const result = await client
+      .getPrompt({
+        name: name,
+        arguments: args,
+      })
+      .catch((e) => {
+        log.error("failed to get prompt from MCP server", {
+          clientName,
+          promptName: name,
+          error: e.message,
+        })
+        return undefined
+      })
+
+    return result
+  }
+
   /**
    * Start OAuth authentication flow for an MCP server.
    * Returns the authorization URL that should be opened in a browser.

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

@@ -24,6 +24,7 @@ export namespace Plugin {
       project: Instance.project,
       worktree: Instance.worktree,
       directory: Instance.directory,
+      serverUrl: Server.url(),
       $: Bun.$,
     }
     const plugins = [...(config.plugin ?? [])]

+ 1 - 0
packages/opencode/src/provider/models.ts

@@ -60,6 +60,7 @@ export namespace ModelsDev {
     options: z.record(z.string(), z.any()),
     headers: z.record(z.string(), z.string()).optional(),
     provider: z.object({ npm: z.string() }).optional(),
+    variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
   })
   export type Model = z.infer<typeof Model>
 

+ 34 - 4
packages/opencode/src/provider/provider.ts

@@ -1,7 +1,7 @@
 import z from "zod"
 import fuzzysort from "fuzzysort"
 import { Config } from "../config/config"
-import { mapValues, mergeDeep, sortBy } from "remeda"
+import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
 import { NoSuchModelError, type Provider as SDK } from "ai"
 import { Log } from "../util/log"
 import { BunProc } from "../bun"
@@ -34,6 +34,8 @@ import { createCohere } from "@ai-sdk/cohere"
 import { createGateway } from "@ai-sdk/gateway"
 import { createTogetherAI } from "@ai-sdk/togetherai"
 import { createPerplexity } from "@ai-sdk/perplexity"
+import { createVercel } from "@ai-sdk/vercel"
+import { ProviderTransform } from "./transform"
 
 export namespace Provider {
   const log = Log.create({ service: "provider" })
@@ -57,6 +59,7 @@ export namespace Provider {
     "@ai-sdk/gateway": createGateway,
     "@ai-sdk/togetherai": createTogetherAI,
     "@ai-sdk/perplexity": createPerplexity,
+    "@ai-sdk/vercel": createVercel,
     // @ts-ignore (TODO: kill this code so we dont have to maintain it)
     "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
   }
@@ -467,6 +470,7 @@ export namespace Provider {
       options: z.record(z.string(), z.any()),
       headers: z.record(z.string(), z.string()),
       release_date: z.string(),
+      variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
     })
     .meta({
       ref: "Model",
@@ -489,7 +493,7 @@ export namespace Provider {
   export type Info = z.infer<typeof Info>
 
   function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model): Model {
-    return {
+    const m: Model = {
       id: model.id,
       providerID: provider.id,
       name: model.name,
@@ -497,7 +501,7 @@ export namespace Provider {
       api: {
         id: model.id,
         url: provider.api!,
-        npm: model.provider?.npm ?? provider.npm ?? provider.id,
+        npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible",
       },
       status: model.status ?? "active",
       headers: model.headers ?? {},
@@ -546,7 +550,12 @@ export namespace Provider {
         interleaved: model.interleaved ?? false,
       },
       release_date: model.release_date,
+      variants: {},
     }
+
+    m.variants = mapValues(ProviderTransform.variants(m), (v) => v)
+
+    return m
   }
 
   export function fromModelsDevProvider(provider: ModelsDev.Provider): Info {
@@ -637,7 +646,11 @@ export namespace Provider {
           api: {
             id: model.id ?? existingModel?.api.id ?? modelID,
             npm:
-              model.provider?.npm ?? provider.npm ?? existingModel?.api.npm ?? modelsDev[providerID]?.npm ?? providerID,
+              model.provider?.npm ??
+              provider.npm ??
+              existingModel?.api.npm ??
+              modelsDev[providerID]?.npm ??
+              "@ai-sdk/openai-compatible",
             url: provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api,
           },
           status: model.status ?? existingModel?.status ?? "active",
@@ -680,7 +693,13 @@ export namespace Provider {
           headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
           family: model.family ?? existingModel?.family ?? "",
           release_date: model.release_date ?? existingModel?.release_date ?? "",
+          variants: {},
         }
+        const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
+        parsedModel.variants = mapValues(
+          pickBy(merged, (v) => !v.disabled),
+          (v) => omit(v, ["disabled"]),
+        )
         parsed.models[modelID] = parsedModel
       }
       database[providerID] = parsed
@@ -805,6 +824,16 @@ export namespace Provider {
           (configProvider?.whitelist && !configProvider.whitelist.includes(modelID))
         )
           delete provider.models[modelID]
+
+        // Filter out disabled variants from config
+        const configVariants = configProvider?.models?.[modelID]?.variants
+        if (configVariants && model.variants) {
+          const merged = mergeDeep(model.variants, configVariants)
+          model.variants = mapValues(
+            pickBy(merged, (v) => !v.disabled),
+            (v) => omit(v, ["disabled"]),
+          )
+        }
       }
 
       if (Object.keys(provider.models).length === 0) {
@@ -993,6 +1022,7 @@ export namespace Provider {
         "claude-haiku-4.5",
         "3-5-haiku",
         "3.5-haiku",
+        "gemini-3-flash",
         "gemini-2.5-flash",
         "gpt-5-nano",
       ]

+ 177 - 12
packages/opencode/src/provider/transform.ts

@@ -3,6 +3,7 @@ import { unique } from "remeda"
 import type { JSONSchema } from "zod/v4/core"
 import type { Provider } from "./provider"
 import type { ModelsDev } from "./models"
+import { iife } from "@/util/iife"
 
 type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
 
@@ -124,7 +125,7 @@ export namespace ProviderTransform {
         cacheControl: { type: "ephemeral" },
       },
       openrouter: {
-        cache_control: { type: "ephemeral" },
+        cacheControl: { type: "ephemeral" },
       },
       bedrock: {
         cachePoint: { type: "ephemeral" },
@@ -229,7 +230,6 @@ export namespace ProviderTransform {
     const id = model.id.toLowerCase()
     if (id.includes("qwen")) return 1
     if (id.includes("minimax-m2")) {
-      if (id.includes("m2.1")) return 0.9
       return 0.95
     }
     if (id.includes("gemini")) return 0.95
@@ -238,11 +238,174 @@ export namespace ProviderTransform {
 
   export function topK(model: Provider.Model) {
     const id = model.id.toLowerCase()
-    if (id.includes("minimax-m2")) return 20
+    if (id.includes("minimax-m2")) {
+      if (id.includes("m2.1")) return 40
+      return 20
+    }
     if (id.includes("gemini")) return 64
     return undefined
   }
 
+  const WIDELY_SUPPORTED_EFFORTS = ["low", "medium", "high"]
+  const OPENAI_EFFORTS = ["none", "minimal", ...WIDELY_SUPPORTED_EFFORTS, "xhigh"]
+
+  export function variants(model: Provider.Model): Record<string, Record<string, any>> {
+    if (!model.capabilities.reasoning) return {}
+
+    const id = model.id.toLowerCase()
+    if (id.includes("deepseek") || id.includes("minimax") || id.includes("glm") || id.includes("mistral")) return {}
+
+    switch (model.api.npm) {
+      case "@openrouter/ai-sdk-provider":
+        if (!model.id.includes("gpt") && !model.id.includes("gemini-3") && !model.id.includes("grok-4")) return {}
+        return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoning: { effort } }]))
+
+      // TODO: YOU CANNOT SET max_tokens if this is set!!!
+      case "@ai-sdk/gateway":
+        return Object.fromEntries(OPENAI_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
+
+      case "@ai-sdk/cerebras":
+      // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cerebras
+      case "@ai-sdk/togetherai":
+      // https://v5.ai-sdk.dev/providers/ai-sdk-providers/togetherai
+      case "@ai-sdk/xai":
+      // https://v5.ai-sdk.dev/providers/ai-sdk-providers/xai
+      case "@ai-sdk/deepinfra":
+      // https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra
+      case "@ai-sdk/openai-compatible":
+        return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
+
+      case "@ai-sdk/azure":
+        // https://v5.ai-sdk.dev/providers/ai-sdk-providers/azure
+        if (id === "o1-mini") return {}
+        const azureEfforts = ["low", "medium", "high"]
+        if (id.includes("gpt-5")) {
+          azureEfforts.unshift("minimal")
+        }
+        return Object.fromEntries(
+          azureEfforts.map((effort) => [
+            effort,
+            {
+              reasoningEffort: effort,
+              reasoningSummary: "auto",
+              include: ["reasoning.encrypted_content"],
+            },
+          ]),
+        )
+      case "@ai-sdk/openai":
+        // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
+        if (id === "gpt-5-pro") return {}
+        const openaiEfforts = iife(() => {
+          if (model.id.includes("codex")) return WIDELY_SUPPORTED_EFFORTS
+          const arr = ["minimal", ...WIDELY_SUPPORTED_EFFORTS]
+          if (model.release_date >= "2025-11-13") {
+            arr.unshift("none")
+          }
+          if (model.release_date >= "2025-12-04") {
+            arr.push("xhigh")
+          }
+          return arr
+        })
+        return Object.fromEntries(
+          openaiEfforts.map((effort) => [
+            effort,
+            {
+              reasoningEffort: effort,
+              reasoningSummary: "auto",
+              include: ["reasoning.encrypted_content"],
+            },
+          ]),
+        )
+
+      case "@ai-sdk/anthropic":
+        // https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic
+        return {
+          high: {
+            thinking: {
+              type: "enabled",
+              budgetTokens: 16000,
+            },
+          },
+          max: {
+            thinking: {
+              type: "enabled",
+              budgetTokens: 31999,
+            },
+          },
+        }
+
+      case "@ai-sdk/amazon-bedrock":
+        // https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock
+        return Object.fromEntries(
+          WIDELY_SUPPORTED_EFFORTS.map((effort) => [
+            effort,
+            {
+              reasoningConfig: {
+                type: "enabled",
+                maxReasoningEffort: effort,
+              },
+            },
+          ]),
+        )
+
+      case "@ai-sdk/google-vertex":
+      // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex
+      case "@ai-sdk/google":
+        // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-generative-ai
+        if (id.includes("2.5")) {
+          return {
+            high: {
+              thinkingConfig: {
+                includeThoughts: true,
+                thinkingBudget: 16000,
+              },
+            },
+            max: {
+              thinkingConfig: {
+                includeThoughts: true,
+                thinkingBudget: 24576,
+              },
+            },
+          }
+        }
+        return Object.fromEntries(
+          ["low", "high"].map((effort) => [
+            effort,
+            {
+              includeThoughts: true,
+              thinkingLevel: effort,
+            },
+          ]),
+        )
+
+      case "@ai-sdk/mistral":
+        // https://v5.ai-sdk.dev/providers/ai-sdk-providers/mistral
+        return {}
+
+      case "@ai-sdk/cohere":
+        // https://v5.ai-sdk.dev/providers/ai-sdk-providers/cohere
+        return {}
+
+      case "@ai-sdk/groq":
+        // https://v5.ai-sdk.dev/providers/ai-sdk-providers/groq
+        const groqEffort = ["none", ...WIDELY_SUPPORTED_EFFORTS]
+        return Object.fromEntries(
+          groqEffort.map((effort) => [
+            effort,
+            {
+              includeThoughts: true,
+              thinkingLevel: effort,
+            },
+          ]),
+        )
+
+      case "@ai-sdk/perplexity":
+        // https://v5.ai-sdk.dev/providers/ai-sdk-providers/perplexity
+        return {}
+    }
+    return {}
+  }
+
   export function options(
     model: Provider.Model,
     sessionID: string,
@@ -302,26 +465,27 @@ export namespace ProviderTransform {
   }
 
   export function smallOptions(model: Provider.Model) {
-    const options: Record<string, any> = {}
-
     if (model.providerID === "openai" || model.api.id.includes("gpt-5")) {
       if (model.api.id.includes("5.")) {
-        options["reasoningEffort"] = "low"
-      } else {
-        options["reasoningEffort"] = "minimal"
+        return { reasoningEffort: "low" }
       }
+      return { reasoningEffort: "minimal" }
     }
     if (model.providerID === "google") {
-      options["thinkingConfig"] = {
-        thinkingBudget: 0,
+      return { thinkingConfig: { thinkingBudget: 0 } }
+    }
+    if (model.providerID === "openrouter") {
+      if (model.api.id.includes("google")) {
+        return { reasoning: { enabled: false } }
       }
+      return { reasoningEffort: "minimal" }
     }
-
-    return options
+    return {}
   }
 
   export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
     switch (model.api.npm) {
+      case "@ai-sdk/github-copilot":
       case "@ai-sdk/openai":
       case "@ai-sdk/azure":
         return {
@@ -335,6 +499,7 @@ export namespace ProviderTransform {
         return {
           ["anthropic" as string]: options,
         }
+      case "@ai-sdk/google-vertex":
       case "@ai-sdk/google":
         return {
           ["google" as string]: options,

+ 1 - 20
packages/opencode/src/pty/index.ts

@@ -7,32 +7,12 @@ import { Log } from "../util/log"
 import type { WSContext } from "hono/ws"
 import { Instance } from "../project/instance"
 import { lazy } from "@opencode-ai/util/lazy"
-import {} from "process"
-import { Installation } from "@/installation"
 import { Shell } from "@/shell/shell"
 
 export namespace Pty {
   const log = Log.create({ service: "pty" })
 
   const pty = lazy(async () => {
-    if (!Installation.isLocal()) {
-      const path = require(
-        `bun-pty/rust-pty/target/release/${
-          process.platform === "win32"
-            ? "rust_pty.dll"
-            : process.platform === "linux" && process.arch === "x64"
-              ? "librust_pty.so"
-              : process.platform === "darwin" && process.arch === "x64"
-                ? "librust_pty.dylib"
-                : process.platform === "darwin" && process.arch === "arm64"
-                  ? "librust_pty_arm64.dylib"
-                  : process.platform === "linux" && process.arch === "arm64"
-                    ? "librust_pty_arm64.so"
-                    : ""
-        }`,
-      )
-      process.env.BUN_PTY_LIB = path
-    }
     const { spawn } = await import("bun-pty")
     return spawn
   })
@@ -128,6 +108,7 @@ export namespace Pty {
       cwd,
       env,
     })
+
     const info = {
       id,
       title: input.title || `Terminal ${id.slice(-4)}`,

+ 1 - 1
packages/opencode/src/server/mdns.ts

@@ -1,5 +1,5 @@
 import { Log } from "@/util/log"
-import Bonjour from "bonjour-service"
+import { Bonjour } from "bonjour-service"
 
 const log = Log.create({ service: "mdns" })
 

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

@@ -47,7 +47,6 @@ import { Snapshot } from "@/snapshot"
 import { SessionSummary } from "@/session/summary"
 import { SessionStatus } from "@/session/status"
 import { upgradeWebSocket, websocket } from "hono/bun"
-import type { BunWebSocketData } from "hono/bun"
 import { errors } from "./error"
 import { Pty } from "@/pty"
 import * as State from "@/webgui/state/state"
@@ -160,6 +159,13 @@ function handleWebGuiRoot(c: Context) {
 export namespace Server {
   const log = Log.create({ service: "server" })
 
+  let _url: URL | undefined
+  let _corsWhitelist: string[] = []
+
+  export function url(): URL {
+    return _url ?? new URL("http://localhost:4096")
+  }
+
   export const Event = {
     Connected: BusEvent.define("server.connected", z.object({})),
     Disposed: BusEvent.define("global.disposed", z.object({})),
@@ -201,7 +207,27 @@ export namespace Server {
           timer.stop()
         }
       })
-      .use(cors())
+      .use(
+        cors({
+          origin(input) {
+            if (!input) return
+
+            if (input.startsWith("http://localhost:")) return input
+            if (input.startsWith("http://127.0.0.1:")) return input
+            if (input === "tauri://localhost" || input === "http://tauri.localhost") return input
+
+            // *.opencode.ai (https only, adjust if needed)
+            if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) {
+              return input
+            }
+            if (_corsWhitelist.includes(input)) {
+              return input
+            }
+
+            return
+          },
+        }),
+      )
       .get(
         "/global/health",
         describeRoute({
@@ -1906,7 +1932,7 @@ export namespace Server {
         "/find/file",
         describeRoute({
           summary: "Find files",
-          description: "Search for files by name or pattern in the project directory.",
+          description: "Search for files or directories by name or pattern in the project directory.",
           operationId: "find.files",
           responses: {
             200: {
@@ -1924,15 +1950,20 @@ export namespace Server {
           z.object({
             query: z.string(),
             dirs: z.enum(["true", "false"]).optional(),
+            type: z.enum(["file", "directory"]).optional(),
+            limit: z.coerce.number().int().min(1).max(200).optional(),
           }),
         ),
         async (c) => {
           const query = c.req.valid("query").query
           const dirs = c.req.valid("query").dirs
+          const type = c.req.valid("query").type
+          const limit = c.req.valid("query").limit
           const results = await File.search({
             query,
-            limit: 10,
+            limit: limit ?? 10,
             dirs: dirs !== "false",
+            type,
           })
           return c.json(results)
         },
@@ -2765,7 +2796,9 @@ export namespace Server {
     return result
   }
 
-  export function listen(opts: { port: number; hostname: string; mdns?: boolean }) {
+  export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
+    _corsWhitelist = opts.cors ?? []
+
     const args = {
       hostname: opts.hostname,
       idleTimeout: 0,
@@ -2782,6 +2815,8 @@ export namespace Server {
     const server = opts.port === 0 ? (tryServe(4096) ?? tryServe(0)) : tryServe(opts.port)
     if (!server) throw new Error(`Failed to start server on port ${opts.port}`)
 
+    _url = server.url
+
     const shouldPublishMDNS =
       opts.mdns &&
       server.port &&
@@ -2789,7 +2824,7 @@ export namespace Server {
       opts.hostname !== "localhost" &&
       opts.hostname !== "::1"
     if (shouldPublishMDNS) {
-      MDNS.publish(server.port!)
+      MDNS.publish(server.port!, `opencode-${server.port!}`)
     } else if (opts.mdns) {
       log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
     }

+ 20 - 8
packages/opencode/src/session/llm.ts

@@ -1,6 +1,14 @@
 import { Provider } from "@/provider/provider"
 import { Log } from "@/util/log"
-import { streamText, wrapLanguageModel, type ModelMessage, type StreamTextResult, type Tool, type ToolSet } from "ai"
+import {
+  streamText,
+  wrapLanguageModel,
+  type ModelMessage,
+  type StreamTextResult,
+  type Tool,
+  type ToolSet,
+  extractReasoningMiddleware,
+} from "ai"
 import { clone, mergeDeep, pipe } from "remeda"
 import { ProviderTransform } from "@/provider/transform"
 import { Config } from "@/config/config"
@@ -74,6 +82,15 @@ export namespace LLM {
     }
 
     const provider = await Provider.getProvider(input.model.providerID)
+    const small = input.small ? ProviderTransform.smallOptions(input.model) : {}
+    const variant = input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
+    const options = pipe(
+      ProviderTransform.options(input.model, input.sessionID, provider.options),
+      mergeDeep(small),
+      mergeDeep(input.model.options),
+      mergeDeep(input.agent.options),
+      mergeDeep(variant),
+    )
 
     const params = await Plugin.trigger(
       "chat.params",
@@ -90,13 +107,7 @@ export namespace LLM {
           : undefined,
         topP: input.agent.topP ?? ProviderTransform.topP(input.model),
         topK: ProviderTransform.topK(input.model),
-        options: pipe(
-          {},
-          mergeDeep(ProviderTransform.options(input.model, input.sessionID, provider.options)),
-          input.small ? mergeDeep(ProviderTransform.smallOptions(input.model)) : mergeDeep({}),
-          mergeDeep(input.model.options),
-          mergeDeep(input.agent.options),
-        ),
+        options,
       },
     )
 
@@ -181,6 +192,7 @@ export namespace LLM {
               return args.params
             },
           },
+          extractReasoningMiddleware({ tagName: "think", startWithReasoning: false }),
         ],
       }),
       experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry },

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

@@ -1,8 +1,6 @@
 import { BusEvent } from "@/bus/bus-event"
-import { Bus } from "@/bus"
 import z from "zod"
 import { NamedError } from "@opencode-ai/util/error"
-import { Message } from "./message"
 import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
 import { Identifier } from "../id/id"
 import { LSP } from "../lsp"
@@ -308,6 +306,7 @@ export namespace MessageV2 {
     }),
     system: z.string().optional(),
     tools: z.record(z.string(), z.boolean()).optional(),
+    variant: z.string().optional(),
   }).meta({
     ref: "UserMessage",
   })
@@ -539,7 +538,7 @@ export namespace MessageV2 {
       }
     }
 
-    return convertToModelMessages(result.filter((msg) => msg.parts.length > 0))
+    return convertToModelMessages(result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")))
   }
 
   export const stream = fn(Identifier.schema("session"), async function* (sessionID) {

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

@@ -90,6 +90,7 @@ export namespace SessionPrompt {
     noReply: z.boolean().optional(),
     tools: z.record(z.string(), z.boolean()).optional(),
     system: z.string().optional(),
+    variant: z.string().optional(),
     parts: z.array(
       z.discriminatedUnion("type", [
         MessageV2.TextPart.omit({
@@ -727,6 +728,7 @@ export namespace SessionPrompt {
       agent: agent.name,
       model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
       system: input.system,
+      variant: input.variant,
     }
 
     const parts = await Promise.all(
@@ -1267,6 +1269,7 @@ export namespace SessionPrompt {
     model: z.string().optional(),
     arguments: z.string(),
     command: z.string(),
+    variant: z.string().optional(),
   })
   export type CommandInput = z.infer<typeof CommandInput>
   const bashRegex = /!`([^`]+)`/g
@@ -1287,7 +1290,9 @@ export namespace SessionPrompt {
     const raw = input.arguments.match(argsRegex) ?? []
     const args = raw.map((arg) => arg.replace(quoteTrimRegex, ""))
 
-    const placeholders = command.template.match(placeholderRegex) ?? []
+    const templateCommand = await command.template
+
+    const placeholders = templateCommand.match(placeholderRegex) ?? []
     let last = 0
     for (const item of placeholders) {
       const value = Number(item.slice(1))
@@ -1295,7 +1300,7 @@ export namespace SessionPrompt {
     }
 
     // Let the final placeholder swallow any extra arguments so prompts read naturally
-    const withArgs = command.template.replaceAll(placeholderRegex, (_, index) => {
+    const withArgs = templateCommand.replaceAll(placeholderRegex, (_, index) => {
       const position = Number(index)
       const argIndex = position - 1
       if (argIndex >= args.length) return ""
@@ -1369,6 +1374,7 @@ export namespace SessionPrompt {
       model,
       agent: agentName,
       parts,
+      variant: input.variant,
     })) as MessageV2.WithParts
 
     Bus.publish(Command.Event.Executed, {

+ 2 - 2
packages/opencode/src/session/prompt/codex.txt

@@ -240,7 +240,7 @@ You are producing plain text that will later be styled by the CLI. Follow these
 - Choose descriptive names that fit the content
 - Keep headers short (1–3 words) and in `**Title Case**`. Always start headers with `**` and end with `**`
 - Leave no blank line before the first bullet under a header.
-- Section headers should only be used where they genuinely improve scanability; avoid fragmenting the answer.
+- Section headers should only be used where they genuinely improve scannability; avoid fragmenting the answer.
 
 **Bullets**
 
@@ -289,7 +289,7 @@ When referencing files in your response, make sure to include the relevant start
 - Don’t nest bullets or create deep hierarchies.
 - Don’t output ANSI escape codes directly — the CLI renderer applies them.
 - Don’t cram unrelated keywords into a single bullet; split for clarity.
-- Don’t let keyword lists run long — wrap or reformat for scanability.
+- Don’t let keyword lists run long — wrap or reformat for scannability.
 
 Generally, ensure your final answers adapt their shape and depth to the request. For example, answers to code explanations should have a precise, structured explanation with code references that answer the question directly. For tasks with a simple implementation, lead with the outcome and supplement only with what’s needed for clarity. Larger changes can be presented as a logical walkthrough of your approach, grouping related steps, explaining rationale where it adds value, and highlighting next actions to accelerate the user. Your answers should provide the right level of detail while being easily scannable.
 

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio