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

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

David Hill 2 месяцев назад
Родитель
Сommit
dbc84ff4c3
45 измененных файлов с 714 добавлено и 687 удалено
  1. 20 15
      bun.lock
  2. 1 1
      nix/hashes.json
  3. 1 1
      packages/console/app/package.json
  4. 1 1
      packages/console/core/package.json
  5. 1 1
      packages/console/function/package.json
  6. 1 1
      packages/console/mail/package.json
  7. 1 1
      packages/desktop/package.json
  8. 1 1
      packages/enterprise/package.json
  9. 6 6
      packages/extensions/zed/extension.toml
  10. 1 1
      packages/function/package.json
  11. 1 1
      packages/opencode/package.json
  12. 6 2
      packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
  13. 1 1
      packages/opencode/src/flag/flag.ts
  14. 1 1
      packages/opencode/src/provider/transform.ts
  15. 2 2
      packages/opencode/src/pty/index.ts
  16. 42 6
      packages/opencode/src/session/prompt.ts
  17. 4 7
      packages/opencode/src/session/summary.ts
  18. 67 0
      packages/opencode/src/shell/shell.ts
  19. 6 60
      packages/opencode/src/tool/bash.ts
  20. 1 1
      packages/plugin/package.json
  21. 1 1
      packages/sdk/js/package.json
  22. 1 1
      packages/slack/package.json
  23. 1 1
      packages/tauri/package.json
  24. 6 5
      packages/tauri/src-tauri/src/lib.rs
  25. 4 1
      packages/ui/package.json
  26. 2 1
      packages/ui/src/components/basic-tool.tsx
  27. 20 0
      packages/ui/src/components/button.css
  28. 1 1
      packages/ui/src/components/button.tsx
  29. 26 0
      packages/ui/src/components/message-part.css
  30. 26 21
      packages/ui/src/components/message-part.tsx
  31. 0 50
      packages/ui/src/components/message-progress.css
  32. 0 179
      packages/ui/src/components/message-progress.tsx
  33. 46 20
      packages/ui/src/components/session-turn.css
  34. 339 232
      packages/ui/src/components/session-turn.tsx
  35. 11 9
      packages/ui/src/components/spinner.tsx
  36. 9 4
      packages/ui/src/components/typewriter.tsx
  37. 10 0
      packages/ui/src/styles/animations.css
  38. 0 1
      packages/ui/src/styles/index.css
  39. 6 6
      packages/ui/src/styles/theme.css
  40. 1 1
      packages/util/package.json
  41. 0 28
      packages/util/src/sanitize.ts
  42. 0 13
      packages/util/src/shell.ts
  43. 1 1
      packages/web/package.json
  44. 37 0
      packages/web/src/content/docs/cli.mdx
  45. 1 1
      sdks/vscode/package.json

+ 20 - 15
bun.lock

@@ -20,7 +20,7 @@
     },
     "packages/console/app": {
       "name": "@opencode-ai/console-app",
-      "version": "1.0.150",
+      "version": "1.0.152",
       "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.150",
+      "version": "1.0.152",
       "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.150",
+      "version": "1.0.152",
       "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.150",
+      "version": "1.0.152",
       "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.150",
+      "version": "1.0.152",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
@@ -169,7 +169,7 @@
     },
     "packages/enterprise": {
       "name": "@opencode-ai/enterprise",
-      "version": "1.0.150",
+      "version": "1.0.152",
       "dependencies": {
         "@opencode-ai/ui": "workspace:*",
         "@opencode-ai/util": "workspace:*",
@@ -198,7 +198,7 @@
     },
     "packages/function": {
       "name": "@opencode-ai/function",
-      "version": "1.0.150",
+      "version": "1.0.152",
       "dependencies": {
         "@octokit/auth-app": "8.0.1",
         "@octokit/rest": "22.0.0",
@@ -214,7 +214,7 @@
     },
     "packages/opencode": {
       "name": "opencode",
-      "version": "1.0.150",
+      "version": "1.0.152",
       "bin": {
         "opencode": "./bin/opencode",
       },
@@ -306,7 +306,7 @@
     },
     "packages/plugin": {
       "name": "@opencode-ai/plugin",
-      "version": "1.0.150",
+      "version": "1.0.152",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "zod": "catalog:",
@@ -326,7 +326,7 @@
     },
     "packages/sdk/js": {
       "name": "@opencode-ai/sdk",
-      "version": "1.0.150",
+      "version": "1.0.152",
       "devDependencies": {
         "@hey-api/openapi-ts": "0.88.1",
         "@tsconfig/node22": "catalog:",
@@ -337,7 +337,7 @@
     },
     "packages/slack": {
       "name": "@opencode-ai/slack",
-      "version": "1.0.150",
+      "version": "1.0.152",
       "dependencies": {
         "@opencode-ai/sdk": "workspace:*",
         "@slack/bolt": "^3.17.1",
@@ -350,7 +350,7 @@
     },
     "packages/tauri": {
       "name": "@opencode-ai/tauri",
-      "version": "1.0.150",
+      "version": "1.0.152",
       "dependencies": {
         "@opencode-ai/desktop": "workspace:*",
         "@tauri-apps/api": "^2",
@@ -375,13 +375,15 @@
     },
     "packages/ui": {
       "name": "@opencode-ai/ui",
-      "version": "1.0.150",
+      "version": "1.0.152",
       "dependencies": {
         "@kobalte/core": "catalog:",
         "@opencode-ai/sdk": "workspace:*",
         "@opencode-ai/util": "workspace:*",
         "@pierre/precision-diffs": "catalog:",
         "@shikijs/transformers": "3.9.2",
+        "@solid-primitives/bounds": "0.1.3",
+        "@solid-primitives/resize-observer": "2.1.3",
         "@solidjs/meta": "catalog:",
         "@typescript/native-preview": "catalog:",
         "fuzzysort": "catalog:",
@@ -398,6 +400,7 @@
         "@tailwindcss/vite": "catalog:",
         "@tsconfig/node22": "catalog:",
         "@types/bun": "catalog:",
+        "@types/luxon": "catalog:",
         "tailwindcss": "catalog:",
         "typescript": "catalog:",
         "vite": "catalog:",
@@ -407,7 +410,7 @@
     },
     "packages/util": {
       "name": "@opencode-ai/util",
-      "version": "1.0.150",
+      "version": "1.0.152",
       "dependencies": {
         "zod": "catalog:",
       },
@@ -418,7 +421,7 @@
     },
     "packages/web": {
       "name": "@opencode-ai/web",
-      "version": "1.0.150",
+      "version": "1.0.152",
       "dependencies": {
         "@astrojs/cloudflare": "12.6.3",
         "@astrojs/markdown-remark": "6.3.1",
@@ -1552,6 +1555,8 @@
 
     "@solid-primitives/audio": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="],
 
+    "@solid-primitives/bounds": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/resize-observer": "^2.1.3", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UbiyKMdSPmtijcEDnYLQL3zzaejpwWDAJJ4Gt5P0hgVs6A72piov0GyNw7V2SroH7NZFwxlYS22YmOr8A5xc1Q=="],
+
     "@solid-primitives/event-bus": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-l+n10/51neGcMaP3ypYt21bXfoeWh8IaC8k7fYuY3ww2a8S1Zv2N2a7FF5Qn+waTu86l0V8/nRHjkyqVIZBYwA=="],
 
     "@solid-primitives/event-listener": ["@solid-primitives/[email protected]", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-h4VqkYFv6Gf+L7SQj+Y6puigL/5DIi7x5q07VZET7AWcS+9/G3WfIE9WheniHWJs51OEkRB43w6lDys5YeFceg=="],

+ 1 - 1
nix/hashes.json

@@ -1,3 +1,3 @@
 {
-  "nodeModules": "sha256-3CG0wAMQp2E6ghPUXbYaYifJorp9b1WvCtHD+o8Nhck="
+  "nodeModules": "sha256-nWSAnQEm/t1ESZe23dr4JnIOJQ0JLN0w4NVoMJajbVQ="
 }

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

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

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

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

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/console-function",
-  "version": "1.0.150",
+  "version": "1.0.152",
   "$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.150",
+  "version": "1.0.152",
   "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.150",
+  "version": "1.0.152",
   "description": "",
   "type": "module",
   "exports": {

+ 1 - 1
packages/enterprise/package.json

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

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

@@ -1,7 +1,7 @@
 id = "opencode"
 name = "OpenCode"
 description = "The open source coding agent."
-version = "1.0.150"
+version = "1.0.152"
 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.150/opencode-darwin-arm64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.152/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.150/opencode-darwin-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-darwin-x64.zip"
 cmd = "./opencode"
 args = ["acp"]
 
 [agent_servers.opencode.targets.linux-aarch64]
-archive = "https://github.com/sst/opencode/releases/download/v1.0.150/opencode-linux-arm64.tar.gz"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.152/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.150/opencode-linux-x64.tar.gz"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.152/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.150/opencode-windows-x64.zip"
+archive = "https://github.com/sst/opencode/releases/download/v1.0.152/opencode-windows-x64.zip"
 cmd = "./opencode.exe"
 args = ["acp"]

+ 1 - 1
packages/function/package.json

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

+ 1 - 1
packages/opencode/package.json

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

+ 6 - 2
packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx

@@ -732,8 +732,12 @@ export function Prompt(props: PromptProps) {
                   return
                 }
                 if (keybind.match("app_exit", e)) {
-                  await exit()
-                  return
+                  if (store.prompt.input === "") {
+                    await exit()
+                    // Don't preventDefault - let textarea potentially handle the event
+                    e.preventDefault()
+                    return
+                  }
                 }
                 if (e.name === "!" && input.visualCursor.offset === 0) {
                   setStore("mode", "shell")

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

@@ -1,5 +1,6 @@
 export namespace Flag {
   export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
+  export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
   export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
   export const OPENCODE_CONFIG_DIR = process.env["OPENCODE_CONFIG_DIR"]
   export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
@@ -17,7 +18,6 @@ export namespace Flag {
   export const OPENCODE_EXPERIMENTAL = truthy("OPENCODE_EXPERIMENTAL")
   export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY =
     OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
-  export const OPENCODE_EXPERIMENTAL_WATCHER = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_WATCHER")
   export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT")
   export const OPENCODE_ENABLE_EXA =
     truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA")

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

@@ -255,7 +255,7 @@ export namespace ProviderTransform {
         result["reasoningEffort"] = "medium"
       }
 
-      if (model.api.id.endsWith("gpt-5.1") && model.providerID !== "azure") {
+      if (model.api.id.endsWith("gpt-5.") && model.providerID !== "azure") {
         result["textVerbosity"] = "low"
       }
 

+ 2 - 2
packages/opencode/src/pty/index.ts

@@ -6,10 +6,10 @@ import { Identifier } from "../id/id"
 import { Log } from "../util/log"
 import type { WSContext } from "hono/ws"
 import { Instance } from "../project/instance"
-import { shell } from "@opencode-ai/util/shell"
 import { lazy } from "@opencode-ai/util/lazy"
 import {} from "process"
 import { Installation } from "@/installation"
+import { Shell } from "@/shell/shell"
 
 export namespace Pty {
   const log = Log.create({ service: "pty" })
@@ -112,7 +112,7 @@ export namespace Pty {
 
   export async function create(input: CreateInput) {
     const id = Identifier.create("pty", false)
-    const command = input.command || shell()
+    const command = input.command || Shell.preferred()
     const args = input.args || []
     const cwd = input.cwd || Instance.directory
     const env = { ...process.env, ...input.env } as Record<string, string>

+ 42 - 6
packages/opencode/src/session/prompt.ts

@@ -50,6 +50,7 @@ import { fn } from "@/util/fn"
 import { SessionProcessor } from "./processor"
 import { TaskTool } from "@/tool/task"
 import { SessionStatus } from "./status"
+import { Shell } from "@/shell/shell"
 
 // @ts-ignore
 globalThis.AI_SDK_LOG_WARNINGS = false
@@ -1172,6 +1173,12 @@ export namespace SessionPrompt {
   })
   export type ShellInput = z.infer<typeof ShellInput>
   export async function shell(input: ShellInput) {
+    const abort = start(input.sessionID)
+    if (!abort) {
+      throw new Session.BusyError(input.sessionID)
+    }
+    using _ = defer(() => cancel(input.sessionID))
+
     const session = await Session.get(input.sessionID)
     if (session.revert) {
       SessionRevert.cleanup(session)
@@ -1244,8 +1251,10 @@ export namespace SessionPrompt {
       },
     }
     await Session.updatePart(part)
-    const shell = process.env["SHELL"] ?? (process.platform === "win32" ? process.env["COMSPEC"] || "cmd.exe" : "bash")
-    const shellName = path.basename(shell).toLowerCase()
+    const shell = Shell.preferred()
+    const shellName = (
+      process.platform === "win32" ? path.win32.basename(shell, ".exe") : path.basename(shell)
+    ).toLowerCase()
 
     const invocations: Record<string, { args: string[] }> = {
       nu: {
@@ -1275,17 +1284,21 @@ export namespace SessionPrompt {
           `,
         ],
       },
-      // Windows cmd.exe
-      "cmd.exe": {
+      // Windows cmd
+      cmd: {
         args: ["/c", input.command],
       },
       // Windows PowerShell
-      "powershell.exe": {
+      powershell: {
+        args: ["-NoProfile", "-Command", input.command],
+      },
+      pwsh: {
         args: ["-NoProfile", "-Command", input.command],
       },
       // Fallback: any shell that doesn't match those above
+      //  - No -l, for max compatibility
       "": {
-        args: ["-c", "-l", `${input.command}`],
+        args: ["-c", `${input.command}`],
       },
     }
 
@@ -1326,11 +1339,34 @@ export namespace SessionPrompt {
       }
     })
 
+    let aborted = false
+    let exited = false
+
+    const kill = () => Shell.killTree(proc, { exited: () => exited })
+
+    if (abort.aborted) {
+      aborted = true
+      await kill()
+    }
+
+    const abortHandler = () => {
+      aborted = true
+      void kill()
+    }
+
+    abort.addEventListener("abort", abortHandler, { once: true })
+
     await new Promise<void>((resolve) => {
       proc.on("close", () => {
+        exited = true
+        abort.removeEventListener("abort", abortHandler)
         resolve()
       })
     })
+
+    if (aborted) {
+      output += "\n\n" + ["<metadata>", "User aborted the command", "</metadata>"].join("\n")
+    }
     msg.time.completed = Date.now()
     await Session.updateMessage(msg)
     if (part.state.status === "running") {

+ 4 - 7
packages/opencode/src/session/summary.ts

@@ -130,10 +130,7 @@ export namespace SessionSummary {
           m.info.role === "assistant" && m.parts.some((p) => p.type === "step-finish" && p.reason !== "tool-calls"),
       )
     ) {
-      let summary = messages
-        .findLast((m) => m.info.role === "assistant")
-        ?.parts.findLast((p) => p.type === "text")?.text
-      if (!summary || diffs.length > 0) {
+      if (diffs.length > 0) {
         for (const msg of messages) {
           for (const part of msg.parts) {
             if (part.type === "tool" && part.state.status === "completed") {
@@ -167,10 +164,10 @@ export namespace SessionSummary {
             },
           },
         }).catch(() => {})
-        if (result) summary = result.text
+        if (result) {
+          userMsg.summary.body = result.text
+        }
       }
-      userMsg.summary.body = summary
-      log.info("body", { body: summary })
       await Session.updateMessage(userMsg)
     }
   }

+ 67 - 0
packages/opencode/src/shell/shell.ts

@@ -0,0 +1,67 @@
+import { Flag } from "@/flag/flag"
+import { lazy } from "@/util/lazy"
+import path from "path"
+import { spawn, type ChildProcess } from "child_process"
+
+const SIGKILL_TIMEOUT_MS = 200
+
+export namespace Shell {
+  export async function killTree(proc: ChildProcess, opts?: { exited?: () => boolean }): Promise<void> {
+    const pid = proc.pid
+    if (!pid || opts?.exited?.()) return
+
+    if (process.platform === "win32") {
+      await new Promise<void>((resolve) => {
+        const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { stdio: "ignore" })
+        killer.once("exit", () => resolve())
+        killer.once("error", () => resolve())
+      })
+      return
+    }
+
+    try {
+      process.kill(-pid, "SIGTERM")
+      await Bun.sleep(SIGKILL_TIMEOUT_MS)
+      if (!opts?.exited?.()) {
+        process.kill(-pid, "SIGKILL")
+      }
+    } catch (_e) {
+      proc.kill("SIGTERM")
+      await Bun.sleep(SIGKILL_TIMEOUT_MS)
+      if (!opts?.exited?.()) {
+        proc.kill("SIGKILL")
+      }
+    }
+  }
+  const BLACKLIST = new Set(["fish", "nu"])
+
+  function fallback() {
+    if (process.platform === "win32") {
+      if (Flag.OPENCODE_GIT_BASH_PATH) return Flag.OPENCODE_GIT_BASH_PATH
+      const git = Bun.which("git")
+      if (git) {
+        // git.exe is typically at: C:\Program Files\Git\cmd\git.exe
+        // bash.exe is at: C:\Program Files\Git\bin\bash.exe
+        const bash = path.join(git, "..", "..", "bin", "bash.exe")
+        if (Bun.file(bash).size) return bash
+      }
+      return process.env.COMSPEC || "cmd.exe"
+    }
+    if (process.platform === "darwin") return "/bin/zsh"
+    const bash = Bun.which("bash")
+    if (bash) return bash
+    return "/bin/sh"
+  }
+
+  export const preferred = lazy(() => {
+    const s = process.env.SHELL
+    if (s) return s
+    return fallback()
+  })
+
+  export const acceptable = lazy(() => {
+    const s = process.env.SHELL
+    if (s && !BLACKLIST.has(process.platform === "win32" ? path.win32.basename(s) : path.basename(s))) return s
+    return fallback()
+  })
+}

+ 6 - 60
packages/opencode/src/tool/bash.ts

@@ -14,11 +14,10 @@ import { Permission } from "@/permission"
 import { fileURLToPath } from "url"
 import { Flag } from "@/flag/flag.ts"
 import path from "path"
-import { iife } from "@/util/iife"
+import { Shell } from "@/shell/shell"
 
 const MAX_OUTPUT_LENGTH = Flag.OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH || 30_000
 const DEFAULT_TIMEOUT = Flag.OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS || 2 * 60 * 1000
-const SIGKILL_TIMEOUT_MS = 200
 
 export const log = Log.create({ service: "bash-tool" })
 
@@ -53,32 +52,7 @@ const parser = lazy(async () => {
 // TODO: we may wanna rename this tool so it works better on other shells
 
 export const BashTool = Tool.define("bash", async () => {
-  const shell = iife(() => {
-    const s = process.env.SHELL
-    if (s) {
-      const basename = path.basename(s)
-      if (!new Set(["fish", "nu"]).has(basename)) {
-        return s
-      }
-    }
-
-    if (process.platform === "darwin") {
-      return "/bin/zsh"
-    }
-
-    if (process.platform === "win32") {
-      // Let Bun / Node pick COMSPEC (usually cmd.exe)
-      // or explicitly:
-      return process.env.COMSPEC || true
-    }
-
-    const bash = Bun.which("bash")
-    if (bash) {
-      return bash
-    }
-
-    return true
-  })
+  const shell = Shell.acceptable()
   log.info("bash tool using shell", { shell })
 
   return {
@@ -261,51 +235,23 @@ export const BashTool = Tool.define("bash", async () => {
       let aborted = false
       let exited = false
 
-      const killTree = async () => {
-        const pid = proc.pid
-        if (!pid || exited) {
-          return
-        }
-
-        if (process.platform === "win32") {
-          await new Promise<void>((resolve) => {
-            const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { stdio: "ignore" })
-            killer.once("exit", resolve)
-            killer.once("error", resolve)
-          })
-          return
-        }
-
-        try {
-          process.kill(-pid, "SIGTERM")
-          await Bun.sleep(SIGKILL_TIMEOUT_MS)
-          if (!exited) {
-            process.kill(-pid, "SIGKILL")
-          }
-        } catch (_e) {
-          proc.kill("SIGTERM")
-          await Bun.sleep(SIGKILL_TIMEOUT_MS)
-          if (!exited) {
-            proc.kill("SIGKILL")
-          }
-        }
-      }
+      const kill = () => Shell.killTree(proc, { exited: () => exited })
 
       if (ctx.abort.aborted) {
         aborted = true
-        await killTree()
+        await kill()
       }
 
       const abortHandler = () => {
         aborted = true
-        void killTree()
+        void kill()
       }
 
       ctx.abort.addEventListener("abort", abortHandler, { once: true })
 
       const timeoutTimer = setTimeout(() => {
         timedOut = true
-        void killTree()
+        void kill()
       }, timeout + 100)
 
       await new Promise<void>((resolve, reject) => {

+ 1 - 1
packages/plugin/package.json

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

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

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

+ 1 - 1
packages/slack/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/slack",
-  "version": "1.0.150",
+  "version": "1.0.152",
   "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.150",
+  "version": "1.0.152",
   "type": "module",
   "scripts": {
     "typecheck": "tsgo -b",

+ 6 - 5
packages/tauri/src-tauri/src/lib.rs

@@ -4,9 +4,9 @@ use std::{
     sync::{Arc, Mutex},
     time::{Duration, Instant},
 };
-use tauri::{
-    AppHandle, LogicalSize, Manager, Monitor, RunEvent, TitleBarStyle, WebviewUrl, WebviewWindow,
-};
+use tauri::{AppHandle, LogicalSize, Manager, Monitor, RunEvent, WebviewUrl, WebviewWindow};
+#[cfg(target_os = "macos")]
+use tauri::TitleBarStyle;
 use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogResult};
 use tauri_plugin_shell::process::{CommandChild, CommandEvent};
 use tauri_plugin_shell::ShellExt;
@@ -183,7 +183,6 @@ pub fn run() {
                         .inner_size(size.width as f64, size.height as f64)
                         .decorations(true)
                         .zoom_hotkeys_enabled(true)
-                        .title_bar_style(TitleBarStyle::Overlay)
                         .initialization_script(format!(
                             r#"
                           window.__OPENCODE__ ??= {{}};
@@ -194,7 +193,9 @@ pub fn run() {
 
                 #[cfg(target_os = "macos")]
                 {
-                    window_builder = window_builder.hidden_title(true);
+                    window_builder = window_builder
+                        .title_bar_style(TitleBarStyle::Overlay)
+                        .hidden_title(true);
                 }
 
                 window_builder.build().expect("Failed to create window");

+ 4 - 1
packages/ui/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/ui",
-  "version": "1.0.150",
+  "version": "1.0.152",
   "type": "module",
   "exports": {
     "./*": "./src/components/*.tsx",
@@ -22,6 +22,7 @@
   },
   "devDependencies": {
     "@types/bun": "catalog:",
+    "@types/luxon": "catalog:",
     "@tsconfig/node22": "catalog:",
     "typescript": "catalog:",
     "vite": "catalog:",
@@ -36,6 +37,8 @@
     "@opencode-ai/util": "workspace:*",
     "@pierre/precision-diffs": "catalog:",
     "@shikijs/transformers": "3.9.2",
+    "@solid-primitives/bounds": "0.1.3",
+    "@solid-primitives/resize-observer": "2.1.3",
     "@solidjs/meta": "catalog:",
     "@typescript/native-preview": "catalog:",
     "fuzzysort": "catalog:",

+ 2 - 1
packages/ui/src/components/basic-tool.tsx

@@ -21,12 +21,13 @@ export interface BasicToolProps {
   trigger: TriggerTitle | JSX.Element
   children?: JSX.Element
   hideDetails?: boolean
+  defaultOpen?: boolean
 }
 
 export function BasicTool(props: BasicToolProps) {
   const resolved = children(() => props.children)
   return (
-    <Collapsible>
+    <Collapsible defaultOpen={props.defaultOpen}>
       <Collapsible.Trigger>
         <div data-component="tool-trigger">
           <div data-slot="basic-tool-tool-trigger-content">

+ 20 - 0
packages/ui/src/components/button.css

@@ -100,6 +100,26 @@
     }
   }
 
+  &[data-size="small"] {
+    height: 22px;
+    padding: 0 8px;
+    &[data-icon] {
+      padding: 0 12px 0 4px;
+    }
+
+    font-size: var(--font-size-small);
+    line-height: var(--line-height-large);
+    gap: 4px;
+
+    /* text-12-medium */
+    font-family: var(--font-family-sans);
+    font-size: var(--font-size-small);
+    font-style: normal;
+    font-weight: var(--font-weight-medium);
+    line-height: var(--line-height-large); /* 166.667% */
+    letter-spacing: var(--letter-spacing-normal);
+  }
+
   &[data-size="normal"] {
     height: 24px;
     padding: 0 6px;

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

@@ -5,7 +5,7 @@ import { Icon, IconProps } from "./icon"
 export interface ButtonProps
   extends ComponentProps<typeof Kobalte>,
     Pick<ComponentProps<"button">, "class" | "classList" | "children"> {
-  size?: "normal" | "large"
+  size?: "small" | "normal" | "large"
   variant?: "primary" | "secondary" | "ghost"
   icon?: IconProps["name"]
 }

+ 26 - 0
packages/ui/src/components/message-part.css

@@ -29,6 +29,16 @@
   }
 }
 
+[data-component="reasoning-part"] {
+  width: 100%;
+  opacity: 0.5;
+
+  [data-component="markdown"] {
+    margin-top: 24px;
+    font-style: italic !important;
+  }
+}
+
 [data-component="tool-error"] {
   display: flex;
   align-items: start;
@@ -74,6 +84,22 @@
     margin: 0;
     padding: 0;
   }
+
+  &[data-scrollable] {
+    height: auto;
+    max-height: 240px;
+    overflow-y: auto;
+    scrollbar-width: none;
+    -ms-overflow-style: none;
+
+    &::-webkit-scrollbar {
+      display: none;
+    }
+
+    [data-component="markdown"] {
+      overflow: visible;
+    }
+  }
 }
 
 [data-component="edit-trigger"],

+ 26 - 21
packages/ui/src/components/message-part.tsx

@@ -8,6 +8,7 @@ import {
   ToolPart,
   UserMessage,
 } from "@opencode-ai/sdk/v2"
+import { useData } from "../context"
 import { useDiffComponent } from "../context/diff"
 import { BasicTool } from "./basic-tool"
 import { GenericTool } from "./basic-tool"
@@ -16,27 +17,34 @@ import { Icon } from "./icon"
 import { Checkbox } from "./checkbox"
 import { DiffChanges } from "./diff-changes"
 import { Markdown } from "./markdown"
-import { getDirectory, getFilename } from "@opencode-ai/util/path"
-import { sanitizePart } from "@opencode-ai/util/sanitize"
-import { unwrap } from "solid-js/store"
+import { getDirectory as _getDirectory, getFilename } from "@opencode-ai/util/path"
 
 export interface MessageProps {
   message: MessageType
   parts: PartType[]
-  sanitize?: RegExp
 }
 
 export interface MessagePartProps {
   part: PartType
   message: MessageType
   hideDetails?: boolean
-  sanitize?: RegExp
 }
 
 export type PartComponent = Component<MessagePartProps>
 
 export const PART_MAPPING: Record<string, PartComponent | undefined> = {}
 
+function relativizeProjectPaths(text: string, directory?: string) {
+  if (!text) return ""
+  if (!directory) return text
+  return text.split(directory).join("")
+}
+
+function getDirectory(path: string | undefined) {
+  const data = useData()
+  return relativizeProjectPaths(_getDirectory(path), data.directory)
+}
+
 export function registerPartComponent(type: string, component: PartComponent) {
   PART_MAPPING[type] = component
 }
@@ -49,27 +57,20 @@ export function Message(props: MessageProps) {
       </Match>
       <Match when={props.message.role === "assistant" && props.message}>
         {(assistantMessage) => (
-          <AssistantMessageDisplay
-            message={assistantMessage() as AssistantMessage}
-            parts={props.parts}
-            sanitize={props.sanitize}
-          />
+          <AssistantMessageDisplay message={assistantMessage() as AssistantMessage} parts={props.parts} />
         )}
       </Match>
     </Switch>
   )
 }
 
-export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[]; sanitize?: RegExp }) {
+export function AssistantMessageDisplay(props: { message: AssistantMessage; parts: PartType[] }) {
   const filteredParts = createMemo(() => {
     return props.parts?.filter((x) => {
-      if (x.type === "reasoning") return false
       return x.type !== "tool" || (x as ToolPart).tool !== "todoread"
     })
   })
-  return (
-    <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} sanitize={props.sanitize} />}</For>
-  )
+  return <For each={filteredParts()}>{(part) => <Part part={part} message={props.message} />}</For>
 }
 
 export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
@@ -84,10 +85,9 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
 
 export function Part(props: MessagePartProps) {
   const component = createMemo(() => PART_MAPPING[props.part.type])
-  const part = createMemo(() => sanitizePart(unwrap(props.part), props.sanitize))
   return (
     <Show when={component()}>
-      <Dynamic component={component()} part={part()} message={props.message} hideDetails={props.hideDetails} />
+      <Dynamic component={component()} part={props.part} message={props.message} hideDetails={props.hideDetails} />
     </Show>
   )
 }
@@ -175,12 +175,15 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
 }
 
 PART_MAPPING["text"] = function TextPartDisplay(props) {
+  const data = useData()
   const part = props.part as TextPart
-  const sanitized = createMemo(() => (props.sanitize ? (sanitizePart(unwrap(part), props.sanitize) as TextPart) : part))
+  const content = createMemo(() => (part.text ?? "").trim())
+  const displayText = createMemo(() => relativizeProjectPaths(content(), data.directory))
+
   return (
-    <Show when={part.text.trim()}>
+    <Show when={displayText()}>
       <div data-component="text-part">
-        <Markdown text={sanitized().text.trim()} />
+        <Markdown text={displayText()} />
       </div>
     </Show>
   )
@@ -318,13 +321,14 @@ ToolRegistry.register({
   render(props) {
     return (
       <BasicTool
+        defaultOpen
         icon="console"
         trigger={{
           title: "Shell",
           subtitle: props.input.description,
         }}
       >
-        <div data-component="tool-output">
+        <div data-component="tool-output" data-scrollable>
           <Markdown
             text={`\`\`\`command\n$ ${props.input.command}${props.output ? "\n\n" + props.output : ""}\n\`\`\``}
           />
@@ -340,6 +344,7 @@ ToolRegistry.register({
     const diffComponent = useDiffComponent()
     return (
       <BasicTool
+        defaultOpen
         icon="code-lines"
         trigger={
           <div data-component="edit-trigger">

+ 0 - 50
packages/ui/src/components/message-progress.css

@@ -1,50 +0,0 @@
-[data-component="message-progress"] {
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
-}
-
-[data-component="message-progress"] [data-slot="message-progress-status"] {
-  display: flex;
-  align-items: center;
-  column-gap: 20px;
-  padding-left: 12px;
-  border: 1px solid transparent;
-  color: var(--text-base);
-}
-
-[data-component="message-progress"] [data-slot="message-progress-status-text"] {
-  font-size: 12px;
-  font-weight: 500;
-  line-height: 1.5;
-}
-
-[data-component="message-progress"] [data-slot="message-progress-list-container"] {
-  height: 120px;
-  overflow: hidden;
-  pointer-events: none;
-  padding-bottom: 4px;
-
-  mask-image: linear-gradient(to bottom, transparent 0%, black 33%, black 95%, transparent 100%);
-  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, black 33%, black 95%, transparent 100%);
-}
-
-[data-component="message-progress"] [data-slot="message-progress-list"] {
-  width: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: flex-start;
-  align-self: stretch;
-  gap: 8px;
-  padding-top: 32px;
-  padding-bottom: 32px;
-
-  transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1);
-}
-
-[data-component="message-progress"] [data-slot="message-progress-item"] {
-  height: 32px;
-  display: flex;
-  align-items: center;
-  width: 100%;
-}

+ 0 - 179
packages/ui/src/components/message-progress.tsx

@@ -1,179 +0,0 @@
-import { For, JSXElement, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
-import { Part } from "./message-part"
-import { Spinner } from "./spinner"
-import { useData } from "../context/data"
-import type { AssistantMessage as AssistantMessageType, ToolPart } from "@opencode-ai/sdk/v2"
-
-export interface MessageProgressProps {
-  assistantMessages: () => AssistantMessageType[]
-  done?: boolean
-}
-
-export function MessageProgress(props: MessageProgressProps) {
-  const data = useData()
-  const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
-  const parts = createMemo(() => props.assistantMessages().flatMap((m) => data.store.part[m.id]))
-  const done = createMemo(() => props.done ?? false)
-  const currentTask = createMemo(
-    () =>
-      parts().findLast(
-        (p) =>
-          p &&
-          p.type === "tool" &&
-          p.tool === "task" &&
-          p.state &&
-          "metadata" in p.state &&
-          p.state.metadata &&
-          p.state.metadata.sessionId &&
-          p.state.status === "running",
-      ) as ToolPart,
-  )
-  const resolvedParts = createMemo(() => {
-    let resolved = parts()
-    const task = currentTask()
-    if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
-      const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
-        (m) => m.role === "assistant",
-      )
-      resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? parts()
-    }
-    return resolved
-  })
-
-  const eligibleItems = createMemo(() => {
-    return resolvedParts().filter((p) => p?.type === "tool" && p?.state.status === "completed") as ToolPart[]
-  })
-  const finishedItems = createMemo<(JSXElement | ToolPart)[]>(() => [
-    <div data-slot="message-progress-item" />,
-    <div data-slot="message-progress-item" />,
-    <div data-slot="message-progress-item" />,
-    ...eligibleItems(),
-    ...(done()
-      ? [
-          <div data-slot="message-progress-item" />,
-          <div data-slot="message-progress-item" />,
-          <div data-slot="message-progress-item" />,
-        ]
-      : []),
-  ])
-
-  const delay = createMemo(() => (done() ? 220 : 400))
-  const [visibleCount, setVisibleCount] = createSignal(eligibleItems().length)
-
-  createEffect(() => {
-    const total = finishedItems().length
-    if (total > visibleCount()) {
-      const timer = setTimeout(() => {
-        setVisibleCount((prev) => prev + 1)
-      }, delay())
-      onCleanup(() => clearTimeout(timer))
-    } else if (total < visibleCount()) {
-      setVisibleCount(total)
-    }
-  })
-
-  const translateY = createMemo(() => {
-    const total = visibleCount()
-    if (total < 2) return "0px"
-    return `-${(total - 2) * 40 - 8}px`
-  })
-
-  const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
-  const rawStatus = createMemo(() => {
-    const last = lastPart()
-    if (!last) return undefined
-
-    if (last.type === "tool") {
-      switch (last.tool) {
-        case "task":
-          return "Delegating work..."
-        case "todowrite":
-        case "todoread":
-          return "Planning next steps..."
-        case "read":
-          return "Gathering context..."
-        case "list":
-        case "grep":
-        case "glob":
-          return "Searching the codebase..."
-        case "webfetch":
-          return "Searching the web..."
-        case "edit":
-        case "write":
-          return "Making edits..."
-        case "bash":
-          return "Running commands..."
-        default:
-          break
-      }
-    } else if (last.type === "reasoning") {
-      return "Thinking..."
-    } else if (last.type === "text") {
-      return "Gathering thoughts..."
-    }
-    return undefined
-  })
-
-  const [status, setStatus] = createSignal(rawStatus())
-  let lastStatusChange = Date.now()
-  let statusTimeout: number | undefined
-
-  createEffect(() => {
-    const newStatus = rawStatus()
-    if (newStatus === status() || !newStatus) return
-
-    const timeSinceLastChange = Date.now() - lastStatusChange
-
-    if (timeSinceLastChange >= 1500) {
-      setStatus(newStatus)
-      lastStatusChange = Date.now()
-      if (statusTimeout) {
-        clearTimeout(statusTimeout)
-        statusTimeout = undefined
-      }
-    } else {
-      if (statusTimeout) clearTimeout(statusTimeout)
-      statusTimeout = setTimeout(() => {
-        setStatus(rawStatus())
-        lastStatusChange = Date.now()
-        statusTimeout = undefined
-      }, 1000 - timeSinceLastChange) as unknown as number
-    }
-  })
-
-  return (
-    <div data-component="message-progress">
-      <div data-slot="message-progress-status">
-        <Spinner /> <span data-slot="message-progress-status-text">{status() ?? "Considering next steps..."}</span>
-      </div>
-      <Show when={eligibleItems().length > 0}>
-        <div data-slot="message-progress-list-container">
-          <div data-slot="message-progress-list" style={{ transform: `translateY(${translateY()})` }}>
-            <For each={finishedItems()}>
-              {(part) => (
-                <Switch>
-                  <Match when={part && typeof part === "object" && "type" in part && part}>
-                    {(p) => {
-                      const part = p() as ToolPart
-                      const message = createMemo(() =>
-                        data.store.message[part.sessionID].find((m) => m.id === part.messageID),
-                      )
-                      return (
-                        <div data-slot="message-progress-item">
-                          <Part message={message()!} part={part} sanitize={sanitizer()} />
-                        </div>
-                      )
-                    }}
-                  </Match>
-                  <Match when={true}>
-                    <div data-slot="message-progress-item">{part as JSXElement}</div>
-                  </Match>
-                </Switch>
-              )}
-            </For>
-          </div>
-        </div>
-      </Show>
-    </div>
-  )
-}

+ 46 - 20
packages/ui/src/components/session-turn.css

@@ -29,20 +29,33 @@
     gap: 32px;
   }
 
+  [data-slot="session-turn-sticky-header"] {
+    width: 100%;
+    position: sticky;
+    top: 0;
+    background-color: var(--background-stronger);
+    z-index: 20;
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+    padding-bottom: 8px;
+  }
+
   [data-slot="session-turn-message-header"] {
     display: flex;
     align-items: center;
     gap: 8px;
     align-self: stretch;
-    position: sticky;
-    top: 0;
-    background-color: var(--background-stronger);
-    z-index: 20;
     height: 32px;
   }
 
-  [data-slot="session-turn-message-content"] {
-    margin-top: -24px;
+  /* [data-slot="session-turn-message-content"] { */
+  /* } */
+
+  [data-slot="session-turn-response-trigger"] {
+    width: calc(100% + 9px);
+    margin-left: -9px;
+    padding-left: 9px;
   }
 
   [data-slot="session-turn-message-title"] {
@@ -202,10 +215,10 @@
   }
 
   [data-component="sticky-accordion-header"] {
-    top: 40px;
+    top: var(--sticky-header-height, 40px);
 
     &[data-expanded]::before {
-      top: -40px;
+      top: calc(-1 * var(--sticky-header-height, 40px));
     }
   }
 
@@ -270,26 +283,35 @@
   }
 
   [data-slot="session-turn-response-section"] {
-    width: 100%;
+    width: calc(100% + 9px);
     min-width: 0;
+    margin-left: -9px;
+    padding-left: 9px;
+  }
+
+  [data-slot="session-turn-collapsible"] {
+    gap: 32px;
+    overflow: visible;
   }
 
   [data-slot="session-turn-collapsible-trigger-content"] {
-    color: var(--text-weak);
-    cursor: pointer;
-    background: none;
-    border: none;
-    padding: 0;
+    width: fit-content;
     display: flex;
     align-items: center;
+    gap: 4px;
+    color: var(--text-weak);
+    margin-left: -9px;
 
-    &:hover {
-      color: var(--text-strong);
+    [data-component="spinner"] {
+      width: 12px;
+      height: 12px;
+      margin-right: 4px;
+    }
+
+    [data-component="icon"] {
+      width: 14px;
+      height: 14px;
     }
-    display: flex;
-    align-items: center;
-    gap: 4px;
-    align-self: stretch;
   }
 
   [data-slot="session-turn-details-text"] {
@@ -308,5 +330,9 @@
     flex-direction: column;
     align-self: stretch;
     gap: 12px;
+
+    > :first-child > [data-component="markdown"]:first-child {
+      margin-top: 0;
+    }
   }
 }

+ 339 - 232
packages/ui/src/components/session-turn.tsx

@@ -1,9 +1,21 @@
-import { AssistantMessage } from "@opencode-ai/sdk/v2"
+import { AssistantMessage, ToolPart } from "@opencode-ai/sdk/v2/client"
 import { useData } from "../context"
 import { useDiffComponent } from "../context/diff"
 import { getDirectory, getFilename } from "@opencode-ai/util/path"
 import { checksum } from "@opencode-ai/util/encode"
-import { createEffect, createMemo, createSignal, For, Match, onCleanup, ParentProps, Show, Switch } from "solid-js"
+import {
+  createEffect,
+  createMemo,
+  createSignal,
+  For,
+  Match,
+  onCleanup,
+  onMount,
+  ParentProps,
+  Show,
+  Switch,
+} from "solid-js"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
 import { DiffChanges } from "./diff-changes"
 import { Typewriter } from "./typewriter"
 import { Message } from "./message-part"
@@ -13,16 +25,11 @@ import { StickyAccordionHeader } from "./sticky-accordion-header"
 import { FileIcon } from "./file-icon"
 import { Icon } from "./icon"
 import { Card } from "./card"
-import { MessageProgress } from "./message-progress"
-import { Collapsible } from "./collapsible"
 import { Dynamic } from "solid-js/web"
-
-// Track animation state per message ID - persists across re-renders
-// "empty" = first saw with no value (should animate when value arrives)
-// "animating" = currently animating (keep returning true)
-// "done" = already animated or first saw with value (never animate)
-const titleAnimationState = new Map<string, "empty" | "animating" | "done">()
-const summaryAnimationState = new Map<string, "empty" | "animating" | "done">()
+import { Button } from "./button"
+import { Spinner } from "./spinner"
+import { createStore } from "solid-js/store"
+import { DateTime, DurationUnit, Interval } from "luxon"
 
 export function SessionTurn(
   props: ParentProps<{
@@ -37,18 +44,13 @@ export function SessionTurn(
 ) {
   const data = useData()
   const diffComponent = useDiffComponent()
-  const sanitizer = createMemo(() => (data.directory ? new RegExp(`${data.directory}/`, "g") : undefined))
   const messages = createMemo(() => (props.sessionID ? (data.store.message[props.sessionID] ?? []) : []))
   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 message = createMemo(() => userMessages()?.find((m) => m.id === props.messageID))
-
   const status = createMemo(
     () =>
       data.store.session_status[props.sessionID] ?? {
@@ -57,241 +59,346 @@ export function SessionTurn(
   )
   const working = createMemo(() => status()?.type !== "idle")
 
-  return (
-    <div data-component="session-turn" class={props.classes?.root}>
-      <div data-slot="session-turn-content" class={props.classes?.content}>
-        <Show when={message()}>
-          {(msg) => {
-            const [detailsExpanded, setDetailsExpanded] = createSignal(false)
+  let scrollRef: HTMLDivElement | undefined
+  const [contentRef, setContentRef] = createSignal<HTMLDivElement>()
+  const [stickyHeaderRef, setStickyHeaderRef] = createSignal<HTMLDivElement>()
+  const [userScrolled, setUserScrolled] = createSignal(false)
+  const [stickyHeaderHeight, setStickyHeaderHeight] = createSignal(0)
 
-            // Animation logic: only animate if we witness the value transition from empty to non-empty
-            // Track in module-level Maps keyed by message ID so it persists across re-renders
+  function handleScroll() {
+    if (!scrollRef) return
+    const { scrollTop, scrollHeight, clientHeight } = scrollRef
+    const atBottom = scrollHeight - scrollTop - clientHeight < 50
+    if (!atBottom && working()) {
+      setUserScrolled(true)
+    }
+  }
 
-            // Initialize animation state for current message (reactive - runs when msg().id changes)
-            createEffect(() => {
-              const id = msg().id
-              if (!titleAnimationState.has(id)) {
-                titleAnimationState.set(id, msg().summary?.title ? "done" : "empty")
-              }
-              if (!summaryAnimationState.has(id)) {
-                const assistantMsgs = messages()?.filter(
-                  (m) => m.role === "assistant" && m.parentID == id,
+  function handleInteraction() {
+    if (working()) {
+      setUserScrolled(true)
+    }
+  }
+
+  createEffect(() => {
+    if (!working()) {
+      setUserScrolled(false)
+    }
+  })
+
+  createResizeObserver(contentRef, () => {
+    if (!scrollRef || userScrolled() || !working()) return
+    scrollRef.scrollTop = scrollRef.scrollHeight
+  })
+
+  createResizeObserver(stickyHeaderRef, ({ height }) => {
+    setStickyHeaderHeight(height + 8)
+  })
+
+  return (
+    <div data-component="session-turn" class={props.classes?.root}>
+      <div ref={scrollRef} onScroll={handleScroll} data-slot="session-turn-content" class={props.classes?.content}>
+        <div ref={setContentRef} onClick={handleInteraction}>
+          <Show when={message()}>
+            {(message) => {
+              const assistantMessages = createMemo(() => {
+                return messages()?.filter(
+                  (m) => m.role === "assistant" && m.parentID == message().id,
                 ) as AssistantMessage[]
-                const parts = assistantMsgs?.flatMap((m) => data.store.part[m.id])
-                const lastText = parts?.filter((p) => p?.type === "text")?.at(-1)
-                const summaryValue = msg().summary?.body ?? lastText?.text
-                summaryAnimationState.set(id, summaryValue ? "done" : "empty")
-              }
+              })
+              const lastAssistantMessage = createMemo(() => assistantMessages()?.at(-1))
+              const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
+              const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
+              const parts = createMemo(() => data.store.part[message().id])
+              const lastTextPart = createMemo(() =>
+                assistantMessageParts()
+                  .filter((p) => p?.type === "text")
+                  ?.at(-1),
+              )
+              const summary = createMemo(() => message().summary?.body ?? lastTextPart()?.text)
+              const lastTextPartShown = createMemo(
+                () => !message().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0,
+              )
 
-              // When message changes or component unmounts, mark any "animating" states as "done"
-              onCleanup(() => {
-                if (titleAnimationState.get(id) === "animating") {
-                  titleAnimationState.set(id, "done")
+              const assistantParts = createMemo(() => assistantMessages().flatMap((m) => data.store.part[m.id]))
+              const currentTask = createMemo(
+                () =>
+                  assistantParts().findLast(
+                    (p) =>
+                      p &&
+                      p.type === "tool" &&
+                      p.tool === "task" &&
+                      p.state &&
+                      "metadata" in p.state &&
+                      p.state.metadata &&
+                      p.state.metadata.sessionId &&
+                      p.state.status === "running",
+                  ) as ToolPart,
+              )
+              const resolvedParts = createMemo(() => {
+                let resolved = assistantParts()
+                const task = currentTask()
+                if (task && task.state && "metadata" in task.state && task.state.metadata?.sessionId) {
+                  const messages = data.store.message[task.state.metadata.sessionId as string]?.filter(
+                    (m) => m.role === "assistant",
+                  )
+                  resolved = messages?.flatMap((m) => data.store.part[m.id]) ?? assistantParts()
                 }
-                if (summaryAnimationState.get(id) === "animating") {
-                  summaryAnimationState.set(id, "done")
+                return resolved
+              })
+              const lastPart = createMemo(() => resolvedParts().slice(-1)?.at(0))
+              const rawStatus = createMemo(() => {
+                const last = lastPart()
+                if (!last) return undefined
+
+                if (last.type === "tool") {
+                  switch (last.tool) {
+                    case "task":
+                      return "Delegating work"
+                    case "todowrite":
+                    case "todoread":
+                      return "Planning next steps"
+                    case "read":
+                      return "Gathering context"
+                    case "list":
+                    case "grep":
+                    case "glob":
+                      return "Searching the codebase"
+                    case "webfetch":
+                      return "Searching the web"
+                    case "edit":
+                    case "write":
+                      return "Making edits"
+                    case "bash":
+                      return "Running commands"
+                    default:
+                      break
+                  }
+                } else if (last.type === "reasoning") {
+                  return "Thinking"
+                } else if (last.type === "text") {
+                  return "Gathering thoughts"
                 }
+                return undefined
               })
-            })
 
-            const assistantMessages = createMemo(() => {
-              return messages()?.filter((m) => m.role === "assistant" && m.parentID == msg().id) as AssistantMessage[]
-            })
-            const assistantMessageParts = createMemo(() => assistantMessages()?.flatMap((m) => data.store.part[m.id]))
-            const error = createMemo(() => assistantMessages().find((m) => m?.error)?.error)
-            const parts = createMemo(() => data.store.part[msg().id])
-            const lastTextPart = createMemo(() =>
-              assistantMessageParts()
-                .filter((p) => p?.type === "text")
-                ?.at(-1),
-            )
-            const hasToolPart = createMemo(() => assistantMessageParts().some((p) => p?.type === "tool"))
-            const messageWorking = createMemo(() => msg().id === lastUserMessage()?.id && working())
-            const initialCompleted = !(msg().id === lastUserMessage()?.id && working())
-            const [completed, setCompleted] = createSignal(initialCompleted)
-            const summary = createMemo(() => msg().summary?.body ?? lastTextPart()?.text)
-            const lastTextPartShown = createMemo(() => !msg().summary?.body && (lastTextPart()?.text?.length ?? 0) > 0)
+              function duration() {
+                const completed = lastAssistantMessage()?.time.completed
+                const from = DateTime.fromMillis(message()!.time.created)
+                const to = completed ? DateTime.fromMillis(completed) : DateTime.now()
+                const interval = Interval.fromDateTimes(from, to)
+                const unit: DurationUnit[] = interval.length("seconds") > 60 ? ["minutes", "seconds"] : ["seconds"]
 
-            // Should animate: state is "empty" AND value now exists, or state is "animating"
-            // Transition: empty -> animating -> done (done happens on cleanup)
-            const animateTitle = createMemo(() => {
-              const id = msg().id
-              const state = titleAnimationState.get(id)
-              const title = msg().summary?.title
-              if (state === "animating") {
-                return true
-              }
-              if (state === "empty" && title) {
-                titleAnimationState.set(id, "animating")
-                return true
-              }
-              return false
-            })
-            const animateSummary = createMemo(() => {
-              const id = msg().id
-              const state = summaryAnimationState.get(id)
-              const value = summary()
-              if (state === "animating") {
-                return true
-              }
-              if (state === "empty" && value) {
-                summaryAnimationState.set(id, "animating")
-                return true
+                return interval.toDuration(unit).normalize().toHuman({
+                  notation: "compact",
+                  unitDisplay: "narrow",
+                  compactDisplay: "short",
+                  showZeros: false,
+                })
               }
-              return false
-            })
 
-            createEffect(() => {
-              const done = !messageWorking()
-              setTimeout(() => setCompleted(done), 1200)
-            })
+              const [store, setStore] = createStore({
+                status: rawStatus(),
+                stepsExpanded: true,
+                duration: duration(),
+              })
 
-            return (
-              <div data-message={msg().id} data-slot="session-turn-message-container" class={props.classes?.container}>
-                {/* Title */}
-                <div data-slot="session-turn-message-header">
-                  <div data-slot="session-turn-message-title">
-                    <Show
-                      when={!animateTitle()}
-                      fallback={<Typewriter as="h1" text={msg().summary?.title} data-slot="session-turn-typewriter" />}
-                    >
-                      <h1>{msg().summary?.title}</h1>
-                    </Show>
-                  </div>
-                </div>
-                <div data-slot="session-turn-message-content">
-                  <Message message={msg()} parts={parts()} sanitize={sanitizer()} />
-                </div>
-                {/* Summary */}
-                <Show when={completed()}>
-                  <div data-slot="session-turn-summary-section">
-                    <div data-slot="session-turn-summary-header">
-                      <h2 data-slot="session-turn-summary-title">
+              createEffect(() => {
+                const timer = setInterval(() => {
+                  setStore("duration", duration())
+                }, 1000)
+                onCleanup(() => clearInterval(timer))
+              })
+
+              let lastStatusChange = Date.now()
+              let statusTimeout: number | undefined
+              createEffect(() => {
+                const newStatus = rawStatus()
+                if (newStatus === store.status || !newStatus) return
+
+                const timeSinceLastChange = Date.now() - lastStatusChange
+
+                if (timeSinceLastChange >= 2500) {
+                  setStore("status", newStatus)
+                  lastStatusChange = Date.now()
+                  if (statusTimeout) {
+                    clearTimeout(statusTimeout)
+                    statusTimeout = undefined
+                  }
+                } else {
+                  if (statusTimeout) clearTimeout(statusTimeout)
+                  statusTimeout = setTimeout(() => {
+                    setStore("status", rawStatus())
+                    lastStatusChange = Date.now()
+                    statusTimeout = undefined
+                  }, 2500 - timeSinceLastChange) as unknown as number
+                }
+              })
+
+              createEffect((prev) => {
+                const isWorking = working()
+                if (prev && !isWorking && !userScrolled()) {
+                  setStore("stepsExpanded", false)
+                }
+                return isWorking
+              }, working())
+
+              return (
+                <div
+                  data-message={message().id}
+                  data-slot="session-turn-message-container"
+                  class={props.classes?.container}
+                  style={{ "--sticky-header-height": `${stickyHeaderHeight()}px` }}
+                >
+                  {/* Sticky Header */}
+                  <div ref={setStickyHeaderRef} data-slot="session-turn-sticky-header">
+                    <div data-slot="session-turn-message-header">
+                      <div data-slot="session-turn-message-title">
                         <Switch>
-                          <Match when={msg().summary?.diffs?.length}>Summary</Match>
-                          <Match when={true}>Response</Match>
+                          <Match when={working()}>
+                            <Typewriter as="h1" text={message().summary?.title} data-slot="session-turn-typewriter" />
+                          </Match>
+                          <Match when={true}>
+                            <h1>{message().summary?.title}</h1>
+                          </Match>
                         </Switch>
-                      </h2>
-                      <Show when={summary()}>
-                        {(summary) => (
-                          <Markdown
-                            data-slot="session-turn-markdown"
-                            data-diffs={!!msg().summary?.diffs?.length}
-                            data-fade={!msg().summary?.diffs?.length && animateSummary()}
-                            text={summary()}
-                          />
-                        )}
+                      </div>
+                    </div>
+                    <div data-slot="session-turn-message-content">
+                      <Message message={message()} parts={parts()} />
+                    </div>
+                    <div data-slot="session-turn-response-trigger">
+                      <Button
+                        data-slot="session-turn-collapsible-trigger-content"
+                        variant="ghost"
+                        size="small"
+                        onClick={() => setStore("stepsExpanded", !store.stepsExpanded)}
+                      >
+                        <Show when={working()}>
+                          <Spinner />
+                        </Show>
+                        <Switch>
+                          <Match when={working()}>{store.status ?? "Considering next steps..."}</Match>
+                          <Match when={store.stepsExpanded}>Hide steps</Match>
+                          <Match when={!store.stepsExpanded}>Show steps</Match>
+                        </Switch>
+                        <span>·</span>
+                        <span>{store.duration}</span>
+                        <Icon name="chevron-grabber-vertical" size="small" />
+                      </Button>
+                    </div>
+                  </div>
+                  {/* Response */}
+                  <Show when={store.stepsExpanded}>
+                    <div data-slot="session-turn-collapsible-content-inner">
+                      <For each={assistantMessages()}>
+                        {(assistantMessage) => {
+                          const parts = createMemo(() => data.store.part[assistantMessage.id] ?? [])
+                          const last = createMemo(() =>
+                            parts()
+                              .filter((p) => p?.type === "text")
+                              .at(-1),
+                          )
+                          return (
+                            <Switch>
+                              <Match when={lastTextPartShown() && lastTextPart()?.id === last()?.id}>
+                                <Message
+                                  message={assistantMessage}
+                                  parts={parts().filter((p) => p?.id !== last()?.id)}
+                                />
+                              </Match>
+                              <Match when={true}>
+                                <Message message={assistantMessage} parts={parts()} />
+                              </Match>
+                            </Switch>
+                          )
+                        }}
+                      </For>
+                      <Show when={error()}>
+                        <Card variant="error" class="error-card">
+                          {error()?.data?.message as string}
+                        </Card>
                       </Show>
                     </div>
-                    <Accordion data-slot="session-turn-accordion" multiple>
-                      <For each={msg().summary?.diffs ?? []}>
-                        {(diff) => (
-                          <Accordion.Item value={diff.file}>
-                            <StickyAccordionHeader>
-                              <Accordion.Trigger>
-                                <div data-slot="session-turn-accordion-trigger-content">
-                                  <div data-slot="session-turn-file-info">
-                                    <FileIcon
-                                      node={{ path: diff.file, type: "file" }}
-                                      data-slot="session-turn-file-icon"
-                                    />
-                                    <div data-slot="session-turn-file-path">
-                                      <Show when={diff.file.includes("/")}>
-                                        <span data-slot="session-turn-directory">{getDirectory(diff.file)}&lrm;</span>
-                                      </Show>
-                                      <span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
+                  </Show>
+                  {/* Summary */}
+                  <Show when={!working()}>
+                    <div data-slot="session-turn-summary-section">
+                      <div data-slot="session-turn-summary-header">
+                        <h2 data-slot="session-turn-summary-title">
+                          <Switch>
+                            <Match when={message().summary?.diffs?.length}>Summary</Match>
+                            <Match when={true}>Response</Match>
+                          </Switch>
+                        </h2>
+                        <Show when={summary()}>
+                          {(summary) => (
+                            <Markdown
+                              data-slot="session-turn-markdown"
+                              data-diffs={!!message().summary?.diffs?.length}
+                              text={summary()}
+                            />
+                          )}
+                        </Show>
+                      </div>
+                      <Accordion data-slot="session-turn-accordion" multiple>
+                        <For each={message().summary?.diffs ?? []}>
+                          {(diff) => (
+                            <Accordion.Item value={diff.file}>
+                              <StickyAccordionHeader>
+                                <Accordion.Trigger>
+                                  <div data-slot="session-turn-accordion-trigger-content">
+                                    <div data-slot="session-turn-file-info">
+                                      <FileIcon
+                                        node={{ path: diff.file, type: "file" }}
+                                        data-slot="session-turn-file-icon"
+                                      />
+                                      <div data-slot="session-turn-file-path">
+                                        <Show when={diff.file.includes("/")}>
+                                          <span data-slot="session-turn-directory">{getDirectory(diff.file)}&lrm;</span>
+                                        </Show>
+                                        <span data-slot="session-turn-filename">{getFilename(diff.file)}</span>
+                                      </div>
+                                    </div>
+                                    <div data-slot="session-turn-accordion-actions">
+                                      <DiffChanges changes={diff} />
+                                      <Icon name="chevron-grabber-vertical" size="small" />
                                     </div>
                                   </div>
-                                  <div data-slot="session-turn-accordion-actions">
-                                    <DiffChanges changes={diff} />
-                                    <Icon name="chevron-grabber-vertical" size="small" />
-                                  </div>
-                                </div>
-                              </Accordion.Trigger>
-                            </StickyAccordionHeader>
-                            <Accordion.Content data-slot="session-turn-accordion-content">
-                              <Dynamic
-                                component={diffComponent}
-                                before={{
-                                  name: diff.file!,
-                                  contents: diff.before!,
-                                  cacheKey: checksum(diff.before!),
-                                }}
-                                after={{
-                                  name: diff.file!,
-                                  contents: diff.after!,
-                                  cacheKey: checksum(diff.after!),
-                                }}
-                              />
-                            </Accordion.Content>
-                          </Accordion.Item>
-                        )}
-                      </For>
-                    </Accordion>
-                  </div>
-                </Show>
-                <Show when={error() && !detailsExpanded()}>
-                  <Card variant="error" class="error-card">
-                    {error()?.data?.message as string}
-                  </Card>
-                </Show>
-                {/* Response */}
-                <div data-slot="session-turn-response-section">
-                  <Switch>
-                    <Match when={!completed()}>
-                      <MessageProgress assistantMessages={assistantMessages} done={!messageWorking()} />
-                    </Match>
-                    <Match when={completed() && hasToolPart()}>
-                      <Collapsible variant="ghost" open={detailsExpanded()} onOpenChange={setDetailsExpanded}>
-                        <Collapsible.Trigger>
-                          <div data-slot="session-turn-collapsible-trigger-content">
-                            <div data-slot="session-turn-details-text">
-                              <Switch>
-                                <Match when={detailsExpanded()}>Hide details</Match>
-                                <Match when={!detailsExpanded()}>Show details</Match>
-                              </Switch>
-                            </div>
-                            <Collapsible.Arrow />
-                          </div>
-                        </Collapsible.Trigger>
-                        <Collapsible.Content>
-                          <div data-slot="session-turn-collapsible-content-inner">
-                            <For each={assistantMessages()}>
-                              {(assistantMessage) => {
-                                const parts = createMemo(() => data.store.part[assistantMessage.id])
-                                const last = createMemo(() =>
-                                  parts()
-                                    .filter((p) => p?.type === "text")
-                                    .at(-1),
-                                )
-                                if (lastTextPartShown() && lastTextPart()?.id === last()?.id) {
-                                  return (
-                                    <Message
-                                      message={assistantMessage}
-                                      parts={parts().filter((p) => p?.id !== last()?.id)}
-                                      sanitize={sanitizer()}
-                                    />
-                                  )
-                                }
-                                return <Message message={assistantMessage} parts={parts()} sanitize={sanitizer()} />
-                              }}
-                            </For>
-                            <Show when={error()}>
-                              <Card variant="error" class="error-card">
-                                {error()?.data?.message as string}
-                              </Card>
-                            </Show>
-                          </div>
-                        </Collapsible.Content>
-                      </Collapsible>
-                    </Match>
-                  </Switch>
+                                </Accordion.Trigger>
+                              </StickyAccordionHeader>
+                              <Accordion.Content data-slot="session-turn-accordion-content">
+                                <Dynamic
+                                  component={diffComponent}
+                                  before={{
+                                    name: diff.file!,
+                                    contents: diff.before!,
+                                    cacheKey: checksum(diff.before!),
+                                  }}
+                                  after={{
+                                    name: diff.file!,
+                                    contents: diff.after!,
+                                    cacheKey: checksum(diff.after!),
+                                  }}
+                                />
+                              </Accordion.Content>
+                            </Accordion.Item>
+                          )}
+                        </For>
+                      </Accordion>
+                    </div>
+                  </Show>
+                  <Show when={error() && !store.stepsExpanded}>
+                    <Card variant="error" class="error-card">
+                      {error()?.data?.message as string}
+                    </Card>
+                  </Show>
                 </div>
-              </div>
-            )
-          }}
-        </Show>
-        {props.children}
+              )
+            }}
+          </Show>
+          {props.children}
+        </div>
       </div>
     </div>
   )

+ 11 - 9
packages/ui/src/components/spinner.tsx

@@ -1,14 +1,16 @@
 import { ComponentProps, For } from "solid-js"
 
-export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
-  const squares = Array.from({ length: 16 }, (_, i) => ({
-    id: i,
-    x: (i % 4) * 4,
-    y: Math.floor(i / 4) * 4,
-    delay: Math.random() * 3,
-    duration: 2 + Math.random() * 2,
-  }))
+const outerIndices = new Set([0, 1, 2, 3, 4, 7, 8, 11, 12, 13, 14, 15])
+const squares = Array.from({ length: 16 }, (_, i) => ({
+  id: i,
+  x: (i % 4) * 4,
+  y: Math.floor(i / 4) * 4,
+  delay: Math.random() * 1.5,
+  duration: 1 + Math.random() * 1,
+  outer: outerIndices.has(i),
+}))
 
+export function Spinner(props: { class?: string; classList?: ComponentProps<"div">["classList"] }) {
   return (
     <svg
       viewBox="0 0 15 15"
@@ -28,7 +30,7 @@ export function Spinner(props: { class?: string; classList?: ComponentProps<"div
             height="3"
             rx="1"
             style={{
-              animation: `pulse-opacity ${square.duration}s ease-in-out infinite`,
+              animation: `${square.outer ? "pulse-opacity-dim" : "pulse-opacity"} ${square.duration}s ease-in-out infinite`,
               "animation-delay": `${square.delay}s`,
             }}
           />

+ 9 - 4
packages/ui/src/components/typewriter.tsx

@@ -1,4 +1,4 @@
-import { createEffect, Show, type ValidComponent } from "solid-js"
+import { createEffect, onCleanup, Show, type ValidComponent } from "solid-js"
 import { createStore } from "solid-js/store"
 import { Dynamic } from "solid-js/web"
 
@@ -14,6 +14,7 @@ export const Typewriter = <T extends ValidComponent = "p">(props: { text?: strin
     if (!text) return
 
     let i = 0
+    const timeouts: ReturnType<typeof setTimeout>[] = []
     setStore("typing", true)
     setStore("displayed", "")
     setStore("cursor", true)
@@ -29,14 +30,18 @@ export const Typewriter = <T extends ValidComponent = "p">(props: { text?: strin
       if (i < text.length) {
         setStore("displayed", text.slice(0, i + 1))
         i++
-        setTimeout(type, getTypingDelay())
+        timeouts.push(setTimeout(type, getTypingDelay()))
       } else {
         setStore("typing", false)
-        setTimeout(() => setStore("cursor", false), 2000)
+        timeouts.push(setTimeout(() => setStore("cursor", false), 2000))
       }
     }
 
-    setTimeout(type, 200)
+    timeouts.push(setTimeout(type, 200))
+
+    onCleanup(() => {
+      for (const timeout of timeouts) clearTimeout(timeout)
+    })
   })
 
   return (

+ 10 - 0
packages/ui/src/styles/animations.css

@@ -12,6 +12,16 @@
   }
 }
 
+@keyframes pulse-opacity-dim {
+  0%,
+  100% {
+    opacity: 0;
+  }
+  50% {
+    opacity: 0.3;
+  }
+}
+
 @keyframes fadeUp {
   from {
     opacity: 0;

+ 0 - 1
packages/ui/src/styles/index.css

@@ -26,7 +26,6 @@
 @import "../components/logo.css" layer(components);
 @import "../components/markdown.css" layer(components);
 @import "../components/message-part.css" layer(components);
-@import "../components/message-progress.css" layer(components);
 @import "../components/message-nav.css" layer(components);
 @import "../components/progress-circle.css" layer(components);
 @import "../components/resize-handle.css" layer(components);

+ 6 - 6
packages/ui/src/styles/theme.css

@@ -122,7 +122,7 @@
   --surface-diff-hidden-weaker: var(--blue-light-1);
   --surface-diff-hidden-strong: var(--blue-light-5);
   --surface-diff-hidden-stronger: var(--blue-light-9);
-  --surface-diff-add-base: #DAFBE0;
+  --surface-diff-add-base: #dafbe0;
   --surface-diff-add-weak: var(--mint-light-2);
   --surface-diff-add-weaker: var(--mint-light-1);
   --surface-diff-add-strong: var(--mint-light-5);
@@ -269,21 +269,21 @@
   --syntax-regexp: var(--text-base);
   --syntax-string: #006656;
   --syntax-keyword: var(--text-weak);
-  --syntax-primitive: #FB4804;
+  --syntax-primitive: #fb4804;
   --syntax-operator: var(--text-base);
   --syntax-variable: var(--text-strong);
-  --syntax-property: #ED6DC8;
+  --syntax-property: #ed6dc8;
   --syntax-type: #596600;
-  --syntax-constant: #007B80;
+  --syntax-constant: #007b80;
   --syntax-punctuation: var(--text-base);
   --syntax-object: var(--text-strong);
   --syntax-success: var(--apple-light-10);
   --syntax-warning: var(--amber-light-10);
   --syntax-critical: var(--ember-light-10);
-  --syntax-info: #0092A8;
+  --syntax-info: #0092a8;
   --syntax-diff-add: var(--mint-light-11);
   --syntax-diff-delete: var(--ember-light-11);
-  --syntax-diff-unknown: #FF0000;
+  --syntax-diff-unknown: #ff0000;
   --markdown-heading: #d68c27;
   --markdown-text: #1a1a1a;
   --markdown-link: #3b7dd8;

+ 1 - 1
packages/util/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/util",
-  "version": "1.0.150",
+  "version": "1.0.152",
   "private": true,
   "type": "module",
   "exports": {

+ 0 - 28
packages/util/src/sanitize.ts

@@ -1,28 +0,0 @@
-import type { Part } from "@opencode-ai/sdk/v2/client"
-
-export const sanitize = (text: string | undefined, remove?: RegExp) => (remove ? text?.replace(remove, "") : text) ?? ""
-
-export const sanitizePart = (part: Part, remove: RegExp | undefined) => {
-  if (part.type === "text") {
-    part.text = sanitize(part.text, remove)
-  } else if (part.type === "reasoning") {
-    part.text = sanitize(part.text, remove)
-  } else if (part.type === "tool") {
-    if (part.state.status === "completed" || part.state.status === "error") {
-      for (const key in part.state.metadata) {
-        if (typeof part.state.metadata[key] === "string") {
-          part.state.metadata[key] = sanitize(part.state.metadata[key] as string, remove)
-        }
-      }
-      for (const key in part.state.input) {
-        if (typeof part.state.input[key] === "string") {
-          part.state.input[key] = sanitize(part.state.input[key] as string, remove)
-        }
-      }
-      if ("error" in part.state) {
-        part.state.error = sanitize(part.state.error as string, remove)
-      }
-    }
-  }
-  return part
-}

+ 0 - 13
packages/util/src/shell.ts

@@ -1,13 +0,0 @@
-export function shell() {
-  const s = process.env.SHELL
-  if (s) return s
-  if (process.platform === "darwin") {
-    return "/bin/zsh"
-  }
-  if (process.platform === "win32") {
-    return process.env.COMSPEC || "cmd.exe"
-  }
-  const bash = Bun.which("bash")
-  if (bash) return bash
-  return "bash"
-}

+ 1 - 1
packages/web/package.json

@@ -1,7 +1,7 @@
 {
   "name": "@opencode-ai/web",
   "type": "module",
-  "version": "1.0.150",
+  "version": "1.0.152",
   "scripts": {
     "dev": "astro dev",
     "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

+ 37 - 0
packages/web/src/content/docs/cli.mdx

@@ -269,3 +269,40 @@ The opencode CLI takes the following global flags.
 | `--version`    | `-v`  | Print version number                 |
 | `--print-logs` |       | Print logs to stderr                 |
 | `--log-level`  |       | Log level (DEBUG, INFO, WARN, ERROR) |
+
+---
+
+## Environment variables
+
+OpenCode can be configured using environment variables.
+
+| Variable                              | Type    | Description                            |
+| ------------------------------------- | ------- | -------------------------------------- |
+| `OPENCODE_AUTO_SHARE`                 | boolean | Automatically share sessions           |
+| `OPENCODE_GIT_BASH_PATH`              | string  | Path to Git Bash executable on Windows |
+| `OPENCODE_CONFIG`                     | string  | Path to config file                    |
+| `OPENCODE_CONFIG_DIR`                 | string  | Path to config directory               |
+| `OPENCODE_CONFIG_CONTENT`             | string  | Inline json config content             |
+| `OPENCODE_DISABLE_AUTOUPDATE`         | boolean | Disable automatic update checks        |
+| `OPENCODE_DISABLE_PRUNE`              | boolean | Disable pruning of old data            |
+| `OPENCODE_PERMISSION`                 | string  | Inlined json permissions config        |
+| `OPENCODE_DISABLE_DEFAULT_PLUGINS`    | boolean | Disable default plugins                |
+| `OPENCODE_DISABLE_LSP_DOWNLOAD`       | boolean | Disable automatic LSP server downloads |
+| `OPENCODE_ENABLE_EXPERIMENTAL_MODELS` | boolean | Enable experimental models             |
+| `OPENCODE_DISABLE_AUTOCOMPACT`        | boolean | Disable automatic context compaction   |
+| `OPENCODE_CLIENT`                     | string  | Client identifier (defaults to `cli`)  |
+| `OPENCODE_ENABLE_EXA`                 | boolean | Enable Exa web search tools            |
+
+---
+
+### Experimental
+
+These environment variables enable experimental features that may change or be removed.
+
+| Variable                                        | Type    | Description                             |
+| ----------------------------------------------- | ------- | --------------------------------------- |
+| `OPENCODE_EXPERIMENTAL`                         | boolean | Enable all experimental features        |
+| `OPENCODE_EXPERIMENTAL_ICON_DISCOVERY`          | boolean | Enable icon discovery                   |
+| `OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT`  | boolean | Disable copy on select in TUI           |
+| `OPENCODE_EXPERIMENTAL_BASH_MAX_OUTPUT_LENGTH`  | number  | Max output length for bash commands     |
+| `OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS` | number  | Default timeout for bash commands in ms |

+ 1 - 1
sdks/vscode/package.json

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