From 5b784871f0befb450f0f80848e397e3f9e36b060 Mon Sep 17 00:00:00 2001
From: Dax
Date: Sat, 31 Jan 2026 20:52:54 -0500
Subject: [PATCH 01/53] feat: add skill dialog for selecting and inserting
skills (#11547)
---
.../cli/cmd/tui/component/dialog-skill.tsx | 34 +++++++++++++++++++
.../cmd/tui/component/prompt/autocomplete.tsx | 3 +-
.../cli/cmd/tui/component/prompt/index.tsx | 23 +++++++++++++
3 files changed, 59 insertions(+), 1 deletion(-)
create mode 100644 packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx
new file mode 100644
index 0000000000..1ca109f232
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx
@@ -0,0 +1,34 @@
+import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
+import { createResource, createMemo } from "solid-js"
+import { useDialog } from "@tui/ui/dialog"
+import { useSDK } from "@tui/context/sdk"
+
+export type DialogSkillProps = {
+ onSelect: (skill: string) => void
+}
+
+export function DialogSkill(props: DialogSkillProps) {
+ const dialog = useDialog()
+ const sdk = useSDK()
+
+ const [skills] = createResource(async () => {
+ const result = await sdk.client.app.skills()
+ return result.data ?? []
+ })
+
+ const options = createMemo[]>(() => {
+ const list = skills() ?? []
+ return list.map((skill) => ({
+ title: skill.name,
+ description: skill.description,
+ value: skill.name,
+ category: "Skills",
+ onSelect: () => {
+ props.onSelect(skill.name)
+ dialog.clear()
+ },
+ }))
+ })
+
+ return
+}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
index bd000e2ab0..5f66dc822a 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
@@ -345,7 +345,8 @@ export function Autocomplete(props: {
const results: AutocompleteOption[] = [...command.slashes()]
for (const serverCommand of sync.data.command) {
- const label = serverCommand.source === "mcp" ? ":mcp" : serverCommand.source === "skill" ? ":skill" : ""
+ if (serverCommand.source === "skill") continue
+ const label = serverCommand.source === "mcp" ? ":mcp" : ""
results.push({
display: "/" + serverCommand.name + label,
description: serverCommand.description,
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 caa1303229..8576dd5763 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -31,6 +31,7 @@ import { DialogAlert } from "../../ui/dialog-alert"
import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
+import { DialogSkill } from "../dialog-skill"
export type PromptProps = {
sessionID?: string
@@ -315,6 +316,28 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = Bun.stringWidth(content)
},
},
+ {
+ title: "Skills",
+ value: "prompt.skills",
+ category: "Prompt",
+ slash: {
+ name: "skills",
+ },
+ onSelect: () => {
+ dialog.replace(() => (
+ {
+ input.setText(`/${skill} `)
+ setStore("prompt", {
+ input: `/${skill} `,
+ parts: [],
+ })
+ input.gotoBufferEnd()
+ }}
+ />
+ ))
+ },
+ },
]
})
From d4c90b2dfb89385461abf0d51430d1293e6de6a8 Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Sat, 31 Jan 2026 21:01:51 -0600
Subject: [PATCH 02/53] fix: issue where you couldn't @ folders/files that
started with a "." (#11553)
---
packages/opencode/src/file/ripgrep.ts | 2 +-
packages/opencode/test/file/ripgrep.test.ts | 39 +++++++++++++++++++++
2 files changed, 40 insertions(+), 1 deletion(-)
create mode 100644 packages/opencode/test/file/ripgrep.test.ts
diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts
index dd94cc6097..463a9fb362 100644
--- a/packages/opencode/src/file/ripgrep.ts
+++ b/packages/opencode/src/file/ripgrep.ts
@@ -215,7 +215,7 @@ export namespace Ripgrep {
const args = [await filepath(), "--files", "--glob=!.git/*"]
if (input.follow) args.push("--follow")
- if (input.hidden) args.push("--hidden")
+ if (input.hidden !== false) args.push("--hidden")
if (input.maxDepth !== undefined) args.push(`--max-depth=${input.maxDepth}`)
if (input.glob) {
for (const g of input.glob) {
diff --git a/packages/opencode/test/file/ripgrep.test.ts b/packages/opencode/test/file/ripgrep.test.ts
new file mode 100644
index 0000000000..ac46f1131b
--- /dev/null
+++ b/packages/opencode/test/file/ripgrep.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, test } from "bun:test"
+import fs from "fs/promises"
+import path from "path"
+import { tmpdir } from "../fixture/fixture"
+import { Ripgrep } from "../../src/file/ripgrep"
+
+describe("file.ripgrep", () => {
+ test("defaults to include hidden", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "visible.txt"), "hello")
+ await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
+ await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
+ },
+ })
+
+ const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path }))
+ const hasVisible = files.includes("visible.txt")
+ const hasHidden = files.includes(path.join(".opencode", "thing.json"))
+ expect(hasVisible).toBe(true)
+ expect(hasHidden).toBe(true)
+ })
+
+ test("hidden false excludes hidden", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "visible.txt"), "hello")
+ await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
+ await Bun.write(path.join(dir, ".opencode", "thing.json"), "{}")
+ },
+ })
+
+ const files = await Array.fromAsync(Ripgrep.files({ cwd: tmp.path, hidden: false }))
+ const hasVisible = files.includes("visible.txt")
+ const hasHidden = files.includes(path.join(".opencode", "thing.json"))
+ expect(hasVisible).toBe(true)
+ expect(hasHidden).toBe(false)
+ })
+})
From 9e45313b0aa38fa8b157c97077964e9db8bb84e3 Mon Sep 17 00:00:00 2001
From: Goni Zahavy
Date: Sun, 1 Feb 2026 05:16:34 +0200
Subject: [PATCH 03/53] ci: fixed stale pr workflow (#11310)
---
.github/workflows/close-stale-prs.yml | 94 ++++++++++++++++++++++-----
1 file changed, 76 insertions(+), 18 deletions(-)
diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml
index cb5c45063f..e1ff4241c9 100644
--- a/.github/workflows/close-stale-prs.yml
+++ b/.github/workflows/close-stale-prs.yml
@@ -28,40 +28,98 @@ jobs:
const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000)
const { owner, repo } = context.repo
const dryRun = context.payload.inputs?.dryRun === "true"
- const stalePrs = []
core.info(`Dry run mode: ${dryRun}`)
+ core.info(`Cutoff date: ${cutoff.toISOString()}`)
- const prs = await github.paginate(github.rest.pulls.list, {
- owner,
- repo,
- state: "open",
- per_page: 100,
- sort: "updated",
- direction: "asc",
- })
+ const query = `
+ query($owner: String!, $repo: String!, $cursor: String) {
+ repository(owner: $owner, name: $repo) {
+ pullRequests(first: 100, states: OPEN, after: $cursor) {
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ nodes {
+ number
+ title
+ author {
+ login
+ }
+ createdAt
+ commits(last: 1) {
+ nodes {
+ commit {
+ committedDate
+ }
+ }
+ }
+ comments(last: 1) {
+ nodes {
+ createdAt
+ }
+ }
+ reviews(last: 1) {
+ nodes {
+ createdAt
+ }
+ }
+ }
+ }
+ }
+ }
+ `
- for (const pr of prs) {
- const lastUpdated = new Date(pr.updated_at)
- if (lastUpdated > cutoff) {
- core.info(`PR ${pr.number} is fresh`)
- continue
+ const allPrs = []
+ let cursor = null
+ let hasNextPage = true
+
+ while (hasNextPage) {
+ const result = await github.graphql(query, {
+ owner,
+ repo,
+ cursor,
+ })
+
+ allPrs.push(...result.repository.pullRequests.nodes)
+ hasNextPage = result.repository.pullRequests.pageInfo.hasNextPage
+ cursor = result.repository.pullRequests.pageInfo.endCursor
+ }
+
+ core.info(`Found ${allPrs.length} open pull requests`)
+
+ const stalePrs = allPrs.filter((pr) => {
+ const dates = [
+ new Date(pr.createdAt),
+ pr.commits.nodes[0] ? new Date(pr.commits.nodes[0].commit.committedDate) : null,
+ pr.comments.nodes[0] ? new Date(pr.comments.nodes[0].createdAt) : null,
+ pr.reviews.nodes[0] ? new Date(pr.reviews.nodes[0].createdAt) : null,
+ ].filter((d) => d !== null)
+
+ const lastActivity = dates.sort((a, b) => b.getTime() - a.getTime())[0]
+
+ if (!lastActivity || lastActivity > cutoff) {
+ core.info(`PR #${pr.number} is fresh (last activity: ${lastActivity?.toISOString() || "unknown"})`)
+ return false
}
- stalePrs.push(pr)
- }
+ core.info(`PR #${pr.number} is STALE (last activity: ${lastActivity.toISOString()})`)
+ return true
+ })
if (!stalePrs.length) {
core.info("No stale pull requests found.")
return
}
+ core.info(`Found ${stalePrs.length} stale pull requests`)
+
for (const pr of stalePrs) {
const issue_number = pr.number
const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.`
if (dryRun) {
- core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`)
+ core.info(`[dry-run] Would close PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
continue
}
@@ -79,5 +137,5 @@ jobs:
state: "closed",
})
- core.info(`Closed PR #${issue_number} from ${pr.user.login}`)
+ core.info(`Closed PR #${issue_number} from ${pr.author.login}: ${pr.title}`)
}
From 2a56a1d6ef1b992aebcaec29fe2571ce61496c0e Mon Sep 17 00:00:00 2001
From: Dax
Date: Sat, 31 Jan 2026 22:40:37 -0500
Subject: [PATCH 04/53] fix(tui): conditionally render bash tool output
(#11558)
---
packages/opencode/src/cli/cmd/tui/routes/session/index.tsx | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index d36a7d2099..49bb40298a 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -1636,7 +1636,9 @@ function Bash(props: ToolProps) {
>
$ {props.input.command}
- {limited()}
+
+ {limited()}
+
{expanded() ? "Click to collapse" : "Click to expand"}
From 9b8b9e28e26598102f29220060892aeb657882af Mon Sep 17 00:00:00 2001
From: Dax
Date: Sat, 31 Jan 2026 22:59:46 -0500
Subject: [PATCH 05/53] feat(tui): add UI for skill tool in session view
(#11561)
---
.../src/cli/cmd/tui/routes/session/index.tsx | 37 +++++++++++--------
1 file changed, 22 insertions(+), 15 deletions(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index 49bb40298a..e5b3dc4406 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -43,6 +43,7 @@ import type { ApplyPatchTool } from "@/tool/apply_patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
import type { QuestionTool } from "@/tool/question"
+import type { SkillTool } from "@/tool/skill"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
@@ -1447,6 +1448,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
+
+
+
@@ -1797,7 +1801,7 @@ function Task(props: ToolProps) {
return (
-
+
) {
>
- {props.input.description} ({props.metadata.summary?.length} toolcalls)
+ {props.input.description} ({props.metadata.summary?.length ?? 0} toolcalls)
@@ -1818,22 +1822,17 @@ function Task(props: ToolProps) {
-
- {keybind.print("session_child_cycle")}
- view subagents
-
+
+
+ {keybind.print("session_child_cycle")}
+ view subagents
+
+
-
- {Locale.titlecase(props.input.subagent_type ?? "unknown")} Task "
- {props.input.description}"
+
+ {props.input.subagent_type} Task {props.input.description}
@@ -2038,6 +2037,14 @@ function Question(props: ToolProps) {
)
}
+function Skill(props: ToolProps) {
+ return (
+
+ Skill "{props.input.name}"
+
+ )
+}
+
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
From 94baf1f721a3be3c42ebfa08902ebec897e732cb Mon Sep 17 00:00:00 2001
From: Dax
Date: Sat, 31 Jan 2026 23:04:57 -0500
Subject: [PATCH 06/53] fix(tui): remove extra padding between search and
results in dialog-select (#11564)
---
packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
index bd1de7d4de..56d8453c93 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
@@ -228,7 +228,7 @@ export function DialogSelect(props: DialogSelectProps) {
esc
-
+
{
batch(() => {
From c3faeae9d0a71bb6434178f07ba5ac51f348196a Mon Sep 17 00:00:00 2001
From: adamjhf <50264672+adamjhf@users.noreply.github.com>
Date: Sun, 1 Feb 2026 17:15:28 +1100
Subject: [PATCH 07/53] fix: correct pluralization of match count in grep and
glob tools (#11565)
---
.../opencode/src/cli/cmd/tui/routes/session/index.tsx | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index e5b3dc4406..ada84e487b 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -1707,7 +1707,9 @@ function Glob(props: ToolProps) {
return (
Glob "{props.input.pattern}" in {normalizePath(props.input.path)}
- ({props.metadata.count} matches)
+
+ ({props.metadata.count} {props.metadata.count === 1 ? "match" : "matches"})
+
)
}
@@ -1743,7 +1745,9 @@ function Grep(props: ToolProps) {
return (
Grep "{props.input.pattern}" in {normalizePath(props.input.path)}
- ({props.metadata.matches} matches)
+
+ ({props.metadata.matches} {props.metadata.matches === 1 ? "match" : "matches"})
+
)
}
From d1d7447493e19ff6236b4363b502fc2de59bf67d Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Sun, 1 Feb 2026 01:36:44 -0600
Subject: [PATCH 08/53] fix: ensure switching anthropic models mid convo on
copilot works without errors, fix issue with reasoning opaque not being
picked up for gemini models (#11569)
---
...vert-to-openai-compatible-chat-messages.ts | 4 +--
.../openai-compatible-chat-language-model.ts | 19 ++++++++--
.../convert-to-copilot-messages.test.ts | 31 ++++++++++++++--
.../copilot/copilot-chat-model.test.ts | 35 +++++++++++++++++++
4 files changed, 83 insertions(+), 6 deletions(-)
diff --git a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts
index 642d7145fe..d6f7cb34bb 100644
--- a/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts
+++ b/packages/opencode/src/provider/sdk/copilot/chat/convert-to-openai-compatible-chat-messages.ts
@@ -100,7 +100,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
break
}
case "reasoning": {
- reasoningText = part.text
+ if (part.text) reasoningText = part.text
break
}
case "tool-call": {
@@ -122,7 +122,7 @@ export function convertToOpenAICompatibleChatMessages(prompt: LanguageModelV2Pro
role: "assistant",
content: text || null,
tool_calls: toolCalls.length > 0 ? toolCalls : undefined,
- reasoning_text: reasoningText,
+ reasoning_text: reasoningOpaque ? reasoningText : undefined,
reasoning_opaque: reasoningOpaque,
...metadata,
})
diff --git a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts
index 94641e640e..c85d3f3d17 100644
--- a/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts
+++ b/packages/opencode/src/provider/sdk/copilot/chat/openai-compatible-chat-language-model.ts
@@ -219,7 +219,13 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
// text content:
const text = choice.message.content
if (text != null && text.length > 0) {
- content.push({ type: "text", text })
+ content.push({
+ type: "text",
+ text,
+ providerMetadata: choice.message.reasoning_opaque
+ ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
+ : undefined,
+ })
}
// reasoning content (Copilot uses reasoning_text):
@@ -243,6 +249,9 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments!,
+ providerMetadata: choice.message.reasoning_opaque
+ ? { copilot: { reasoningOpaque: choice.message.reasoning_opaque } }
+ : undefined,
})
}
}
@@ -478,7 +487,11 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
}
if (!isActiveText) {
- controller.enqueue({ type: "text-start", id: "txt-0" })
+ controller.enqueue({
+ type: "text-start",
+ id: "txt-0",
+ providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
+ })
isActiveText = true
}
@@ -559,6 +572,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
+ providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
})
toolCall.hasFinished = true
}
@@ -601,6 +615,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV2 {
toolCallId: toolCall.id ?? generateId(),
toolName: toolCall.function.name,
input: toolCall.function.arguments,
+ providerMetadata: reasoningOpaque ? { copilot: { reasoningOpaque } } : undefined,
})
toolCall.hasFinished = true
}
diff --git a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts
index ffc7469115..9f305123af 100644
--- a/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts
+++ b/packages/opencode/test/provider/copilot/convert-to-copilot-messages.test.ts
@@ -354,7 +354,7 @@ describe("tool calls", () => {
})
describe("reasoning (copilot-specific)", () => {
- test("should include reasoning_text from reasoning part", () => {
+ test("should omit reasoning_text without reasoning_opaque", () => {
const result = convertToCopilotMessages([
{
role: "assistant",
@@ -370,7 +370,7 @@ describe("reasoning (copilot-specific)", () => {
role: "assistant",
content: "The answer is 42.",
tool_calls: undefined,
- reasoning_text: "Let me think about this...",
+ reasoning_text: undefined,
reasoning_opaque: undefined,
},
])
@@ -404,6 +404,33 @@ describe("reasoning (copilot-specific)", () => {
])
})
+ test("should include reasoning_opaque from text part providerOptions", () => {
+ const result = convertToCopilotMessages([
+ {
+ role: "assistant",
+ content: [
+ {
+ type: "text",
+ text: "Done!",
+ providerOptions: {
+ copilot: { reasoningOpaque: "opaque-text-456" },
+ },
+ },
+ ],
+ },
+ ])
+
+ expect(result).toEqual([
+ {
+ role: "assistant",
+ content: "Done!",
+ tool_calls: undefined,
+ reasoning_text: undefined,
+ reasoning_opaque: "opaque-text-456",
+ },
+ ])
+ })
+
test("should handle reasoning-only assistant message", () => {
const result = convertToCopilotMessages([
{
diff --git a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts
index 0b82c18684..562da4507d 100644
--- a/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts
+++ b/packages/opencode/test/provider/copilot/copilot-chat-model.test.ts
@@ -65,6 +65,12 @@ const FIXTURES = {
`data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{\\"code\\":\\"1 + 1\\"}","name":"project_eval"},"id":"call_MHw3RDhmT1J5Z3B6WlhpVjlveTc","index":0,"type":"function"}],"reasoning_opaque":"ytGNWFf2doK38peANDvm7whkLPKrd+Fv6/k34zEPBF6Qwitj4bTZT0FBXleydLb6"}}],"created":1766068644,"id":"oBFEaafzD9DVlOoPkY3l4Qs","usage":{"completion_tokens":12,"prompt_tokens":8677,"prompt_tokens_details":{"cached_tokens":3692},"total_tokens":8768,"reasoning_tokens":79},"model":"gemini-3-pro-preview"}`,
`data: [DONE]`,
],
+
+ reasoningOpaqueWithToolCallsNoReasoningText: [
+ `data: {"choices":[{"index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only","index":0,"type":"function"}],"reasoning_opaque":"opaque-xyz"}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":0,"prompt_tokens":0,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":0,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`,
+ `data: {"choices":[{"finish_reason":"tool_calls","index":0,"delta":{"content":null,"role":"assistant","tool_calls":[{"function":{"arguments":"{}","name":"read_file"},"id":"call_reasoning_only_2","index":1,"type":"function"}]}}],"created":1769917420,"id":"opaque-only","usage":{"completion_tokens":12,"prompt_tokens":123,"prompt_tokens_details":{"cached_tokens":0},"total_tokens":135,"reasoning_tokens":0},"model":"gemini-3-flash-preview"}`,
+ `data: [DONE]`,
+ ],
}
function createMockFetch(chunks: string[]) {
@@ -447,6 +453,35 @@ describe("doStream", () => {
})
})
+ test("should attach reasoning_opaque to tool calls without reasoning_text", async () => {
+ const mockFetch = createMockFetch(FIXTURES.reasoningOpaqueWithToolCallsNoReasoningText)
+ const model = createModel(mockFetch)
+
+ const { stream } = await model.doStream({
+ prompt: TEST_PROMPT,
+ includeRawChunks: false,
+ })
+
+ const parts = await convertReadableStreamToArray(stream)
+ const reasoningParts = parts.filter(
+ (p) => p.type === "reasoning-start" || p.type === "reasoning-delta" || p.type === "reasoning-end",
+ )
+
+ expect(reasoningParts).toHaveLength(0)
+
+ const toolCall = parts.find((p) => p.type === "tool-call" && p.toolCallId === "call_reasoning_only")
+ expect(toolCall).toMatchObject({
+ type: "tool-call",
+ toolCallId: "call_reasoning_only",
+ toolName: "read_file",
+ providerMetadata: {
+ copilot: {
+ reasoningOpaque: "opaque-xyz",
+ },
+ },
+ })
+ })
+
test("should include response metadata from first chunk", async () => {
const mockFetch = createMockFetch(FIXTURES.basicText)
const model = createModel(mockFetch)
From 3e671042577f947cad6d645e8a5096add62e8687 Mon Sep 17 00:00:00 2001
From: Filip <34747899+neriousy@users.noreply.github.com>
Date: Sun, 1 Feb 2026 13:42:33 +0100
Subject: [PATCH 09/53] fix(app): show retry status only on active turn
(#11543)
---
packages/ui/src/components/session-turn.tsx | 2 ++
1 file changed, 2 insertions(+)
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index f1c62c0aff..48d6337edb 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -398,6 +398,8 @@ export function SessionTurn(
const status = createMemo(() => data.store.session_status[props.sessionID] ?? idle)
const working = createMemo(() => status().type !== "idle" && isLastUserMessage())
const retry = createMemo(() => {
+ // session_status is session-scoped; only show retry on the active (last) turn
+ if (!isLastUserMessage()) return
const s = status()
if (s.type !== "retry") return
return s
From 2af1ca7290916c1f9e391033cd9b8d3ffc3bc24f Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=86=E5=A5=95=E4=B8=9E?= <01luyicheng@gmail.com>
Date: Sun, 1 Feb 2026 23:33:21 +0800
Subject: [PATCH 10/53] docs: improve zh-TW punctuation to match Taiwan usage
(#11574) (#11589)
---
README.zht.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.zht.md b/README.zht.md
index 298b5b35ac..f31bf8b834 100644
--- a/README.zht.md
+++ b/README.zht.md
@@ -124,7 +124,7 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
- 100% 開源。
- 不綁定特定的服務提供商。雖然我們推薦使用透過 [OpenCode Zen](https://opencode.ai/zen) 提供的模型,但 OpenCode 也可搭配 Claude, OpenAI, Google 甚至本地模型使用。隨著模型不斷演進,彼此間的差距會縮小且價格會下降,因此具備「不限廠商 (provider-agnostic)」的特性至關重要。
- 內建 LSP (語言伺服器協定) 支援。
-- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造;我們將不斷挑戰終端機介面的極限。
+- 專注於終端機介面 (TUI)。OpenCode 由 Neovim 愛好者與 [terminal.shop](https://terminal.shop) 的創作者打造。我們將不斷挑戰終端機介面的極限。
- 客戶端/伺服器架構 (Client/Server Architecture)。這讓 OpenCode 能夠在您的電腦上運行的同時,由行動裝置進行遠端操控。這意味著 TUI 前端只是眾多可能的客戶端之一。
---
From dfbe553626ac37ff9a6fd34da55cf9de9ebd635c Mon Sep 17 00:00:00 2001
From: Alper Kartkaya <114335677+AlperKartkaya@users.noreply.github.com>
Date: Sun, 1 Feb 2026 18:37:45 +0300
Subject: [PATCH 11/53] docs: add Turkish README translation (#11524)
---
README.ar.md | 4 +-
README.br.md | 4 +-
README.da.md | 4 +-
README.de.md | 4 +-
README.es.md | 4 +-
README.fr.md | 4 +-
README.it.md | 4 +-
README.ja.md | 4 +-
README.ko.md | 4 +-
README.md | 3 +-
README.no.md | 4 +-
README.pl.md | 4 +-
README.ru.md | 4 +-
README.th.md | 3 +-
README.tr.md | 135 ++++++++++++++++++++++++++++++++++++++++++++++++++
README.zh.md | 4 +-
README.zht.md | 4 +-
17 files changed, 181 insertions(+), 16 deletions(-)
create mode 100644 README.tr.md
diff --git a/README.ar.md b/README.ar.md
index 2abceb300d..4c8ac5fcc3 100644
--- a/README.ar.md
+++ b/README.ar.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.br.md b/README.br.md
index 6a58241c98..ee5e85fd44 100644
--- a/README.br.md
+++ b/README.br.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.da.md b/README.da.md
index 7e7dda42a8..79928fd944 100644
--- a/README.da.md
+++ b/README.da.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.de.md b/README.de.md
index c949dd00f4..ccb3ad07dc 100644
--- a/README.de.md
+++ b/README.de.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.es.md b/README.es.md
index 3e3797ed30..e5a7d8e8dd 100644
--- a/README.es.md
+++ b/README.es.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.fr.md b/README.fr.md
index 00133b1e9f..5436009903 100644
--- a/README.fr.md
+++ b/README.fr.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.it.md b/README.it.md
index 89692a3668..cbc8a5f6d2 100644
--- a/README.it.md
+++ b/README.it.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.ja.md b/README.ja.md
index 5f3a9e189e..8827efae88 100644
--- a/README.ja.md
+++ b/README.ja.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.ko.md b/README.ko.md
index 213f46bfe7..806dc642c1 100644
--- a/README.ko.md
+++ b/README.ko.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.md b/README.md
index 7e20902547..dd5adb9809 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,8 @@
العربية |
Norsk |
Português (Brasil) |
- ไทย
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.no.md b/README.no.md
index 44371df5ed..90b631fef2 100644
--- a/README.no.md
+++ b/README.no.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.pl.md b/README.pl.md
index b183cd6245..ae653a7fa0 100644
--- a/README.pl.md
+++ b/README.pl.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.ru.md b/README.ru.md
index c192036b54..cf15c6ebce 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.th.md b/README.th.md
index a4b306a6c4..4077abc011 100644
--- a/README.th.md
+++ b/README.th.md
@@ -30,7 +30,8 @@
العربية |
Norsk |
Português (Brasil) |
- ไทย
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.tr.md b/README.tr.md
new file mode 100644
index 0000000000..e3055e7a99
--- /dev/null
+++ b/README.tr.md
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+Açık kaynaklı yapay zeka kodlama asistanı.
+
+
+
+
+
+
+
+ English |
+ 简体中文 |
+ 繁體中文 |
+ 한국어 |
+ Deutsch |
+ Español |
+ Français |
+ Italiano |
+ Dansk |
+ 日本語 |
+ Polski |
+ Русский |
+ العربية |
+ Norsk |
+ Português (Brasil) |
+ ไทย |
+ Türkçe
+
+
+[](https://opencode.ai)
+
+---
+
+### Kurulum
+
+```bash
+# YOLO
+curl -fsSL https://opencode.ai/install | bash
+
+# Paket yöneticileri
+npm i -g opencode-ai@latest # veya bun/pnpm/yarn
+scoop install opencode # Windows
+choco install opencode # Windows
+brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
+brew install opencode # macOS ve Linux (resmi brew formülü, daha az güncellenir)
+paru -S opencode-bin # Arch Linux
+mise use -g opencode # Tüm işletim sistemleri
+nix run nixpkgs#opencode # veya en güncel geliştirme dalı için github:anomalyco/opencode
+```
+
+> [!TIP]
+> Kurulumdan önce 0.1.x'ten eski sürümleri kaldırın.
+
+### Masaüstü Uygulaması (BETA)
+
+OpenCode ayrıca masaüstü uygulaması olarak da mevcuttur. Doğrudan [sürüm sayfasından](https://github.com/anomalyco/opencode/releases) veya [opencode.ai/download](https://opencode.ai/download) adresinden indirebilirsiniz.
+
+| Platform | İndirme |
+| --------------------- | ------------------------------------- |
+| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` |
+| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` |
+| Windows | `opencode-desktop-windows-x64.exe` |
+| Linux | `.deb`, `.rpm` veya AppImage |
+
+```bash
+# macOS (Homebrew)
+brew install --cask opencode-desktop
+# Windows (Scoop)
+scoop bucket add extras; scoop install extras/opencode-desktop
+```
+
+#### Kurulum Dizini (Installation Directory)
+
+Kurulum betiği (install script), kurulum yolu (installation path) için aşağıdaki öncelik sırasını takip eder:
+
+1. `$OPENCODE_INSTALL_DIR` - Özel kurulum dizini
+2. `$XDG_BIN_DIR` - XDG Base Directory Specification uyumlu yol
+3. `$HOME/bin` - Standart kullanıcı binary dizini (varsa veya oluşturulabiliyorsa)
+4. `$HOME/.opencode/bin` - Varsayılan yedek konum
+
+```bash
+# Örnekler
+OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash
+XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash
+```
+
+### Ajanlar
+
+OpenCode, `Tab` tuşuyla aralarında geçiş yapabileceğiniz iki yerleşik (built-in) ajan içerir.
+
+- **build** - Varsayılan, geliştirme çalışmaları için tam erişimli ajan
+- **plan** - Analiz ve kod keşfi için salt okunur ajan
+ - Varsayılan olarak dosya düzenlemelerini reddeder
+ - Bash komutlarını çalıştırmadan önce izin ister
+ - Tanımadığınız kod tabanlarını keşfetmek veya değişiklikleri planlamak için ideal
+
+Ayrıca, karmaşık aramalar ve çok adımlı görevler için bir **genel** alt ajan bulunmaktadır.
+Bu dahili olarak kullanılır ve mesajlarda `@general` ile çağrılabilir.
+
+[Ajanlar](https://opencode.ai/docs/agents) hakkında daha fazla bilgi edinin.
+
+### Dokümantasyon
+
+OpenCode'u nasıl yapılandıracağınız hakkında daha fazla bilgi için [**dokümantasyonumuza göz atın**](https://opencode.ai/docs).
+
+### Katkıda Bulunma
+
+OpenCode'a katkıda bulunmak istiyorsanız, lütfen bir pull request göndermeden önce [katkıda bulunma dokümanlarımızı](./CONTRIBUTING.md) okuyun.
+
+### OpenCode Üzerine Geliştirme
+
+OpenCode ile ilgili bir proje üzerinde çalışıyorsanız ve projenizin adının bir parçası olarak "opencode" kullanıyorsanız (örneğin, "opencode-dashboard" veya "opencode-mobile"), lütfen README dosyanıza projenin OpenCode ekibi tarafından geliştirilmediğini ve bizimle hiçbir şekilde bağlantılı olmadığını belirten bir not ekleyin.
+
+### SSS
+
+#### Bu Claude Code'dan nasıl farklı?
+
+Yetenekler açısından Claude Code'a çok benzer. İşte temel farklar:
+
+- %100 açık kaynak
+- Herhangi bir sağlayıcıya bağlı değil. [OpenCode Zen](https://opencode.ai/zen) üzerinden sunduğumuz modelleri önermekle birlikte; OpenCode, Claude, OpenAI, Google veya hatta yerel modellerle kullanılabilir. Modeller geliştikçe aralarındaki farklar kapanacak ve fiyatlar düşecek, bu nedenle sağlayıcıdan bağımsız olmak önemlidir.
+- Kurulum gerektirmeyen hazır LSP desteği
+- TUI odaklı yaklaşım. OpenCode, neovim kullanıcıları ve [terminal.shop](https://terminal.shop)'un geliştiricileri tarafından geliştirilmektedir; terminalde olabileceklerin sınırlarını zorlayacağız.
+- İstemci/sunucu (client/server) mimarisi. Bu, örneğin OpenCode'un bilgisayarınızda çalışması ve siz onu bir mobil uygulamadan uzaktan yönetmenizi sağlar. TUI arayüzü olası istemcilerden sadece biridir.
+
+---
+
+**Topluluğumuza katılın** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
diff --git a/README.zh.md b/README.zh.md
index 9ebbe8ce93..6970fe34ef 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
diff --git a/README.zht.md b/README.zht.md
index f31bf8b834..a045f45490 100644
--- a/README.zht.md
+++ b/README.zht.md
@@ -29,7 +29,9 @@
Русский |
العربية |
Norsk |
- Português (Brasil)
+ Português (Brasil) |
+ ไทย |
+ Türkçe
[](https://opencode.ai)
From b51005ec4af0dc99e4cdbfb42055581bee584fa4 Mon Sep 17 00:00:00 2001
From: Alex Yaroshuk <34632190+alexyaroshuk@users.noreply.github.com>
Date: Sun, 1 Feb 2026 23:38:23 +0800
Subject: [PATCH 12/53] fix(app): use static language names in Thai
localization (#11496)
---
packages/app/src/i18n/th.ts | 28 ++++++++++++++--------------
1 file changed, 14 insertions(+), 14 deletions(-)
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index 9ccb61ac76..f8a646f558 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -322,20 +322,20 @@ export const dict = {
"context.usage.clickToView": "คลิกเพื่อดูบริบท",
"context.usage.view": "ดูการใช้บริบท",
- "language.en": "อังกฤษ",
- "language.zh": "จีนตัวย่อ",
- "language.zht": "จีนตัวเต็ม",
- "language.ko": "เกาหลี",
- "language.de": "เยอรมัน",
- "language.es": "สเปน",
- "language.fr": "ฝรั่งเศส",
- "language.da": "เดนมาร์ก",
- "language.ja": "ญี่ปุ่น",
- "language.pl": "โปแลนด์",
- "language.ru": "รัสเซีย",
- "language.ar": "อาหรับ",
- "language.no": "นอร์เวย์",
- "language.br": "โปรตุเกส (บราซิล)",
+ "language.en": "English",
+ "language.zh": "简体中文",
+ "language.zht": "繁體中文",
+ "language.ko": "한국어",
+ "language.de": "Deutsch",
+ "language.es": "Español",
+ "language.fr": "Français",
+ "language.da": "Dansk",
+ "language.ja": "日本語",
+ "language.pl": "Polski",
+ "language.ru": "Русский",
+ "language.ar": "العربية",
+ "language.no": "Norsk",
+ "language.br": "Português (Brasil)",
"language.th": "ไทย",
"toast.language.title": "ภาษา",
From 23c803707d2ee1ac71bbb9420272c45139947382 Mon Sep 17 00:00:00 2001
From: Alex Yaroshuk <34632190+alexyaroshuk@users.noreply.github.com>
Date: Sun, 1 Feb 2026 23:40:33 +0800
Subject: [PATCH 13/53] fix(app): binary file handling in file view (#11312)
---
packages/app/src/i18n/ar.ts | 1 +
packages/app/src/i18n/br.ts | 1 +
packages/app/src/i18n/da.ts | 1 +
packages/app/src/i18n/de.ts | 1 +
packages/app/src/i18n/en.ts | 1 +
packages/app/src/i18n/es.ts | 1 +
packages/app/src/i18n/fr.ts | 1 +
packages/app/src/i18n/ja.ts | 1 +
packages/app/src/i18n/ko.ts | 1 +
packages/app/src/i18n/no.ts | 1 +
packages/app/src/i18n/pl.ts | 1 +
packages/app/src/i18n/ru.ts | 1 +
packages/app/src/i18n/th.ts | 1 +
packages/app/src/i18n/zh.ts | 1 +
packages/app/src/i18n/zht.ts | 1 +
packages/app/src/pages/session.tsx | 14 ++
packages/opencode/src/file/index.ts | 212 +++++++++++++++++++++---
packages/sdk/js/src/gen/types.gen.ts | 2 +-
packages/sdk/js/src/v2/gen/types.gen.ts | 2 +-
19 files changed, 221 insertions(+), 24 deletions(-)
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index 8ca05cdfeb..80179144a8 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -432,6 +432,7 @@ export const dict = {
"session.review.noChanges": "لا توجد تغييرات",
"session.files.selectToOpen": "اختر ملفًا لفتحه",
"session.files.all": "كل الملفات",
+ "session.files.binaryContent": "ملف ثنائي (لا يمكن عرض المحتوى)",
"session.messages.renderEarlier": "عرض الرسائل السابقة",
"session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...",
"session.messages.loadEarlier": "تحميل الرسائل السابقة",
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index ad0772cd8b..c874a4376b 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -433,6 +433,7 @@ export const dict = {
"session.review.noChanges": "Sem alterações",
"session.files.selectToOpen": "Selecione um arquivo para abrir",
"session.files.all": "Todos os arquivos",
+ "session.files.binaryContent": "Arquivo binário (conteúdo não pode ser exibido)",
"session.messages.renderEarlier": "Renderizar mensagens anteriores",
"session.messages.loadingEarlier": "Carregando mensagens anteriores...",
"session.messages.loadEarlier": "Carregar mensagens anteriores",
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index 031d92d4b9..555990a9c4 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -434,6 +434,7 @@ export const dict = {
"session.review.noChanges": "Ingen ændringer",
"session.files.selectToOpen": "Vælg en fil at åbne",
"session.files.all": "Alle filer",
+ "session.files.binaryContent": "Binær fil (indhold kan ikke vises)",
"session.messages.renderEarlier": "Vis tidligere beskeder",
"session.messages.loadingEarlier": "Indlæser tidligere beskeder...",
"session.messages.loadEarlier": "Indlæs tidligere beskeder",
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index 9febfcff1e..e56081c908 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -442,6 +442,7 @@ export const dict = {
"session.review.noChanges": "Keine Änderungen",
"session.files.selectToOpen": "Datei zum Öffnen auswählen",
"session.files.all": "Alle Dateien",
+ "session.files.binaryContent": "Binärdatei (Inhalt kann nicht angezeigt werden)",
"session.messages.renderEarlier": "Frühere Nachrichten rendern",
"session.messages.loadingEarlier": "Lade frühere Nachrichten...",
"session.messages.loadEarlier": "Frühere Nachrichten laden",
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index a6a50506a0..4254860ac9 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -441,6 +441,7 @@ export const dict = {
"session.files.selectToOpen": "Select a file to open",
"session.files.all": "All files",
+ "session.files.binaryContent": "Binary file (content cannot be displayed)",
"session.messages.renderEarlier": "Render earlier messages",
"session.messages.loadingEarlier": "Loading earlier messages...",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index ee75a143df..e928f03cec 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -436,6 +436,7 @@ export const dict = {
"session.review.noChanges": "Sin cambios",
"session.files.selectToOpen": "Selecciona un archivo para abrir",
"session.files.all": "Todos los archivos",
+ "session.files.binaryContent": "Archivo binario (el contenido no puede ser mostrado)",
"session.messages.renderEarlier": "Renderizar mensajes anteriores",
"session.messages.loadingEarlier": "Cargando mensajes anteriores...",
"session.messages.loadEarlier": "Cargar mensajes anteriores",
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index f0652a9814..31000cd17a 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -441,6 +441,7 @@ export const dict = {
"session.review.noChanges": "Aucune modification",
"session.files.selectToOpen": "Sélectionnez un fichier à ouvrir",
"session.files.all": "Tous les fichiers",
+ "session.files.binaryContent": "Fichier binaire (le contenu ne peut pas être affiché)",
"session.messages.renderEarlier": "Afficher les messages précédents",
"session.messages.loadingEarlier": "Chargement des messages précédents...",
"session.messages.loadEarlier": "Charger les messages précédents",
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index ffe5368142..80efc5c2aa 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -433,6 +433,7 @@ export const dict = {
"session.review.noChanges": "変更なし",
"session.files.selectToOpen": "開くファイルを選択",
"session.files.all": "すべてのファイル",
+ "session.files.binaryContent": "バイナリファイル(内容を表示できません)",
"session.messages.renderEarlier": "以前のメッセージを表示",
"session.messages.loadingEarlier": "以前のメッセージを読み込み中...",
"session.messages.loadEarlier": "以前のメッセージを読み込む",
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index 6c30e0123d..014092d07f 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -435,6 +435,7 @@ export const dict = {
"session.review.noChanges": "변경 없음",
"session.files.selectToOpen": "열 파일을 선택하세요",
"session.files.all": "모든 파일",
+ "session.files.binaryContent": "바이너리 파일 (내용을 표시할 수 없음)",
"session.messages.renderEarlier": "이전 메시지 렌더링",
"session.messages.loadingEarlier": "이전 메시지 로드 중...",
"session.messages.loadEarlier": "이전 메시지 로드",
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index 132c0b6c1f..400ce37d35 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -436,6 +436,7 @@ export const dict = {
"session.review.noChanges": "Ingen endringer",
"session.files.selectToOpen": "Velg en fil å åpne",
"session.files.all": "Alle filer",
+ "session.files.binaryContent": "Binær fil (innhold kan ikke vises)",
"session.messages.renderEarlier": "Vis tidligere meldinger",
"session.messages.loadingEarlier": "Laster inn tidligere meldinger...",
"session.messages.loadEarlier": "Last inn tidligere meldinger",
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index efed3eeb15..5a05809829 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -435,6 +435,7 @@ export const dict = {
"session.review.noChanges": "Brak zmian",
"session.files.selectToOpen": "Wybierz plik do otwarcia",
"session.files.all": "Wszystkie pliki",
+ "session.files.binaryContent": "Plik binarny (zawartość nie może być wyświetlona)",
"session.messages.renderEarlier": "Renderuj wcześniejsze wiadomości",
"session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...",
"session.messages.loadEarlier": "Załaduj wcześniejsze wiadomości",
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index 0728c4a342..4277368f5d 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -437,6 +437,7 @@ export const dict = {
"session.review.noChanges": "Нет изменений",
"session.files.selectToOpen": "Выберите файл, чтобы открыть",
"session.files.all": "Все файлы",
+ "session.files.binaryContent": "Двоичный файл (содержимое не может быть отображено)",
"session.messages.renderEarlier": "Показать предыдущие сообщения",
"session.messages.loadingEarlier": "Загрузка предыдущих сообщений...",
"session.messages.loadEarlier": "Загрузить предыдущие сообщения",
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index f8a646f558..e2eabd7ad8 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -438,6 +438,7 @@ export const dict = {
"session.files.selectToOpen": "เลือกไฟล์เพื่อเปิด",
"session.files.all": "ไฟล์ทั้งหมด",
+ "session.files.binaryContent": "ไฟล์ไบนารี (ไม่สามารถแสดงเนื้อหาได้)",
"session.messages.renderEarlier": "แสดงข้อความก่อนหน้า",
"session.messages.loadingEarlier": "กำลังโหลดข้อความก่อนหน้า...",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index 2266c109b0..118e03ce47 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -434,6 +434,7 @@ export const dict = {
"session.review.noChanges": "无更改",
"session.files.selectToOpen": "选择要打开的文件",
"session.files.all": "所有文件",
+ "session.files.binaryContent": "二进制文件(无法显示内容)",
"session.messages.renderEarlier": "显示更早的消息",
"session.messages.loadingEarlier": "正在加载更早的消息...",
"session.messages.loadEarlier": "加载更早的消息",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 30837e56fb..45a789df4c 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -431,6 +431,7 @@ export const dict = {
"session.review.noChanges": "沒有變更",
"session.files.selectToOpen": "選取要開啟的檔案",
"session.files.all": "所有檔案",
+ "session.files.binaryContent": "二進位檔案(無法顯示內容)",
"session.messages.renderEarlier": "顯示更早的訊息",
"session.messages.loadingEarlier": "正在載入更早的訊息...",
"session.messages.loadEarlier": "載入更早的訊息",
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index b346fa6928..d3e74072a8 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -2342,6 +2342,7 @@ export default function Page() {
const c = state()?.content
return c?.mimeType === "image/svg+xml"
})
+ const isBinary = createMemo(() => state()?.content?.type === "binary")
const svgContent = createMemo(() => {
if (!isSvg()) return
const c = state()?.content
@@ -2794,6 +2795,19 @@ export default function Page() {
+
+
+
+
+
+ {path()?.split("/").pop()}
+
+
+ {language.t("session.files.binaryContent")}
+
+
+
+
{renderCode(contents(), "pb-40")}
{language.t("common.loading")}...
diff --git a/packages/opencode/src/file/index.ts b/packages/opencode/src/file/index.ts
index dfa6356a27..32465015e9 100644
--- a/packages/opencode/src/file/index.ts
+++ b/packages/opencode/src/file/index.ts
@@ -44,7 +44,7 @@ export namespace File {
export const Content = z
.object({
- type: z.literal("text"),
+ type: z.enum(["text", "binary"]),
content: z.string(),
diff: z.string().optional(),
patch: z
@@ -73,6 +73,174 @@ export namespace File {
})
export type Content = z.infer
+ const binaryExtensions = new Set([
+ "exe",
+ "dll",
+ "pdb",
+ "bin",
+ "so",
+ "dylib",
+ "o",
+ "a",
+ "lib",
+ "wav",
+ "mp3",
+ "ogg",
+ "oga",
+ "ogv",
+ "ogx",
+ "flac",
+ "aac",
+ "wma",
+ "m4a",
+ "weba",
+ "mp4",
+ "avi",
+ "mov",
+ "wmv",
+ "flv",
+ "webm",
+ "mkv",
+ "zip",
+ "tar",
+ "gz",
+ "gzip",
+ "bz",
+ "bz2",
+ "bzip",
+ "bzip2",
+ "7z",
+ "rar",
+ "xz",
+ "lz",
+ "z",
+ "pdf",
+ "doc",
+ "docx",
+ "ppt",
+ "pptx",
+ "xls",
+ "xlsx",
+ "dmg",
+ "iso",
+ "img",
+ "vmdk",
+ "ttf",
+ "otf",
+ "woff",
+ "woff2",
+ "eot",
+ "sqlite",
+ "db",
+ "mdb",
+ "apk",
+ "ipa",
+ "aab",
+ "xapk",
+ "app",
+ "pkg",
+ "deb",
+ "rpm",
+ "snap",
+ "flatpak",
+ "appimage",
+ "msi",
+ "msp",
+ "jar",
+ "war",
+ "ear",
+ "class",
+ "kotlin_module",
+ "dex",
+ "vdex",
+ "odex",
+ "oat",
+ "art",
+ "wasm",
+ "wat",
+ "bc",
+ "ll",
+ "s",
+ "ko",
+ "sys",
+ "drv",
+ "efi",
+ "rom",
+ "com",
+ "bat",
+ "cmd",
+ "ps1",
+ "sh",
+ "bash",
+ "zsh",
+ "fish",
+ ])
+
+ const imageExtensions = new Set([
+ "png",
+ "jpg",
+ "jpeg",
+ "gif",
+ "bmp",
+ "webp",
+ "ico",
+ "tif",
+ "tiff",
+ "svg",
+ "svgz",
+ "avif",
+ "apng",
+ "jxl",
+ "heic",
+ "heif",
+ "raw",
+ "cr2",
+ "nef",
+ "arw",
+ "dng",
+ "orf",
+ "raf",
+ "pef",
+ "x3f",
+ ])
+
+ function isImageByExtension(filepath: string): boolean {
+ const ext = path.extname(filepath).toLowerCase().slice(1)
+ return imageExtensions.has(ext)
+ }
+
+ function getImageMimeType(filepath: string): string {
+ const ext = path.extname(filepath).toLowerCase().slice(1)
+ const mimeTypes: Record = {
+ png: "image/png",
+ jpg: "image/jpeg",
+ jpeg: "image/jpeg",
+ gif: "image/gif",
+ bmp: "image/bmp",
+ webp: "image/webp",
+ ico: "image/x-icon",
+ tif: "image/tiff",
+ tiff: "image/tiff",
+ svg: "image/svg+xml",
+ svgz: "image/svg+xml",
+ avif: "image/avif",
+ apng: "image/apng",
+ jxl: "image/jxl",
+ heic: "image/heic",
+ heif: "image/heif",
+ }
+ return mimeTypes[ext] || "image/" + ext
+ }
+
+ function isBinaryByExtension(filepath: string): boolean {
+ const ext = path.extname(filepath).toLowerCase().slice(1)
+ return binaryExtensions.has(ext)
+ }
+
+ function isImage(mimeType: string): boolean {
+ return mimeType.startsWith("image/")
+ }
+
async function shouldEncode(file: BunFile): Promise {
const type = file.type?.toLowerCase()
log.info("shouldEncode", { type })
@@ -83,30 +251,10 @@ export namespace File {
const parts = type.split("/", 2)
const top = parts[0]
- const rest = parts[1] ?? ""
- const sub = rest.split(";", 1)[0]
const tops = ["image", "audio", "video", "font", "model", "multipart"]
if (tops.includes(top)) return true
- const bins = [
- "zip",
- "gzip",
- "bzip",
- "compressed",
- "binary",
- "pdf",
- "msword",
- "powerpoint",
- "excel",
- "ogg",
- "exe",
- "dmg",
- "iso",
- "rar",
- ]
- if (bins.some((mark) => sub.includes(mark))) return true
-
return false
}
@@ -287,6 +435,22 @@ export namespace File {
throw new Error(`Access denied: path escapes project directory`)
}
+ // Fast path: check extension before any filesystem operations
+ if (isImageByExtension(file)) {
+ const bunFile = Bun.file(full)
+ if (await bunFile.exists()) {
+ const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
+ const content = Buffer.from(buffer).toString("base64")
+ const mimeType = getImageMimeType(file)
+ return { type: "text", content, mimeType, encoding: "base64" }
+ }
+ return { type: "text", content: "" }
+ }
+
+ if (isBinaryByExtension(file)) {
+ return { type: "binary", content: "" }
+ }
+
const bunFile = Bun.file(full)
if (!(await bunFile.exists())) {
@@ -294,11 +458,15 @@ export namespace File {
}
const encode = await shouldEncode(bunFile)
+ const mimeType = bunFile.type || "application/octet-stream"
+
+ if (encode && !isImage(mimeType)) {
+ return { type: "binary", content: "", mimeType }
+ }
if (encode) {
const buffer = await bunFile.arrayBuffer().catch(() => new ArrayBuffer(0))
const content = Buffer.from(buffer).toString("base64")
- const mimeType = bunFile.type || "application/octet-stream"
return { type: "text", content, mimeType, encoding: "base64" }
}
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index ca13e5e93c..8eefe5bfe9 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -1554,7 +1554,7 @@ export type FileNode = {
}
export type FileContent = {
- type: "text"
+ type: "text" | "binary"
content: string
diff?: string
patch?: {
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index cb2f586775..e992b27bd5 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -2042,7 +2042,7 @@ export type FileNode = {
}
export type FileContent = {
- type: "text"
+ type: "text" | "binary"
content: string
diff?: string
patch?: {
From 29d02d643b8f1dcea8e86fe3d6f0530876340120 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
Date: Sun, 1 Feb 2026 15:41:11 +0000
Subject: [PATCH 14/53] chore: generate
---
packages/sdk/openapi.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index 4be0a87f98..f383ffbba8 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -10564,7 +10564,7 @@
"properties": {
"type": {
"type": "string",
- "const": "text"
+ "enum": ["text", "binary"]
},
"content": {
"type": "string"
From 3577d829c2b0002e3697932b71a22dd9fbabc28a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Joscha=20G=C3=B6tzer?=
Date: Sun, 1 Feb 2026 17:50:41 +0100
Subject: [PATCH 15/53] fix: allow user plugins to override built-in auth
plugins (#11058)
Co-authored-by: JosXa
---
packages/opencode/src/cli/cmd/auth.ts | 4 +-
.../test/plugin/auth-override.test.ts | 44 +++++++++++++++++++
2 files changed, 46 insertions(+), 2 deletions(-)
create mode 100644 packages/opencode/test/plugin/auth-override.test.ts
diff --git a/packages/opencode/src/cli/cmd/auth.ts b/packages/opencode/src/cli/cmd/auth.ts
index bbaecfd8c7..34e2269d0c 100644
--- a/packages/opencode/src/cli/cmd/auth.ts
+++ b/packages/opencode/src/cli/cmd/auth.ts
@@ -307,7 +307,7 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(provider)) throw new UI.CancelledError()
- const plugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
+ const plugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (plugin && plugin.auth) {
const handled = await handlePluginAuth({ auth: plugin.auth }, provider)
if (handled) return
@@ -323,7 +323,7 @@ export const AuthLoginCommand = cmd({
if (prompts.isCancel(provider)) throw new UI.CancelledError()
// Check if a plugin provides auth for this custom provider
- const customPlugin = await Plugin.list().then((x) => x.find((x) => x.auth?.provider === provider))
+ const customPlugin = await Plugin.list().then((x) => x.findLast((x) => x.auth?.provider === provider))
if (customPlugin && customPlugin.auth) {
const handled = await handlePluginAuth({ auth: customPlugin.auth }, provider)
if (handled) return
diff --git a/packages/opencode/test/plugin/auth-override.test.ts b/packages/opencode/test/plugin/auth-override.test.ts
new file mode 100644
index 0000000000..d8f8ea4551
--- /dev/null
+++ b/packages/opencode/test/plugin/auth-override.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, test } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { tmpdir } from "../fixture/fixture"
+import { Instance } from "../../src/project/instance"
+import { ProviderAuth } from "../../src/provider/auth"
+
+describe("plugin.auth-override", () => {
+ test("user plugin overrides built-in github-copilot auth", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ const pluginDir = path.join(dir, ".opencode", "plugin")
+ await fs.mkdir(pluginDir, { recursive: true })
+
+ await Bun.write(
+ path.join(pluginDir, "custom-copilot-auth.ts"),
+ [
+ "export default async () => ({",
+ " auth: {",
+ ' provider: "github-copilot",',
+ " methods: [",
+ ' { type: "api", label: "Test Override Auth" },',
+ " ],",
+ " loader: async () => ({ access: 'test-token' }),",
+ " },",
+ "})",
+ "",
+ ].join("\n"),
+ )
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const methods = await ProviderAuth.methods()
+ const copilot = methods["github-copilot"]
+ expect(copilot).toBeDefined()
+ expect(copilot.length).toBe(1)
+ expect(copilot[0].label).toBe("Test Override Auth")
+ },
+ })
+ }, 30000) // Increased timeout for plugin installation
+})
From 2c82e6c6ae2cd7472de3598c2bf3bb2a4e9560d2 Mon Sep 17 00:00:00 2001
From: Filip <34747899+neriousy@users.noreply.github.com>
Date: Sun, 1 Feb 2026 17:52:43 +0100
Subject: [PATCH 16/53] docs: prefer wsl over native windows stuff (#11637)
---
packages/web/astro.config.mjs | 1 +
packages/web/src/content/docs/index.mdx | 4 +
.../web/src/content/docs/troubleshooting.mdx | 6 +
packages/web/src/content/docs/web.mdx | 4 +
packages/web/src/content/docs/windows-wsl.mdx | 113 ++++++++++++++++++
5 files changed, 128 insertions(+)
create mode 100644 packages/web/src/content/docs/windows-wsl.mdx
diff --git a/packages/web/astro.config.mjs b/packages/web/astro.config.mjs
index eed1b87fd7..acaaf12bee 100644
--- a/packages/web/astro.config.mjs
+++ b/packages/web/astro.config.mjs
@@ -85,6 +85,7 @@ export default defineConfig({
"network",
"enterprise",
"troubleshooting",
+ "windows-wsl",
"1-0",
{
label: "Usage",
diff --git a/packages/web/src/content/docs/index.mdx b/packages/web/src/content/docs/index.mdx
index 8b3d3a9c82..bb3b8cb5d0 100644
--- a/packages/web/src/content/docs/index.mdx
+++ b/packages/web/src/content/docs/index.mdx
@@ -89,6 +89,10 @@ You can also install it with the following commands:
#### Windows
+:::tip[Recommended: Use WSL]
+For the best experience on Windows, we recommend using [Windows Subsystem for Linux (WSL)](/docs/windows-wsl). It provides better performance and full compatibility with OpenCode's features.
+:::
+
- **Using Chocolatey**
```bash
diff --git a/packages/web/src/content/docs/troubleshooting.mdx b/packages/web/src/content/docs/troubleshooting.mdx
index 7137d88fae..40ac70b9eb 100644
--- a/packages/web/src/content/docs/troubleshooting.mdx
+++ b/packages/web/src/content/docs/troubleshooting.mdx
@@ -136,6 +136,12 @@ On Windows, OpenCode Desktop requires the Microsoft Edge **WebView2 Runtime**. I
---
+### Windows: General performance issues
+
+If you're experiencing slow performance, file access issues, or terminal problems on Windows, try using [WSL (Windows Subsystem for Linux)](/docs/windows-wsl). WSL provides a Linux environment that works more seamlessly with OpenCode's features.
+
+---
+
### Notifications not showing
OpenCode Desktop only shows system notifications when:
diff --git a/packages/web/src/content/docs/web.mdx b/packages/web/src/content/docs/web.mdx
index fa3d071090..1013712f3a 100644
--- a/packages/web/src/content/docs/web.mdx
+++ b/packages/web/src/content/docs/web.mdx
@@ -21,6 +21,10 @@ This starts a local server on `127.0.0.1` with a random available port and autom
If `OPENCODE_SERVER_PASSWORD` is not set, the server will be unsecured. This is fine for local use but should be set for network access.
:::
+:::tip[Windows Users]
+For the best experience, run `opencode web` from [WSL](/docs/windows-wsl) rather than PowerShell. This ensures proper file system access and terminal integration.
+:::
+
---
## Configuration
diff --git a/packages/web/src/content/docs/windows-wsl.mdx b/packages/web/src/content/docs/windows-wsl.mdx
new file mode 100644
index 0000000000..ebc35d0d9e
--- /dev/null
+++ b/packages/web/src/content/docs/windows-wsl.mdx
@@ -0,0 +1,113 @@
+---
+title: Windows (WSL)
+description: Run OpenCode on Windows using WSL for the best experience.
+---
+
+import { Steps } from "@astrojs/starlight/components"
+
+While OpenCode can run directly on Windows, we recommend using [Windows Subsystem for Linux (WSL)](https://learn.microsoft.com/en-us/windows/wsl/install) for the best experience. WSL provides a Linux environment that works seamlessly with OpenCode's features.
+
+:::tip[Why WSL?]
+WSL offers better file system performance, full terminal support, and compatibility with development tools that OpenCode relies on.
+:::
+
+---
+
+## Setup
+
+
+
+1. **Install WSL**
+
+ If you haven't already, [install WSL](https://learn.microsoft.com/en-us/windows/wsl/install) using the official Microsoft guide.
+
+2. **Install OpenCode in WSL**
+
+ Once WSL is set up, open your WSL terminal and install OpenCode using one of the [installation methods](/docs/).
+
+ ```bash
+ curl -fsSL https://opencode.ai/install | bash
+ ```
+
+3. **Use OpenCode from WSL**
+
+ Navigate to your project directory (access Windows files via `/mnt/c/`, `/mnt/d/`, etc.) and run OpenCode.
+
+ ```bash
+ cd /mnt/c/Users/YourName/project
+ opencode
+ ```
+
+
+
+---
+
+## Desktop App + WSL Server
+
+If you prefer using the OpenCode Desktop app but want to run the server in WSL:
+
+1. **Start the server in WSL** with `--hostname 0.0.0.0` to allow external connections:
+
+ ```bash
+ opencode serve --hostname 0.0.0.0 --port 4096
+ ```
+
+2. **Connect the Desktop app** to `http://localhost:4096`
+
+:::note
+If `localhost` does not work in your setup, connect using the WSL IP address instead (from WSL: `hostname -I`) and use `http://:4096`.
+:::
+
+:::caution
+When using `--hostname 0.0.0.0`, set `OPENCODE_SERVER_PASSWORD` to secure the server.
+
+```bash
+OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0
+```
+
+:::
+
+---
+
+## Web Client + WSL
+
+For the best web experience on Windows:
+
+1. **Run `opencode web` in the WSL terminal** rather than PowerShell:
+
+ ```bash
+ opencode web --hostname 0.0.0.0
+ ```
+
+2. **Access from your Windows browser** at `http://localhost:` (OpenCode prints the URL)
+
+Running `opencode web` from WSL ensures proper file system access and terminal integration while still being accessible from your Windows browser.
+
+---
+
+## Accessing Windows Files
+
+WSL can access all your Windows files through the `/mnt/` directory:
+
+- `C:` drive → `/mnt/c/`
+- `D:` drive → `/mnt/d/`
+- And so on...
+
+Example:
+
+```bash
+cd /mnt/c/Users/YourName/Documents/project
+opencode
+```
+
+:::tip
+For the smoothest experience, consider cloning/copying your repo into the WSL filesystem (for example under `~/code/`) and running OpenCode there.
+:::
+
+---
+
+## Tips
+
+- Keep OpenCode running in WSL for projects stored on Windows drives - file access is seamless
+- Use VS Code's [WSL extension](https://code.visualstudio.com/docs/remote/wsl) alongside OpenCode for an integrated development workflow
+- Your OpenCode config and sessions are stored within the WSL environment at `~/.local/share/opencode/`
From 1798af72b04b82c1a93ceec251e29188f09d5f63 Mon Sep 17 00:00:00 2001
From: Axel Sarmiento Mrak <96851183+AxelMrak@users.noreply.github.com>
Date: Sun, 1 Feb 2026 15:19:37 -0300
Subject: [PATCH 17/53] fix(ecosystem): fix link Daytona (#11621)
---
packages/web/src/content/docs/ecosystem.mdx | 64 ++++++++++-----------
1 file changed, 32 insertions(+), 32 deletions(-)
diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx
index 07110dc1b5..9f84c6af17 100644
--- a/packages/web/src/content/docs/ecosystem.mdx
+++ b/packages/web/src/content/docs/ecosystem.mdx
@@ -15,38 +15,38 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw
## Plugins
-| Name | Description |
-| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
-| [opencode-daytona](https://github.com/jamesmurdza/daytona/tree/main/libs/opencode-plugin) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews |
-| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
-| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
-| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
-| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
-| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
-| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports |
-| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
-| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
-| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
-| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
-| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
-| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
-| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
-| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
-| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
-| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
-| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events |
-| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
-| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection |
-| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory |
-| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing |
-| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control |
-| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax |
-| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity |
-| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms |
-| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence |
-| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Native OS notifications for OpenCode – know when tasks complete |
-| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install |
-| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode |
+| Name | Description |
+| --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
+| [opencode-daytona](https://github.com/jamesmurdza/daytona/blob/main/guides/typescript/opencode/README.md) | Automatically run OpenCode sessions in isolated Daytona sandboxes with git sync and live previews |
+| [opencode-helicone-session](https://github.com/H2Shami/opencode-helicone-session) | Automatically inject Helicone session headers for request grouping |
+| [opencode-type-inject](https://github.com/nick-vi/opencode-type-inject) | Auto-inject TypeScript/Svelte types into file reads with lookup tools |
+| [opencode-openai-codex-auth](https://github.com/numman-ali/opencode-openai-codex-auth) | Use your ChatGPT Plus/Pro subscription instead of API credits |
+| [opencode-gemini-auth](https://github.com/jenslys/opencode-gemini-auth) | Use your existing Gemini plan instead of API billing |
+| [opencode-antigravity-auth](https://github.com/NoeFabris/opencode-antigravity-auth) | Use Antigravity's free models instead of API billing |
+| [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) | Multi-branch devcontainer isolation with shallow clones and auto-assigned ports |
+| [opencode-google-antigravity-auth](https://github.com/shekohex/opencode-google-antigravity-auth) | Google Antigravity OAuth Plugin, with support for Google Search, and more robust API handling |
+| [opencode-dynamic-context-pruning](https://github.com/Tarquinen/opencode-dynamic-context-pruning) | Optimize token usage by pruning obsolete tool outputs |
+| [opencode-websearch-cited](https://github.com/ghoulr/opencode-websearch-cited.git) | Add native websearch support for supported providers with Google grounded style |
+| [opencode-pty](https://github.com/shekohex/opencode-pty.git) | Enables AI agents to run background processes in a PTY, send interactive input to them. |
+| [opencode-shell-strategy](https://github.com/JRedeker/opencode-shell-strategy) | Instructions for non-interactive shell commands - prevents hangs from TTY-dependent operations |
+| [opencode-wakatime](https://github.com/angristan/opencode-wakatime) | Track OpenCode usage with Wakatime |
+| [opencode-md-table-formatter](https://github.com/franlol/opencode-md-table-formatter/tree/main) | Clean up markdown tables produced by LLMs |
+| [opencode-morph-fast-apply](https://github.com/JRedeker/opencode-morph-fast-apply) | 10x faster code editing with Morph Fast Apply API and lazy edit markers |
+| [oh-my-opencode](https://github.com/code-yeongyu/oh-my-opencode) | Background agents, pre-built LSP/AST/MCP tools, curated agents, Claude Code compatible |
+| [opencode-notificator](https://github.com/panta82/opencode-notificator) | Desktop notifications and sound alerts for OpenCode sessions |
+| [opencode-notifier](https://github.com/mohak34/opencode-notifier) | Desktop notifications and sound alerts for permission, completion, and error events |
+| [opencode-zellij-namer](https://github.com/24601/opencode-zellij-namer) | AI-powered automatic Zellij session naming based on OpenCode context |
+| [opencode-skillful](https://github.com/zenobi-us/opencode-skillful) | Allow OpenCode agents to lazy load prompts on demand with skill discovery and injection |
+| [opencode-supermemory](https://github.com/supermemoryai/opencode-supermemory) | Persistent memory across sessions using Supermemory |
+| [@plannotator/opencode](https://github.com/backnotprop/plannotator/tree/main/apps/opencode-plugin) | Interactive plan review with visual annotation and private/offline sharing |
+| [@openspoon/subtask2](https://github.com/spoons-and-mirrors/subtask2) | Extend opencode /commands into a powerful orchestration system with granular flow control |
+| [opencode-scheduler](https://github.com/different-ai/opencode-scheduler) | Schedule recurring jobs using launchd (Mac) or systemd (Linux) with cron syntax |
+| [micode](https://github.com/vtemian/micode) | Structured Brainstorm → Plan → Implement workflow with session continuity |
+| [octto](https://github.com/vtemian/octto) | Interactive browser UI for AI brainstorming with multi-question forms |
+| [opencode-background-agents](https://github.com/kdcokenny/opencode-background-agents) | Claude Code-style background agents with async delegation and context persistence |
+| [opencode-notify](https://github.com/kdcokenny/opencode-notify) | Native OS notifications for OpenCode – know when tasks complete |
+| [opencode-workspace](https://github.com/kdcokenny/opencode-workspace) | Bundled multi-agent orchestration harness – 16 components, one install |
+| [opencode-worktree](https://github.com/kdcokenny/opencode-worktree) | Zero-friction git worktrees for OpenCode |
---
From cc1d3732bc610b7dcd9a16ef98be1bd7738b3811 Mon Sep 17 00:00:00 2001
From: zerone0x
Date: Mon, 2 Feb 2026 03:11:18 +0800
Subject: [PATCH 18/53] fix(tui): remove outer backtick wrapper in session
transcript tool formatting (#11566)
Co-authored-by: Claude
---
.../src/cli/cmd/tui/util/transcript.ts | 10 +++----
.../opencode/test/cli/tui/transcript.test.ts | 29 +++++++++++++++++--
2 files changed, 32 insertions(+), 7 deletions(-)
diff --git a/packages/opencode/src/cli/cmd/tui/util/transcript.ts b/packages/opencode/src/cli/cmd/tui/util/transcript.ts
index 8f986c3379..420c9dde1b 100644
--- a/packages/opencode/src/cli/cmd/tui/util/transcript.ts
+++ b/packages/opencode/src/cli/cmd/tui/util/transcript.ts
@@ -80,17 +80,17 @@ export function formatPart(part: Part, options: TranscriptOptions): string {
}
if (part.type === "tool") {
- let result = `\`\`\`\nTool: ${part.tool}\n`
+ let result = `**Tool: ${part.tool}**\n`
if (options.toolDetails && part.state.input) {
- result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\``
+ result += `\n**Input:**\n\`\`\`json\n${JSON.stringify(part.state.input, null, 2)}\n\`\`\`\n`
}
if (options.toolDetails && part.state.status === "completed" && part.state.output) {
- result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\``
+ result += `\n**Output:**\n\`\`\`\n${part.state.output}\n\`\`\`\n`
}
if (options.toolDetails && part.state.status === "error" && part.state.error) {
- result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\``
+ result += `\n**Error:**\n\`\`\`\n${part.state.error}\n\`\`\`\n`
}
- result += `\n\`\`\`\n\n`
+ result += `\n`
return result
}
diff --git a/packages/opencode/test/cli/tui/transcript.test.ts b/packages/opencode/test/cli/tui/transcript.test.ts
index 2cb29e1a89..7a5fa6b8f1 100644
--- a/packages/opencode/test/cli/tui/transcript.test.ts
+++ b/packages/opencode/test/cli/tui/transcript.test.ts
@@ -119,13 +119,38 @@ describe("transcript", () => {
},
}
const result = formatPart(part, options)
- expect(result).toContain("Tool: bash")
+ expect(result).toContain("**Tool: bash**")
expect(result).toContain("**Input:**")
expect(result).toContain('"command": "ls"')
expect(result).toContain("**Output:**")
expect(result).toContain("file1.txt")
})
+ test("formats tool output containing triple backticks without breaking markdown", () => {
+ const part: Part = {
+ id: "part_1",
+ sessionID: "ses_123",
+ messageID: "msg_123",
+ type: "tool",
+ callID: "call_1",
+ tool: "bash",
+ state: {
+ status: "completed",
+ input: { command: "echo '```hello```'" },
+ output: "```hello```",
+ title: "Echo backticks",
+ metadata: {},
+ time: { start: 1000, end: 1100 },
+ },
+ }
+ const result = formatPart(part, options)
+ // The tool header should not be inside a code block
+ expect(result).toStartWith("**Tool: bash**\n")
+ // Input and output should each be in their own code blocks
+ expect(result).toContain("**Input:**\n```json")
+ expect(result).toContain("**Output:**\n```\n```hello```\n```")
+ })
+
test("formats tool part without details when disabled", () => {
const part: Part = {
id: "part_1",
@@ -144,7 +169,7 @@ describe("transcript", () => {
},
}
const result = formatPart(part, { ...options, toolDetails: false })
- expect(result).toContain("Tool: bash")
+ expect(result).toContain("**Tool: bash**")
expect(result).not.toContain("**Input:**")
expect(result).not.toContain("**Output:**")
})
From eace76e525d6552eee6378912172d18a36a8f09a Mon Sep 17 00:00:00 2001
From: Desmond Sow
Date: Mon, 2 Feb 2026 03:14:34 +0800
Subject: [PATCH 19/53] fix: opencode hanging when using client.app.log()
during initialization (#11642)
---
packages/opencode/src/server/server.ts | 15 +++++++++------
1 file changed, 9 insertions(+), 6 deletions(-)
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 1d832d77a2..f6dd0d122f 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -185,12 +185,15 @@ export namespace Server {
},
)
.use(async (c, next) => {
- let directory = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
- try {
- directory = decodeURIComponent(directory)
- } catch {
- // fallback to original value
- }
+ if (c.req.path === "/log") return next()
+ const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
+ const directory = (() => {
+ try {
+ return decodeURIComponent(raw)
+ } catch {
+ return raw
+ }
+ })()
return Instance.provide({
directory,
init: InstanceBootstrap,
From 16145af480a520906b0659a85e9a871781b69089 Mon Sep 17 00:00:00 2001
From: YeonGyu-Kim
Date: Mon, 2 Feb 2026 04:46:08 +0900
Subject: [PATCH 20/53] fix: prevent duplicate AGENTS.md injection when reading
instruction files (#11581)
Co-authored-by: Aiden Cline
---
packages/opencode/src/session/instruction.ts | 17 ++++++++++------
.../opencode/test/session/instruction.test.ts | 20 +++++++++++++++++++
2 files changed, 31 insertions(+), 6 deletions(-)
diff --git a/packages/opencode/src/session/instruction.ts b/packages/opencode/src/session/instruction.ts
index 723439a3fd..65ca1e9bb2 100644
--- a/packages/opencode/src/session/instruction.ts
+++ b/packages/opencode/src/session/instruction.ts
@@ -75,7 +75,9 @@ export namespace InstructionPrompt {
for (const file of FILES) {
const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) {
- matches.forEach((p) => paths.add(path.resolve(p)))
+ matches.forEach((p) => {
+ paths.add(path.resolve(p))
+ })
break
}
}
@@ -103,7 +105,9 @@ export namespace InstructionPrompt {
}),
).catch(() => [])
: await resolveRelative(instruction)
- matches.forEach((p) => paths.add(path.resolve(p)))
+ matches.forEach((p) => {
+ paths.add(path.resolve(p))
+ })
}
}
@@ -168,12 +172,14 @@ export namespace InstructionPrompt {
const already = loaded(messages)
const results: { filepath: string; content: string }[] = []
- let current = path.dirname(path.resolve(filepath))
+ const target = path.resolve(filepath)
+ let current = path.dirname(target)
const root = path.resolve(Instance.directory)
- while (current.startsWith(root)) {
+ while (current.startsWith(root) && current !== root) {
const found = await find(current)
- if (found && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
+
+ if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
claim(messageID, found)
const content = await Bun.file(found)
.text()
@@ -182,7 +188,6 @@ export namespace InstructionPrompt {
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
}
}
- if (current === root) break
current = path.dirname(current)
}
diff --git a/packages/opencode/test/session/instruction.test.ts b/packages/opencode/test/session/instruction.test.ts
index 67719fa339..4d57e92a25 100644
--- a/packages/opencode/test/session/instruction.test.ts
+++ b/packages/opencode/test/session/instruction.test.ts
@@ -47,4 +47,24 @@ describe("InstructionPrompt.resolve", () => {
},
})
})
+
+ test("doesn't reload AGENTS.md when reading it directly", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await Bun.write(path.join(dir, "subdir", "AGENTS.md"), "# Subdir Instructions")
+ await Bun.write(path.join(dir, "subdir", "nested", "file.ts"), "const x = 1")
+ },
+ })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
+ const system = await InstructionPrompt.systemPaths()
+ expect(system.has(filepath)).toBe(false)
+
+ const results = await InstructionPrompt.resolve([], filepath, "test-message-2")
+ expect(results).toEqual([])
+ },
+ })
+ })
})
From f15755684f6de98f9953b5c1a04bac3202cb0e20 Mon Sep 17 00:00:00 2001
From: neavo
Date: Mon, 2 Feb 2026 04:12:30 +0800
Subject: [PATCH 21/53] fix(opencode): scope agent variant to model (#11410)
---
packages/opencode/src/agent/agent.ts | 2 +
packages/opencode/src/config/config.ts | 5 ++
packages/opencode/src/session/prompt.ts | 15 ++++-
packages/opencode/test/config/config.test.ts | 31 ++++++++++
.../test/session/prompt-variant.test.ts | 60 +++++++++++++++++++
5 files changed, 111 insertions(+), 2 deletions(-)
create mode 100644 packages/opencode/test/session/prompt-variant.test.ts
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index 1d90a4c365..72e7f8985d 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -37,6 +37,7 @@ export namespace Agent {
providerID: z.string(),
})
.optional(),
+ variant: z.string().optional(),
prompt: z.string().optional(),
options: z.record(z.string(), z.any()),
steps: z.number().int().positive().optional(),
@@ -214,6 +215,7 @@ export namespace Agent {
native: false,
}
if (value.model) item.model = Provider.parseModel(value.model)
+ item.variant = value.variant ?? item.variant
item.prompt = value.prompt ?? item.prompt
item.description = value.description ?? item.description
item.temperature = value.temperature ?? item.temperature
diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts
index 7969e30795..98970ba392 100644
--- a/packages/opencode/src/config/config.ts
+++ b/packages/opencode/src/config/config.ts
@@ -593,6 +593,10 @@ export namespace Config {
export const Agent = z
.object({
model: z.string().optional(),
+ variant: z
+ .string()
+ .optional()
+ .describe("Default model variant for this agent (applies only when using the agent's configured model)."),
temperature: z.number().optional(),
top_p: z.number().optional(),
prompt: z.string().optional(),
@@ -624,6 +628,7 @@ export namespace Config {
const knownKeys = new Set([
"name",
"model",
+ "variant",
"prompt",
"description",
"temperature",
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 94eabdef7f..ba77cd7ca3 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -827,6 +827,17 @@ export namespace SessionPrompt {
async function createUserMessage(input: PromptInput) {
const agent = await Agent.get(input.agent ?? (await Agent.defaultAgent()))
+
+ const model = input.model ?? agent.model ?? (await lastModel(input.sessionID))
+ const variant =
+ input.variant ??
+ (agent.variant &&
+ agent.model &&
+ model.providerID === agent.model.providerID &&
+ model.modelID === agent.model.modelID
+ ? agent.variant
+ : undefined)
+
const info: MessageV2.Info = {
id: input.messageID ?? Identifier.ascending("message"),
role: "user",
@@ -836,9 +847,9 @@ export namespace SessionPrompt {
},
tools: input.tools,
agent: agent.name,
- model: input.model ?? agent.model ?? (await lastModel(input.sessionID)),
+ model,
system: input.system,
- variant: input.variant,
+ variant,
}
using _ = defer(() => InstructionPrompt.clear(info.id))
diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts
index 1752e22e01..8611d82969 100644
--- a/packages/opencode/test/config/config.test.ts
+++ b/packages/opencode/test/config/config.test.ts
@@ -255,6 +255,37 @@ test("handles agent configuration", async () => {
})
})
+test("treats agent variant as model-scoped setting (not provider option)", async () => {
+ await using tmp = await tmpdir({
+ init: async (dir) => {
+ await writeConfig(dir, {
+ $schema: "https://opencode.ai/config.json",
+ agent: {
+ test_agent: {
+ model: "openai/gpt-5.2",
+ variant: "xhigh",
+ max_tokens: 123,
+ },
+ },
+ })
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const config = await Config.get()
+ const agent = config.agent?.["test_agent"]
+
+ expect(agent?.variant).toBe("xhigh")
+ expect(agent?.options).toMatchObject({
+ max_tokens: 123,
+ })
+ expect(agent?.options).not.toHaveProperty("variant")
+ },
+ })
+})
+
test("handles command configuration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
diff --git a/packages/opencode/test/session/prompt-variant.test.ts b/packages/opencode/test/session/prompt-variant.test.ts
new file mode 100644
index 0000000000..16e8a22444
--- /dev/null
+++ b/packages/opencode/test/session/prompt-variant.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, test } from "bun:test"
+import { Instance } from "../../src/project/instance"
+import { Session } from "../../src/session"
+import { SessionPrompt } from "../../src/session/prompt"
+import { tmpdir } from "../fixture/fixture"
+
+describe("session.prompt agent variant", () => {
+ test("applies agent variant only when using agent model", async () => {
+ await using tmp = await tmpdir({
+ git: true,
+ config: {
+ agent: {
+ build: {
+ model: "openai/gpt-5.2",
+ variant: "xhigh",
+ },
+ },
+ },
+ })
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const session = await Session.create({})
+
+ const other = await SessionPrompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ model: { providerID: "opencode", modelID: "kimi-k2.5-free" },
+ noReply: true,
+ parts: [{ type: "text", text: "hello" }],
+ })
+ if (other.info.role !== "user") throw new Error("expected user message")
+ expect(other.info.variant).toBeUndefined()
+
+ const match = await SessionPrompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ noReply: true,
+ parts: [{ type: "text", text: "hello again" }],
+ })
+ if (match.info.role !== "user") throw new Error("expected user message")
+ expect(match.info.model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
+ expect(match.info.variant).toBe("xhigh")
+
+ const override = await SessionPrompt.prompt({
+ sessionID: session.id,
+ agent: "build",
+ noReply: true,
+ variant: "high",
+ parts: [{ type: "text", text: "hello third" }],
+ })
+ if (override.info.role !== "user") throw new Error("expected user message")
+ expect(override.info.variant).toBe("high")
+
+ await Session.remove(session.id)
+ },
+ })
+ })
+})
From d29dfe31e4e3577b1ad9644d834f1377b82691db Mon Sep 17 00:00:00 2001
From: Caleb Norton
Date: Sun, 1 Feb 2026 14:12:54 -0600
Subject: [PATCH 22/53] chore: reduce nix fetching (#11660)
---
nix/hashes.json | 4 ++--
nix/node_modules.nix | 7 ++++---
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/nix/hashes.json b/nix/hashes.json
index b48e4cb0da..654f0296e0 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
{
"nodeModules": {
- "x86_64-linux": "sha256-3wRTDLo5FZoUc2Bwm1aAJZ4dNsekX8XoY6TwTmohgYo=",
+ "x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=",
"aarch64-linux": "sha256-CKiuc6c52UV9cLEtccYEYS4QN0jYzNJv1fHSayqbHKo=",
- "aarch64-darwin": "sha256-jGr2udrVeseioMWpIzpjYFfS1CN8GvNFwo6o92Aa5Oc=",
+ "aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=",
"x86_64-darwin": "sha256-k5384Uun7tLjKkfJXXPcaZSXQ5jf/tMv21xi5cJU1rM="
}
}
diff --git a/nix/node_modules.nix b/nix/node_modules.nix
index 6d75b9e750..836ef02a56 100644
--- a/nix/node_modules.nix
+++ b/nix/node_modules.nix
@@ -46,15 +46,16 @@ stdenvNoCC.mkDerivation {
buildPhase = ''
runHook preBuild
- export HOME=$(mktemp -d)
export BUN_INSTALL_CACHE_DIR=$(mktemp -d)
bun install \
--cpu="${bunCpu}" \
--os="${bunOs}" \
+ --filter '!./' \
+ --filter './packages/opencode' \
+ --filter './packages/desktop' \
--frozen-lockfile \
--ignore-scripts \
- --no-progress \
- --linker=isolated
+ --no-progress
bun --bun ${./scripts/canonicalize-node-modules.ts}
bun --bun ${./scripts/normalize-bun-binaries.ts}
runHook postBuild
From e62a15d421e6bfafac6151f152e018a75015a3f0 Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
Date: Sun, 1 Feb 2026 20:13:33 +0000
Subject: [PATCH 23/53] chore: generate
---
packages/sdk/js/src/v2/gen/types.gen.ts | 5 +++++
packages/sdk/openapi.json | 7 +++++++
2 files changed, 12 insertions(+)
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index e992b27bd5..0556e1ad94 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1371,6 +1371,10 @@ export type PermissionConfig =
export type AgentConfig = {
model?: string
+ /**
+ * Default model variant for this agent (applies only when using the agent's configured model).
+ */
+ variant?: string
temperature?: number
top_p?: number
prompt?: string
@@ -2136,6 +2140,7 @@ export type Agent = {
modelID: string
providerID: string
}
+ variant?: string
prompt?: string
options: {
[key: string]: unknown
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index f383ffbba8..95bca32303 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -9017,6 +9017,10 @@
"model": {
"type": "string"
},
+ "variant": {
+ "description": "Default model variant for this agent (applies only when using the agent's configured model).",
+ "type": "string"
+ },
"temperature": {
"type": "number"
},
@@ -10842,6 +10846,9 @@
},
"required": ["modelID", "providerID"]
},
+ "variant": {
+ "type": "string"
+ },
"prompt": {
"type": "string"
},
From 01cec84789235c4cc9db65953bfa358f43b13888 Mon Sep 17 00:00:00 2001
From: Caleb Norton
Date: Sun, 1 Feb 2026 14:24:09 -0600
Subject: [PATCH 24/53] fix(desktop): nix - add missing dep (#11656)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
---
nix/desktop.nix | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/nix/desktop.nix b/nix/desktop.nix
index 9625f75c27..efdc2bd72e 100644
--- a/nix/desktop.nix
+++ b/nix/desktop.nix
@@ -45,8 +45,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
rustc
jq
makeWrapper
- ]
- ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
+ ] ++ lib.optionals stdenv.hostPlatform.isLinux [ wrapGAppsHook4 ];
buildInputs = lib.optionals stdenv.isLinux [
dbus
@@ -61,6 +60,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
gst_all_1.gstreamer
gst_all_1.gst-plugins-base
gst_all_1.gst-plugins-good
+ gst_all_1.gst-plugins-bad
];
strictDeps = true;
@@ -97,4 +97,4 @@ rustPlatform.buildRustPackage (finalAttrs: {
mainProgram = "opencode-desktop";
inherit (opencode.meta) platforms;
};
-})
\ No newline at end of file
+})
From ca5e85d6eab37e8f82ece19dcc482752acbe2ad5 Mon Sep 17 00:00:00 2001
From: Rohan Godha
Date: Sun, 1 Feb 2026 15:42:42 -0500
Subject: [PATCH 25/53] fix: prompt caching for opus on bedrock (#11664)
---
packages/opencode/src/provider/transform.ts | 5 +++--
packages/opencode/test/provider/transform.test.ts | 2 +-
2 files changed, 4 insertions(+), 3 deletions(-)
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index f5fe419db9..68220b90c1 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -179,7 +179,7 @@ export namespace ProviderTransform {
cacheControl: { type: "ephemeral" },
},
bedrock: {
- cachePoint: { type: "ephemeral" },
+ cachePoint: { type: "default" },
},
openaiCompatible: {
cache_control: { type: "ephemeral" },
@@ -190,7 +190,8 @@ export namespace ProviderTransform {
}
for (const msg of unique([...system, ...final])) {
- const shouldUseContentOptions = providerID !== "anthropic" && Array.isArray(msg.content) && msg.content.length > 0
+ const useMessageLevelOptions = providerID === "anthropic" || providerID.includes("bedrock")
+ const shouldUseContentOptions = !useMessageLevelOptions && Array.isArray(msg.content) && msg.content.length > 0
if (shouldUseContentOptions) {
const lastContent = msg.content[msg.content.length - 1]
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index 0973e61585..cbb9ddbc41 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -1166,7 +1166,7 @@ describe("ProviderTransform.message - claude w/bedrock custom inference profile"
expect(result[0].providerOptions?.bedrock).toEqual(
expect.objectContaining({
cachePoint: {
- type: "ephemeral",
+ type: "default",
},
}),
)
From d52ee41b3aa2148c58d94bda75e83654dd38892a Mon Sep 17 00:00:00 2001
From: "opencode-agent[bot]"
Date: Sun, 1 Feb 2026 20:48:33 +0000
Subject: [PATCH 26/53] chore: update nix node_modules hashes
---
nix/hashes.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/nix/hashes.json b/nix/hashes.json
index 654f0296e0..6fe8f61d3d 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-06Otz3loT4vn0578VDxUqVudtzQvV7oM3EIzjZnsejo=",
- "aarch64-linux": "sha256-CKiuc6c52UV9cLEtccYEYS4QN0jYzNJv1fHSayqbHKo=",
+ "aarch64-linux": "sha256-88Qai5RkSenCZkakOg52b6xU2ok+h/Ns4/5L3+55sFY=",
"aarch64-darwin": "sha256-x8dgCF0CJBWi2dZLDHMGdlTqys1X755ok0PM6x0HAGo=",
- "x86_64-darwin": "sha256-k5384Uun7tLjKkfJXXPcaZSXQ5jf/tMv21xi5cJU1rM="
+ "x86_64-darwin": "sha256-FkLDqorfIfOw+tB7SW5vgyhOIoI0IV9lqPW1iEmvUiI="
}
}
From f6948d0ffaa4b9d7e727bf2963c943563d72788f Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Sun, 1 Feb 2026 14:50:49 -0600
Subject: [PATCH 27/53] fix: variant logic for anthropic models through openai
compat endpoint (#11665)
---
packages/opencode/src/provider/transform.ts | 41 +-----
.../opencode/test/provider/transform.test.ts | 131 ------------------
2 files changed, 2 insertions(+), 170 deletions(-)
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index 68220b90c1..ded416e66d 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -395,31 +395,6 @@ export namespace ProviderTransform {
case "@ai-sdk/deepinfra":
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/deepinfra
case "@ai-sdk/openai-compatible":
- // When using openai-compatible SDK with Claude/Anthropic models,
- // we must use snake_case (budget_tokens) as the SDK doesn't convert parameter names
- // and the OpenAI-compatible API spec uses snake_case
- if (
- model.providerID === "anthropic" ||
- model.api.id.includes("anthropic") ||
- model.api.id.includes("claude") ||
- model.id.includes("anthropic") ||
- model.id.includes("claude")
- ) {
- return {
- high: {
- thinking: {
- type: "enabled",
- budget_tokens: 16000,
- },
- },
- max: {
- thinking: {
- type: "enabled",
- budget_tokens: 31999,
- },
- },
- }
- }
return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { reasoningEffort: effort }]))
case "@ai-sdk/azure":
@@ -719,21 +694,9 @@ export namespace ProviderTransform {
const modelCap = modelLimit || globalLimit
const standardLimit = Math.min(modelCap, globalLimit)
- // Handle thinking mode for @ai-sdk/anthropic, @ai-sdk/google-vertex/anthropic (budgetTokens)
- // and @ai-sdk/openai-compatible with Claude (budget_tokens)
- if (
- npm === "@ai-sdk/anthropic" ||
- npm === "@ai-sdk/google-vertex/anthropic" ||
- npm === "@ai-sdk/openai-compatible"
- ) {
+ if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
const thinking = options?.["thinking"]
- // Support both camelCase (for @ai-sdk/anthropic) and snake_case (for openai-compatible)
- const budgetTokens =
- typeof thinking?.["budgetTokens"] === "number"
- ? thinking["budgetTokens"]
- : typeof thinking?.["budget_tokens"] === "number"
- ? thinking["budget_tokens"]
- : 0
+ const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0
const enabled = thinking?.["type"] === "enabled"
if (enabled && budgetTokens > 0) {
// Return text tokens so that text + thinking <= model cap, preferring 32k text when possible.
diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts
index cbb9ddbc41..8e28f1209e 100644
--- a/packages/opencode/test/provider/transform.test.ts
+++ b/packages/opencode/test/provider/transform.test.ts
@@ -267,76 +267,6 @@ describe("ProviderTransform.maxOutputTokens", () => {
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
})
-
- describe("openai-compatible with thinking options (snake_case)", () => {
- test("returns 32k when budget_tokens + 32k <= modelLimit", () => {
- const modelLimit = 100000
- const options = {
- thinking: {
- type: "enabled",
- budget_tokens: 10000,
- },
- }
- const result = ProviderTransform.maxOutputTokens(
- "@ai-sdk/openai-compatible",
- options,
- modelLimit,
- OUTPUT_TOKEN_MAX,
- )
- expect(result).toBe(OUTPUT_TOKEN_MAX)
- })
-
- test("returns modelLimit - budget_tokens when budget_tokens + 32k > modelLimit", () => {
- const modelLimit = 50000
- const options = {
- thinking: {
- type: "enabled",
- budget_tokens: 30000,
- },
- }
- const result = ProviderTransform.maxOutputTokens(
- "@ai-sdk/openai-compatible",
- options,
- modelLimit,
- OUTPUT_TOKEN_MAX,
- )
- expect(result).toBe(20000)
- })
-
- test("returns 32k when thinking type is not enabled", () => {
- const modelLimit = 100000
- const options = {
- thinking: {
- type: "disabled",
- budget_tokens: 10000,
- },
- }
- const result = ProviderTransform.maxOutputTokens(
- "@ai-sdk/openai-compatible",
- options,
- modelLimit,
- OUTPUT_TOKEN_MAX,
- )
- expect(result).toBe(OUTPUT_TOKEN_MAX)
- })
-
- test("returns 32k when budget_tokens is 0", () => {
- const modelLimit = 100000
- const options = {
- thinking: {
- type: "enabled",
- budget_tokens: 0,
- },
- }
- const result = ProviderTransform.maxOutputTokens(
- "@ai-sdk/openai-compatible",
- options,
- modelLimit,
- OUTPUT_TOKEN_MAX,
- )
- expect(result).toBe(OUTPUT_TOKEN_MAX)
- })
- })
})
describe("ProviderTransform.schema - gemini array items", () => {
@@ -1564,67 +1494,6 @@ describe("ProviderTransform.variants", () => {
expect(result.low).toEqual({ reasoningEffort: "low" })
expect(result.high).toEqual({ reasoningEffort: "high" })
})
-
- test("Claude via LiteLLM returns thinking with snake_case budget_tokens", () => {
- const model = createMockModel({
- id: "anthropic/claude-sonnet-4-5",
- providerID: "anthropic",
- api: {
- id: "claude-sonnet-4-5-20250929",
- url: "http://localhost:4000",
- npm: "@ai-sdk/openai-compatible",
- },
- })
- const result = ProviderTransform.variants(model)
- expect(Object.keys(result)).toEqual(["high", "max"])
- expect(result.high).toEqual({
- thinking: {
- type: "enabled",
- budget_tokens: 16000,
- },
- })
- expect(result.max).toEqual({
- thinking: {
- type: "enabled",
- budget_tokens: 31999,
- },
- })
- })
-
- test("Claude model (by model.id) via openai-compatible uses snake_case", () => {
- const model = createMockModel({
- id: "litellm/claude-3-opus",
- providerID: "litellm",
- api: {
- id: "claude-3-opus-20240229",
- url: "http://localhost:4000",
- npm: "@ai-sdk/openai-compatible",
- },
- })
- const result = ProviderTransform.variants(model)
- expect(Object.keys(result)).toEqual(["high", "max"])
- expect(result.high).toEqual({
- thinking: {
- type: "enabled",
- budget_tokens: 16000,
- },
- })
- })
-
- test("Anthropic model (by model.api.id) via openai-compatible uses snake_case", () => {
- const model = createMockModel({
- id: "custom/my-model",
- providerID: "custom",
- api: {
- id: "anthropic.claude-sonnet",
- url: "http://localhost:4000",
- npm: "@ai-sdk/openai-compatible",
- },
- })
- const result = ProviderTransform.variants(model)
- expect(Object.keys(result)).toEqual(["high", "max"])
- expect(result.high.thinking.budget_tokens).toBe(16000)
- })
})
describe("@ai-sdk/azure", () => {
From ec720145fafdcd927e4aa4baa5521798f7f5e99d Mon Sep 17 00:00:00 2001
From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Date: Sun, 1 Feb 2026 14:57:47 -0600
Subject: [PATCH 28/53] fix: when using codex sub, send the custom agent
prompts as a separate developer message (previously sent as user message but
api allows for instructions AND developer messages) (#11667)
Co-authored-by: Carlos
---
packages/opencode/src/session/llm.ts | 19 ++++++-------------
1 file changed, 6 insertions(+), 13 deletions(-)
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index befa46fe4a..4be6e2538f 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -233,19 +233,12 @@ export namespace LLM {
},
maxRetries: input.retries ?? 0,
messages: [
- ...(isCodex
- ? [
- {
- role: "user",
- content: system.join("\n\n"),
- } as ModelMessage,
- ]
- : system.map(
- (x): ModelMessage => ({
- role: "system",
- content: x,
- }),
- )),
+ ...system.map(
+ (x): ModelMessage => ({
+ role: "system",
+ content: x,
+ }),
+ ),
...input.messages,
],
model: wrapLanguageModel({
From 91f2ac3cb27c209532ef104cfcab139279881ace Mon Sep 17 00:00:00 2001
From: Filip <34747899+neriousy@users.noreply.github.com>
Date: Sun, 1 Feb 2026 22:39:50 +0100
Subject: [PATCH 29/53] test(app): workspace tests (#11659)
---
packages/app/e2e/actions.ts | 70 ++++
packages/app/e2e/projects/workspaces.spec.ts | 391 +++++++++++++++++++
packages/app/e2e/selectors.ts | 9 +
packages/app/playwright.config.ts | 4 +-
packages/app/src/pages/layout.tsx | 16 +-
packages/opencode/src/session/index.ts | 7 +-
packages/opencode/src/worktree/index.ts | 30 +-
7 files changed, 517 insertions(+), 10 deletions(-)
create mode 100644 packages/app/e2e/projects/workspaces.spec.ts
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index 1eb2da1db7..5f80d67c24 100644
--- a/packages/app/e2e/actions.ts
+++ b/packages/app/e2e/actions.ts
@@ -8,11 +8,15 @@ import {
sessionItemSelector,
dropdownMenuTriggerSelector,
dropdownMenuContentSelector,
+ projectMenuTriggerSelector,
+ projectWorkspacesToggleSelector,
titlebarRightSelector,
popoverBodySelector,
listItemSelector,
listItemKeySelector,
listItemKeyStartsWithSelector,
+ workspaceItemSelector,
+ workspaceMenuTriggerSelector,
} from "./selectors"
import type { createSdk } from "./utils"
@@ -291,3 +295,69 @@ export async function openStatusPopover(page: Page) {
return { rightSection, popoverBody }
}
+
+export async function openProjectMenu(page: Page, projectSlug: string) {
+ const trigger = page.locator(projectMenuTriggerSelector(projectSlug)).first()
+ await expect(trigger).toHaveCount(1)
+
+ await trigger.focus()
+ await page.keyboard.press("Enter")
+
+ const menu = page.locator(dropdownMenuContentSelector).first()
+ const opened = await menu
+ .waitFor({ state: "visible", timeout: 1500 })
+ .then(() => true)
+ .catch(() => false)
+
+ if (opened) {
+ const viewport = page.viewportSize()
+ const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
+ const y = viewport ? Math.max(viewport.height - 5, 0) : 800
+ await page.mouse.move(x, y)
+ return menu
+ }
+
+ await trigger.click({ force: true })
+
+ await expect(menu).toBeVisible()
+
+ const viewport = page.viewportSize()
+ const x = viewport ? Math.max(viewport.width - 5, 0) : 1200
+ const y = viewport ? Math.max(viewport.height - 5, 0) : 800
+ await page.mouse.move(x, y)
+ return menu
+}
+
+export async function setWorkspacesEnabled(page: Page, projectSlug: string, enabled: boolean) {
+ const current = await page
+ .getByRole("button", { name: "New workspace" })
+ .first()
+ .isVisible()
+ .then((x) => x)
+ .catch(() => false)
+
+ if (current === enabled) return
+
+ await openProjectMenu(page, projectSlug)
+
+ const toggle = page.locator(projectWorkspacesToggleSelector(projectSlug)).first()
+ await expect(toggle).toBeVisible()
+ await toggle.click({ force: true })
+
+ const expected = enabled ? "New workspace" : "New session"
+ await expect(page.getByRole("button", { name: expected }).first()).toBeVisible()
+}
+
+export async function openWorkspaceMenu(page: Page, workspaceSlug: string) {
+ const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
+ await expect(item).toBeVisible()
+ await item.hover()
+
+ const trigger = page.locator(workspaceMenuTriggerSelector(workspaceSlug)).first()
+ await expect(trigger).toBeVisible()
+ await trigger.click({ force: true })
+
+ const menu = page.locator(dropdownMenuContentSelector).first()
+ await expect(menu).toBeVisible()
+ return menu
+}
diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts
new file mode 100644
index 0000000000..80cd63aa2a
--- /dev/null
+++ b/packages/app/e2e/projects/workspaces.spec.ts
@@ -0,0 +1,391 @@
+import { base64Decode } from "@opencode-ai/util/encode"
+import fs from "node:fs/promises"
+import path from "node:path"
+import type { Page } from "@playwright/test"
+
+import { test, expect } from "../fixtures"
+
+test.describe.configure({ mode: "serial" })
+import {
+ cleanupTestProject,
+ clickMenuItem,
+ confirmDialog,
+ createTestProject,
+ openSidebar,
+ openWorkspaceMenu,
+ seedProjects,
+ setWorkspacesEnabled,
+} from "../actions"
+import { inlineInputSelector, projectSwitchSelector, workspaceItemSelector } from "../selectors"
+import { dirSlug } from "../utils"
+
+function slugFromUrl(url: string) {
+ return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? ""
+}
+
+async function setupWorkspaceTest(page: Page, directory: string, gotoSession: () => Promise) {
+ const project = await createTestProject()
+ const rootSlug = dirSlug(project)
+ await seedProjects(page, { directory, extra: [project] })
+
+ await gotoSession()
+ await openSidebar(page)
+
+ const target = page.locator(projectSwitchSelector(rootSlug)).first()
+ await expect(target).toBeVisible()
+ await target.click()
+ await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, rootSlug, true)
+
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+ await expect
+ .poll(
+ () => {
+ const slug = slugFromUrl(page.url())
+ return slug.length > 0 && slug !== rootSlug
+ },
+ { timeout: 45_000 },
+ )
+ .toBe(true)
+
+ const slug = slugFromUrl(page.url())
+ const dir = base64Decode(slug)
+
+ await openSidebar(page)
+
+ await expect
+ .poll(
+ async () => {
+ const item = page.locator(workspaceItemSelector(slug)).first()
+ try {
+ await item.hover({ timeout: 500 })
+ return true
+ } catch {
+ return false
+ }
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(true)
+
+ return { project, rootSlug, slug, directory: dir }
+}
+
+test("can enable and disable workspaces from project menu", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const project = await createTestProject()
+ const slug = dirSlug(project)
+ await seedProjects(page, { directory, extra: [project] })
+
+ try {
+ await gotoSession()
+ await openSidebar(page)
+
+ const target = page.locator(projectSwitchSelector(slug)).first()
+ await expect(target).toBeVisible()
+ await target.click()
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
+
+ await openSidebar(page)
+
+ await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+ await expect(page.getByRole("button", { name: "New workspace" })).toHaveCount(0)
+
+ await setWorkspacesEnabled(page, slug, true)
+ await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+ await expect(page.locator(workspaceItemSelector(slug)).first()).toBeVisible()
+
+ await setWorkspacesEnabled(page, slug, false)
+ await expect(page.getByRole("button", { name: "New session" }).first()).toBeVisible()
+ await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
+ } finally {
+ await cleanupTestProject(project)
+ }
+})
+
+test("can create a workspace", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const project = await createTestProject()
+ const slug = dirSlug(project)
+ await seedProjects(page, { directory, extra: [project] })
+
+ try {
+ await gotoSession()
+ await openSidebar(page)
+
+ const target = page.locator(projectSwitchSelector(slug)).first()
+ await expect(target).toBeVisible()
+ await target.click()
+ await expect(page).toHaveURL(new RegExp(`/${slug}/session`))
+
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, slug, true)
+
+ await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
+
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+
+ await expect
+ .poll(
+ () => {
+ const currentSlug = slugFromUrl(page.url())
+ return currentSlug.length > 0 && currentSlug !== slug
+ },
+ { timeout: 45_000 },
+ )
+ .toBe(true)
+
+ const workspaceSlug = slugFromUrl(page.url())
+ const workspaceDir = base64Decode(workspaceSlug)
+
+ await openSidebar(page)
+
+ await expect
+ .poll(
+ async () => {
+ const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
+ try {
+ await item.hover({ timeout: 500 })
+ return true
+ } catch {
+ return false
+ }
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(true)
+
+ await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
+
+ await cleanupTestProject(workspaceDir)
+ } finally {
+ await cleanupTestProject(project)
+ }
+})
+
+test("can rename a workspace", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const { project, slug } = await setupWorkspaceTest(page, directory, gotoSession)
+
+ try {
+ const rename = `e2e workspace ${Date.now()}`
+ const menu = await openWorkspaceMenu(page, slug)
+ await clickMenuItem(menu, /^Rename$/i, { force: true })
+
+ await expect(menu).toHaveCount(0)
+
+ const item = page.locator(workspaceItemSelector(slug)).first()
+ await expect(item).toBeVisible()
+ const input = item.locator(inlineInputSelector).first()
+ await expect(input).toBeVisible()
+ await input.fill(rename)
+ await input.press("Enter")
+ await expect(item).toContainText(rename)
+ } finally {
+ await cleanupTestProject(project)
+ }
+})
+
+test("can reset a workspace", async ({ page, directory, sdk, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const { project, slug, directory: createdDir } = await setupWorkspaceTest(page, directory, gotoSession)
+
+ try {
+ const readme = path.join(createdDir, "README.md")
+ const extra = path.join(createdDir, `e2e_reset_${Date.now()}.txt`)
+ const original = await fs.readFile(readme, "utf8")
+ const dirty = `${original.trimEnd()}\n\nchange_${Date.now()}\n`
+ await fs.writeFile(readme, dirty, "utf8")
+ await fs.writeFile(extra, `created_${Date.now()}\n`, "utf8")
+
+ await expect
+ .poll(async () => {
+ return await fs
+ .stat(extra)
+ .then(() => true)
+ .catch(() => false)
+ })
+ .toBe(true)
+
+ await expect
+ .poll(async () => {
+ const files = await sdk.file
+ .status({ directory: createdDir })
+ .then((r) => r.data ?? [])
+ .catch(() => [])
+ return files.length
+ })
+ .toBeGreaterThan(0)
+
+ const menu = await openWorkspaceMenu(page, slug)
+ await clickMenuItem(menu, /^Reset$/i, { force: true })
+ await confirmDialog(page, /^Reset workspace$/i)
+
+ await expect
+ .poll(
+ async () => {
+ const files = await sdk.file
+ .status({ directory: createdDir })
+ .then((r) => r.data ?? [])
+ .catch(() => [])
+ return files.length
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(0)
+
+ await expect.poll(() => fs.readFile(readme, "utf8"), { timeout: 60_000 }).toBe(original)
+
+ await expect
+ .poll(async () => {
+ return await fs
+ .stat(extra)
+ .then(() => true)
+ .catch(() => false)
+ })
+ .toBe(false)
+ } finally {
+ await cleanupTestProject(project)
+ }
+})
+
+test("can delete a workspace", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const { project, rootSlug, slug } = await setupWorkspaceTest(page, directory, gotoSession)
+
+ try {
+ const menu = await openWorkspaceMenu(page, slug)
+ await clickMenuItem(menu, /^Delete$/i, { force: true })
+ await confirmDialog(page, /^Delete workspace$/i)
+
+ await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+ await expect(page.locator(workspaceItemSelector(slug))).toHaveCount(0)
+ await expect(page.locator(workspaceItemSelector(rootSlug)).first()).toBeVisible()
+ } finally {
+ await cleanupTestProject(project)
+ }
+})
+
+test("can reorder workspaces by drag and drop", async ({ page, directory, gotoSession }) => {
+ await page.setViewportSize({ width: 1400, height: 800 })
+
+ const project = await createTestProject()
+ const rootSlug = dirSlug(project)
+ await seedProjects(page, { directory, extra: [project] })
+
+ const workspaces = [] as { directory: string; slug: string }[]
+
+ const listSlugs = async () => {
+ const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]')
+ const slugs = await nodes.evaluateAll((els) => {
+ return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0)
+ })
+ return slugs
+ }
+
+ const waitReady = async (slug: string) => {
+ await expect
+ .poll(
+ async () => {
+ const item = page.locator(workspaceItemSelector(slug)).first()
+ try {
+ await item.hover({ timeout: 500 })
+ return true
+ } catch {
+ return false
+ }
+ },
+ { timeout: 60_000 },
+ )
+ .toBe(true)
+ }
+
+ const drag = async (from: string, to: string) => {
+ const src = page.locator(workspaceItemSelector(from)).first()
+ const dst = page.locator(workspaceItemSelector(to)).first()
+
+ await src.scrollIntoViewIfNeeded()
+ await dst.scrollIntoViewIfNeeded()
+
+ const a = await src.boundingBox()
+ const b = await dst.boundingBox()
+ if (!a || !b) throw new Error("Failed to resolve workspace drag bounds")
+
+ await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2)
+ await page.mouse.down()
+ await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 12 })
+ await page.mouse.up()
+ }
+
+ try {
+ await gotoSession()
+ await openSidebar(page)
+
+ const target = page.locator(projectSwitchSelector(rootSlug)).first()
+ await expect(target).toBeVisible()
+ await target.click()
+ await expect(page).toHaveURL(new RegExp(`/${rootSlug}/session`))
+
+ await openSidebar(page)
+ await setWorkspacesEnabled(page, rootSlug, true)
+
+ for (const _ of [0, 1]) {
+ const prev = slugFromUrl(page.url())
+ await page.getByRole("button", { name: "New workspace" }).first().click()
+ await expect
+ .poll(
+ () => {
+ const slug = slugFromUrl(page.url())
+ return slug.length > 0 && slug !== rootSlug && slug !== prev
+ },
+ { timeout: 45_000 },
+ )
+ .toBe(true)
+
+ const slug = slugFromUrl(page.url())
+ const dir = base64Decode(slug)
+ workspaces.push({ slug, directory: dir })
+
+ await openSidebar(page)
+ }
+
+ if (workspaces.length !== 2) throw new Error("Expected two created workspaces")
+
+ const a = workspaces[0].slug
+ const b = workspaces[1].slug
+
+ await waitReady(a)
+ await waitReady(b)
+
+ const list = async () => {
+ const slugs = await listSlugs()
+ return slugs.filter((s) => s !== rootSlug && (s === a || s === b)).slice(0, 2)
+ }
+
+ await expect
+ .poll(async () => {
+ const slugs = await list()
+ return slugs.length === 2
+ })
+ .toBe(true)
+
+ const before = await list()
+ const from = before[1]
+ const to = before[0]
+ if (!from || !to) throw new Error("Failed to resolve initial workspace order")
+
+ await drag(from, to)
+
+ await expect.poll(async () => await list()).toEqual([from, to])
+ } finally {
+ await Promise.all(workspaces.map((w) => cleanupTestProject(w.directory)))
+ await cleanupTestProject(project)
+ }
+})
diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts
index 90cfef8db9..317c70969d 100644
--- a/packages/app/e2e/selectors.ts
+++ b/packages/app/e2e/selectors.ts
@@ -27,6 +27,9 @@ export const projectMenuTriggerSelector = (slug: string) =>
export const projectCloseMenuSelector = (slug: string) => `[data-action="project-close-menu"][data-project="${slug}"]`
+export const projectWorkspacesToggleSelector = (slug: string) =>
+ `[data-action="project-workspaces-toggle"][data-project="${slug}"]`
+
export const titlebarRightSelector = "#opencode-titlebar-right"
export const popoverBodySelector = '[data-slot="popover-body"]'
@@ -39,6 +42,12 @@ export const inlineInputSelector = '[data-component="inline-input"]'
export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} [data-session-id="${sessionID}"]`
+export const workspaceItemSelector = (slug: string) =>
+ `${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]`
+
+export const workspaceMenuTriggerSelector = (slug: string) =>
+ `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]`
+
export const listItemSelector = '[data-slot="list-item"]'
export const listItemKeyStartsWithSelector = (prefix: string) => `${listItemSelector}[data-key^="${prefix}"]`
diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts
index 10819e69ff..57bf86b5a8 100644
--- a/packages/app/playwright.config.ts
+++ b/packages/app/playwright.config.ts
@@ -6,6 +6,7 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "localhost"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = `bun run dev -- --host 0.0.0.0 --port ${port}`
const reuse = !process.env.CI
+const win = process.platform === "win32"
export default defineConfig({
testDir: "./e2e",
@@ -14,7 +15,8 @@ export default defineConfig({
expect: {
timeout: 10_000,
},
- fullyParallel: true,
+ fullyParallel: !win,
+ workers: win ? 1 : undefined,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]],
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index f049dc3bcc..845a4fc834 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -2114,12 +2114,20 @@ export default function Layout(props: ParentProps) {
>
-
+
+
{header()}
}
@@ -2146,6 +2154,8 @@ export default function Layout(props: ParentProps) {
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md"
+ data-action="workspace-menu"
+ data-workspace={base64Encode(props.directory)}
aria-label={language.t("common.moreOptions")}
/>
@@ -2592,6 +2602,8 @@ export default function Layout(props: ParentProps) {
{language.t("common.edit")}
{
const enabled = layout.sidebar.workspaces(p.worktree)()
diff --git a/packages/opencode/src/session/index.ts b/packages/opencode/src/session/index.ts
index 87cf3a0820..556fad01f5 100644
--- a/packages/opencode/src/session/index.ts
+++ b/packages/opencode/src/session/index.ts
@@ -332,7 +332,9 @@ export namespace Session {
export async function* list() {
const project = Instance.project
for (const item of await Storage.list(["session", project.id])) {
- yield Storage.read(item)
+ const session = await Storage.read(item).catch(() => undefined)
+ if (!session) continue
+ yield session
}
}
@@ -340,7 +342,8 @@ export namespace Session {
const project = Instance.project
const result = [] as Session.Info[]
for (const item of await Storage.list(["session", project.id])) {
- const session = await Storage.read(item)
+ const session = await Storage.read(item).catch(() => undefined)
+ if (!session) continue
if (session.parentID !== parentID) continue
result.push(session)
}
diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts
index 0f2e2f4a06..b0dfd57dd2 100644
--- a/packages/opencode/src/worktree/index.ts
+++ b/packages/opencode/src/worktree/index.ts
@@ -219,6 +219,13 @@ export namespace Worktree {
return [outputText(result.stderr), outputText(result.stdout)].filter(Boolean).join("\n")
}
+ async function canonical(input: string) {
+ const abs = path.resolve(input)
+ const real = await fs.realpath(abs).catch(() => abs)
+ const normalized = path.normalize(real)
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized
+ }
+
async function candidate(root: string, base?: string) {
for (const attempt of Array.from({ length: 26 }, (_, i) => i)) {
const name = base ? (attempt === 0 ? base : `${base}-${randomName()}`) : randomName()
@@ -374,7 +381,7 @@ export namespace Worktree {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
- const directory = path.resolve(input.directory)
+ const directory = await canonical(input.directory)
const list = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree)
if (list.exitCode !== 0) {
throw new RemoveFailedError({ message: errorText(list) || "Failed to read git worktrees" })
@@ -397,7 +404,13 @@ export namespace Worktree {
return acc
}, [])
- const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
+ const entry = await (async () => {
+ for (const item of entries) {
+ if (!item.path) continue
+ const key = await canonical(item.path)
+ if (key === directory) return item
+ }
+ })()
if (!entry?.path) {
throw new RemoveFailedError({ message: "Worktree not found" })
}
@@ -423,8 +436,9 @@ export namespace Worktree {
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
}
- const directory = path.resolve(input.directory)
- if (directory === path.resolve(Instance.worktree)) {
+ const directory = await canonical(input.directory)
+ const primary = await canonical(Instance.worktree)
+ if (directory === primary) {
throw new ResetFailedError({ message: "Cannot reset the primary workspace" })
}
@@ -450,7 +464,13 @@ export namespace Worktree {
return acc
}, [])
- const entry = entries.find((item) => item.path && path.resolve(item.path) === directory)
+ const entry = await (async () => {
+ for (const item of entries) {
+ if (!item.path) continue
+ const key = await canonical(item.path)
+ if (key === directory) return item
+ }
+ })()
if (!entry?.path) {
throw new ResetFailedError({ message: "Worktree not found" })
}
From f23d8d343be94c1af2f67ddeaaddc556c9617420 Mon Sep 17 00:00:00 2001
From: Sumit Srivastava
Date: Mon, 2 Feb 2026 03:43:53 +0530
Subject: [PATCH 30/53] docs (web): Update incorrect Kimi model name in zen.mdx
(#11677)
---
packages/web/src/content/docs/zen.mdx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx
index ddaabbef09..27f4c229c5 100644
--- a/packages/web/src/content/docs/zen.mdx
+++ b/packages/web/src/content/docs/zen.mdx
@@ -156,7 +156,7 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don
The free models:
- GLM 4.7 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
-- Kimi M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
+- Kimi K2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- MiniMax M2.1 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
From b39c1f158f5e78b41edd944dce792a8b602819c1 Mon Sep 17 00:00:00 2001
From: Frank
Date: Sun, 1 Feb 2026 17:50:15 -0500
Subject: [PATCH 31/53] zen: add minimax logo (#11682)
---
packages/console/app/src/routes/zen/index.tsx | 21 +++++++++++--------
1 file changed, 12 insertions(+), 9 deletions(-)
diff --git a/packages/console/app/src/routes/zen/index.tsx b/packages/console/app/src/routes/zen/index.tsx
index 5708c238cd..accc8d67c9 100644
--- a/packages/console/app/src/routes/zen/index.tsx
+++ b/packages/console/app/src/routes/zen/index.tsx
@@ -18,7 +18,7 @@ import { Legal } from "~/component/legal"
import { Footer } from "~/component/footer"
import { Header } from "~/component/header"
import { getLastSeenWorkspaceID } from "../workspace/common"
-import { IconGemini, IconZai } from "~/component/icon"
+import { IconGemini, IconMiniMax, IconZai } from "~/component/icon"
const checkLoggedIn = query(async () => {
"use server"
@@ -98,14 +98,7 @@ export default function Home() {
Get started with Zen
From 377bf7ff21a4f05807c38675ac70cd08fe67b516 Mon Sep 17 00:00:00 2001
From: Aaron Iker
Date: Mon, 2 Feb 2026 01:17:14 +0100
Subject: [PATCH 32/53] feat(ui): Select, dropdown, popover styles &
transitions (#11675)
---
.../src/components/dialog-select-model.tsx | 9 +-
packages/app/src/components/prompt-input.tsx | 153 +++++++++++-------
.../app/src/components/settings-general.tsx | 34 ++--
packages/ui/src/components/button.css | 29 ++--
packages/ui/src/components/button.tsx | 2 +-
packages/ui/src/components/cycle-label.css | 49 ++++++
packages/ui/src/components/cycle-label.tsx | 132 +++++++++++++++
packages/ui/src/components/dropdown-menu.css | 45 +++---
packages/ui/src/components/icon.tsx | 4 +-
packages/ui/src/components/message-part.tsx | 6 +-
packages/ui/src/components/morph-chevron.css | 10 ++
packages/ui/src/components/morph-chevron.tsx | 73 +++++++++
packages/ui/src/components/popover.css | 58 +++++--
packages/ui/src/components/reasoning-icon.css | 9 ++
packages/ui/src/components/reasoning-icon.tsx | 32 ++++
packages/ui/src/components/select.css | 87 ++++++----
packages/ui/src/components/select.tsx | 20 ++-
packages/ui/src/styles/index.css | 2 +
packages/ui/src/styles/utilities.css | 42 +++++
19 files changed, 631 insertions(+), 165 deletions(-)
create mode 100644 packages/ui/src/components/cycle-label.css
create mode 100644 packages/ui/src/components/cycle-label.tsx
create mode 100644 packages/ui/src/components/morph-chevron.css
create mode 100644 packages/ui/src/components/morph-chevron.tsx
create mode 100644 packages/ui/src/components/reasoning-icon.css
create mode 100644 packages/ui/src/components/reasoning-icon.tsx
diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx
index 4f0dcc3ee6..00e654d8ef 100644
--- a/packages/app/src/components/dialog-select-model.tsx
+++ b/packages/app/src/components/dialog-select-model.tsx
@@ -90,9 +90,10 @@ const ModelList: Component<{
export function ModelSelectorPopover(props: {
provider?: string
- children?: JSX.Element
+ children?: JSX.Element | ((open: boolean) => JSX.Element)
triggerAs?: T
- triggerProps?: ComponentProps
+ triggerProps?: ComponentProps,
+ gutter?: number
}) {
const [store, setStore] = createStore<{
open: boolean
@@ -175,14 +176,14 @@ export function ModelSelectorPopover(props: {
}}
modal={false}
placement="top-start"
- gutter={8}
+ gutter={props.gutter ?? 8}
>
setStore("trigger", el)}
as={props.triggerAs ?? "div"}
{...(props.triggerProps as any)}
>
- {props.children}
+ {typeof props.children === "function" ? props.children(store.open) : props.children}
= (props) => {
.abort({
sessionID,
})
- .catch(() => {})
+ .catch(() => { })
}
const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => {
@@ -1252,7 +1255,7 @@ export const PromptInput: Component = (props) => {
clearInput()
client.session
.shell({
- sessionID: session.id,
+ sessionID: session?.id || "",
agent,
model,
command: text,
@@ -1275,7 +1278,7 @@ export const PromptInput: Component = (props) => {
clearInput()
client.session
.command({
- sessionID: session.id,
+ sessionID: session?.id || "",
command: commandName,
arguments: args.join(" "),
agent,
@@ -1348,18 +1351,18 @@ export const PromptInput: Component = (props) => {
const contextParts: Array<
| {
- id: string
- type: "text"
- text: string
- synthetic?: boolean
- }
+ id: string
+ type: "text"
+ text: string
+ synthetic?: boolean
+ }
| {
- id: string
- type: "file"
- mime: string
- url: string
- filename?: string
- }
+ id: string
+ type: "file"
+ mime: string
+ url: string
+ filename?: string
+ }
> = []
const commentNote = (path: string, selection: FileSelection | undefined, comment: string) => {
@@ -1431,13 +1434,13 @@ export const PromptInput: Component = (props) => {
const optimisticParts = requestParts.map((part) => ({
...part,
- sessionID: session.id,
+ sessionID: session?.id || "",
messageID,
})) as unknown as Part[]
const optimisticMessage: Message = {
id: messageID,
- sessionID: session.id,
+ sessionID: session?.id || "",
role: "user",
time: { created: Date.now() },
agent,
@@ -1448,9 +1451,9 @@ export const PromptInput: Component = (props) => {
if (sessionDirectory === projectDirectory) {
sync.set(
produce((draft) => {
- const messages = draft.message[session.id]
+ const messages = draft.message[session?.id || ""]
if (!messages) {
- draft.message[session.id] = [optimisticMessage]
+ draft.message[session?.id || ""] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
@@ -1466,9 +1469,9 @@ export const PromptInput: Component = (props) => {
globalSync.child(sessionDirectory)[1](
produce((draft) => {
- const messages = draft.message[session.id]
+ const messages = draft.message[session?.id || ""]
if (!messages) {
- draft.message[session.id] = [optimisticMessage]
+ draft.message[session?.id || ""] = [optimisticMessage]
} else {
const result = Binary.search(messages, messageID, (m) => m.id)
messages.splice(result.index, 0, optimisticMessage)
@@ -1485,7 +1488,7 @@ export const PromptInput: Component = (props) => {
if (sessionDirectory === projectDirectory) {
sync.set(
produce((draft) => {
- const messages = draft.message[session.id]
+ const messages = draft.message[session?.id || ""]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
@@ -1498,7 +1501,7 @@ export const PromptInput: Component = (props) => {
globalSync.child(sessionDirectory)[1](
produce((draft) => {
- const messages = draft.message[session.id]
+ const messages = draft.message[session?.id || ""]
if (messages) {
const result = Binary.search(messages, messageID, (m) => m.id)
if (result.found) messages.splice(result.index, 1)
@@ -1519,15 +1522,15 @@ export const PromptInput: Component = (props) => {
const worktree = WorktreeState.get(sessionDirectory)
if (!worktree || worktree.status !== "pending") return true
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "busy" })
+ if (sessionDirectory === projectDirectory && session?.id) {
+ sync.set("session_status", session?.id, { type: "busy" })
}
const controller = new AbortController()
const cleanup = () => {
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "idle" })
+ if (sessionDirectory === projectDirectory && session?.id) {
+ sync.set("session_status", session?.id, { type: "idle" })
}
removeOptimisticMessage()
for (const item of commentItems) {
@@ -1544,7 +1547,7 @@ export const PromptInput: Component = (props) => {
restoreInput()
}
- pending.set(session.id, { abort: controller, cleanup })
+ pending.set(session?.id || "", { abort: controller, cleanup })
const abort = new Promise>>((resolve) => {
if (controller.signal.aborted) {
@@ -1572,7 +1575,7 @@ export const PromptInput: Component = (props) => {
if (timer.id === undefined) return
clearTimeout(timer.id)
})
- pending.delete(session.id)
+ pending.delete(session?.id || "")
if (controller.signal.aborted) return false
if (result.status === "failed") throw new Error(result.message)
return true
@@ -1582,7 +1585,7 @@ export const PromptInput: Component = (props) => {
const ok = await waitForWorktree()
if (!ok) return
await client.session.prompt({
- sessionID: session.id,
+ sessionID: session?.id || "",
agent,
model,
messageID,
@@ -1592,9 +1595,9 @@ export const PromptInput: Component = (props) => {
}
void send().catch((err) => {
- pending.delete(session.id)
- if (sessionDirectory === projectDirectory) {
- sync.set("session_status", session.id, { type: "idle" })
+ pending.delete(session?.id || "")
+ if (sessionDirectory === projectDirectory && session?.id) {
+ sync.set("session_status", session?.id, { type: "idle" })
}
showToast({
title: language.t("prompt.toast.promptSendFailed.title"),
@@ -1616,6 +1619,28 @@ export const PromptInput: Component = (props) => {
})
}
+ const currrentModelVariant = createMemo(() => {
+ const modelVariant = local.model.variant.current() ?? ""
+ return modelVariant === "xhigh"
+ ? "xHigh"
+ : modelVariant.length > 0
+ ? modelVariant[0].toUpperCase() + modelVariant.slice(1)
+ : "Default"
+ })
+
+ const reasoningPercentage = createMemo(() => {
+ const variants = local.model.variant.list()
+ const current = local.model.variant.current()
+ const totalEntries = variants.length + 1
+
+ if (totalEntries <= 2 || current === "Default") {
+ return 0
+ }
+
+ const currentIndex = current ? variants.indexOf(current) + 1 : 0
+ return ((currentIndex + 1) / totalEntries) * 100
+ }, [local.model.variant])
+
return (
@@ -1668,7 +1693,7 @@ export const PromptInput: Component = (props) => {
>
}
>
-
+
@{(item as { type: "agent"; name: string }).name}
@@ -1729,9 +1754,9 @@ export const PromptInput: Component = (props) => {
}}
>
-
+
-
+
{language.t("prompt.dropzone.label")}
@@ -1770,7 +1795,7 @@ export const PromptInput: Component
= (props) => {
}}
>
-
+
{getFilenameTruncated(item.path, 14)}
@@ -1787,7 +1812,7 @@ export const PromptInput: Component = (props) => {
type="button"
icon="close-small"
variant="ghost"
- class="ml-auto h-5 w-5 opacity-0 group-hover:opacity-100 transition-all"
+ class="ml-auto size-7 opacity-0 group-hover:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation()
if (item.commentID) comments.remove(item.path, item.commentID)
@@ -1817,7 +1842,7 @@ export const PromptInput: Component = (props) => {
when={attachment.mime.startsWith("image/")}
fallback={
-
+
}
>
@@ -1891,7 +1916,7 @@ export const PromptInput: Component = (props) => {
-
+
@@ -1912,6 +1937,7 @@ export const PromptInput: Component
= (props) => {
onSelect={local.agent.set}
class="capitalize"
variant="ghost"
+ gutter={12}
/>
= (props) => {
title={language.t("command.model.choose")}
keybind={command.keybind("model.choose")}
>
-
@@ -1955,10 +1990,13 @@ export const PromptInput: Component = (props) => {
local.model.variant.cycle()}
>
- {local.model.variant.current() ?? language.t("common.default")}
+ 1}>
+
+
+
@@ -1972,7 +2010,7 @@ export const PromptInput: Component = (props) => {
variant="ghost"
onClick={() => permission.toggleAutoAccept(params.id!, sdk.directory)}
classList={{
- "_hidden group-hover/prompt-input:flex size-6 items-center justify-center": true,
+ "_hidden group-hover/prompt-input:flex items-center justify-center": true,
"text-text-base": !permission.isAutoAccepting(params.id!, sdk.directory),
"hover:bg-surface-success-base": permission.isAutoAccepting(params.id!, sdk.directory),
}}
@@ -1994,7 +2032,7 @@ export const PromptInput: Component = (props) => {
-
+
= (props) => {
e.currentTarget.value = ""
}}
/>
-
+
fileInputRef.click()}
aria-label={language.t("prompt.action.attachFile")}
>
-
+
@@ -2036,7 +2075,7 @@ export const PromptInput: Component
= (props) => {
{language.t("prompt.action.send")}
-
+
@@ -2047,7 +2086,7 @@ export const PromptInput: Component = (props) => {
disabled={!prompt.dirty() && !working()}
icon={working() ? "stop" : "arrow-up"}
variant="primary"
- class="h-6 w-4.5"
+ class="h-6 w-5.5"
aria-label={working() ? language.t("prompt.action.stop") : language.t("prompt.action.send")}
/>
@@ -2162,4 +2201,4 @@ function setCursorPosition(parent: HTMLElement, position: number) {
fallbackRange.collapse(false)
fallbackSelection?.removeAllRanges()
fallbackSelection?.addRange(fallbackRange)
-}
+}
\ No newline at end of file
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx
index b31cfb6cc7..e43b82e4a8 100644
--- a/packages/app/src/components/settings-general.tsx
+++ b/packages/app/src/components/settings-general.tsx
@@ -60,24 +60,24 @@ export const SettingsGeneral: Component = () => {
const actions =
platform.update && platform.restart
? [
- {
- label: language.t("toast.update.action.installRestart"),
- onClick: async () => {
- await platform.update!()
- await platform.restart!()
- },
+ {
+ label: language.t("toast.update.action.installRestart"),
+ onClick: async () => {
+ await platform.update!()
+ await platform.restart!()
},
- {
- label: language.t("toast.update.action.notYet"),
- onClick: "dismiss" as const,
- },
- ]
+ },
+ {
+ label: language.t("toast.update.action.notYet"),
+ onClick: "dismiss" as const,
+ },
+ ]
: [
- {
- label: language.t("toast.update.action.notYet"),
- onClick: "dismiss" as const,
- },
- ]
+ {
+ label: language.t("toast.update.action.notYet"),
+ onClick: "dismiss" as const,
+ },
+ ]
showToast({
persistent: true,
@@ -226,7 +226,7 @@ export const SettingsGeneral: Component = () => {
variant="secondary"
size="small"
triggerVariant="settings"
- triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
+ triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "field-sizing": "content" }}
>
{(option) => (
diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css
index d9b3459230..3e5d21d1de 100644
--- a/packages/ui/src/components/button.css
+++ b/packages/ui/src/components/button.css
@@ -9,7 +9,13 @@
user-select: none;
cursor: default;
outline: none;
+ padding: 4px 8px;
white-space: nowrap;
+ transition-property: background-color, border-color, color, box-shadow, opacity;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+ outline: none;
+ line-height: 20px;
&[data-variant="primary"] {
background-color: var(--button-primary-base);
@@ -94,7 +100,6 @@
&:active:not(:disabled) {
background-color: var(--button-secondary-base);
scale: 0.99;
- transition: all 150ms ease-out;
}
&:disabled {
border-color: var(--border-disabled);
@@ -109,34 +114,31 @@
}
&[data-size="small"] {
- height: 22px;
- padding: 0 8px;
+ padding: 4px 8px;
&[data-icon] {
- padding: 0 12px 0 4px;
+ padding: 4px 12px 4px 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-size: var(--font-size-base);
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;
- line-height: 24px;
- padding: 0 6px;
+ padding: 4px 6px;
&[data-icon] {
- padding: 0 12px 0 4px;
+ padding: 4px 12px 4px 4px;
+ }
+
+ &[aria-haspopup] {
+ padding: 4px 6px 4px 8px;
}
- font-size: var(--font-size-small);
gap: 6px;
/* text-12-medium */
@@ -148,7 +150,6 @@
}
&[data-size="large"] {
- height: 32px;
padding: 6px 12px;
&[data-icon] {
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index 7f974b2f76..1b110ca8af 100644
--- a/packages/ui/src/components/button.tsx
+++ b/packages/ui/src/components/button.tsx
@@ -4,7 +4,7 @@ import { Icon, IconProps } from "./icon"
export interface ButtonProps
extends ComponentProps,
- Pick, "class" | "classList" | "children"> {
+ Pick, "class" | "classList" | "children" | "style"> {
size?: "small" | "normal" | "large"
variant?: "primary" | "secondary" | "ghost"
icon?: IconProps["name"]
diff --git a/packages/ui/src/components/cycle-label.css b/packages/ui/src/components/cycle-label.css
new file mode 100644
index 0000000000..3c98fcd261
--- /dev/null
+++ b/packages/ui/src/components/cycle-label.css
@@ -0,0 +1,49 @@
+.cycle-label {
+ --c-duration: 200ms;
+ --c-stagger: 30ms;
+ --c-opacity-start: 0;
+ --c-opacity-end: 1;
+ --c-blur-start: 0px;
+ --c-blur-end: 0px;
+ --c-skew: 10deg;
+
+ display: inline-flex;
+ position: relative;
+
+ transform-style: preserve-3d;
+ perspective: 500px;
+ transition: width var(--transition-duration) var(--transition-easing);
+ will-change: width;
+ overflow: hidden;
+
+ .cycle-char {
+ display: inline-block;
+ transform-style: preserve-3d;
+ min-width: 0.25em;
+ backface-visibility: hidden;
+
+ transition-property: transform, opacity, filter;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+ transition-delay: calc(var(--i, 0) * var(--c-stagger));
+
+ &.enter {
+ opacity: var(--c-opacity-end);
+ filter: blur(var(--c-blur-end));
+ transform: translateY(0) rotateX(0) skewX(0);
+ }
+
+ &.exit {
+ opacity: var(--c-opacity-start);
+ filter: blur(var(--c-blur-start));
+ transform: translateY(50%) rotateX(90deg) skewX(var(--c-skew));
+ }
+
+ &.pre {
+ opacity: var(--c-opacity-start);
+ filter: blur(var(--c-blur-start));
+ transition: none;
+ transform: translateY(-50%) rotateX(-90deg) skewX(calc(var(--c-skew) * -1));
+ }
+ }
+}
diff --git a/packages/ui/src/components/cycle-label.tsx b/packages/ui/src/components/cycle-label.tsx
new file mode 100644
index 0000000000..f1eaa88f31
--- /dev/null
+++ b/packages/ui/src/components/cycle-label.tsx
@@ -0,0 +1,132 @@
+import "./cycle-label.css"
+import { createEffect, createSignal, JSX, on } from "solid-js"
+
+export interface CycleLabelProps extends JSX.HTMLAttributes {
+ value: string
+ onValueChange?: (value: string) => void
+ duration?: number | ((value: string) => number)
+ stagger?: number
+ opacity?: [number, number]
+ blur?: [number, number]
+ skewX?: number
+ onAnimationStart?: () => void
+ onAnimationEnd?: () => void
+}
+
+const segmenter =
+ typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter("en", { granularity: "grapheme" }) : null
+
+const getChars = (text: string): string[] =>
+ segmenter ? Array.from(segmenter.segment(text), (s) => s.segment) : text.split("")
+
+const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
+
+export function CycleLabel(props: CycleLabelProps) {
+ const getDuration = (text: string) => {
+ const d = props.duration ?? Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? 200
+ return typeof d === "function" ? d(text) : d
+ }
+ const stagger = () => props?.stagger ?? 30
+ const opacity = () => props?.opacity ?? [0, 1]
+ const blur = () => props?.blur ?? [0, 0]
+ const skewX = () => props?.skewX ?? 10
+
+ let containerRef: HTMLSpanElement | undefined
+ let isAnimating = false
+ const [currentText, setCurrentText] = createSignal(props.value)
+
+ const setChars = (el: HTMLElement, text: string, state: "enter" | "exit" | "pre" = "enter") => {
+ el.innerHTML = ""
+ const chars = getChars(text)
+ chars.forEach((char, i) => {
+ const span = document.createElement("span")
+ span.textContent = char === " " ? "\u00A0" : char
+ span.className = `cycle-char ${state}`
+ span.style.setProperty("--i", String(i))
+ el.appendChild(span)
+ })
+ }
+
+ const animateToText = async (newText: string) => {
+ if (!containerRef || isAnimating) return
+ if (newText === currentText()) return
+
+ isAnimating = true
+ props.onAnimationStart?.()
+
+ const dur = getDuration(newText)
+ const stag = stagger()
+
+ containerRef.style.width = containerRef.offsetWidth + "px"
+
+ const oldChars = containerRef.querySelectorAll(".cycle-char")
+ oldChars.forEach((c) => c.classList.replace("enter", "exit"))
+
+ const clone = containerRef.cloneNode(false) as HTMLElement
+ Object.assign(clone.style, {
+ position: "absolute",
+ visibility: "hidden",
+ width: "auto",
+ transition: "none",
+ })
+ setChars(clone, newText)
+ document.body.appendChild(clone)
+ const nextWidth = clone.offsetWidth
+ clone.remove()
+
+ const exitTime = oldChars.length * stag + dur
+ await wait(exitTime * 0.3)
+
+ containerRef.style.width = nextWidth + "px"
+
+ const widthDur = 200
+ await wait(widthDur * 0.3)
+
+ setChars(containerRef, newText, "pre")
+ containerRef.offsetWidth
+
+ Array.from(containerRef.children).forEach((c) => (c.className = "cycle-char enter"))
+ setCurrentText(newText)
+ props.onValueChange?.(newText)
+
+ const enterTime = getChars(newText).length * stag + dur
+ await wait(enterTime)
+
+ containerRef.style.width = ""
+ isAnimating = false
+ props.onAnimationEnd?.()
+ }
+
+ createEffect(
+ on(
+ () => props.value,
+ (newValue) => {
+ if (newValue !== currentText()) {
+ animateToText(newValue)
+ }
+ },
+ ),
+ )
+
+ const initRef = (el: HTMLSpanElement) => {
+ containerRef = el
+ setChars(el, props.value)
+ }
+
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css
index cba041613e..18266ac1a1 100644
--- a/packages/ui/src/components/dropdown-menu.css
+++ b/packages/ui/src/components/dropdown-menu.css
@@ -2,26 +2,29 @@
[data-component="dropdown-menu-sub-content"] {
min-width: 8rem;
overflow: hidden;
+ border: none;
border-radius: var(--radius-md);
- border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
+ box-shadow: var(--shadow-xs-border);
background-clip: padding-box;
background-color: var(--surface-raised-stronger-non-alpha);
padding: 4px;
- box-shadow: var(--shadow-md);
- z-index: 50;
+ z-index: 100;
transform-origin: var(--kb-menu-content-transform-origin);
- &:focus,
- &:focus-visible {
+ &:focus-within,
+ &:focus {
outline: none;
}
- &[data-closed] {
- animation: dropdown-menu-close 0.15s ease-out;
+ animation: dropdownMenuContentHide var(--transition-duration) var(--transition-easing) forwards;
+
+ @starting-style {
+ animation: none;
}
&[data-expanded] {
- animation: dropdown-menu-open 0.15s ease-out;
+ pointer-events: auto;
+ animation: dropdownMenuContentShow var(--transition-duration) var(--transition-easing) forwards;
}
}
@@ -38,18 +41,22 @@
padding: 4px 8px;
border-radius: var(--radius-sm);
cursor: default;
- user-select: none;
outline: none;
font-family: var(--font-family-sans);
- font-size: var(--font-size-small);
+ font-size: var(--font-size-base);
font-weight: var(--font-weight-medium);
line-height: var(--line-height-large);
letter-spacing: var(--letter-spacing-normal);
color: var(--text-strong);
- &[data-highlighted] {
- background: var(--surface-raised-base-hover);
+ transition-property: background-color, color;
+ transition-duration: var(--transition-duration);
+ transition-timing-function: var(--transition-easing);
+ user-select: none;
+
+ &:hover {
+ background-color: var(--surface-raised-base-hover);
}
&[data-disabled] {
@@ -61,6 +68,8 @@
[data-slot="dropdown-menu-sub-trigger"] {
&[data-expanded] {
background: var(--surface-raised-base-hover);
+ outline: none;
+ border: none;
}
}
@@ -102,24 +111,24 @@
}
}
-@keyframes dropdown-menu-open {
+@keyframes dropdownMenuContentShow {
from {
opacity: 0;
- transform: scale(0.96);
+ transform: scaleY(0.95);
}
to {
opacity: 1;
- transform: scale(1);
+ transform: scaleY(1);
}
}
-@keyframes dropdown-menu-close {
+@keyframes dropdownMenuContentHide {
from {
opacity: 1;
- transform: scale(1);
+ transform: scaleY(1);
}
to {
opacity: 0;
- transform: scale(0.96);
+ transform: scaleY(0.95);
}
}
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index 544c6abdd2..f23357293e 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -80,13 +80,13 @@ const icons = {
export interface IconProps extends ComponentProps<"svg"> {
name: keyof typeof icons
- size?: "small" | "normal" | "medium" | "large"
+ size?: "small" | "normal" | "medium" | "large" | number
}
export function Icon(props: IconProps) {
const [local, others] = splitProps(props, ["name", "size", "class", "classList"])
return (
-
+