summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorDavid Hill <[email protected]>2025-12-12 22:50:50 +0000
committerDavid Hill <[email protected]>2025-12-12 22:50:50 +0000
commitdbc84ff4c347aa446e18b80ef1811c5fd9c886f9 (patch)
treed4b4640a6f90ad62b57d6942a2fe3fef232f86bf
parentc11ea3fd923957d8f6c94878e69babdbad194e31 (diff)
parent3c3a0f8afbc1325ab53985995826f5ccf6c80737 (diff)
downloadopencode-dbc84ff4c347aa446e18b80ef1811c5fd9c886f9.tar.gz
opencode-dbc84ff4c347aa446e18b80ef1811c5fd9c886f9.zip
Merge branch 'dev' of https://github.com/sst/opencode into dev
-rw-r--r--bun.lock35
-rw-r--r--nix/hashes.json2
-rw-r--r--packages/console/app/package.json2
-rw-r--r--packages/console/core/package.json2
-rw-r--r--packages/console/function/package.json2
-rw-r--r--packages/console/mail/package.json2
-rw-r--r--packages/desktop/package.json2
-rw-r--r--packages/enterprise/package.json2
-rw-r--r--packages/extensions/zed/extension.toml12
-rw-r--r--packages/function/package.json2
-rw-r--r--packages/opencode/package.json2
-rw-r--r--packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx8
-rw-r--r--packages/opencode/src/flag/flag.ts2
-rw-r--r--packages/opencode/src/provider/transform.ts2
-rw-r--r--packages/opencode/src/pty/index.ts4
-rw-r--r--packages/opencode/src/session/prompt.ts48
-rw-r--r--packages/opencode/src/session/summary.ts11
-rw-r--r--packages/opencode/src/shell/shell.ts67
-rw-r--r--packages/opencode/src/tool/bash.ts66
-rw-r--r--packages/plugin/package.json2
-rw-r--r--packages/sdk/js/package.json2
-rw-r--r--packages/slack/package.json2
-rw-r--r--packages/tauri/package.json2
-rw-r--r--packages/tauri/src-tauri/src/lib.rs11
-rw-r--r--packages/ui/package.json5
-rw-r--r--packages/ui/src/components/basic-tool.tsx3
-rw-r--r--packages/ui/src/components/button.css20
-rw-r--r--packages/ui/src/components/button.tsx2
-rw-r--r--packages/ui/src/components/message-part.css26
-rw-r--r--packages/ui/src/components/message-part.tsx47
-rw-r--r--packages/ui/src/components/message-progress.css50
-rw-r--r--packages/ui/src/components/message-progress.tsx179
-rw-r--r--packages/ui/src/components/session-turn.css66
-rw-r--r--packages/ui/src/components/session-turn.tsx571
-rw-r--r--packages/ui/src/components/spinner.tsx20
-rw-r--r--packages/ui/src/components/typewriter.tsx13
-rw-r--r--packages/ui/src/styles/animations.css10
-rw-r--r--packages/ui/src/styles/index.css1
-rw-r--r--packages/ui/src/styles/theme.css12
-rw-r--r--packages/util/package.json2
-rw-r--r--packages/util/src/sanitize.ts28
-rw-r--r--packages/util/src/shell.ts13
-rw-r--r--packages/web/package.json2
-rw-r--r--packages/web/src/content/docs/cli.mdx37
-rw-r--r--sdks/vscode/package.json2
45 files changed, 714 insertions, 687 deletions
diff --git a/bun.lock b/bun.lock
index 9d5819b15..5bc89cf56 100644
--- a/bun.lock
+++ b/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=="],
diff --git a/nix/hashes.json b/nix/hashes.json
index b64021947..e28f98d05 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,3 +1,3 @@
{
- "nodeModules": "sha256-3CG0wAMQp2E6ghPUXbYaYifJorp9b1WvCtHD+o8Nhck="
+ "nodeModules": "sha256-nWSAnQEm/t1ESZe23dr4JnIOJQ0JLN0w4NVoMJajbVQ="
}
diff --git a/packages/console/app/package.json b/packages/console/app/package.json
index 9831346f2..96cd611f4 100644
--- a/packages/console/app/package.json
+++ b/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",
diff --git a/packages/console/core/package.json b/packages/console/core/package.json
index 86a59d6bb..6fd87c2f8 100644
--- a/packages/console/core/package.json
+++ b/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": {
diff --git a/packages/console/function/package.json b/packages/console/function/package.json
index d32bde30c..22322aa24 100644
--- a/packages/console/function/package.json
+++ b/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",
diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json
index 764daf918..f26d54d35 100644
--- a/packages/console/mail/package.json
+++ b/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",
diff --git a/packages/desktop/package.json b/packages/desktop/package.json
index a2b995a4a..91e04af08 100644
--- a/packages/desktop/package.json
+++ b/packages/desktop/package.json
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/desktop",
- "version": "1.0.150",
+ "version": "1.0.152",
"description": "",
"type": "module",
"exports": {
diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json
index 637d9cc0b..f265b3b27 100644
--- a/packages/enterprise/package.json
+++ b/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": {
diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml
index e7cb19deb..649233b99 100644
--- a/packages/extensions/zed/extension.toml
+++ b/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"]
diff --git a/packages/function/package.json b/packages/function/package.json
index 591dcfb3c..42baa2787 100644
--- a/packages/function/package.json
+++ b/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",
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 972568983..362f5b1f2 100644
--- a/packages/opencode/package.json
+++ b/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,
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 53ce04353..941b383e6 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/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")
diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts
index 2dcf112ae..ca1af6d84 100644
--- a/packages/opencode/src/flag/flag.ts
+++ b/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")
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 5873ec7b5..1c4fa39c8 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/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"
}
diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts
index 34323371b..d192eaf1f 100644
--- a/packages/opencode/src/pty/index.ts
+++ b/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>
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 7f1b03c94..c9e24f8ca 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/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") {
diff --git a/packages/opencode/src/session/summary.ts b/packages/opencode/src/session/summary.ts
index ab6a98686..4761c9d2f 100644
--- a/packages/opencode/src/session/summary.ts
+++ b/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)
}
}
diff --git a/packages/opencode/src/shell/shell.ts b/packages/opencode/src/shell/shell.ts
new file mode 100644
index 000000000..2e8d48bfd
--- /dev/null
+++ b/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()
+ })
+}
diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts
index 0c099fe80..6b84d1bff 100644
--- a/packages/opencode/src/tool/bash.ts
+++ b/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) => {
diff --git a/packages/plugin/package.json b/packages/plugin/package.json
index 3256079a5..8589fa250 100644
--- a/packages/plugin/package.json
+++ b/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",
diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json
index 0ff29129e..e6e888b94 100644
--- a/packages/sdk/js/package.json
+++ b/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",
diff --git a/packages/slack/package.json b/packages/slack/package.json
index ab046fc40..925602f9a 100644
--- a/packages/slack/package.json
+++ b/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",
diff --git a/packages/tauri/package.json b/packages/tauri/package.json
index d712f15f4..768791ddc 100644
--- a/packages/tauri/package.json
+++ b/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",
diff --git a/packages/tauri/src-tauri/src/lib.rs b/packages/tauri/src-tauri/src/lib.rs
index fea3addb5..a275fab78 100644
--- a/packages/tauri/src-tauri/src/lib.rs
+++ b/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");
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 7aede1dcd..d3d230c49 100644
--- a/packages/ui/package.json
+++ b/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:",
diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx
index 596eef00b..4fab331a5 100644
--- a/packages/ui/src/components/basic-tool.tsx
+++ b/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">
diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css
index 3a32672fe..c5bd2c696 100644
--- a/packages/ui/src/components/button.css
+++ b/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;
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index 0802c3629..7f974b2f7 100644
--- a/packages/ui/src/components/button.tsx
+++ b/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"]
}
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 1ccee7320..9d4214bae 100644
--- a/packages/ui/src/components/message-part.css
+++ b/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"],
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index a28e36aa8..a596b811e 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/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">
diff --git a/packages/ui/src/components/message-progress.css b/packages/ui/src/components/message-progress.css
deleted file mode 100644
index 0b84e0393..000000000
--- a/packages/ui/src/components/message-progress.css
+++ /dev/null
@@ -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%;
-}
diff --git a/packages/ui/src/components/message-progress.tsx b/packages/ui/src/components/message-progress.tsx
deleted file mode 100644
index ef3548ab3..000000000
--- a/packages/ui/src/components/message-progress.tsx
+++ /dev/null
@@ -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>
- )
-}
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index d2a3d618a..c4dd2b839 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/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;
+ }
}
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index f97a3224c..708ac5b83 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/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>
)
diff --git a/packages/ui/src/components/spinner.tsx b/packages/ui/src/components/spinner.tsx
index 5e787d86b..41f4d9e71 100644
--- a/packages/ui/src/components/spinner.tsx
+++ b/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`,
}}
/>
diff --git a/packages/ui/src/components/typewriter.tsx b/packages/ui/src/components/typewriter.tsx
index 2f6ecb016..16c85a110 100644
--- a/packages/ui/src/components/typewriter.tsx
+++ b/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 (
diff --git a/packages/ui/src/styles/animations.css b/packages/ui/src/styles/animations.css
index 5fcebb93f..0ae3493eb 100644
--- a/packages/ui/src/styles/animations.css
+++ b/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;
diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css
index d60082d93..ba2c954bc 100644
--- a/packages/ui/src/styles/index.css
+++ b/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);
diff --git a/packages/ui/src/styles/theme.css b/packages/ui/src/styles/theme.css
index 98450ff53..2a095b436 100644
--- a/packages/ui/src/styles/theme.css
+++ b/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;
diff --git a/packages/util/package.json b/packages/util/package.json
index 496987ebb..43a5f7bb3 100644
--- a/packages/util/package.json
+++ b/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": {
diff --git a/packages/util/src/sanitize.ts b/packages/util/src/sanitize.ts
deleted file mode 100644
index 4bb762393..000000000
--- a/packages/util/src/sanitize.ts
+++ /dev/null
@@ -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
-}
diff --git a/packages/util/src/shell.ts b/packages/util/src/shell.ts
deleted file mode 100644
index e23ba0199..000000000
--- a/packages/util/src/shell.ts
+++ /dev/null
@@ -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"
-}
diff --git a/packages/web/package.json b/packages/web/package.json
index 5b82ae78b..f79b7ac36 100644
--- a/packages/web/package.json
+++ b/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",
diff --git a/packages/web/src/content/docs/cli.mdx b/packages/web/src/content/docs/cli.mdx
index 083db369b..64e11ff56 100644
--- a/packages/web/src/content/docs/cli.mdx
+++ b/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 |
diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json
index 22c0e0578..f674b07a3 100644
--- a/sdks/vscode/package.json
+++ b/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",