Просмотр исходного кода

Merge branch 'dev' of https://github.com/sst/opencode into dev

David Hill 2 месяцев назад
Родитель
Сommit
05e0759878
100 измененных файлов с 2685 добавлено и 1022 удалено
  1. 0 63
      .github/workflows/auto-label-tui.yml
  2. 8 2
      .github/workflows/publish.yml
  3. 34 0
      .github/workflows/triage.yml
  4. 12 0
      .opencode/agent/triage.md
  5. 49 0
      .opencode/bun.lock
  6. 4 0
      .opencode/env.d.ts
  7. 3 0
      .opencode/opencode.jsonc
  8. 6 0
      .opencode/package.json
  9. 51 0
      .opencode/tool/github-triage.ts
  10. 80 0
      .opencode/tool/github-triage.txt
  11. 35 32
      bun.lock
  12. 3 3
      flake.lock
  13. 1 1
      github/package.json
  14. 9 8
      install
  15. 1 1
      nix/hashes.json
  16. 2 1
      package.json
  17. 1 1
      packages/console/app/package.json
  18. 21 2
      packages/console/app/src/component/header.tsx
  19. 2 2
      packages/console/app/src/config.ts
  20. 3 0
      packages/console/app/src/entry-server.tsx
  21. 1 0
      packages/console/app/src/routes/index.css
  22. 16 1
      packages/console/app/src/routes/index.tsx
  23. 1 1
      packages/console/core/package.json
  24. 1 1
      packages/console/function/package.json
  25. 1 1
      packages/console/mail/package.json
  26. 1 1
      packages/desktop/package.json
  27. 27 17
      packages/desktop/src/app.tsx
  28. 10 12
      packages/desktop/src/components/dialog-connect-provider.tsx
  29. 9 4
      packages/desktop/src/components/dialog-select-file.tsx
  30. 3 3
      packages/desktop/src/components/dialog-select-model-unpaid.tsx
  31. 3 3
      packages/desktop/src/components/dialog-select-model.tsx
  32. 1 1
      packages/desktop/src/components/dialog-select-provider.tsx
  33. 440 171
      packages/desktop/src/components/prompt-input.tsx
  34. 1 1
      packages/desktop/src/components/terminal.tsx
  35. 239 0
      packages/desktop/src/context/command.tsx
  36. 45 6
      packages/desktop/src/context/global-sync.tsx
  37. 89 3
      packages/desktop/src/context/layout.tsx
  38. 2 1
      packages/desktop/src/context/local.tsx
  39. 21 5
      packages/desktop/src/context/notification.tsx
  40. 112 0
      packages/desktop/src/context/prompt.tsx
  41. 0 321
      packages/desktop/src/context/session.tsx
  42. 106 0
      packages/desktop/src/context/terminal.tsx
  43. 1 1
      packages/desktop/src/hooks/use-providers.ts
  44. 1 4
      packages/desktop/src/pages/directory-layout.tsx
  45. 285 80
      packages/desktop/src/pages/layout.tsx
  46. 348 158
      packages/desktop/src/pages/session.tsx
  47. 47 0
      packages/desktop/src/utils/prompt.ts
  48. 2 2
      packages/enterprise/package.json
  49. 1 1
      packages/enterprise/src/routes/share/[shareID].tsx
  50. 6 6
      packages/extensions/zed/extension.toml
  51. 2 2
      packages/function/package.json
  52. 3 3
      packages/opencode/package.json
  53. 20 20
      packages/opencode/src/agent/agent.ts
  54. 8 1
      packages/opencode/src/cli/cmd/agent.ts
  55. 1 1
      packages/opencode/src/cli/cmd/github.ts
  56. 18 0
      packages/opencode/src/cli/cmd/tui/app.tsx
  57. 7 0
      packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
  58. 1 0
      packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
  59. 8 1
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  60. 7 2
      packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
  61. 1 2
      packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
  62. 8 0
      packages/opencode/src/config/config.ts
  63. 23 11
      packages/opencode/src/lsp/client.ts
  64. 104 20
      packages/opencode/src/lsp/server.ts
  65. 6 1
      packages/opencode/src/provider/transform.ts
  66. 5 1
      packages/opencode/src/pty/index.ts
  67. 8 0
      packages/opencode/src/server/server.ts
  68. 9 0
      packages/opencode/src/session/llm.ts
  69. 8 10
      packages/opencode/src/tool/edit.ts
  70. 2 1
      packages/opencode/src/tool/write.ts
  71. 16 0
      packages/opencode/src/util/archive.ts
  72. 14 0
      packages/opencode/src/util/filesystem.ts
  73. 1 1
      packages/plugin/package.json
  74. 1 1
      packages/plugin/src/index.ts
  75. 1 1
      packages/sdk/js/package.json
  76. 11 0
      packages/sdk/js/src/v2/gen/types.gen.ts
  77. 19 0
      packages/sdk/openapi.json
  78. 1 1
      packages/slack/package.json
  79. 1 1
      packages/tauri/package.json
  80. 0 1
      packages/tauri/scripts/predev.ts
  81. 1 0
      packages/tauri/src-tauri/src/lib.rs
  82. 1 1
      packages/tauri/src-tauri/tauri.conf.json
  83. 6 2
      packages/tauri/src/index.tsx
  84. 94 0
      packages/tauri/src/menu.ts
  85. 2 0
      packages/tauri/src/updater.ts
  86. 2 2
      packages/ui/package.json
  87. BIN
      packages/ui/src/assets/audio/nope-01.aac
  88. BIN
      packages/ui/src/assets/audio/nope-02.aac
  89. BIN
      packages/ui/src/assets/audio/nope-03.aac
  90. BIN
      packages/ui/src/assets/audio/nope-04.aac
  91. BIN
      packages/ui/src/assets/audio/nope-05.aac
  92. 1 1
      packages/ui/src/components/code.tsx
  93. 8 0
      packages/ui/src/components/dialog.tsx
  94. 4 4
      packages/ui/src/components/diff-ssr.tsx
  95. 2 2
      packages/ui/src/components/diff.css
  96. 1 1
      packages/ui/src/components/diff.tsx
  97. 1 0
      packages/ui/src/components/icon.tsx
  98. 1 0
      packages/ui/src/components/list.css
  99. 1 1
      packages/ui/src/components/list.tsx
  100. 101 5
      packages/ui/src/components/message-part.css

+ 0 - 63
.github/workflows/auto-label-tui.yml

@@ -1,63 +0,0 @@
-name: Auto-label TUI Issues
-
-on:
-  issues:
-    types: [opened]
-
-jobs:
-  auto-label:
-    runs-on: blacksmith-4vcpu-ubuntu-2404
-    permissions:
-      contents: read
-      issues: write
-    steps:
-      - name: Auto-label and assign issues
-        uses: actions/github-script@v7
-        with:
-          github-token: ${{ secrets.GITHUB_TOKEN }}
-          script: |
-            const issue = context.payload.issue;
-            const title = issue.title;
-            const description = issue.body || '';
-
-            // Check for "opencode web" keyword
-            const webPattern = /(opencode web)/i;
-            const isWebRelated = webPattern.test(title) || webPattern.test(description);
-
-            // Check for version patterns like v1.0.x or 1.0.x
-            const versionPattern = /[v]?1\.0\./i;
-            const isVersionRelated = versionPattern.test(title) || versionPattern.test(description);
-
-            // Check for "nix" keyword
-            const nixPattern = /\bnix\b/i;
-            const isNixRelated = nixPattern.test(title) || nixPattern.test(description);
-
-            const labels = [];
-
-            if (isWebRelated) {
-              labels.push('web');
-              
-              // Assign to adamdotdevin
-              await github.rest.issues.addAssignees({
-                owner: context.repo.owner,
-                repo: context.repo.repo,
-                issue_number: issue.number,
-                assignees: ['adamdotdevin']
-              });
-            } else if (isVersionRelated) {
-              // Only add opentui if NOT web-related
-              labels.push('opentui');
-            }
-
-            if (isNixRelated) {
-              labels.push('nix');
-            }
-
-            if (labels.length > 0) {
-              await github.rest.issues.addLabels({
-                owner: context.repo.owner,
-                repo: context.repo.repo,
-                issue_number: issue.number,
-                labels: labels
-              });
-            }

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

@@ -21,7 +21,7 @@ on:
         required: false
         type: string
 
-concurrency: ${{ github.workflow }}-${{ github.ref }}
+concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
 
 permissions:
   id-token: write
@@ -109,6 +109,7 @@ jobs:
       - uses: actions/checkout@v3
         with:
           fetch-depth: 0
+          ref: ${{ needs.publish.outputs.tagName }}
 
       - uses: apple-actions/import-codesign-certs@v2
         if: ${{ runner.os == 'macOS' }}
@@ -166,10 +167,15 @@ jobs:
           GH_TOKEN: ${{ github.token }}
 
       # Fixes AppImage build issues, can be removed when https://github.com/tauri-apps/tauri/pull/12491 is released
-      - run: cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage
+      - name: Install tauri-cli from portable appimage branch
         if: contains(matrix.settings.host, 'ubuntu')
+        run: |
+          cargo install tauri-cli --git https://github.com/tauri-apps/tauri --branch feat/truly-portable-appimage --force
+          echo "Installed tauri-cli version:"
+          cargo tauri --version
 
       - name: Build and upload artifacts
+        timeout-minutes: 20
         uses: tauri-apps/tauri-action@390cbe447412ced1303d35abe75287949e43437a
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 34 - 0
.github/workflows/triage.yml

@@ -0,0 +1,34 @@
+name: Issue Triage
+
+on:
+  issues:
+    types: [opened]
+
+jobs:
+  triage:
+    runs-on: blacksmith-4vcpu-ubuntu-2404
+    permissions:
+      contents: read
+      issues: write
+    steps:
+      - name: Checkout repository
+        uses: actions/checkout@v4
+        with:
+          fetch-depth: 1
+
+      - name: Install opencode
+        run: curl -fsSL https://opencode.ai/install | bash
+
+      - name: Triage issue
+        env:
+          OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          ISSUE_NUMBER: ${{ github.event.issue.number }}
+          ISSUE_TITLE: ${{ github.event.issue.title }}
+          ISSUE_BODY: ${{ github.event.issue.body }}
+        run: |
+          opencode run --agent triage "The following issue was just opened, triage it:
+
+          Title: $ISSUE_TITLE
+
+          $ISSUE_BODY"

+ 12 - 0
.opencode/agent/triage.md

@@ -0,0 +1,12 @@
+---
+mode: primary
+hidden: true
+model: opencode/gpt-5-nano
+tools:
+  "*": false
+  "github-triage": true
+---
+
+You are a triage agent responsible for triaging github issues.
+
+Use your github-triage tool to triage issues.

+ 49 - 0
.opencode/bun.lock

@@ -0,0 +1,49 @@
+{
+  "lockfileVersion": 1,
+  "configVersion": 0,
+  "workspaces": {
+    "": {
+      "dependencies": {
+        "@octokit/rest": "^22.0.1",
+        "@opencode-ai/plugin": "0.0.0-dev-202512160508",
+      },
+    },
+  },
+  "packages": {
+    "@octokit/auth-token": ["@octokit/[email protected]", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="],
+
+    "@octokit/core": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/auth-token": "^6.0.0", "@octokit/graphql": "^9.0.3", "@octokit/request": "^10.0.6", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "before-after-hook": "^4.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q=="],
+
+    "@octokit/endpoint": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ=="],
+
+    "@octokit/graphql": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/request": "^10.0.6", "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" } }, "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA=="],
+
+    "@octokit/openapi-types": ["@octokit/[email protected]", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="],
+
+    "@octokit/plugin-paginate-rest": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw=="],
+
+    "@octokit/plugin-request-log": ["@octokit/[email protected]", "", { "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q=="],
+
+    "@octokit/plugin-rest-endpoint-methods": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/types": "^16.0.0" }, "peerDependencies": { "@octokit/core": ">=6" } }, "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw=="],
+
+    "@octokit/request": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/endpoint": "^11.0.2", "@octokit/request-error": "^7.0.2", "@octokit/types": "^16.0.0", "fast-content-type-parse": "^3.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA=="],
+
+    "@octokit/request-error": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="],
+
+    "@octokit/rest": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/core": "^7.0.6", "@octokit/plugin-paginate-rest": "^14.0.0", "@octokit/plugin-request-log": "^6.0.0", "@octokit/plugin-rest-endpoint-methods": "^17.0.0" } }, "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw=="],
+
+    "@octokit/types": ["@octokit/[email protected]", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="],
+
+    "@opencode-ai/plugin": ["@opencode-ai/[email protected]", "", { "dependencies": { "@opencode-ai/sdk": "0.0.0-dev-202512160508", "zod": "4.1.8" } }, "sha512-GLnvMQhEWRHG9E84FyyQKPKi54bGUkytXPfZYjwNy9W6djw8zAW/kpeYPrdIJHPdTHk4OjIHEwoB1SXZzGaLFQ=="],
+
+    "@opencode-ai/sdk": ["@opencode-ai/[email protected]", "", {}, "sha512-ICpZ1bX528yQKqYGGyUJQMu3RY0F1pQ6RCoTJ4ESLiYmcXUY1EldgIidiwPA+A/zpEXLu2lPwPZ1LYn/bX6aFA=="],
+
+    "before-after-hook": ["[email protected]", "", {}, "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ=="],
+
+    "fast-content-type-parse": ["[email protected]", "", {}, "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg=="],
+
+    "universal-user-agent": ["[email protected]", "", {}, "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A=="],
+
+    "zod": ["[email protected]", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
+  }
+}

+ 4 - 0
.opencode/env.d.ts

@@ -0,0 +1,4 @@
+declare module "*.txt" {
+  const content: string
+  export default content
+}

+ 3 - 0
.opencode/opencode.jsonc

@@ -11,4 +11,7 @@
     },
   },
   "mcp": {},
+  "tools": {
+    "github-triage": false,
+  },
 }

+ 6 - 0
.opencode/package.json

@@ -0,0 +1,6 @@
+{
+  "dependencies": {
+    "@octokit/rest": "^22.0.1",
+    "@opencode-ai/plugin": "0.0.0-dev-202512160508"
+  }
+}

+ 51 - 0
.opencode/tool/github-triage.ts

@@ -0,0 +1,51 @@
+import { Octokit } from "@octokit/rest"
+import { tool } from "@opencode-ai/plugin"
+import DESCRIPTION from "./github-triage.txt"
+
+function getIssueNumber(): number {
+  const issue = parseInt(process.env.ISSUE_NUMBER ?? "", 10)
+  if (!issue) throw new Error("ISSUE_NUMBER env var not set")
+  return issue
+}
+
+export default tool({
+  description: DESCRIPTION,
+  args: {
+    assignee: tool.schema
+      .enum(["thdxr", "adamdotdevin", "rekram1-node", "fwang", "jayair", "kommander"])
+      .describe("The username of the assignee")
+      .default("rekram1-node"),
+    labels: tool.schema
+      .array(tool.schema.enum(["nix", "opentui", "perf", "web", "zen", "docs"]))
+      .describe("The labels(s) to add to the issue")
+      .optional(),
+  },
+  async execute(args) {
+    const issue = getIssueNumber()
+    const octokit = new Octokit({ auth: process.env.GITHUB_TOKEN })
+    const owner = "sst"
+    const repo = "opencode"
+
+    const results: string[] = []
+
+    await octokit.rest.issues.addAssignees({
+      owner,
+      repo,
+      issue_number: issue,
+      assignees: [args.assignee],
+    })
+    results.push(`Assigned @${args.assignee} to issue #${issue}`)
+
+    if (args.labels && args.labels.length > 0) {
+      await octokit.rest.issues.addLabels({
+        owner,
+        repo,
+        issue_number: issue,
+        labels: args.labels,
+      })
+      results.push(`Added labels: ${args.labels.join(", ")}`)
+    }
+
+    return results.join("\n")
+  },
+})

+ 80 - 0
.opencode/tool/github-triage.txt

@@ -0,0 +1,80 @@
+Use this tool to assign and/or label a Github issue.
+
+You can assign the following users:
+- thdxr
+- adamdotdevin
+- fwang
+- jayair
+- kommander
+- rekram1-node
+
+
+You can use the following labels:
+- nix
+- opentui
+- perf
+- web
+- zen
+- docs
+
+Always try to assign an issue, if in doubt, assign rekram1-node to it.
+
+## Breakdown of responsibilities:
+
+### thdxr
+
+Dax is responsible for managing core parts of the application, for large feature requests, api changes, or things that require significant changes to the codebase assign him.
+
+This relates to OpenCode server primarily but has overlap with just about anything
+
+### adamdotdevin
+
+Adam is responsible for managing the Desktop/Web app. If there is an issue relating to the desktop app or `opencode web` command. Assign him.
+
+
+### fwang
+
+Frank is responsible for managing Zen, if you see complaints about OpenCode Zen, maybe it's the dashboard, the model quality, billing issues, etc. Assign him to the issue.
+
+### jayair
+
+Jay is responsible for documentation. If there is an issue relating to documentation assign him.
+
+### kommander
+
+Sebastian is responsible for managing an OpenTUI (a library for building terminal user interfaces). OpenCode's TUI is built with OpenTUI. If there are issues about:
+- random characters on screen
+- keybinds not working on different terminals
+- general terminal stuff
+Then assign the issue to Him.
+
+### rekram1-node
+
+Assign Aiden to an issue as a catch all, if you can't assign anyone else. Most of the time this will be bugs/polish things.
+If no one else makes sense to assign, assign rekram1-node to it.
+
+## Breakdown of Labels:
+
+### nix
+
+Any issue that mentions nix, or nixos should have a nix label
+
+### opentui
+
+Anything relating to the TUI itself should have an opentui label
+
+### perf
+
+Anything related to slow performance, high ram, high cpu usage, or any other performance related issue should have a perf label
+
+### web
+
+Anything related to `opencode web` or the desktop app should have a web label. Never add this label for anything terminal/tui related
+
+### zen
+
+Anything related to OpenCode Zen, billing, or model quality from Zen should have a zen label
+
+### docs
+
+Anything related to the documentation should have a docs label

+ 35 - 32
bun.lock

@@ -20,7 +20,7 @@
     },
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@cloudflare/vite-plugin": "1.15.2",
         "@ibm/plex": "6.4.1",
@@ -48,7 +48,7 @@
     },
     "packages/console/core": {
       "name": "@opencode-ai/console-core",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@aws-sdk/client-sts": "3.782.0",
         "@jsx-email/render": "1.1.1",
@@ -75,7 +75,7 @@
     },
     "packages/console/function": {
       "name": "@opencode-ai/console-function",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@ai-sdk/anthropic": "2.0.0",
         "@ai-sdk/openai": "2.0.2",
@@ -99,7 +99,7 @@
     },
     "packages/console/mail": {
       "name": "@opencode-ai/console-mail",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@jsx-email/all": "2.2.3",
         "@jsx-email/cli": "1.4.3",
@@ -123,7 +123,7 @@
     },
     "packages/desktop": {
       "name": "@opencode-ai/desktop",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -170,11 +170,11 @@
     },
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/util": "workspace:*",
-        "@pierre/precision-diffs": "catalog:",
+        "@pierre/diffs": "catalog:",
         "@solidjs/meta": "catalog:",
         "@solidjs/router": "catalog:",
         "@solidjs/start": "catalog:",
@@ -199,10 +199,10 @@
     },
     "packages/function": {
       "name": "@opencode-ai/function",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
-        "@octokit/rest": "22.0.0",
+        "@octokit/rest": "catalog:",
         "hono": "catalog:",
         "jose": "6.0.11",
       },
@@ -215,7 +215,7 @@
     },
     "packages/opencode": {
       "name": "opencode",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -238,7 +238,7 @@
         "@hono/zod-validator": "catalog:",
         "@modelcontextprotocol/sdk": "1.15.1",
         "@octokit/graphql": "9.0.2",
-        "@octokit/rest": "22.0.0",
+        "@octokit/rest": "catalog:",
         "@openauthjs/openauth": "catalog:",
         "@opencode-ai/plugin": "workspace:*",
         "@opencode-ai/script": "workspace:*",
@@ -248,7 +248,7 @@
         "@opentui/core": "0.0.0-20251211-4403a69a",
         "@opentui/solid": "0.0.0-20251211-4403a69a",
         "@parcel/watcher": "2.5.1",
-        "@pierre/precision-diffs": "catalog:",
+        "@pierre/diffs": "catalog:",
         "@solid-primitives/event-bus": "1.1.2",
         "@standard-schema/spec": "1.0.0",
         "@zip.js/zip.js": "2.7.62",
@@ -307,7 +307,7 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "zod": "catalog:",
@@ -327,7 +327,7 @@
     },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "devDependencies": {
         "@hey-api/openapi-ts": "0.88.1",
         "@tsconfig/node22": "catalog:",
@@ -338,7 +338,7 @@
     },
     "packages/slack": {
       "name": "@opencode-ai/slack",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
@@ -351,7 +351,7 @@
     },
     "packages/tauri": {
       "name": "@opencode-ai/tauri",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@opencode-ai/desktop": "workspace:*",
         "@tauri-apps/api": "^2",
@@ -376,12 +376,12 @@
     },
     "packages/ui": {
       "name": "@opencode-ai/ui",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/util": "workspace:*",
-        "@pierre/precision-diffs": "catalog:",
+        "@pierre/diffs": "catalog:",
         "@shikijs/transformers": "3.9.2",
         "@solid-primitives/bounds": "0.1.3",
         "@solid-primitives/resize-observer": "2.1.3",
@@ -411,7 +411,7 @@
     },
     "packages/util": {
       "name": "@opencode-ai/util",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "zod": "catalog:",
       },
@@ -422,7 +422,7 @@
     },
     "packages/web": {
       "name": "@opencode-ai/web",
-      "version": "1.0.153",
+      "version": "1.0.162",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
@@ -470,8 +470,9 @@
     "@cloudflare/workers-types": "4.20251008.0",
     "@hono/zod-validator": "0.4.2",
     "@kobalte/core": "0.13.11",
+    "@octokit/rest": "22.0.0",
     "@openauthjs/openauth": "0.0.0-20250322224806",
-    "@pierre/precision-diffs": "0.6.1",
+    "@pierre/diffs": "1.0.0-beta.3",
     "@solidjs/meta": "0.29.4",
     "@solidjs/router": "0.15.4",
     "@solidjs/start": "https://pkg.pr.new/@solidjs/start@dfb2020",
@@ -1286,7 +1287,7 @@
 
     "@petamoriken/float16": ["@petamoriken/[email protected]", "", {}, "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g=="],
 
-    "@pierre/precision-diffs": ["@pierre/[email protected]", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/transformers": "3.15.0", "diff": "8.0.2", "fast-deep-equal": "3.1.3", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.15.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-HXafRSOly6B0rRt6fuP0yy1MimHJMQ2NNnBGcIHhHwsgK4WWs+SBWRWt1usdgz0NIuSgXdIyQn8HY3F1jKyDBQ=="],
+    "@pierre/diffs": ["@pierre/[email protected]", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/transformers": "3.19.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "3.19.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-W3dFWdFOBZ9OskGSOgN16aci8dsUyAavCxz3ZvbbVLTb2qRzMZ7H90qdfON13/N2l1HTyh84lkrCs1/sDvnRjQ=="],
 
     "@pkgjs/parseargs": ["@pkgjs/[email protected]", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
 
@@ -4112,11 +4113,13 @@
 
     "@parcel/watcher/node-addon-api": ["[email protected]", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
 
-    "@pierre/precision-diffs/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-8TOG6yG557q+fMsSVa8nkEDOZNTSxjbbR8l6lF2gyr6Np+jrPlslqDxQkN6rMXCECQ3isNPZAGszAfYoJOPGlg=="],
+    "@pierre/diffs/@shikijs/core": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-L7SrRibU7ZoYi1/TrZsJOFAnnHyLTE1SwHG1yNWjZIVCqjOEmCSuK2ZO9thnRbJG6TOkPp+Z963JmpCNw5nzvA=="],
 
-    "@pierre/precision-diffs/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/types": "3.15.0" } }, "sha512-Hmwip5ovvSkg+Kc41JTvSHHVfCYF+C8Cp1omb5AJj4Xvd+y9IXz2rKJwmFRGsuN0vpHxywcXJ1+Y4B9S7EG1/A=="],
+    "@pierre/diffs/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-ZfWJNm2VMhKkQIKT9qXbs76RRcT0SF/CAvEz0+RkpUDAoDaCx0uFdCGzSRiD9gSlhm6AHkjdieOBJMaO2eC1rQ=="],
 
-    "@pierre/precision-diffs/shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="],
+    "@pierre/diffs/@shikijs/transformers": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/types": "3.19.0" } }, "sha512-e6vwrsyw+wx4OkcrDbL+FVCxwx8jgKiCoXzakVur++mIWVcgpzIi8vxf4/b4dVTYrV/nUx5RjinMf4tq8YV8Fw=="],
+
+    "@pierre/diffs/shiki": ["[email protected]", "", { "dependencies": { "@shikijs/core": "3.19.0", "@shikijs/engine-javascript": "3.19.0", "@shikijs/engine-oniguruma": "3.19.0", "@shikijs/langs": "3.19.0", "@shikijs/themes": "3.19.0", "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-77VJr3OR/VUZzPiStyRhADmO2jApMM0V2b1qf0RpfWya8Zr1PeZev5AEpPGAAKWdiYUtcZGBE4F5QvJml1PvWA=="],
 
     "@poppinss/dumper/supports-color": ["[email protected]", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
 
@@ -4670,19 +4673,19 @@
 
     "@opentui/solid/@babel/core/semver": ["[email protected]", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
 
-    "@pierre/precision-diffs/@shikijs/core/@shikijs/types": ["@shikijs/[email protected]5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="],
+    "@pierre/diffs/@shikijs/core/@shikijs/types": ["@shikijs/[email protected]9.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
 
-    "@pierre/precision-diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/[email protected].0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="],
+    "@pierre/diffs/@shikijs/engine-javascript/@shikijs/types": ["@shikijs/[email protected].0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
 
-    "@pierre/precision-diffs/shiki/@shikijs/engine-javascript": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.3" } }, "sha512-ZedbOFpopibdLmvTz2sJPJgns8Xvyabe2QbmqMTz07kt1pTzfEvKZc5IqPVO/XFiEbbNyaOpjPBkkr1vlwS+qg=="],
+    "@pierre/diffs/@shikijs/transformers/@shikijs/types": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
 
-    "@pierre/precision-diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/[email protected]5.0", "", { "dependencies": { "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-HnqFsV11skAHvOArMZdLBZZApRSYS4LSztk2K3016Y9VCyZISnlYUYsL2hzlS7tPqKHvNqmI5JSUJZprXloMvA=="],
+    "@pierre/diffs/shiki/@shikijs/engine-oniguruma": ["@shikijs/[email protected]9.0", "", { "dependencies": { "@shikijs/types": "3.19.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-1hRxtYIJfJSZeM5ivbUXv9hcJP3PWRo5prG/V2sWwiubUKTa+7P62d2qxCW8jiVFX4pgRHhnHNp+qeR7Xl+6kg=="],
 
-    "@pierre/precision-diffs/shiki/@shikijs/langs": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-WpRvEFvkVvO65uKYW4Rzxs+IG0gToyM8SARQMtGGsH4GDMNZrr60qdggXrFOsdfOVssG/QQGEl3FnJ3EZ+8w8A=="],
+    "@pierre/diffs/shiki/@shikijs/langs": ["@shikijs/[email protected]", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-dBMFzzg1QiXqCVQ5ONc0z2ebyoi5BKz+MtfByLm0o5/nbUu3Iz8uaTCa5uzGiscQKm7lVShfZHU1+OG3t5hgwg=="],
 
-    "@pierre/precision-diffs/shiki/@shikijs/themes": ["@shikijs/[email protected]5.0", "", { "dependencies": { "@shikijs/types": "3.15.0" } }, "sha512-8ow2zWb1IDvCKjYb0KiLNrK4offFdkfNVPXb1OZykpLCzRU6j+efkY+Y7VQjNlNFXonSw+4AOdGYtmqykDbRiQ=="],
+    "@pierre/diffs/shiki/@shikijs/themes": ["@shikijs/[email protected]9.0", "", { "dependencies": { "@shikijs/types": "3.19.0" } }, "sha512-H36qw+oh91Y0s6OlFfdSuQ0Ld+5CgB/VE6gNPK+Hk4VRbVG/XQgkjnt4KzfnnoO6tZPtKJKHPjwebOCfjd6F8A=="],
 
-    "@pierre/precision-diffs/shiki/@shikijs/types": ["@shikijs/[email protected].0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-BnP+y/EQnhihgHy4oIAN+6FFtmfTekwOLsQbRw9hOKwqgNy8Bdsjq8B05oAt/ZgvIWWFrshV71ytOrlPfYjIJw=="],
+    "@pierre/diffs/shiki/@shikijs/types": ["@shikijs/[email protected].0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-Z2hdeEQlzuntf/BZpFG8a+Fsw9UVXdML7w0o3TgSXV3yNESGon+bs9ITkQb3Ki7zxoXOOu5oJWqZ2uto06V9iQ=="],
 
     "@slack/web-api/form-data/mime-types": ["[email protected]", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
 

+ 3 - 3
flake.lock

@@ -2,11 +2,11 @@
   "nodes": {
     "nixpkgs": {
       "locked": {
-        "lastModified": 1765425892,
-        "narHash": "sha256-jlQpSkg2sK6IJVzTQBDyRxQZgKADC2HKMRfGCSgNMHo=",
+        "lastModified": 1765772535,
+        "narHash": "sha256-aq+dQoaPONOSjtFIBnAXseDm9TUhIbe215TPmkfMYww=",
         "owner": "NixOS",
         "repo": "nixpkgs",
-        "rev": "5d6bdbddb4695a62f0d00a3620b37a15275a5093",
+        "rev": "09b8fda8959d761445f12b55f380d90375a1d6bb",
         "type": "github"
       },
       "original": {

+ 1 - 1
github/package.json

@@ -13,7 +13,7 @@
     "@actions/core": "1.11.1",
     "@actions/github": "6.0.1",
     "@octokit/graphql": "9.0.1",
-    "@octokit/rest": "22.0.0",
+    "@octokit/rest": "catalog:",
     "@opencode-ai/sdk": "workspace:*"
   }
 }

+ 9 - 8
install

@@ -240,22 +240,23 @@ download_with_progress() {
 
 download_and_install() {
     print_message info "\n${MUTED}Installing ${NC}opencode ${MUTED}version: ${NC}$specific_version"
-    mkdir -p opencodetmp && cd opencodetmp
+    local tmp_dir="${TMPDIR:-/tmp}/opencode_install_$$"
+    mkdir -p "$tmp_dir"
     
-    if [[ "$os" == "windows" ]] || ! download_with_progress "$url" "$filename"; then
-        # Fallback to standard curl on Windows or if custom progress fails
-        curl -# -L -o "$filename" "$url"
+    if [[ "$os" == "windows" ]] || ! [ -t 2 ] || ! download_with_progress "$url" "$tmp_dir/$filename"; then
+        # Fallback to standard curl on Windows, non-TTY environments, or if custom progress fails
+        curl -# -L -o "$tmp_dir/$filename" "$url"
     fi
 
     if [ "$os" = "linux" ]; then
-        tar -xzf "$filename"
+        tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
     else
-        unzip -q "$filename"
+        unzip -q "$tmp_dir/$filename" -d "$tmp_dir"
     fi
     
-    mv opencode "$INSTALL_DIR"
+    mv "$tmp_dir/opencode" "$INSTALL_DIR"
     chmod 755 "${INSTALL_DIR}/opencode"
-    cd .. && rm -rf opencodetmp
+    rm -rf "$tmp_dir"
 }
 
 check_version

+ 1 - 1
nix/hashes.json

@@ -1,3 +1,3 @@
 {
-  "nodeModules": "sha256-lgPsYtNJT7a+mDk5cTiEJLlBnTMTjxZCl8bw5WxcuaM="
+  "nodeModules": "sha256-PyoVOza+3WnwZbtpPF6uSN1zkyLsSG2VsgBfIMvIFAs="
 }

+ 2 - 1
package.json

@@ -21,6 +21,7 @@
     ],
     "catalog": {
       "@types/bun": "1.3.4",
+      "@octokit/rest": "22.0.0",
       "@hono/zod-validator": "0.4.2",
       "ulid": "3.0.1",
       "@kobalte/core": "0.13.11",
@@ -30,7 +31,7 @@
       "@tsconfig/bun": "1.0.9",
       "@cloudflare/workers-types": "4.20251008.0",
       "@openauthjs/openauth": "0.0.0-20250322224806",
-      "@pierre/precision-diffs": "0.6.1",
+      "@pierre/diffs": "1.0.0-beta.3",
       "@tailwindcss/vite": "4.1.11",
       "diff": "8.0.2",
       "ai": "5.0.97",

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

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

+ 21 - 2
packages/console/app/src/component/header.tsx

@@ -119,8 +119,8 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
     <section data-component="top">
       <div onContextMenu={handleLogoContextMenu}>
         <A href="/">
-          <img data-slot="logo light" src={logoLight} alt="opencode logo light" />
-          <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" />
+          <img data-slot="logo light" src={logoLight} alt="opencode logo light" width="189" height="34" />
+          <img data-slot="logo dark" src={logoDark} alt="opencode logo dark" width="189" height="34" />
         </A>
       </div>
 
@@ -169,6 +169,25 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
               </Match>
             </Switch>
           </li>
+          <Show when={!props.hideGetStarted}>
+            {" "}
+            <li>
+              {" "}
+              <A href="/download" data-slot="cta-button">
+                {" "}
+                <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+                  {" "}
+                  <path
+                    d="M12.1875 9.75L9.00001 12.9375L5.8125 9.75M9.00001 2.0625L9 12.375M14.4375 15.9375H3.5625"
+                    stroke="currentColor"
+                    stroke-width="1.5"
+                    stroke-linecap="square"
+                  />{" "}
+                </svg>{" "}
+                Free{" "}
+              </A>{" "}
+            </li>
+          </Show>
         </ul>
       </nav>
       <nav data-component="nav-mobile">

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

@@ -22,8 +22,8 @@ export const config = {
 
   // Static stats (used on landing page)
   stats: {
-    contributors: "375",
-    commits: "5,250",
+    contributors: "400",
+    commits: "5,000",
     monthlyUsers: "400,000",
   },
 } as const

+ 3 - 0
packages/console/app/src/entry-server.tsx

@@ -1,6 +1,8 @@
 // @refresh reload
 import { createHandler, StartServer } from "@solidjs/start/server"
 
+const criticalCSS = `[data-component="top"]{min-height:80px;display:flex;align-items:center}`
+
 export default createHandler(
   () => (
     <StartServer
@@ -11,6 +13,7 @@ export default createHandler(
             <meta name="viewport" content="width=device-width, initial-scale=1" />
             <meta property="og:image" content="/social-share.png" />
             <meta property="twitter:image" content="/social-share.png" />
+            <style>{criticalCSS}</style>
             {assets}
           </head>
           <body>

+ 1 - 0
packages/console/app/src/routes/index.css

@@ -206,6 +206,7 @@ body {
   [data-component="top"] {
     padding: 24px var(--padding);
     height: 80px;
+    min-height: 80px;
     position: sticky;
     top: 0;
     display: flex;

+ 16 - 1
packages/console/app/src/routes/index.tsx

@@ -52,6 +52,21 @@ export default function Home() {
 
         <div data-component="content">
           <section data-component="hero">
+            <div data-component="desktop-app-banner">
+              <span data-slot="badge">New</span>
+              <div data-slot="content">
+                <span data-slot="text">
+                  Desktop app available in beta<span data-slot="platforms"> on macOS, Windows, and Linux</span>.
+                </span>
+                <a href="/download" data-slot="link">
+                  Download now
+                </a>
+                <a href="/download" data-slot="link-mobile">
+                  Download the desktop beta now
+                </a>
+              </div>
+            </div>
+
             <div data-slot="hero-copy">
               {/*<a data-slot="releases"*/}
               {/*   href={release()?.url ?? `${config.github.repoUrl}/releases`}*/}
@@ -213,7 +228,7 @@ export default function Home() {
                 <span>[*]</span>
                 <p>
                   With over <strong>{config.github.starsFormatted.full}</strong> GitHub stars,{" "}
-                  <strong>{config.stats.contributors}</strong> contributors, and almost{" "}
+                  <strong>{config.stats.contributors}</strong> contributors, and over{" "}
                   <strong>{config.stats.commits}</strong> commits, OpenCode is used and trusted by over{" "}
                   <strong>{config.stats.monthlyUsers}</strong> developers every month.
                 </p>

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

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

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

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

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

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

+ 1 - 1
packages/desktop/package.json

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

+ 27 - 17
packages/desktop/src/app.tsx

@@ -9,9 +9,11 @@ import { Diff } from "@opencode-ai/ui/diff"
 import { GlobalSyncProvider } from "@/context/global-sync"
 import { LayoutProvider } from "@/context/layout"
 import { GlobalSDKProvider } from "@/context/global-sdk"
-import { SessionProvider } from "@/context/session"
+import { TerminalProvider } from "@/context/terminal"
+import { PromptProvider } from "@/context/prompt"
 import { NotificationProvider } from "@/context/notification"
 import { DialogProvider } from "@opencode-ai/ui/context/dialog"
+import { CommandProvider } from "@/context/command"
 import Layout from "@/pages/layout"
 import Home from "@/pages/home"
 import DirectoryLayout from "@/pages/directory-layout"
@@ -34,16 +36,22 @@ const url =
 
 export function App() {
   return (
-    <MarkedProvider>
-      <DiffComponentProvider component={Diff}>
-        <GlobalSDKProvider url={url}>
-          <GlobalSyncProvider>
-            <LayoutProvider>
-              <DialogProvider>
+    <DialogProvider>
+      <MarkedProvider>
+        <DiffComponentProvider component={Diff}>
+          <GlobalSDKProvider url={url}>
+            <GlobalSyncProvider>
+              <LayoutProvider>
                 <NotificationProvider>
                   <MetaProvider>
                     <Font />
-                    <Router root={Layout}>
+                    <Router
+                      root={(props) => (
+                        <CommandProvider>
+                          <Layout>{props.children}</Layout>
+                        </CommandProvider>
+                      )}
+                    >
                       <Route path="/" component={Home} />
                       <Route path="/:dir" component={DirectoryLayout}>
                         <Route path="/" component={() => <Navigate href="session" />} />
@@ -51,9 +59,11 @@ export function App() {
                           path="/session/:id?"
                           component={(p) => (
                             <Show when={p.params.id || true} keyed>
-                              <SessionProvider>
-                                <Session />
-                              </SessionProvider>
+                              <TerminalProvider>
+                                <PromptProvider>
+                                  <Session />
+                                </PromptProvider>
+                              </TerminalProvider>
                             </Show>
                           )}
                         />
@@ -61,11 +71,11 @@ export function App() {
                     </Router>
                   </MetaProvider>
                 </NotificationProvider>
-              </DialogProvider>
-            </LayoutProvider>
-          </GlobalSyncProvider>
-        </GlobalSDKProvider>
-      </DiffComponentProvider>
-    </MarkedProvider>
+              </LayoutProvider>
+            </GlobalSyncProvider>
+          </GlobalSDKProvider>
+        </DiffComponentProvider>
+      </MarkedProvider>
+    </DialogProvider>
   )
 }

+ 10 - 12
packages/desktop/src/components/dialog-connect-provider.tsx

@@ -108,20 +108,18 @@ export function DialogConnectProvider(props: { provider: string }) {
 
   async function complete() {
     await globalSDK.client.global.dispose()
-    setTimeout(() => {
-      showToast({
-        variant: "success",
-        icon: "circle-check",
-        title: `${provider().name} connected`,
-        description: `${provider().name} models are now available to use.`,
-      })
-      dialog.replace(() => <DialogSelectModel provider={props.provider} />)
-    }, 1000)
+    dialog.close()
+    showToast({
+      variant: "success",
+      icon: "circle-check",
+      title: `${provider().name} connected`,
+      description: `${provider().name} models are now available to use.`,
+    })
   }
 
   function goBack() {
     if (methods().length === 1) {
-      dialog.replace(() => <DialogSelectProvider />)
+      dialog.show(() => <DialogSelectProvider />)
       return
     }
     if (store.authorization) {
@@ -133,7 +131,7 @@ export function DialogConnectProvider(props: { provider: string }) {
       setStore("methodIndex", undefined)
       return
     }
-    dialog.replace(() => <DialogSelectProvider />)
+    dialog.show(() => <DialogSelectProvider />)
   }
 
   return (
@@ -352,7 +350,7 @@ export function DialogConnectProvider(props: { provider: string }) {
                       })
                       if (result.error) {
                         // TODO: show error
-                        dialog.clear()
+                        dialog.close()
                         return
                       }
                       await complete()

+ 9 - 4
packages/desktop/src/components/dialog-select-file.tsx

@@ -3,13 +3,18 @@ import { Dialog } from "@opencode-ai/ui/dialog"
 import { List } from "@opencode-ai/ui/list"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { useSession } from "@/context/session"
+import { useLayout } from "@/context/layout"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { useParams } from "@solidjs/router"
+import { createMemo } from "solid-js"
 
 export function DialogSelectFile() {
-  const session = useSession()
+  const layout = useLayout()
   const local = useLocal()
   const dialog = useDialog()
+  const params = useParams()
+  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+  const tabs = createMemo(() => layout.tabs(sessionKey()))
   return (
     <Dialog title="Select file">
       <List
@@ -20,9 +25,9 @@ export function DialogSelectFile() {
         key={(x) => x}
         onSelect={(path) => {
           if (path) {
-            session.layout.openTab("file://" + path)
+            tabs().open("file://" + path)
           }
-          dialog.clear()
+          dialog.close()
         }}
       >
         {(i) => (

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

@@ -42,7 +42,7 @@ export const DialogSelectModelUnpaid: Component = () => {
             local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
               recent: true,
             })
-            dialog.clear()
+            dialog.close()
           }}
         >
           {(i) => (
@@ -75,7 +75,7 @@ export const DialogSelectModelUnpaid: Component = () => {
                 }}
                 onSelect={(x) => {
                   if (!x) return
-                  dialog.replace(() => <DialogConnectProvider provider={x.id} />)
+                  dialog.show(() => <DialogConnectProvider provider={x.id} />)
                 }}
               >
                 {(i) => (
@@ -105,7 +105,7 @@ export const DialogSelectModelUnpaid: Component = () => {
                 class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
                 icon="dot-grid"
                 onClick={() => {
-                  dialog.replace(() => <DialogSelectProvider />)
+                  dialog.show(() => <DialogSelectProvider />)
                 }}
               >
                 View all providers

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

@@ -28,7 +28,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
           class="h-7 -my-1 text-14-medium"
           icon="plus-small"
           tabIndex={-1}
-          onClick={() => dialog.replace(() => <DialogSelectProvider />)}
+          onClick={() => dialog.show(() => <DialogSelectProvider />)}
         >
           Connect provider
         </Button>
@@ -57,7 +57,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
           local.model.set(x ? { modelID: x.id, providerID: x.provider.id } : undefined, {
             recent: true,
           })
-          dialog.clear()
+          dialog.close()
         }}
       >
         {(i) => (
@@ -75,7 +75,7 @@ export const DialogSelectModel: Component<{ provider?: string }> = (props) => {
       <Button
         variant="ghost"
         class="ml-3 mt-5 mb-6 text-text-base self-start"
-        onClick={() => dialog.replace(() => <DialogManageModels />)}
+        onClick={() => dialog.show(() => <DialogManageModels />)}
       >
         Manage models
       </Button>

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

@@ -34,7 +34,7 @@ export const DialogSelectProvider: Component = () => {
         }}
         onSelect={(x) => {
           if (!x) return
-          dialog.replace(() => <DialogConnectProvider provider={x.id} />)
+          dialog.show(() => <DialogConnectProvider provider={x.id} />)
         }}
       >
         {(i) => (

+ 440 - 171
packages/desktop/src/components/prompt-input.tsx

@@ -1,12 +1,13 @@
 import { useFilteredList } from "@opencode-ai/ui/hooks"
-import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match } from "solid-js"
-import { createStore } from "solid-js/store"
+import { createEffect, on, Component, Show, For, onMount, onCleanup, Switch, Match, createMemo } from "solid-js"
+import { createStore, produce } from "solid-js/store"
 import { makePersisted } from "@solid-primitives/storage"
 import { createFocusSignal } from "@solid-primitives/active-element"
 import { useLocal } from "@/context/local"
-import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, useSession } from "@/context/session"
+import { ContentPart, DEFAULT_PROMPT, isPromptEqual, Prompt, usePrompt, ImageAttachmentPart } from "@/context/prompt"
+import { useLayout } from "@/context/layout"
 import { useSDK } from "@/context/sdk"
-import { useNavigate } from "@solidjs/router"
+import { useNavigate, useParams } from "@solidjs/router"
 import { useSync } from "@/context/sync"
 import { FileIcon } from "@opencode-ai/ui/file-icon"
 import { Button } from "@opencode-ai/ui/button"
@@ -19,6 +20,10 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectModel } from "@/components/dialog-select-model"
 import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
 import { useProviders } from "@/hooks/use-providers"
+import { useCommand, formatKeybind } from "@/context/command"
+
+const ACCEPTED_IMAGE_TYPES = ["image/png", "image/jpeg", "image/gif", "image/webp"]
+const ACCEPTED_FILE_TYPES = [...ACCEPTED_IMAGE_TYPES, "application/pdf"]
 
 interface PromptInputProps {
   class?: string
@@ -53,26 +58,54 @@ const PLACEHOLDERS = [
   "How do environment variables work here?",
 ]
 
+interface SlashCommand {
+  id: string
+  trigger: string
+  title: string
+  description?: string
+  keybind?: string
+  type: "builtin" | "custom"
+}
+
 export const PromptInput: Component<PromptInputProps> = (props) => {
   const navigate = useNavigate()
   const sdk = useSDK()
   const sync = useSync()
   const local = useLocal()
-  const session = useSession()
+  const prompt = usePrompt()
+  const layout = useLayout()
+  const params = useParams()
   const dialog = useDialog()
   const providers = useProviders()
+  const command = useCommand()
   let editorRef!: HTMLDivElement
+  let fileInputRef!: HTMLInputElement
+
+  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+  const tabs = createMemo(() => layout.tabs(sessionKey()))
+  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+  const status = createMemo(
+    () =>
+      sync.data.session_status[params.id ?? ""] ?? {
+        type: "idle",
+      },
+  )
+  const working = createMemo(() => status()?.type !== "idle")
 
   const [store, setStore] = createStore<{
-    popoverIsOpen: boolean
+    popover: "file" | "slash" | null
     historyIndex: number
     savedPrompt: Prompt | null
     placeholder: number
+    dragging: boolean
+    imageAttachments: ImageAttachmentPart[]
   }>({
-    popoverIsOpen: false,
+    popover: null,
     historyIndex: -1,
     savedPrompt: null,
     placeholder: Math.floor(Math.random() * PLACEHOLDERS.length),
+    dragging: false,
+    imageAttachments: [],
   })
 
   const MAX_HISTORY = 100
@@ -88,58 +121,48 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   )
 
   const clonePromptParts = (prompt: Prompt): Prompt =>
-    prompt.map((part) =>
-      part.type === "text"
-        ? { ...part }
-        : {
-            ...part,
-            selection: part.selection ? { ...part.selection } : undefined,
-          },
-    )
+    prompt.map((part) => {
+      if (part.type === "text") return { ...part }
+      if (part.type === "image") return { ...part }
+      return {
+        ...part,
+        selection: part.selection ? { ...part.selection } : undefined,
+      }
+    })
 
-  const promptLength = (prompt: Prompt) => prompt.reduce((len, part) => len + part.content.length, 0)
+  const promptLength = (prompt: Prompt) =>
+    prompt.reduce((len, part) => len + ("content" in part ? part.content.length : 0), 0)
 
-  const applyHistoryPrompt = (prompt: Prompt, position: "start" | "end") => {
-    const length = position === "start" ? 0 : promptLength(prompt)
-    session.prompt.set(prompt, length)
+  const applyHistoryPrompt = (p: Prompt, position: "start" | "end") => {
+    const length = position === "start" ? 0 : promptLength(p)
+    prompt.set(p, length)
     requestAnimationFrame(() => {
       editorRef.focus()
       setCursorPosition(editorRef, length)
     })
   }
 
-  const getCaretLineState = () => {
+  const getCaretState = () => {
     const selection = window.getSelection()
-    if (!selection || selection.rangeCount === 0) return { collapsed: false, onFirstLine: false, onLastLine: false }
-    const range = selection.getRangeAt(0)
-    const rect = range.getBoundingClientRect()
-    const editorRect = editorRef.getBoundingClientRect()
-    const style = window.getComputedStyle(editorRef)
-    const paddingTop = parseFloat(style.paddingTop) || 0
-    const paddingBottom = parseFloat(style.paddingBottom) || 0
-    let lineHeight = parseFloat(style.lineHeight)
-    if (!Number.isFinite(lineHeight)) lineHeight = parseFloat(style.fontSize) || 16
-    const scrollTop = editorRef.scrollTop
-    let relativeTop = rect.top - editorRect.top - paddingTop + scrollTop
-    if (!Number.isFinite(relativeTop)) relativeTop = scrollTop
-    relativeTop = Math.max(0, relativeTop)
-    let caretHeight = rect.height
-    if (!caretHeight || !Number.isFinite(caretHeight)) caretHeight = lineHeight
-    const relativeBottom = relativeTop + caretHeight
-    const contentHeight = Math.max(caretHeight, editorRef.scrollHeight - paddingTop - paddingBottom)
-    const threshold = Math.max(2, lineHeight / 2)
-
+    const textLength = promptLength(prompt.current())
+    if (!selection || selection.rangeCount === 0) {
+      return { collapsed: false, cursorPosition: 0, textLength }
+    }
+    const anchorNode = selection.anchorNode
+    if (!anchorNode || !editorRef.contains(anchorNode)) {
+      return { collapsed: false, cursorPosition: 0, textLength }
+    }
     return {
       collapsed: selection.isCollapsed,
-      onFirstLine: relativeTop <= threshold,
-      onLastLine: contentHeight - relativeBottom <= threshold,
+      cursorPosition: getCursorPosition(editorRef),
+      textLength,
     }
   }
 
   createEffect(() => {
-    session.id
+    params.id
     editorRef.focus()
-    if (session.id) return
+    if (params.id) return
     const interval = setInterval(() => {
       setStore("placeholder", (prev) => (prev + 1) % PLACEHOLDERS.length)
     }, 6500)
@@ -148,14 +171,89 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const isFocused = createFocusSignal(() => editorRef)
 
-  const handlePaste = (event: ClipboardEvent) => {
+  const addImageAttachment = async (file: File) => {
+    if (!ACCEPTED_FILE_TYPES.includes(file.type)) return
+
+    const reader = new FileReader()
+    reader.onload = () => {
+      const dataUrl = reader.result as string
+      const attachment: ImageAttachmentPart = {
+        type: "image",
+        id: crypto.randomUUID(),
+        filename: file.name,
+        mime: file.type,
+        dataUrl,
+      }
+      setStore(
+        produce((draft) => {
+          draft.imageAttachments.push(attachment)
+        }),
+      )
+    }
+    reader.readAsDataURL(file)
+  }
+
+  const removeImageAttachment = (id: string) => {
+    setStore(
+      produce((draft) => {
+        draft.imageAttachments = draft.imageAttachments.filter((a) => a.id !== id)
+      }),
+    )
+  }
+
+  const handlePaste = async (event: ClipboardEvent) => {
+    const clipboardData = event.clipboardData
+    if (!clipboardData) return
+
+    const items = Array.from(clipboardData.items)
+    const imageItems = items.filter((item) => ACCEPTED_FILE_TYPES.includes(item.type))
+
+    if (imageItems.length > 0) {
+      event.preventDefault()
+      event.stopPropagation()
+      for (const item of imageItems) {
+        const file = item.getAsFile()
+        if (file) await addImageAttachment(file)
+      }
+      return
+    }
+
     event.preventDefault()
     event.stopPropagation()
-    // @ts-expect-error
-    const plainText = (event.clipboardData || window.clipboardData)?.getData("text/plain") ?? ""
+    const plainText = clipboardData.getData("text/plain") ?? ""
     addPart({ type: "text", content: plainText, start: 0, end: 0 })
   }
 
+  const handleDragOver = (event: DragEvent) => {
+    event.preventDefault()
+    const hasFiles = event.dataTransfer?.types.includes("Files")
+    if (hasFiles) {
+      setStore("dragging", true)
+    }
+  }
+
+  const handleDragLeave = (event: DragEvent) => {
+    const related = event.relatedTarget as Node | null
+    const form = event.currentTarget as HTMLElement
+    if (!related || !form.contains(related)) {
+      setStore("dragging", false)
+    }
+  }
+
+  const handleDrop = async (event: DragEvent) => {
+    event.preventDefault()
+    setStore("dragging", false)
+
+    const files = event.dataTransfer?.files
+    if (!files) return
+
+    for (const file of Array.from(files)) {
+      if (ACCEPTED_FILE_TYPES.includes(file.type)) {
+        await addImageAttachment(file)
+      }
+    }
+  }
+
   onMount(() => {
     editorRef.addEventListener("paste", handlePaste)
   })
@@ -167,7 +265,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (isFocused()) {
       handleInput()
     } else {
-      setStore("popoverIsOpen", false)
+      setStore("popover", null)
     }
   })
 
@@ -182,9 +280,70 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     onSelect: handleFileSelect,
   })
 
+  const slashCommands = createMemo<SlashCommand[]>(() => {
+    const builtin = command.options
+      .filter((opt) => !opt.disabled && !opt.id.startsWith("suggested.") && opt.slash)
+      .map((opt) => ({
+        id: opt.id,
+        trigger: opt.slash!,
+        title: opt.title,
+        description: opt.description,
+        keybind: opt.keybind,
+        type: "builtin" as const,
+      }))
+
+    const custom = sync.data.command.map((cmd) => ({
+      id: `custom.${cmd.name}`,
+      trigger: cmd.name,
+      title: cmd.name,
+      description: cmd.description,
+      type: "custom" as const,
+    }))
+
+    return [...custom, ...builtin]
+  })
+
+  const handleSlashSelect = (cmd: SlashCommand | undefined) => {
+    if (!cmd) return
+    setStore("popover", null)
+
+    if (cmd.type === "custom") {
+      const text = `/${cmd.trigger} `
+      editorRef.innerHTML = ""
+      editorRef.textContent = text
+      prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
+      requestAnimationFrame(() => {
+        editorRef.focus()
+        const range = document.createRange()
+        const sel = window.getSelection()
+        range.selectNodeContents(editorRef)
+        range.collapse(false)
+        sel?.removeAllRanges()
+        sel?.addRange(range)
+      })
+      return
+    }
+
+    editorRef.innerHTML = ""
+    prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+    command.trigger(cmd.id, "slash")
+  }
+
+  const {
+    flat: slashFlat,
+    active: slashActive,
+    onInput: slashOnInput,
+    onKeyDown: slashOnKeyDown,
+  } = useFilteredList<SlashCommand>({
+    items: slashCommands,
+    key: (x) => x?.id,
+    filterKeys: ["trigger", "title", "description"],
+    onSelect: handleSlashSelect,
+  })
+
   createEffect(
     on(
-      () => session.prompt.current(),
+      () => prompt.current(),
       (currentParts) => {
         const domParts = parseFromDOM()
         if (isPromptEqual(currentParts, domParts)) return
@@ -253,14 +412,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   const handleInput = () => {
     const rawParts = parseFromDOM()
     const cursorPosition = getCursorPosition(editorRef)
-    const rawText = rawParts.map((p) => p.content).join("")
+    const rawText = rawParts.map((p) => ("content" in p ? p.content : "")).join("")
 
     const atMatch = rawText.substring(0, cursorPosition).match(/@(\S*)$/)
+    const slashMatch = rawText.match(/^\/(\S*)$/)
+
     if (atMatch) {
       onInput(atMatch[1])
-      setStore("popoverIsOpen", true)
-    } else if (store.popoverIsOpen) {
-      setStore("popoverIsOpen", false)
+      setStore("popover", "file")
+    } else if (slashMatch) {
+      slashOnInput(slashMatch[1])
+      setStore("popover", "slash")
+    } else {
+      setStore("popover", null)
     }
 
     if (store.historyIndex >= 0) {
@@ -268,7 +432,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       setStore("savedPrompt", null)
     }
 
-    session.prompt.set(rawParts, cursorPosition)
+    prompt.set(rawParts, cursorPosition)
   }
 
   const addPart = (part: ContentPart) => {
@@ -276,8 +440,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (!selection || selection.rangeCount === 0) return
 
     const cursorPosition = getCursorPosition(editorRef)
-    const prompt = session.prompt.current()
-    const rawText = prompt.map((p) => p.content).join("")
+    const currentPrompt = prompt.current()
+    const rawText = currentPrompt.map((p) => ("content" in p ? p.content : "")).join("")
     const textBeforeCursor = rawText.substring(0, cursorPosition)
     const atMatch = textBeforeCursor.match(/@(\S*)$/)
 
@@ -294,8 +458,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       const range = selection.getRangeAt(0)
 
       if (atMatch) {
-        // let node: Node | null = range.startContainer
-        // let offset = range.startOffset
         let runningLength = 0
 
         const walker = document.createTreeWalker(editorRef, NodeFilter.SHOW_TEXT, null)
@@ -335,17 +497,17 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
 
     handleInput()
-    setStore("popoverIsOpen", false)
+    setStore("popover", null)
   }
 
   const abort = () =>
     sdk.client.session.abort({
-      sessionID: session.id!,
+      sessionID: params.id!,
     })
 
   const addToHistory = (prompt: Prompt) => {
     const text = prompt
-      .map((p) => p.content)
+      .map((p) => ("content" in p ? p.content : ""))
       .join("")
       .trim()
     if (!text) return
@@ -353,7 +515,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     const entry = clonePromptParts(prompt)
     const lastEntry = history.entries[0]
     if (lastEntry) {
-      const lastText = lastEntry.map((p) => p.content).join("")
+      const lastText = lastEntry.map((p) => ("content" in p ? p.content : "")).join("")
       if (lastText === text) return
     }
 
@@ -367,7 +529,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     if (direction === "up") {
       if (entries.length === 0) return false
       if (current === -1) {
-        setStore("savedPrompt", clonePromptParts(session.prompt.current()))
+        setStore("savedPrompt", clonePromptParts(prompt.current()))
         setStore("historyIndex", 0)
         applyHistoryPrompt(entries[0], "start")
         return true
@@ -403,24 +565,25 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
   }
 
   const handleKeyDown = (event: KeyboardEvent) => {
-    if (store.popoverIsOpen && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
-      onKeyDown(event)
+    if (store.popover && (event.key === "ArrowUp" || event.key === "ArrowDown" || event.key === "Enter")) {
+      if (store.popover === "file") {
+        onKeyDown(event)
+      } else {
+        slashOnKeyDown(event)
+      }
       event.preventDefault()
       return
     }
 
     if (event.key === "ArrowUp" || event.key === "ArrowDown") {
-      const { collapsed, onFirstLine, onLastLine } = getCaretLineState()
+      if (event.altKey || event.ctrlKey || event.metaKey) return
+      const { collapsed, cursorPosition, textLength } = getCaretState()
       if (!collapsed) return
-      const cursorPos = getCursorPosition(editorRef)
-      const textLength = promptLength(session.prompt.current())
       const inHistory = store.historyIndex >= 0
-      const isStart = cursorPos === 0
-      const isEnd = cursorPos === textLength
-      const atAbsoluteStart = onFirstLine && isStart
-      const atAbsoluteEnd = onLastLine && isEnd
-      const allowUp = (inHistory && isEnd) || atAbsoluteStart
-      const allowDown = (inHistory && isStart) || atAbsoluteEnd
+      const atAbsoluteStart = cursorPosition === 0
+      const atAbsoluteEnd = cursorPosition === textLength
+      const allowUp = (inHistory && atAbsoluteEnd) || atAbsoluteStart
+      const allowDown = (inHistory && atAbsoluteStart) || atAbsoluteEnd
 
       if (event.key === "ArrowUp") {
         if (!allowUp) return
@@ -441,9 +604,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       handleSubmit(event)
     }
     if (event.key === "Escape") {
-      if (store.popoverIsOpen) {
-        setStore("popoverIsOpen", false)
-      } else if (session.working()) {
+      if (store.popover) {
+        setStore("popover", null)
+      } else if (working()) {
         abort()
       }
     }
@@ -451,18 +614,19 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
 
   const handleSubmit = async (event: Event) => {
     event.preventDefault()
-    const prompt = session.prompt.current()
-    const text = prompt.map((part) => part.content).join("")
-    if (text.trim().length === 0) {
-      if (session.working()) abort()
+    const currentPrompt = prompt.current()
+    const text = currentPrompt.map((part) => ("content" in part ? part.content : "")).join("")
+    const hasImageAttachments = store.imageAttachments.length > 0
+    if (text.trim().length === 0 && !hasImageAttachments) {
+      if (working()) abort()
       return
     }
 
-    addToHistory(prompt)
+    addToHistory(currentPrompt)
     setStore("historyIndex", -1)
     setStore("savedPrompt", null)
 
-    let existing = session.info()
+    let existing = info()
     if (!existing) {
       const created = await sdk.client.session.create()
       existing = created.data ?? undefined
@@ -470,32 +634,12 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
     }
     if (!existing) return
 
-    // if (!session.id) {
-    // session.layout.setOpenedTabs(
-    // session.layout.copyTabs("", session.id)
-    // }
-
     const toAbsolutePath = (path: string) => (path.startsWith("/") ? path : sync.absolute(path))
-    const attachments = prompt.filter((part) => part.type === "file")
-
-    // const activeFile = local.context.active()
-    // if (activeFile) {
-    //   registerAttachment(
-    //     activeFile.path,
-    //     activeFile.selection,
-    //     activeFile.name ?? formatAttachmentLabel(activeFile.path, activeFile.selection),
-    //   )
-    // }
-
-    // for (const contextFile of local.context.all()) {
-    //   registerAttachment(
-    //     contextFile.path,
-    //     contextFile.selection,
-    //     formatAttachmentLabel(contextFile.path, contextFile.selection),
-    //   )
-    // }
-
-    const attachmentParts = attachments.map((attachment) => {
+    const attachments = currentPrompt.filter(
+      (part) => part.type === "file",
+    ) as import("@/context/prompt").FileAttachmentPart[]
+
+    const fileAttachmentParts = attachments.map((attachment) => {
       const absolute = toAbsolutePath(attachment.path)
       const query = attachment.selection
         ? `?start=${attachment.selection.startLine}&end=${attachment.selection.endLine}`
@@ -517,11 +661,33 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
       }
     })
 
-    session.layout.setActiveTab(undefined)
-    session.messages.setActive(undefined)
-    // Clear the editor DOM directly to ensure it's empty
+    const imageAttachmentParts = store.imageAttachments.map((attachment) => ({
+      type: "file" as const,
+      mime: attachment.mime,
+      url: attachment.dataUrl,
+      filename: attachment.filename,
+    }))
+
+    tabs().setActive(undefined)
     editorRef.innerHTML = ""
-    session.prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+    prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0)
+    setStore("imageAttachments", [])
+
+    if (text.startsWith("/")) {
+      const [cmdName, ...args] = text.split(" ")
+      const commandName = cmdName.slice(1)
+      const customCommand = sync.data.command.find((c) => c.name === commandName)
+      if (customCommand) {
+        sdk.client.session.command({
+          sessionID: existing.id,
+          command: commandName,
+          arguments: args.join(" "),
+          agent: local.agent.current()!.name,
+          model: `${local.model.current()!.provider.id}/${local.model.current()!.id}`,
+        })
+        return
+      }
+    }
 
     sdk.client.session.prompt({
       sessionID: existing.id,
@@ -535,55 +701,136 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
           type: "text",
           text,
         },
-        ...attachmentParts,
+        ...fileAttachmentParts,
+        ...imageAttachmentParts,
       ],
     })
   }
 
   return (
     <div class="relative size-full _max-h-[320px] flex flex-col gap-3">
-      <Show when={store.popoverIsOpen}>
+      <Show when={store.popover}>
         <div
-          class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-[252px] min-h-10
-                 overflow-auto no-scrollbar flex flex-col p-2 pb-0 rounded-md
+          class="absolute inset-x-0 -top-3 -translate-y-full origin-bottom-left max-h-80 min-h-10
+                 overflow-auto no-scrollbar flex flex-col p-2 rounded-md
                  border border-border-base bg-surface-raised-stronger-non-alpha shadow-md"
         >
-          <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2">No matching files</div>}>
-            <For each={flat()}>
-              {(i) => (
-                <button
-                  classList={{
-                    "w-full flex items-center justify-between rounded-md": true,
-                    "bg-surface-raised-base-hover": active() === i,
-                  }}
-                  onClick={() => handleFileSelect(i)}
-                >
-                  <div class="flex items-center gap-x-2 grow min-w-0">
-                    <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
-                    <div class="flex items-center text-14-regular">
-                      <span class="text-text-weak whitespace-nowrap overflow-hidden overflow-ellipsis truncate min-w-0">
-                        {getDirectory(i)}
-                      </span>
-                      <Show when={!i.endsWith("/")}>
-                        <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
-                      </Show>
-                    </div>
-                  </div>
-                  <div class="flex items-center gap-x-1 text-text-muted/40 shrink-0"></div>
-                </button>
-              )}
-            </For>
-          </Show>
+          <Switch>
+            <Match when={store.popover === "file"}>
+              <Show when={flat().length > 0} fallback={<div class="text-text-weak px-2 py-1">No matching files</div>}>
+                <For each={flat()}>
+                  {(i) => (
+                    <button
+                      classList={{
+                        "w-full flex items-center gap-x-2 rounded-md px-2 py-0.5": true,
+                        "bg-surface-raised-base-hover": active() === i,
+                      }}
+                      onClick={() => handleFileSelect(i)}
+                    >
+                      <FileIcon node={{ path: i, type: "file" }} class="shrink-0 size-4" />
+                      <div class="flex items-center text-14-regular min-w-0">
+                        <span class="text-text-weak whitespace-nowrap truncate min-w-0">{getDirectory(i)}</span>
+                        <Show when={!i.endsWith("/")}>
+                          <span class="text-text-strong whitespace-nowrap">{getFilename(i)}</span>
+                        </Show>
+                      </div>
+                    </button>
+                  )}
+                </For>
+              </Show>
+            </Match>
+            <Match when={store.popover === "slash"}>
+              <Show
+                when={slashFlat().length > 0}
+                fallback={<div class="text-text-weak px-2 py-1">No matching commands</div>}
+              >
+                <For each={slashFlat()}>
+                  {(cmd) => (
+                    <button
+                      classList={{
+                        "w-full flex items-center justify-between gap-4 rounded-md px-2 py-1": true,
+                        "bg-surface-raised-base-hover": slashActive() === cmd.id,
+                      }}
+                      onClick={() => handleSlashSelect(cmd)}
+                    >
+                      <div class="flex items-center gap-2 min-w-0">
+                        <span class="text-14-regular text-text-strong whitespace-nowrap">/{cmd.trigger}</span>
+                        <Show when={cmd.description}>
+                          <span class="text-14-regular text-text-weak truncate">{cmd.description}</span>
+                        </Show>
+                      </div>
+                      <div class="flex items-center gap-2 shrink-0">
+                        <Show when={cmd.type === "custom"}>
+                          <span class="text-11-regular text-text-subtle px-1.5 py-0.5 bg-surface-base rounded">
+                            custom
+                          </span>
+                        </Show>
+                        <Show when={cmd.keybind}>
+                          <span class="text-12-regular text-text-subtle">{formatKeybind(cmd.keybind!)}</span>
+                        </Show>
+                      </div>
+                    </button>
+                  )}
+                </For>
+              </Show>
+            </Match>
+          </Switch>
         </div>
       </Show>
       <form
         onSubmit={handleSubmit}
+        onDragOver={handleDragOver}
+        onDragLeave={handleDragLeave}
+        onDrop={handleDrop}
         classList={{
-          "bg-surface-raised-stronger-non-alpha shadow-xs-border": true,
+          "bg-surface-raised-stronger-non-alpha shadow-xs-border relative": true,
           "rounded-md overflow-clip focus-within:shadow-xs-border": true,
+          "border-icon-info-active border-dashed": store.dragging,
           [props.class ?? ""]: !!props.class,
         }}
       >
+        <Show when={store.dragging}>
+          <div class="absolute inset-0 z-10 flex items-center justify-center bg-surface-raised-stronger-non-alpha/90 pointer-events-none">
+            <div class="flex flex-col items-center gap-2 text-text-weak">
+              <Icon name="photo" class="size-8" />
+              <span class="text-14-regular">Drop images or PDFs here</span>
+            </div>
+          </div>
+        </Show>
+        <Show when={store.imageAttachments.length > 0}>
+          <div class="flex flex-wrap gap-2 px-3 pt-3">
+            <For each={store.imageAttachments}>
+              {(attachment) => (
+                <div class="relative group">
+                  <Show
+                    when={attachment.mime.startsWith("image/")}
+                    fallback={
+                      <div class="size-16 rounded-md bg-surface-base flex items-center justify-center border border-border-base">
+                        <Icon name="folder" class="size-6 text-text-weak" />
+                      </div>
+                    }
+                  >
+                    <img
+                      src={attachment.dataUrl}
+                      alt={attachment.filename}
+                      class="size-16 rounded-md object-cover border border-border-base"
+                    />
+                  </Show>
+                  <button
+                    type="button"
+                    onClick={() => removeImageAttachment(attachment.id)}
+                    class="absolute -top-1.5 -right-1.5 size-5 rounded-full bg-surface-raised-stronger-non-alpha border border-border-base flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity hover:bg-surface-raised-base-hover"
+                  >
+                    <Icon name="close" class="size-3 text-text-weak" />
+                  </button>
+                  <div class="absolute bottom-0 left-0 right-0 px-1 py-0.5 bg-black/50 rounded-b-md">
+                    <span class="text-10-regular text-white truncate block">{attachment.filename}</span>
+                  </div>
+                </div>
+              )}
+            </For>
+          </div>
+        </Show>
         <div class="relative max-h-[240px] overflow-y-auto">
           <div
             ref={(el) => {
@@ -598,7 +845,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               "[&>[data-type=file]]:text-icon-info-active": true,
             }}
           />
-          <Show when={!session.prompt.dirty()}>
+          <Show when={!prompt.dirty() && store.imageAttachments.length === 0}>
             <div class="absolute top-0 left-0 px-5 py-3 text-14-regular text-text-weak pointer-events-none">
               Ask anything... "{PLACEHOLDERS[store.placeholder]}"
             </div>
@@ -617,7 +864,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               as="div"
               variant="ghost"
               onClick={() =>
-                dialog.push(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />))
+                dialog.show(() => (providers.paid().length > 0 ? <DialogSelectModel /> : <DialogSelectModelUnpaid />))
               }
             >
               {local.model.current()?.name ?? "Select model"}
@@ -625,34 +872,56 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
               <Icon name="chevron-down" size="small" />
             </Button>
           </div>
-          <Tooltip
-            placement="top"
-            inactive={!session.prompt.dirty() && !session.working()}
-            value={
-              <Switch>
-                <Match when={session.working()}>
-                  <div class="flex items-center gap-2">
-                    <span>Stop</span>
-                    <span class="text-icon-base text-12-medium text-[10px]!">ESC</span>
-                  </div>
-                </Match>
-                <Match when={true}>
-                  <div class="flex items-center gap-2">
-                    <span>Send</span>
-                    <Icon name="enter" size="small" class="text-icon-base" />
-                  </div>
-                </Match>
-              </Switch>
-            }
-          >
-            <IconButton
-              type="submit"
-              disabled={!session.prompt.dirty() && !session.working()}
-              icon={session.working() ? "stop" : "arrow-up"}
-              variant="primary"
-              class="h-10 w-8 absolute right-2 bottom-2"
+          <div class="flex items-center gap-1 absolute right-2 bottom-2">
+            <input
+              ref={fileInputRef}
+              type="file"
+              accept={ACCEPTED_IMAGE_TYPES.join(",")}
+              class="hidden"
+              onChange={(e) => {
+                const file = e.currentTarget.files?.[0]
+                if (file) addImageAttachment(file)
+                e.currentTarget.value = ""
+              }}
             />
-          </Tooltip>
+            <Tooltip placement="top" value="Attach image">
+              <IconButton
+                type="button"
+                icon="photo"
+                variant="ghost"
+                class="h-10 w-8"
+                onClick={() => fileInputRef.click()}
+              />
+            </Tooltip>
+            <Tooltip
+              placement="top"
+              inactive={!prompt.dirty() && !working()}
+              value={
+                <Switch>
+                  <Match when={working()}>
+                    <div class="flex items-center gap-2">
+                      <span>Stop</span>
+                      <span class="text-icon-base text-12-medium text-[10px]!">ESC</span>
+                    </div>
+                  </Match>
+                  <Match when={true}>
+                    <div class="flex items-center gap-2">
+                      <span>Send</span>
+                      <Icon name="enter" size="small" class="text-icon-base" />
+                    </div>
+                  </Match>
+                </Switch>
+              }
+            >
+              <IconButton
+                type="submit"
+                disabled={!prompt.dirty() && store.imageAttachments.length === 0 && !working()}
+                icon={working() ? "stop" : "arrow-up"}
+                variant="primary"
+                class="h-10 w-8"
+              />
+            </Tooltip>
+          </div>
         </div>
       </form>
     </div>

+ 1 - 1
packages/desktop/src/components/terminal.tsx

@@ -2,7 +2,7 @@ import { Ghostty, Terminal as Term, FitAddon } from "ghostty-web"
 import { ComponentProps, createEffect, onCleanup, onMount, splitProps } from "solid-js"
 import { useSDK } from "@/context/sdk"
 import { SerializeAddon } from "@/addons/serialize"
-import { LocalPTY } from "@/context/session"
+import { LocalPTY } from "@/context/terminal"
 import { usePrefersDark } from "@solid-primitives/media"
 
 export interface TerminalProps extends ComponentProps<"div"> {

+ 239 - 0
packages/desktop/src/context/command.tsx

@@ -0,0 +1,239 @@
+import { createMemo, createSignal, onCleanup, onMount, Show, type Accessor } from "solid-js"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { Dialog } from "@opencode-ai/ui/dialog"
+import { List } from "@opencode-ai/ui/list"
+
+const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
+
+export type KeybindConfig = string
+
+export interface Keybind {
+  key: string
+  ctrl: boolean
+  meta: boolean
+  shift: boolean
+  alt: boolean
+}
+
+export interface CommandOption {
+  id: string
+  title: string
+  description?: string
+  category?: string
+  keybind?: KeybindConfig
+  slash?: string
+  suggested?: boolean
+  disabled?: boolean
+  onSelect?: (source?: "palette" | "keybind" | "slash") => void
+}
+
+export function parseKeybind(config: string): Keybind[] {
+  if (!config || config === "none") return []
+
+  return config.split(",").map((combo) => {
+    const parts = combo.trim().toLowerCase().split("+")
+    const keybind: Keybind = {
+      key: "",
+      ctrl: false,
+      meta: false,
+      shift: false,
+      alt: false,
+    }
+
+    for (const part of parts) {
+      switch (part) {
+        case "ctrl":
+        case "control":
+          keybind.ctrl = true
+          break
+        case "meta":
+        case "cmd":
+        case "command":
+          keybind.meta = true
+          break
+        case "mod":
+          if (IS_MAC) keybind.meta = true
+          else keybind.ctrl = true
+          break
+        case "alt":
+        case "option":
+          keybind.alt = true
+          break
+        case "shift":
+          keybind.shift = true
+          break
+        default:
+          keybind.key = part
+          break
+      }
+    }
+
+    return keybind
+  })
+}
+
+export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
+  const eventKey = event.key.toLowerCase()
+
+  for (const kb of keybinds) {
+    const keyMatch = kb.key === eventKey
+    const ctrlMatch = kb.ctrl === (event.ctrlKey || false)
+    const metaMatch = kb.meta === (event.metaKey || false)
+    const shiftMatch = kb.shift === (event.shiftKey || false)
+    const altMatch = kb.alt === (event.altKey || false)
+
+    if (keyMatch && ctrlMatch && metaMatch && shiftMatch && altMatch) {
+      return true
+    }
+  }
+
+  return false
+}
+
+export function formatKeybind(config: string): string {
+  if (!config || config === "none") return ""
+
+  const keybinds = parseKeybind(config)
+  if (keybinds.length === 0) return ""
+
+  const kb = keybinds[0]
+  const parts: string[] = []
+
+  if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl")
+  if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt")
+  if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift")
+  if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta")
+
+  if (kb.key) {
+    const displayKey = kb.key.length === 1 ? kb.key.toUpperCase() : kb.key.charAt(0).toUpperCase() + kb.key.slice(1)
+    parts.push(displayKey)
+  }
+
+  return IS_MAC ? parts.join("") : parts.join("+")
+}
+
+function DialogCommand(props: { options: CommandOption[] }) {
+  const dialog = useDialog()
+
+  return (
+    <Dialog title="Commands">
+      <List
+        class="px-2.5"
+        search={{ placeholder: "Search commands", autofocus: true }}
+        emptyMessage="No commands found"
+        items={() => props.options.filter((x) => !x.id.startsWith("suggested.") || !x.disabled)}
+        key={(x) => x?.id}
+        filterKeys={["title", "description", "category"]}
+        groupBy={(x) => x.category ?? ""}
+        onSelect={(option) => {
+          if (option) {
+            dialog.close()
+            option.onSelect?.("palette")
+          }
+        }}
+      >
+        {(option) => (
+          <div class="w-full flex items-center justify-between gap-4">
+            <div class="flex items-center gap-2 min-w-0">
+              <span class="text-14-regular text-text-strong whitespace-nowrap">{option.title}</span>
+              <Show when={option.description}>
+                <span class="text-14-regular text-text-weak truncate">{option.description}</span>
+              </Show>
+            </div>
+            <Show when={option.keybind}>
+              <span class="text-12-regular text-text-subtle shrink-0">{formatKeybind(option.keybind!)}</span>
+            </Show>
+          </div>
+        )}
+      </List>
+    </Dialog>
+  )
+}
+
+export const { use: useCommand, provider: CommandProvider } = createSimpleContext({
+  name: "Command",
+  init: () => {
+    const [registrations, setRegistrations] = createSignal<Accessor<CommandOption[]>[]>([])
+    const [suspendCount, setSuspendCount] = createSignal(0)
+    const dialog = useDialog()
+
+    const options = createMemo(() => {
+      const all = registrations().flatMap((x) => x())
+      const suggested = all.filter((x) => x.suggested && !x.disabled)
+      return [
+        ...suggested.map((x) => ({
+          ...x,
+          id: "suggested." + x.id,
+          category: "Suggested",
+        })),
+        ...all,
+      ]
+    })
+
+    const suspended = () => suspendCount() > 0
+
+    const showPalette = () => {
+      if (!dialog.active) {
+        dialog.show(() => <DialogCommand options={options().filter((x) => !x.disabled)} />)
+      }
+    }
+
+    const handleKeyDown = (event: KeyboardEvent) => {
+      if (suspended()) return
+
+      const paletteKeybinds = parseKeybind("mod+shift+p")
+      if (matchKeybind(paletteKeybinds, event)) {
+        event.preventDefault()
+        showPalette()
+        return
+      }
+
+      for (const option of options()) {
+        if (option.disabled) continue
+        if (!option.keybind) continue
+
+        const keybinds = parseKeybind(option.keybind)
+        if (matchKeybind(keybinds, event)) {
+          event.preventDefault()
+          option.onSelect?.("keybind")
+          return
+        }
+      }
+    }
+
+    onMount(() => {
+      document.addEventListener("keydown", handleKeyDown)
+    })
+
+    onCleanup(() => {
+      document.removeEventListener("keydown", handleKeyDown)
+    })
+
+    return {
+      register(cb: () => CommandOption[]) {
+        const results = createMemo(cb)
+        setRegistrations((arr) => [results, ...arr])
+        onCleanup(() => {
+          setRegistrations((arr) => arr.filter((x) => x !== results))
+        })
+      },
+      trigger(id: string, source?: "palette" | "keybind" | "slash") {
+        for (const option of options()) {
+          if (option.id === id || option.id === "suggested." + id) {
+            option.onSelect?.(source)
+            return
+          }
+        }
+      },
+      show: showPalette,
+      keybinds(enabled: boolean) {
+        setSuspendCount((count) => count + (enabled ? -1 : 1))
+      },
+      suspended,
+      get options() {
+        return options()
+      },
+    }
+  },
+})

+ 45 - 6
packages/desktop/src/context/global-sync.tsx

@@ -13,6 +13,7 @@ import {
   type SessionStatus,
   type ProviderListResponse,
   type ProviderAuthResponse,
+  type Command,
   createOpencodeClient,
 } from "@opencode-ai/sdk/v2/client"
 import { createStore, produce, reconcile } from "solid-js/store"
@@ -24,6 +25,7 @@ import { onMount } from "solid-js"
 type State = {
   ready: boolean
   agent: Agent[]
+  command: Command[]
   project: string
   provider: ProviderListResponse
   config: Config
@@ -79,6 +81,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
           path: { state: "", config: "", worktree: "", directory: "", home: "" },
           ready: false,
           agent: [],
+          command: [],
           session: [],
           session_status: {},
           session_diff: {},
@@ -97,11 +100,17 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
 
     async function loadSessions(directory: string) {
       globalSDK.client.session.list({ directory }).then((x) => {
-        const sessions = (x.data ?? [])
+        const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000
+        const nonArchived = (x.data ?? [])
           .slice()
           .filter((s) => !s.time.archived)
           .sort((a, b) => a.id.localeCompare(b.id))
-          .slice(0, 5)
+        // Include at least 5 sessions, plus any updated in the last hour
+        const sessions = nonArchived.filter((s, i) => {
+          if (i < 5) return true
+          const updated = new Date(s.time.updated).getTime()
+          return updated > fourHoursAgo
+        })
         const [, setStore] = child(directory)
         setStore("session", sessions)
       })
@@ -118,6 +127,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
         provider: () => sdk.provider.list().then((x) => setStore("provider", x.data!)),
         path: () => sdk.path.get().then((x) => setStore("path", x.data!)),
         agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])),
+        command: () => sdk.command.list().then((x) => setStore("command", x.data ?? [])),
         session: () => loadSessions(directory),
         status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)),
         config: () => sdk.config.get().then((x) => setStore("config", x.data!)),
@@ -128,11 +138,12 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
     }
 
     globalSDK.event.listen((e) => {
+      console.log(e)
       const directory = e.name
       const event = e.details
 
       if (directory === "global") {
-        switch (event.type) {
+        switch (event?.type) {
           case "global.disposed": {
             bootstrap()
             break
@@ -216,6 +227,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
           )
           break
         }
+        case "message.removed": {
+          const messages = store.message[event.properties.sessionID]
+          if (!messages) break
+          const result = Binary.search(messages, event.properties.messageID, (m) => m.id)
+          if (result.found) {
+            setStore(
+              "message",
+              event.properties.sessionID,
+              produce((draft) => {
+                draft.splice(result.index, 1)
+              }),
+            )
+          }
+          break
+        }
         case "message.part.updated": {
           const part = event.properties.part
           const parts = store.part[part.messageID]
@@ -237,6 +263,21 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
           )
           break
         }
+        case "message.part.removed": {
+          const parts = store.part[event.properties.messageID]
+          if (!parts) break
+          const result = Binary.search(parts, event.properties.partID, (p) => p.id)
+          if (result.found) {
+            setStore(
+              "part",
+              event.properties.messageID,
+              produce((draft) => {
+                draft.splice(result.index, 1)
+              }),
+            )
+          }
+          break
+        }
       }
     })
 
@@ -248,9 +289,7 @@ export const { use: useGlobalSync, provider: GlobalSyncProvider } = createSimple
         globalSDK.client.project.list().then(async (x) => {
           setGlobalStore(
             "project",
-            x
-              .data!.filter((p) => !p.worktree.includes("opencode-test") && p.vcs)
-              .sort((a, b) => a.id.localeCompare(b.id)),
+            x.data!.filter((p) => !p.worktree.includes("opencode-test")).sort((a, b) => a.id.localeCompare(b.id)),
           )
         }),
         globalSDK.client.provider.list().then((x) => {

+ 89 - 3
packages/desktop/src/context/layout.tsx

@@ -1,5 +1,5 @@
-import { createStore } from "solid-js/store"
-import { createMemo, onMount } from "solid-js"
+import { createStore, produce } from "solid-js/store"
+import { batch, createMemo, onMount } from "solid-js"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { makePersisted } from "@solid-primitives/storage"
 import { useGlobalSync } from "./global-sync"
@@ -22,6 +22,11 @@ export function getAvatarColors(key?: string) {
   }
 }
 
+type SessionTabs = {
+  active?: string
+  all: string[]
+}
+
 export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
   name: "Layout",
   init: () => {
@@ -41,9 +46,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
         review: {
           state: "pane" as "pane" | "tab",
         },
+        sessionTabs: {} as Record<string, SessionTabs>,
       }),
       {
-        name: "layout.v2",
+        name: "layout.v3",
       },
     )
 
@@ -155,6 +161,86 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
           setStore("review", "state", "tab")
         },
       },
+      tabs(sessionKey: string) {
+        const tabs = createMemo(() => store.sessionTabs[sessionKey] ?? { all: [] })
+        return {
+          tabs,
+          active: createMemo(() => tabs().active),
+          all: createMemo(() => tabs().all),
+          setActive(tab: string | undefined) {
+            if (!store.sessionTabs[sessionKey]) {
+              setStore("sessionTabs", sessionKey, { all: [], active: tab })
+            } else {
+              setStore("sessionTabs", sessionKey, "active", tab)
+            }
+          },
+          setAll(all: string[]) {
+            if (!store.sessionTabs[sessionKey]) {
+              setStore("sessionTabs", sessionKey, { all, active: undefined })
+            } else {
+              setStore("sessionTabs", sessionKey, "all", all)
+            }
+          },
+          async open(tab: string) {
+            if (tab === "chat") {
+              if (!store.sessionTabs[sessionKey]) {
+                setStore("sessionTabs", sessionKey, { all: [], active: undefined })
+              } else {
+                setStore("sessionTabs", sessionKey, "active", undefined)
+              }
+              return
+            }
+            const current = store.sessionTabs[sessionKey] ?? { all: [] }
+            if (tab !== "review") {
+              if (!current.all.includes(tab)) {
+                if (!store.sessionTabs[sessionKey]) {
+                  setStore("sessionTabs", sessionKey, { all: [tab], active: tab })
+                } else {
+                  setStore("sessionTabs", sessionKey, "all", [...current.all, tab])
+                  setStore("sessionTabs", sessionKey, "active", tab)
+                }
+                return
+              }
+            }
+            if (!store.sessionTabs[sessionKey]) {
+              setStore("sessionTabs", sessionKey, { all: [], active: tab })
+            } else {
+              setStore("sessionTabs", sessionKey, "active", tab)
+            }
+          },
+          close(tab: string) {
+            const current = store.sessionTabs[sessionKey]
+            if (!current) return
+            batch(() => {
+              setStore(
+                "sessionTabs",
+                sessionKey,
+                "all",
+                current.all.filter((x) => x !== tab),
+              )
+              if (current.active === tab) {
+                const index = current.all.findIndex((f) => f === tab)
+                const previous = current.all[Math.max(0, index - 1)]
+                setStore("sessionTabs", sessionKey, "active", previous)
+              }
+            })
+          },
+          move(tab: string, to: number) {
+            const current = store.sessionTabs[sessionKey]
+            if (!current) return
+            const index = current.all.findIndex((f) => f === tab)
+            if (index === -1) return
+            setStore(
+              "sessionTabs",
+              sessionKey,
+              "all",
+              produce((opened) => {
+                opened.splice(to, 0, opened.splice(index, 1)[0])
+              }),
+            )
+          },
+        }
+      },
     }
   },
 })

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

@@ -249,6 +249,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
         set(model: ModelKey | undefined, options?: { recent?: boolean }) {
           batch(() => {
             setEphemeral("model", agent.current().name, model ?? fallbackModel())
+            if (model) updateVisibility(model, "show")
             if (options?.recent && model) {
               const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID)
               if (uniq.length > 5) uniq.pop()
@@ -405,7 +406,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
           case "file.watcher.updated":
             const relativePath = relative(event.properties.file)
             if (relativePath.startsWith(".git/")) return
-            load(relativePath)
+            if (store.node[relativePath]) load(relativePath)
             break
         }
       })

+ 21 - 5
packages/desktop/src/context/notification.tsx

@@ -2,9 +2,12 @@ import { createStore } from "solid-js/store"
 import { createSimpleContext } from "@opencode-ai/ui/context"
 import { makePersisted } from "@solid-primitives/storage"
 import { useGlobalSDK } from "./global-sdk"
+import { useGlobalSync } from "./global-sync"
+import { Binary } from "@opencode-ai/util/binary"
 import { EventSessionError } from "@opencode-ai/sdk/v2"
 import { makeAudioPlayer } from "@solid-primitives/audio"
 import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac"
+import errorSound from "@opencode-ai/ui/audio/nope-03.aac"
 
 type NotificationBase = {
   directory?: string
@@ -29,7 +32,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
   name: "Notification",
   init: () => {
     const idlePlayer = makeAudioPlayer(idleSound)
+    const errorPlayer = makeAudioPlayer(errorSound)
     const globalSDK = useGlobalSDK()
+    const globalSync = useGlobalSync()
 
     const [store, setStore] = makePersisted(
       createStore({
@@ -46,6 +51,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
     // })
 
     globalSDK.event.listen((e) => {
+      console.log(e)
       const directory = e.name
       const event = e.details
       const base = {
@@ -55,22 +61,32 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
       }
       switch (event.type) {
         case "session.idle": {
+          const sessionID = event.properties.sessionID
+          const [syncStore] = globalSync.child(directory)
+          const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
+          const isChild = match.found && syncStore.session[match.index].parentID
+          if (isChild) break
           idlePlayer.play()
-          const session = event.properties.sessionID
           setStore("list", store.list.length, {
             ...base,
             type: "turn-complete",
-            session,
+            session: sessionID,
           })
           break
         }
         case "session.error": {
-          const session = event.properties.sessionID ?? "global"
-          // errorPlayer.play()
+          const sessionID = event.properties.sessionID
+          if (sessionID) {
+            const [syncStore] = globalSync.child(directory)
+            const match = Binary.search(syncStore.session, sessionID, (s) => s.id)
+            const isChild = match.found && syncStore.session[match.index].parentID
+            if (isChild) break
+          }
+          errorPlayer.play()
           setStore("list", store.list.length, {
             ...base,
             type: "error",
-            session,
+            session: sessionID ?? "global",
             error: "error" in event.properties ? event.properties.error : undefined,
           })
           break

+ 112 - 0
packages/desktop/src/context/prompt.tsx

@@ -0,0 +1,112 @@
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { batch, createMemo } from "solid-js"
+import { makePersisted } from "@solid-primitives/storage"
+import { useParams } from "@solidjs/router"
+import { TextSelection } from "./local"
+
+interface PartBase {
+  content: string
+  start: number
+  end: number
+}
+
+export interface TextPart extends PartBase {
+  type: "text"
+}
+
+export interface FileAttachmentPart extends PartBase {
+  type: "file"
+  path: string
+  selection?: TextSelection
+}
+
+export interface ImageAttachmentPart {
+  type: "image"
+  id: string
+  filename: string
+  mime: string
+  dataUrl: string
+}
+
+export type ContentPart = TextPart | FileAttachmentPart | ImageAttachmentPart
+export type Prompt = ContentPart[]
+
+export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
+
+export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
+  if (promptA.length !== promptB.length) return false
+  for (let i = 0; i < promptA.length; i++) {
+    const partA = promptA[i]
+    const partB = promptB[i]
+    if (partA.type !== partB.type) return false
+    if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
+      return false
+    }
+    if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
+      return false
+    }
+    if (partA.type === "image" && partA.id !== (partB as ImageAttachmentPart).id) {
+      return false
+    }
+  }
+  return true
+}
+
+function cloneSelection(selection?: TextSelection) {
+  if (!selection) return undefined
+  return { ...selection }
+}
+
+function clonePart(part: ContentPart): ContentPart {
+  if (part.type === "text") return { ...part }
+  if (part.type === "image") return { ...part }
+  return {
+    ...part,
+    selection: cloneSelection(part.selection),
+  }
+}
+
+function clonePrompt(prompt: Prompt): Prompt {
+  return prompt.map(clonePart)
+}
+
+export const { use: usePrompt, provider: PromptProvider } = createSimpleContext({
+  name: "Prompt",
+  init: () => {
+    const params = useParams()
+    const name = createMemo(() => `${params.dir}/prompt${params.id ? "/" + params.id : ""}.v1`)
+
+    const [store, setStore] = makePersisted(
+      createStore<{
+        prompt: Prompt
+        cursor?: number
+      }>({
+        prompt: clonePrompt(DEFAULT_PROMPT),
+        cursor: undefined,
+      }),
+      {
+        name: name(),
+      },
+    )
+
+    return {
+      current: createMemo(() => store.prompt),
+      cursor: createMemo(() => store.cursor),
+      dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
+      set(prompt: Prompt, cursorPosition?: number) {
+        const next = clonePrompt(prompt)
+        batch(() => {
+          setStore("prompt", next)
+          if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
+        })
+      },
+      reset() {
+        batch(() => {
+          setStore("prompt", clonePrompt(DEFAULT_PROMPT))
+          setStore("cursor", 0)
+        })
+      },
+    }
+  },
+})

+ 0 - 321
packages/desktop/src/context/session.tsx

@@ -1,321 +0,0 @@
-import { createStore, produce } from "solid-js/store"
-import { createSimpleContext } from "@opencode-ai/ui/context"
-import { batch, createEffect, createMemo } from "solid-js"
-import { useSync } from "./sync"
-import { makePersisted } from "@solid-primitives/storage"
-import { TextSelection } from "./local"
-import { pipe, sumBy } from "remeda"
-import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
-import { useParams } from "@solidjs/router"
-import { useSDK } from "./sdk"
-
-export type LocalPTY = {
-  id: string
-  title: string
-  rows?: number
-  cols?: number
-  buffer?: string
-  scrollY?: number
-}
-
-export const { use: useSession, provider: SessionProvider } = createSimpleContext({
-  name: "Session",
-  init: () => {
-    const sdk = useSDK()
-    const params = useParams()
-    const sync = useSync()
-    const name = createMemo(() => `${params.dir}/session${params.id ? "/" + params.id : ""}.v3`)
-
-    const [store, setStore] = makePersisted(
-      createStore<{
-        messageId?: string
-        tabs: {
-          active?: string
-          all: string[]
-        }
-        prompt: Prompt
-        cursor?: number
-        terminals: {
-          active?: string
-          all: LocalPTY[]
-        }
-      }>({
-        tabs: {
-          all: [],
-        },
-        prompt: clonePrompt(DEFAULT_PROMPT),
-        cursor: undefined,
-        terminals: { all: [] },
-      }),
-      {
-        name: name(),
-      },
-    )
-
-    createEffect(() => {
-      if (!params.id) return
-      sync.session.sync(params.id)
-    })
-
-    const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
-    const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
-    const userMessages = createMemo(() =>
-      messages()
-        .filter((m) => m.role === "user")
-        .sort((a, b) => a.id.localeCompare(b.id)),
-    )
-    const lastUserMessage = createMemo(() => {
-      return userMessages()?.at(-1)
-    })
-    const activeMessage = createMemo(() => {
-      if (!store.messageId) return lastUserMessage()
-      return userMessages()?.find((m) => m.id === store.messageId)
-    })
-    const status = createMemo(
-      () =>
-        sync.data.session_status[params.id ?? ""] ?? {
-          type: "idle",
-        },
-    )
-    const working = createMemo(() => status()?.type !== "idle")
-
-    const cost = createMemo(() => {
-      const total = pipe(
-        messages(),
-        sumBy((x) => (x.role === "assistant" ? x.cost : 0)),
-      )
-      return new Intl.NumberFormat("en-US", {
-        style: "currency",
-        currency: "USD",
-      }).format(total)
-    })
-
-    const last = createMemo(
-      () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
-    )
-    const model = createMemo(() =>
-      last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
-    )
-    const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
-
-    const tokens = createMemo(() => {
-      if (!last()) return
-      const tokens = last().tokens
-      return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write
-    })
-
-    const context = createMemo(() => {
-      const total = tokens()
-      const limit = model()?.limit.context
-      if (!total || !limit) return 0
-      return Math.round((total / limit) * 100)
-    })
-
-    return {
-      get id() {
-        return params.id
-      },
-      info,
-      status,
-      working,
-      diffs,
-      prompt: {
-        current: createMemo(() => store.prompt),
-        cursor: createMemo(() => store.cursor),
-        dirty: createMemo(() => !isPromptEqual(store.prompt, DEFAULT_PROMPT)),
-        set(prompt: Prompt, cursorPosition?: number) {
-          const next = clonePrompt(prompt)
-          batch(() => {
-            setStore("prompt", next)
-            if (cursorPosition !== undefined) setStore("cursor", cursorPosition)
-          })
-        },
-      },
-      messages: {
-        all: messages,
-        user: userMessages,
-        last: lastUserMessage,
-        active: activeMessage,
-        setActive(message: UserMessage | undefined) {
-          setStore("messageId", message?.id)
-        },
-      },
-      usage: {
-        tokens,
-        cost,
-        context,
-      },
-      layout: {
-        tabs: store.tabs,
-        setActiveTab(tab: string | undefined) {
-          setStore("tabs", "active", tab)
-        },
-        setOpenedTabs(tabs: string[]) {
-          setStore("tabs", "all", tabs)
-        },
-        async openTab(tab: string) {
-          if (tab === "chat") {
-            setStore("tabs", "active", undefined)
-            return
-          }
-          if (tab !== "review") {
-            if (!store.tabs.all.includes(tab)) {
-              setStore("tabs", "all", [...store.tabs.all, tab])
-            }
-          }
-          setStore("tabs", "active", tab)
-        },
-        closeTab(tab: string) {
-          batch(() => {
-            setStore(
-              "tabs",
-              "all",
-              store.tabs.all.filter((x) => x !== tab),
-            )
-            if (store.tabs.active === tab) {
-              const index = store.tabs.all.findIndex((f) => f === tab)
-              const previous = store.tabs.all[Math.max(0, index - 1)]
-              setStore("tabs", "active", previous)
-            }
-          })
-        },
-        moveTab(tab: string, to: number) {
-          const index = store.tabs.all.findIndex((f) => f === tab)
-          if (index === -1) return
-          setStore(
-            "tabs",
-            "all",
-            produce((opened) => {
-              opened.splice(to, 0, opened.splice(index, 1)[0])
-            }),
-          )
-        },
-      },
-      terminal: {
-        all: createMemo(() => Object.values(store.terminals.all)),
-        active: createMemo(() => store.terminals.active),
-        new() {
-          sdk.client.pty.create({ title: `Terminal ${store.terminals.all.length + 1}` }).then((pty) => {
-            const id = pty.data?.id
-            if (!id) return
-            setStore("terminals", "all", [
-              ...store.terminals.all,
-              {
-                id,
-                title: pty.data?.title ?? "Terminal",
-              },
-            ])
-            setStore("terminals", "active", id)
-          })
-        },
-        update(pty: Partial<LocalPTY> & { id: string }) {
-          setStore("terminals", "all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
-          sdk.client.pty.update({
-            ptyID: pty.id,
-            title: pty.title,
-            size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
-          })
-        },
-        async clone(id: string) {
-          const index = store.terminals.all.findIndex((x) => x.id === id)
-          const pty = store.terminals.all[index]
-          if (!pty) return
-          const clone = await sdk.client.pty.create({
-            title: pty.title,
-          })
-          if (!clone.data) return
-          setStore("terminals", "all", index, {
-            ...pty,
-            ...clone.data,
-          })
-          if (store.terminals.active === pty.id) {
-            setStore("terminals", "active", clone.data.id)
-          }
-        },
-        open(id: string) {
-          setStore("terminals", "active", id)
-        },
-        async close(id: string) {
-          batch(() => {
-            setStore(
-              "terminals",
-              "all",
-              store.terminals.all.filter((x) => x.id !== id),
-            )
-            if (store.terminals.active === id) {
-              const index = store.terminals.all.findIndex((f) => f.id === id)
-              const previous = store.tabs.all[Math.max(0, index - 1)]
-              setStore("terminals", "active", previous)
-            }
-          })
-          await sdk.client.pty.remove({ ptyID: id })
-        },
-        move(id: string, to: number) {
-          const index = store.terminals.all.findIndex((f) => f.id === id)
-          if (index === -1) return
-          setStore(
-            "terminals",
-            "all",
-            produce((all) => {
-              all.splice(to, 0, all.splice(index, 1)[0])
-            }),
-          )
-        },
-      },
-    }
-  },
-})
-
-interface PartBase {
-  content: string
-  start: number
-  end: number
-}
-
-export interface TextPart extends PartBase {
-  type: "text"
-}
-
-export interface FileAttachmentPart extends PartBase {
-  type: "file"
-  path: string
-  selection?: TextSelection
-}
-
-export type ContentPart = TextPart | FileAttachmentPart
-export type Prompt = ContentPart[]
-
-export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
-
-export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean {
-  if (promptA.length !== promptB.length) return false
-  for (let i = 0; i < promptA.length; i++) {
-    const partA = promptA[i]
-    const partB = promptB[i]
-    if (partA.type !== partB.type) return false
-    if (partA.type === "text" && partA.content !== (partB as TextPart).content) {
-      return false
-    }
-    if (partA.type === "file" && partA.path !== (partB as FileAttachmentPart).path) {
-      return false
-    }
-  }
-  return true
-}
-
-function cloneSelection(selection?: TextSelection) {
-  if (!selection) return undefined
-  return { ...selection }
-}
-
-function clonePart(part: ContentPart): ContentPart {
-  if (part.type === "text") return { ...part }
-  return {
-    ...part,
-    selection: cloneSelection(part.selection),
-  }
-}
-
-function clonePrompt(prompt: Prompt): Prompt {
-  return prompt.map(clonePart)
-}

+ 106 - 0
packages/desktop/src/context/terminal.tsx

@@ -0,0 +1,106 @@
+import { createStore, produce } from "solid-js/store"
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { batch, createMemo } from "solid-js"
+import { makePersisted } from "@solid-primitives/storage"
+import { useParams } from "@solidjs/router"
+import { useSDK } from "./sdk"
+
+export type LocalPTY = {
+  id: string
+  title: string
+  rows?: number
+  cols?: number
+  buffer?: string
+  scrollY?: number
+}
+
+export const { use: useTerminal, provider: TerminalProvider } = createSimpleContext({
+  name: "Terminal",
+  init: () => {
+    const sdk = useSDK()
+    const params = useParams()
+    const name = createMemo(() => `${params.dir}/terminal${params.id ? "/" + params.id : ""}.v1`)
+
+    const [store, setStore] = makePersisted(
+      createStore<{
+        active?: string
+        all: LocalPTY[]
+      }>({
+        all: [],
+      }),
+      {
+        name: name(),
+      },
+    )
+
+    return {
+      all: createMemo(() => Object.values(store.all)),
+      active: createMemo(() => store.active),
+      new() {
+        sdk.client.pty.create({ title: `Terminal ${store.all.length + 1}` }).then((pty) => {
+          const id = pty.data?.id
+          if (!id) return
+          setStore("all", [
+            ...store.all,
+            {
+              id,
+              title: pty.data?.title ?? "Terminal",
+            },
+          ])
+          setStore("active", id)
+        })
+      },
+      update(pty: Partial<LocalPTY> & { id: string }) {
+        setStore("all", (x) => x.map((x) => (x.id === pty.id ? { ...x, ...pty } : x)))
+        sdk.client.pty.update({
+          ptyID: pty.id,
+          title: pty.title,
+          size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+        })
+      },
+      async clone(id: string) {
+        const index = store.all.findIndex((x) => x.id === id)
+        const pty = store.all[index]
+        if (!pty) return
+        const clone = await sdk.client.pty.create({
+          title: pty.title,
+        })
+        if (!clone.data) return
+        setStore("all", index, {
+          ...pty,
+          ...clone.data,
+        })
+        if (store.active === pty.id) {
+          setStore("active", clone.data.id)
+        }
+      },
+      open(id: string) {
+        setStore("active", id)
+      },
+      async close(id: string) {
+        batch(() => {
+          setStore(
+            "all",
+            store.all.filter((x) => x.id !== id),
+          )
+          if (store.active === id) {
+            const index = store.all.findIndex((f) => f.id === id)
+            const previous = store.all[Math.max(0, index - 1)]
+            setStore("active", previous?.id)
+          }
+        })
+        await sdk.client.pty.remove({ ptyID: id })
+      },
+      move(id: string, to: number) {
+        const index = store.all.findIndex((f) => f.id === id)
+        if (index === -1) return
+        setStore(
+          "all",
+          produce((all) => {
+            all.splice(to, 0, all.splice(index, 1)[0])
+          }),
+        )
+      },
+    }
+  },
+})

+ 1 - 1
packages/desktop/src/hooks/use-providers.ts

@@ -6,8 +6,8 @@ import { createMemo } from "solid-js"
 export const popularProviders = ["opencode", "anthropic", "github-copilot", "openai", "google", "openrouter", "vercel"]
 
 export function useProviders() {
-  const params = useParams()
   const globalSync = useGlobalSync()
+  const params = useParams()
   const currentDirectory = createMemo(() => base64Decode(params.dir ?? ""))
   const providers = createMemo(() => {
     if (currentDirectory()) {

+ 1 - 4
packages/desktop/src/pages/directory-layout.tsx

@@ -6,7 +6,6 @@ import { LocalProvider } from "@/context/local"
 import { base64Decode } from "@opencode-ai/util/encode"
 import { DataProvider } from "@opencode-ai/ui/context"
 import { iife } from "@opencode-ai/util/iife"
-import { DialogRoot } from "@opencode-ai/ui/context/dialog"
 
 export default function Layout(props: ParentProps) {
   const params = useParams()
@@ -21,9 +20,7 @@ export default function Layout(props: ParentProps) {
             const sync = useSync()
             return (
               <DataProvider data={sync.data} directory={directory()}>
-                <LocalProvider>
-                  <DialogRoot>{props.children}</DialogRoot>
-                </LocalProvider>
+                <LocalProvider>{props.children}</LocalProvider>
               </DataProvider>
             )
           })}

+ 285 - 80
packages/desktop/src/pages/layout.tsx

@@ -12,6 +12,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
 import { Tooltip } from "@opencode-ai/ui/tooltip"
 import { Collapsible } from "@opencode-ai/ui/collapsible"
 import { DiffChanges } from "@opencode-ai/ui/diff-changes"
+import { Spinner } from "@opencode-ai/ui/spinner"
 import { getFilename } from "@opencode-ai/util/path"
 import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
 import { Session, Project } from "@opencode-ai/sdk/v2/client"
@@ -35,6 +36,7 @@ import { Binary } from "@opencode-ai/util/binary"
 import { Header } from "@/components/header"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectProvider } from "@/components/dialog-select-provider"
+import { useCommand } from "@/context/command"
 
 export default function Layout(props: ParentProps) {
   const [store, setStore] = createStore({
@@ -42,6 +44,16 @@ export default function Layout(props: ParentProps) {
     activeDraggable: undefined as string | undefined,
   })
 
+  let scrollContainerRef: HTMLDivElement | undefined
+
+  function scrollToSession(sessionId: string) {
+    if (!scrollContainerRef) return
+    const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`)
+    if (element) {
+      element.scrollIntoView({ block: "center", behavior: "smooth" })
+    }
+  }
+
   const params = useParams()
   const globalSDK = useGlobalSDK()
   const globalSync = useGlobalSync()
@@ -51,9 +63,161 @@ export default function Layout(props: ParentProps) {
   const navigate = useNavigate()
   const providers = useProviders()
   const dialog = useDialog()
+  const command = useCommand()
+
+  function flattenSessions(sessions: Session[]): Session[] {
+    const childrenMap = new Map<string, Session[]>()
+    for (const session of sessions) {
+      if (session.parentID) {
+        const children = childrenMap.get(session.parentID) ?? []
+        children.push(session)
+        childrenMap.set(session.parentID, children)
+      }
+    }
+    const result: Session[] = []
+    function visit(session: Session) {
+      result.push(session)
+      for (const child of childrenMap.get(session.id) ?? []) {
+        visit(child)
+      }
+    }
+    for (const session of sessions) {
+      if (!session.parentID) visit(session)
+    }
+    return result
+  }
+
+  const currentSessions = createMemo(() => {
+    if (!params.dir) return []
+    const directory = base64Decode(params.dir)
+    return flattenSessions(globalSync.child(directory)[0].session ?? [])
+  })
+
+  function navigateSessionByOffset(offset: number) {
+    const projects = layout.projects.list()
+    if (projects.length === 0) return
+
+    const currentDirectory = params.dir ? base64Decode(params.dir) : undefined
+    const projectIndex = currentDirectory ? projects.findIndex((p) => p.worktree === currentDirectory) : -1
+
+    if (projectIndex === -1) {
+      const targetProject = offset > 0 ? projects[0] : projects[projects.length - 1]
+      if (targetProject) navigateToProject(targetProject.worktree)
+      return
+    }
+
+    const sessions = currentSessions()
+    const sessionIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1
+
+    let targetIndex: number
+    if (sessionIndex === -1) {
+      targetIndex = offset > 0 ? 0 : sessions.length - 1
+    } else {
+      targetIndex = sessionIndex + offset
+    }
+
+    if (targetIndex >= 0 && targetIndex < sessions.length) {
+      const session = sessions[targetIndex]
+      navigateToSession(session)
+      queueMicrotask(() => scrollToSession(session.id))
+      return
+    }
+
+    const nextProjectIndex = projectIndex + (offset > 0 ? 1 : -1)
+    const nextProject = projects[nextProjectIndex]
+    if (!nextProject) return
+
+    const nextProjectSessions = flattenSessions(globalSync.child(nextProject.worktree)[0].session ?? [])
+    if (nextProjectSessions.length === 0) {
+      navigateToProject(nextProject.worktree)
+      return
+    }
+
+    const targetSession = offset > 0 ? nextProjectSessions[0] : nextProjectSessions[nextProjectSessions.length - 1]
+    navigate(`/${base64Encode(nextProject.worktree)}/session/${targetSession.id}`)
+    queueMicrotask(() => scrollToSession(targetSession.id))
+  }
+
+  async function archiveSession(session: Session) {
+    const [store, setStore] = globalSync.child(session.directory)
+    const sessions = store.session ?? []
+    const index = sessions.findIndex((s) => s.id === session.id)
+    const nextSession = sessions[index + 1] ?? sessions[index - 1]
+
+    await globalSDK.client.session.update({
+      directory: session.directory,
+      sessionID: session.id,
+      time: { archived: Date.now() },
+    })
+    setStore(
+      produce((draft) => {
+        const match = Binary.search(draft.session, session.id, (s) => s.id)
+        if (match.found) draft.session.splice(match.index, 1)
+      }),
+    )
+    if (session.id === params.id) {
+      if (nextSession) {
+        navigate(`/${params.dir}/session/${nextSession.id}`)
+      } else {
+        navigate(`/${params.dir}/session`)
+      }
+    }
+  }
+
+  command.register(() => [
+    {
+      id: "sidebar.toggle",
+      title: "Toggle sidebar",
+      category: "View",
+      keybind: "mod+b",
+      onSelect: () => layout.sidebar.toggle(),
+    },
+    ...(platform.openDirectoryPickerDialog
+      ? [
+          {
+            id: "project.open",
+            title: "Open project",
+            category: "Project",
+            keybind: "mod+o",
+            onSelect: () => chooseProject(),
+          },
+        ]
+      : []),
+    {
+      id: "provider.connect",
+      title: "Connect provider",
+      category: "Provider",
+      onSelect: () => connectProvider(),
+    },
+    {
+      id: "session.previous",
+      title: "Previous session",
+      category: "Session",
+      keybind: "alt+arrowup",
+      onSelect: () => navigateSessionByOffset(-1),
+    },
+    {
+      id: "session.next",
+      title: "Next session",
+      category: "Session",
+      keybind: "alt+arrowdown",
+      onSelect: () => navigateSessionByOffset(1),
+    },
+    {
+      id: "session.archive",
+      title: "Archive session",
+      category: "Session",
+      keybind: "mod+shift+backspace",
+      disabled: !params.dir || !params.id,
+      onSelect: () => {
+        const session = currentSessions().find((s) => s.id === params.id)
+        if (session) archiveSession(session)
+      },
+    },
+  ])
 
   function connectProvider() {
-    dialog.replace(() => <DialogSelectProvider />)
+    dialog.show(() => <DialogSelectProvider />)
   }
 
   function navigateToProject(directory: string | undefined) {
@@ -236,13 +400,117 @@ export default function Layout(props: ParentProps) {
     )
   }
 
-  const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => {
+  const SessionItem = (props: {
+    session: Session
+    slug: string
+    project: Project
+    depth?: number
+    childrenMap: Map<string, Session[]>
+  }): JSX.Element => {
     const notification = useNotification()
+    const depth = props.depth ?? 0
+    const children = createMemo(() => props.childrenMap.get(props.session.id) ?? [])
+    const updated = createMemo(() => DateTime.fromMillis(props.session.time.updated))
+    const notifications = createMemo(() => notification.session.unseen(props.session.id))
+    const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
+    const isWorking = createMemo(
+      () =>
+        props.session.id !== params.id &&
+        globalSync.child(props.project.worktree)[0].session_status[props.session.id]?.type === "busy",
+    )
+    return (
+      <>
+        <div
+          data-session-id={props.session.id}
+          class="group/session relative w-full pr-2 py-1 rounded-md cursor-default transition-colors
+                 hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
+          style={{ "padding-left": `${16 + depth * 12}px` }}
+        >
+          <Tooltip placement="right" value={props.session.title} gutter={10}>
+            <A
+              href={`${props.slug}/session/${props.session.id}`}
+              class="flex flex-col min-w-0 text-left w-full focus:outline-none"
+            >
+              <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
+                <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
+                  {props.session.title}
+                </span>
+                <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
+                  <Switch>
+                    <Match when={isWorking()}>
+                      <Spinner class="size-2.5 mr-0.5" />
+                    </Match>
+                    <Match when={hasError()}>
+                      <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
+                    </Match>
+                    <Match when={notifications().length > 0}>
+                      <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
+                    </Match>
+                    <Match when={true}>
+                      <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
+                        {Math.abs(updated().diffNow().as("seconds")) < 60
+                          ? "Now"
+                          : updated()
+                              .toRelative({
+                                style: "short",
+                                unit: ["days", "hours", "minutes"],
+                              })
+                              ?.replace(" ago", "")
+                              ?.replace(/ days?/, "d")
+                              ?.replace(" min.", "m")
+                              ?.replace(" hr.", "h")}
+                      </span>
+                    </Match>
+                  </Switch>
+                </div>
+              </div>
+              <Show when={props.session.summary?.files}>
+                <div class="flex justify-between items-center self-stretch">
+                  <span class="text-12-regular text-text-weak">{`${props.session.summary?.files || "No"} file${props.session.summary?.files !== 1 ? "s" : ""} changed`}</span>
+                  <Show when={props.session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
+                </div>
+              </Show>
+            </A>
+          </Tooltip>
+          <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
+            <Tooltip placement="right" value="Archive session">
+              <IconButton icon="archive" variant="ghost" onClick={() => archiveSession(props.session)} />
+            </Tooltip>
+          </div>
+        </div>
+        <For each={children()}>
+          {(child) => (
+            <SessionItem
+              session={child}
+              slug={props.slug}
+              project={props.project}
+              depth={depth + 1}
+              childrenMap={props.childrenMap}
+            />
+          )}
+        </For>
+      </>
+    )
+  }
+
+  const SortableProject = (props: { project: Project & { expanded: boolean } }): JSX.Element => {
     const sortable = createSortable(props.project.worktree)
     const slug = createMemo(() => base64Encode(props.project.worktree))
     const name = createMemo(() => getFilename(props.project.worktree))
-    const [store, setStore] = globalSync.child(props.project.worktree)
+    const [store] = globalSync.child(props.project.worktree)
     const sessions = createMemo(() => store.session ?? [])
+    const rootSessions = createMemo(() => sessions().filter((s) => !s.parentID))
+    const childSessionsByParent = createMemo(() => {
+      const map = new Map<string, Session[]>()
+      for (const session of sessions()) {
+        if (session.parentID) {
+          const children = map.get(session.parentID) ?? []
+          children.push(session)
+          map.set(session.parentID, children)
+        }
+      }
+      return map
+    })
     const [expanded, setExpanded] = createSignal(true)
     return (
       // @ts-ignore
@@ -282,83 +550,17 @@ export default function Layout(props: ParentProps) {
               </Button>
               <Collapsible.Content>
                 <nav class="hidden @[4rem]:flex w-full flex-col gap-1.5">
-                  <For each={sessions()}>
-                    {(session) => {
-                      const updated = createMemo(() => DateTime.fromMillis(session.time.updated))
-                      const notifications = createMemo(() => notification.session.unseen(session.id))
-                      const hasError = createMemo(() => notifications().some((n) => n.type === "error"))
-                      async function archive(session: Session) {
-                        await globalSDK.client.session.update({
-                          directory: session.directory,
-                          sessionID: session.id,
-                          time: { archived: Date.now() },
-                        })
-                        setStore(
-                          produce((draft) => {
-                            const match = Binary.search(draft.session, session.id, (s) => s.id)
-                            if (match.found) draft.session.splice(match.index, 1)
-                          }),
-                        )
-                      }
-                      return (
-                        <div
-                          class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
-                                 hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
-                        >
-                          <Tooltip placement="right" value={session.title} gutter={10}>
-                            <A
-                              href={`${slug()}/session/${session.id}`}
-                              class="flex flex-col min-w-0 text-left w-full focus:outline-none"
-                            >
-                              <div class="flex items-center self-stretch gap-6 justify-between transition-[padding] group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7">
-                                <span class="text-14-regular text-text-strong overflow-hidden text-ellipsis truncate">
-                                  {session.title}
-                                </span>
-                                <div class="shrink-0 group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
-                                  <Switch>
-                                    <Match when={hasError()}>
-                                      <div class="size-1.5 mr-1.5 rounded-full bg-text-diff-delete-base" />
-                                    </Match>
-                                    <Match when={notifications().length > 0}>
-                                      <div class="size-1.5 mr-1.5 rounded-full bg-text-interactive-base" />
-                                    </Match>
-                                    <Match when={true}>
-                                      <span class="text-12-regular text-text-weak text-right whitespace-nowrap">
-                                        {Math.abs(updated().diffNow().as("seconds")) < 60
-                                          ? "Now"
-                                          : updated()
-                                              .toRelative({
-                                                style: "short",
-                                                unit: ["days", "hours", "minutes"],
-                                              })
-                                              ?.replace(" ago", "")
-                                              ?.replace(/ days?/, "d")
-                                              ?.replace(" min.", "m")
-                                              ?.replace(" hr.", "h")}
-                                      </span>
-                                    </Match>
-                                  </Switch>
-                                </div>
-                              </div>
-                              <Show when={session.summary?.files}>
-                                <div class="flex justify-between items-center self-stretch">
-                                  <span class="text-12-regular text-text-weak">{`${session.summary?.files || "No"} file${session.summary?.files !== 1 ? "s" : ""} changed`}</span>
-                                  <Show when={session.summary}>{(summary) => <DiffChanges changes={summary()} />}</Show>
-                                </div>
-                              </Show>
-                            </A>
-                          </Tooltip>
-                          <div class="hidden group-hover/session:flex group-active/session:flex group-focus-within/session:flex text-text-base gap-1 items-center absolute top-1 right-1">
-                            {/* <IconButton icon="dot-grid" variant="ghost" /> */}
-                            <Tooltip placement="right" value="Archive session">
-                              <IconButton icon="archive" variant="ghost" onClick={() => archive(session)} />
-                            </Tooltip>
-                          </div>
-                        </div>
-                      )
-                    }}
+                  <For each={rootSessions()}>
+                    {(session) => (
+                      <SessionItem
+                        session={session}
+                        slug={slug()}
+                        project={props.project}
+                        childrenMap={childSessionsByParent()}
+                      />
+                    )}
                   </For>
-                  <Show when={sessions().length === 0}>
+                  <Show when={rootSessions().length === 0}>
                     <div
                       class="group/session relative w-full pl-4 pr-2 py-1 rounded-md cursor-default transition-colors
                              hover:bg-surface-raised-base-hover focus-within:bg-surface-raised-base-hover has-[.active]:bg-surface-raised-base-hover"
@@ -471,7 +673,10 @@ export default function Layout(props: ParentProps) {
             >
               <DragDropSensors />
               <ConstrainDragXAxis />
-              <div class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar">
+              <div
+                ref={scrollContainerRef}
+                class="w-full min-w-8 flex flex-col gap-2 min-h-0 overflow-y-auto no-scrollbar"
+              >
                 <SortableProvider ids={layout.projects.list().map((p) => p.worktree)}>
                   <For each={layout.projects.list()}>{(project) => <SortableProject project={project} />}</For>
                 </SortableProvider>

+ 348 - 158
packages/desktop/src/pages/session.tsx

@@ -1,4 +1,4 @@
-import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect } from "solid-js"
+import { For, onCleanup, onMount, Show, Match, Switch, createResource, createMemo, createEffect, on } from "solid-js"
 import { useLocal, type LocalFile } from "@/context/local"
 import { createStore } from "solid-js/store"
 import { PromptInput } from "@/components/prompt-input"
@@ -27,113 +27,333 @@ import {
 import type { DragEvent, Transformer } from "@thisbeyond/solid-dnd"
 import type { JSX } from "solid-js"
 import { useSync } from "@/context/sync"
-import { useSession, type LocalPTY } from "@/context/session"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
 import { useLayout } from "@/context/layout"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { Terminal } from "@/components/terminal"
 import { checksum } from "@opencode-ai/util/encode"
 import { useDialog } from "@opencode-ai/ui/context/dialog"
 import { DialogSelectFile } from "@/components/dialog-select-file"
+import { DialogSelectModel } from "@/components/dialog-select-model"
+import { useCommand } from "@/context/command"
+import { useNavigate, useParams } from "@solidjs/router"
+import { AssistantMessage, UserMessage } from "@opencode-ai/sdk/v2"
+import { useSDK } from "@/context/sdk"
+import { usePrompt } from "@/context/prompt"
+import { extractPromptFromParts } from "@/utils/prompt"
 
 export default function Page() {
   const layout = useLayout()
   const local = useLocal()
   const sync = useSync()
-  const session = useSession()
+  const terminal = useTerminal()
   const dialog = useDialog()
+  const command = useCommand()
+  const params = useParams()
+  const navigate = useNavigate()
+  const sdk = useSDK()
+  const prompt = usePrompt()
+
+  const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
+  const tabs = createMemo(() => layout.tabs(sessionKey()))
+
+  const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
+  const revertMessageID = createMemo(() => info()?.revert?.messageID)
+  const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : []))
+  const userMessages = createMemo(() =>
+    messages()
+      .filter((m) => m.role === "user")
+      .sort((a, b) => a.id.localeCompare(b.id)),
+  )
+  // Visible user messages excludes reverted messages (those >= revertMessageID)
+  const visibleUserMessages = createMemo(() => {
+    const revert = revertMessageID()
+    if (!revert) return userMessages()
+    return userMessages().filter((m) => m.id < revert)
+  })
+  const lastUserMessage = createMemo(() => visibleUserMessages()?.at(-1))
+
+  const [messageStore, setMessageStore] = createStore<{ messageId?: string }>({})
+  const activeMessage = createMemo(() => {
+    if (!messageStore.messageId) return lastUserMessage()
+    // If the stored message is no longer visible (e.g., was reverted), fall back to last visible
+    const found = visibleUserMessages()?.find((m) => m.id === messageStore.messageId)
+    return found ?? lastUserMessage()
+  })
+  const setActiveMessage = (message: UserMessage | undefined) => {
+    setMessageStore("messageId", message?.id)
+  }
+
+  function navigateMessageByOffset(offset: number) {
+    const msgs = visibleUserMessages()
+    if (msgs.length === 0) return
+
+    const current = activeMessage()
+    const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
+
+    let targetIndex: number
+    if (currentIndex === -1) {
+      targetIndex = offset > 0 ? 0 : msgs.length - 1
+    } else {
+      targetIndex = currentIndex + offset
+    }
+
+    if (targetIndex < 0 || targetIndex >= msgs.length) return
+
+    setActiveMessage(msgs[targetIndex])
+  }
+
+  const last = createMemo(
+    () => messages().findLast((x) => x.role === "assistant" && x.tokens.output > 0) as AssistantMessage,
+  )
+  const model = createMemo(() =>
+    last() ? sync.data.provider.all.find((x) => x.id === last().providerID)?.models[last().modelID] : undefined,
+  )
+  const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
+
+  const tokens = createMemo(() => {
+    if (!last()) return
+    const t = last().tokens
+    return t.input + t.output + t.reasoning + t.cache.read + t.cache.write
+  })
+
+  const context = createMemo(() => {
+    const total = tokens()
+    const limit = model()?.limit.context
+    if (!total || !limit) return 0
+    return Math.round((total / limit) * 100)
+  })
+
   const [store, setStore] = createStore({
     clickTimer: undefined as number | undefined,
     activeDraggable: undefined as string | undefined,
     activeTerminalDraggable: undefined as string | undefined,
+    stepsExpanded: false,
   })
   let inputRef!: HTMLDivElement
 
-  const MOD = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) ? "Meta" : "Control"
-
-  onMount(() => {
-    document.addEventListener("keydown", handleKeyDown)
-  })
-
-  onCleanup(() => {
-    document.removeEventListener("keydown", handleKeyDown)
+  createEffect(() => {
+    if (!params.id) return
+    sync.session.sync(params.id)
   })
 
   createEffect(() => {
     if (layout.terminal.opened()) {
-      if (session.terminal.all().length === 0) {
-        session.terminal.new()
+      if (terminal.all().length === 0) {
+        terminal.new()
       }
     }
   })
 
+  createEffect(
+    on(
+      () => visibleUserMessages().at(-1)?.id,
+      (lastId, prevLastId) => {
+        if (lastId && prevLastId && lastId > prevLastId) {
+          setMessageStore("messageId", undefined)
+        }
+      },
+      { defer: true },
+    ),
+  )
+
+  const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? { type: "idle" })
+
+  command.register(() => [
+    {
+      id: "session.new",
+      title: "New session",
+      description: "Create a new session",
+      category: "Session",
+      keybind: "mod+shift+s",
+      slash: "new",
+      onSelect: () => navigate(`/${params.dir}/session`),
+    },
+    {
+      id: "file.open",
+      title: "Open file",
+      description: "Search and open a file",
+      category: "File",
+      keybind: "mod+p",
+      slash: "open",
+      onSelect: () => dialog.show(() => <DialogSelectFile />),
+    },
+    // {
+    //   id: "theme.toggle",
+    //   title: "Toggle theme",
+    //   description: "Switch between themes",
+    //   category: "View",
+    //   keybind: "ctrl+t",
+    //   slash: "theme",
+    //   onSelect: () => {
+    //     const currentTheme = localStorage.getItem("theme") ?? "oc-1"
+    //     const themes = ["oc-1", "oc-2-paper"]
+    //     const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
+    //     localStorage.setItem("theme", nextTheme)
+    //     document.documentElement.setAttribute("data-theme", nextTheme)
+    //   },
+    // },
+    {
+      id: "terminal.toggle",
+      title: "Toggle terminal",
+      description: "Show or hide the terminal",
+      category: "View",
+      keybind: "ctrl+`",
+      slash: "terminal",
+      onSelect: () => layout.terminal.toggle(),
+    },
+    {
+      id: "terminal.new",
+      title: "New terminal",
+      description: "Create a new terminal tab",
+      category: "Terminal",
+      keybind: "ctrl+shift+`",
+      onSelect: () => terminal.new(),
+    },
+    {
+      id: "steps.toggle",
+      title: "Toggle steps",
+      description: "Show or hide the steps",
+      category: "View",
+      keybind: "mod+e",
+      slash: "steps",
+      disabled: !params.id,
+      onSelect: () => setStore("stepsExpanded", (x) => !x),
+    },
+    {
+      id: "message.previous",
+      title: "Previous message",
+      description: "Go to the previous user message",
+      category: "Session",
+      keybind: "mod+arrowup",
+      disabled: !params.id,
+      onSelect: () => navigateMessageByOffset(-1),
+    },
+    {
+      id: "message.next",
+      title: "Next message",
+      description: "Go to the next user message",
+      category: "Session",
+      keybind: "mod+arrowdown",
+      disabled: !params.id,
+      onSelect: () => navigateMessageByOffset(1),
+    },
+    {
+      id: "model.choose",
+      title: "Choose model",
+      description: "Select a different model",
+      category: "Model",
+      keybind: "mod+'",
+      slash: "model",
+      onSelect: () => dialog.show(() => <DialogSelectModel />),
+    },
+    {
+      id: "agent.cycle",
+      title: "Cycle agent",
+      description: "Switch to the next agent",
+      category: "Agent",
+      keybind: "mod+.",
+      slash: "agent",
+      onSelect: () => local.agent.move(1),
+    },
+    {
+      id: "session.undo",
+      title: "Undo",
+      description: "Undo the last message",
+      category: "Session",
+      keybind: "mod+z",
+      slash: "undo",
+      disabled: !params.id || visibleUserMessages().length === 0,
+      onSelect: async () => {
+        const sessionID = params.id
+        if (!sessionID) return
+        if (status()?.type !== "idle") {
+          await sdk.client.session.abort({ sessionID }).catch(() => {})
+        }
+        const revert = info()?.revert?.messageID
+        // Find the last user message that's not already reverted
+        const message = userMessages().findLast((x) => !revert || x.id < revert)
+        if (!message) return
+        await sdk.client.session.revert({ sessionID, messageID: message.id })
+        // Restore the prompt from the reverted message
+        const parts = sync.data.part[message.id]
+        if (parts) {
+          const restored = extractPromptFromParts(parts)
+          prompt.set(restored)
+        }
+        // Navigate to the message before the reverted one (which will be the new last visible message)
+        const priorMessage = userMessages().findLast((x) => x.id < message.id)
+        setActiveMessage(priorMessage)
+      },
+    },
+    {
+      id: "session.redo",
+      title: "Redo",
+      description: "Redo the last undone message",
+      category: "Session",
+      keybind: "mod+shift+z",
+      slash: "redo",
+      disabled: !params.id || !info()?.revert?.messageID,
+      onSelect: async () => {
+        const sessionID = params.id
+        if (!sessionID) return
+        const revertMessageID = info()?.revert?.messageID
+        if (!revertMessageID) return
+        const nextMessage = userMessages().find((x) => x.id > revertMessageID)
+        if (!nextMessage) {
+          // Full unrevert - restore all messages and navigate to last
+          await sdk.client.session.unrevert({ sessionID })
+          prompt.reset()
+          // Navigate to the last message (the one that was at the revert point)
+          const lastMsg = userMessages().findLast((x) => x.id >= revertMessageID)
+          setActiveMessage(lastMsg)
+          return
+        }
+        // Partial redo - move forward to next message
+        await sdk.client.session.revert({ sessionID, messageID: nextMessage.id })
+        // Navigate to the message before the new revert point
+        const priorMsg = userMessages().findLast((x) => x.id < nextMessage.id)
+        setActiveMessage(priorMsg)
+      },
+    },
+  ])
+
   const handleKeyDown = (event: KeyboardEvent) => {
-    if (event.getModifierState(MOD) && event.shiftKey && event.key.toLowerCase() === "p") {
-      event.preventDefault()
-      return
-    }
-    if (event.getModifierState(MOD) && event.key.toLowerCase() === "p") {
-      event.preventDefault()
-      dialog.replace(() => <DialogSelectFile />)
-      return
-    }
-    if (event.ctrlKey && event.key.toLowerCase() === "t") {
-      event.preventDefault()
-      const currentTheme = localStorage.getItem("theme") ?? "oc-1"
-      const themes = ["oc-1", "oc-2-paper"]
-      const nextTheme = themes[(themes.indexOf(currentTheme) + 1) % themes.length]
-      localStorage.setItem("theme", nextTheme)
-      document.documentElement.setAttribute("data-theme", nextTheme)
-      return
-    }
-    if (event.ctrlKey && event.key.toLowerCase() === "`") {
-      event.preventDefault()
-      if (event.shiftKey) {
-        session.terminal.new()
-        return
-      }
-      layout.terminal.toggle()
-      return
-    }
+    if ((document.activeElement as HTMLElement)?.dataset?.component === "terminal") return
+    if (dialog.active) return
 
-    // @ts-expect-error
-    if (document.activeElement?.dataset?.component === "terminal") {
+    if (event.key === "PageUp" || event.key === "PageDown") {
+      const scrollContainer = document.querySelector('[data-slot="session-turn-content"]') as HTMLElement
+      if (scrollContainer) {
+        event.preventDefault()
+        const scrollAmount = scrollContainer.clientHeight * 0.8
+        scrollContainer.scrollBy({
+          top: event.key === "PageUp" ? -scrollAmount : scrollAmount,
+          behavior: "instant",
+        })
+      }
       return
     }
 
     const focused = document.activeElement === inputRef
     if (focused) {
-      if (event.key === "Escape") {
-        inputRef?.blur()
-      }
+      if (event.key === "Escape") inputRef?.blur()
       return
     }
 
-    // if (local.file.active()) {
-    //   const active = local.file.active()!
-    //   if (event.key === "Enter" && active.selection) {
-    //     local.context.add({
-    //       type: "file",
-    //       path: active.path,
-    //       selection: { ...active.selection },
-    //     })
-    //     return
-    //   }
-    //
-    //   if (event.getModifierState(MOD)) {
-    //     if (event.key.toLowerCase() === "a") {
-    //       return
-    //     }
-    //     if (event.key.toLowerCase() === "c") {
-    //       return
-    //     }
-    //   }
-    // }
-
     if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
       inputRef?.focus()
     }
   }
 
+  onMount(() => {
+    document.addEventListener("keydown", handleKeyDown)
+  })
+
+  onCleanup(() => {
+    document.removeEventListener("keydown", handleKeyDown)
+  })
+
   const resetClickTimer = () => {
     if (!store.clickTimer) return
     clearTimeout(store.clickTimer)
@@ -167,11 +387,11 @@ export default function Page() {
   const handleDragOver = (event: DragEvent) => {
     const { draggable, droppable } = event
     if (draggable && droppable) {
-      const currentTabs = session.layout.tabs.all
+      const currentTabs = tabs().all()
       const fromIndex = currentTabs?.indexOf(draggable.id.toString())
       const toIndex = currentTabs?.indexOf(droppable.id.toString())
       if (fromIndex !== toIndex && toIndex !== undefined) {
-        session.layout.moveTab(draggable.id.toString(), toIndex)
+        tabs().move(draggable.id.toString(), toIndex)
       }
     }
   }
@@ -189,11 +409,11 @@ export default function Page() {
   const handleTerminalDragOver = (event: DragEvent) => {
     const { draggable, droppable } = event
     if (draggable && droppable) {
-      const terminals = session.terminal.all()
-      const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString())
-      const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString())
+      const terminals = terminal.all()
+      const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString())
+      const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString())
       if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
-        session.terminal.move(draggable.id.toString(), toIndex)
+        terminal.move(draggable.id.toString(), toIndex)
       }
     }
   }
@@ -211,8 +431,8 @@ export default function Page() {
           <Tabs.Trigger
             value={props.terminal.id}
             closeButton={
-              session.terminal.all().length > 1 && (
-                <IconButton icon="close" variant="ghost" onClick={() => session.terminal.close(props.terminal.id)} />
+              terminal.all().length > 1 && (
+                <IconButton icon="close" variant="ghost" onClick={() => terminal.close(props.terminal.id)} />
               )
             }
           >
@@ -327,7 +547,7 @@ export default function Page() {
     return typeof draggable.id === "string" ? draggable.id : undefined
   }
 
-  const wide = createMemo(() => layout.review.state() === "tab" || !session.diffs().length)
+  const wide = createMemo(() => layout.review.state() === "tab" || !diffs().length)
 
   return (
     <div class="relative bg-background-base size-full overflow-x-hidden flex flex-col">
@@ -340,7 +560,7 @@ export default function Page() {
         >
           <DragDropSensors />
           <ConstrainDragYAxis />
-          <Tabs value={session.layout.tabs.active ?? "chat"} onChange={session.layout.openTab}>
+          <Tabs value={tabs().active() ?? "chat"} onChange={tabs().open}>
             <div class="sticky top-0 shrink-0 flex">
               <Tabs.List>
                 <Tabs.Trigger value="chat">
@@ -350,15 +570,15 @@ export default function Page() {
                       value={`${new Intl.NumberFormat("en-US", {
                         notation: "compact",
                         compactDisplay: "short",
-                      }).format(session.usage.tokens() ?? 0)} Tokens`}
+                      }).format(tokens() ?? 0)} Tokens`}
                       class="flex items-center gap-1.5"
                     >
-                      <ProgressCircle percentage={session.usage.context() ?? 0} />
-                      <div class="text-14-regular text-text-weak text-left w-7">{session.usage.context() ?? 0}%</div>
+                      <ProgressCircle percentage={context() ?? 0} />
+                      <div class="text-14-regular text-text-weak text-left w-7">{context() ?? 0}%</div>
                     </Tooltip>
                   </div>
                 </Tabs.Trigger>
-                <Show when={layout.review.state() === "tab" && session.diffs().length}>
+                <Show when={layout.review.state() === "tab" && diffs().length}>
                   <Tabs.Trigger
                     value="review"
                     closeButton={
@@ -368,25 +588,23 @@ export default function Page() {
                     }
                   >
                     <div class="flex items-center gap-3">
-                      <Show when={session.diffs()}>
-                        <DiffChanges changes={session.diffs()} variant="bars" />
+                      <Show when={diffs()}>
+                        <DiffChanges changes={diffs()} variant="bars" />
                       </Show>
                       <div class="flex items-center gap-1.5">
                         <div>Review</div>
-                        <Show when={session.info()?.summary?.files}>
+                        <Show when={info()?.summary?.files}>
                           <div class="text-12-medium text-text-strong h-4 px-2 flex flex-col items-center justify-center rounded-full bg-surface-base">
-                            {session.info()?.summary?.files ?? 0}
+                            {info()?.summary?.files ?? 0}
                           </div>
                         </Show>
                       </div>
                     </div>
                   </Tabs.Trigger>
                 </Show>
-                <SortableProvider ids={session.layout.tabs.all ?? []}>
-                  <For each={session.layout.tabs.all ?? []}>
-                    {(tab) => (
-                      <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={session.layout.closeTab} />
-                    )}
+                <SortableProvider ids={tabs().all() ?? []}>
+                  <For each={tabs().all() ?? []}>
+                    {(tab) => <SortableTab tab={tab} onTabClick={handleTabClick} onTabClose={tabs().close} />}
                   </For>
                 </SortableProvider>
                 <div class="bg-background-base h-full flex items-center justify-center border-b border-border-weak-base px-3">
@@ -395,7 +613,7 @@ export default function Page() {
                       icon="plus-small"
                       variant="ghost"
                       iconSize="large"
-                      onClick={() => dialog.replace(() => <DialogSelectFile />)}
+                      onClick={() => dialog.show(() => <DialogSelectFile />)}
                     />
                   </Tooltip>
                 </div>
@@ -416,29 +634,33 @@ export default function Page() {
                   }}
                 >
                   <Switch>
-                    <Match when={session.id}>
+                    <Match when={params.id}>
                       <div class="flex items-start justify-start h-full min-h-0">
                         <SessionMessageRail
-                          messages={session.messages.user()}
-                          current={session.messages.active()}
-                          onMessageSelect={session.messages.setActive}
+                          messages={visibleUserMessages()}
+                          current={activeMessage()}
+                          onMessageSelect={setActiveMessage}
                           wide={wide()}
                         />
-                        <SessionTurn
-                          sessionID={session.id!}
-                          messageID={session.messages.active()?.id!}
-                          classes={{
-                            root: "pb-20 flex-1 min-w-0",
-                            content: "pb-20",
-                            container:
-                              "w-full " +
-                              (wide()
-                                ? "max-w-146 mx-auto px-6"
-                                : session.messages.user().length > 1
-                                  ? "pr-6 pl-18"
-                                  : "px-6"),
-                          }}
-                        />
+                        <Show when={activeMessage()}>
+                          <SessionTurn
+                            sessionID={params.id!}
+                            messageID={activeMessage()!.id}
+                            stepsExpanded={store.stepsExpanded}
+                            onStepsExpandedChange={(expanded) => setStore("stepsExpanded", expanded)}
+                            classes={{
+                              root: "pb-20 flex-1 min-w-0",
+                              content: "pb-20",
+                              container:
+                                "w-full " +
+                                (wide()
+                                  ? "max-w-146 mx-auto px-6"
+                                  : visibleUserMessages().length > 1
+                                    ? "pr-6 pl-18"
+                                    : "px-6"),
+                            }}
+                          />
+                        </Show>
                       </div>
                     </Match>
                     <Match when={true}>
@@ -477,7 +699,7 @@ export default function Page() {
                     </div>
                   </div>
                 </div>
-                <Show when={layout.review.state() === "pane" && session.diffs().length}>
+                <Show when={layout.review.state() === "pane" && diffs().length}>
                   <div
                     classList={{
                       "relative grow pt-3 flex-1 min-h-0 border-l border-border-weak-base": true,
@@ -489,7 +711,7 @@ export default function Page() {
                         header: "px-6",
                         container: "px-6",
                       }}
-                      diffs={session.diffs()}
+                      diffs={diffs()}
                       actions={
                         <Tooltip value="Open in tab">
                           <IconButton
@@ -497,7 +719,7 @@ export default function Page() {
                             variant="ghost"
                             onClick={() => {
                               layout.review.tab()
-                              session.layout.setActiveTab("review")
+                              tabs().setActive("review")
                             }}
                           />
                         </Tooltip>
@@ -507,7 +729,7 @@ export default function Page() {
                 </Show>
               </div>
             </Tabs.Content>
-            <Show when={layout.review.state() === "tab" && session.diffs().length}>
+            <Show when={layout.review.state() === "tab" && diffs().length}>
               <Tabs.Content value="review" class="select-text flex flex-col h-full overflow-hidden">
                 <div
                   classList={{
@@ -520,13 +742,13 @@ export default function Page() {
                       header: "px-6",
                       container: "px-6",
                     }}
-                    diffs={session.diffs()}
+                    diffs={diffs()}
                     split
                   />
                 </div>
               </Tabs.Content>
             </Show>
-            <For each={session.layout.tabs.all}>
+            <For each={tabs().all()}>
               {(tab) => {
                 const [file] = createResource(
                   () => tab,
@@ -580,7 +802,7 @@ export default function Page() {
             </Show>
           </DragOverlay>
         </DragDropProvider>
-        <Show when={session.layout.tabs.active}>
+        <Show when={tabs().active()}>
           <div class="absolute inset-x-0 px-6 max-w-146 flex flex-col justify-center items-center z-50 mx-auto bottom-8">
             <PromptInput
               ref={(el) => {
@@ -589,34 +811,6 @@ export default function Page() {
             />
           </div>
         </Show>
-        <div class="hidden shrink-0 w-56 p-2 h-full overflow-y-auto">
-          {/* <FileTree path="" onFileClick={ handleTabClick} /> */}
-        </div>
-        <div class="hidden shrink-0 w-56 p-2">
-          <Show
-            when={local.file.changes().length}
-            fallback={<div class="px-2 text-xs text-text-muted">No changes</div>}
-          >
-            <ul class="">
-              <For each={local.file.changes()}>
-                {(path) => (
-                  <li>
-                    <button
-                      onClick={() => local.file.open(path, { view: "diff-unified", pinned: true })}
-                      class="w-full flex items-center px-2 py-0.5 gap-x-2 text-text-muted grow min-w-0 hover:bg-background-element"
-                    >
-                      <FileIcon node={{ path, type: "file" }} class="shrink-0 size-3" />
-                      <span class="text-xs text-text whitespace-nowrap">{getFilename(path)}</span>
-                      <span class="text-xs text-text-muted/60 whitespace-nowrap truncate min-w-0">
-                        {getDirectory(path)}
-                      </span>
-                    </button>
-                  </li>
-                )}
-              </For>
-            </ul>
-          </Show>
-        </div>
       </div>
       <Show when={layout.terminal.opened()}>
         <div
@@ -640,25 +834,21 @@ export default function Page() {
           >
             <DragDropSensors />
             <ConstrainDragYAxis />
-            <Tabs variant="alt" value={session.terminal.active()} onChange={session.terminal.open}>
+            <Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
               <Tabs.List class="h-10">
-                <SortableProvider ids={session.terminal.all().map((t) => t.id)}>
-                  <For each={session.terminal.all()}>{(terminal) => <SortableTerminalTab terminal={terminal} />}</For>
+                <SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
+                  <For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</For>
                 </SortableProvider>
                 <div class="h-full flex items-center justify-center">
                   <Tooltip value="New Terminal" class="flex items-center">
-                    <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={session.terminal.new} />
+                    <IconButton icon="plus-small" variant="ghost" iconSize="large" onClick={terminal.new} />
                   </Tooltip>
                 </div>
               </Tabs.List>
-              <For each={session.terminal.all()}>
-                {(terminal) => (
-                  <Tabs.Content value={terminal.id}>
-                    <Terminal
-                      pty={terminal}
-                      onCleanup={session.terminal.update}
-                      onConnectError={() => session.terminal.clone(terminal.id)}
-                    />
+              <For each={terminal.all()}>
+                {(pty) => (
+                  <Tabs.Content value={pty.id}>
+                    <Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
                   </Tabs.Content>
                 )}
               </For>
@@ -666,9 +856,9 @@ export default function Page() {
             <DragOverlay>
               <Show when={store.activeTerminalDraggable}>
                 {(draggedId) => {
-                  const terminal = createMemo(() => session.terminal.all().find((t) => t.id === draggedId()))
+                  const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId()))
                   return (
-                    <Show when={terminal()}>
+                    <Show when={pty()}>
                       {(t) => (
                         <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
                           {t().title}

+ 47 - 0
packages/desktop/src/utils/prompt.ts

@@ -0,0 +1,47 @@
+import type { Part, TextPart, FilePart } from "@opencode-ai/sdk/v2"
+import type { Prompt, FileAttachmentPart } from "@/context/prompt"
+
+/**
+ * Extract prompt content from message parts for restoring into the prompt input.
+ * This is used by undo to restore the original user prompt.
+ */
+export function extractPromptFromParts(parts: Part[]): Prompt {
+  const result: Prompt = []
+  let position = 0
+
+  for (const part of parts) {
+    if (part.type === "text") {
+      const textPart = part as TextPart
+      if (!textPart.synthetic && textPart.text) {
+        result.push({
+          type: "text",
+          content: textPart.text,
+          start: position,
+          end: position + textPart.text.length,
+        })
+        position += textPart.text.length
+      }
+    } else if (part.type === "file") {
+      const filePart = part as FilePart
+      if (filePart.source?.type === "file") {
+        const path = filePart.source.path
+        const content = "@" + path
+        const attachment: FileAttachmentPart = {
+          type: "file",
+          path,
+          content,
+          start: position,
+          end: position + content.length,
+        }
+        result.push(attachment)
+        position += content.length
+      }
+    }
+  }
+
+  if (result.length === 0) {
+    result.push({ type: "text", content: "", start: 0, end: 0 })
+  }
+
+  return result
+}

+ 2 - 2
packages/enterprise/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/enterprise",
-  "version": "1.0.153",
+  "version": "1.0.162",
   "private": true,
   "type": "module",
   "scripts": {
@@ -14,7 +14,7 @@
     "@opencode-ai/util": "workspace:*",
     "@opencode-ai/ui": "workspace:*",
     "aws4fetch": "^1.0.20",
-    "@pierre/precision-diffs": "catalog:",
+    "@pierre/diffs": "catalog:",
     "@solidjs/router": "catalog:",
     "@solidjs/start": "catalog:",
     "@solidjs/meta": "catalog:",

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

@@ -19,7 +19,7 @@ import { createStore } from "solid-js/store"
 import z from "zod"
 import NotFound from "../[...404]"
 import { Tabs } from "@opencode-ai/ui/tabs"
-import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
+import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr"
 import { clientOnly } from "@solidjs/start"
 import { type IconName } from "@opencode-ai/ui/icons/provider"

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

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

+ 2 - 2
packages/function/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/function",
-  "version": "1.0.153",
+  "version": "1.0.162",
   "$schema": "https://json.schemastore.org/package.json",
   "private": true,
   "type": "module",
@@ -12,7 +12,7 @@
   },
   "dependencies": {
     "@octokit/auth-app": "8.0.1",
-    "@octokit/rest": "22.0.0",
+    "@octokit/rest": "catalog:",
     "hono": "catalog:",
     "jose": "6.0.11"
   }

+ 3 - 3
packages/opencode/package.json

@@ -1,6 +1,6 @@
 {
   "$schema": "https://json.schemastore.org/package.json",
-  "version": "1.0.153",
+  "version": "1.0.162",
   "name": "opencode",
   "type": "module",
   "private": true,
@@ -64,7 +64,7 @@
     "@hono/zod-validator": "catalog:",
     "@modelcontextprotocol/sdk": "1.15.1",
     "@octokit/graphql": "9.0.2",
-    "@octokit/rest": "22.0.0",
+    "@octokit/rest": "catalog:",
     "@openauthjs/openauth": "catalog:",
     "@opencode-ai/plugin": "workspace:*",
     "@opencode-ai/script": "workspace:*",
@@ -74,7 +74,7 @@
     "@opentui/core": "0.0.0-20251211-4403a69a",
     "@opentui/solid": "0.0.0-20251211-4403a69a",
     "@parcel/watcher": "2.5.1",
-    "@pierre/precision-diffs": "catalog:",
+    "@pierre/diffs": "catalog:",
     "@solid-primitives/event-bus": "1.1.2",
     "@standard-schema/spec": "1.0.0",
     "@zip.js/zip.js": "2.7.62",

+ 20 - 20
packages/opencode/src/agent/agent.ts

@@ -107,6 +107,24 @@ export namespace Agent {
     )
 
     const result: Record<string, Info> = {
+      build: {
+        name: "build",
+        tools: { ...defaultTools },
+        options: {},
+        permission: agentPermission,
+        mode: "primary",
+        native: true,
+      },
+      plan: {
+        name: "plan",
+        options: {},
+        permission: planPermission,
+        tools: {
+          ...defaultTools,
+        },
+        mode: "primary",
+        native: true,
+      },
       general: {
         name: "general",
         description: `General-purpose agent for researching complex questions and executing multi-step tasks. Use this agent to execute multiple units of work in parallel.`,
@@ -149,14 +167,6 @@ export namespace Agent {
         options: {},
         permission: agentPermission,
       },
-      build: {
-        name: "build",
-        tools: { ...defaultTools },
-        options: {},
-        permission: agentPermission,
-        mode: "primary",
-        native: true,
-      },
       title: {
         name: "title",
         mode: "primary",
@@ -177,16 +187,6 @@ export namespace Agent {
         prompt: PROMPT_SUMMARY,
         tools: {},
       },
-      plan: {
-        name: "plan",
-        options: {},
-        permission: planPermission,
-        tools: {
-          ...defaultTools,
-        },
-        mode: "primary",
-        native: true,
-      },
     }
     for (const [key, value] of Object.entries(cfg.agent ?? {})) {
       if (value.disable) {
@@ -256,9 +256,9 @@ export namespace Agent {
     return state().then((x) => Object.values(x))
   }
 
-  export async function generate(input: { description: string }) {
+  export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) {
     const cfg = await Config.get()
-    const defaultModel = await Provider.defaultModel()
+    const defaultModel = input.model ?? (await Provider.defaultModel())
     const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID)
     const language = await Provider.getLanguage(model)
     const system = SystemPrompt.header(defaultModel.providerID)

+ 8 - 1
packages/opencode/src/cli/cmd/agent.ts

@@ -3,6 +3,7 @@ import * as prompts from "@clack/prompts"
 import { UI } from "../ui"
 import { Global } from "../../global"
 import { Agent } from "../../agent/agent"
+import { Provider } from "../../provider/provider"
 import path from "path"
 import fs from "fs/promises"
 import matter from "gray-matter"
@@ -47,6 +48,11 @@ const AgentCreateCommand = cmd({
       .option("tools", {
         type: "string",
         describe: `comma-separated list of tools to enable (default: all). Available: "${AVAILABLE_TOOLS.join(", ")}"`,
+      })
+      .option("model", {
+        type: "string",
+        alias: ["m"],
+        describe: "model to use in the format of provider/model",
       }),
   async handler(args) {
     await Instance.provide({
@@ -114,7 +120,8 @@ const AgentCreateCommand = cmd({
         // Generate agent
         const spinner = prompts.spinner()
         spinner.start("Generating agent configuration...")
-        const generated = await Agent.generate({ description }).catch((error) => {
+        const model = args.model ? Provider.parseModel(args.model) : undefined
+        const generated = await Agent.generate({ description, model }).catch((error) => {
           spinner.stop(`LLM failed to generate agent: ${error.message}`, 1)
           if (isFullyNonInteractive) process.exit(1)
           throw new UI.CancelledError()

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

@@ -278,7 +278,7 @@ export const GithubInstallCommand = cmd({
               process.platform === "darwin"
                 ? `open "${url}"`
                 : process.platform === "win32"
-                  ? `start "${url}"`
+                  ? `start "" "${url}"`
                   : `xdg-open "${url}"`
 
             exec(command, (error) => {

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

@@ -297,6 +297,24 @@ function App() {
         local.model.cycle(-1)
       },
     },
+    {
+      title: "Favorite cycle",
+      value: "model.cycle_favorite",
+      keybind: "model_cycle_favorite",
+      category: "Agent",
+      onSelect: () => {
+        local.model.cycleFavorite(1)
+      },
+    },
+    {
+      title: "Favorite cycle reverse",
+      value: "model.cycle_favorite_reverse",
+      keybind: "model_cycle_favorite_reverse",
+      category: "Agent",
+      onSelect: () => {
+        local.model.cycleFavorite(-1)
+      },
+    },
     {
       title: "Switch agent",
       value: "agent.list",

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

@@ -364,6 +364,13 @@ export function Autocomplete(props: {
     const result = fuzzysort.go(currentFilter, mixed, {
       keys: [(obj) => obj.display.trimEnd(), "description", (obj) => obj.aliases?.join(" ") ?? ""],
       limit: 10,
+      scoreFn: (objResults) => {
+        const displayResult = objResults[0]
+        if (displayResult && displayResult.target.startsWith(store.visible + currentFilter)) {
+          return objResults.score * 2
+        }
+        return objResults.score
+      },
     })
     return result.map((arr) => arr.obj)
   })

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

@@ -9,6 +9,7 @@ import type { AgentPart, FilePart, TextPart } from "@opencode-ai/sdk/v2"
 
 export type PromptInfo = {
   input: string
+  mode?: "normal" | "shell"
   parts: (
     | Omit<FilePart, "id" | "messageID" | "sessionID">
     | Omit<AgentPart, "id" | "messageID" | "sessionID">

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

@@ -495,6 +495,9 @@ export function Prompt(props: PromptProps) {
     // Filter out text parts (pasted content) since they're now expanded inline
     const nonTextParts = store.prompt.parts.filter((part) => part.type !== "text")
 
+    // Capture mode before it gets reset
+    const currentMode = store.mode
+
     if (store.mode === "shell") {
       sdk.client.session.shell({
         sessionID,
@@ -543,7 +546,10 @@ export function Prompt(props: PromptProps) {
         ],
       })
     }
-    history.append(store.prompt)
+    history.append({
+      ...store.prompt,
+      mode: currentMode,
+    })
     input.extmarks.clear()
     setStore("prompt", {
       input: "",
@@ -767,6 +773,7 @@ export function Prompt(props: PromptProps) {
                     if (item) {
                       input.setText(item.input)
                       setStore("prompt", item)
+                      setStore("mode", item.mode ?? "normal")
                       restoreExtmarksFromParts(item.parts)
                       e.preventDefault()
                       if (direction === -1) input.cursorOffset = 0

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

@@ -64,6 +64,7 @@ import { Editor } from "../../util/editor"
 import stripAnsi from "strip-ansi"
 import { Footer } from "./footer.tsx"
 import { usePromptRef } from "../../context/prompt"
+import { Filesystem } from "@/util/filesystem"
 
 addDefaultParsers(parsers.parsers)
 
@@ -1414,7 +1415,10 @@ ToolRegistry.register<typeof WriteTool>({
       return props.input.content
     })
 
-    const diagnostics = createMemo(() => props.metadata.diagnostics?.[props.input.filePath ?? ""] ?? [])
+    const diagnostics = createMemo(() => {
+      const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
+      return props.metadata.diagnostics?.[filePath] ?? []
+    })
 
     return (
       <>
@@ -1587,7 +1591,8 @@ ToolRegistry.register<typeof EditTool>({
     const diffContent = createMemo(() => props.metadata.diff ?? props.permission["diff"])
 
     const diagnostics = createMemo(() => {
-      const arr = props.metadata.diagnostics?.[props.input.filePath ?? ""] ?? []
+      const filePath = Filesystem.normalizePath(props.input.filePath ?? "")
+      const arr = props.metadata.diagnostics?.[filePath] ?? []
       return arr.filter((x) => x.severity === 1).slice(0, 3)
     })
 

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

@@ -307,10 +307,9 @@ function Option(props: {
         fg={props.active ? fg : props.current ? theme.primary : theme.text}
         attributes={props.active ? TextAttributes.BOLD : undefined}
         overflow="hidden"
-        wrapMode="word"
         paddingLeft={3}
       >
-        {Locale.truncate(props.title, 62)}
+        {Locale.truncate(props.title, 61)}
         <Show when={props.description}>
           <span style={{ fg: props.active ? fg : theme.textMuted }}> {props.description}</span>
         </Show>

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

@@ -460,6 +460,8 @@ export namespace Config {
       model_list: z.string().optional().default("<leader>m").describe("List available models"),
       model_cycle_recent: z.string().optional().default("f2").describe("Next recently used model"),
       model_cycle_recent_reverse: z.string().optional().default("shift+f2").describe("Previous recently used model"),
+      model_cycle_favorite: z.string().optional().default("none").describe("Next favorite model"),
+      model_cycle_favorite_reverse: z.string().optional().default("none").describe("Previous favorite model"),
       command_list: z.string().optional().default("ctrl+p").describe("List available commands"),
       agent_list: z.string().optional().default("<leader>a").describe("List agents"),
       agent_cycle: z.string().optional().default("tab").describe("Next agent"),
@@ -668,10 +670,16 @@ export namespace Config {
         .describe("@deprecated Use `agent` field instead."),
       agent: z
         .object({
+          // primary
           plan: Agent.optional(),
           build: Agent.optional(),
+          // subagent
           general: Agent.optional(),
           explore: Agent.optional(),
+          // specialized
+          title: Agent.optional(),
+          summary: Agent.optional(),
+          compaction: Agent.optional(),
         })
         .catchall(Agent)
         .optional()

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

@@ -11,6 +11,9 @@ import type { LSPServer } from "./server"
 import { NamedError } from "@opencode-ai/util/error"
 import { withTimeout } from "../util/timeout"
 import { Instance } from "../project/instance"
+import { Filesystem } from "../util/filesystem"
+
+const DIAGNOSTICS_DEBOUNCE_MS = 150
 
 export namespace LSPClient {
   const log = Log.create({ service: "lsp.client" })
@@ -47,14 +50,15 @@ export namespace LSPClient {
 
     const diagnostics = new Map<string, Diagnostic[]>()
     connection.onNotification("textDocument/publishDiagnostics", (params) => {
-      const path = fileURLToPath(params.uri)
+      const filePath = Filesystem.normalizePath(fileURLToPath(params.uri))
       l.info("textDocument/publishDiagnostics", {
-        path,
+        path: filePath,
+        count: params.diagnostics.length,
       })
-      const exists = diagnostics.has(path)
-      diagnostics.set(path, params.diagnostics)
+      const exists = diagnostics.has(filePath)
+      diagnostics.set(filePath, params.diagnostics)
       if (!exists && input.serverID === "typescript") return
-      Bus.publish(Event.Diagnostics, { path, serverID: input.serverID })
+      Bus.publish(Event.Diagnostics, { path: filePath, serverID: input.serverID })
     })
     connection.onRequest("window/workDoneProgress/create", (params) => {
       l.info("window/workDoneProgress/create", params)
@@ -181,16 +185,23 @@ export namespace LSPClient {
         return diagnostics
       },
       async waitForDiagnostics(input: { path: string }) {
-        input.path = path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path)
-        log.info("waiting for diagnostics", input)
+        const normalizedPath = Filesystem.normalizePath(
+          path.isAbsolute(input.path) ? input.path : path.resolve(Instance.directory, input.path),
+        )
+        log.info("waiting for diagnostics", { path: normalizedPath })
         let unsub: () => void
+        let debounceTimer: ReturnType<typeof setTimeout> | undefined
         return await withTimeout(
           new Promise<void>((resolve) => {
             unsub = Bus.subscribe(Event.Diagnostics, (event) => {
-              if (event.properties.path === input.path && event.properties.serverID === result.serverID) {
-                log.info("got diagnostics", input)
-                unsub?.()
-                resolve()
+              if (event.properties.path === normalizedPath && event.properties.serverID === result.serverID) {
+                // Debounce to allow LSP to send follow-up diagnostics (e.g., semantic after syntax)
+                if (debounceTimer) clearTimeout(debounceTimer)
+                debounceTimer = setTimeout(() => {
+                  log.info("got diagnostics", { path: normalizedPath })
+                  unsub?.()
+                  resolve()
+                }, DIAGNOSTICS_DEBOUNCE_MS)
               }
             })
           }),
@@ -198,6 +209,7 @@ export namespace LSPClient {
         )
           .catch(() => {})
           .finally(() => {
+            if (debounceTimer) clearTimeout(debounceTimer)
             unsub?.()
           })
       },

+ 104 - 20
packages/opencode/src/lsp/server.ts

@@ -9,6 +9,7 @@ import fs from "fs/promises"
 import { Filesystem } from "../util/filesystem"
 import { Instance } from "../project/instance"
 import { Flag } from "../flag/flag"
+import { Archive } from "../util/archive"
 
 export namespace LSPServer {
   const log = Log.create({ service: "lsp.server" })
@@ -176,7 +177,13 @@ export namespace LSPServer {
         const zipPath = path.join(Global.Path.bin, "vscode-eslint.zip")
         await Bun.file(zipPath).write(response)
 
-        await $`unzip -o -q ${zipPath}`.quiet().cwd(Global.Path.bin).nothrow()
+        const ok = await Archive.extractZip(zipPath, Global.Path.bin)
+          .then(() => true)
+          .catch((error) => {
+            log.error("Failed to extract vscode-eslint archive", { error })
+            return false
+          })
+        if (!ok) return
         await fs.rm(zipPath, { force: true })
 
         const extractedPath = path.join(Global.Path.bin, "vscode-eslint-main")
@@ -281,7 +288,7 @@ export namespace LSPServer {
     extensions: [".go"],
     async spawn(root) {
       let bin = Bun.which("gopls", {
-        PATH: process.env["PATH"] + ":" + Global.Path.bin,
+        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
       })
       if (!bin) {
         if (!Bun.which("go")) return
@@ -319,7 +326,7 @@ export namespace LSPServer {
     extensions: [".rb", ".rake", ".gemspec", ".ru"],
     async spawn(root) {
       let bin = Bun.which("rubocop", {
-        PATH: process.env["PATH"] + ":" + Global.Path.bin,
+        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
       })
       if (!bin) {
         const ruby = Bun.which("ruby")
@@ -420,7 +427,7 @@ export namespace LSPServer {
           Global.Path.bin,
           "elixir-ls-master",
           "release",
-          process.platform === "win32" ? "language_server.bar" : "language_server.sh",
+          process.platform === "win32" ? "language_server.bat" : "language_server.sh",
         )
 
         if (!(await Bun.file(binary).exists())) {
@@ -438,7 +445,13 @@ export namespace LSPServer {
           const zipPath = path.join(Global.Path.bin, "elixir-ls.zip")
           await Bun.file(zipPath).write(response)
 
-          await $`unzip -o -q ${zipPath}`.quiet().cwd(Global.Path.bin).nothrow()
+          const ok = await Archive.extractZip(zipPath, Global.Path.bin)
+            .then(() => true)
+            .catch((error) => {
+              log.error("Failed to extract elixir-ls archive", { error })
+              return false
+            })
+          if (!ok) return
 
           await fs.rm(zipPath, {
             force: true,
@@ -470,7 +483,7 @@ export namespace LSPServer {
     root: NearestRoot(["build.zig"]),
     async spawn(root) {
       let bin = Bun.which("zls", {
-        PATH: process.env["PATH"] + ":" + Global.Path.bin,
+        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
       })
 
       if (!bin) {
@@ -541,7 +554,13 @@ export namespace LSPServer {
         await Bun.file(tempPath).write(downloadResponse)
 
         if (ext === "zip") {
-          await $`unzip -o -q ${tempPath}`.quiet().cwd(Global.Path.bin).nothrow()
+          const ok = await Archive.extractZip(tempPath, Global.Path.bin)
+            .then(() => true)
+            .catch((error) => {
+              log.error("Failed to extract zls archive", { error })
+              return false
+            })
+          if (!ok) return
         } else {
           await $`tar -xf ${tempPath}`.cwd(Global.Path.bin).nothrow()
         }
@@ -576,7 +595,7 @@ export namespace LSPServer {
     extensions: [".cs"],
     async spawn(root) {
       let bin = Bun.which("csharp-ls", {
-        PATH: process.env["PATH"] + ":" + Global.Path.bin,
+        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
       })
       if (!bin) {
         if (!Bun.which("dotnet")) {
@@ -610,6 +629,46 @@ export namespace LSPServer {
     },
   }
 
+  export const FSharp: Info = {
+    id: "fsharp",
+    root: NearestRoot([".sln", ".fsproj", "global.json"]),
+    extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
+    async spawn(root) {
+      let bin = Bun.which("fsautocomplete", {
+        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
+      })
+      if (!bin) {
+        if (!Bun.which("dotnet")) {
+          log.error(".NET SDK is required to install fsautocomplete")
+          return
+        }
+
+        if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+        log.info("installing fsautocomplete via dotnet tool")
+        const proc = Bun.spawn({
+          cmd: ["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin],
+          stdout: "pipe",
+          stderr: "pipe",
+          stdin: "pipe",
+        })
+        const exit = await proc.exited
+        if (exit !== 0) {
+          log.error("Failed to install fsautocomplete")
+          return
+        }
+
+        bin = path.join(Global.Path.bin, "fsautocomplete" + (process.platform === "win32" ? ".exe" : ""))
+        log.info(`installed fsautocomplete`, { bin })
+      }
+
+      return {
+        process: spawn(bin, {
+          cwd: root,
+        }),
+      }
+    },
+  }
+
   export const SourceKit: Info = {
     id: "sourcekit-lsp",
     extensions: [".swift", ".objc", "objcpp"],
@@ -800,7 +859,13 @@ export namespace LSPServer {
       }
 
       if (zip) {
-        await $`unzip -o -q ${archive}`.quiet().cwd(Global.Path.bin).nothrow()
+        const ok = await Archive.extractZip(archive, Global.Path.bin)
+          .then(() => true)
+          .catch((error) => {
+            log.error("Failed to extract clangd archive", { error })
+            return false
+          })
+        if (!ok) return
       }
       if (tar) {
         await $`tar -xf ${archive}`.cwd(Global.Path.bin).nothrow()
@@ -1070,7 +1135,7 @@ export namespace LSPServer {
     extensions: [".lua"],
     async spawn(root) {
       let bin = Bun.which("lua-language-server", {
-        PATH: process.env["PATH"] + ":" + Global.Path.bin,
+        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
       })
 
       if (!bin) {
@@ -1148,14 +1213,21 @@ export namespace LSPServer {
         await fs.mkdir(installDir, { recursive: true })
 
         if (ext === "zip") {
-          const ok = await $`unzip -o -q ${tempPath} -d ${installDir}`.quiet().catch((error) => {
-            log.error("Failed to extract lua-language-server archive", { error })
-          })
+          const ok = await Archive.extractZip(tempPath, installDir)
+            .then(() => true)
+            .catch((error) => {
+              log.error("Failed to extract lua-language-server archive", { error })
+              return false
+            })
           if (!ok) return
         } else {
-          const ok = await $`tar -xzf ${tempPath} -C ${installDir}`.quiet().catch((error) => {
-            log.error("Failed to extract lua-language-server archive", { error })
-          })
+          const ok = await $`tar -xzf ${tempPath} -C ${installDir}`
+            .quiet()
+            .then(() => true)
+            .catch((error) => {
+              log.error("Failed to extract lua-language-server archive", { error })
+              return false
+            })
           if (!ok) return
         }
 
@@ -1309,7 +1381,7 @@ export namespace LSPServer {
     root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
     async spawn(root) {
       let bin = Bun.which("terraform-ls", {
-        PATH: process.env["PATH"] + ":" + Global.Path.bin,
+        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
       })
 
       if (!bin) {
@@ -1356,7 +1428,13 @@ export namespace LSPServer {
         const tempPath = path.join(Global.Path.bin, assetName)
         await Bun.file(tempPath).write(downloadResponse)
 
-        await $`unzip -o -q ${tempPath}`.cwd(Global.Path.bin).nothrow()
+        const ok = await Archive.extractZip(tempPath, Global.Path.bin)
+          .then(() => true)
+          .catch((error) => {
+            log.error("Failed to extract terraform-ls archive", { error })
+            return false
+          })
+        if (!ok) return
         await fs.rm(tempPath, { force: true })
 
         bin = path.join(Global.Path.bin, "terraform-ls" + (platform === "win32" ? ".exe" : ""))
@@ -1393,7 +1471,7 @@ export namespace LSPServer {
     root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
     async spawn(root) {
       let bin = Bun.which("texlab", {
-        PATH: process.env["PATH"] + ":" + Global.Path.bin,
+        PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
       })
 
       if (!bin) {
@@ -1441,7 +1519,13 @@ export namespace LSPServer {
         await Bun.file(tempPath).write(downloadResponse)
 
         if (ext === "zip") {
-          await $`unzip -o -q ${tempPath}`.cwd(Global.Path.bin).nothrow()
+          const ok = await Archive.extractZip(tempPath, Global.Path.bin)
+            .then(() => true)
+            .catch((error) => {
+              log.error("Failed to extract texlab archive", { error })
+              return false
+            })
+          if (!ok) return
         }
         if (ext === "tar.gz") {
           await $`tar -xzf ${tempPath}`.cwd(Global.Path.bin).nothrow()

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

@@ -205,7 +205,12 @@ export namespace ProviderTransform {
   export function message(msgs: ModelMessage[], model: Provider.Model) {
     msgs = unsupportedParts(msgs, model)
     msgs = normalizeMessages(msgs, model)
-    if (model.providerID === "anthropic" || model.api.id.includes("anthropic") || model.api.id.includes("claude")) {
+    if (
+      model.providerID === "anthropic" ||
+      model.api.id.includes("anthropic") ||
+      model.api.id.includes("claude") ||
+      model.api.npm === "@ai-sdk/anthropic"
+    ) {
       msgs = applyCaching(msgs, model.providerID)
     }
 

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

@@ -114,8 +114,12 @@ export namespace Pty {
     const id = Identifier.create("pty", false)
     const command = input.command || Shell.preferred()
     const args = input.args || []
+    if (command.endsWith("sh")) {
+      args.push("-l")
+    }
+
     const cwd = input.cwd || Instance.directory
-    const env = { ...process.env, ...input.env } as Record<string, string>
+    const env = { ...process.env, ...input.env, TERM: "xterm-256color" } as Record<string, string>
     log.info("creating session", { id, cmd: command, args, cwd })
 
     const spawn = await pty()

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

@@ -125,6 +125,14 @@ export namespace Server {
         async (c) => {
           log.info("global event connected")
           return streamSSE(c, async (stream) => {
+            stream.writeSSE({
+              data: JSON.stringify({
+                payload: {
+                  type: "server.connected",
+                  properties: {},
+                },
+              }),
+            })
             async function handler(event: any) {
               await stream.writeSSE({
                 data: JSON.stringify(event),

+ 9 - 0
packages/opencode/src/session/llm.ts

@@ -60,11 +60,18 @@ export namespace LLM {
         .join("\n"),
     )
 
+    const header = system[0]
     const original = clone(system)
     await Plugin.trigger("experimental.chat.system.transform", {}, { system })
     if (system.length === 0) {
       system.push(...original)
     }
+    // rejoin to maintain 2-part structure for caching if header unchanged
+    if (system.length > 2 && system[0] === header) {
+      const rest = system.slice(1)
+      system.length = 0
+      system.push(header, rest.join("\n"))
+    }
 
     const params = await Plugin.trigger(
       "chat.params",
@@ -80,6 +87,7 @@ export namespace LLM {
           ? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
           : undefined,
         topP: input.agent.topP ?? ProviderTransform.topP(input.model),
+        topK: ProviderTransform.topK(input.model),
         options: pipe(
           {},
           mergeDeep(ProviderTransform.options(input.model, input.sessionID)),
@@ -132,6 +140,7 @@ export namespace LLM {
       },
       temperature: params.temperature,
       topP: params.topP,
+      topK: params.topK,
       providerOptions: ProviderTransform.providerOptions(input.model, params.options),
       activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
       tools,

+ 8 - 10
packages/opencode/src/tool/edit.ts

@@ -140,16 +140,14 @@ export const EditTool = Tool.define("edit", {
     let output = ""
     await LSP.touchFile(filePath, true)
     const diagnostics = await LSP.diagnostics()
-    for (const [file, issues] of Object.entries(diagnostics)) {
-      if (issues.length === 0) continue
-      if (file === filePath) {
-        const errors = issues.filter((item) => item.severity === 1)
-        const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
-        const suffix =
-          errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
-        output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
-        continue
-      }
+    const normalizedFilePath = Filesystem.normalizePath(filePath)
+    const issues = diagnostics[normalizedFilePath] ?? []
+    if (issues.length > 0) {
+      const errors = issues.filter((item) => item.severity === 1)
+      const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE)
+      const suffix =
+        errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
+      output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
     }
 
     const filediff: Snapshot.FileDiff = {

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

@@ -80,6 +80,7 @@ export const WriteTool = Tool.define("write", {
     let output = ""
     await LSP.touchFile(filepath, true)
     const diagnostics = await LSP.diagnostics()
+    const normalizedFilepath = Filesystem.normalizePath(filepath)
     let projectDiagnosticsCount = 0
     for (const [file, issues] of Object.entries(diagnostics)) {
       if (issues.length === 0) continue
@@ -87,7 +88,7 @@ export const WriteTool = Tool.define("write", {
       const limited = sorted.slice(0, MAX_DIAGNOSTICS_PER_FILE)
       const suffix =
         issues.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${issues.length - MAX_DIAGNOSTICS_PER_FILE} more` : ""
-      if (file === filepath) {
+      if (file === normalizedFilepath) {
         output += `\nThis file has errors, please fix\n<file_diagnostics>\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n</file_diagnostics>\n`
         continue
       }

+ 16 - 0
packages/opencode/src/util/archive.ts

@@ -0,0 +1,16 @@
+import { $ } from "bun"
+import path from "path"
+
+export namespace Archive {
+  export async function extractZip(zipPath: string, destDir: string) {
+    if (process.platform === "win32") {
+      const winZipPath = path.resolve(zipPath)
+      const winDestDir = path.resolve(destDir)
+      // $global:ProgressPreference suppresses PowerShell's blue progress bar popup
+      const cmd = `$global:ProgressPreference = 'SilentlyContinue'; Expand-Archive -Path '${winZipPath}' -DestinationPath '${winDestDir}' -Force`
+      await $`powershell -NoProfile -NonInteractive -Command ${cmd}`.quiet()
+    } else {
+      await $`unzip -o -q ${zipPath} -d ${destDir}`.quiet()
+    }
+  }
+}

+ 14 - 0
packages/opencode/src/util/filesystem.ts

@@ -1,7 +1,21 @@
+import { realpathSync } from "fs"
 import { exists } from "fs/promises"
 import { dirname, join, relative } from "path"
 
 export namespace Filesystem {
+  /**
+   * On Windows, normalize a path to its canonical casing using the filesystem.
+   * This is needed because Windows paths are case-insensitive but LSP servers
+   * may return paths with different casing than what we send them.
+   */
+  export function normalizePath(p: string): string {
+    if (process.platform !== "win32") return p
+    try {
+      return realpathSync.native(p)
+    } catch {
+      return p
+    }
+  }
   export function overlaps(a: string, b: string) {
     const relA = relative(a, b)
     const relB = relative(b, a)

+ 1 - 1
packages/plugin/package.json

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

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

@@ -161,7 +161,7 @@ export interface Hooks {
    */
   "chat.params"?: (
     input: { sessionID: string; agent: string; model: Model; provider: ProviderContext; message: UserMessage },
-    output: { temperature: number; topP: number; options: Record<string, any> },
+    output: { temperature: number; topP: number; topK: number; options: Record<string, any> },
   ) => Promise<void>
   "permission.ask"?: (input: Permission, output: { status: "ask" | "deny" | "allow" }) => Promise<void>
   "tool.execute.before"?: (

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

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

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

@@ -917,6 +917,14 @@ export type KeybindsConfig = {
    * Previous recently used model
    */
   model_cycle_recent_reverse?: string
+  /**
+   * Next favorite model
+   */
+  model_cycle_favorite?: string
+  /**
+   * Previous favorite model
+   */
+  model_cycle_favorite_reverse?: string
   /**
    * List available commands
    */
@@ -1413,6 +1421,9 @@ export type Config = {
     build?: AgentConfig
     general?: AgentConfig
     explore?: AgentConfig
+    title?: AgentConfig
+    summary?: AgentConfig
+    compaction?: AgentConfig
     [key: string]: AgentConfig | undefined
   }
   /**

+ 19 - 0
packages/sdk/openapi.json

@@ -7169,6 +7169,16 @@
             "default": "shift+f2",
             "type": "string"
           },
+          "model_cycle_favorite": {
+            "description": "Next favorite model",
+            "default": "none",
+            "type": "string"
+          },
+          "model_cycle_favorite_reverse": {
+            "description": "Previous favorite model",
+            "default": "none",
+            "type": "string"
+          },
           "command_list": {
             "description": "List available commands",
             "default": "ctrl+p",
@@ -7992,6 +8002,15 @@
               },
               "explore": {
                 "$ref": "#/components/schemas/AgentConfig"
+              },
+              "title": {
+                "$ref": "#/components/schemas/AgentConfig"
+              },
+              "summary": {
+                "$ref": "#/components/schemas/AgentConfig"
+              },
+              "compaction": {
+                "$ref": "#/components/schemas/AgentConfig"
               }
             },
             "additionalProperties": {

+ 1 - 1
packages/slack/package.json

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

+ 1 - 1
packages/tauri/package.json

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

+ 0 - 1
packages/tauri/scripts/predev.ts

@@ -1,4 +1,3 @@
-import * as fs from "node:fs/promises"
 import { $ } from "bun"
 
 import { copyBinaryToSidecarFolder, getCurrentSidecar } from "./utils"

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

@@ -183,6 +183,7 @@ pub fn run() {
                         .inner_size(size.width as f64, size.height as f64)
                         .decorations(true)
                         .zoom_hotkeys_enabled(true)
+                        .disable_drag_drop_handler()
                         .initialization_script(format!(
                             r#"
                           window.__OPENCODE__ ??= {{}};

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

@@ -19,7 +19,7 @@
   },
   "bundle": {
     "active": true,
-    "targets": ["deb", "rpm", "dmg", "nsis"],
+    "targets": ["deb", "rpm", "dmg", "nsis", "app"],
     "icon": ["icons/32x32.png", "icons/128x128.png", "icons/[email protected]", "icons/icon.icns", "icons/icon.ico"],
     "externalBin": ["sidecars/opencode-cli"],
     "createUpdaterArtifacts": true,

+ 6 - 2
packages/tauri/src/index.tsx

@@ -1,12 +1,14 @@
 // @refresh reload
 import { render } from "solid-js/web"
 import { App, PlatformProvider, Platform } from "@opencode-ai/desktop"
-import { runUpdater } from "./updater"
 import { onMount } from "solid-js"
 import { open, save } from "@tauri-apps/plugin-dialog"
 import { open as shellOpen } from "@tauri-apps/plugin-shell"
 import { type as ostype } from "@tauri-apps/plugin-os"
 
+import { runUpdater, UPDATER_ENABLED } from "./updater"
+import { createMenu } from "./menu"
+
 const root = document.getElementById("root")
 if (import.meta.env.DEV && !(root instanceof HTMLElement)) {
   throw new Error(
@@ -48,9 +50,11 @@ const platform: Platform = {
   },
 }
 
+createMenu()
+
 render(() => {
   onMount(() => {
-    if (window.__OPENCODE__?.updaterEnabled) runUpdater()
+    if (UPDATER_ENABLED) runUpdater()
   })
 
   return (

+ 94 - 0
packages/tauri/src/menu.ts

@@ -0,0 +1,94 @@
+import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu"
+import { type as ostype } from "@tauri-apps/plugin-os"
+
+import { runUpdater, UPDATER_ENABLED } from "./updater"
+
+export async function createMenu() {
+  if (ostype() !== "macos") return
+
+  const menu = await Menu.new({
+    items: [
+      await Submenu.new({
+        text: "OpenCode",
+        items: [
+          await PredefinedMenuItem.new({
+            item: { About: null },
+          }),
+          await MenuItem.new({
+            enabled: UPDATER_ENABLED,
+            action: () => runUpdater(),
+            text: "Check For Updates...",
+          }),
+          await PredefinedMenuItem.new({
+            item: "Separator",
+          }),
+          await PredefinedMenuItem.new({
+            item: "Hide",
+          }),
+          await PredefinedMenuItem.new({
+            item: "HideOthers",
+          }),
+          await PredefinedMenuItem.new({
+            item: "ShowAll",
+          }),
+          await PredefinedMenuItem.new({
+            item: "Separator",
+          }),
+          await PredefinedMenuItem.new({
+            item: "Quit",
+          }),
+        ].filter(Boolean),
+      }),
+      // await Submenu.new({
+      //   text: "File",
+      //   items: [
+      //     await MenuItem.new({
+      //       enabled: false,
+      //       text: "Open Project...",
+      //     }),
+      //     await PredefinedMenuItem.new({
+      //       item: "Separator"
+      //     }),
+      //     await MenuItem.new({
+      //       enabled: false,
+      //       text: "New Session",
+      //     }),
+      //     await PredefinedMenuItem.new({
+      //       item: "Separator"
+      //     }),
+      //     await MenuItem.new({
+      //       enabled: false,
+      //       text: "Close Project",
+      //     })
+      //   ]
+      // }),
+      await Submenu.new({
+        text: "Edit",
+        items: [
+          await PredefinedMenuItem.new({
+            item: "Undo",
+          }),
+          await PredefinedMenuItem.new({
+            item: "Redo",
+          }),
+          await PredefinedMenuItem.new({
+            item: "Separator",
+          }),
+          await PredefinedMenuItem.new({
+            item: "Cut",
+          }),
+          await PredefinedMenuItem.new({
+            item: "Copy",
+          }),
+          await PredefinedMenuItem.new({
+            item: "Paste",
+          }),
+          await PredefinedMenuItem.new({
+            item: "SelectAll",
+          }),
+        ],
+      }),
+    ],
+  })
+  menu.setAsAppMenu()
+}

+ 2 - 0
packages/tauri/src/updater.ts

@@ -2,6 +2,8 @@ import { check, DownloadEvent } from "@tauri-apps/plugin-updater"
 import { relaunch } from "@tauri-apps/plugin-process"
 import { ask, message } from "@tauri-apps/plugin-dialog"
 
+export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
+
 export async function runUpdater(onDownloadEvent?: (progress: DownloadEvent) => void) {
   let update
   try {

+ 2 - 2
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/ui",
-  "version": "1.0.153",
+  "version": "1.0.162",
   "type": "module",
   "exports": {
     "./*": "./src/components/*.tsx",
@@ -35,7 +35,7 @@
     "@kobalte/core": "catalog:",
     "@opencode-ai/sdk": "workspace:*",
     "@opencode-ai/util": "workspace:*",
-    "@pierre/precision-diffs": "catalog:",
+    "@pierre/diffs": "catalog:",
     "@shikijs/transformers": "3.9.2",
     "@solid-primitives/bounds": "0.1.3",
     "@solid-primitives/resize-observer": "2.1.3",

BIN
packages/ui/src/assets/audio/nope-01.aac


BIN
packages/ui/src/assets/audio/nope-02.aac


BIN
packages/ui/src/assets/audio/nope-03.aac


BIN
packages/ui/src/assets/audio/nope-04.aac


BIN
packages/ui/src/assets/audio/nope-05.aac


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

@@ -1,4 +1,4 @@
-import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/precision-diffs"
+import { type FileContents, File, FileOptions, LineAnnotation } from "@pierre/diffs"
 import { ComponentProps, createEffect, createMemo, splitProps } from "solid-js"
 import { createDefaultOptions, styleVariables } from "../pierre"
 import { workerPool } from "../pierre/worker"

+ 8 - 0
packages/ui/src/components/dialog.tsx

@@ -20,6 +20,14 @@ export function Dialog(props: DialogProps) {
             ...(props.classList ?? {}),
             [props.class ?? ""]: !!props.class,
           }}
+          onOpenAutoFocus={(e) => {
+            const target = e.currentTarget as HTMLElement | null
+            const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null
+            if (autofocusEl) {
+              e.preventDefault()
+              autofocusEl.focus()
+            }
+          }}
         >
           <Show when={props.title || props.action}>
             <div data-slot="dialog-header">

+ 4 - 4
packages/ui/src/components/diff-ssr.tsx

@@ -1,5 +1,5 @@
-import { FileDiff } from "@pierre/precision-diffs"
-import { PreloadMultiFileDiffResult } from "@pierre/precision-diffs/ssr"
+import { FileDiff } from "@pierre/diffs"
+import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
 import { onCleanup, onMount, Show, splitProps } from "solid-js"
 import { isServer } from "solid-js/web"
 import { createDefaultOptions, styleVariables, type DiffProps } from "../pierre"
@@ -65,11 +65,11 @@ export function Diff<T>(props: SSRDiffProps<T>) {
 
   return (
     <div data-component="diff" style={styleVariables} ref={container}>
-      <file-diff ref={fileDiffRef} id="ssr-diff">
+      <diffs-container ref={fileDiffRef} id="ssr-diff">
         <Show when={isServer}>
           <template shadowrootmode="open" innerHTML={props.preloadedDiff.prerenderedHTML} />
         </Show>
-      </file-diff>
+      </diffs-container>
     </div>
   )
 }

+ 2 - 2
packages/ui/src/components/diff.css

@@ -19,8 +19,8 @@
     position: sticky;
     background-color: var(--surface-diff-hidden-base);
     color: var(--text-base);
-    width: var(--pjs-column-content-width);
-    left: var(--pjs-column-number-width);
+    width: var(--diffs-column-content-width);
+    left: var(--diffs-column-number-width);
     padding-left: 8px;
     user-select: none;
     cursor: default;

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

@@ -1,4 +1,4 @@
-import { FileDiff } from "@pierre/precision-diffs"
+import { FileDiff } from "@pierre/diffs"
 import { createEffect, createMemo, onCleanup, splitProps } from "solid-js"
 import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre"
 import { workerPool } from "../pierre/worker"

+ 1 - 0
packages/ui/src/components/icon.tsx

@@ -51,6 +51,7 @@ const icons = {
   "circle-check": `<path d="M12.4987 7.91732L8.7487 12.5007L7.08203 10.834M17.9154 10.0007C17.9154 14.3729 14.371 17.9173 9.9987 17.9173C5.62644 17.9173 2.08203 14.3729 2.08203 10.0007C2.08203 5.6284 5.62644 2.08398 9.9987 2.08398C14.371 2.08398 17.9154 5.6284 17.9154 10.0007Z" stroke="currentColor" stroke-linecap="square"/>`,
   copy: `<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>`,
   check: `<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>`,
+  photo: `<path d="M16.6665 16.6666L11.6665 11.6666L9.99984 13.3333L6.6665 9.99996L3.08317 13.5833M2.9165 2.91663H17.0832V17.0833H2.9165V2.91663ZM13.3332 7.49996C13.3332 8.30537 12.6803 8.95829 11.8748 8.95829C11.0694 8.95829 10.4165 8.30537 10.4165 7.49996C10.4165 6.69454 11.0694 6.04163 11.8748 6.04163C12.6803 6.04163 13.3332 6.69454 13.3332 7.49996Z" stroke="currentColor" stroke-linecap="square"/>`,
 }
 
 export interface IconProps extends ComponentProps<"svg"> {

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

@@ -112,6 +112,7 @@
         padding: 4px 10px;
         align-items: center;
         color: var(--text-strong);
+        scroll-margin-top: 28px;
 
         /* text-14-medium */
         font-family: var(--font-family-sans);

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

@@ -79,7 +79,7 @@ export function List<T>(props: ListProps<T> & { ref?: (ref: ListRef) => void })
       return
     }
     const element = scrollRef()?.querySelector(`[data-key="${active()}"]`)
-    element?.scrollIntoView({ block: "nearest", behavior: "smooth" })
+    element?.scrollIntoView({ block: "center", behavior: "smooth" })
   })
 
   const handleSelect = (item: T | undefined, index: number) => {

+ 101 - 5
packages/ui/src/components/message-part.css

@@ -14,11 +14,78 @@
   line-height: var(--line-height-large);
   letter-spacing: var(--letter-spacing-normal);
   color: var(--text-base);
-  display: -webkit-box;
-  line-clamp: 3;
-  -webkit-line-clamp: 3;
-  -webkit-box-orient: vertical;
-  overflow: hidden;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+
+  [data-slot="user-message-attachments"] {
+    display: flex;
+    flex-wrap: wrap;
+    gap: 8px;
+  }
+
+  [data-slot="user-message-attachment"] {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    border-radius: 6px;
+    overflow: hidden;
+    background: var(--surface-base);
+    border: 1px solid var(--border-base);
+    transition: border-color 0.15s ease;
+
+    &:hover {
+      border-color: var(--border-strong-base);
+    }
+
+    &[data-type="image"] {
+      width: 48px;
+      height: 48px;
+    }
+
+    &[data-type="file"] {
+      width: 48px;
+      height: 48px;
+    }
+  }
+
+  [data-slot="user-message-attachment-image"] {
+    width: 100%;
+    height: 100%;
+    object-fit: cover;
+  }
+
+  [data-slot="user-message-attachment-icon"] {
+    width: 100%;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: var(--icon-weak);
+
+    [data-component="icon"] {
+      width: 20px;
+      height: 20px;
+    }
+  }
+
+  [data-slot="user-message-text"] {
+    display: -webkit-box;
+    white-space: pre-wrap;
+    line-clamp: 3;
+    -webkit-line-clamp: 3;
+    -webkit-box-orient: vertical;
+    overflow: hidden;
+  }
+
+  .text-text-strong {
+    color: var(--text-strong);
+  }
+
+  .font-medium {
+    font-weight: var(--font-weight-medium);
+  }
 }
 
 [data-component="text-part"] {
@@ -108,15 +175,19 @@
   display: flex;
   align-items: center;
   justify-content: space-between;
+  gap: 8px;
   width: 100%;
 
   [data-slot="message-part-title-area"] {
+    flex-grow: 1;
     display: flex;
     align-items: center;
     gap: 8px;
+    min-width: 0;
   }
 
   [data-slot="message-part-title"] {
+    flex-shrink: 0;
     font-family: var(--font-family-sans);
     font-size: var(--font-size-base);
     font-style: normal;
@@ -129,14 +200,22 @@
 
   [data-slot="message-part-path"] {
     display: flex;
+    flex-grow: 1;
+    min-width: 0;
   }
 
   [data-slot="message-part-directory"] {
     color: var(--text-weak);
+    text-overflow: ellipsis;
+    overflow: hidden;
+    white-space: nowrap;
+    direction: rtl;
+    text-align: left;
   }
 
   [data-slot="message-part-filename"] {
     color: var(--text-strong);
+    flex-shrink: 0;
   }
 
   [data-slot="message-part-actions"] {
@@ -151,6 +230,23 @@
   border-top: 1px solid var(--border-weaker-base);
 }
 
+[data-component="write-content"] {
+  border-top: 1px solid var(--border-weaker-base);
+  max-height: 240px;
+  overflow-y: auto;
+
+  [data-component="code"] {
+    padding-bottom: 0px !important;
+  }
+
+  /* Hide scrollbar */
+  scrollbar-width: none;
+  -ms-overflow-style: none;
+  &::-webkit-scrollbar {
+    display: none;
+  }
+}
+
 [data-component="tool-action"] {
   width: 24px;
   height: 24px;

Некоторые файлы не были показаны из-за большого количества измененных файлов