Browse Source

Merge branch 'dev' into sqlite2

Dax 2 months ago
parent
commit
949e69a9bf
100 changed files with 2241 additions and 908 deletions
  1. 115 21
      .github/workflows/close-stale-prs.yml
  2. 1 0
      .opencode/opencode.jsonc
  3. 2 1
      .prettierignore
  4. 27 27
      bun.lock
  5. 4 4
      nix/hashes.json
  6. 1 1
      packages/app/package.json
  7. 150 32
      packages/app/src/components/dialog-select-directory.tsx
  8. 3 4
      packages/app/src/components/dialog-select-model.tsx
  9. 48 1
      packages/app/src/components/file-tree.tsx
  10. 62 85
      packages/app/src/components/prompt-input.tsx
  11. 1 1
      packages/app/src/components/session-context-usage.tsx
  12. 9 3
      packages/app/src/components/session/session-sortable-tab.tsx
  13. 3 9
      packages/app/src/components/settings-general.tsx
  14. 2 8
      packages/app/src/components/settings-keybinds.tsx
  15. 2 8
      packages/app/src/components/settings-models.tsx
  16. 2 8
      packages/app/src/components/settings-providers.tsx
  17. 7 10
      packages/app/src/components/titlebar.tsx
  18. 9 7
      packages/app/src/context/global-sync.tsx
  19. 4 0
      packages/app/src/context/platform.tsx
  20. 6 4
      packages/app/src/context/sync.tsx
  21. 8 0
      packages/app/src/i18n/ar.ts
  22. 8 0
      packages/app/src/i18n/br.ts
  23. 8 0
      packages/app/src/i18n/da.ts
  24. 3 0
      packages/app/src/i18n/de.ts
  25. 10 0
      packages/app/src/i18n/en.ts
  26. 8 0
      packages/app/src/i18n/es.ts
  27. 8 0
      packages/app/src/i18n/fr.ts
  28. 3 0
      packages/app/src/i18n/ja.ts
  29. 8 0
      packages/app/src/i18n/ko.ts
  30. 8 0
      packages/app/src/i18n/no.ts
  31. 8 0
      packages/app/src/i18n/pl.ts
  32. 8 0
      packages/app/src/i18n/ru.ts
  33. 9 1
      packages/app/src/i18n/th.ts
  34. 9 1
      packages/app/src/i18n/zh.ts
  35. 9 1
      packages/app/src/i18n/zht.ts
  36. 537 150
      packages/app/src/pages/layout.tsx
  37. 19 7
      packages/app/src/pages/session.tsx
  38. 1 1
      packages/console/app/package.json
  39. 5 5
      packages/console/app/src/config.ts
  40. 14 3
      packages/console/app/src/routes/zen/util/rateLimiter.ts
  41. 1 1
      packages/console/core/package.json
  42. 6 1
      packages/console/core/src/model.ts
  43. 1 1
      packages/console/function/package.json
  44. 1 1
      packages/console/mail/package.json
  45. 1 1
      packages/desktop/index.html
  46. 1 1
      packages/desktop/package.json
  47. 207 40
      packages/desktop/src-tauri/Cargo.lock
  48. 12 2
      packages/desktop/src-tauri/Cargo.toml
  49. 1 0
      packages/desktop/src-tauri/src/cli.rs
  50. 72 16
      packages/desktop/src-tauri/src/lib.rs
  51. 4 1
      packages/desktop/src-tauri/src/markdown.rs
  52. 2 2
      packages/desktop/src-tauri/src/window_customizer.rs
  53. 19 0
      packages/desktop/src/bindings.ts
  54. 2 2
      packages/desktop/src/cli.ts
  55. 11 15
      packages/desktop/src/index.tsx
  56. 2 2
      packages/desktop/src/menu.ts
  57. 3 3
      packages/desktop/src/updater.ts
  58. 22 16
      packages/desktop/src/webview-zoom.ts
  59. 1 1
      packages/enterprise/package.json
  60. 6 6
      packages/extensions/zed/extension.toml
  61. 1 1
      packages/function/package.json
  62. 5 4
      packages/opencode/package.json
  63. 2 0
      packages/opencode/script/publish.ts
  64. 3 5
      packages/opencode/src/agent/agent.ts
  65. 0 2
      packages/opencode/src/bun/index.ts
  66. 365 181
      packages/opencode/src/cli/cmd/run.ts
  67. 1 0
      packages/opencode/src/cli/cmd/tui/app.tsx
  68. 15 5
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  69. 40 11
      packages/opencode/src/cli/cmd/tui/context/exit.tsx
  70. 3 3
      packages/opencode/src/cli/cmd/tui/context/theme.tsx
  71. 14 0
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  72. 1 1
      packages/opencode/src/cli/cmd/web.ts
  73. 8 1
      packages/opencode/src/cli/network.ts
  74. 19 11
      packages/opencode/src/config/config.ts
  75. 9 0
      packages/opencode/src/format/formatter.ts
  76. 10 1
      packages/opencode/src/provider/provider.ts
  77. 1 6
      packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts
  78. 11 4
      packages/opencode/src/provider/transform.ts
  79. 3 2
      packages/opencode/src/server/mdns.ts
  80. 8 2
      packages/opencode/src/server/server.ts
  81. 1 9
      packages/opencode/src/session/processor.ts
  82. 26 43
      packages/opencode/src/session/prompt.ts
  83. 19 0
      packages/opencode/src/snapshot/index.ts
  84. 7 3
      packages/opencode/src/tool/bash.ts
  85. 1 7
      packages/opencode/src/tool/batch.ts
  86. 4 0
      packages/opencode/src/tool/read.ts
  87. 1 1
      packages/opencode/src/tool/registry.ts
  88. 1 1
      packages/opencode/src/tool/tool.ts
  89. 7 7
      packages/opencode/test/agent/agent.test.ts
  90. 18 0
      packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts
  91. 0 62
      packages/opencode/test/session/prompt.test.ts
  92. 46 0
      packages/opencode/test/snapshot/snapshot.test.ts
  93. 36 1
      packages/opencode/test/tool/bash.test.ts
  94. 1 1
      packages/plugin/package.json
  95. 1 1
      packages/sdk/js/package.json
  96. 5 0
      packages/sdk/js/src/v2/gen/types.gen.ts
  97. 8 0
      packages/sdk/openapi.json
  98. 1 1
      packages/slack/package.json
  99. 1 1
      packages/ui/package.json
  100. 14 15
      packages/ui/src/components/button.css

+ 115 - 21
.github/workflows/close-stale-prs.yml

@@ -18,6 +18,7 @@ permissions:
 jobs:
   close-stale-prs:
     runs-on: ubuntu-latest
+    timeout-minutes: 15
     steps:
       - name: Close inactive PRs
         uses: actions/github-script@v8
@@ -25,6 +26,15 @@ jobs:
           github-token: ${{ secrets.GITHUB_TOKEN }}
           script: |
             const DAYS_INACTIVE = 60
+            const MAX_RETRIES = 3
+
+            // Adaptive delay: fast for small batches, slower for large to respect
+            // GitHub's 80 content-generating requests/minute limit
+            const SMALL_BATCH_THRESHOLD = 10
+            const SMALL_BATCH_DELAY_MS = 1000  // 1s for daily operations (≤10 PRs)
+            const LARGE_BATCH_DELAY_MS = 2000  // 2s for backlog (>10 PRs) = ~30 ops/min, well under 80 limit
+
+            const startTime = Date.now()
             const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
             const { owner, repo } = context.repo
             const dryRun = context.payload.inputs?.dryRun === "true"
@@ -32,6 +42,42 @@ jobs:
             core.info(`Dry run mode: ${dryRun}`)
             core.info(`Cutoff date: ${cutoff.toISOString()}`)
 
+            function sleep(ms) {
+              return new Promise(resolve => setTimeout(resolve, ms))
+            }
+
+            async function withRetry(fn, description = 'API call') {
+              let lastError
+              for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
+                try {
+                  const result = await fn()
+                  return result
+                } catch (error) {
+                  lastError = error
+                  const isRateLimited = error.status === 403 &&
+                    (error.message?.includes('rate limit') || error.message?.includes('secondary'))
+
+                  if (!isRateLimited) {
+                    throw error
+                  }
+
+                  // Parse retry-after header, default to 60 seconds
+                  const retryAfter = error.response?.headers?.['retry-after']
+                    ? parseInt(error.response.headers['retry-after'])
+                    : 60
+
+                  // Exponential backoff: retryAfter * 2^attempt
+                  const backoffMs = retryAfter * 1000 * Math.pow(2, attempt)
+
+                  core.warning(`${description}: Rate limited (attempt ${attempt + 1}/${MAX_RETRIES}). Waiting ${backoffMs / 1000}s before retry...`)
+
+                  await sleep(backoffMs)
+                }
+              }
+              core.error(`${description}: Max retries (${MAX_RETRIES}) exceeded`)
+              throw lastError
+            }
+
             const query = `
               query($owner: String!, $repo: String!, $cursor: String) {
                 repository(owner: $owner, name: $repo) {
@@ -73,17 +119,27 @@ jobs:
             const allPrs = []
             let cursor = null
             let hasNextPage = true
+            let pageCount = 0
 
             while (hasNextPage) {
-              const result = await github.graphql(query, {
-                owner,
-                repo,
-                cursor,
-              })
+              pageCount++
+              core.info(`Fetching page ${pageCount} of open PRs...`)
+
+              const result = await withRetry(
+                () => github.graphql(query, { owner, repo, cursor }),
+                `GraphQL page ${pageCount}`
+              )
 
               allPrs.push(...result.repository.pullRequests.nodes)
               hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
               cursor = result.repository.pullRequests.pageInfo.endCursor
+
+              core.info(`Page ${pageCount}: fetched ${result.repository.pullRequests.nodes.length} PRs (total: ${allPrs.length})`)
+
+              // Delay between pagination requests (use small batch delay for reads)
+              if (hasNextPage) {
+                await sleep(SMALL_BATCH_DELAY_MS)
+              }
             }
 
             core.info(`Found ${allPrs.length} open pull requests`)
@@ -114,28 +170,66 @@ jobs:
 
             core.info(`Found ${stalePrs.length} stale pull requests`)
 
+            // ============================================
+            // Close stale PRs
+            // ============================================
+            const requestDelayMs = stalePrs.length > SMALL_BATCH_THRESHOLD
+              ? LARGE_BATCH_DELAY_MS
+              : SMALL_BATCH_DELAY_MS
+
+            core.info(`Using ${requestDelayMs}ms delay between operations (${stalePrs.length > SMALL_BATCH_THRESHOLD ? 'large' : 'small'} batch mode)`)
+
+            let closedCount = 0
+            let skippedCount = 0
+
             for (const pr of stalePrs) {
               const issue_number = pr.number
               const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
 
               if (dryRun) {
-                core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
+                core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
                 continue
               }
 
-              await github.rest.issues.createComment({
-                owner,
-                repo,
-                issue_number,
-                body: closeComment,
-              })
-
-              await github.rest.pulls.update({
-                owner,
-                repo,
-                pull_number: issue_number,
-                state: "closed",
-              })
-
-              core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
+              try {
+                // Add comment
+                await withRetry(
+                  () => github.rest.issues.createComment({
+                    owner,
+                    repo,
+                    issue_number,
+                    body: closeComment,
+                  }),
+                  `Comment on PR #${issue_number}`
+                )
+
+                // Close PR
+                await withRetry(
+                  () => github.rest.pulls.update({
+                    owner,
+                    repo,
+                    pull_number: issue_number,
+                    state: "closed",
+                  }),
+                  `Close PR #${issue_number}`
+                )
+
+                closedCount++
+                core.info(`Closed PR #${issue_number} from ${pr.author?.login || 'unknown'}: ${pr.title}`)
+
+                // Delay before processing next PR
+                await sleep(requestDelayMs)
+              } catch (error) {
+                skippedCount++
+                core.error(`Failed to close PR #${issue_number}: ${error.message}`)
+              }
             }
+
+            const elapsed = Math.round((Date.now() - startTime) / 1000)
+            core.info(`\n========== Summary ==========`)
+            core.info(`Total open PRs found: ${allPrs.length}`)
+            core.info(`Stale PRs identified: ${stalePrs.length}`)
+            core.info(`PRs closed: ${closedCount}`)
+            core.info(`PRs skipped (errors): ${skippedCount}`)
+            core.info(`Elapsed time: ${elapsed}s`)
+            core.info(`=============================`)

+ 1 - 0
.opencode/opencode.jsonc

@@ -9,6 +9,7 @@
       "options": {},
     },
   },
+  "mcp": {},
   "tools": {
     "github-triage": false,
     "github-pr-search": false,

+ 2 - 1
.prettierignore

@@ -1 +1,2 @@
-sst-env.d.ts
+sst-env.d.ts
+desktop/src/bindings.ts

+ 27 - 27
bun.lock

@@ -23,7 +23,7 @@
     },
     "packages/app": {
       "name": "@opencode-ai/app",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
     },
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
@@ -107,7 +107,7 @@
     },
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
@@ -134,7 +134,7 @@
     },
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@ai-sdk/anthropic": "2.0.0",
         "@ai-sdk/openai": "2.0.2",
@@ -158,7 +158,7 @@
     },
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
@@ -182,7 +182,7 @@
     },
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@opencode-ai/app": "workspace:*",
         "@opencode-ai/ui": "workspace:*",
@@ -213,7 +213,7 @@
     },
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/util": "workspace:*",
@@ -242,7 +242,7 @@
     },
     "packages/function": {
       "name": "@opencode-ai/function",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "catalog:",
@@ -258,7 +258,7 @@
     },
     "packages/opencode": {
       "name": "opencode",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -286,7 +286,7 @@
         "@ai-sdk/vercel": "1.0.33",
         "@ai-sdk/xai": "2.0.56",
         "@clack/prompts": "1.0.0-alpha.1",
-        "@gitlab/gitlab-ai-provider": "3.3.1",
+        "@gitlab/gitlab-ai-provider": "3.4.0",
         "@hono/standard-validator": "0.1.5",
         "@hono/zod-validator": "catalog:",
         "@modelcontextprotocol/sdk": "1.25.2",
@@ -298,8 +298,8 @@
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/util": "workspace:*",
         "@openrouter/ai-sdk-provider": "1.5.4",
-        "@opentui/core": "0.1.76",
-        "@opentui/solid": "0.1.76",
+        "@opentui/core": "0.1.77",
+        "@opentui/solid": "0.1.77",
         "@parcel/watcher": "2.5.1",
         "@pierre/diffs": "catalog:",
         "@solid-primitives/event-bus": "1.1.2",
@@ -365,7 +365,7 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "zod": "catalog:",
@@ -385,7 +385,7 @@
     },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "devDependencies": {
         "@hey-api/openapi-ts": "0.90.10",
         "@tsconfig/node22": "catalog:",
@@ -396,7 +396,7 @@
     },
     "packages/slack": {
       "name": "@opencode-ai/slack",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
@@ -409,7 +409,7 @@
     },
     "packages/ui": {
       "name": "@opencode-ai/ui",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -451,7 +451,7 @@
     },
     "packages/util": {
       "name": "@opencode-ai/util",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "zod": "catalog:",
       },
@@ -462,7 +462,7 @@
     },
     "packages/web": {
       "name": "@opencode-ai/web",
-      "version": "1.1.48",
+      "version": "1.1.49",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
@@ -940,7 +940,7 @@
 
     "@fontsource/inter": ["@fontsource/[email protected]", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="],
 
-    "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.3.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-J4/LfVcxOKbR2gfoBWRKp1BpWppprC2Cz/Ff5E0B/0lS341CDtZwzkgWvHfkM/XU6q83JRs059dS0cR8VOODOQ=="],
+    "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.4.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-1fEZgqjSZ0WLesftw/J5UtFuJCYFDvCZCHhTH5PZAmpDEmCwllJBoe84L3+vIk38V2FGDMTW128iKTB2mVzr3A=="],
 
     "@graphql-typed-document-node/core": ["@graphql-typed-document-node/[email protected]", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
 
@@ -1246,21 +1246,21 @@
 
     "@opentelemetry/api": ["@opentelemetry/[email protected]", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
 
-    "@opentui/core": ["@opentui/[email protected]6", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.76", "@opentui/core-darwin-x64": "0.1.76", "@opentui/core-linux-arm64": "0.1.76", "@opentui/core-linux-x64": "0.1.76", "@opentui/core-win32-arm64": "0.1.76", "@opentui/core-win32-x64": "0.1.76", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Y4f4KH6Mbj0J6+MorcvtHSeT+Lbs3YDPEQcTRTWsPOqWz3A0F5/+OPtZKho1EtLWQqJflCWdf/JQj5A3We3qRg=="],
+    "@opentui/core": ["@opentui/[email protected]7", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.77", "@opentui/core-darwin-x64": "0.1.77", "@opentui/core-linux-arm64": "0.1.77", "@opentui/core-linux-x64": "0.1.77", "@opentui/core-win32-arm64": "0.1.77", "@opentui/core-win32-x64": "0.1.77", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-lE3kabm6jdqK3AuBq+O0zZrXdxt6ulmibTc57sf+AsPny6cmwYHnWI4wD6hcreFiYoQVNVvdiJchVgPtowMlEg=="],
 
-    "@opentui/core-darwin-arm64": ["@opentui/[email protected]6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-aRYNOPRKL6URovSPhRvXtBV7SqdmR7s6hmEBSdXiYo39AozTcvKviF8gJWXQATcKDEcOtRir6TsASzDq5Coheg=="],
+    "@opentui/core-darwin-arm64": ["@opentui/[email protected]7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-SNqmygCMEsPCW7xWjzCZ5caBf36xaprwVdAnFijGDOuIzLA4iaDa6um8cj3TJh7awenN3NTRsuRc7OuH42UH+g=="],
 
-    "@opentui/core-darwin-x64": ["@opentui/[email protected]6", "", { "os": "darwin", "cpu": "x64" }, "sha512-KFaRvVQ0Wr1PgaexUkF3KYt41pYmxGJW3otENeE6WDa/nXe2AElibPFRjqSEH54YrY5Q84SDI77/wGP4LZ/Wyg=="],
+    "@opentui/core-darwin-x64": ["@opentui/[email protected]7", "", { "os": "darwin", "cpu": "x64" }, "sha512-/8fsa03swEHTQt/9NrGm98kemlU+VuTURI/OFZiH53vPDRrOYIYoa4Jyga/H7ZMcG+iFhkq97zIe+0Kw95LGmA=="],
 
-    "@opentui/core-linux-arm64": ["@opentui/[email protected]6", "", { "os": "linux", "cpu": "arm64" }, "sha512-s7v+GDwavfieZg8xZV4V07fXFrHfFq4UZ2JpYFDUgNs9vFp+++WUjh3pfbfE+2ldbhcG2iOtuiV9aG1tVCbTEg=="],
+    "@opentui/core-linux-arm64": ["@opentui/[email protected]7", "", { "os": "linux", "cpu": "arm64" }, "sha512-QfUXZJPc69OvqoMu+AlLgjqXrwu4IeqcBuUWYMuH8nPTeLsVUc3CBbXdV2lv9UDxWzxzrxdS4ALPaxvmEv9lsQ=="],
 
-    "@opentui/core-linux-x64": ["@opentui/[email protected]6", "", { "os": "linux", "cpu": "x64" }, "sha512-ugwuHpmvdKRHXKVsrC3zRYY6bg2JxVCzAQ1NOiWRLq3N3N4ha6BHAkHMCeHgR/ZI4R8MSRB6vtJRVI1F9VHxjA=="],
+    "@opentui/core-linux-x64": ["@opentui/[email protected]7", "", { "os": "linux", "cpu": "x64" }, "sha512-Kmfx0yUKnPj67AoXYIgL7qQo0QVsUG5Iw8aRtv6XFzXqa5SzBPhaKkKZ9yHPjOmTalZquUs+9zcCRNKpYYuL7A=="],
 
-    "@opentui/core-win32-arm64": ["@opentui/[email protected]6", "", { "os": "win32", "cpu": "arm64" }, "sha512-wjpRWrerPItb5E1fP4SAcNMxQp1yEukbgvP4Azip836/ixxbghL6y0P57Ya/rv7QYLrkNZXoQ+tr9oXhPH5BVA=="],
+    "@opentui/core-win32-arm64": ["@opentui/[email protected]7", "", { "os": "win32", "cpu": "arm64" }, "sha512-HGTscPXc7gdd23Nh1DbzUNjog1I+5IZp95XPtLftGTpjrWs60VcetXcyJqK2rQcXNxewJK5yDyaa5QyMjfEhCQ=="],
 
-    "@opentui/core-win32-x64": ["@opentui/[email protected]6", "", { "os": "win32", "cpu": "x64" }, "sha512-2YjtZJdd3iO+SY9NKocE4/Pm9VolzAthUOXjpK4Pv5pnR9hBpPvX7FFSXJTfASj7y2j1tATWrlQLocZCFP/oMA=="],
+    "@opentui/core-win32-x64": ["@opentui/[email protected]7", "", { "os": "win32", "cpu": "x64" }, "sha512-c7GijsbvVgnlzd2murIbwuwrGbcv76KdUw6WlVv7a0vex50z6xJCpv1keGzpe0QfxrZ/6fFEFX7JnwGLno0wjA=="],
 
-    "@opentui/solid": ["@opentui/[email protected]6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.76", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-PiD62FGoPoVLFpY4g08i4UYlx4sGR2OmHUPj6CuZZwy2UJD4fKn1RYV+kAPHfUW4qN/88I1k/w/Dniz1WvXrAQ=="],
+    "@opentui/solid": ["@opentui/[email protected]7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.77", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.9", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.9" } }, "sha512-JY+hUbXVV+XCk6bC8dvcwawWCEmC3Gid6GDs23AJWBgHZ3TU2kRKrgwTdltm45DOq2cZXrYCt690/yE8bP+Gxg=="],
 
     "@oslojs/asn1": ["@oslojs/[email protected]", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
 

+ 4 - 4
nix/hashes.json

@@ -1,8 +1,8 @@
 {
   "nodeModules": {
-    "x86_64-linux": "sha256-aRFzPzgu32XgNSk8S2z4glTlgHqEmOLZHlBQSIYIMvY=",
-    "aarch64-linux": "sha256-aCZLkmRrCa0bli0jgsaLcC5GlZdjQPbb6xD6Fc03eX8=",
-    "aarch64-darwin": "sha256-oZOOR6k8MmabNVDQNY5ywR06rRycdnXZL+gUucKSQ+g=",
-    "x86_64-darwin": "sha256-LXIcLnjn+1eTFWIsQ9W0U2orGm59P/L470O0KFFkRHg="
+    "x86_64-linux": "sha256-yIrljJgOR1GZCAXi5bx+YvrIAjSkTAMTSzlhLFY/ufE=",
+    "aarch64-linux": "sha256-Xa3BgqbuD5Cx5OpyVSN1v7Klge449hPqR1GY9E9cAX0=",
+    "aarch64-darwin": "sha256-Q3FKm7+4Jr3PL+TnQngrTtv/xdek2st5HmgeoEOHUis=",
+    "x86_64-darwin": "sha256-asJ8DBvIgkqh8HhrN48M/L4xj1kwv+uyQMy9bN2HxuM="
   }
 }

+ 1 - 1
packages/app/package.json

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

+ 150 - 32
packages/app/src/components/dialog-select-directory.tsx

@@ -4,10 +4,11 @@ import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { List } from "@opencode-ai/ui/list"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import fuzzysort from "fuzzysort"
-import { createMemo } from "solid-js"
+import { createMemo, createResource, createSignal } from "solid-js"
 import { useGlobalSDK } from "@/context/global-sdk"
 import { useGlobalSync } from "@/context/global-sync"
 import { useLanguage } from "@/context/language"
+import type { ListRef } from "@opencode-ai/ui/list"
 
 interface DialogSelectDirectoryProps {
   title?: string
@@ -15,18 +16,47 @@ interface DialogSelectDirectoryProps {
   onSelect: (result: string | string[] | null) => void
 }
 
+type Row = {
+  absolute: string
+  search: string
+}
+
 export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
   const sync = useGlobalSync()
   const sdk = useGlobalSDK()
   const dialog = useDialog()
   const language = useLanguage()
 
-  const home = createMemo(() => sync.data.path.home)
+  const [filter, setFilter] = createSignal("")
+
+  let list: ListRef | undefined
+
+  const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory))
+
+  const [fallbackPath] = createResource(
+    () => (missingBase() ? true : undefined),
+    async () => {
+      return sdk.client.path
+        .get()
+        .then((x) => x.data)
+        .catch(() => undefined)
+    },
+    { initialValue: undefined },
+  )
+
+  const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "")
 
-  const start = createMemo(() => sync.data.path.home || sync.data.path.directory)
+  const start = createMemo(
+    () => sync.data.path.home || sync.data.path.directory || fallbackPath()?.home || fallbackPath()?.directory,
+  )
 
   const cache = new Map<string, Promise<Array<{ name: string; absolute: string }>>>()
 
+  const clean = (value: string) => {
+    const first = (value ?? "").split(/\r?\n/)[0] ?? ""
+    return first.replace(/[\u0000-\u001F\u007F]/g, "").trim()
+  }
+
   function normalize(input: string) {
     const v = input.replaceAll("\\", "/")
     if (v.startsWith("//") && !v.startsWith("///")) return "//" + v.slice(2).replace(/\/+/g, "/")
@@ -64,24 +94,67 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
     return ""
   }
 
-  function display(path: string) {
+  function parentOf(input: string) {
+    const v = trimTrailing(input)
+    if (v === "/") return v
+    if (v === "//") return v
+    if (/^[A-Za-z]:\/$/.test(v)) return v
+
+    const i = v.lastIndexOf("/")
+    if (i <= 0) return "/"
+    if (i === 2 && /^[A-Za-z]:/.test(v)) return v.slice(0, 3)
+    return v.slice(0, i)
+  }
+
+  function modeOf(input: string) {
+    const raw = normalizeDriveRoot(input.trim())
+    if (!raw) return "relative" as const
+    if (raw.startsWith("~")) return "tilde" as const
+    if (rootOf(raw)) return "absolute" as const
+    return "relative" as const
+  }
+
+  function display(path: string, input: string) {
     const full = trimTrailing(path)
+    if (modeOf(input) === "absolute") return full
+
+    return tildeOf(full) || full
+  }
+
+  function tildeOf(absolute: string) {
+    const full = trimTrailing(absolute)
     const h = home()
-    if (!h) return full
+    if (!h) return ""
 
     const hn = trimTrailing(h)
     const lc = full.toLowerCase()
     const hc = hn.toLowerCase()
     if (lc === hc) return "~"
     if (lc.startsWith(hc + "/")) return "~" + full.slice(hn.length)
-    return full
+    return ""
+  }
+
+  function row(absolute: string): Row {
+    const full = trimTrailing(absolute)
+    const tilde = tildeOf(full)
+
+    const withSlash = (value: string) => {
+      if (!value) return ""
+      if (value.endsWith("/")) return value
+      return value + "/"
+    }
+
+    const search = Array.from(
+      new Set([full, withSlash(full), tilde, withSlash(tilde), getFilename(full)].filter(Boolean)),
+    ).join("\n")
+    return { absolute: full, search }
   }
 
-  function scoped(filter: string) {
+  function scoped(value: string) {
     const base = start()
     if (!base) return
 
-    const raw = normalizeDriveRoot(filter.trim())
+    const raw = normalizeDriveRoot(value)
     if (!raw) return { directory: trimTrailing(base), path: "" }
 
     const h = home()
@@ -122,21 +195,25 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
   }
 
   const directories = async (filter: string) => {
-    const input = scoped(filter)
-    if (!input) return [] as string[]
+    const value = clean(filter)
+    const scopedInput = scoped(value)
+    if (!scopedInput) return [] as string[]
 
-    const raw = normalizeDriveRoot(filter.trim())
+    const raw = normalizeDriveRoot(value)
     const isPath = raw.startsWith("~") || !!rootOf(raw) || raw.includes("/")
 
-    const query = normalizeDriveRoot(input.path)
+    const query = normalizeDriveRoot(scopedInput.path)
 
-    if (!isPath) {
-      const results = await sdk.client.find
-        .files({ directory: input.directory, query, type: "directory", limit: 50 })
+    const find = () =>
+      sdk.client.find
+        .files({ directory: scopedInput.directory, query, type: "directory", limit: 50 })
         .then((x) => x.data ?? [])
         .catch(() => [])
 
-      return results.map((rel) => join(input.directory, rel)).slice(0, 50)
+    if (!isPath) {
+      const results = await find()
+
+      return results.map((rel) => join(scopedInput.directory, rel)).slice(0, 50)
     }
 
     const segments = query.replace(/^\/+/, "").split("/")
@@ -145,17 +222,10 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
 
     const cap = 12
     const branch = 4
-    let paths = [input.directory]
+    let paths = [scopedInput.directory]
     for (const part of head) {
       if (part === "..") {
-        paths = paths.map((p) => {
-          const v = trimTrailing(p)
-          if (v === "/") return v
-          if (/^[A-Za-z]:\/$/.test(v)) return v
-          const i = v.lastIndexOf("/")
-          if (i <= 0) return "/"
-          return v.slice(0, i)
-        })
+        paths = paths.map(parentOf)
         continue
       }
 
@@ -165,7 +235,27 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
     }
 
     const out = (await Promise.all(paths.map((p) => match(p, tail, 50)))).flat()
-    return Array.from(new Set(out)).slice(0, 50)
+    const deduped = Array.from(new Set(out))
+    const base = raw.startsWith("~") ? trimTrailing(scopedInput.directory) : ""
+    const expand = !raw.endsWith("/")
+    if (!expand || !tail) {
+      const items = base ? Array.from(new Set([base, ...deduped])) : deduped
+      return items.slice(0, 50)
+    }
+
+    const needle = tail.toLowerCase()
+    const exact = deduped.filter((p) => getFilename(p).toLowerCase() === needle)
+    const target = exact[0]
+    if (!target) return deduped.slice(0, 50)
+
+    const children = await match(target, "", 30)
+    const items = Array.from(new Set([...deduped, ...children]))
+    return (base ? Array.from(new Set([base, ...items])) : items).slice(0, 50)
+  }
+
+  const items = async (value: string) => {
+    const results = await directories(value)
+    return results.map(row)
   }
 
   function resolve(absolute: string) {
@@ -179,24 +269,52 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) {
         search={{ placeholder: language.t("dialog.directory.search.placeholder"), autofocus: true }}
         emptyMessage={language.t("dialog.directory.empty")}
         loadingMessage={language.t("common.loading")}
-        items={directories}
-        key={(x) => x}
+        items={items}
+        key={(x) => x.absolute}
+        filterKeys={["search"]}
+        ref={(r) => (list = r)}
+        onFilter={(value) => setFilter(clean(value))}
+        onKeyEvent={(e, item) => {
+          if (e.key !== "Tab") return
+          if (e.shiftKey) return
+          if (!item) return
+
+          e.preventDefault()
+          e.stopPropagation()
+
+          const value = display(item.absolute, filter())
+          list?.setFilter(value.endsWith("/") ? value : value + "/")
+        }}
         onSelect={(path) => {
           if (!path) return
-          resolve(path)
+          resolve(path.absolute)
         }}
       >
-        {(absolute) => {
-          const path = display(absolute)
+        {(item) => {
+          const path = display(item.absolute, filter())
+          if (path === "~") {
+            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: item.absolute, type: "directory" }} class="shrink-0 size-4" />
+                  <div class="flex items-center text-14-regular min-w-0">
+                    <span class="text-text-strong whitespace-nowrap">~</span>
+                    <span class="text-text-weak whitespace-nowrap">/</span>
+                  </div>
+                </div>
+              </div>
+            )
+          }
           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: absolute, type: "directory" }} class="shrink-0 size-4" />
+                <FileIcon node={{ path: item.absolute, 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>
+                  <span class="text-text-weak whitespace-nowrap">/</span>
                 </div>
               </div>
             </div>

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

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

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

@@ -130,10 +130,57 @@ export default function FileTree(props: {
     const nodes = file.tree.children(props.path)
     const current = filter()
     if (!current) return nodes
-    return nodes.filter((node) => {
+
+    const parent = (path: string) => {
+      const idx = path.lastIndexOf("/")
+      if (idx === -1) return ""
+      return path.slice(0, idx)
+    }
+
+    const leaf = (path: string) => {
+      const idx = path.lastIndexOf("/")
+      return idx === -1 ? path : path.slice(idx + 1)
+    }
+
+    const out = nodes.filter((node) => {
       if (node.type === "file") return current.files.has(node.path)
       return current.dirs.has(node.path)
     })
+
+    const seen = new Set(out.map((node) => node.path))
+
+    for (const dir of current.dirs) {
+      if (parent(dir) !== props.path) continue
+      if (seen.has(dir)) continue
+      out.push({
+        name: leaf(dir),
+        path: dir,
+        absolute: dir,
+        type: "directory",
+        ignored: false,
+      })
+      seen.add(dir)
+    }
+
+    for (const item of current.files) {
+      if (parent(item) !== props.path) continue
+      if (seen.has(item)) continue
+      out.push({
+        name: leaf(item),
+        path: item,
+        absolute: item,
+        type: "file",
+        ignored: false,
+      })
+      seen.add(item)
+    }
+
+    return out.toSorted((a, b) => {
+      if (a.type !== b.type) {
+        return a.type === "directory" ? -1 : 1
+      }
+      return a.name.localeCompare(b.name)
+    })
   })
 
   const Node = (

+ 62 - 85
packages/app/src/components/prompt-input.tsx

@@ -32,9 +32,7 @@ import { useNavigate, useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
 import { useComments } from "@/context/comments"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
-import { MorphChevron } from "@opencode-ai/ui/morph-chevron"
 import { Button } from "@opencode-ai/ui/button"
-import { CycleLabel } from "@opencode-ai/ui/cycle-label"
 import { Icon } from "@opencode-ai/ui/icon"
 import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
 import type { IconName } from "@opencode-ai/ui/icons/provider"
@@ -44,7 +42,6 @@ import { Select } from "@opencode-ai/ui/select"
 import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/util/path"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { ImagePreview } from "@opencode-ai/ui/image-preview"
-import { ReasoningIcon } from "@opencode-ai/ui/reasoning-icon"
 import { ModelSelectorPopover } from "@/components/dialog-select-model"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
@@ -1257,7 +1254,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       clearInput()
       client.session
         .shell({
-          sessionID: session?.id || "",
+          sessionID: session.id,
           agent,
           model,
           command: text,
@@ -1280,7 +1277,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         clearInput()
         client.session
           .command({
-            sessionID: session?.id || "",
+            sessionID: session.id,
             command: commandName,
             arguments: args.join(" "),
             agent,
@@ -1436,13 +1433,13 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
     const optimisticParts = requestParts.map((part) => ({
       ...part,
-      sessionID: session?.id || "",
+      sessionID: session.id,
       messageID,
     })) as unknown as Part[]
 
     const optimisticMessage: Message = {
       id: messageID,
-      sessionID: session?.id || "",
+      sessionID: session.id,
       role: "user",
       time: { created: Date.now() },
       agent,
@@ -1453,9 +1450,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       if (sessionDirectory === projectDirectory) {
         sync.set(
           produce((draft) => {
-            const messages = draft.message[session?.id || ""]
+            const messages = draft.message[session.id]
             if (!messages) {
-              draft.message[session?.id || ""] = [optimisticMessage]
+              draft.message[session.id] = [optimisticMessage]
             } else {
               const result = Binary.search(messages, messageID, (m) => m.id)
               messages.splice(result.index, 0, optimisticMessage)
@@ -1463,7 +1460,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             draft.part[messageID] = optimisticParts
               .filter((p) => !!p?.id)
               .slice()
-              .sort((a, b) => a.id.localeCompare(b.id))
+              .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
           }),
         )
         return
@@ -1471,9 +1468,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
       globalSync.child(sessionDirectory)[1](
         produce((draft) => {
-          const messages = draft.message[session?.id || ""]
+          const messages = draft.message[session.id]
           if (!messages) {
-            draft.message[session?.id || ""] = [optimisticMessage]
+            draft.message[session.id] = [optimisticMessage]
           } else {
             const result = Binary.search(messages, messageID, (m) => m.id)
             messages.splice(result.index, 0, optimisticMessage)
@@ -1481,7 +1478,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           draft.part[messageID] = optimisticParts
             .filter((p) => !!p?.id)
             .slice()
-            .sort((a, b) => a.id.localeCompare(b.id))
+            .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
         }),
       )
     }
@@ -1490,7 +1487,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       if (sessionDirectory === projectDirectory) {
         sync.set(
           produce((draft) => {
-            const messages = draft.message[session?.id || ""]
+            const messages = draft.message[session.id]
             if (messages) {
               const result = Binary.search(messages, messageID, (m) => m.id)
               if (result.found) messages.splice(result.index, 1)
@@ -1503,7 +1500,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
       globalSync.child(sessionDirectory)[1](
         produce((draft) => {
-          const messages = draft.message[session?.id || ""]
+          const messages = draft.message[session.id]
           if (messages) {
             const result = Binary.search(messages, messageID, (m) => m.id)
             if (result.found) messages.splice(result.index, 1)
@@ -1524,15 +1521,15 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const worktree = WorktreeState.get(sessionDirectory)
       if (!worktree || worktree.status !== "pending") return true
 
-      if (sessionDirectory === projectDirectory && session?.id) {
-        sync.set("session_status", session?.id, { type: "busy" })
+      if (sessionDirectory === projectDirectory) {
+        sync.set("session_status", session.id, { type: "busy" })
       }
 
       const controller = new AbortController()
 
       const cleanup = () => {
-        if (sessionDirectory === projectDirectory && session?.id) {
-          sync.set("session_status", session?.id, { type: "idle" })
+        if (sessionDirectory === projectDirectory) {
+          sync.set("session_status", session.id, { type: "idle" })
         }
         removeOptimisticMessage()
         for (const item of commentItems) {
@@ -1549,7 +1546,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         restoreInput()
       }
 
-      pending.set(session?.id || "", { abort: controller, cleanup })
+      pending.set(session.id, { abort: controller, cleanup })
 
       const abort = new Promise<Awaited<ReturnType<typeof WorktreeState.wait>>>((resolve) => {
         if (controller.signal.aborted) {
@@ -1577,7 +1574,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         if (timer.id === undefined) return
         clearTimeout(timer.id)
       })
-      pending.delete(session?.id || "")
+      pending.delete(session.id)
       if (controller.signal.aborted) return false
       if (result.status === "failed") throw new Error(result.message)
       return true
@@ -1587,7 +1584,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const ok = await waitForWorktree()
       if (!ok) return
       await client.session.prompt({
-        sessionID: session?.id || "",
+        sessionID: session.id,
         agent,
         model,
         messageID,
@@ -1597,9 +1594,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
 
     void send().catch((err) => {
-      pending.delete(session?.id || "")
-      if (sessionDirectory === projectDirectory && session?.id) {
-        sync.set("session_status", session?.id, { type: "idle" })
+      pending.delete(session.id)
+      if (sessionDirectory === projectDirectory) {
+        sync.set("session_status", session.id, { type: "idle" })
       }
       showToast({
         title: language.t("prompt.toast.promptSendFailed.title"),
@@ -1621,28 +1618,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     })
   }
 
-  const currrentModelVariant = createMemo(() => {
-    const modelVariant = local.model.variant.current() ?? ""
-    return modelVariant === "xhigh"
-      ? "xHigh"
-      : modelVariant.length > 0
-        ? modelVariant[0].toUpperCase() + modelVariant.slice(1)
-        : "Default"
-  })
-
-  const reasoningPercentage = createMemo(() => {
-    const variants = local.model.variant.list()
-    const current = local.model.variant.current()
-    const totalEntries = variants.length + 1
-
-    if (totalEntries <= 2 || current === "Default") {
-      return 0
-    }
-
-    const currentIndex = current ? variants.indexOf(current) + 1 : 0
-    return ((currentIndex + 1) / totalEntries) * 100
-  }, [local.model.variant])
-
   return (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
       <Show when={store.popover}>
@@ -1695,7 +1670,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                           </>
                         }
                       >
-                        <Icon name="brain" size="normal" class="text-icon-info-active shrink-0" />
+                        <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>
@@ -1760,9 +1735,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
         }}
       >
         <Show when={store.dragging}>
-          <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 mr-1 pointer-events-none">
+          <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
             <div class="flex flex-col items-center gap-2 text-text-weak">
-              <Icon name="photo" size={18} class="text-icon-base stroke-1.5" />
+              <Icon name="photo" class="size-8" />
               <span class="text-14-regular">{language.t("prompt.dropzone.label")}</span>
             </div>
           </div>
@@ -1801,7 +1776,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       }}
                     >
                       <div class="flex items-center gap-1.5">
-                        <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-7" />
+                        <FileIcon node={{ path: item.path, type: "file" }} class="shrink-0 size-3.5" />
                         <div class="flex items-center text-11-regular min-w-0 font-medium">
                           <span class="text-text-strong whitespace-nowrap">{getFilenameTruncated(item.path, 14)}</span>
                           <Show when={item.selection}>
@@ -1818,7 +1793,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                           type="button"
                           icon="close-small"
                           variant="ghost"
-                          class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all"
+                          class="ml-auto size-3.5 opacity-0 group-hover:opacity-100 transition-all"
                           onClick={(e) => {
                             e.stopPropagation()
                             if (item.commentID) comments.remove(item.path, item.commentID)
@@ -1848,7 +1823,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     when={attachment.mime.startsWith("image/")}
                     fallback={
                       <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
-                        <Icon name="folder" size="normal" class="size-6 text-text-base" />
+                        <Icon name="folder" class="size-6 text-text-weak" />
                       </div>
                     }
                   >
@@ -1921,8 +1896,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
             </div>
           </Show>
         </div>
-        <div class="relative p-3 flex items-center justify-between">
-          <div class="flex items-center justify-start gap-2">
+        <div class="relative p-3 flex items-center justify-between gap-2">
+          <div class="flex items-center gap-2 min-w-0 flex-1">
             <Switch>
               <Match when={store.mode === "shell"}>
                 <div class="flex items-center gap-2 px-2 h-6">
@@ -1934,6 +1909,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               <Match when={store.mode === "normal"}>
                 <TooltipKeybind
                   placement="top"
+                  gutter={8}
                   title={language.t("command.agent.cycle")}
                   keybind={command.keybind("agent.cycle")}
                 >
@@ -1941,9 +1917,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                     options={local.agent.list().map((agent) => agent.name)}
                     current={local.agent.current()?.name ?? ""}
                     onSelect={local.agent.set}
-                    class="capitalize"
+                    class={`capitalize ${local.model.variant.list().length > 0 ? "max-w-[80px]" : "max-w-[120px]"}`}
+                    valueClass="truncate"
                     variant="ghost"
-                    gutter={12}
                   />
                 </TooltipKeybind>
                 <Show
@@ -1951,66 +1927,68 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   fallback={
                     <TooltipKeybind
                       placement="top"
+                      gutter={8}
                       title={language.t("command.model.choose")}
                       keybind={command.keybind("model.choose")}
                     >
                       <Button
                         as="div"
                         variant="ghost"
-                        class="px-2"
+                        class="px-2 min-w-0 max-w-[140px]"
                         onClick={() => dialog.show(() => <DialogSelectModelUnpaid />)}
                       >
                         <Show when={local.model.current()?.provider?.id}>
                           <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
                         </Show>
-                        {local.model.current()?.name ?? language.t("dialog.model.select.title")}
-                        <MorphChevron
-                          expanded={!!dialog.active?.id && dialog.active.id.startsWith("select-model-unpaid")}
-                        />
+                        <span class="truncate max-w-[100px]">
+                          {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+                        </span>
+                        <Icon name="chevron-down" size="small" class="shrink-0" />
                       </Button>
                     </TooltipKeybind>
                   }
                 >
                   <TooltipKeybind
                     placement="top"
+                    gutter={8}
                     title={language.t("command.model.choose")}
                     keybind={command.keybind("model.choose")}
                   >
-                    <ModelSelectorPopover triggerAs={Button} triggerProps={{ variant: "ghost" }} gutter={12}>
-                      {(open) => (
-                        <>
-                          <Show when={local.model.current()?.provider?.id}>
-                            <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
-                          </Show>
-                          {local.model.current()?.name ?? language.t("dialog.model.select.title")}
-                          <MorphChevron expanded={open} class="text-text-weak" />
-                        </>
-                      )}
+                    <ModelSelectorPopover
+                      triggerAs={Button}
+                      triggerProps={{ variant: "ghost", class: "min-w-0 max-w-[140px]" }}
+                    >
+                      <Show when={local.model.current()?.provider?.id}>
+                        <ProviderIcon id={local.model.current()!.provider.id as IconName} class="size-4 shrink-0" />
+                      </Show>
+                      <span class="truncate max-w-[100px]">
+                        {local.model.current()?.name ?? language.t("dialog.model.select.title")}
+                      </span>
+                      <Icon name="chevron-down" size="small" class="shrink-0" />
                     </ModelSelectorPopover>
                   </TooltipKeybind>
                 </Show>
                 <Show when={local.model.variant.list().length > 0}>
                   <TooltipKeybind
                     placement="top"
+                    gutter={8}
                     title={language.t("command.model.variant.cycle")}
                     keybind={command.keybind("model.variant.cycle")}
                   >
                     <Button
                       data-action="model-variant-cycle"
                       variant="ghost"
-                      class="text-text-strong text-12-regular"
+                      class="text-text-base _hidden group-hover/prompt-input:inline-block capitalize text-12-regular"
                       onClick={() => local.model.variant.cycle()}
                     >
-                      <Show when={local.model.variant.list().length > 1}>
-                        <ReasoningIcon percentage={reasoningPercentage()} size={16} strokeWidth={1.25} />
-                      </Show>
-                      <CycleLabel value={currrentModelVariant()} />
+                      {local.model.variant.current() ?? language.t("common.default")}
                     </Button>
                   </TooltipKeybind>
                 </Show>
                 <Show when={permission.permissionsEnabled() && params.id}>
                   <TooltipKeybind
                     placement="top"
+                    gutter={8}
                     title={language.t("command.permissions.autoaccept.enable")}
                     keybind={command.keybind("permissions.autoaccept")}
                   >
@@ -2018,7 +1996,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                       variant="ghost"
                       onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
                       classList={{
-                        "_hidden group-hover/prompt-input:flex items-center justify-center": true,
+                        "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
                         "text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
                         "hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
                       }}
@@ -2040,7 +2018,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               </Match>
             </Switch>
           </div>
-          <div class="flex items-center gap-1 absolute right-3 bottom-3">
+          <div class="flex items-center gap-1 shrink-0">
             <input
               ref={fileInputRef}
               type="file"
@@ -2052,19 +2030,18 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 e.currentTarget.value = ""
               }}
             />
-            <div class="flex items-center gap-1.5 mr-1.5">
+            <div class="flex items-center gap-1 mr-1">
               <SessionContextUsage />
               <Show when={store.mode === "normal"}>
                 <Tooltip placement="top" value={language.t("prompt.action.attachFile")}>
                   <Button
                     type="button"
                     variant="ghost"
-                    size="small"
-                    class="px-1"
+                    class="size-6 px-1"
                     onClick={() => fileInputRef.click()}
                     aria-label={language.t("prompt.action.attachFile")}
                   >
-                    <Icon name="photo" class="size-6 text-icon-base" />
+                    <Icon name="photo" class="size-4.5" />
                   </Button>
                 </Tooltip>
               </Show>
@@ -2083,7 +2060,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                   <Match when={true}>
                     <div class="flex items-center gap-2">
                       <span>{language.t("prompt.action.send")}</span>
-                      <Icon name="enter" size="normal" class="text-icon-base" />
+                      <Icon name="enter" size="small" class="text-icon-base" />
                     </div>
                   </Match>
                 </Switch>
@@ -2094,7 +2071,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
                 disabled={!prompt.dirty() && !working()}
                 icon={working() ? "stop" : "arrow-up"}
                 variant="primary"
-                class="h-6 w-5.5"
+                class="h-6 w-4.5"
                 aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
               />
             </Tooltip>

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

@@ -64,7 +64,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
   }
 
   const circle = () => (
-    <div class="p-1">
+    <div class="flex items-center justify-center">
       <ProgressCircle size={16} strokeWidth={2} percentage={context()?.percentage ?? 0} />
     </div>
   )

+ 9 - 3
packages/app/src/components/session/session-sortable-tab.tsx

@@ -3,11 +3,12 @@ import type { JSX } from "solid-js"
 import { createSortable } from "@thisbeyond/solid-dnd"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
-import { Tooltip } from "@opencode-ai/ui/tooltip"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { getFilename } from "@opencode-ai/util/path"
 import { useFile } from "@/context/file"
 import { useLanguage } from "@/context/language"
+import { useCommand } from "@/context/command"
 
 export function FileVisual(props: { path: string; active?: boolean }): JSX.Element {
   return (
@@ -27,6 +28,7 @@ export function FileVisual(props: { path: string; active?: boolean }): JSX.Eleme
 export function SortableTab(props: { tab: string; onTabClose: (tab: string) => void }): JSX.Element {
   const file = useFile()
   const language = useLanguage()
+  const command = useCommand()
   const sortable = createSortable(props.tab)
   const path = createMemo(() => file.pathFromTab(props.tab))
   return (
@@ -36,7 +38,11 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
         <Tabs.Trigger
           value={props.tab}
           closeButton={
-            <Tooltip value={language.t("common.closeTab")} placement="bottom">
+            <TooltipKeybind
+              title={language.t("common.closeTab")}
+              keybind={command.keybind("tab.close")}
+              placement="bottom"
+            >
               <IconButton
                 icon="close-small"
                 variant="ghost"
@@ -44,7 +50,7 @@ export function SortableTab(props: { tab: string; onTabClose: (tab: string) => v
                 onClick={() => props.onTabClose(props.tab)}
                 aria-label={language.t("common.closeTab")}
               />
-            </Tooltip>
+            </TooltipKeybind>
           }
           hideCloseButton
           onMiddleClick={() => props.onTabClose(props.tab)}

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

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

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

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

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

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

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

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

+ 7 - 10
packages/app/src/components/titlebar.tsx

@@ -24,6 +24,8 @@ export function Titlebar() {
   const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
   const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
   const web = createMemo(() => platform.platform === "web")
+  const zoom = () => platform.webviewZoom?.() ?? 1
+  const minHeight = () => (mac() ? `${40 / zoom()}px` : undefined)
 
   const [history, setHistory] = createStore({
     stack: [] as string[],
@@ -134,7 +136,7 @@ export function Titlebar() {
   return (
     <header
       class="h-10 shrink-0 bg-background-base relative grid grid-cols-[auto_minmax(0,1fr)_auto] items-center"
-      data-tauri-drag-region
+      style={{ "min-height": minHeight() }}
     >
       <div
         classList={{
@@ -142,10 +144,9 @@ export function Titlebar() {
           "pl-2": !mac(),
         }}
         onMouseDown={drag}
-        data-tauri-drag-region
       >
         <Show when={mac()}>
-          <div class="w-[72px] h-full shrink-0" data-tauri-drag-region />
+          <div class="h-full shrink-0" style={{ width: `${72 / zoom()}px` }} />
           <div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
             <IconButton
               icon="menu"
@@ -219,13 +220,10 @@ export function Titlebar() {
             </Tooltip>
           </div>
         </div>
-        <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" data-tauri-drag-region />
+        <div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
       </div>
 
-      <div
-        class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center"
-        data-tauri-drag-region
-      >
+      <div class="min-w-0 flex items-center justify-center pointer-events-none lg:absolute lg:inset-0 lg:flex lg:items-center lg:justify-center">
         <div id="opencode-titlebar-center" class="pointer-events-auto w-full min-w-0 flex justify-center lg:w-fit" />
       </div>
 
@@ -235,9 +233,8 @@ export function Titlebar() {
           "pr-6": !windows(),
         }}
         onMouseDown={drag}
-        data-tauri-drag-region
       >
-        <div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" data-tauri-drag-region />
+        <div id="opencode-titlebar-right" class="flex items-center gap-3 shrink-0 justify-end" />
         <Show when={windows()}>
           <div class="w-6 shrink-0" />
           <div data-tauri-decorum-tb class="flex flex-row" />

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

@@ -119,6 +119,8 @@ type ChildOptions = {
   bootstrap?: boolean
 }
 
+const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+
 function normalizeProviderList(input: ProviderListResponse): ProviderListResponse {
   return {
     ...input,
@@ -297,7 +299,7 @@ function createGlobalSync() {
     const aUpdated = sessionUpdatedAt(a)
     const bUpdated = sessionUpdatedAt(b)
     if (aUpdated !== bUpdated) return bUpdated - aUpdated
-    return a.id.localeCompare(b.id)
+    return cmp(a.id, b.id)
   }
 
   function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) {
@@ -325,7 +327,7 @@ function createGlobalSync() {
     const all = input
       .filter((s) => !!s?.id)
       .filter((s) => !s.time?.archived)
-      .sort((a, b) => a.id.localeCompare(b.id))
+      .sort((a, b) => cmp(a.id, b.id))
 
     const roots = all.filter((s) => !s.parentID)
     const children = all.filter((s) => !!s.parentID)
@@ -342,7 +344,7 @@ function createGlobalSync() {
       return sessionUpdatedAt(s) > cutoff
     })
 
-    return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id))
+    return [...keepRoots, ...keepChildren].sort((a, b) => cmp(a.id, b.id))
   }
 
   function ensureChild(directory: string) {
@@ -457,7 +459,7 @@ function createGlobalSync() {
         const nonArchived = (x.data ?? [])
           .filter((s) => !!s?.id)
           .filter((s) => !s.time?.archived)
-          .sort((a, b) => a.id.localeCompare(b.id))
+          .sort((a, b) => cmp(a.id, b.id))
 
         // Read the current limit at resolve-time so callers that bump the limit while
         // a request is in-flight still get the expanded result.
@@ -559,7 +561,7 @@ function createGlobalSync() {
                 "permission",
                 sessionID,
                 reconcile(
-                  permissions.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
+                  permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
                   { key: "id" },
                 ),
               )
@@ -588,7 +590,7 @@ function createGlobalSync() {
                 "question",
                 sessionID,
                 reconcile(
-                  questions.filter((q) => !!q?.id).sort((a, b) => a.id.localeCompare(b.id)),
+                  questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
                   { key: "id" },
                 ),
               )
@@ -1003,7 +1005,7 @@ function createGlobalSync() {
             .filter((p) => !!p?.id)
             .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test"))
             .slice()
-            .sort((a, b) => a.id.localeCompare(b.id))
+            .sort((a, b) => cmp(a.id, b.id))
           setGlobalStore("project", projects)
         }),
       ),

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

@@ -1,5 +1,6 @@
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
+import type { Accessor } from "solid-js"
 
 export type Platform = {
   /** Platform discriminator */
@@ -55,6 +56,9 @@ export type Platform = {
 
   /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */
   parseMarkdown?(markdown: string): Promise<string>
+
+  /** Webview zoom level (desktop only) */
+  webviewZoom?: Accessor<number>
 }
 
 export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({

+ 6 - 4
packages/app/src/context/sync.tsx

@@ -9,6 +9,8 @@ import type { Message, Part } from "@opencode-ai/sdk/v2/client"
 
 const keyFor = (directory: string, id: string) => `${directory}\n${id}`
 
+const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0)
+
 export const { use: useSync, provider: SyncProvider } = createSimpleContext({
   name: "Sync",
   init: () => {
@@ -59,7 +61,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           const next = items
             .map((x) => x.info)
             .filter((m) => !!m?.id)
-            .sort((a, b) => a.id.localeCompare(b.id))
+            .sort((a, b) => cmp(a.id, b.id))
 
           batch(() => {
             input.setStore("message", input.sessionID, reconcile(next, { key: "id" }))
@@ -69,7 +71,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
                 "part",
                 message.info.id,
                 reconcile(
-                  message.parts.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id)),
+                  message.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
                   { key: "id" },
                 ),
               )
@@ -129,7 +131,7 @@ 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.filter((p) => !!p?.id).sort((a, b) => a.id.localeCompare(b.id))
+              draft.part[input.messageID] = input.parts.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id))
             }),
           )
         },
@@ -271,7 +273,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
           await client.session.list().then((x) => {
             const sessions = (x.data ?? [])
               .filter((s) => !!s?.id)
-              .sort((a, b) => a.id.localeCompare(b.id))
+              .sort((a, b) => cmp(a.id, b.id))
               .slice(0, store.limit)
             setStore("session", reconcile(sessions, { key: "id" }))
           })

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

@@ -28,6 +28,8 @@ export const dict = {
   "command.settings.open": "فتح الإعدادات",
   "command.session.previous": "الجلسة السابقة",
   "command.session.next": "الجلسة التالية",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "أرشفة الجلسة",
 
   "command.palette": "لوحة الأوامر",
@@ -68,6 +70,7 @@ export const dict = {
   "command.model.variant.cycle.description": "التبديل إلى مستوى الجهد التالي",
   "command.permissions.autoaccept.enable": "قبول التعديلات تلقائيًا",
   "command.permissions.autoaccept.disable": "إيقاف قبول التعديلات تلقائيًا",
+  "command.workspace.toggle": "تبديل مساحات العمل",
   "command.session.undo": "تراجع",
   "command.session.undo.description": "تراجع عن الرسالة الأخيرة",
   "command.session.redo": "إعادة",
@@ -346,6 +349,11 @@ export const dict = {
   "toast.permissions.autoaccept.off.title": "توقف قبول التعديلات تلقائيًا",
   "toast.permissions.autoaccept.off.description": "ستتطلب أذونات التحرير والكتابة موافقة",
 
+  "toast.workspace.enabled.title": "تم تمكين مساحات العمل",
+  "toast.workspace.enabled.description": "الآن يتم عرض عدة worktrees في الشريط الجانبي",
+  "toast.workspace.disabled.title": "تم تعطيل مساحات العمل",
+  "toast.workspace.disabled.description": "يتم عرض worktree الرئيسي فقط في الشريط الجانبي",
+
   "toast.model.none.title": "لم يتم تحديد نموذج",
   "toast.model.none.description": "قم بتوصيل موفر لتلخيص هذه الجلسة",
 

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

@@ -28,6 +28,8 @@ export const dict = {
   "command.settings.open": "Abrir configurações",
   "command.session.previous": "Sessão anterior",
   "command.session.next": "Próxima sessão",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "Arquivar sessão",
 
   "command.palette": "Paleta de comandos",
@@ -68,6 +70,7 @@ export const dict = {
   "command.model.variant.cycle.description": "Mudar para o próximo nível de esforço",
   "command.permissions.autoaccept.enable": "Aceitar edições automaticamente",
   "command.permissions.autoaccept.disable": "Parar de aceitar edições automaticamente",
+  "command.workspace.toggle": "Alternar espaços de trabalho",
   "command.session.undo": "Desfazer",
   "command.session.undo.description": "Desfazer a última mensagem",
   "command.session.redo": "Refazer",
@@ -345,6 +348,11 @@ export const dict = {
   "toast.permissions.autoaccept.off.title": "Parou de aceitar edições automaticamente",
   "toast.permissions.autoaccept.off.description": "Permissões de edição e escrita exigirão aprovação",
 
+  "toast.workspace.enabled.title": "Espaços de trabalho ativados",
+  "toast.workspace.enabled.description": "Várias worktrees agora são exibidas na barra lateral",
+  "toast.workspace.disabled.title": "Espaços de trabalho desativados",
+  "toast.workspace.disabled.description": "Apenas a worktree principal é exibida na barra lateral",
+
   "toast.model.none.title": "Nenhum modelo selecionado",
   "toast.model.none.description": "Conecte um provedor para resumir esta sessão",
 

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

@@ -28,6 +28,8 @@ export const dict = {
   "command.settings.open": "Åbn indstillinger",
   "command.session.previous": "Forrige session",
   "command.session.next": "Næste session",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "Arkivér session",
 
   "command.palette": "Kommandopalette",
@@ -68,6 +70,7 @@ export const dict = {
   "command.model.variant.cycle.description": "Skift til næste indsatsniveau",
   "command.permissions.autoaccept.enable": "Accepter ændringer automatisk",
   "command.permissions.autoaccept.disable": "Stop automatisk accept af ændringer",
+  "command.workspace.toggle": "Skift arbejdsområder",
   "command.session.undo": "Fortryd",
   "command.session.undo.description": "Fortryd den sidste besked",
   "command.session.redo": "Omgør",
@@ -347,6 +350,11 @@ export const dict = {
   "toast.permissions.autoaccept.off.title": "Stoppede automatisk accept af ændringer",
   "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetilladelser vil kræve godkendelse",
 
+  "toast.workspace.enabled.title": "Arbejdsområder aktiveret",
+  "toast.workspace.enabled.description": "Flere worktrees vises nu i sidepanelet",
+  "toast.workspace.disabled.title": "Arbejdsområder deaktiveret",
+  "toast.workspace.disabled.description": "Kun hoved-worktree vises i sidepanelet",
+
   "toast.model.none.title": "Ingen model valgt",
   "toast.model.none.description": "Forbind en udbyder for at opsummere denne session",
 

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

@@ -32,6 +32,8 @@ export const dict = {
   "command.settings.open": "Einstellungen öffnen",
   "command.session.previous": "Vorherige Sitzung",
   "command.session.next": "Nächste Sitzung",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "Sitzung archivieren",
 
   "command.palette": "Befehlspalette",
@@ -72,6 +74,7 @@ export const dict = {
   "command.model.variant.cycle.description": "Zum nächsten Aufwandslevel wechseln",
   "command.permissions.autoaccept.enable": "Änderungen automatisch akzeptieren",
   "command.permissions.autoaccept.disable": "Automatische Annahme von Änderungen stoppen",
+  "command.workspace.toggle": "Arbeitsbereiche umschalten",
   "command.session.undo": "Rückgängig",
   "command.session.undo.description": "Letzte Nachricht rückgängig machen",
   "command.session.redo": "Wiederherstellen",

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

@@ -28,6 +28,8 @@ export const dict = {
   "command.settings.open": "Open settings",
   "command.session.previous": "Previous session",
   "command.session.next": "Next session",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "Archive session",
 
   "command.palette": "Command palette",
@@ -43,6 +45,7 @@ export const dict = {
   "command.session.new": "New session",
   "command.file.open": "Open file",
   "command.file.open.description": "Search files and commands",
+  "command.tab.close": "Close tab",
   "command.context.addSelection": "Add selection to context",
   "command.context.addSelection.description": "Add selected lines from the current file",
   "command.terminal.toggle": "Toggle terminal",
@@ -68,6 +71,8 @@ export const dict = {
   "command.model.variant.cycle.description": "Switch to the next effort level",
   "command.permissions.autoaccept.enable": "Auto-accept edits",
   "command.permissions.autoaccept.disable": "Stop auto-accepting edits",
+  "command.workspace.toggle": "Toggle workspaces",
+  "command.workspace.toggle.description": "Enable or disable multiple workspaces in the sidebar",
   "command.session.undo": "Undo",
   "command.session.undo.description": "Undo the last message",
   "command.session.redo": "Redo",
@@ -347,6 +352,11 @@ export const dict = {
   "toast.theme.title": "Theme switched",
   "toast.scheme.title": "Color scheme",
 
+  "toast.workspace.enabled.title": "Workspaces enabled",
+  "toast.workspace.enabled.description": "Multiple worktrees are now shown in the sidebar",
+  "toast.workspace.disabled.title": "Workspaces disabled",
+  "toast.workspace.disabled.description": "Only the main worktree is shown in the sidebar",
+
   "toast.permissions.autoaccept.on.title": "Auto-accepting edits",
   "toast.permissions.autoaccept.on.description": "Edit and write permissions will be automatically approved",
   "toast.permissions.autoaccept.off.title": "Stopped auto-accepting edits",

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

@@ -28,6 +28,8 @@ export const dict = {
   "command.settings.open": "Abrir ajustes",
   "command.session.previous": "Sesión anterior",
   "command.session.next": "Siguiente sesión",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "Archivar sesión",
 
   "command.palette": "Paleta de comandos",
@@ -68,6 +70,7 @@ export const dict = {
   "command.model.variant.cycle.description": "Cambiar al siguiente nivel de esfuerzo",
   "command.permissions.autoaccept.enable": "Aceptar ediciones automáticamente",
   "command.permissions.autoaccept.disable": "Dejar de aceptar ediciones automáticamente",
+  "command.workspace.toggle": "Alternar espacios de trabajo",
   "command.session.undo": "Deshacer",
   "command.session.undo.description": "Deshacer el último mensaje",
   "command.session.redo": "Rehacer",
@@ -348,6 +351,11 @@ export const dict = {
   "toast.permissions.autoaccept.off.title": "Se dejó de aceptar ediciones automáticamente",
   "toast.permissions.autoaccept.off.description": "Los permisos de edición y escritura requerirán aprobación",
 
+  "toast.workspace.enabled.title": "Espacios de trabajo habilitados",
+  "toast.workspace.enabled.description": "Ahora se muestran varios worktrees en la barra lateral",
+  "toast.workspace.disabled.title": "Espacios de trabajo deshabilitados",
+  "toast.workspace.disabled.description": "Solo se muestra el worktree principal en la barra lateral",
+
   "toast.model.none.title": "Ningún modelo seleccionado",
   "toast.model.none.description": "Conecta un proveedor para resumir esta sesión",
 

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

@@ -28,6 +28,8 @@ export const dict = {
   "command.settings.open": "Ouvrir les paramètres",
   "command.session.previous": "Session précédente",
   "command.session.next": "Session suivante",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "Archiver la session",
 
   "command.palette": "Palette de commandes",
@@ -68,6 +70,7 @@ export const dict = {
   "command.model.variant.cycle.description": "Passer au niveau d'effort suivant",
   "command.permissions.autoaccept.enable": "Accepter automatiquement les modifications",
   "command.permissions.autoaccept.disable": "Arrêter l'acceptation automatique des modifications",
+  "command.workspace.toggle": "Basculer les espaces de travail",
   "command.session.undo": "Annuler",
   "command.session.undo.description": "Annuler le dernier message",
   "command.session.redo": "Rétablir",
@@ -350,6 +353,11 @@ export const dict = {
   "toast.permissions.autoaccept.off.description":
     "Les permissions de modification et d'écriture nécessiteront une approbation",
 
+  "toast.workspace.enabled.title": "Espaces de travail activés",
+  "toast.workspace.enabled.description": "Plusieurs worktrees sont désormais affichés dans la barre latérale",
+  "toast.workspace.disabled.title": "Espaces de travail désactivés",
+  "toast.workspace.disabled.description": "Seul le worktree principal est affiché dans la barre latérale",
+
   "toast.model.none.title": "Aucun modèle sélectionné",
   "toast.model.none.description": "Connectez un fournisseur pour résumer cette session",
 

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

@@ -28,6 +28,8 @@ export const dict = {
   "command.settings.open": "設定を開く",
   "command.session.previous": "前のセッション",
   "command.session.next": "次のセッション",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "セッションをアーカイブ",
 
   "command.palette": "コマンドパレット",
@@ -68,6 +70,7 @@ export const dict = {
   "command.model.variant.cycle.description": "次の思考レベルに切り替え",
   "command.permissions.autoaccept.enable": "編集を自動承認",
   "command.permissions.autoaccept.disable": "編集の自動承認を停止",
+  "command.workspace.toggle": "ワークスペースを切り替え",
   "command.session.undo": "元に戻す",
   "command.session.undo.description": "最後のメッセージを元に戻す",
   "command.session.redo": "やり直す",

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

@@ -32,6 +32,8 @@ export const dict = {
   "command.settings.open": "설정 열기",
   "command.session.previous": "이전 세션",
   "command.session.next": "다음 세션",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "세션 보관",
 
   "command.palette": "명령 팔레트",
@@ -72,6 +74,7 @@ export const dict = {
   "command.model.variant.cycle.description": "다음 생각 수준으로 전환",
   "command.permissions.autoaccept.enable": "편집 자동 수락",
   "command.permissions.autoaccept.disable": "편집 자동 수락 중지",
+  "command.workspace.toggle": "작업 공간 전환",
   "command.session.undo": "실행 취소",
   "command.session.undo.description": "마지막 메시지 실행 취소",
   "command.session.redo": "다시 실행",
@@ -349,6 +352,11 @@ export const dict = {
   "toast.permissions.autoaccept.off.title": "편집 자동 수락 중지됨",
   "toast.permissions.autoaccept.off.description": "편집 및 쓰기 권한 승인이 필요합니다",
 
+  "toast.workspace.enabled.title": "작업 공간 활성화됨",
+  "toast.workspace.enabled.description": "이제 사이드바에 여러 작업 트리가 표시됩니다",
+  "toast.workspace.disabled.title": "작업 공간 비활성화됨",
+  "toast.workspace.disabled.description": "사이드바에 메인 작업 트리만 표시됩니다",
+
   "toast.model.none.title": "선택된 모델 없음",
   "toast.model.none.description": "이 세션을 요약하려면 공급자를 연결하세요",
 

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

@@ -31,6 +31,8 @@ export const dict = {
   "command.settings.open": "Åpne innstillinger",
   "command.session.previous": "Forrige sesjon",
   "command.session.next": "Neste sesjon",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "Arkiver sesjon",
 
   "command.palette": "Kommandopalett",
@@ -71,6 +73,7 @@ export const dict = {
   "command.model.variant.cycle.description": "Bytt til neste innsatsnivå",
   "command.permissions.autoaccept.enable": "Godta endringer automatisk",
   "command.permissions.autoaccept.disable": "Slutt å godta endringer automatisk",
+  "command.workspace.toggle": "Veksle arbeidsområder",
   "command.session.undo": "Angre",
   "command.session.undo.description": "Angre siste melding",
   "command.session.redo": "Gjør om",
@@ -349,6 +352,11 @@ export const dict = {
   "toast.permissions.autoaccept.off.title": "Sluttet å godta endringer automatisk",
   "toast.permissions.autoaccept.off.description": "Redigerings- og skrivetillatelser vil kreve godkjenning",
 
+  "toast.workspace.enabled.title": "Arbeidsområder aktivert",
+  "toast.workspace.enabled.description": "Flere worktrees vises nå i sidefeltet",
+  "toast.workspace.disabled.title": "Arbeidsområder deaktivert",
+  "toast.workspace.disabled.description": "Kun hoved-worktree vises i sidefeltet",
+
   "toast.model.none.title": "Ingen modell valgt",
   "toast.model.none.description": "Koble til en leverandør for å oppsummere denne sesjonen",
 

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

@@ -28,6 +28,8 @@ export const dict = {
   "command.settings.open": "Otwórz ustawienia",
   "command.session.previous": "Poprzednia sesja",
   "command.session.next": "Następna sesja",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "Zarchiwizuj sesję",
 
   "command.palette": "Paleta poleceń",
@@ -68,6 +70,7 @@ export const dict = {
   "command.model.variant.cycle.description": "Przełącz na następny poziom wysiłku",
   "command.permissions.autoaccept.enable": "Automatyczne akceptowanie edycji",
   "command.permissions.autoaccept.disable": "Zatrzymaj automatyczne akceptowanie edycji",
+  "command.workspace.toggle": "Przełącz przestrzenie robocze",
   "command.session.undo": "Cofnij",
   "command.session.undo.description": "Cofnij ostatnią wiadomość",
   "command.session.redo": "Ponów",
@@ -347,6 +350,11 @@ export const dict = {
   "toast.permissions.autoaccept.off.title": "Zatrzymano automatyczne akceptowanie edycji",
   "toast.permissions.autoaccept.off.description": "Uprawnienia do edycji i zapisu będą wymagały zatwierdzenia",
 
+  "toast.workspace.enabled.title": "Przestrzenie robocze włączone",
+  "toast.workspace.enabled.description": "Kilka worktree jest teraz wyświetlanych na pasku bocznym",
+  "toast.workspace.disabled.title": "Przestrzenie robocze wyłączone",
+  "toast.workspace.disabled.description": "Tylko główny worktree jest wyświetlany na pasku bocznym",
+
   "toast.model.none.title": "Nie wybrano modelu",
   "toast.model.none.description": "Połącz dostawcę, aby podsumować tę sesję",
 

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

@@ -28,6 +28,8 @@ export const dict = {
   "command.settings.open": "Открыть настройки",
   "command.session.previous": "Предыдущая сессия",
   "command.session.next": "Следующая сессия",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "Архивировать сессию",
 
   "command.palette": "Палитра команд",
@@ -68,6 +70,7 @@ export const dict = {
   "command.model.variant.cycle.description": "Переключиться к следующему уровню усилий",
   "command.permissions.autoaccept.enable": "Авто-принятие изменений",
   "command.permissions.autoaccept.disable": "Прекратить авто-принятие изменений",
+  "command.workspace.toggle": "Переключить рабочие пространства",
   "command.session.undo": "Отменить",
   "command.session.undo.description": "Отменить последнее сообщение",
   "command.session.redo": "Повторить",
@@ -348,6 +351,11 @@ export const dict = {
   "toast.permissions.autoaccept.off.title": "Авто-принятие остановлено",
   "toast.permissions.autoaccept.off.description": "Редактирование и запись потребуют подтверждения",
 
+  "toast.workspace.enabled.title": "Рабочие пространства включены",
+  "toast.workspace.enabled.description": "В боковой панели теперь отображаются несколько рабочих деревьев",
+  "toast.workspace.disabled.title": "Рабочие пространства отключены",
+  "toast.workspace.disabled.description": "В боковой панели отображается только главное рабочее дерево",
+
   "toast.model.none.title": "Модель не выбрана",
   "toast.model.none.description": "Подключите провайдера для суммаризации сессии",
 

+ 9 - 1
packages/app/src/i18n/th.ts

@@ -28,6 +28,8 @@ export const dict = {
   "command.settings.open": "เปิดการตั้งค่า",
   "command.session.previous": "เซสชันก่อนหน้า",
   "command.session.next": "เซสชันถัดไป",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "จัดเก็บเซสชัน",
 
   "command.palette": "คำสั่งค้นหา",
@@ -68,6 +70,7 @@ export const dict = {
   "command.model.variant.cycle.description": "สลับไปยังระดับความพยายามถัดไป",
   "command.permissions.autoaccept.enable": "ยอมรับการแก้ไขโดยอัตโนมัติ",
   "command.permissions.autoaccept.disable": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
+  "command.workspace.toggle": "สลับพื้นที่ทำงาน",
   "command.session.undo": "ยกเลิก",
   "command.session.undo.description": "ยกเลิกข้อความล่าสุด",
   "command.session.redo": "ทำซ้ำ",
@@ -347,10 +350,15 @@ export const dict = {
   "toast.scheme.title": "โทนสี",
 
   "toast.permissions.autoaccept.on.title": "กำลังยอมรับการแก้ไขโดยอัตโนมัติ",
-  "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและเขียนจะได้รับการอนุมัติโดยอัตโนมัติ",
+  "toast.permissions.autoaccept.on.description": "สิทธิ์การแก้ไขและจะได้รับเขียนการอนุมัติโดยอัตโนมัติ",
   "toast.permissions.autoaccept.off.title": "หยุดยอมรับการแก้ไขโดยอัตโนมัติ",
   "toast.permissions.autoaccept.off.description": "สิทธิ์การแก้ไขและเขียนจะต้องได้รับการอนุมัติ",
 
+  "toast.workspace.enabled.title": "เปิดใช้งานพื้นที่ทำงานแล้ว",
+  "toast.workspace.enabled.description": "ตอนนี้จะแสดง worktree หลายรายการในแถบด้านข้าง",
+  "toast.workspace.disabled.title": "ปิดใช้งานพื้นที่ทำงานแล้ว",
+  "toast.workspace.disabled.description": "จะแสดงเฉพาะ worktree หลักในแถบด้านข้าง",
+
   "toast.model.none.title": "ไม่ได้เลือกโมเดล",
   "toast.model.none.description": "เชื่อมต่อผู้ให้บริการเพื่อสรุปเซสชันนี้",
 

+ 9 - 1
packages/app/src/i18n/zh.ts

@@ -32,6 +32,8 @@ export const dict = {
   "command.settings.open": "打开设置",
   "command.session.previous": "上一个会话",
   "command.session.next": "下一个会话",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "归档会话",
 
   "command.palette": "命令面板",
@@ -72,6 +74,7 @@ export const dict = {
   "command.model.variant.cycle.description": "切换到下一个强度等级",
   "command.permissions.autoaccept.enable": "自动接受编辑",
   "command.permissions.autoaccept.disable": "停止自动接受编辑",
+  "command.workspace.toggle": "切换工作区",
   "command.session.undo": "撤销",
   "command.session.undo.description": "撤销上一条消息",
   "command.session.redo": "重做",
@@ -342,7 +345,12 @@ export const dict = {
   "toast.language.description": "已切换到{{language}}",
 
   "toast.theme.title": "主题已切换",
-  "toast.scheme.title": "配色方案",
+  "toast.scheme.title": "颜色方案",
+
+  "toast.workspace.enabled.title": "工作区已启用",
+  "toast.workspace.enabled.description": "侧边栏现在显示多个工作树",
+  "toast.workspace.disabled.title": "工作区已禁用",
+  "toast.workspace.disabled.description": "侧边栏只显示主工作树",
 
   "toast.permissions.autoaccept.on.title": "自动接受编辑",
   "toast.permissions.autoaccept.on.description": "编辑和写入权限将自动获批",

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

@@ -32,6 +32,8 @@ export const dict = {
   "command.settings.open": "開啟設定",
   "command.session.previous": "上一個工作階段",
   "command.session.next": "下一個工作階段",
+  "command.session.previous.unseen": "Previous unread session",
+  "command.session.next.unseen": "Next unread session",
   "command.session.archive": "封存工作階段",
 
   "command.palette": "命令面板",
@@ -72,6 +74,7 @@ export const dict = {
   "command.model.variant.cycle.description": "切換到下一個強度等級",
   "command.permissions.autoaccept.enable": "自動接受編輯",
   "command.permissions.autoaccept.disable": "停止自動接受編輯",
+  "command.workspace.toggle": "切換工作區",
   "command.session.undo": "復原",
   "command.session.undo.description": "復原上一則訊息",
   "command.session.redo": "重做",
@@ -339,7 +342,12 @@ export const dict = {
   "toast.language.description": "已切換到 {{language}}",
 
   "toast.theme.title": "主題已切換",
-  "toast.scheme.title": "配色方案",
+  "toast.scheme.title": "顏色方案",
+
+  "toast.workspace.enabled.title": "工作區已啟用",
+  "toast.workspace.enabled.description": "側邊欄現在顯示多個工作樹",
+  "toast.workspace.disabled.title": "工作區已停用",
+  "toast.workspace.disabled.description": "側邊欄只顯示主工作樹",
 
   "toast.permissions.autoaccept.on.title": "自動接受編輯",
   "toast.permissions.autoaccept.on.description": "編輯和寫入權限將自動獲准",

+ 537 - 150
packages/app/src/pages/layout.tsx

@@ -27,10 +27,12 @@ import { Button } from "@opencode-ai/ui/button"
 import { Icon } from "@opencode-ai/ui/icon"
 import { IconButton } from "@opencode-ai/ui/icon-button"
 import { InlineInput } from "@opencode-ai/ui/inline-input"
+import { List, type ListRef } from "@opencode-ai/ui/list"
 import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
 import { HoverCard } from "@opencode-ai/ui/hover-card"
 import { MessageNav } from "@opencode-ai/ui/message-nav"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { ContextMenu } from "@opencode-ai/ui/context-menu"
 import { Collapsible } from "@opencode-ai/ui/collapsible"
 import { DiffChanges } from "@opencode-ai/ui/diff-changes"
 import { Spinner } from "@opencode-ai/ui/spinner"
@@ -108,7 +110,7 @@ export default function Layout(props: ParentProps) {
   const command = useCommand()
   const theme = useTheme()
   const language = useLanguage()
-  const initialDir = params.dir
+  const initialDirectory = decode64(params.dir)
   const availableThemeEntries = createMemo(() => Object.entries(theme.themes()))
   const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"]
   const colorSchemeKey: Record<ColorScheme, "theme.scheme.system" | "theme.scheme.light" | "theme.scheme.dark"> = {
@@ -119,7 +121,7 @@ export default function Layout(props: ParentProps) {
   const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme])
 
   const [state, setState] = createStore({
-    autoselect: !params.dir,
+    autoselect: !initialDirectory,
     busyWorkspaces: new Set<string>(),
     hoverSession: undefined as string | undefined,
     hoverProject: undefined as string | undefined,
@@ -179,13 +181,21 @@ export default function Layout(props: ParentProps) {
 
   const autoselecting = createMemo(() => {
     if (params.dir) return false
-    if (initialDir) return false
     if (!state.autoselect) return false
     if (!pageReady()) return true
     if (!layoutReady()) return true
     const list = layout.projects.list()
-    if (list.length === 0) return false
-    return true
+    if (list.length > 0) return true
+    return !!server.projects.last()
+  })
+
+  createEffect(() => {
+    if (!state.autoselect) return
+    const dir = params.dir
+    if (!dir) return
+    const directory = decode64(dir)
+    if (!directory) return
+    setState("autoselect", false)
   })
 
   const editorOpen = (id: string) => editor.active === id
@@ -498,7 +508,7 @@ export default function Layout(props: ParentProps) {
       const bUpdated = b.time.updated ?? b.time.created
       const aRecent = aUpdated > oneMinuteAgo
       const bRecent = bUpdated > oneMinuteAgo
-      if (aRecent && bRecent) return a.id.localeCompare(b.id)
+      if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
       if (aRecent && !bRecent) return -1
       if (!aRecent && bRecent) return 1
       return bUpdated - aUpdated
@@ -565,11 +575,18 @@ export default function Layout(props: ParentProps) {
         if (!value.ready) return
         if (!value.layoutReady) return
         if (!state.autoselect) return
-        if (initialDir) return
         if (value.dir) return
-        if (value.list.length === 0) return
 
         const last = server.projects.last()
+
+        if (value.list.length === 0) {
+          if (!last) return
+          setState("autoselect", false)
+          openProject(last, false)
+          navigateToProject(last)
+          return
+        }
+
         const next = value.list.find((project) => project.worktree === last) ?? value.list[0]
         if (!next) return
         setState("autoselect", false)
@@ -738,7 +755,7 @@ export default function Layout(props: ParentProps) {
   }
 
   async function prefetchMessages(directory: string, sessionID: string, token: number) {
-    const [, setStore] = globalSync.child(directory, { bootstrap: false })
+    const [store, setStore] = globalSync.child(directory, { bootstrap: false })
 
     return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
       .then((messages) => {
@@ -749,23 +766,49 @@ export default function Layout(props: ParentProps) {
           .map((x) => x.info)
           .filter((m) => !!m?.id)
           .slice()
-          .sort((a, b) => a.id.localeCompare(b.id))
+          .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+
+        const current = store.message[sessionID] ?? []
+        const merged = (() => {
+          if (current.length === 0) return next
+
+          const map = new Map<string, Message>()
+          for (const item of current) {
+            if (!item?.id) continue
+            map.set(item.id, item)
+          }
+          for (const item of next) {
+            map.set(item.id, item)
+          }
+          return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+        })()
 
         batch(() => {
-          setStore("message", sessionID, reconcile(next, { key: "id" }))
+          setStore("message", sessionID, reconcile(merged, { key: "id" }))
 
           for (const message of items) {
-            setStore(
-              "part",
-              message.info.id,
-              reconcile(
-                message.parts
+            const currentParts = store.part[message.info.id] ?? []
+            const mergedParts = (() => {
+              if (currentParts.length === 0) {
+                return message.parts
                   .filter((p) => !!p?.id)
                   .slice()
-                  .sort((a, b) => a.id.localeCompare(b.id)),
-                { key: "id" },
-              ),
-            )
+                  .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+              }
+
+              const map = new Map<string, (typeof currentParts)[number]>()
+              for (const item of currentParts) {
+                if (!item?.id) continue
+                map.set(item.id, item)
+              }
+              for (const item of message.parts) {
+                if (!item?.id) continue
+                map.set(item.id, item)
+              }
+              return [...map.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
+            })()
+
+            setStore("part", message.info.id, reconcile(mergedParts, { key: "id" }))
           }
         })
       })
@@ -886,6 +929,52 @@ export default function Layout(props: ParentProps) {
     queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
   }
 
+  function navigateSessionByUnseen(offset: number) {
+    const sessions = currentSessions()
+    if (sessions.length === 0) return
+
+    const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0)
+    if (!hasUnseen) return
+
+    const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
+    const start = activeIndex === -1 ? (offset > 0 ? -1 : 0) : activeIndex
+
+    for (let i = 1; i <= sessions.length; i++) {
+      const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length
+      const session = sessions[index]
+      if (!session) continue
+      if (notification.session.unseen(session.id).length === 0) continue
+
+      prefetchSession(session, "high")
+
+      const next = sessions[(index + 1) % sessions.length]
+      const prev = sessions[(index - 1 + sessions.length) % sessions.length]
+
+      if (offset > 0) {
+        if (next) prefetchSession(next, "high")
+        if (prev) prefetchSession(prev)
+      }
+
+      if (offset < 0) {
+        if (prev) prefetchSession(prev, "high")
+        if (next) prefetchSession(next)
+      }
+
+      if (import.meta.env.DEV) {
+        navStart({
+          dir: base64Encode(session.directory),
+          from: params.id,
+          to: session.id,
+          trigger: offset > 0 ? "shift+alt+arrowdown" : "shift+alt+arrowup",
+        })
+      }
+
+      navigateToSession(session)
+      queueMicrotask(() => scrollToSession(session.id, `${session.directory}:${session.id}`))
+      return
+    }
+  }
+
   async function archiveSession(session: Session) {
     const [store, setStore] = globalSync.child(session.directory)
     const sessions = store.session ?? []
@@ -1024,6 +1113,20 @@ export default function Layout(props: ParentProps) {
         keybind: "alt+arrowdown",
         onSelect: () => navigateSessionByOffset(1),
       },
+      {
+        id: "session.previous.unseen",
+        title: language.t("command.session.previous.unseen"),
+        category: language.t("command.category.session"),
+        keybind: "shift+alt+arrowup",
+        onSelect: () => navigateSessionByUnseen(-1),
+      },
+      {
+        id: "session.next.unseen",
+        title: language.t("command.session.next.unseen"),
+        category: language.t("command.category.session"),
+        keybind: "shift+alt+arrowdown",
+        onSelect: () => navigateSessionByUnseen(1),
+      },
       {
         id: "session.archive",
         title: language.t("command.session.archive"),
@@ -1035,6 +1138,29 @@ export default function Layout(props: ParentProps) {
           if (session) archiveSession(session)
         },
       },
+      {
+        id: "workspace.toggle",
+        title: language.t("command.workspace.toggle"),
+        description: language.t("command.workspace.toggle.description"),
+        category: language.t("command.category.workspace"),
+        slash: "workspace",
+        disabled: !currentProject() || currentProject()?.vcs !== "git",
+        onSelect: () => {
+          const project = currentProject()
+          if (!project) return
+          if (project.vcs !== "git") return
+          const wasEnabled = layout.sidebar.workspaces(project.worktree)()
+          layout.sidebar.toggleWorkspaces(project.worktree)
+          showToast({
+            title: wasEnabled
+              ? language.t("toast.workspace.disabled.title")
+              : language.t("toast.workspace.enabled.title"),
+            description: wasEnabled
+              ? language.t("toast.workspace.disabled.description")
+              : language.t("toast.workspace.enabled.description"),
+          })
+        },
+      },
       {
         id: "theme.cycle",
         title: language.t("command.theme.cycle"),
@@ -2250,10 +2376,13 @@ export default function Layout(props: ParentProps) {
       () => props.project.vcs === "git" && layout.sidebar.workspaces(props.project.worktree)(),
     )
     const [open, setOpen] = createSignal(false)
+    const [menu, setMenu] = createSignal(false)
 
     const preview = createMemo(() => !props.mobile && layout.sidebar.opened())
     const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened())
-    const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree))
+    const active = createMemo(
+      () => menu() || (preview() ? open() : overlay() && state.hoverProject === props.project.worktree),
+    )
 
     createEffect(() => {
       if (preview()) return
@@ -2291,50 +2420,95 @@ export default function Layout(props: ParentProps) {
     }
 
     const projectName = () => props.project.name || getFilename(props.project.worktree)
-    const trigger = (
-      <button
-        type="button"
-        aria-label={projectName()}
-        data-action="project-switch"
-        data-project={base64Encode(props.project.worktree)}
-        classList={{
-          "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
-          "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
-          "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
-            !selected() && !active(),
-          "bg-surface-base-hover border border-border-weak-base": !selected() && active(),
+    const Trigger = () => (
+      <ContextMenu
+        modal={!sidebarHovering()}
+        onOpenChange={(value) => {
+          setMenu(value)
+          if (value) setOpen(false)
         }}
-        onMouseEnter={() => {
-          if (!overlay()) return
-          globalSync.child(props.project.worktree)
-          setState("hoverProject", props.project.worktree)
-          setState("hoverSession", undefined)
-        }}
-        onFocus={() => {
-          if (!overlay()) return
-          globalSync.child(props.project.worktree)
-          setState("hoverProject", props.project.worktree)
-          setState("hoverSession", undefined)
-        }}
-        onClick={() => navigateToProject(props.project.worktree)}
-        onBlur={() => setOpen(false)}
       >
-        <ProjectIcon project={props.project} notify />
-      </button>
+        <ContextMenu.Trigger
+          as="button"
+          type="button"
+          aria-label={projectName()}
+          data-action="project-switch"
+          data-project={base64Encode(props.project.worktree)}
+          classList={{
+            "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true,
+            "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(),
+            "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
+              !selected() && !active(),
+            "bg-surface-base-hover border border-border-weak-base": !selected() && active(),
+          }}
+          onMouseEnter={() => {
+            if (!overlay()) return
+            globalSync.child(props.project.worktree)
+            setState("hoverProject", props.project.worktree)
+            setState("hoverSession", undefined)
+          }}
+          onFocus={() => {
+            if (!overlay()) return
+            globalSync.child(props.project.worktree)
+            setState("hoverProject", props.project.worktree)
+            setState("hoverSession", undefined)
+          }}
+          onClick={() => navigateToProject(props.project.worktree)}
+          onBlur={() => setOpen(false)}
+        >
+          <ProjectIcon project={props.project} notify />
+        </ContextMenu.Trigger>
+        <ContextMenu.Portal mount={!props.mobile ? state.nav : undefined}>
+          <ContextMenu.Content>
+            <ContextMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={props.project} />)}>
+              <ContextMenu.ItemLabel>{language.t("common.edit")}</ContextMenu.ItemLabel>
+            </ContextMenu.Item>
+            <ContextMenu.Item
+              data-action="project-workspaces-toggle"
+              data-project={base64Encode(props.project.worktree)}
+              disabled={props.project.vcs !== "git" && !layout.sidebar.workspaces(props.project.worktree)()}
+              onSelect={() => {
+                const enabled = layout.sidebar.workspaces(props.project.worktree)()
+                if (enabled) {
+                  layout.sidebar.toggleWorkspaces(props.project.worktree)
+                  return
+                }
+                if (props.project.vcs !== "git") return
+                layout.sidebar.toggleWorkspaces(props.project.worktree)
+              }}
+            >
+              <ContextMenu.ItemLabel>
+                {layout.sidebar.workspaces(props.project.worktree)()
+                  ? language.t("sidebar.workspaces.disable")
+                  : language.t("sidebar.workspaces.enable")}
+              </ContextMenu.ItemLabel>
+            </ContextMenu.Item>
+            <ContextMenu.Separator />
+            <ContextMenu.Item
+              data-action="project-close-menu"
+              data-project={base64Encode(props.project.worktree)}
+              onSelect={() => closeProject(props.project.worktree)}
+            >
+              <ContextMenu.ItemLabel>{language.t("common.close")}</ContextMenu.ItemLabel>
+            </ContextMenu.Item>
+          </ContextMenu.Content>
+        </ContextMenu.Portal>
+      </ContextMenu>
     )
 
     return (
       // @ts-ignore
       <div use:sortable classList={{ "opacity-30": sortable.isActiveDraggable }}>
-        <Show when={preview()} fallback={trigger}>
+        <Show when={preview()} fallback={<Trigger />}>
           <HoverCard
-            open={open()}
+            open={open() && !menu()}
             openDelay={0}
             closeDelay={0}
             placement="right-start"
             gutter={6}
-            trigger={trigger}
+            trigger={<Trigger />}
             onOpenChange={(value) => {
+              if (menu()) return
               setOpen(value)
               if (value) setState("hoverSession", undefined)
             }}
@@ -2532,6 +2706,14 @@ export default function Layout(props: ParentProps) {
   }
 
   const SidebarPanel = (panelProps: { project: LocalProject | undefined; mobile?: boolean }) => {
+    type SearchItem = {
+      id: string
+      title: string
+      directory: string
+      label: string
+      archived?: number
+    }
+
     const projectName = createMemo(() => {
       const project = panelProps.project
       if (!project) return ""
@@ -2547,6 +2729,107 @@ export default function Layout(props: ParentProps) {
     })
     const homedir = createMemo(() => globalSync.data.path.home)
 
+    const [search, setSearch] = createStore({
+      value: "",
+    })
+    const searching = createMemo(() => search.value.trim().length > 0)
+    let searchRef: HTMLInputElement | undefined
+    let listRef: ListRef | undefined
+
+    const token = { value: 0 }
+    let inflight: Promise<SearchItem[]> | undefined
+    let all: SearchItem[] | undefined
+
+    const reset = () => {
+      token.value += 1
+      inflight = undefined
+      all = undefined
+      setSearch({ value: "" })
+      listRef = undefined
+    }
+
+    const open = (item: SearchItem | undefined) => {
+      if (!item) return
+
+      const href = `/${base64Encode(item.directory)}/session/${item.id}`
+      if (!layout.sidebar.opened()) {
+        setState("hoverSession", undefined)
+        setState("hoverProject", undefined)
+      }
+      reset()
+      navigate(href)
+      layout.mobileSidebar.hide()
+    }
+
+    const items = (filter: string) => {
+      const query = filter.trim()
+      if (!query) {
+        token.value += 1
+        inflight = undefined
+        all = undefined
+        return [] as SearchItem[]
+      }
+
+      const project = panelProps.project
+      if (!project) return [] as SearchItem[]
+      if (all) return all
+      if (inflight) return inflight
+
+      const current = token.value
+      const dirs = workspaceIds(project)
+      inflight = Promise.all(
+        dirs.map((input) => {
+          const directory = workspaceKey(input)
+          const [workspaceStore] = globalSync.child(directory, { bootstrap: false })
+          const kind =
+            directory === project.worktree ? language.t("workspace.type.local") : language.t("workspace.type.sandbox")
+          const name = workspaceLabel(directory, workspaceStore.vcs?.branch, project.id)
+          const label = `${kind} : ${name}`
+          return globalSDK.client.session
+            .list({ directory, roots: true })
+            .then((x) =>
+              (x.data ?? [])
+                .filter((s) => !!s?.id)
+                .map((s) => ({
+                  id: s.id,
+                  title: s.title ?? language.t("command.session.new"),
+                  directory,
+                  label,
+                  archived: s.time?.archived,
+                })),
+            )
+            .catch(() => [] as SearchItem[])
+        }),
+      )
+        .then((results) => {
+          if (token.value !== current) return [] as SearchItem[]
+
+          const seen = new Set<string>()
+          const next = results.flat().filter((item) => {
+            const key = `${item.directory}:${item.id}`
+            if (seen.has(key)) return false
+            seen.add(key)
+            return true
+          })
+          all = next
+          return next
+        })
+        .catch(() => [] as SearchItem[])
+        .finally(() => {
+          inflight = undefined
+        })
+
+      return inflight
+    }
+
+    createEffect(
+      on(
+        () => panelProps.project?.worktree,
+        () => reset(),
+        { defer: true },
+      ),
+    )
+
     return (
       <div
         classList={{
@@ -2555,7 +2838,7 @@ export default function Layout(props: ParentProps) {
         }}
         style={{ width: panelProps.mobile ? undefined : `${Math.max(layout.sidebar.width() - 64, 0)}px` }}
       >
-        <Show when={panelProps.project} keyed>
+        <Show when={panelProps.project}>
           {(p) => (
             <>
               <div class="shrink-0 px-2 py-1">
@@ -2564,7 +2847,7 @@ export default function Layout(props: ParentProps) {
                     <InlineEditor
                       id={`project:${projectId()}`}
                       value={projectName}
-                      onSave={(next) => renameProject(p, next)}
+                      onSave={(next) => renameProject(p(), next)}
                       class="text-16-medium text-text-strong truncate"
                       displayClass="text-16-medium text-text-strong truncate"
                       stopPropagation
@@ -2573,7 +2856,7 @@ export default function Layout(props: ParentProps) {
                     <Tooltip
                       placement="bottom"
                       gutter={2}
-                      value={p.worktree}
+                      value={p().worktree}
                       class="shrink-0"
                       contentStyle={{
                         "max-width": "640px",
@@ -2581,7 +2864,7 @@ export default function Layout(props: ParentProps) {
                       }}
                     >
                       <span class="text-12-regular text-text-base truncate select-text">
-                        {p.worktree.replace(homedir(), "~")}
+                        {p().worktree.replace(homedir(), "~")}
                       </span>
                     </Tooltip>
                   </div>
@@ -2592,31 +2875,31 @@ export default function Layout(props: ParentProps) {
                       icon="dot-grid"
                       variant="ghost"
                       data-action="project-menu"
-                      data-project={base64Encode(p.worktree)}
+                      data-project={base64Encode(p().worktree)}
                       class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active"
                       aria-label={language.t("common.moreOptions")}
                     />
                     <DropdownMenu.Portal mount={!panelProps.mobile ? state.nav : undefined}>
                       <DropdownMenu.Content class="mt-1">
-                        <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p} />)}>
+                        <DropdownMenu.Item onSelect={() => dialog.show(() => <DialogEditProject project={p()} />)}>
                           <DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
                         <DropdownMenu.Item
                           data-action="project-workspaces-toggle"
-                          data-project={base64Encode(p.worktree)}
-                          disabled={p.vcs !== "git" && !layout.sidebar.workspaces(p.worktree)()}
+                          data-project={base64Encode(p().worktree)}
+                          disabled={p().vcs !== "git" && !layout.sidebar.workspaces(p().worktree)()}
                           onSelect={() => {
-                            const enabled = layout.sidebar.workspaces(p.worktree)()
+                            const enabled = layout.sidebar.workspaces(p().worktree)()
                             if (enabled) {
-                              layout.sidebar.toggleWorkspaces(p.worktree)
+                              layout.sidebar.toggleWorkspaces(p().worktree)
                               return
                             }
-                            if (p.vcs !== "git") return
-                            layout.sidebar.toggleWorkspaces(p.worktree)
+                            if (p().vcs !== "git") return
+                            layout.sidebar.toggleWorkspaces(p().worktree)
                           }}
                         >
                           <DropdownMenu.ItemLabel>
-                            {layout.sidebar.workspaces(p.worktree)()
+                            {layout.sidebar.workspaces(p().worktree)()
                               ? language.t("sidebar.workspaces.disable")
                               : language.t("sidebar.workspaces.enable")}
                           </DropdownMenu.ItemLabel>
@@ -2624,8 +2907,8 @@ export default function Layout(props: ParentProps) {
                         <DropdownMenu.Separator />
                         <DropdownMenu.Item
                           data-action="project-close-menu"
-                          data-project={base64Encode(p.worktree)}
-                          onSelect={() => closeProject(p.worktree)}
+                          data-project={base64Encode(p().worktree)}
+                          onSelect={() => closeProject(p().worktree)}
                         >
                           <DropdownMenu.ItemLabel>{language.t("common.close")}</DropdownMenu.ItemLabel>
                         </DropdownMenu.Item>
@@ -2635,103 +2918,207 @@ export default function Layout(props: ParentProps) {
                 </div>
               </div>
 
-              <Show
-                when={workspacesEnabled()}
-                fallback={
+              <div class="shrink-0 px-2 pt-2">
+                <div
+                  class="flex items-center gap-2 p-2 rounded-md bg-surface-base shadow-xs-border-base focus-within:shadow-xs-border-select"
+                  onPointerDown={(event) => {
+                    const target = event.target
+                    if (!(target instanceof Element)) return
+                    if (target.closest("input, textarea, [contenteditable='true']")) return
+                    searchRef?.focus()
+                  }}
+                >
+                  <Icon name="magnifying-glass" />
+                  <InlineInput
+                    ref={(el) => {
+                      searchRef = el
+                    }}
+                    class="flex-1 min-w-0 text-14-regular text-text-strong placeholder:text-text-weak"
+                    style={{ "box-shadow": "none" }}
+                    value={search.value}
+                    onInput={(event) => setSearch("value", event.currentTarget.value)}
+                    onKeyDown={(event) => {
+                      if (event.key === "Escape") {
+                        event.preventDefault()
+                        setSearch("value", "")
+                        queueMicrotask(() => searchRef?.focus())
+                        return
+                      }
+
+                      if (!searching()) return
+
+                      if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Enter") {
+                        const ref = listRef
+                        if (!ref) return
+                        event.stopPropagation()
+                        ref.onKeyDown(event)
+                        return
+                      }
+
+                      if (event.ctrlKey && !event.metaKey && !event.altKey && !event.shiftKey) {
+                        if (event.key === "n" || event.key === "p") {
+                          const ref = listRef
+                          if (!ref) return
+                          event.stopPropagation()
+                          ref.onKeyDown(event)
+                        }
+                      }
+                    }}
+                    placeholder={language.t("session.header.search.placeholder", { project: projectName() })}
+                    spellcheck={false}
+                    autocorrect="off"
+                    autocomplete="off"
+                    autocapitalize="off"
+                  />
+                  <Show when={search.value}>
+                    <IconButton
+                      icon="circle-x"
+                      variant="ghost"
+                      class="size-5"
+                      aria-label={language.t("common.close")}
+                      onClick={() => {
+                        setSearch("value", "")
+                        queueMicrotask(() => searchRef?.focus())
+                      }}
+                    />
+                  </Show>
+                </div>
+              </div>
+
+              <Show when={searching()}>
+                <List
+                  class="flex-1 min-h-0 pb-2 pt-2 !px-2 [&_[data-slot=list-scroll]]:flex-1 [&_[data-slot=list-scroll]]:min-h-0"
+                  items={items}
+                  filter={search.value}
+                  filterKeys={["title", "label", "id"]}
+                  key={(item) => `${item.directory}:${item.id}`}
+                  onSelect={open}
+                  ref={(ref) => {
+                    listRef = ref
+                  }}
+                >
+                  {(item) => (
+                    <div class="flex flex-col gap-0.5 min-w-0 pr-2 text-left">
+                      <span
+                        class="text-14-medium text-text-strong truncate"
+                        classList={{ "opacity-70": !!item.archived }}
+                      >
+                        {item.title}
+                      </span>
+                      <span
+                        class="text-12-regular text-text-weak truncate"
+                        classList={{ "opacity-70": !!item.archived }}
+                      >
+                        {item.label}
+                      </span>
+                    </div>
+                  )}
+                </List>
+              </Show>
+
+              <div class="flex-1 min-h-0 flex flex-col" classList={{ hidden: searching() }}>
+                <Show
+                  when={workspacesEnabled()}
+                  fallback={
+                    <>
+                      <div class="shrink-0 py-4 px-3">
+                        <TooltipKeybind
+                          title={language.t("command.session.new")}
+                          keybind={command.keybind("session.new")}
+                          placement="top"
+                        >
+                          <Button
+                            size="large"
+                            icon="plus-small"
+                            class="w-full"
+                            onClick={() => {
+                              if (!layout.sidebar.opened()) {
+                                setState("hoverSession", undefined)
+                                setState("hoverProject", undefined)
+                              }
+                              navigate(`/${base64Encode(p().worktree)}/session`)
+                              layout.mobileSidebar.hide()
+                            }}
+                          >
+                            {language.t("command.session.new")}
+                          </Button>
+                        </TooltipKeybind>
+                      </div>
+                      <div class="flex-1 min-h-0">
+                        <LocalWorkspace project={p()} mobile={panelProps.mobile} />
+                      </div>
+                    </>
+                  }
+                >
                   <>
-                    <div class="py-4 px-3">
+                    <div class="shrink-0 py-4 px-3">
                       <TooltipKeybind
-                        title={language.t("command.session.new")}
-                        keybind={command.keybind("session.new")}
+                        title={language.t("workspace.new")}
+                        keybind={command.keybind("workspace.new")}
                         placement="top"
                       >
-                        <Button
-                          size="large"
-                          icon="plus-small"
-                          class="w-full"
-                          onClick={() => {
-                            if (!layout.sidebar.opened()) {
-                              setState("hoverSession", undefined)
-                              setState("hoverProject", undefined)
-                            }
-                            navigate(`/${base64Encode(p.worktree)}/session`)
-                            layout.mobileSidebar.hide()
-                          }}
-                        >
-                          {language.t("command.session.new")}
+                        <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p())}>
+                          {language.t("workspace.new")}
                         </Button>
                       </TooltipKeybind>
                     </div>
-                    <div class="flex-1 min-h-0">
-                      <LocalWorkspace project={p} mobile={panelProps.mobile} />
+                    <div class="relative flex-1 min-h-0">
+                      <DragDropProvider
+                        onDragStart={handleWorkspaceDragStart}
+                        onDragEnd={handleWorkspaceDragEnd}
+                        onDragOver={handleWorkspaceDragOver}
+                        collisionDetector={closestCenter}
+                      >
+                        <DragDropSensors />
+                        <ConstrainDragXAxis />
+                        <div
+                          ref={(el) => {
+                            if (!panelProps.mobile) scrollContainerRef = el
+                          }}
+                          class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
+                        >
+                          <SortableProvider ids={workspaces()}>
+                            <For each={workspaces()}>
+                              {(directory) => (
+                                <SortableWorkspace directory={directory} project={p()} mobile={panelProps.mobile} />
+                              )}
+                            </For>
+                          </SortableProvider>
+                        </div>
+                        <DragOverlay>
+                          <WorkspaceDragOverlay />
+                        </DragOverlay>
+                      </DragDropProvider>
                     </div>
                   </>
-                }
-              >
-                <>
-                  <div class="py-4 px-3">
-                    <TooltipKeybind
-                      title={language.t("workspace.new")}
-                      keybind={command.keybind("workspace.new")}
-                      placement="top"
-                    >
-                      <Button size="large" icon="plus-small" class="w-full" onClick={() => createWorkspace(p)}>
-                        {language.t("workspace.new")}
-                      </Button>
-                    </TooltipKeybind>
-                  </div>
-                  <div class="relative flex-1 min-h-0">
-                    <DragDropProvider
-                      onDragStart={handleWorkspaceDragStart}
-                      onDragEnd={handleWorkspaceDragEnd}
-                      onDragOver={handleWorkspaceDragOver}
-                      collisionDetector={closestCenter}
-                    >
-                      <DragDropSensors />
-                      <ConstrainDragXAxis />
-                      <div
-                        ref={(el) => {
-                          if (!panelProps.mobile) scrollContainerRef = el
-                        }}
-                        class="size-full flex flex-col py-2 gap-4 overflow-y-auto no-scrollbar [overflow-anchor:none]"
-                      >
-                        <SortableProvider ids={workspaces()}>
-                          <For each={workspaces()}>
-                            {(directory) => (
-                              <SortableWorkspace directory={directory} project={p} mobile={panelProps.mobile} />
-                            )}
-                          </For>
-                        </SortableProvider>
-                      </div>
-                      <DragOverlay>
-                        <WorkspaceDragOverlay />
-                      </DragOverlay>
-                    </DragDropProvider>
-                  </div>
-                </>
-              </Show>
+                </Show>
+              </div>
             </>
           )}
         </Show>
-        <Show when={providers.all().length > 0 && providers.paid().length === 0}>
-          <div class="shrink-0 px-2 py-3 border-t border-border-weak-base">
-            <div class="rounded-md bg-background-base shadow-xs-border-base">
-              <div class="p-3 flex flex-col gap-2">
-                <div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
-                <div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
-                <div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
-              </div>
-              <Button
-                class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
-                size="large"
-                icon="plus"
-                onClick={connectProvider}
-              >
-                {language.t("command.provider.connect")}
-              </Button>
+
+        <div
+          class="shrink-0 px-2 py-3 border-t border-border-weak-base"
+          classList={{
+            hidden: searching() || !(providers.all().length > 0 && providers.paid().length === 0),
+          }}
+        >
+          <div class="rounded-md bg-background-base shadow-xs-border-base">
+            <div class="p-3 flex flex-col gap-2">
+              <div class="text-12-medium text-text-strong">{language.t("sidebar.gettingStarted.title")}</div>
+              <div class="text-text-base">{language.t("sidebar.gettingStarted.line1")}</div>
+              <div class="text-text-base">{language.t("sidebar.gettingStarted.line2")}</div>
             </div>
+            <Button
+              class="flex w-full text-left justify-start text-12-medium text-text-strong stroke-[1.5px] rounded-md rounded-t-none shadow-none border-t border-border-weak-base px-3"
+              size="large"
+              icon="plus"
+              onClick={connectProvider}
+            >
+              {language.t("command.provider.connect")}
+            </Button>
           </div>
-        </Show>
+        </div>
       </div>
     )
   }

+ 19 - 7
packages/app/src/pages/session.tsx

@@ -500,9 +500,7 @@ export default function Page() {
     const out = new Map<string, "add" | "del" | "mix">()
     for (const diff of diffs()) {
       const file = normalize(diff.file)
-      const add = diff.additions > 0
-      const del = diff.deletions > 0
-      const kind = add && del ? "mix" : add ? "add" : del ? "del" : "mix"
+      const kind = diff.status === "added" ? "add" : diff.status === "deleted" ? "del" : "mix"
 
       out.set(file, kind)
 
@@ -689,6 +687,18 @@ export default function Page() {
       slash: "open",
       onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={() => showAllFiles()} />),
     },
+    {
+      id: "tab.close",
+      title: language.t("command.tab.close"),
+      category: language.t("command.category.file"),
+      keybind: "mod+w",
+      disabled: !tabs().active(),
+      onSelect: () => {
+        const active = tabs().active()
+        if (!active) return
+        tabs().close(active)
+      },
+    },
     {
       id: "context.addSelection",
       title: language.t("command.context.addSelection"),
@@ -1940,7 +1950,8 @@ export default function Page() {
                               "sticky top-0 z-30 bg-background-stronger": true,
                               "w-full": true,
                               "px-4 md:px-6": true,
-                              "md:max-w-200 md:mx-auto": centered(),
+                              "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto":
+                                centered(),
                             }}
                           >
                             <div class="h-10 flex items-center gap-1">
@@ -1968,7 +1979,8 @@ export default function Page() {
                           class="flex flex-col gap-32 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]"
                           classList={{
                             "w-full": true,
-                            "md:max-w-200 md:mx-auto": centered(),
+                            "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 3xl:mx-auto 4xl:max-w-[1600px] 4xl:mx-auto 5xl:max-w-[1900px] 5xl:mx-auto":
+                              centered(),
                             "mt-0.5": centered(),
                             "mt-0": !centered(),
                           }}
@@ -2021,7 +2033,7 @@ export default function Page() {
                                   data-message-id={message.id}
                                   classList={{
                                     "min-w-0 w-full max-w-full": true,
-                                    "md:max-w-200": centered(),
+                                    "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(),
                                   }}
                                 >
                                   <SessionTurn
@@ -2078,7 +2090,7 @@ export default function Page() {
             <div
               classList={{
                 "w-full px-4 pointer-events-auto": true,
-                "md:max-w-200 md:mx-auto": centered(),
+                "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(),
               }}
             >
               <Show when={request()} keyed>

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

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

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

@@ -9,8 +9,8 @@ export const config = {
   github: {
     repoUrl: "https://github.com/anomalyco/opencode",
     starsFormatted: {
-      compact: "80K",
-      full: "80,000",
+      compact: "95K",
+      full: "95,000",
     },
   },
 
@@ -22,8 +22,8 @@ export const config = {
 
   // Static stats (used on landing page)
   stats: {
-    contributors: "600",
-    commits: "7,500",
-    monthlyUsers: "1.5M",
+    contributors: "650",
+    commits: "8,500",
+    monthlyUsers: "2.5M",
   },
 } as const

+ 14 - 3
packages/console/app/src/routes/zen/util/rateLimiter.ts

@@ -2,13 +2,17 @@ import { Database, eq, and, sql, inArray } from "@opencode-ai/console-core/drizz
 import { IpRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
 import { RateLimitError } from "./error"
 import { logger } from "./logger"
+import { ZenData } from "@opencode-ai/console-core/model.js"
 
-export function createRateLimiter(limit: number | undefined, rawIp: string) {
+export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: string) {
   if (!limit) return
 
   const ip = !rawIp.length ? "unknown" : rawIp
   const now = Date.now()
-  const intervals = [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
+  const intervals =
+    limit.period === "day"
+      ? [buildYYYYMMDD(now)]
+      : [buildYYYYMMDDHH(now), buildYYYYMMDDHH(now - 3_600_000), buildYYYYMMDDHH(now - 7_200_000)]
 
   return {
     track: async () => {
@@ -28,11 +32,18 @@ export function createRateLimiter(limit: number | undefined, rawIp: string) {
       )
       const total = rows.reduce((sum, r) => sum + r.count, 0)
       logger.debug(`rate limit total: ${total}`)
-      if (total >= limit) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
+      if (total >= limit.value) throw new RateLimitError(`Rate limit exceeded. Please try again later.`)
     },
   }
 }
 
+function buildYYYYMMDD(timestamp: number) {
+  return new Date(timestamp)
+    .toISOString()
+    .replace(/[^0-9]/g, "")
+    .substring(0, 8)
+}
+
 function buildYYYYMMDDHH(timestamp: number) {
   return new Date(timestamp)
     .toISOString()

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

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

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

@@ -18,8 +18,13 @@ export namespace ZenData {
       }),
     ),
   })
+  const RateLimitSchema = z.object({
+    period: z.enum(["day", "rolling"]),
+    value: z.number().int(),
+  })
   export type Format = z.infer<typeof FormatSchema>
   export type Trial = z.infer<typeof TrialSchema>
+  export type RateLimit = z.infer<typeof RateLimitSchema>
 
   const ModelCostSchema = z.object({
     input: z.number(),
@@ -37,7 +42,7 @@ export namespace ZenData {
     byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
     stickyProvider: z.enum(["strict", "prefer"]).optional(),
     trial: TrialSchema.optional(),
-    rateLimit: z.number().optional(),
+    rateLimit: RateLimitSchema.optional(),
     fallbackProvider: z.string().optional(),
     providers: z.array(
       z.object({

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

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

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

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

+ 1 - 1
packages/desktop/index.html

@@ -17,7 +17,7 @@
   </head>
   <body class="antialiased overscroll-none text-12-regular overflow-hidden">
     <noscript>You need to enable JavaScript to run this app.</noscript>
-    <div id="root" class="flex flex-col h-dvh p-px"></div>
+    <div id="root" class="flex flex-col h-dvh"></div>
     <div data-tauri-decorum-tb class="w-0 h-0 hidden" />
     <script src="/src/index.tsx" type="module"></script>
   </body>

+ 1 - 1
packages/desktop/package.json

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

+ 207 - 40
packages/desktop/src-tauri/Cargo.lock

@@ -2,6 +2,12 @@
 # It is not intended for manual editing.
 version = 4
 
+[[package]]
+name = "Inflector"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3"
+
 [[package]]
 name = "adler2"
 version = "2.0.1"
@@ -1994,9 +2000,9 @@ dependencies = [
 
 [[package]]
 name = "ico"
-version = "0.4.0"
+version = "0.5.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc50b891e4acf8fe0e71ef88ec43ad82ee07b3810ad09de10f1d01f072ed4b98"
+checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
 dependencies = [
  "byteorder",
  "png 0.17.16",
@@ -3065,12 +3071,14 @@ dependencies = [
  "listeners",
  "objc2 0.6.3",
  "objc2-web-kit",
- "reqwest",
+ "reqwest 0.12.24",
  "semver",
  "serde",
  "serde_json",
+ "specta",
+ "specta-typescript",
  "tauri",
- "tauri-build",
+ "tauri-build 2.5.2",
  "tauri-plugin-clipboard-manager",
  "tauri-plugin-decorum",
  "tauri-plugin-deep-link",
@@ -3085,6 +3093,7 @@ dependencies = [
  "tauri-plugin-store",
  "tauri-plugin-updater",
  "tauri-plugin-window-state",
+ "tauri-specta",
  "tokio",
  "uuid",
  "webkit2gtk",
@@ -3221,6 +3230,12 @@ dependencies = [
  "windows-link 0.2.1",
 ]
 
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
 [[package]]
 name = "pathdiff"
 version = "0.2.3"
@@ -3947,6 +3962,40 @@ dependencies = [
  "webpki-roots",
 ]
 
+[[package]]
+name = "reqwest"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde",
+ "serde_json",
+ "sync_wrapper",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+]
+
 [[package]]
 name = "rfd"
 version = "0.15.4"
@@ -4497,6 +4546,44 @@ dependencies = [
  "system-deps",
 ]
 
+[[package]]
+name = "specta"
+version = "2.0.0-rc.22"
+source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
+dependencies = [
+ "paste",
+ "rustc_version",
+ "specta-macros",
+]
+
+[[package]]
+name = "specta-macros"
+version = "2.0.0-rc.18"
+source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
+dependencies = [
+ "Inflector",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.110",
+]
+
+[[package]]
+name = "specta-serde"
+version = "0.0.9"
+source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
+dependencies = [
+ "specta",
+]
+
+[[package]]
+name = "specta-typescript"
+version = "0.0.9"
+source = "git+https://github.com/specta-rs/specta?rev=106425eac4964d8ff34d3a02f1612e33117b08bb#106425eac4964d8ff34d3a02f1612e33117b08bb"
+dependencies = [
+ "specta",
+ "specta-serde",
+]
+
 [[package]]
 name = "stable_deref_trait"
 version = "1.2.1"
@@ -4712,9 +4799,8 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
 
 [[package]]
 name = "tauri"
-version = "2.9.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9e492485dd390b35f7497401f67694f46161a2a00ffd800938d5dd3c898fb9d8"
+version = "2.9.5"
+source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
 dependencies = [
  "anyhow",
  "bytes",
@@ -4740,17 +4826,18 @@ dependencies = [
  "percent-encoding",
  "plist",
  "raw-window-handle",
- "reqwest",
+ "reqwest 0.13.1",
  "serde",
  "serde_json",
  "serde_repr",
  "serialize-to-javascript",
+ "specta",
  "swift-rs",
- "tauri-build",
+ "tauri-build 2.5.3",
  "tauri-macros",
  "tauri-runtime",
  "tauri-runtime-wry",
- "tauri-utils",
+ "tauri-utils 2.8.1",
  "thiserror 2.0.17",
  "tokio",
  "tray-icon",
@@ -4777,7 +4864,28 @@ dependencies = [
  "semver",
  "serde",
  "serde_json",
- "tauri-utils",
+ "tauri-utils 2.8.0",
+ "tauri-winres",
+ "toml 0.9.8",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-build"
+version = "2.5.3"
+source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
+dependencies = [
+ "anyhow",
+ "cargo_toml",
+ "dirs",
+ "glob",
+ "heck 0.5.0",
+ "json-patch",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde_json",
+ "tauri-utils 2.8.1",
  "tauri-winres",
  "toml 0.9.8",
  "walkdir",
@@ -4785,9 +4893,8 @@ dependencies = [
 
 [[package]]
 name = "tauri-codegen"
-version = "2.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b7ef707148f0755110ca54377560ab891d722de4d53297595380a748026f139f"
+version = "2.5.2"
+source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
 dependencies = [
  "base64 0.22.1",
  "brotli",
@@ -4802,7 +4909,7 @@ dependencies = [
  "serde_json",
  "sha2",
  "syn 2.0.110",
- "tauri-utils",
+ "tauri-utils 2.8.1",
  "thiserror 2.0.17",
  "time",
  "url",
@@ -4812,16 +4919,15 @@ dependencies = [
 
 [[package]]
 name = "tauri-macros"
-version = "2.5.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "71664fd715ee6e382c05345ad258d6d1d50f90cf1b58c0aa726638b33c2a075d"
+version = "2.5.2"
+source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
 dependencies = [
  "heck 0.5.0",
  "proc-macro2",
  "quote",
  "syn 2.0.110",
  "tauri-codegen",
- "tauri-utils",
+ "tauri-utils 2.8.1",
 ]
 
 [[package]]
@@ -4836,7 +4942,7 @@ dependencies = [
  "schemars 0.8.22",
  "serde",
  "serde_json",
- "tauri-utils",
+ "tauri-utils 2.8.0",
  "toml 0.9.8",
  "walkdir",
 ]
@@ -4886,7 +4992,7 @@ dependencies = [
  "serde_json",
  "tauri",
  "tauri-plugin",
- "tauri-utils",
+ "tauri-utils 2.8.0",
  "thiserror 2.0.17",
  "tracing",
  "url",
@@ -4928,7 +5034,7 @@ dependencies = [
  "serde_repr",
  "tauri",
  "tauri-plugin",
- "tauri-utils",
+ "tauri-utils 2.8.0",
  "thiserror 2.0.17",
  "toml 0.9.8",
  "url",
@@ -4945,7 +5051,7 @@ dependencies = [
  "data-url",
  "http",
  "regex",
- "reqwest",
+ "reqwest 0.12.24",
  "schemars 0.8.22",
  "serde",
  "serde_json",
@@ -5096,7 +5202,7 @@ dependencies = [
  "minisign-verify",
  "osakit",
  "percent-encoding",
- "reqwest",
+ "reqwest 0.12.24",
  "semver",
  "serde",
  "serde_json",
@@ -5129,9 +5235,8 @@ dependencies = [
 
 [[package]]
 name = "tauri-runtime"
-version = "2.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9368f09358496f2229313fccb37682ad116b7f46fa76981efe116994a0628926"
+version = "2.9.2"
+source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
 dependencies = [
  "cookie",
  "dpi",
@@ -5144,7 +5249,7 @@ dependencies = [
  "raw-window-handle",
  "serde",
  "serde_json",
- "tauri-utils",
+ "tauri-utils 2.8.1",
  "thiserror 2.0.17",
  "url",
  "webkit2gtk",
@@ -5154,9 +5259,8 @@ dependencies = [
 
 [[package]]
 name = "tauri-runtime-wry"
-version = "2.9.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "929f5df216f5c02a9e894554401bcdab6eec3e39ec6a4a7731c7067fc8688a93"
+version = "2.9.3"
+source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
 dependencies = [
  "gtk",
  "http",
@@ -5171,7 +5275,7 @@ dependencies = [
  "softbuffer",
  "tao",
  "tauri-runtime",
- "tauri-utils",
+ "tauri-utils 2.8.1",
  "url",
  "webkit2gtk",
  "webview2-com",
@@ -5179,11 +5283,74 @@ dependencies = [
  "wry",
 ]
 
+[[package]]
+name = "tauri-specta"
+version = "2.0.0-rc.21"
+source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b"
+dependencies = [
+ "heck 0.5.0",
+ "serde",
+ "serde_json",
+ "specta",
+ "specta-typescript",
+ "tauri",
+ "tauri-specta-macros",
+ "thiserror 2.0.17",
+]
+
+[[package]]
+name = "tauri-specta-macros"
+version = "2.0.0-rc.16"
+source = "git+https://github.com/specta-rs/tauri-specta?rev=6720b2848eff9a3e40af54c48d65f6d56b640c0b#6720b2848eff9a3e40af54c48d65f6d56b640c0b"
+dependencies = [
+ "darling",
+ "heck 0.5.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.110",
+]
+
 [[package]]
 name = "tauri-utils"
 version = "2.8.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "f6b8bbe426abdbf52d050e52ed693130dbd68375b9ad82a3fb17efb4c8d85673"
+dependencies = [
+ "anyhow",
+ "cargo_metadata",
+ "ctor",
+ "dunce",
+ "glob",
+ "html5ever",
+ "http",
+ "infer",
+ "json-patch",
+ "kuchikiki",
+ "log",
+ "memchr",
+ "phf 0.11.3",
+ "proc-macro2",
+ "quote",
+ "regex",
+ "schemars 0.8.22",
+ "semver",
+ "serde",
+ "serde-untagged",
+ "serde_json",
+ "serde_with",
+ "swift-rs",
+ "thiserror 2.0.17",
+ "toml 0.9.8",
+ "url",
+ "urlpattern",
+ "uuid",
+ "walkdir",
+]
+
+[[package]]
+name = "tauri-utils"
+version = "2.8.1"
+source = "git+https://github.com/tauri-apps/tauri?rev=4d5d78daf636feaac20c5bc48a6071491c4291ee#4d5d78daf636feaac20c5bc48a6071491c4291ee"
 dependencies = [
  "anyhow",
  "brotli",
@@ -5547,9 +5714,9 @@ dependencies = [
 
 [[package]]
 name = "tower-http"
-version = "0.6.6"
+version = "0.6.8"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
 dependencies = [
  "bitflags 2.10.0",
  "bytes",
@@ -6034,9 +6201,9 @@ dependencies = [
 
 [[package]]
 name = "webkit2gtk"
-version = "2.0.1"
+version = "2.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76b1bc1e54c581da1e9f179d0b38512ba358fb1af2d634a1affe42e37172361a"
+checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793"
 dependencies = [
  "bitflags 1.3.2",
  "cairo-rs",
@@ -6058,9 +6225,9 @@ dependencies = [
 
 [[package]]
 name = "webkit2gtk-sys"
-version = "2.0.1"
+version = "2.0.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "62daa38afc514d1f8f12b8693d30d5993ff77ced33ce30cd04deebc267a6d57c"
+checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5"
 dependencies = [
  "bitflags 1.3.2",
  "cairo-sys-rs",
@@ -6719,9 +6886,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
 
 [[package]]
 name = "wry"
-version = "0.53.5"
+version = "0.54.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "728b7d4c8ec8d81cab295e0b5b8a4c263c0d41a785fb8f8c4df284e5411140a2"
+checksum = "5ed1a195b0375491dd15a7066a10251be217ce743cf4bbbbdcf5391d6473bee0"
 dependencies = [
  "base64 0.22.1",
  "block2 0.6.2",

+ 12 - 2
packages/desktop/src-tauri/Cargo.toml

@@ -18,7 +18,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
 tauri-build = { version = "2", features = [] }
 
 [dependencies]
-tauri = { version = "2", features = ["macos-private-api", "devtools"] }
+tauri = { version = "2.9.5", features = ["macos-private-api", "devtools"] }
 tauri-plugin-opener = "2"
 tauri-plugin-deep-link = "2.4.6"
 tauri-plugin-shell = "2"
@@ -43,10 +43,13 @@ reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"
 uuid = { version = "1.19.0", features = ["v4"] }
 tauri-plugin-decorum = "1.1.1"
 comrak = { version = "0.50", default-features = false }
+specta = "=2.0.0-rc.22"
+specta-typescript = "0.0.9"
+tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
 
 [target.'cfg(target_os = "linux")'.dependencies]
 gtk = "0.18.2"
-webkit2gtk = "=2.0.1"
+webkit2gtk = "=2.0.2"
 
 [target.'cfg(target_os = "macos")'.dependencies]
 objc2 = "0.6"
@@ -59,3 +62,10 @@ windows = { version = "0.61", features = [
     "Win32_System_Threading",
     "Win32_Security"
 ] }
+
+[patch.crates-io]
+specta = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
+specta-typescript = { git = "https://github.com/specta-rs/specta", rev = "106425eac4964d8ff34d3a02f1612e33117b08bb" }
+tauri-specta = { git = "https://github.com/specta-rs/tauri-specta", rev = "6720b2848eff9a3e40af54c48d65f6d56b640c0b" }
+# TODO: https://github.com/tauri-apps/tauri/pull/14812
+tauri  = { git = "https://github.com/tauri-apps/tauri", rev = "4d5d78daf636feaac20c5bc48a6071491c4291ee" }

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

@@ -51,6 +51,7 @@ fn is_cli_installed() -> bool {
 const INSTALL_SCRIPT: &str = include_str!("../../../../install");
 
 #[tauri::command]
+#[specta::specta]
 pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
     if cfg!(not(unix)) {
         return Err("CLI installation is only supported on macOS & Linux".to_string());

+ 72 - 16
packages/desktop/src-tauri/src/lib.rs

@@ -16,21 +16,26 @@ use std::{
     time::{Duration, Instant},
 };
 use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder};
-#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
-use tauri_plugin_deep_link::DeepLinkExt;
 #[cfg(windows)]
 use tauri_plugin_decorum::WebviewWindowExt;
+#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
+use tauri_plugin_deep_link::DeepLinkExt;
 use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
 use tauri_plugin_shell::process::{CommandChild, CommandEvent};
 use tauri_plugin_store::StoreExt;
-use tokio::sync::oneshot;
+use tauri_plugin_window_state::{AppHandleExt, StateFlags};
+use tokio::sync::{mpsc, oneshot};
 
 use crate::window_customizer::PinchZoomDisablePlugin;
 
 const SETTINGS_STORE: &str = "opencode.settings.dat";
 const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
 
-#[derive(Clone, serde::Serialize)]
+fn window_state_flags() -> StateFlags {
+    StateFlags::all() - StateFlags::DECORATIONS
+}
+
+#[derive(Clone, serde::Serialize, specta::Type)]
 struct ServerReadyData {
     url: String,
     password: Option<String>,
@@ -64,6 +69,7 @@ struct LogState(Arc<Mutex<VecDeque<String>>>);
 const MAX_LOG_ENTRIES: usize = 200;
 
 #[tauri::command]
+#[specta::specta]
 fn kill_sidecar(app: AppHandle) {
     let Some(server_state) = app.try_state::<ServerState>() else {
         println!("Server not running");
@@ -97,6 +103,7 @@ async fn get_logs(app: AppHandle) -> Result<String, String> {
 }
 
 #[tauri::command]
+#[specta::specta]
 async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerReadyData, String> {
     state
         .status
@@ -106,6 +113,7 @@ async fn ensure_server_ready(state: State<'_, ServerState>) -> Result<ServerRead
 }
 
 #[tauri::command]
+#[specta::specta]
 fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
     let store = app
         .store(SETTINGS_STORE)
@@ -119,6 +127,7 @@ fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
 }
 
 #[tauri::command]
+#[specta::specta]
 async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Result<(), String> {
     let store = app
         .store(SETTINGS_STORE)
@@ -252,6 +261,26 @@ async fn check_server_health(url: &str, password: Option<&str>) -> bool {
 pub fn run() {
     let updater_enabled = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
 
+    let builder = tauri_specta::Builder::<tauri::Wry>::new()
+        // Then register them (separated by a comma)
+        .commands(tauri_specta::collect_commands![
+            kill_sidecar,
+            install_cli,
+            ensure_server_ready,
+            get_default_server_url,
+            set_default_server_url,
+            markdown::parse_markdown_command
+        ])
+        .error_handling(tauri_specta::ErrorHandlingMode::Throw);
+
+    #[cfg(debug_assertions)] // <- Only export on non-release builds
+    builder
+        .export(
+            specta_typescript::Typescript::default(),
+            "../src/bindings.ts",
+        )
+        .expect("Failed to export typescript bindings");
+
     #[cfg(all(target_os = "macos", not(debug_assertions)))]
     let _ = std::process::Command::new("killall")
         .arg("opencode-cli")
@@ -269,10 +298,7 @@ pub fn run() {
         .plugin(tauri_plugin_os::init())
         .plugin(
             tauri_plugin_window_state::Builder::new()
-                .with_state_flags(
-                    tauri_plugin_window_state::StateFlags::all()
-                        - tauri_plugin_window_state::StateFlags::DECORATIONS,
-                )
+                .with_state_flags(window_state_flags())
                 .build(),
         )
         .plugin(tauri_plugin_store::Builder::new().build())
@@ -285,15 +311,10 @@ pub fn run() {
         .plugin(tauri_plugin_notification::init())
         .plugin(PinchZoomDisablePlugin)
         .plugin(tauri_plugin_decorum::init())
-        .invoke_handler(tauri::generate_handler![
-            kill_sidecar,
-            install_cli,
-            ensure_server_ready,
-            get_default_server_url,
-            set_default_server_url,
-            markdown::parse_markdown_command
-        ])
+        .invoke_handler(builder.invoke_handler())
         .setup(move |app| {
+            builder.mount_events(app);
+
             #[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
             app.deep_link().register_all().ok();
 
@@ -346,6 +367,8 @@ pub fn run() {
 
             let window = window_builder.build().expect("Failed to create window");
 
+            setup_window_state_listener(&app, &window);
+
             #[cfg(windows)]
             let _ = window.create_overlay_titlebar();
 
@@ -526,6 +549,7 @@ async fn spawn_local_server(
     let timestamp = Instant::now();
     loop {
         if timestamp.elapsed() > Duration::from_secs(30) {
+            let _ = child.kill();
             break Err(format!(
                 "Failed to spawn OpenCode Server. Logs:\n{}",
                 get_logs(app.clone()).await.unwrap()
@@ -540,3 +564,35 @@ async fn spawn_local_server(
         }
     }
 }
+
+fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWindow) {
+    let (tx, mut rx) = mpsc::channel::<()>(1);
+
+    window.on_window_event(move |event| {
+        use tauri::WindowEvent;
+        if !matches!(event, WindowEvent::Moved(_) | WindowEvent::Resized(_)) {
+            return;
+        }
+        let _ = tx.try_send(());
+    });
+
+    tauri::async_runtime::spawn({
+        let app = app.clone();
+
+        async move {
+            let save = || {
+                let handle = app.clone();
+                let app = app.clone();
+                let _ = handle.run_on_main_thread(move || {
+                    let _ = app.save_window_state(window_state_flags());
+                });
+            };
+
+            while rx.recv().await.is_some() {
+                tokio::time::sleep(Duration::from_millis(200)).await;
+
+                save();
+            }
+        }
+    });
+}

+ 4 - 1
packages/desktop/src-tauri/src/markdown.rs

@@ -1,4 +1,6 @@
-use comrak::{create_formatter, parse_document, Arena, Options, html::ChildRendering, nodes::NodeValue};
+use comrak::{
+    Arena, Options, create_formatter, html::ChildRendering, nodes::NodeValue, parse_document,
+};
 use std::fmt::Write;
 
 create_formatter!(ExternalLinkFormatter, {
@@ -55,6 +57,7 @@ pub fn parse_markdown(input: &str) -> String {
 }
 
 #[tauri::command]
+#[specta::specta]
 pub async fn parse_markdown_command(markdown: String) -> Result<String, String> {
     Ok(parse_markdown(&markdown))
 }

+ 2 - 2
packages/desktop/src-tauri/src/window_customizer.rs

@@ -1,4 +1,4 @@
-use tauri::{plugin::Plugin, Manager, Runtime, Window};
+use tauri::{Manager, Runtime, Window, plugin::Plugin};
 
 pub struct PinchZoomDisablePlugin;
 
@@ -21,8 +21,8 @@ impl<R: Runtime> Plugin<R> for PinchZoomDisablePlugin {
         let _ = webview_window.with_webview(|_webview| {
             #[cfg(target_os = "linux")]
             unsafe {
-                use gtk::glib::ObjectExt;
                 use gtk::GestureZoom;
+                use gtk::glib::ObjectExt;
                 use webkit2gtk::glib::gobject_ffi;
 
                 if let Some(data) = _webview.inner().data::<GestureZoom>("wk-view-zoom-gesture") {

+ 19 - 0
packages/desktop/src/bindings.ts

@@ -0,0 +1,19 @@
+// This file has been generated by Tauri Specta. Do not edit this file manually.
+
+import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core"
+
+/** Commands */
+export const commands = {
+  killSidecar: () => __TAURI_INVOKE<void>("kill_sidecar"),
+  installCli: () => __TAURI_INVOKE<string>("install_cli"),
+  ensureServerReady: () => __TAURI_INVOKE<ServerReadyData>("ensure_server_ready"),
+  getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
+  setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
+  parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
+}
+
+/* Types */
+export type ServerReadyData = {
+  url: string
+  password: string | null
+}

+ 2 - 2
packages/desktop/src/cli.ts

@@ -1,13 +1,13 @@
-import { invoke } from "@tauri-apps/api/core"
 import { message } from "@tauri-apps/plugin-dialog"
 
 import { initI18n, t } from "./i18n"
+import { commands } from "./bindings"
 
 export async function installCli(): Promise<void> {
   await initI18n()
 
   try {
-    const path = await invoke<string>("install_cli")
+    const path = await commands.installCli()
     await message(t("desktop.cli.installed.message", { path }), { title: t("desktop.cli.installed.title") })
   } catch (e) {
     await message(t("desktop.cli.failed.message", { error: String(e) }), { title: t("desktop.cli.failed.title") })

+ 11 - 15
packages/desktop/src/index.tsx

@@ -1,5 +1,5 @@
 // @refresh reload
-import "./webview-zoom"
+import { webviewZoom } from "./webview-zoom"
 import { render } from "solid-js/web"
 import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app"
 import { open, save } from "@tauri-apps/plugin-dialog"
@@ -7,7 +7,6 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"
 import { open as shellOpen } from "@tauri-apps/plugin-shell"
 import { type as ostype } from "@tauri-apps/plugin-os"
 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"
@@ -22,6 +21,7 @@ import { createMenu } from "./menu"
 import { initI18n, t } from "./i18n"
 import pkg from "../package.json"
 import "./styles.css"
+import { commands } from "./bindings"
 
 const root = document.getElementById("root")
 if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
@@ -274,12 +274,12 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
 
   update: async () => {
     if (!UPDATER_ENABLED || !update) return
-    if (ostype() === "windows") await invoke("kill_sidecar").catch(() => undefined)
+    if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
     await update.install().catch(() => undefined)
   },
 
   restart: async () => {
-    await invoke("kill_sidecar").catch(() => undefined)
+    await commands.killSidecar().catch(() => undefined)
     await relaunch()
   },
 
@@ -335,17 +335,17 @@ const createPlatform = (password: Accessor<string | null>): Platform => ({
   },
 
   getDefaultServerUrl: async () => {
-    const result = await invoke<string | null>("get_default_server_url").catch(() => null)
+    const result = await commands.getDefaultServerUrl().catch(() => null)
     return result
   },
 
   setDefaultServerUrl: async (url: string | null) => {
-    await invoke("set_default_server_url", { url })
+    await commands.setDefaultServerUrl(url)
   },
 
-  parseMarkdown: async (markdown: string) => {
-    return invoke<string>("parse_markdown_command", { markdown })
-  },
+  parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
+
+  webviewZoom,
 })
 
 createMenu()
@@ -391,11 +391,7 @@ type ServerReadyData = { url: string; password: string | null }
 
 // Gate component that waits for the server to be ready
 function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.Element }) {
-  const [serverData] = createResource<ServerReadyData>(() =>
-    invoke("ensure_server_ready").then((v) => {
-      return new Promise((res) => setTimeout(() => res(v as ServerReadyData), 2000))
-    }),
-  )
+  const [serverData] = createResource(() => commands.ensureServerReady())
 
   const errorMessage = () => {
     const error = serverData.error
@@ -406,7 +402,7 @@ function ServerGate(props: { children: (data: Accessor<ServerReadyData>) => JSX.
   }
 
   const restartApp = async () => {
-    await invoke("kill_sidecar").catch(() => undefined)
+    await commands.killSidecar().catch(() => undefined)
     await relaunch().catch(() => undefined)
   }
 

+ 2 - 2
packages/desktop/src/menu.ts

@@ -1,11 +1,11 @@
 import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
 import { type as ostype } from "@tauri-apps/plugin-os"
-import { invoke } from "@tauri-apps/api/core"
 import { relaunch } from "@tauri-apps/plugin-process"
 
 import { runUpdater, UPDATER_ENABLED } from "./updater"
 import { installCli } from "./cli"
 import { initI18n, t } from "./i18n"
+import { commands } from "./bindings"
 
 export async function createMenu() {
   if (ostype() !== "macos") return
@@ -35,7 +35,7 @@ export async function createMenu() {
           }),
           await MenuItem.new({
             action: async () => {
-              await invoke("kill_sidecar").catch(() => undefined)
+              await commands.killSidecar().catch(() => undefined)
               await relaunch().catch(() => undefined)
             },
             text: t("desktop.menu.restart"),

+ 3 - 3
packages/desktop/src/updater.ts

@@ -1,10 +1,10 @@
 import { check } from "@tauri-apps/plugin-updater"
 import { relaunch } from "@tauri-apps/plugin-process"
 import { ask, message } from "@tauri-apps/plugin-dialog"
-import { invoke } from "@tauri-apps/api/core"
 import { type as ostype } from "@tauri-apps/plugin-os"
 
 import { initI18n, t } from "./i18n"
+import { commands } from "./bindings"
 
 export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
 
@@ -39,13 +39,13 @@ export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
   if (!shouldUpdate) return
 
   try {
-    if (ostype() === "windows") await invoke("kill_sidecar")
+    if (ostype() === "windows") await commands.killSidecar()
     await update.install()
   } catch {
     await message(t("desktop.updater.installFailed.message"), { title: t("desktop.updater.installFailed.title") })
     return
   }
 
-  await invoke("kill_sidecar")
+  await commands.killSidecar()
   await relaunch()
 }

+ 22 - 16
packages/desktop/src/webview-zoom.ts

@@ -4,28 +4,34 @@
 
 import { invoke } from "@tauri-apps/api/core"
 import { type as ostype } from "@tauri-apps/plugin-os"
+import { createSignal } from "solid-js"
 
 const OS_NAME = ostype()
 
-let zoomLevel = 1
+const [webviewZoom, setWebviewZoom] = createSignal(1)
 
 const MAX_ZOOM_LEVEL = 10
 const MIN_ZOOM_LEVEL = 0.2
 
+const clamp = (value: number) => Math.min(Math.max(value, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
+
+const applyZoom = (next: number) => {
+  setWebviewZoom(next)
+  invoke("plugin:webview|set_webview_zoom", {
+    value: next,
+  })
+}
+
 window.addEventListener("keydown", (event) => {
-  if (OS_NAME === "macos" ? event.metaKey : event.ctrlKey) {
-    if (event.key === "-") {
-      zoomLevel -= 0.2
-    } else if (event.key === "=" || event.key === "+") {
-      zoomLevel += 0.2
-    } else if (event.key === "0") {
-      zoomLevel = 1
-    } else {
-      return
-    }
-    zoomLevel = Math.min(Math.max(zoomLevel, MIN_ZOOM_LEVEL), MAX_ZOOM_LEVEL)
-    invoke("plugin:webview|set_webview_zoom", {
-      value: zoomLevel,
-    })
-  }
+  if (!(OS_NAME === "macos" ? event.metaKey : event.ctrlKey)) return
+
+  let newZoom = webviewZoom()
+
+  if (event.key === "-") newZoom -= 0.2
+  if (event.key === "=" || event.key === "+") newZoom += 0.2
+  if (event.key === "0") newZoom = 1
+
+  applyZoom(clamp(newZoom))
 })
+
+export { webviewZoom }

+ 1 - 1
packages/enterprise/package.json

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

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

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

+ 1 - 1
packages/function/package.json

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

+ 5 - 4
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.1.48",
+  "version": "1.1.49",
   "name": "opencode",
   "type": "module",
   "license": "MIT",
@@ -21,6 +21,7 @@
   "bin": {
     "opencode": "./bin/opencode"
   },
+  "randomField": "this-is-a-random-value-12345",
   "exports": {
     "./*": "./src/*.ts"
   },
@@ -73,7 +74,7 @@
     "@ai-sdk/vercel": "1.0.33",
     "@ai-sdk/xai": "2.0.56",
     "@clack/prompts": "1.0.0-alpha.1",
-    "@gitlab/gitlab-ai-provider": "3.3.1",
+    "@gitlab/gitlab-ai-provider": "3.4.0",
     "@hono/standard-validator": "0.1.5",
     "@hono/zod-validator": "catalog:",
     "@modelcontextprotocol/sdk": "1.25.2",
@@ -85,8 +86,8 @@
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/util": "workspace:*",
     "@openrouter/ai-sdk-provider": "1.5.4",
-    "@opentui/core": "0.1.76",
-    "@opentui/solid": "0.1.76",
+    "@opentui/core": "0.1.77",
+    "@opentui/solid": "0.1.77",
     "@parcel/watcher": "2.5.1",
     "@pierre/diffs": "catalog:",
     "@solid-primitives/event-bus": "1.1.2",

+ 2 - 0
packages/opencode/script/publish.ts

@@ -18,6 +18,7 @@ const version = Object.values(binaries)[0]
 await $`mkdir -p ./dist/${pkg.name}`
 await $`cp -r ./bin ./dist/${pkg.name}/bin`
 await $`cp ./script/postinstall.mjs ./dist/${pkg.name}/postinstall.mjs`
+await Bun.file(`./dist/${pkg.name}/LICENSE`).write(await Bun.file("../../LICENSE").text())
 
 await Bun.file(`./dist/${pkg.name}/package.json`).write(
   JSON.stringify(
@@ -30,6 +31,7 @@ await Bun.file(`./dist/${pkg.name}/package.json`).write(
         postinstall: "bun ./postinstall.mjs || node ./postinstall.mjs",
       },
       version: version,
+      license: pkg.license,
       optionalDependencies: binaries,
     },
     null,

+ 3 - 5
packages/opencode/src/agent/agent.ts

@@ -55,7 +55,6 @@ export namespace Agent {
       doom_loop: "ask",
       external_directory: {
         "*": "ask",
-        [Truncate.DIR]: "allow",
         [Truncate.GLOB]: "allow",
       },
       question: "deny",
@@ -140,7 +139,6 @@ export namespace Agent {
             codesearch: "allow",
             read: "allow",
             external_directory: {
-              [Truncate.DIR]: "allow",
               [Truncate.GLOB]: "allow",
             },
           }),
@@ -229,19 +227,19 @@ export namespace Agent {
       item.permission = PermissionNext.merge(item.permission, PermissionNext.fromConfig(value.permission ?? {}))
     }
 
-    // Ensure Truncate.DIR is allowed unless explicitly configured
+    // Ensure Truncate.GLOB is allowed unless explicitly configured
     for (const name in result) {
       const agent = result[name]
       const explicit = agent.permission.some((r) => {
         if (r.permission !== "external_directory") return false
         if (r.action !== "deny") return false
-        return r.pattern === Truncate.DIR || r.pattern === Truncate.GLOB
+        return r.pattern === Truncate.GLOB
       })
       if (explicit) continue
 
       result[name].permission = PermissionNext.merge(
         result[name].permission,
-        PermissionNext.fromConfig({ external_directory: { [Truncate.DIR]: "allow", [Truncate.GLOB]: "allow" } }),
+        PermissionNext.fromConfig({ external_directory: { [Truncate.GLOB]: "allow" } }),
       )
     }
 

+ 0 - 2
packages/opencode/src/bun/index.ts

@@ -5,12 +5,10 @@ import path from "path"
 import { Filesystem } from "../util/filesystem"
 import { NamedError } from "@opencode-ai/util/error"
 import { readableStreamToText } from "bun"
-import { createRequire } from "module"
 import { Lock } from "../util/lock"
 
 export namespace BunProc {
   const log = Log.create({ service: "bun" })
-  const req = createRequire(import.meta.url)
 
   export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
     log.info("running", {

+ 365 - 181
packages/opencode/src/cli/cmd/run.ts

@@ -4,25 +4,211 @@ import { UI } from "../ui"
 import { cmd } from "./cmd"
 import { Flag } from "../../flag/flag"
 import { bootstrap } from "../bootstrap"
-import { Command } from "../../command"
 import { EOL } from "os"
-import { select } from "@clack/prompts"
-import { createOpencodeClient, type OpencodeClient } from "@opencode-ai/sdk/v2"
+import { createOpencodeClient, type Message, type OpencodeClient, type ToolPart } from "@opencode-ai/sdk/v2"
 import { Server } from "../../server/server"
 import { Provider } from "../../provider/provider"
 import { Agent } from "../../agent/agent"
+import { PermissionNext } from "../../permission/next"
+import { Tool } from "../../tool/tool"
+import { GlobTool } from "../../tool/glob"
+import { GrepTool } from "../../tool/grep"
+import { ListTool } from "../../tool/ls"
+import { ReadTool } from "../../tool/read"
+import { WebFetchTool } from "../../tool/webfetch"
+import { EditTool } from "../../tool/edit"
+import { WriteTool } from "../../tool/write"
+import { CodeSearchTool } from "../../tool/codesearch"
+import { WebSearchTool } from "../../tool/websearch"
+import { TaskTool } from "../../tool/task"
+import { SkillTool } from "../../tool/skill"
+import { BashTool } from "../../tool/bash"
+import { TodoWriteTool } from "../../tool/todo"
+import { Locale } from "../../util/locale"
+
+type ToolProps<T extends Tool.Info> = {
+  input: Tool.InferParameters<T>
+  metadata: Tool.InferMetadata<T>
+  part: ToolPart
+}
+
+function props<T extends Tool.Info>(part: ToolPart): ToolProps<T> {
+  const state = part.state
+  return {
+    input: state.input as Tool.InferParameters<T>,
+    metadata: ("metadata" in state ? state.metadata : {}) as Tool.InferMetadata<T>,
+    part,
+  }
+}
+
+type Inline = {
+  icon: string
+  title: string
+  description?: string
+}
+
+function inline(info: Inline) {
+  const suffix = info.description ? UI.Style.TEXT_DIM + ` ${info.description}` + UI.Style.TEXT_NORMAL : ""
+  UI.println(UI.Style.TEXT_NORMAL + info.icon, UI.Style.TEXT_NORMAL + info.title + suffix)
+}
+
+function block(info: Inline, output?: string) {
+  UI.empty()
+  inline(info)
+  if (!output?.trim()) return
+  UI.println(output)
+  UI.empty()
+}
+
+function fallback(part: ToolPart) {
+  const state = part.state
+  const input = "input" in state ? state.input : undefined
+  const title =
+    ("title" in state && state.title ? state.title : undefined) ||
+    (input && typeof input === "object" && Object.keys(input).length > 0 ? JSON.stringify(input) : "Unknown")
+  inline({
+    icon: "⚙",
+    title: `${part.tool} ${title}`,
+  })
+}
+
+function glob(info: ToolProps<typeof GlobTool>) {
+  const root = info.input.path ?? ""
+  const title = `Glob "${info.input.pattern}"`
+  const suffix = root ? `in ${normalizePath(root)}` : ""
+  const num = info.metadata.count
+  const description =
+    num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
+  inline({
+    icon: "✱",
+    title,
+    ...(description && { description }),
+  })
+}
+
+function grep(info: ToolProps<typeof GrepTool>) {
+  const root = info.input.path ?? ""
+  const title = `Grep "${info.input.pattern}"`
+  const suffix = root ? `in ${normalizePath(root)}` : ""
+  const num = info.metadata.matches
+  const description =
+    num === undefined ? suffix : `${suffix}${suffix ? " · " : ""}${num} ${num === 1 ? "match" : "matches"}`
+  inline({
+    icon: "✱",
+    title,
+    ...(description && { description }),
+  })
+}
+
+function list(info: ToolProps<typeof ListTool>) {
+  const dir = info.input.path ? normalizePath(info.input.path) : ""
+  inline({
+    icon: "→",
+    title: dir ? `List ${dir}` : "List",
+  })
+}
+
+function read(info: ToolProps<typeof ReadTool>) {
+  const file = normalizePath(info.input.filePath)
+  const pairs = Object.entries(info.input).filter(([key, value]) => {
+    if (key === "filePath") return false
+    return typeof value === "string" || typeof value === "number" || typeof value === "boolean"
+  })
+  const description = pairs.length ? `[${pairs.map(([key, value]) => `${key}=${value}`).join(", ")}]` : undefined
+  inline({
+    icon: "→",
+    title: `Read ${file}`,
+    ...(description && { description }),
+  })
+}
+
+function write(info: ToolProps<typeof WriteTool>) {
+  block(
+    {
+      icon: "←",
+      title: `Write ${normalizePath(info.input.filePath)}`,
+    },
+    info.part.state.status === "completed" ? info.part.state.output : undefined,
+  )
+}
+
+function webfetch(info: ToolProps<typeof WebFetchTool>) {
+  inline({
+    icon: "%",
+    title: `WebFetch ${info.input.url}`,
+  })
+}
+
+function edit(info: ToolProps<typeof EditTool>) {
+  const title = normalizePath(info.input.filePath)
+  const diff = info.metadata.diff
+  block(
+    {
+      icon: "←",
+      title: `Edit ${title}`,
+    },
+    diff,
+  )
+}
+
+function codesearch(info: ToolProps<typeof CodeSearchTool>) {
+  inline({
+    icon: "◇",
+    title: `Exa Code Search "${info.input.query}"`,
+  })
+}
+
+function websearch(info: ToolProps<typeof WebSearchTool>) {
+  inline({
+    icon: "◈",
+    title: `Exa Web Search "${info.input.query}"`,
+  })
+}
+
+function task(info: ToolProps<typeof TaskTool>) {
+  const agent = Locale.titlecase(info.input.subagent_type)
+  const desc = info.input.description
+  const started = info.part.state.status === "running"
+  const name = desc ?? `${agent} Task`
+  inline({
+    icon: started ? "•" : "✓",
+    title: name,
+    description: desc ? `${agent} Agent` : undefined,
+  })
+}
+
+function skill(info: ToolProps<typeof SkillTool>) {
+  inline({
+    icon: "→",
+    title: `Skill "${info.input.name}"`,
+  })
+}
 
-const TOOL: Record<string, [string, string]> = {
-  todowrite: ["Todo", UI.Style.TEXT_WARNING_BOLD],
-  todoread: ["Todo", UI.Style.TEXT_WARNING_BOLD],
-  bash: ["Bash", UI.Style.TEXT_DANGER_BOLD],
-  edit: ["Edit", UI.Style.TEXT_SUCCESS_BOLD],
-  glob: ["Glob", UI.Style.TEXT_INFO_BOLD],
-  grep: ["Grep", UI.Style.TEXT_INFO_BOLD],
-  list: ["List", UI.Style.TEXT_INFO_BOLD],
-  read: ["Read", UI.Style.TEXT_HIGHLIGHT_BOLD],
-  write: ["Write", UI.Style.TEXT_SUCCESS_BOLD],
-  websearch: ["Search", UI.Style.TEXT_DIM_BOLD],
+function bash(info: ToolProps<typeof BashTool>) {
+  const output = info.part.state.status === "completed" ? info.part.state.output?.trim() : undefined
+  block(
+    {
+      icon: "$",
+      title: `${info.input.command}`,
+    },
+    output,
+  )
+}
+
+function todo(info: ToolProps<typeof TodoWriteTool>) {
+  block(
+    {
+      icon: "#",
+      title: "Todos",
+    },
+    info.input.todos.map((item) => `${item.status === "completed" ? "[x]" : "[ ]"} ${item.content}`).join("\n"),
+  )
+}
+
+function normalizePath(input?: string) {
+  if (!input) return ""
+  if (path.isAbsolute(input)) return path.relative(process.cwd(), input) || "."
+  return input
 }
 
 export const RunCommand = cmd({
@@ -97,11 +283,11 @@ export const RunCommand = cmd({
       .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg))
       .join(" ")
 
-    const fileParts: any[] = []
+    const files: { type: "file"; url: string; filename: string; mime: string }[] = []
     if (args.file) {
-      const files = Array.isArray(args.file) ? args.file : [args.file]
+      const list = Array.isArray(args.file) ? args.file : [args.file]
 
-      for (const filePath of files) {
+      for (const filePath of list) {
         const resolvedPath = path.resolve(process.cwd(), filePath)
         const file = Bun.file(resolvedPath)
         const stats = await file.stat().catch(() => {})
@@ -117,7 +303,7 @@ export const RunCommand = cmd({
         const stat = await file.stat()
         const mime = stat.isDirectory() ? "application/x-directory" : "text/plain"
 
-        fileParts.push({
+        files.push({
           type: "file",
           url: `file://${resolvedPath}`,
           filename: path.basename(resolvedPath),
@@ -133,17 +319,75 @@ export const RunCommand = cmd({
       process.exit(1)
     }
 
-    const execute = async (sdk: OpencodeClient, sessionID: string) => {
-      const printEvent = (color: string, type: string, title: string) => {
-        UI.println(
-          color + `|`,
-          UI.Style.TEXT_NORMAL + UI.Style.TEXT_DIM + ` ${type.padEnd(7, " ")}`,
-          "",
-          UI.Style.TEXT_NORMAL + title,
-        )
+    const rules: PermissionNext.Ruleset = [
+      {
+        permission: "question",
+        action: "deny",
+        pattern: "*",
+      },
+      {
+        permission: "plan_enter",
+        action: "deny",
+        pattern: "*",
+      },
+      {
+        permission: "plan_exit",
+        action: "deny",
+        pattern: "*",
+      },
+    ]
+
+    function title() {
+      if (args.title === undefined) return
+      if (args.title !== "") return args.title
+      return message.slice(0, 50) + (message.length > 50 ? "..." : "")
+    }
+
+    async function session(sdk: OpencodeClient) {
+      if (args.continue) {
+        const result = await sdk.session.list()
+        return result.data?.find((s) => !s.parentID)?.id
       }
+      if (args.session) return args.session
+      const name = title()
+      const result = await sdk.session.create({ title: name, permission: rules })
+      return result.data?.id
+    }
 
-      const outputJsonEvent = (type: string, data: any) => {
+    async function share(sdk: OpencodeClient, sessionID: string) {
+      const cfg = await sdk.config.get()
+      if (!cfg.data) return
+      if (cfg.data.share !== "auto" && !Flag.OPENCODE_AUTO_SHARE && !args.share) return
+      const res = await sdk.session.share({ sessionID }).catch((error) => {
+        if (error instanceof Error && error.message.includes("disabled")) {
+          UI.println(UI.Style.TEXT_DANGER_BOLD + "!  " + error.message)
+        }
+        return { error }
+      })
+      if (!res.error && "data" in res && res.data?.share?.url) {
+        UI.println(UI.Style.TEXT_INFO_BOLD + "~  " + res.data.share.url)
+      }
+    }
+
+    async function execute(sdk: OpencodeClient) {
+      function tool(part: ToolPart) {
+        if (part.tool === "bash") return bash(props<typeof BashTool>(part))
+        if (part.tool === "glob") return glob(props<typeof GlobTool>(part))
+        if (part.tool === "grep") return grep(props<typeof GrepTool>(part))
+        if (part.tool === "list") return list(props<typeof ListTool>(part))
+        if (part.tool === "read") return read(props<typeof ReadTool>(part))
+        if (part.tool === "write") return write(props<typeof WriteTool>(part))
+        if (part.tool === "webfetch") return webfetch(props<typeof WebFetchTool>(part))
+        if (part.tool === "edit") return edit(props<typeof EditTool>(part))
+        if (part.tool === "codesearch") return codesearch(props<typeof CodeSearchTool>(part))
+        if (part.tool === "websearch") return websearch(props<typeof WebSearchTool>(part))
+        if (part.tool === "task") return task(props<typeof TaskTool>(part))
+        if (part.tool === "todowrite") return todo(props<typeof TodoWriteTool>(part))
+        if (part.tool === "skill") return skill(props<typeof SkillTool>(part))
+        return fallback(part)
+      }
+
+      function emit(type: string, data: Record<string, unknown>) {
         if (args.format === "json") {
           process.stdout.write(JSON.stringify({ type, timestamp: Date.now(), sessionID, ...data }) + EOL)
           return true
@@ -152,41 +396,77 @@ export const RunCommand = cmd({
       }
 
       const events = await sdk.event.subscribe()
-      let errorMsg: string | undefined
+      let error: string | undefined
+
+      async function loop() {
+        const toggles = new Map<string, boolean>()
 
-      const eventProcessor = (async () => {
         for await (const event of events.stream) {
+          if (
+            event.type === "message.updated" &&
+            event.properties.info.role === "assistant" &&
+            args.format !== "json" &&
+            toggles.get("start") !== true
+          ) {
+            UI.empty()
+            UI.println(`> ${event.properties.info.agent} · ${event.properties.info.modelID}`)
+            UI.empty()
+            toggles.set("start", true)
+          }
+
           if (event.type === "message.part.updated") {
             const part = event.properties.part
             if (part.sessionID !== sessionID) continue
 
             if (part.type === "tool" && part.state.status === "completed") {
-              if (outputJsonEvent("tool_use", { part })) continue
-              const [tool, color] = TOOL[part.tool] ?? [part.tool, UI.Style.TEXT_INFO_BOLD]
-              const title =
-                part.state.title ||
-                (Object.keys(part.state.input).length > 0 ? JSON.stringify(part.state.input) : "Unknown")
-              printEvent(color, tool, title)
-              if (part.tool === "bash" && part.state.output?.trim()) {
-                UI.println()
-                UI.println(part.state.output)
-              }
+              if (emit("tool_use", { part })) continue
+              tool(part)
+            }
+
+            if (
+              part.type === "tool" &&
+              part.tool === "task" &&
+              part.state.status === "running" &&
+              args.format !== "json"
+            ) {
+              if (toggles.get(part.id) === true) continue
+              task(props<typeof TaskTool>(part))
+              toggles.set(part.id, true)
             }
 
             if (part.type === "step-start") {
-              if (outputJsonEvent("step_start", { part })) continue
+              if (emit("step_start", { part })) continue
             }
 
             if (part.type === "step-finish") {
-              if (outputJsonEvent("step_finish", { part })) continue
+              if (emit("step_finish", { part })) continue
             }
 
             if (part.type === "text" && part.time?.end) {
-              if (outputJsonEvent("text", { part })) continue
-              const isPiped = !process.stdout.isTTY
-              if (!isPiped) UI.println()
-              process.stdout.write((isPiped ? part.text : UI.markdown(part.text)) + EOL)
-              if (!isPiped) UI.println()
+              if (emit("text", { part })) continue
+              const text = part.text.trim()
+              if (!text) continue
+              if (!process.stdout.isTTY) {
+                process.stdout.write(text + EOL)
+                continue
+              }
+              UI.empty()
+              UI.println(text)
+              UI.empty()
+            }
+
+            if (part.type === "reasoning" && part.time?.end) {
+              if (emit("reasoning", { part })) continue
+              const text = part.text.trim()
+              if (!text) continue
+              const line = `Thinking: ${text}`
+              if (process.stdout.isTTY) {
+                UI.empty()
+                UI.println(`${UI.Style.TEXT_DIM}\u001b[3m${line}\u001b[0m${UI.Style.TEXT_NORMAL}`)
+                UI.empty()
+                continue
+              }
+              process.stdout.write(line + EOL)
             }
           }
 
@@ -197,42 +477,40 @@ export const RunCommand = cmd({
             if ("data" in props.error && props.error.data && "message" in props.error.data) {
               err = String(props.error.data.message)
             }
-            errorMsg = errorMsg ? errorMsg + EOL + err : err
-            if (outputJsonEvent("error", { error: props.error })) continue
+            error = error ? error + EOL + err : err
+            if (emit("error", { error: props.error })) continue
             UI.error(err)
           }
 
-          if (event.type === "session.idle" && event.properties.sessionID === sessionID) {
+          if (
+            event.type === "session.status" &&
+            event.properties.sessionID === sessionID &&
+            event.properties.status.type === "idle"
+          ) {
             break
           }
 
           if (event.type === "permission.asked") {
             const permission = event.properties
             if (permission.sessionID !== sessionID) continue
-            const result = await select({
-              message: `Permission required: ${permission.permission} (${permission.patterns.join(", ")})`,
-              options: [
-                { value: "once", label: "Allow once" },
-                { value: "always", label: "Always allow: " + permission.always.join(", ") },
-                { value: "reject", label: "Reject" },
-              ],
-              initialValue: "once",
-            }).catch(() => "reject")
-            const response = (result.toString().includes("cancel") ? "reject" : result) as "once" | "always" | "reject"
-            await sdk.permission.respond({
-              sessionID,
-              permissionID: permission.id,
-              response,
+            UI.println(
+              UI.Style.TEXT_WARNING_BOLD + "!",
+              UI.Style.TEXT_NORMAL +
+                `permission requested: ${permission.permission} (${permission.patterns.join(", ")}); auto-rejecting`,
+            )
+            await sdk.permission.reply({
+              requestID: permission.id,
+              reply: "reject",
             })
           }
         }
-      })()
+      }
 
       // Validate agent if specified
-      const resolvedAgent = await (async () => {
+      const agent = await (async () => {
         if (!args.agent) return undefined
-        const agent = await Agent.get(args.agent)
-        if (!agent) {
+        const entry = await Agent.get(args.agent)
+        if (!entry) {
           UI.println(
             UI.Style.TEXT_WARNING_BOLD + "!",
             UI.Style.TEXT_NORMAL,
@@ -240,7 +518,7 @@ export const RunCommand = cmd({
           )
           return undefined
         }
-        if (agent.mode === "subagent") {
+        if (entry.mode === "subagent") {
           UI.println(
             UI.Style.TEXT_WARNING_BOLD + "!",
             UI.Style.TEXT_NORMAL,
@@ -251,91 +529,42 @@ export const RunCommand = cmd({
         return args.agent
       })()
 
+      const sessionID = await session(sdk)
+      if (!sessionID) {
+        UI.error("Session not found")
+        process.exit(1)
+      }
+      await share(sdk, sessionID)
+
+      loop().catch((e) => {
+        console.error(e)
+        process.exit(1)
+      })
+
       if (args.command) {
         await sdk.session.command({
           sessionID,
-          agent: resolvedAgent,
+          agent,
           model: args.model,
           command: args.command,
           arguments: message,
           variant: args.variant,
         })
       } else {
-        const modelParam = args.model ? Provider.parseModel(args.model) : undefined
+        const model = args.model ? Provider.parseModel(args.model) : undefined
         await sdk.session.prompt({
           sessionID,
-          agent: resolvedAgent,
-          model: modelParam,
+          agent,
+          model,
           variant: args.variant,
-          parts: [...fileParts, { type: "text", text: message }],
+          parts: [...files, { type: "text", text: message }],
         })
       }
-
-      await eventProcessor
-      if (errorMsg) process.exit(1)
     }
 
     if (args.attach) {
       const sdk = createOpencodeClient({ baseUrl: args.attach })
-
-      const sessionID = await (async () => {
-        if (args.continue) {
-          const result = await sdk.session.list()
-          return result.data?.find((s) => !s.parentID)?.id
-        }
-        if (args.session) return args.session
-
-        const title =
-          args.title !== undefined
-            ? args.title === ""
-              ? message.slice(0, 50) + (message.length > 50 ? "..." : "")
-              : args.title
-            : undefined
-
-        const result = await sdk.session.create(
-          title
-            ? {
-                title,
-                permission: [
-                  {
-                    permission: "question",
-                    action: "deny",
-                    pattern: "*",
-                  },
-                ],
-              }
-            : {
-                permission: [
-                  {
-                    permission: "question",
-                    action: "deny",
-                    pattern: "*",
-                  },
-                ],
-              },
-        )
-        return result.data?.id
-      })()
-
-      if (!sessionID) {
-        UI.error("Session not found")
-        process.exit(1)
-      }
-
-      const cfgResult = await sdk.config.get()
-      if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
-        const shareResult = await sdk.session.share({ sessionID }).catch((error) => {
-          if (error instanceof Error && error.message.includes("disabled")) {
-            UI.println(UI.Style.TEXT_DANGER_BOLD + "!  " + error.message)
-          }
-          return { error }
-        })
-        if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) {
-          UI.println(UI.Style.TEXT_INFO_BOLD + "~  " + shareResult.data.share.url)
-        }
-      }
-
-      return await execute(sdk, sessionID)
+      return await execute(sdk)
     }
 
     await bootstrap(process.cwd(), async () => {
@@ -344,52 +573,7 @@ export const RunCommand = cmd({
         return Server.App().fetch(request)
       }) as typeof globalThis.fetch
       const sdk = createOpencodeClient({ baseUrl: "http://opencode.internal", fetch: fetchFn })
-
-      if (args.command) {
-        const exists = await Command.get(args.command)
-        if (!exists) {
-          UI.error(`Command "${args.command}" not found`)
-          process.exit(1)
-        }
-      }
-
-      const sessionID = await (async () => {
-        if (args.continue) {
-          const result = await sdk.session.list()
-          return result.data?.find((s) => !s.parentID)?.id
-        }
-        if (args.session) return args.session
-
-        const title =
-          args.title !== undefined
-            ? args.title === ""
-              ? message.slice(0, 50) + (message.length > 50 ? "..." : "")
-              : args.title
-            : undefined
-
-        const result = await sdk.session.create(title ? { title } : {})
-        return result.data?.id
-      })()
-
-      if (!sessionID) {
-        UI.error("Session not found")
-        process.exit(1)
-      }
-
-      const cfgResult = await sdk.config.get()
-      if (cfgResult.data && (cfgResult.data.share === "auto" || Flag.OPENCODE_AUTO_SHARE || args.share)) {
-        const shareResult = await sdk.session.share({ sessionID }).catch((error) => {
-          if (error instanceof Error && error.message.includes("disabled")) {
-            UI.println(UI.Style.TEXT_DANGER_BOLD + "!  " + error.message)
-          }
-          return { error }
-        })
-        if (!shareResult.error && "data" in shareResult && shareResult.data?.share?.url) {
-          UI.println(UI.Style.TEXT_INFO_BOLD + "~  " + shareResult.data.share.url)
-        }
-      }
-
-      await execute(sdk, sessionID)
+      await execute(sdk)
     })
   },
 })

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

@@ -169,6 +169,7 @@ export function tui(input: {
         gatherStats: false,
         exitOnCtrlC: false,
         useKittyKeyboard: {},
+        autoFocus: false,
         consoleOptions: {
           keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }],
           onCopySelection: (text) => {

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

@@ -129,6 +129,16 @@ export function Autocomplete(props: {
     return props.input().getTextRange(store.index + 1, props.input().cursorOffset)
   })
 
+  // filter() reads reactive props.value plus non-reactive cursor/text state.
+  // On keypress those can be briefly out of sync, so filter() may return an empty/partial string.
+  // Copy it into search in an effect because effects run after reactive updates have been rendered and painted
+  // so the input has settled and all consumers read the same stable value.
+  const [search, setSearch] = createSignal("")
+  createEffect(() => {
+    const next = filter()
+    setSearch(next ? next : "")
+  })
+
   // When the filter changes due to how TUI works, the mousemove might still be triggered
   // via a synthetic event as the layout moves underneath the cursor. This is a workaround to make sure the input mode remains keyboard so
   // that the mouseover event doesn't trigger when filtering.
@@ -208,7 +218,7 @@ export function Autocomplete(props: {
   }
 
   const [files] = createResource(
-    () => filter(),
+    () => search(),
     async (query) => {
       if (!store.visible || store.visible === "/") return []
 
@@ -378,9 +388,9 @@ export function Autocomplete(props: {
     const mixed: AutocompleteOption[] =
       store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
 
-    const currentFilter = filter()
+    const searchValue = search()
 
-    if (!currentFilter) {
+    if (!searchValue) {
       return mixed
     }
 
@@ -388,7 +398,7 @@ export function Autocomplete(props: {
       return prev
     }
 
-    const result = fuzzysort.go(removeLineRange(currentFilter), mixed, {
+    const result = fuzzysort.go(removeLineRange(searchValue), mixed, {
       keys: [
         (obj) => removeLineRange((obj.value ?? obj.display).trimEnd()),
         "description",
@@ -398,7 +408,7 @@ export function Autocomplete(props: {
       scoreFn: (objResults) => {
         const displayResult = objResults[0]
         let score = objResults.score
-        if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
+        if (displayResult && displayResult.target.startsWith(store.visible + searchValue)) {
           score *= 2
         }
         const frecencyScore = objResults.obj.path ? frecency.getFrecency(objResults.obj.path) : 0

+ 40 - 11
packages/opencode/src/cli/cmd/tui/context/exit.tsx

@@ -1,23 +1,52 @@
 import { useRenderer } from "@opentui/solid"
 import { createSimpleContext } from "./helper"
 import { FormatError, FormatUnknownError } from "@/cli/error"
+type Exit = ((reason?: unknown) => Promise<void>) & {
+  message: {
+    set: (value?: string) => () => void
+    clear: () => void
+    get: () => string | undefined
+  }
+}
 
 export const { use: useExit, provider: ExitProvider } = createSimpleContext({
   name: "Exit",
   init: (input: { onExit?: () => Promise<void> }) => {
     const renderer = useRenderer()
-    return async (reason?: any) => {
-      // Reset window title before destroying renderer
-      renderer.setTerminalTitle("")
-      renderer.destroy()
-      await input.onExit?.()
-      if (reason) {
-        const formatted = FormatError(reason) ?? FormatUnknownError(reason)
-        if (formatted) {
-          process.stderr.write(formatted + "\n")
+    let message: string | undefined
+    const store = {
+      set: (value?: string) => {
+        const prev = message
+        message = value
+        return () => {
+          message = prev
         }
-      }
-      process.exit(0)
+      },
+      clear: () => {
+        message = undefined
+      },
+      get: () => message,
     }
+    const exit: Exit = Object.assign(
+      async (reason?: unknown) => {
+        // Reset window title before destroying renderer
+        renderer.setTerminalTitle("")
+        renderer.destroy()
+        await input.onExit?.()
+        if (reason) {
+          const formatted = FormatError(reason) ?? FormatUnknownError(reason)
+          if (formatted) {
+            process.stderr.write(formatted + "\n")
+          }
+        }
+        const text = store.get()
+        if (text) process.stdout.write(text + "\n")
+        process.exit(0)
+      },
+      {
+        message: store,
+      },
+    )
+    return exit
   },
 })

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

@@ -41,7 +41,6 @@ import { useRenderer } from "@opentui/solid"
 import { createStore, produce } from "solid-js/store"
 import { Global } from "@/global"
 import { Filesystem } from "@/util/filesystem"
-import { useSDK } from "./sdk"
 
 type ThemeColors = {
   primary: RGBA
@@ -429,6 +428,7 @@ export function tint(base: RGBA, overlay: RGBA, alpha: number): RGBA {
 function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJson {
   const bg = RGBA.fromHex(colors.defaultBackground ?? colors.palette[0]!)
   const fg = RGBA.fromHex(colors.defaultForeground ?? colors.palette[7]!)
+  const transparent = RGBA.fromInts(0, 0, 0, 0)
   const isDark = mode == "dark"
 
   const col = (i: number) => {
@@ -479,8 +479,8 @@ function generateSystem(colors: TerminalColors, mode: "dark" | "light"): ThemeJs
       textMuted,
       selectedListItemText: bg,
 
-      // Background colors
-      background: bg,
+      // Background colors - use transparent to respect terminal transparency
+      background: transparent,
       backgroundPanel: grays[2],
       backgroundElement: grays[3],
       backgroundMenu: grays[3],

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

@@ -77,6 +77,7 @@ import { PermissionPrompt } from "./permission"
 import { QuestionPrompt } from "./question"
 import { DialogExportOptions } from "../../ui/dialog-export-options"
 import { formatTranscript } from "../../util/transcript"
+import { UI } from "@/cli/ui.ts"
 
 addDefaultParsers(parsers.parsers)
 
@@ -222,6 +223,19 @@ export function Session() {
 
   // Allow exit when in child session (prompt is hidden)
   const exit = useExit()
+
+  createEffect(() => {
+    const title = Locale.truncate(session()?.title ?? "", 50)
+    return exit.message.set(
+      [
+        ``,
+        `  █▀▀█  ${UI.Style.TEXT_DIM}${title}${UI.Style.TEXT_NORMAL}`,
+        `  █  █  ${UI.Style.TEXT_DIM}opencode -s ${session()?.id}${UI.Style.TEXT_NORMAL}`,
+        `  ▀▀▀▀  `,
+      ].join("\n"),
+    )
+  })
+
   useKeyboard((evt) => {
     if (!session()?.parentID) return
     if (keybind.match("app_exit", evt)) {

+ 1 - 1
packages/opencode/src/cli/cmd/web.ts

@@ -63,7 +63,7 @@ export const WebCommand = cmd({
         UI.println(
           UI.Style.TEXT_INFO_BOLD + "  mDNS:              ",
           UI.Style.TEXT_NORMAL,
-          `opencode.local:${server.port}`,
+          `${opts.mdnsDomain}:${server.port}`,
         )
       }
 

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

@@ -17,6 +17,11 @@ const options = {
     describe: "enable mDNS service discovery (defaults hostname to 0.0.0.0)",
     default: false,
   },
+  "mdns-domain": {
+    type: "string" as const,
+    describe: "custom domain name for mDNS service (default: opencode.local)",
+    default: "opencode.local",
+  },
   cors: {
     type: "string" as const,
     array: true,
@@ -36,9 +41,11 @@ 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 mdnsDomainExplicitlySet = process.argv.includes("--mdns-domain")
   const corsExplicitlySet = process.argv.includes("--cors")
 
   const mdns = mdnsExplicitlySet ? args.mdns : (config?.server?.mdns ?? args.mdns)
+  const mdnsDomain = mdnsDomainExplicitlySet ? args["mdns-domain"] : (config?.server?.mdnsDomain ?? args["mdns-domain"])
   const port = portExplicitlySet ? args.port : (config?.server?.port ?? args.port)
   const hostname = hostnameExplicitlySet
     ? args.hostname
@@ -49,5 +56,5 @@ export async function resolveNetworkOptions(args: NetworkOptions) {
   const argsCors = Array.isArray(args.cors) ? args.cors : args.cors ? [args.cors] : []
   const cors = [...configCors, ...argsCors]
 
-  return { hostname, port, mdns, cors }
+  return { hostname, port, mdns, mdnsDomain, cors }
 }

+ 19 - 11
packages/opencode/src/config/config.ts

@@ -62,8 +62,14 @@ export namespace Config {
   export const state = Instance.state(async () => {
     const auth = await Auth.all()
 
-    // Load remote/well-known config first as the base layer (lowest precedence)
-    // This allows organizations to provide default configs that users can override
+    // Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order
+    // 1) Remote .well-known/opencode (org defaults)
+    // 2) Global config (~/.config/opencode/opencode.json{,c})
+    // 3) Custom config (OPENCODE_CONFIG)
+    // 4) Project config (opencode.json{,c})
+    // 5) .opencode directories (.opencode/agents/, .opencode/commands/, .opencode/plugins/, .opencode/opencode.json{,c})
+    // 6) Inline config (OPENCODE_CONFIG_CONTENT)
+    // Managed config directory is enterprise-only and always overrides everything above.
     let result: Info = {}
     for (const [key, value] of Object.entries(auth)) {
       if (value.type === "wellknown") {
@@ -85,16 +91,16 @@ export namespace Config {
       }
     }
 
-    // Global user config overrides remote config
+    // Global user config overrides remote config.
     result = mergeConfigConcatArrays(result, await global())
 
-    // Custom config path overrides global
+    // Custom config path overrides global config.
     if (Flag.OPENCODE_CONFIG) {
       result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
       log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
     }
 
-    // Project config has highest precedence (overrides global and remote)
+    // Project config overrides global and remote config.
     if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
       for (const file of ["opencode.jsonc", "opencode.json"]) {
         const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
@@ -104,12 +110,6 @@ export namespace Config {
       }
     }
 
-    // Inline config content has highest precedence
-    if (Flag.OPENCODE_CONFIG_CONTENT) {
-      result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
-      log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
-    }
-
     result.agent = result.agent || {}
     result.mode = result.mode || {}
     result.plugin = result.plugin || []
@@ -136,6 +136,7 @@ export namespace Config {
       )),
     ]
 
+    // .opencode directory config overrides (project and global) config sources.
     if (Flag.OPENCODE_CONFIG_DIR) {
       directories.push(Flag.OPENCODE_CONFIG_DIR)
       log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
@@ -163,6 +164,12 @@ export namespace Config {
       result.plugin.push(...(await loadPlugin(dir)))
     }
 
+    // Inline config content overrides all non-managed config sources.
+    if (Flag.OPENCODE_CONFIG_CONTENT) {
+      result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT))
+      log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
+    }
+
     // Load managed config files last (highest priority) - enterprise admin-controlled
     // Kept separate from directories array to avoid write operations when installing plugins
     // which would fail on system directories requiring elevated permissions
@@ -853,6 +860,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"),
+      mdnsDomain: z.string().optional().describe("Custom domain name for mDNS service (default: opencode.local)"),
       cors: z.array(z.string()).optional().describe("Additional domains to allow for CORS"),
     })
     .strict()

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

@@ -355,3 +355,12 @@ export const pint: Info = {
     return false
   },
 }
+
+export const ormolu: Info = {
+  name: "ormolu",
+  command: ["ormolu", "-i", "$FILE"],
+  extensions: [".hs"],
+  async enabled() {
+    return Bun.which("ormolu") !== null
+  },
+}

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

@@ -1,4 +1,5 @@
 import z from "zod"
+import os from "os"
 import fuzzysort from "fuzzysort"
 import { Config } from "../config/config"
 import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
@@ -35,8 +36,9 @@ 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 { createGitLab } from "@gitlab/gitlab-ai-provider"
+import { createGitLab, VERSION as GITLAB_PROVIDER_VERSION } from "@gitlab/gitlab-ai-provider"
 import { ProviderTransform } from "./transform"
+import { Installation } from "../installation"
 
 export namespace Provider {
   const log = Log.create({ service: "provider" })
@@ -424,11 +426,17 @@ export namespace Provider {
       const config = await Config.get()
       const providerConfig = config.provider?.["gitlab"]
 
+      const aiGatewayHeaders = {
+        "User-Agent": `opencode/${Installation.VERSION} gitlab-ai-provider/${GITLAB_PROVIDER_VERSION} (${os.platform()} ${os.release()}; ${os.arch()})`,
+        ...(providerConfig?.options?.aiGatewayHeaders || {}),
+      }
+
       return {
         autoload: !!apiKey,
         options: {
           instanceUrl,
           apiKey,
+          aiGatewayHeaders,
           featureFlags: {
             duo_agent_platform_agentic_chat: true,
             duo_agent_platform: true,
@@ -437,6 +445,7 @@ export namespace Provider {
         },
         async getModel(sdk: ReturnType<typeof createGitLab>, modelID: string) {
           return sdk.agenticChat(modelID, {
+            aiGatewayHeaders,
             featureFlags: {
               duo_agent_platform_agentic_chat: true,
               duo_agent_platform: true,

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

@@ -18,12 +18,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
       case "system": {
         messages.push({
           role: "system",
-          content: [
-            {
-              type: "text",
-              text: content,
-            },
-          ],
+          content: content,
           ...metadata,
         })
         break

+ 11 - 4
packages/opencode/src/provider/transform.ts

@@ -658,11 +658,18 @@ export namespace ProviderTransform {
   }
 
   export function smallOptions(model: Provider.Model) {
-    if (model.providerID === "openai" || model.api.id.includes("gpt-5")) {
-      if (model.api.id.includes("5.")) {
-        return { reasoningEffort: "low" }
+    if (
+      model.providerID === "openai" ||
+      model.api.npm === "@ai-sdk/openai" ||
+      model.api.npm === "@ai-sdk/github-copilot"
+    ) {
+      if (model.api.id.includes("gpt-5")) {
+        if (model.api.id.includes("5.")) {
+          return { store: false, reasoningEffort: "low" }
+        }
+        return { store: false, reasoningEffort: "minimal" }
       }
-      return { reasoningEffort: "minimal" }
+      return { store: false }
     }
     if (model.providerID === "google") {
       // gemini-3 uses thinkingLevel, gemini-2.5 uses thinkingBudget

+ 3 - 2
packages/opencode/src/server/mdns.ts

@@ -7,17 +7,18 @@ export namespace MDNS {
   let bonjour: Bonjour | undefined
   let currentPort: number | undefined
 
-  export function publish(port: number) {
+  export function publish(port: number, domain?: string) {
     if (currentPort === port) return
     if (bonjour) unpublish()
 
     try {
+      const host = domain ?? "opencode.local"
       const name = `opencode-${port}`
       bonjour = new Bonjour()
       const service = bonjour.publish({
         name,
         type: "http",
-        host: "opencode.local",
+        host,
         port,
         txt: { path: "/" },
       })

+ 8 - 2
packages/opencode/src/server/server.ts

@@ -563,7 +563,13 @@ export namespace Server {
     return result
   }
 
-  export function listen(opts: { port: number; hostname: string; mdns?: boolean; cors?: string[] }) {
+  export function listen(opts: {
+    port: number
+    hostname: string
+    mdns?: boolean
+    mdnsDomain?: string
+    cors?: string[]
+  }) {
     _corsWhitelist = opts.cors ?? []
 
     const args = {
@@ -591,7 +597,7 @@ export namespace Server {
       opts.hostname !== "localhost" &&
       opts.hostname !== "::1"
     if (shouldPublishMDNS) {
-      MDNS.publish(server.port!)
+      MDNS.publish(server.port!, opts.mdnsDomain)
     } else if (opts.mdns) {
       log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
     }

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

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

+ 26 - 43
packages/opencode/src/session/prompt.ts

@@ -185,17 +185,13 @@ export namespace SessionPrompt {
         text: template,
       },
     ]
-    const matches = ConfigMarkdown.files(template)
+    const files = ConfigMarkdown.files(template)
     const seen = new Set<string>()
-    const names = matches
-      .map((match) => match[1])
-      .filter((name) => {
-        if (seen.has(name)) return false
+    await Promise.all(
+      files.map(async (match) => {
+        const name = match[1]
+        if (seen.has(name)) return
         seen.add(name)
-        return true
-      })
-    const resolved = await Promise.all(
-      names.map(async (name) => {
         const filepath = name.startsWith("~/")
           ? path.join(os.homedir(), name.slice(2))
           : path.resolve(Instance.worktree, name)
@@ -203,34 +199,33 @@ export namespace SessionPrompt {
         const stats = await fs.stat(filepath).catch(() => undefined)
         if (!stats) {
           const agent = await Agent.get(name)
-          if (!agent) return undefined
-          return {
-            type: "agent",
-            name: agent.name,
-          } satisfies PromptInput["parts"][number]
+          if (agent) {
+            parts.push({
+              type: "agent",
+              name: agent.name,
+            })
+          }
+          return
         }
 
         if (stats.isDirectory()) {
-          return {
+          parts.push({
             type: "file",
             url: `file://${filepath}`,
             filename: name,
             mime: "application/x-directory",
-          } satisfies PromptInput["parts"][number]
+          })
+          return
         }
 
-        return {
+        parts.push({
           type: "file",
           url: `file://${filepath}`,
           filename: name,
           mime: "text/plain",
-        } satisfies PromptInput["parts"][number]
+        })
       }),
     )
-    for (const item of resolved) {
-      if (!item) continue
-      parts.push(item)
-    }
     return parts
   }
 
@@ -430,12 +425,6 @@ export namespace SessionPrompt {
         assistantMessage.time.completed = Date.now()
         await Session.updateMessage(assistantMessage)
         if (result && part.state.status === "running") {
-          const attachments = result.attachments?.map((attachment) => ({
-            ...attachment,
-            id: Identifier.ascending("part"),
-            messageID: assistantMessage.id,
-            sessionID: assistantMessage.sessionID,
-          }))
           await Session.updatePart({
             ...part,
             state: {
@@ -444,7 +433,7 @@ export namespace SessionPrompt {
               title: result.title,
               metadata: result.metadata,
               output: result.output,
-              attachments,
+              attachments: result.attachments,
               time: {
                 ...part.state.time,
                 end: Date.now(),
@@ -783,13 +772,16 @@ export namespace SessionPrompt {
         )
 
         const textParts: string[] = []
-        const attachments: Omit<MessageV2.FilePart, "id" | "messageID" | "sessionID">[] = []
+        const attachments: MessageV2.FilePart[] = []
 
         for (const contentItem of result.content) {
           if (contentItem.type === "text") {
             textParts.push(contentItem.text)
           } else if (contentItem.type === "image") {
             attachments.push({
+              id: Identifier.ascending("part"),
+              sessionID: input.session.id,
+              messageID: input.processor.message.id,
               type: "file",
               mime: contentItem.mimeType,
               url: `data:${contentItem.mimeType};base64,${contentItem.data}`,
@@ -801,6 +793,9 @@ export namespace SessionPrompt {
             }
             if (resource.blob) {
               attachments.push({
+                id: Identifier.ascending("part"),
+                sessionID: input.session.id,
+                messageID: input.processor.message.id,
                 type: "file",
                 mime: resource.mimeType ?? "application/octet-stream",
                 url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`,
@@ -1049,7 +1044,6 @@ export namespace SessionPrompt {
                       pieces.push(
                         ...result.attachments.map((attachment) => ({
                           ...attachment,
-                          id: Identifier.ascending("part"),
                           synthetic: true,
                           filename: attachment.filename ?? part.filename,
                           messageID: info.id,
@@ -1187,18 +1181,7 @@ export namespace SessionPrompt {
           },
         ]
       }),
-    )
-      .then((x) => x.flat())
-      .then((drafts) =>
-        drafts.map(
-          (part): MessageV2.Part => ({
-            ...part,
-            id: Identifier.ascending("part"),
-            messageID: info.id,
-            sessionID: input.sessionID,
-          }),
-        ),
-      )
+    ).then((x) => x.flat())
 
     await Plugin.trigger(
       "chat.message",

+ 19 - 0
packages/opencode/src/snapshot/index.ts

@@ -188,6 +188,7 @@ export namespace Snapshot {
       after: z.string(),
       additions: z.number(),
       deletions: z.number(),
+      status: z.enum(["added", "deleted", "modified"]).optional(),
     })
     .meta({
       ref: "FileDiff",
@@ -196,6 +197,23 @@ export namespace Snapshot {
   export async function diffFull(from: string, to: string): Promise<FileDiff[]> {
     const git = gitdir()
     const result: FileDiff[] = []
+    const status = new Map<string, "added" | "deleted" | "modified">()
+
+    const statuses =
+      await $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --name-status --no-renames ${from} ${to} -- .`
+        .quiet()
+        .cwd(Instance.directory)
+        .nothrow()
+        .text()
+
+    for (const line of statuses.trim().split("\n")) {
+      if (!line) continue
+      const [code, file] = line.split("\t")
+      if (!code || !file) continue
+      const kind = code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified"
+      status.set(file, kind)
+    }
+
     for await (const line of $`git -c core.autocrlf=false -c core.quotepath=false --git-dir ${git} --work-tree ${Instance.worktree} diff --no-ext-diff --no-renames --numstat ${from} ${to} -- .`
       .quiet()
       .cwd(Instance.directory)
@@ -224,6 +242,7 @@ export namespace Snapshot {
         after,
         additions: Number.isFinite(added) ? added : 0,
         deletions: Number.isFinite(deleted) ? deleted : 0,
+        status: status.get(file) ?? "modified",
       })
     }
     return result

+ 7 - 3
packages/opencode/src/tool/bash.ts

@@ -128,7 +128,10 @@ export const BashTool = Tool.define("bash", async () => {
                 process.platform === "win32" && resolved.match(/^\/[a-z]\//)
                   ? resolved.replace(/^\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`).replace(/\//g, "\\")
                   : resolved
-              if (!Instance.containsPath(normalized)) directories.add(normalized)
+              if (!Instance.containsPath(normalized)) {
+                const dir = (await Filesystem.isDir(normalized)) ? normalized : path.dirname(normalized)
+                directories.add(dir)
+              }
             }
           }
         }
@@ -141,10 +144,11 @@ export const BashTool = Tool.define("bash", async () => {
       }
 
       if (directories.size > 0) {
+        const globs = Array.from(directories).map((dir) => path.join(dir, "*"))
         await ctx.ask({
           permission: "external_directory",
-          patterns: Array.from(directories),
-          always: Array.from(directories).map((x) => path.dirname(x) + "*"),
+          patterns: globs,
+          always: globs,
           metadata: {},
         })
       }

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

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

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

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

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

@@ -110,7 +110,7 @@ export namespace ToolRegistry {
       TaskTool,
       WebFetchTool,
       TodoWriteTool,
-      TodoReadTool,
+      // TodoReadTool,
       WebSearchTool,
       CodeSearchTool,
       SkillTool,

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

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

+ 7 - 7
packages/opencode/test/agent/agent.test.ts

@@ -447,7 +447,7 @@ test("legacy tools config maps write/edit/patch/multiedit to edit permission", a
   })
 })
 
-test("Truncate.DIR is allowed even when user denies external_directory globally", async () => {
+test("Truncate.GLOB is allowed even when user denies external_directory globally", async () => {
   const { Truncate } = await import("../../src/tool/truncation")
   await using tmp = await tmpdir({
     config: {
@@ -460,14 +460,14 @@ test("Truncate.DIR is allowed even when user denies external_directory globally"
     directory: tmp.path,
     fn: async () => {
       const build = await Agent.get("build")
-      expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
       expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
+      expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
       expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
     },
   })
 })
 
-test("Truncate.DIR is allowed even when user denies external_directory per-agent", async () => {
+test("Truncate.GLOB is allowed even when user denies external_directory per-agent", async () => {
   const { Truncate } = await import("../../src/tool/truncation")
   await using tmp = await tmpdir({
     config: {
@@ -484,21 +484,21 @@ test("Truncate.DIR is allowed even when user denies external_directory per-agent
     directory: tmp.path,
     fn: async () => {
       const build = await Agent.get("build")
-      expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("allow")
       expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("allow")
+      expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
       expect(PermissionNext.evaluate("external_directory", "/some/other/path", build!.permission).action).toBe("deny")
     },
   })
 })
 
-test("explicit Truncate.DIR deny is respected", async () => {
+test("explicit Truncate.GLOB deny is respected", async () => {
   const { Truncate } = await import("../../src/tool/truncation")
   await using tmp = await tmpdir({
     config: {
       permission: {
         external_directory: {
           "*": "deny",
-          [Truncate.DIR]: "deny",
+          [Truncate.GLOB]: "deny",
         },
       },
     },
@@ -507,8 +507,8 @@ test("explicit Truncate.DIR deny is respected", async () => {
     directory: tmp.path,
     fn: async () => {
       const build = await Agent.get("build")
-      expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
       expect(PermissionNext.evaluate("external_directory", Truncate.GLOB, build!.permission).action).toBe("deny")
+      expect(PermissionNext.evaluate("external_directory", Truncate.DIR, build!.permission).action).toBe("deny")
     },
   })
 })

+ 18 - 0
packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts

@@ -1,6 +1,24 @@
 import { convertToOpenAICompatibleChatMessages as convertToCopilotMessages } from "@/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages"
 import { describe, test, expect } from "bun:test"
 
+describe("system messages", () => {
+  test("should convert system message content to string", () => {
+    const result = convertToCopilotMessages([
+      {
+        role: "system",
+        content: "You are a helpful assistant with AGENTS.md instructions.",
+      },
+    ])
+
+    expect(result).toEqual([
+      {
+        role: "system",
+        content: "You are a helpful assistant with AGENTS.md instructions.",
+      },
+    ])
+  })
+})
+
 describe("user messages", () => {
   test("should convert messages with only a text part to a string content", () => {
     const result = convertToCopilotMessages([

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

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

+ 46 - 0
packages/opencode/test/snapshot/snapshot.test.ts

@@ -749,6 +749,52 @@ test("revert preserves file that existed in snapshot when deleted then recreated
   })
 })
 
+test("diffFull sets status based on git change type", async () => {
+  await using tmp = await bootstrap()
+  await Instance.provide({
+    directory: tmp.path,
+    fn: async () => {
+      await Bun.write(`${tmp.path}/grow.txt`, "one\n")
+      await Bun.write(`${tmp.path}/trim.txt`, "line1\nline2\n")
+      await Bun.write(`${tmp.path}/delete.txt`, "gone")
+
+      const before = await Snapshot.track()
+      expect(before).toBeTruthy()
+
+      await Bun.write(`${tmp.path}/grow.txt`, "one\ntwo\n")
+      await Bun.write(`${tmp.path}/trim.txt`, "line1\n")
+      await $`rm ${tmp.path}/delete.txt`.quiet()
+      await Bun.write(`${tmp.path}/added.txt`, "new")
+
+      const after = await Snapshot.track()
+      expect(after).toBeTruthy()
+
+      const diffs = await Snapshot.diffFull(before!, after!)
+      expect(diffs.length).toBe(4)
+
+      const added = diffs.find((d) => d.file === "added.txt")
+      expect(added).toBeDefined()
+      expect(added!.status).toBe("added")
+
+      const deleted = diffs.find((d) => d.file === "delete.txt")
+      expect(deleted).toBeDefined()
+      expect(deleted!.status).toBe("deleted")
+
+      const grow = diffs.find((d) => d.file === "grow.txt")
+      expect(grow).toBeDefined()
+      expect(grow!.status).toBe("modified")
+      expect(grow!.additions).toBeGreaterThan(0)
+      expect(grow!.deletions).toBe(0)
+
+      const trim = diffs.find((d) => d.file === "trim.txt")
+      expect(trim).toBeDefined()
+      expect(trim!.status).toBe("modified")
+      expect(trim!.additions).toBe(0)
+      expect(trim!.deletions).toBeGreaterThan(0)
+    },
+  })
+})
+
 test("diffFull with new file additions", async () => {
   await using tmp = await bootstrap()
   await Instance.provide({

+ 36 - 1
packages/opencode/test/tool/bash.test.ts

@@ -144,7 +144,42 @@ describe("tool.bash permissions", () => {
         )
         const extDirReq = requests.find((r) => r.permission === "external_directory")
         expect(extDirReq).toBeDefined()
-        expect(extDirReq!.patterns).toContain("/tmp")
+        expect(extDirReq!.patterns).toContain("/tmp/*")
+      },
+    })
+  })
+
+  test("asks for external_directory permission when file arg is outside project", async () => {
+    await using outerTmp = await tmpdir({
+      init: async (dir) => {
+        await Bun.write(path.join(dir, "outside.txt"), "x")
+      },
+    })
+    await using tmp = await tmpdir({ git: true })
+    await Instance.provide({
+      directory: tmp.path,
+      fn: async () => {
+        const bash = await BashTool.init()
+        const requests: Array<Omit<PermissionNext.Request, "id" | "sessionID" | "tool">> = []
+        const testCtx = {
+          ...ctx,
+          ask: async (req: Omit<PermissionNext.Request, "id" | "sessionID" | "tool">) => {
+            requests.push(req)
+          },
+        }
+        const filepath = path.join(outerTmp.path, "outside.txt")
+        await bash.execute(
+          {
+            command: `cat ${filepath}`,
+            description: "Read external file",
+          },
+          testCtx,
+        )
+        const extDirReq = requests.find((r) => r.permission === "external_directory")
+        const expected = path.join(outerTmp.path, "*")
+        expect(extDirReq).toBeDefined()
+        expect(extDirReq!.patterns).toContain(expected)
+        expect(extDirReq!.always).toContain(expected)
       },
     })
   })

+ 1 - 1
packages/plugin/package.json

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

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

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

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

@@ -96,6 +96,7 @@ export type FileDiff = {
   after: string
   additions: number
   deletions: number
+  status?: "added" | "deleted" | "modified"
 }
 
 export type UserMessage = {
@@ -1338,6 +1339,10 @@ export type ServerConfig = {
    * Enable mDNS service discovery
    */
   mdns?: boolean
+  /**
+   * Custom domain name for mDNS service (default: opencode.local)
+   */
+  mdnsDomain?: string
   /**
    * Additional domains to allow for CORS
    */

+ 8 - 0
packages/sdk/openapi.json

@@ -6087,6 +6087,10 @@
           },
           "deletions": {
             "type": "number"
+          },
+          "status": {
+            "type": "string",
+            "enum": ["added", "deleted", "modified"]
           }
         },
         "required": ["file", "before", "after", "additions", "deletions"]
@@ -8933,6 +8937,10 @@
             "description": "Enable mDNS service discovery",
             "type": "boolean"
           },
+          "mdnsDomain": {
+            "description": "Custom domain name for mDNS service (default: opencode.local)",
+            "type": "string"
+          },
           "cors": {
             "description": "Additional domains to allow for CORS",
             "type": "array",

+ 1 - 1
packages/slack/package.json

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

+ 1 - 1
packages/ui/package.json

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

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

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

Some files were not shown because too many files changed in this diff