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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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 @@ +

+ + + + + OpenCode logo + + +

+

Açık kaynaklı yapay zeka kodlama asistanı.

+

+ Discord + npm + Build status +

+ +

+ English | + 简体中文 | + 繁體中文 | + 한국어 | + Deutsch | + Español | + Français | + Italiano | + Dansk | + 日本語 | + Polski | + Русский | + العربية | + Norsk | + Português (Brasil) | + ไทย | + Türkçe +

+ +[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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

[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](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() {
- - - +
@@ -118,6 +111,16 @@ 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")} > - } @@ -1937,12 +1968,16 @@ export const PromptInput: Component = (props) => { title={language.t("command.model.choose")} keybind={command.keybind("model.choose")} > - - - - - {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + + {(open) => ( + <> + + + + {local.model.current()?.name ?? language.t("dialog.model.select.title")} + + + )} @@ -1955,10 +1990,13 @@ export const PromptInput: Component = (props) => { @@ -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 = "" }} /> -
+
@@ -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 ( -
+
- +
m.id === perm.tool!.messageID) + const message = messages.findLast((m) => m.id === perm.tool!.messageID) if (!message) return undefined const parts = data.store.part[message.id] ?? [] for (const part of parts) { diff --git a/packages/ui/src/components/morph-chevron.css b/packages/ui/src/components/morph-chevron.css new file mode 100644 index 0000000000..f6edb3f649 --- /dev/null +++ b/packages/ui/src/components/morph-chevron.css @@ -0,0 +1,10 @@ +[data-slot="morph-chevron-svg"] { + width: 16px; + height: 16px; + display: block; + fill: none; + stroke-width: 1.5; + stroke: currentcolor; + stroke-linecap: round; + stroke-linejoin: round; +} diff --git a/packages/ui/src/components/morph-chevron.tsx b/packages/ui/src/components/morph-chevron.tsx new file mode 100644 index 0000000000..a14a2252b6 --- /dev/null +++ b/packages/ui/src/components/morph-chevron.tsx @@ -0,0 +1,73 @@ +import { createEffect, createUniqueId, on } from "solid-js" + +export interface MorphChevronProps { + expanded: boolean + class?: string +} + +const COLLAPSED = "M4 6L8 10L12 6" +const EXPANDED = "M4 10L8 6L12 10" + +export function MorphChevron(props: MorphChevronProps) { + const id = createUniqueId() + let path: SVGPathElement | undefined + let expandAnim: SVGAnimateElement | undefined + let collapseAnim: SVGAnimateElement | undefined + + createEffect( + on( + () => props.expanded, + (expanded, prev) => { + if (prev === undefined) { + // Set initial state without animation + path?.setAttribute("d", expanded ? EXPANDED : COLLAPSED) + return + } + if (expanded) { + expandAnim?.beginElement() + } else { + collapseAnim?.beginElement() + } + }, + ), + ) + + return ( + + ) +} \ No newline at end of file diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index b49542afd9..d200fe8b24 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -15,16 +15,35 @@ transform-origin: var(--kb-popover-content-transform-origin); - &:focus-within { - outline: none; - } + animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; - &[data-closed] { - animation: popover-close 0.15s ease-out; + @starting-style { + animation: none; } &[data-expanded] { - animation: popover-open 0.15s ease-out; + pointer-events: auto; + animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; + } + + [data-origin-top-right] { + transform-origin: top right; + } + + [data-origin-top-left] { + transform-origin: top left; + } + + [data-origin-bottom-right] { + transform-origin: bottom right; + } + + [data-origin-bottom-left] { + transform-origin: bottom left; + } + + &:focus-within { + outline: none; } [data-slot="popover-header"] { @@ -75,24 +94,39 @@ } } -@keyframes popover-open { +@keyframes popoverContentShow { from { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); } } -@keyframes popover-close { +@keyframes popoverContentHide { from { opacity: 1; - transform: scale(1); + transform: scaleY(1); } to { opacity: 0; - transform: scale(0.96); + transform: scaleY(0.95); + } +} + +[data-component="model-popover-content"] { + transform-origin: var(--kb-popper-content-transform-origin); + pointer-events: none; + animation: popoverContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; + } + + &[data-expanded] { + pointer-events: auto; + animation: popoverContentShow var(--transition-duration) var(--transition-easing) forwards; } } diff --git a/packages/ui/src/components/reasoning-icon.css b/packages/ui/src/components/reasoning-icon.css new file mode 100644 index 0000000000..26fbc01448 --- /dev/null +++ b/packages/ui/src/components/reasoning-icon.css @@ -0,0 +1,9 @@ +[data-component="reasoning-icon"] { + color: var(--icon-strong-base); + + [data-slot="reasoning-icon-percentage"] { + transition: clip-path 200ms cubic-bezier(0.25, 0, 0.5, 1); + clip-path: inset(calc(100% - var(--reasoning-icon-percentage) * 100%) 0 0 0); + opacity: calc(var(--reasoning-icon-percentage) * 0.75); + } +} diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx new file mode 100644 index 0000000000..ba753b3b84 --- /dev/null +++ b/packages/ui/src/components/reasoning-icon.tsx @@ -0,0 +1,32 @@ +import { type ComponentProps, splitProps } from "solid-js" + +export interface ReasoningIconProps extends Pick, "class" | "classList"> { + percentage: number + size?: number + strokeWidth?: number +} + +export function ReasoningIcon(props: ReasoningIconProps) { + const [split, rest] = splitProps(props, ["percentage", "size", "strokeWidth", "class", "classList"]) + + const size = () => split.size || 16 + const strokeWidth = () => split.strokeWidth || 1.25 + + return ( + + + + + ) +} \ No newline at end of file diff --git a/packages/ui/src/components/select.css b/packages/ui/src/components/select.css index 25dd2eb40b..eaba6fd6d2 100644 --- a/packages/ui/src/components/select.css +++ b/packages/ui/src/components/select.css @@ -1,7 +1,13 @@ [data-component="select"] { [data-slot="select-select-trigger"] { - padding: 0 4px 0 8px; + display: flex; + padding: 4px 8px !important; + align-items: center; + justify-content: space-between; box-shadow: none; + transition-property: background-color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); [data-slot="select-select-trigger-value"] { overflow: hidden; @@ -15,10 +21,10 @@ align-items: center; justify-content: center; flex-shrink: 0; - color: var(--text-weak); - transition: transform 0.1s ease-in-out; + color: var(--icon-base); } + &:hover, &[data-expanded] { &[data-variant="secondary"] { background-color: var(--button-secondary-hover); @@ -30,13 +36,13 @@ background-color: var(--icon-strong-active); } } - + &:not([data-expanded]):focus, &:not([data-expanded]):focus-visible { &[data-variant="secondary"] { background-color: var(--button-secondary-base); } &[data-variant="ghost"] { - background-color: var(--surface-raised-base-hover); + background-color: transparent; } &[data-variant="primary"] { background-color: var(--icon-strong-base); @@ -46,10 +52,10 @@ &[data-trigger-style="settings"] { [data-slot="select-select-trigger"] { - padding: 6px 6px 6px 12px; + padding: 6px 6px 6px 10px; box-shadow: none; border-radius: 6px; - min-width: 160px; + field-sizing: content; height: 32px; justify-content: flex-end; gap: 12px; @@ -61,6 +67,7 @@ white-space: nowrap; font-size: var(--font-size-base); font-weight: var(--font-weight-regular); + padding: 4px 8px 4px 4px; } [data-slot="select-select-trigger-icon"] { width: 16px; @@ -91,17 +98,26 @@ } [data-component="select-content"] { - min-width: 104px; + min-width: 8rem; max-width: 23rem; overflow: hidden; border-radius: var(--radius-md); background-color: var(--surface-raised-stronger-non-alpha); padding: 4px; box-shadow: var(--shadow-xs-border); - z-index: 60; + z-index: 50; + transform-origin: var(--kb-popper-content-transform-origin); + pointer-events: none; + + animation: selectContentHide var(--transition-duration) var(--transition-easing) forwards; + + @starting-style { + animation: none; + } &[data-expanded] { - animation: select-open 0.15s ease-out; + pointer-events: auto; + animation: selectContentShow var(--transition-duration) var(--transition-easing) forwards; } [data-slot="select-select-content-list"] { @@ -111,43 +127,38 @@ overflow-x: hidden; display: flex; flex-direction: column; - &:focus { outline: none; } - > *:not([role="presentation"]) + *:not([role="presentation"]) { margin-top: 2px; } } - [data-slot="select-select-item"] { position: relative; display: flex; align-items: center; - padding: 2px 8px; + padding: 4px 8px; gap: 12px; - border-radius: 4px; - cursor: default; + border-radius: var(--radius-sm); /* 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); - color: var(--text-strong); - transition: - background-color 0.2s ease-in-out, - color 0.2s ease-in-out; + transition-property: background-color, color; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); outline: none; user-select: none; - &[data-highlighted] { - background: var(--surface-raised-base-hover); + &:hover { + background-color: var(--surface-raised-base-hover); } &[data-disabled] { background-color: var(--surface-raised-base); @@ -160,6 +171,11 @@ margin-left: auto; width: 16px; height: 16px; + color: var(--icon-strong-base); + + svg { + color: var(--icon-strong-base); + } } &:focus { outline: none; @@ -171,13 +187,9 @@ } [data-component="select-content"][data-trigger-style="settings"] { - min-width: 160px; + field-sizing: content; border-radius: 8px; - padding: 0; - - [data-slot="select-select-content-list"] { - padding: 4px; - } + padding: 0 0 0 4px; [data-slot="select-select-item"] { /* text-14-regular */ @@ -190,13 +202,24 @@ } } -@keyframes select-open { +@keyframes selectContentShow { from { opacity: 0; - transform: scale(0.95); + transform: scaleY(0.95); } to { opacity: 1; - transform: scale(1); + transform: scaleY(1); + } +} + +@keyframes selectContentHide { + from { + opacity: 1; + transform: scaleY(1); + } + to { + opacity: 0; + transform: scaleY(0.95); } } diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 0386c329ec..66f48e69b1 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -1,8 +1,10 @@ import { Select as Kobalte } from "@kobalte/core/select" -import { createMemo, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" +import { createMemo, createSignal, onCleanup, splitProps, type ComponentProps, type JSX } from "solid-js" import { pipe, groupBy, entries, map } from "remeda" +import { Show } from "solid-js" import { Button, ButtonProps } from "./button" import { Icon } from "./icon" +import { MorphChevron } from "./morph-chevron" export type SelectProps = Omit>, "value" | "onSelect" | "children"> & { placeholder?: string @@ -38,6 +40,8 @@ export function Select(props: SelectProps & Omit) "triggerVariant", ]) + const [isOpen, setIsOpen] = createSignal(false) + const state = { key: undefined as string | undefined, cleanup: undefined as (() => void) | void, @@ -85,7 +89,7 @@ export function Select(props: SelectProps & Omit) data-component="select" data-trigger-style={local.triggerVariant} placement={local.triggerVariant === "settings" ? "bottom-end" : "bottom-start"} - gutter={4} + gutter={8} value={local.current} options={grouped()} optionValue={(x) => (local.value ? local.value(x) : (x as string))} @@ -115,7 +119,7 @@ export function Select(props: SelectProps & Omit) : (itemProps.item.rawValue as string)} - + )} @@ -124,6 +128,7 @@ export function Select(props: SelectProps & Omit) stop() }} onOpenChange={(open) => { + setIsOpen(open) local.onOpenChange?.(open) if (!open) stop() }} @@ -149,7 +154,12 @@ export function Select(props: SelectProps & Omit) }} - + + + + + + @@ -166,4 +176,4 @@ export function Select(props: SelectProps & Omit) ) -} +} \ No newline at end of file diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index 3ed0310ef2..cae48137ba 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -48,6 +48,8 @@ @import "../components/toast.css" layer(components); @import "../components/tooltip.css" layer(components); @import "../components/typewriter.css" layer(components); +@import "../components/morph-chevron.css" layer(components); +@import "../components/reasoning-icon.css" layer(components); @import "./utilities.css" layer(utilities); @import "./animations.css" layer(utilities); diff --git a/packages/ui/src/styles/utilities.css b/packages/ui/src/styles/utilities.css index 8c954f1fe4..82a913c883 100644 --- a/packages/ui/src/styles/utilities.css +++ b/packages/ui/src/styles/utilities.css @@ -1,6 +1,17 @@ :root { interpolate-size: allow-keywords; + /* Transition tokens */ + --transition-duration: 200ms; + --transition-easing: cubic-bezier(0.25, 0, 0.5, 1); + --transition-fast: 150ms; + --transition-slow: 300ms; + + /* Allow height transitions from 0 to auto */ + @supports (interpolate-size: allow-keywords) { + interpolate-size: allow-keywords; + } + [data-popper-positioner] { pointer-events: none; } @@ -129,3 +140,34 @@ line-height: var(--line-height-x-large); /* 120% */ letter-spacing: var(--letter-spacing-tightest); } + +/* Transition utility classes */ +.transition-colors { + transition-property: background-color, border-color, color, fill, stroke; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-opacity { + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-transform { + transition-property: transform; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-shadow { + transition-property: box-shadow; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} + +.transition-interactive { + transition-property: background-color, border-color, color, box-shadow, opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); +} From e84d441b823463162cada18e1a8b5383820c695a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 00:17:54 +0000 Subject: [PATCH 33/53] chore: generate --- .../src/components/dialog-select-model.tsx | 2 +- packages/app/src/components/prompt-input.tsx | 30 +++++++++-------- .../app/src/components/settings-general.tsx | 32 +++++++++---------- packages/ui/src/components/button.tsx | 2 +- packages/ui/src/components/cycle-label.tsx | 7 ++-- packages/ui/src/components/icon.tsx | 5 ++- packages/ui/src/components/morph-chevron.tsx | 2 +- packages/ui/src/components/reasoning-icon.tsx | 20 ++++++++++-- packages/ui/src/components/select.tsx | 2 +- 9 files changed, 62 insertions(+), 40 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 00e654d8ef..2135b1edf4 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -92,7 +92,7 @@ export function ModelSelectorPopover(props: { provider?: string children?: JSX.Element | ((open: boolean) => JSX.Element) triggerAs?: T - triggerProps?: ComponentProps, + triggerProps?: ComponentProps gutter?: number }) { const [store, setStore] = createStore<{ diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 0c16bc8935..c175824deb 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -925,7 +925,7 @@ export const PromptInput: Component = (props) => { .abort({ sessionID, }) - .catch(() => { }) + .catch(() => {}) } const addToHistory = (prompt: Prompt, mode: "normal" | "shell") => { @@ -1351,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) => { @@ -1958,7 +1958,9 @@ export const PromptInput: Component = (props) => { {local.model.current()?.name ?? language.t("dialog.model.select.title")} - + } @@ -2201,4 +2203,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 e43b82e4a8..94813871e4 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, diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx index 1b110ca8af..b2d2004d3c 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" | "style"> { + 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.tsx b/packages/ui/src/components/cycle-label.tsx index f1eaa88f31..dc12bd75c8 100644 --- a/packages/ui/src/components/cycle-label.tsx +++ b/packages/ui/src/components/cycle-label.tsx @@ -23,7 +23,10 @@ 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 + const d = + props.duration ?? + Number(getComputedStyle(document.documentElement).getPropertyValue("--transition-duration")) ?? + 200 return typeof d === "function" ? d(text) : d } const stagger = () => props?.stagger ?? 30 @@ -129,4 +132,4 @@ export function CycleLabel(props: CycleLabelProps) { }} /> ) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index f23357293e..97488a42f0 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -86,7 +86,10 @@ export interface IconProps extends ComponentProps<"svg"> { export function Icon(props: IconProps) { const [local, others] = splitProps(props, ["name", "size", "class", "classList"]) return ( -
+
) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/reasoning-icon.tsx b/packages/ui/src/components/reasoning-icon.tsx index ba753b3b84..7bac49ffd2 100644 --- a/packages/ui/src/components/reasoning-icon.tsx +++ b/packages/ui/src/components/reasoning-icon.tsx @@ -25,8 +25,22 @@ export function ReasoningIcon(props: ReasoningIconProps) { [split.class ?? ""]: !!split.class, }} > - - + + ) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index 66f48e69b1..fef00500a7 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -176,4 +176,4 @@ export function Select(props: SelectProps & Omit) ) -} \ No newline at end of file +} From e445dc07464d75c893756f6e256c1755d9e2285e Mon Sep 17 00:00:00 2001 From: Aaron Iker Date: Mon, 2 Feb 2026 01:18:06 +0100 Subject: [PATCH 34/53] feat(ui): Smooth fading out on scroll, style fixes (#11683) --- .../app/src/components/settings-general.tsx | 37 ++-- .../app/src/components/settings-keybinds.tsx | 10 +- .../app/src/components/settings-models.tsx | 5 +- .../app/src/components/settings-providers.tsx | 5 +- packages/ui/src/components/list.css | 50 ++--- packages/ui/src/components/list.tsx | 13 +- packages/ui/src/components/scroll-fade.css | 82 +++++++ packages/ui/src/components/scroll-fade.tsx | 206 ++++++++++++++++++ packages/ui/src/components/scroll-reveal.tsx | 141 ++++++++++++ packages/ui/src/styles/index.css | 1 + 10 files changed, 490 insertions(+), 60 deletions(-) create mode 100644 packages/ui/src/components/scroll-fade.css create mode 100644 packages/ui/src/components/scroll-fade.tsx create mode 100644 packages/ui/src/components/scroll-reveal.tsx diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 94813871e4..1d82a24c30 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -5,6 +5,7 @@ import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" @@ -60,24 +61,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, @@ -130,7 +131,7 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] return ( -
+

{language.t("settings.tab.general")}

@@ -411,7 +412,7 @@ export const SettingsGeneral: Component = () => {
-
+ ) } diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index a24db13f5c..8655bca34b 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -5,6 +5,7 @@ import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" import fuzzysort from "fuzzysort" import { formatKeybind, parseKeybind, useCommand } from "@/context/command" import { useLanguage } from "@/context/language" @@ -352,7 +353,12 @@ export const SettingsKeybinds: Component = () => { }) return ( -
+
@@ -430,6 +436,6 @@ export const SettingsKeybinds: Component = () => {
-
+
) } diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 1807d561ea..c9453ddf1b 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -9,6 +9,7 @@ import { type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" import { popularProviders } from "@/hooks/use-providers" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ModelItem = ReturnType["list"]>[number] @@ -39,7 +40,7 @@ export const SettingsModels: Component = () => { }) return ( -
+

{language.t("settings.models.title")}

@@ -125,6 +126,6 @@ export const SettingsModels: Component = () => {
-
+
) } diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index dcc597139e..71a0bb6ad8 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -12,6 +12,7 @@ import { useGlobalSync } from "@/context/global-sync" import { DialogConnectProvider } from "./dialog-connect-provider" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogCustomProvider } from "./dialog-custom-provider" +import { ScrollFade } from "@opencode-ai/ui/scroll-fade" type ProviderSource = "env" | "api" | "config" | "custom" type ProviderMeta = { source?: ProviderSource } @@ -115,7 +116,7 @@ export const SettingsProviders: Component = () => { } return ( -
+

{language.t("settings.providers.title")}

@@ -261,6 +262,6 @@ export const SettingsProviders: Component = () => {
-
+ ) } diff --git a/packages/ui/src/components/list.css b/packages/ui/src/components/list.css index b12d304151..7b365c288a 100644 --- a/packages/ui/src/components/list.css +++ b/packages/ui/src/components/list.css @@ -1,25 +1,7 @@ -@property --bottom-fade { - syntax: ""; - inherits: false; - initial-value: 0px; -} - -@keyframes scroll { - 0% { - --bottom-fade: 20px; - } - 90% { - --bottom-fade: 20px; - } - 100% { - --bottom-fade: 0; - } -} - [data-component="list"] { display: flex; flex-direction: column; - gap: 12px; + gap: 8px; overflow: hidden; padding: 0 12px; @@ -37,7 +19,9 @@ flex-shrink: 0; background-color: transparent; opacity: 0.5; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -88,7 +72,9 @@ height: 20px; background-color: transparent; opacity: 0.5; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); &:hover:not(:disabled), &:focus-visible:not(:disabled), @@ -131,15 +117,6 @@ gap: 12px; overflow-y: auto; overscroll-behavior: contain; - mask: linear-gradient(to bottom, #ffff calc(100% - var(--bottom-fade)), #0000); - animation: scroll; - animation-timeline: --scroll; - scroll-timeline: --scroll y; - scrollbar-width: none; - -ms-overflow-style: none; - &::-webkit-scrollbar { - display: none; - } [data-slot="list-empty-state"] { display: flex; @@ -215,7 +192,9 @@ background: linear-gradient(to bottom, var(--surface-raised-stronger-non-alpha), transparent); pointer-events: none; opacity: 0; - transition: opacity 0.15s ease; + transition-property: opacity; + transition-duration: var(--transition-duration); + transition-timing-function: var(--transition-easing); } &[data-stuck="true"]::after { @@ -251,17 +230,22 @@ align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1/1; + aspect-ratio: 1 / 1; [data-component="icon"] { color: var(--icon-strong-base); } } + + [name="check"] { + color: var(--icon-strong-base); + } + [data-slot="list-item-active-icon"] { display: none; align-items: center; justify-content: center; flex-shrink: 0; - aspect-ratio: 1/1; + aspect-ratio: 1 / 1; [data-component="icon"] { color: var(--icon-strong-base); } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 2132897f7c..950ad3d203 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -5,6 +5,7 @@ import { useI18n } from "../context/i18n" import { Icon, type IconProps } from "./icon" import { IconButton } from "./icon-button" import { TextField } from "./text-field" +import { ScrollFade } from "./scroll-fade" function findByKey(container: HTMLElement, key: string) { const nodes = container.querySelectorAll('[data-slot="list-item"][data-key]') @@ -267,7 +268,13 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {searchAction()}
-
+ 0 || showAdd()} fallback={ @@ -339,7 +346,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
-
+
) -} +} \ No newline at end of file diff --git a/packages/ui/src/components/scroll-fade.css b/packages/ui/src/components/scroll-fade.css new file mode 100644 index 0000000000..ede5fabec4 --- /dev/null +++ b/packages/ui/src/components/scroll-fade.css @@ -0,0 +1,82 @@ +[data-component="scroll-fade"] { + overflow: auto; + overscroll-behavior: contain; + scrollbar-width: none; + box-sizing: border-box; + color: inherit; + font: inherit; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } + + &[data-direction="horizontal"] { + overflow-x: auto; + overflow-y: hidden; + + /* Both fades */ + &[data-fade-start][data-fade-end] { + mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to right, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + + /* Only start fade */ + &[data-fade-start]:not([data-fade-end]) { + mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); + -webkit-mask-image: linear-gradient(to right, transparent, black var(--scroll-fade-start), black 100%); + } + + /* Only end fade */ + &:not([data-fade-start])[data-fade-end] { + mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + -webkit-mask-image: linear-gradient(to right, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + } + } + + &[data-direction="vertical"] { + overflow-y: auto; + overflow-x: hidden; + + &[data-fade-start][data-fade-end] { + mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + -webkit-mask-image: linear-gradient( + to bottom, + transparent, + black var(--scroll-fade-start), + black calc(100% - var(--scroll-fade-end)), + transparent + ); + } + + /* Only start fade */ + &[data-fade-start]:not([data-fade-end]) { + mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); + -webkit-mask-image: linear-gradient(to bottom, transparent, black var(--scroll-fade-start), black 100%); + } + + /* Only end fade */ + &:not([data-fade-start])[data-fade-end] { + mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + -webkit-mask-image: linear-gradient(to bottom, black 0%, black calc(100% - var(--scroll-fade-end)), transparent); + } + } +} diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx new file mode 100644 index 0000000000..d190d67c03 --- /dev/null +++ b/packages/ui/src/components/scroll-fade.tsx @@ -0,0 +1,206 @@ +import { type JSX, createEffect, createSignal, onCleanup, onMount, splitProps } from "solid-js" + +export interface ScrollFadeProps extends JSX.HTMLAttributes { + direction?: "horizontal" | "vertical" + fadeStartSize?: number + fadeEndSize?: number + trackTransformSelector?: string + ref?: (el: HTMLDivElement) => void +} + +export function ScrollFade(props: ScrollFadeProps) { + const [local, others] = splitProps(props, [ + "children", + "direction", + "fadeStartSize", + "fadeEndSize", + "trackTransformSelector", + "class", + "style", + "ref", + ]) + + const direction = () => local.direction ?? "vertical" + const fadeStartSize = () => local.fadeStartSize ?? 20 + const fadeEndSize = () => local.fadeEndSize ?? 20 + + const getTransformOffset = (element: Element): number => { + const style = getComputedStyle(element) + const transform = style.transform + if (!transform || transform === "none") return 0 + + const match = transform.match(/matrix(?:3d)?\(([^)]+)\)/) + if (!match) return 0 + + const values = match[1].split(",").map((v) => parseFloat(v.trim())) + const isHorizontal = direction() === "horizontal" + + if (transform.startsWith("matrix3d")) { + return isHorizontal ? -(values[12] || 0) : -(values[13] || 0) + } else { + return isHorizontal ? -(values[4] || 0) : -(values[5] || 0) + } + } + + let containerRef: HTMLDivElement | undefined + + const [fadeStart, setFadeStart] = createSignal(0) + const [fadeEnd, setFadeEnd] = createSignal(0) + const [isScrollable, setIsScrollable] = createSignal(false) + + let lastScrollPos = 0 + let lastTransformPos = 0 + let lastScrollSize = 0 + let lastClientSize = 0 + + const updateFade = () => { + if (!containerRef) return + + const isHorizontal = direction() === "horizontal" + const scrollPos = isHorizontal ? containerRef.scrollLeft : containerRef.scrollTop + const scrollSize = isHorizontal ? containerRef.scrollWidth : containerRef.scrollHeight + const clientSize = isHorizontal ? containerRef.clientWidth : containerRef.clientHeight + + let transformPos = 0 + if (local.trackTransformSelector) { + const transformElement = containerRef.querySelector(local.trackTransformSelector) + if (transformElement) { + transformPos = getTransformOffset(transformElement) + } + } + + const effectiveScrollPos = Math.max(scrollPos, transformPos) + + if ( + effectiveScrollPos === lastScrollPos && + transformPos === lastTransformPos && + scrollSize === lastScrollSize && + clientSize === lastClientSize + ) { + return + } + + lastScrollPos = effectiveScrollPos + lastTransformPos = transformPos + lastScrollSize = scrollSize + lastClientSize = clientSize + + const maxScroll = scrollSize - clientSize + const canScroll = maxScroll > 1 + + setIsScrollable(canScroll) + + if (!canScroll) { + setFadeStart(0) + setFadeEnd(0) + return + } + + const progress = maxScroll > 0 ? effectiveScrollPos / maxScroll : 0 + + const startProgress = Math.min(progress / 0.1, 1) + setFadeStart(startProgress * fadeStartSize()) + + const endProgress = progress > 0.9 ? (1 - progress) / 0.1 : 1 + setFadeEnd(Math.max(0, endProgress) * fadeEndSize()) + } + + onMount(() => { + if (!containerRef) return + + updateFade() + + let rafId: number | undefined + let isPolling = false + let pollTimeout: ReturnType | undefined + + const startPolling = () => { + if (isPolling) return + isPolling = true + + const pollScroll = () => { + updateFade() + rafId = requestAnimationFrame(pollScroll) + } + rafId = requestAnimationFrame(pollScroll) + } + + const stopPolling = () => { + if (!isPolling) return + isPolling = false + if (rafId !== undefined) { + cancelAnimationFrame(rafId) + rafId = undefined + } + } + + const schedulePollingStop = () => { + if (pollTimeout !== undefined) clearTimeout(pollTimeout) + pollTimeout = setTimeout(stopPolling, 1000) + } + + const onActivity = () => { + updateFade() + if (local.trackTransformSelector) { + startPolling() + schedulePollingStop() + } + } + + containerRef.addEventListener("scroll", onActivity, { passive: true }) + + const resizeObserver = new ResizeObserver(() => { + lastScrollSize = 0 + lastClientSize = 0 + onActivity() + }) + resizeObserver.observe(containerRef) + + const mutationObserver = new MutationObserver(() => { + lastScrollSize = 0 + lastClientSize = 0 + requestAnimationFrame(onActivity) + }) + mutationObserver.observe(containerRef, { + childList: true, + subtree: true, + characterData: true, + }) + + onCleanup(() => { + containerRef?.removeEventListener("scroll", onActivity) + resizeObserver.disconnect() + mutationObserver.disconnect() + stopPolling() + if (pollTimeout !== undefined) clearTimeout(pollTimeout) + }) + }) + + createEffect(() => { + local.children + requestAnimationFrame(updateFade) + }) + + return ( +
{ + containerRef = el + local.ref?.(el) + }} + data-component="scroll-fade" + data-direction={direction()} + data-scrollable={isScrollable() || undefined} + data-fade-start={fadeStart() > 0 || undefined} + data-fade-end={fadeEnd() > 0 || undefined} + class={local.class} + style={{ + ...(typeof local.style === "object" ? local.style : {}), + "--scroll-fade-start": `${fadeStart()}px`, + "--scroll-fade-end": `${fadeEnd()}px`, + }} + {...others} + > + {local.children} +
+ ) +} \ No newline at end of file diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx new file mode 100644 index 0000000000..7eb7ff37fa --- /dev/null +++ b/packages/ui/src/components/scroll-reveal.tsx @@ -0,0 +1,141 @@ +import { type JSX, onCleanup, splitProps } from "solid-js" +import { ScrollFade, type ScrollFadeProps } from './scroll-fade' + +const SCROLL_SPEED = 60 +const PAUSE_DURATION = 800 + +type ScrollAnimationState = { + rafId: number | null + startTime: number + running: boolean +} + +const startScrollAnimation = (containerEl: HTMLElement): ScrollAnimationState | null => { + containerEl.offsetHeight + + const extraWidth = containerEl.scrollWidth - containerEl.clientWidth + + if (extraWidth <= 0) { + return null + } + + const scrollDuration = (extraWidth / SCROLL_SPEED) * 1000 + const totalDuration = PAUSE_DURATION + scrollDuration + PAUSE_DURATION + scrollDuration + PAUSE_DURATION + + const state: ScrollAnimationState = { + rafId: null, + startTime: performance.now(), + running: true, + } + + const animate = (currentTime: number) => { + if (!state.running) return + + const elapsed = currentTime - state.startTime + const progress = (elapsed % totalDuration) / totalDuration + + const pausePercent = PAUSE_DURATION / totalDuration + const scrollPercent = scrollDuration / totalDuration + + const pauseEnd1 = pausePercent + const scrollEnd1 = pauseEnd1 + scrollPercent + const pauseEnd2 = scrollEnd1 + pausePercent + const scrollEnd2 = pauseEnd2 + scrollPercent + + let scrollPos = 0 + + if (progress < pauseEnd1) { + scrollPos = 0 + } else if (progress < scrollEnd1) { + const scrollProgress = (progress - pauseEnd1) / scrollPercent + scrollPos = scrollProgress * extraWidth + } else if (progress < pauseEnd2) { + scrollPos = extraWidth + } else if (progress < scrollEnd2) { + const scrollProgress = (progress - pauseEnd2) / scrollPercent + scrollPos = extraWidth * (1 - scrollProgress) + } else { + scrollPos = 0 + } + + containerEl.scrollLeft = scrollPos + state.rafId = requestAnimationFrame(animate) + } + + state.rafId = requestAnimationFrame(animate) + return state +} + +const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: HTMLElement) => { + if (state) { + state.running = false + if (state.rafId !== null) { + cancelAnimationFrame(state.rafId) + } + } + if (containerEl) { + containerEl.scrollLeft = 0 + } +} + +export interface ScrollRevealProps extends Omit { + hoverDelay?: number +} + +export function ScrollReveal(props: ScrollRevealProps) { + const [local, others] = splitProps(props, ["children", "hoverDelay", "ref"]) + + const hoverDelay = () => local.hoverDelay ?? 300 + + let containerRef: HTMLDivElement | undefined + let hoverTimeout: ReturnType | undefined + let scrollAnimationState: ScrollAnimationState | null = null + + const handleMouseEnter: JSX.EventHandler = () => { + hoverTimeout = setTimeout(() => { + if (!containerRef) return + + containerRef.offsetHeight + + const isScrollable = containerRef.scrollWidth > containerRef.clientWidth + 1 + + if (isScrollable) { + stopScrollAnimation(scrollAnimationState, containerRef) + scrollAnimationState = startScrollAnimation(containerRef) + } + }, hoverDelay()) + } + + const handleMouseLeave: JSX.EventHandler = () => { + if (hoverTimeout) { + clearTimeout(hoverTimeout) + hoverTimeout = undefined + } + stopScrollAnimation(scrollAnimationState, containerRef) + scrollAnimationState = null + } + + onCleanup(() => { + if (hoverTimeout) { + clearTimeout(hoverTimeout) + } + stopScrollAnimation(scrollAnimationState, containerRef) + }) + + return ( + { + containerRef = el + local.ref?.(el) + }} + fadeStartSize={8} + fadeEndSize={8} + direction="horizontal" + onMouseEnter={handleMouseEnter} + onMouseLeave={handleMouseLeave} + {...others} + > + {local.children} + + ) +} \ No newline at end of file diff --git a/packages/ui/src/styles/index.css b/packages/ui/src/styles/index.css index cae48137ba..2a8171f98c 100644 --- a/packages/ui/src/styles/index.css +++ b/packages/ui/src/styles/index.css @@ -40,6 +40,7 @@ @import "../components/select.css" layer(components); @import "../components/spinner.css" layer(components); @import "../components/switch.css" layer(components); +@import "../components/scroll-fade.css" layer(components); @import "../components/session-review.css" layer(components); @import "../components/session-turn.css" layer(components); @import "../components/sticky-accordion-header.css" layer(components); From fa75d922ed9e9c7e11ae43baec933674d5d3012d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 2 Feb 2026 00:18:42 +0000 Subject: [PATCH 35/53] chore: generate --- .../app/src/components/settings-general.tsx | 39 +++++++++++-------- .../app/src/components/settings-models.tsx | 7 +++- .../app/src/components/settings-providers.tsx | 7 +++- packages/ui/src/components/list.tsx | 10 +---- packages/ui/src/components/scroll-fade.tsx | 2 +- packages/ui/src/components/scroll-reveal.tsx | 4 +- 6 files changed, 39 insertions(+), 30 deletions(-) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 1d82a24c30..a0251ed41b 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -61,24 +61,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, @@ -131,7 +131,12 @@ export const SettingsGeneral: Component = () => { const soundOptions = [...SOUND_OPTIONS] return ( - +

{language.t("settings.tab.general")}

diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index c9453ddf1b..0ee5caf73d 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -40,7 +40,12 @@ export const SettingsModels: Component = () => { }) return ( - +

{language.t("settings.models.title")}

diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 71a0bb6ad8..2460534c05 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -116,7 +116,12 @@ export const SettingsProviders: Component = () => { } return ( - +

{language.t("settings.providers.title")}

diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 950ad3d203..15854180e4 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -268,13 +268,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {searchAction()}
- + 0 || showAdd()} fallback={ @@ -349,4 +343,4 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/scroll-fade.tsx b/packages/ui/src/components/scroll-fade.tsx index d190d67c03..97f0339e82 100644 --- a/packages/ui/src/components/scroll-fade.tsx +++ b/packages/ui/src/components/scroll-fade.tsx @@ -203,4 +203,4 @@ export function ScrollFade(props: ScrollFadeProps) { {local.children}
) -} \ No newline at end of file +} diff --git a/packages/ui/src/components/scroll-reveal.tsx b/packages/ui/src/components/scroll-reveal.tsx index 7eb7ff37fa..6e5072dc81 100644 --- a/packages/ui/src/components/scroll-reveal.tsx +++ b/packages/ui/src/components/scroll-reveal.tsx @@ -1,5 +1,5 @@ import { type JSX, onCleanup, splitProps } from "solid-js" -import { ScrollFade, type ScrollFadeProps } from './scroll-fade' +import { ScrollFade, type ScrollFadeProps } from "./scroll-fade" const SCROLL_SPEED = 60 const PAUSE_DURATION = 800 @@ -138,4 +138,4 @@ export function ScrollReveal(props: ScrollRevealProps) { {local.children} ) -} \ No newline at end of file +} From 12b8c4238768d4fc3aa57c7023c9724c4f705db2 Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Sat, 31 Jan 2026 10:27:27 -0500 Subject: [PATCH 36/53] feat(app): show skill/mcp badges for slash commands Display 'skill' or 'mcp' badge instead of 'custom' for slash commands based on their source type. This provides better clarity to users about where each command comes from. --- packages/app/src/components/prompt-input.tsx | 8 +++++++- packages/app/src/i18n/ar.ts | 2 ++ packages/app/src/i18n/br.ts | 2 ++ packages/app/src/i18n/da.ts | 2 ++ packages/app/src/i18n/de.ts | 2 ++ packages/app/src/i18n/en.ts | 2 ++ packages/app/src/i18n/es.ts | 2 ++ packages/app/src/i18n/fr.ts | 2 ++ packages/app/src/i18n/ja.ts | 2 ++ packages/app/src/i18n/ko.ts | 2 ++ packages/app/src/i18n/no.ts | 2 ++ packages/app/src/i18n/pl.ts | 2 ++ packages/app/src/i18n/ru.ts | 2 ++ packages/app/src/i18n/th.ts | 2 ++ packages/app/src/i18n/zh.ts | 2 ++ packages/app/src/i18n/zht.ts | 2 ++ 16 files changed, 37 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index c175824deb..9a9d589a7e 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -115,6 +115,7 @@ interface SlashCommand { description?: string keybind?: string type: "builtin" | "custom" + source?: "command" | "mcp" | "skill" } export const PromptInput: Component = (props) => { @@ -520,6 +521,7 @@ export const PromptInput: Component = (props) => { title: cmd.name, description: cmd.description, type: "custom" as const, + source: cmd.source, })) return [...custom, ...builtin] @@ -1728,7 +1730,11 @@ export const PromptInput: Component = (props) => {
- {language.t("prompt.slash.badge.custom")} + {cmd.source === "skill" + ? language.t("prompt.slash.badge.skill") + : cmd.source === "mcp" + ? language.t("prompt.slash.badge.mcp") + : language.t("prompt.slash.badge.custom")} diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 80179144a8..e3831e23c4 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "لا توجد أوامر مطابقة", "prompt.dropzone.label": "أفلت الصور أو ملفات PDF هنا", "prompt.slash.badge.custom": "مخصص", + "prompt.slash.badge.skill": "مهارة", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "نشط", "prompt.context.includeActiveFile": "تضمين الملف النشط", "prompt.context.removeActiveFile": "إزالة الملف النشط من السياق", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index c874a4376b..f930a66aff 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Nenhum comando correspondente", "prompt.dropzone.label": "Solte imagens ou PDFs aqui", "prompt.slash.badge.custom": "personalizado", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "ativo", "prompt.context.includeActiveFile": "Incluir arquivo ativo", "prompt.context.removeActiveFile": "Remover arquivo ativo do contexto", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 555990a9c4..2b7d77456d 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slip billeder eller PDF'er her", "prompt.slash.badge.custom": "brugerdefineret", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Inkluder aktiv fil", "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index e56081c908..4648ad9c41 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -214,6 +214,8 @@ export const dict = { "prompt.popover.emptyCommands": "Keine passenden Befehle", "prompt.dropzone.label": "Bilder oder PDFs hier ablegen", "prompt.slash.badge.custom": "benutzerdefiniert", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Aktive Datei einbeziehen", "prompt.context.removeActiveFile": "Aktive Datei aus dem Kontext entfernen", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 4254860ac9..12ddcb4cd8 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -216,6 +216,8 @@ export const dict = { "prompt.popover.emptyCommands": "No matching commands", "prompt.dropzone.label": "Drop images or PDFs here", "prompt.slash.badge.custom": "custom", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "active", "prompt.context.includeActiveFile": "Include active file", "prompt.context.removeActiveFile": "Remove active file from context", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index e928f03cec..5d396f0b4f 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Sin comandos coincidentes", "prompt.dropzone.label": "Suelta imágenes o PDFs aquí", "prompt.slash.badge.custom": "personalizado", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "activo", "prompt.context.includeActiveFile": "Incluir archivo activo", "prompt.context.removeActiveFile": "Eliminar archivo activo del contexto", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 31000cd17a..4226d0c7e2 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Aucune commande correspondante", "prompt.dropzone.label": "Déposez des images ou des PDF ici", "prompt.slash.badge.custom": "personnalisé", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "actif", "prompt.context.includeActiveFile": "Inclure le fichier actif", "prompt.context.removeActiveFile": "Retirer le fichier actif du contexte", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 80efc5c2aa..28a925a0d3 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -209,6 +209,8 @@ export const dict = { "prompt.popover.emptyCommands": "一致するコマンドがありません", "prompt.dropzone.label": "画像またはPDFをここにドロップ", "prompt.slash.badge.custom": "カスタム", + "prompt.slash.badge.skill": "スキル", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "アクティブ", "prompt.context.includeActiveFile": "アクティブなファイルを含める", "prompt.context.removeActiveFile": "コンテキストからアクティブなファイルを削除", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 014092d07f..1be4e1eb4b 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -213,6 +213,8 @@ export const dict = { "prompt.popover.emptyCommands": "일치하는 명령어 없음", "prompt.dropzone.label": "이미지나 PDF를 여기에 드롭하세요", "prompt.slash.badge.custom": "사용자 지정", + "prompt.slash.badge.skill": "스킬", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "활성", "prompt.context.includeActiveFile": "활성 파일 포함", "prompt.context.removeActiveFile": "컨텍스트에서 활성 파일 제거", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 400ce37d35..0a3b398856 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -213,6 +213,8 @@ export const dict = { "prompt.popover.emptyCommands": "Ingen matchende kommandoer", "prompt.dropzone.label": "Slipp bilder eller PDF-er her", "prompt.slash.badge.custom": "egendefinert", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktiv", "prompt.context.includeActiveFile": "Inkluder aktiv fil", "prompt.context.removeActiveFile": "Fjern aktiv fil fra kontekst", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 5a05809829..f4457c6acf 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Brak pasujących poleceń", "prompt.dropzone.label": "Upuść obrazy lub pliki PDF tutaj", "prompt.slash.badge.custom": "własne", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "aktywny", "prompt.context.includeActiveFile": "Dołącz aktywny plik", "prompt.context.removeActiveFile": "Usuń aktywny plik z kontekstu", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 4277368f5d..d5a4014d36 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -210,6 +210,8 @@ export const dict = { "prompt.popover.emptyCommands": "Нет совпадающих команд", "prompt.dropzone.label": "Перетащите изображения или PDF сюда", "prompt.slash.badge.custom": "своё", + "prompt.slash.badge.skill": "навык", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "активно", "prompt.context.includeActiveFile": "Включить активный файл", "prompt.context.removeActiveFile": "Удалить активный файл из контекста", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index e2eabd7ad8..1914b8e5bd 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -215,6 +215,8 @@ export const dict = { "prompt.popover.emptyCommands": "ไม่พบคำสั่งที่ตรงกัน", "prompt.dropzone.label": "วางรูปภาพหรือ PDF ที่นี่", "prompt.slash.badge.custom": "กำหนดเอง", + "prompt.slash.badge.skill": "skill", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "ใช้งานอยู่", "prompt.context.includeActiveFile": "รวมไฟล์ที่ใช้งานอยู่", "prompt.context.removeActiveFile": "เอาไฟล์ที่ใช้งานอยู่ออกจากบริบท", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 118e03ce47..b9d5395730 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -214,6 +214,8 @@ export const dict = { "prompt.popover.emptyCommands": "没有匹配的命令", "prompt.dropzone.label": "将图片或 PDF 拖到这里", "prompt.slash.badge.custom": "自定义", + "prompt.slash.badge.skill": "技能", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "当前", "prompt.context.includeActiveFile": "包含当前文件", "prompt.context.removeActiveFile": "从上下文移除活动文件", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 45a789df4c..23d3d80e13 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -211,6 +211,8 @@ export const dict = { "prompt.popover.emptyCommands": "沒有符合的命令", "prompt.dropzone.label": "將圖片或 PDF 拖到這裡", "prompt.slash.badge.custom": "自訂", + "prompt.slash.badge.skill": "技能", + "prompt.slash.badge.mcp": "mcp", "prompt.context.active": "作用中", "prompt.context.includeActiveFile": "包含作用中檔案", "prompt.context.removeActiveFile": "從上下文移除目前檔案", From cb6ec0a564cfc332e7af3c844157e145c1d86a60 Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Sat, 31 Jan 2026 12:11:30 -0500 Subject: [PATCH 37/53] fix(app): hide badge for builtin slash commands Add source: 'command' to builtin and config-defined commands so they don't show a 'custom' badge. Only MCP and skill commands show badges. --- packages/app/src/components/prompt-input.tsx | 2 +- packages/opencode/src/command/index.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 9a9d589a7e..2b63b6f5fd 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1728,7 +1728,7 @@ export const PromptInput: Component = (props) => {
- + {cmd.source === "skill" ? language.t("prompt.slash.badge.skill") diff --git a/packages/opencode/src/command/index.ts b/packages/opencode/src/command/index.ts index 14dbeb6794..dce7ac8bbc 100644 --- a/packages/opencode/src/command/index.ts +++ b/packages/opencode/src/command/index.ts @@ -63,6 +63,7 @@ export namespace Command { [Default.INIT]: { name: Default.INIT, description: "create/update AGENTS.md", + source: "command", get template() { return PROMPT_INITIALIZE.replace("${path}", Instance.worktree) }, @@ -71,6 +72,7 @@ export namespace Command { [Default.REVIEW]: { name: Default.REVIEW, description: "review changes [commit|branch|pr], defaults to uncommitted", + source: "command", get template() { return PROMPT_REVIEW.replace("${path}", Instance.worktree) }, @@ -85,6 +87,7 @@ export namespace Command { agent: command.agent, model: command.model, description: command.description, + source: "command", get template() { return command.template }, From 612b656d3670f252541be79f96bfda31d78dcf73 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 30 Jan 2026 15:55:00 -0600 Subject: [PATCH 38/53] fix: adjust resolve parts so that when messages with multiple @ references occur, the tool calls are properly ordered --- packages/opencode/src/session/processor.ts | 10 +++- packages/opencode/src/session/prompt.ts | 69 ++++++++++++++-------- packages/opencode/src/tool/batch.ts | 8 ++- packages/opencode/src/tool/read.ts | 4 -- packages/opencode/src/tool/tool.ts | 2 +- 5 files changed, 60 insertions(+), 33 deletions(-) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2707105618..ada6f83145 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -172,6 +172,14 @@ export namespace SessionProcessor { case "tool-result": { const match = toolcalls[value.toolCallId] if (match && match.state.status === "running") { + const attachments = value.output.attachments?.map( + (attachment: Omit) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: match.messageID, + sessionID: match.sessionID, + }), + ) await Session.updatePart({ ...match, state: { @@ -184,7 +192,7 @@ export namespace SessionProcessor { start: match.state.time.start, end: Date.now(), }, - attachments: value.output.attachments, + attachments, }, }) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index ba77cd7ca3..c6d040f2fa 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -187,13 +187,17 @@ export namespace SessionPrompt { text: template, }, ] - const files = ConfigMarkdown.files(template) + const matches = ConfigMarkdown.files(template) const seen = new Set() - await Promise.all( - files.map(async (match) => { - const name = match[1] - if (seen.has(name)) return + const names = matches + .map((match) => match[1]) + .filter((name) => { + if (seen.has(name)) return false seen.add(name) + return true + }) + const resolved = await Promise.all( + names.map(async (name) => { const filepath = name.startsWith("~/") ? path.join(os.homedir(), name.slice(2)) : path.resolve(Instance.worktree, name) @@ -201,33 +205,34 @@ export namespace SessionPrompt { const stats = await fs.stat(filepath).catch(() => undefined) if (!stats) { const agent = await Agent.get(name) - if (agent) { - parts.push({ - type: "agent", - name: agent.name, - }) - } - return + if (!agent) return undefined + return { + type: "agent", + name: agent.name, + } satisfies PromptInput["parts"][number] } if (stats.isDirectory()) { - parts.push({ + return { type: "file", url: `file://${filepath}`, filename: name, mime: "application/x-directory", - }) - return + } satisfies PromptInput["parts"][number] } - parts.push({ + return { type: "file", url: `file://${filepath}`, filename: name, mime: "text/plain", - }) + } satisfies PromptInput["parts"][number] }), ) + for (const item of resolved) { + if (!item) continue + parts.push(item) + } return parts } @@ -424,6 +429,12 @@ export namespace SessionPrompt { assistantMessage.time.completed = Date.now() await Session.updateMessage(assistantMessage) if (result && part.state.status === "running") { + const attachments = result.attachments?.map((attachment) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: assistantMessage.id, + sessionID: assistantMessage.sessionID, + })) await Session.updatePart({ ...part, state: { @@ -432,7 +443,7 @@ export namespace SessionPrompt { title: result.title, metadata: result.metadata, output: result.output, - attachments: result.attachments, + attachments, time: { ...part.state.time, end: Date.now(), @@ -771,16 +782,13 @@ export namespace SessionPrompt { ) const textParts: string[] = [] - const attachments: MessageV2.FilePart[] = [] + const attachments: Omit[] = [] for (const contentItem of result.content) { if (contentItem.type === "text") { textParts.push(contentItem.text) } else if (contentItem.type === "image") { attachments.push({ - id: Identifier.ascending("part"), - sessionID: input.session.id, - messageID: input.processor.message.id, type: "file", mime: contentItem.mimeType, url: `data:${contentItem.mimeType};base64,${contentItem.data}`, @@ -792,9 +800,6 @@ export namespace SessionPrompt { } if (resource.blob) { attachments.push({ - id: Identifier.ascending("part"), - sessionID: input.session.id, - messageID: input.processor.message.id, type: "file", mime: resource.mimeType ?? "application/octet-stream", url: `data:${resource.mimeType ?? "application/octet-stream"};base64,${resource.blob}`, @@ -1043,6 +1048,7 @@ export namespace SessionPrompt { pieces.push( ...result.attachments.map((attachment) => ({ ...attachment, + id: Identifier.ascending("part"), synthetic: true, filename: attachment.filename ?? part.filename, messageID: info.id, @@ -1180,7 +1186,18 @@ export namespace SessionPrompt { }, ] }), - ).then((x) => x.flat()) + ) + .then((x) => x.flat()) + .then((drafts) => + drafts.map( + (part): MessageV2.Part => ({ + ...part, + id: Identifier.ascending("part"), + messageID: info.id, + sessionID: input.sessionID, + }), + ), + ) await Plugin.trigger( "chat.message", diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index ba34eb48f5..b5c3ad0a12 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -77,6 +77,12 @@ export const BatchTool = Tool.define("batch", async () => { }) const result = await tool.execute(validatedParams, { ...ctx, callID: partID }) + const attachments = result.attachments?.map((attachment) => ({ + ...attachment, + id: Identifier.ascending("part"), + messageID: ctx.messageID, + sessionID: ctx.sessionID, + })) await Session.updatePart({ id: partID, @@ -91,7 +97,7 @@ export const BatchTool = Tool.define("batch", async () => { output: result.output, title: result.title, metadata: result.metadata, - attachments: result.attachments, + attachments, time: { start: callStartTime, end: Date.now(), diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index f230cdf44c..13236d44dd 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -6,7 +6,6 @@ import { LSP } from "../lsp" import { FileTime } from "../file/time" import DESCRIPTION from "./read.txt" import { Instance } from "../project/instance" -import { Identifier } from "../id/id" import { assertExternalDirectory } from "./external-directory" import { InstructionPrompt } from "../session/instruction" @@ -79,9 +78,6 @@ export const ReadTool = Tool.define("read", { }, attachments: [ { - id: Identifier.ascending("part"), - sessionID: ctx.sessionID, - messageID: ctx.messageID, type: "file", mime, url: `data:${mime};base64,${Buffer.from(await file.bytes()).toString("base64")}`, diff --git a/packages/opencode/src/tool/tool.ts b/packages/opencode/src/tool/tool.ts index 3d17ea192d..0e78ba665c 100644 --- a/packages/opencode/src/tool/tool.ts +++ b/packages/opencode/src/tool/tool.ts @@ -36,7 +36,7 @@ export namespace Tool { title: string metadata: M output: string - attachments?: MessageV2.FilePart[] + attachments?: Omit[] }> formatValidationError?(error: z.ZodError): string }> From 5db089070a24d66063f55d4f5baf0da20883daf9 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 30 Jan 2026 16:04:07 -0600 Subject: [PATCH 39/53] test: add unit test --- packages/opencode/test/session/prompt.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 packages/opencode/test/session/prompt.test.ts diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts new file mode 100644 index 0000000000..e778bfe514 --- /dev/null +++ b/packages/opencode/test/session/prompt.test.ts @@ -0,0 +1,62 @@ +import path from "path" +import { describe, expect, test } from "bun:test" +import { Session } from "../../src/session" +import { SessionPrompt } from "../../src/session/prompt" +import { MessageV2 } from "../../src/session/message-v2" +import { Instance } from "../../src/project/instance" +import { Log } from "../../src/util/log" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +describe("SessionPrompt ordering", () => { + test("keeps @file order with read output parts", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + await Bun.write(path.join(dir, "a.txt"), "28\n") + await Bun.write(path.join(dir, "b.txt"), "42\n") + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const session = await Session.create({}) + const template = "What numbers are written in files @a.txt and @b.txt ?" + const parts = await SessionPrompt.resolvePromptParts(template) + const fileParts = parts.filter((part) => part.type === "file") + + expect(fileParts.map((part) => part.filename)).toStrictEqual(["a.txt", "b.txt"]) + + const message = await SessionPrompt.prompt({ + sessionID: session.id, + parts, + noReply: true, + }) + const stored = await MessageV2.get({ sessionID: session.id, messageID: message.info.id }) + const items = stored.parts + const aPath = path.join(tmp.path, "a.txt") + const bPath = path.join(tmp.path, "b.txt") + const sequence = items.flatMap((part) => { + if (part.type === "text") { + if (part.text.includes(aPath)) return ["input:a"] + if (part.text.includes(bPath)) return ["input:b"] + if (part.text.includes("00001| 28")) return ["output:a"] + if (part.text.includes("00001| 42")) return ["output:b"] + return [] + } + if (part.type === "file") { + if (part.filename === "a.txt") return ["file:a"] + if (part.filename === "b.txt") return ["file:b"] + } + return [] + }) + + expect(sequence).toStrictEqual(["input:a", "output:a", "file:a", "input:b", "output:b", "file:b"]) + + await Session.remove(session.id) + }, + }) + }) +}) From 7417e6eb38e96c5e3904a4503740a305475d154a Mon Sep 17 00:00:00 2001 From: R44VC0RP Date: Fri, 30 Jan 2026 16:13:27 -0500 Subject: [PATCH 40/53] fix(plugin): correct exports to point to dist instead of src The package.json exports were pointing to ./src/*.ts but the published package only includes the dist/ folder. This caused 'Cannot find module' errors when custom tools tried to import @opencode-ai/plugin. Changed exports from: ".": "./src/index.ts" "./tool": "./src/tool.ts" To: ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } "./tool": { "types": "./dist/tool.d.ts", "import": "./dist/tool.js" } --- packages/plugin/package.json | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 5a4afbae43..160ce6a826 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -9,8 +9,14 @@ "build": "tsc" }, "exports": { - ".": "./src/index.ts", - "./tool": "./src/tool.ts" + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./tool": { + "types": "./dist/tool.d.ts", + "import": "./dist/tool.js" + } }, "files": [ "dist" From d35956fd9272e302201d99548a1389315a20cdaf Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:26:21 -0500 Subject: [PATCH 41/53] ci: prevent rate limit errors when fetching team PRs for beta releases --- script/beta.ts | 52 ++++++++++++++++++++++++++------------------------ 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 7a3dfcccf4..a17f02a091 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,8 +1,11 @@ #!/usr/bin/env bun +import { Script } from "@opencode-ai/script" + interface PR { number: number title: string + author: { login: string } } interface RunResult { @@ -12,15 +15,29 @@ interface RunResult { } async function main() { - console.log("Fetching open contributor PRs...") + console.log("Fetching open PRs from team members...") - const prsResult = await $`gh pr list --label contributor --state open --json number,title --limit 100`.nothrow() - if (prsResult.exitCode !== 0) { - throw new Error(`Failed to fetch PRs: ${prsResult.stderr}`) + const allPrs: PR[] = [] + for (const member of Script.team) { + const result = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.nothrow() + if (result.exitCode !== 0) continue + const memberPrs: PR[] = JSON.parse(result.stdout) + allPrs.push(...memberPrs) } - const prs: PR[] = JSON.parse(prsResult.stdout) - console.log(`Found ${prs.length} open contributor PRs`) + const seen = new Set() + const prs = allPrs.filter((pr) => { + if (seen.has(pr.number)) return false + seen.add(pr.number) + return true + }) + + console.log(`Found ${prs.length} open PRs from team members`) + + if (prs.length === 0) { + console.log("No team PRs to merge") + return + } console.log("Fetching latest dev branch...") const fetchDev = await $`git fetch origin dev`.nothrow() @@ -35,7 +52,6 @@ async function main() { } const applied: number[] = [] - const skipped: Array<{ number: number; reason: string }> = [] for (const pr of prs) { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) @@ -43,9 +59,7 @@ async function main() { console.log(" Fetching PR head...") const fetch = await run(["git", "fetch", "origin", `pull/${pr.number}/head:pr/${pr.number}`]) if (fetch.exitCode !== 0) { - console.log(` Failed to fetch PR head: ${fetch.stderr}`) - skipped.push({ number: pr.number, reason: `Fetch failed: ${fetch.stderr}` }) - continue + throw new Error(`Failed to fetch PR #${pr.number}: ${fetch.stderr}`) } console.log(" Merging...") @@ -55,34 +69,24 @@ async function main() { await $`git merge --abort`.nothrow() await $`git checkout -- .`.nothrow() await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: "Has conflicts" }) - continue + throw new Error(`Failed to merge PR #${pr.number}: Has conflicts`) } const mergeHead = await $`git rev-parse -q --verify MERGE_HEAD`.nothrow() if (mergeHead.exitCode !== 0) { console.log(" No changes, skipping") - skipped.push({ number: pr.number, reason: "No changes" }) continue } const add = await $`git add -A`.nothrow() if (add.exitCode !== 0) { - console.log(" Failed to stage") - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: "Failed to stage" }) - continue + throw new Error(`Failed to stage changes for PR #${pr.number}`) } const commitMsg = `Apply PR #${pr.number}: ${pr.title}` const commit = await run(["git", "commit", "-m", commitMsg]) if (commit.exitCode !== 0) { - console.log(` Failed to commit: ${commit.stderr}`) - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() - skipped.push({ number: pr.number, reason: `Commit failed: ${commit.stderr}` }) - continue + throw new Error(`Failed to commit PR #${pr.number}: ${commit.stderr}`) } console.log(" Applied successfully") @@ -92,8 +96,6 @@ async function main() { console.log("\n--- Summary ---") console.log(`Applied: ${applied.length} PRs`) applied.forEach((num) => console.log(` - PR #${num}`)) - console.log(`Skipped: ${skipped.length} PRs`) - skipped.forEach((x) => console.log(` - PR #${x.number}: ${x.reason}`)) console.log("\nForce pushing beta branch...") const push = await $`git push origin beta --force --no-verify`.nothrow() From c9891fe0718551daa9dddccf0aacec1647850786 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:36:40 -0500 Subject: [PATCH 42/53] ci: collect all failed PR merges and notify Discord --- script/beta.ts | 61 ++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 57 insertions(+), 4 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index a17f02a091..5648c8b8a5 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -14,6 +14,42 @@ interface RunResult { stderr: string } +interface FailedPR { + number: number + title: string + reason: string +} + +async function postToDiscord(failures: FailedPR[]) { + const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL + if (!webhookUrl) { + console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") + return + } + + const message = `**Beta Branch Merge Failures** + +The following team PRs failed to merge into the beta branch: + +${failures.map((f) => `- **#${f.number}**: ${f.title} - ${f.reason}`).join("\n")} + +Please resolve these conflicts manually.` + + const content = JSON.stringify({ content: message }) + + const response = await fetch(webhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: content, + }) + + if (!response.ok) { + console.error("Failed to post to Discord:", await response.text()) + } else { + console.log("Posted failures to Discord") + } +} + async function main() { console.log("Fetching open PRs from team members...") @@ -52,6 +88,7 @@ async function main() { } const applied: number[] = [] + const failed: FailedPR[] = [] for (const pr of prs) { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) @@ -59,7 +96,9 @@ async function main() { console.log(" Fetching PR head...") const fetch = await run(["git", "fetch", "origin", `pull/${pr.number}/head:pr/${pr.number}`]) if (fetch.exitCode !== 0) { - throw new Error(`Failed to fetch PR #${pr.number}: ${fetch.stderr}`) + console.log(` Failed to fetch: ${fetch.stderr}`) + failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" }) + continue } console.log(" Merging...") @@ -69,7 +108,8 @@ async function main() { await $`git merge --abort`.nothrow() await $`git checkout -- .`.nothrow() await $`git clean -fd`.nothrow() - throw new Error(`Failed to merge PR #${pr.number}: Has conflicts`) + failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) + continue } const mergeHead = await $`git rev-parse -q --verify MERGE_HEAD`.nothrow() @@ -80,13 +120,17 @@ async function main() { const add = await $`git add -A`.nothrow() if (add.exitCode !== 0) { - throw new Error(`Failed to stage changes for PR #${pr.number}`) + console.log(" Failed to stage changes") + failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" }) + continue } const commitMsg = `Apply PR #${pr.number}: ${pr.title}` const commit = await run(["git", "commit", "-m", commitMsg]) if (commit.exitCode !== 0) { - throw new Error(`Failed to commit PR #${pr.number}: ${commit.stderr}`) + console.log(` Failed to commit: ${commit.stderr}`) + failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" }) + continue } console.log(" Applied successfully") @@ -97,6 +141,15 @@ async function main() { console.log(`Applied: ${applied.length} PRs`) applied.forEach((num) => console.log(` - PR #${num}`)) + if (failed.length > 0) { + console.log(`Failed: ${failed.length} PRs`) + failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) + + await postToDiscord(failed) + + throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) + } + console.log("\nForce pushing beta branch...") const push = await $`git push origin beta --force --no-verify`.nothrow() if (push.exitCode !== 0) { From 2f63152af302ed41ab769f12f79c264ee872f063 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:37:09 -0500 Subject: [PATCH 43/53] ci: add DISCORD_ISSUES_WEBHOOK_URL secret to beta workflow --- .github/workflows/beta.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index a793bfb037..6edbb4326c 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -29,4 +29,5 @@ jobs: - name: Sync beta branch env: GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }} + DISCORD_ISSUES_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} run: bun script/beta.ts From 5dee3328d466708cfd42b2cf5ef77bd40d574d17 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:37:43 -0500 Subject: [PATCH 44/53] ci: add --discord-webhook / -d CLI option for custom Discord webhook URL --- script/beta.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 5648c8b8a5..fff1e91e0d 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { Script } from "@opencode-ai/script" +import { parseArgs } from "util" interface PR { number: number @@ -20,10 +21,10 @@ interface FailedPR { reason: string } -async function postToDiscord(failures: FailedPR[]) { - const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL - if (!webhookUrl) { - console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") +async function postToDiscord(failures: FailedPR[], webhookUrl?: string) { + const url = webhookUrl || process.env.DISCORD_ISSUES_WEBHOOK_URL + if (!url) { + console.log("Warning: No Discord webhook URL provided, skipping notification") return } @@ -37,7 +38,7 @@ Please resolve these conflicts manually.` const content = JSON.stringify({ content: message }) - const response = await fetch(webhookUrl, { + const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: content, @@ -51,6 +52,15 @@ Please resolve these conflicts manually.` } async function main() { + const { values } = parseArgs({ + args: Bun.argv.slice(2), + options: { + "discord-webhook": { type: "string", short: "d" }, + }, + }) + + const discordWebhook = values["discord-webhook"] as string | undefined + console.log("Fetching open PRs from team members...") const allPrs: PR[] = [] @@ -145,7 +155,7 @@ async function main() { console.log(`Failed: ${failed.length} PRs`) failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) - await postToDiscord(failed) + await postToDiscord(failed, discordWebhook) throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) } From e9f8e6aeecbe9d993ddb77129e285b3d31cb4c4a Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:38:20 -0500 Subject: [PATCH 45/53] ci: remove parseArgs CLI option and use environment variable directly --- script/beta.ts | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index fff1e91e0d..5648c8b8a5 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,7 +1,6 @@ #!/usr/bin/env bun import { Script } from "@opencode-ai/script" -import { parseArgs } from "util" interface PR { number: number @@ -21,10 +20,10 @@ interface FailedPR { reason: string } -async function postToDiscord(failures: FailedPR[], webhookUrl?: string) { - const url = webhookUrl || process.env.DISCORD_ISSUES_WEBHOOK_URL - if (!url) { - console.log("Warning: No Discord webhook URL provided, skipping notification") +async function postToDiscord(failures: FailedPR[]) { + const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL + if (!webhookUrl) { + console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") return } @@ -38,7 +37,7 @@ Please resolve these conflicts manually.` const content = JSON.stringify({ content: message }) - const response = await fetch(url, { + const response = await fetch(webhookUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: content, @@ -52,15 +51,6 @@ Please resolve these conflicts manually.` } async function main() { - const { values } = parseArgs({ - args: Bun.argv.slice(2), - options: { - "discord-webhook": { type: "string", short: "d" }, - }, - }) - - const discordWebhook = values["discord-webhook"] as string | undefined - console.log("Fetching open PRs from team members...") const allPrs: PR[] = [] @@ -155,7 +145,7 @@ async function main() { console.log(`Failed: ${failed.length} PRs`) failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) - await postToDiscord(failed, discordWebhook) + await postToDiscord(failed) throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) } From 744fb6aed0c29712a3e058ae12cde144fcf026db Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:39:38 -0500 Subject: [PATCH 46/53] ci: rewrite beta script to use proper Bun shell patterns --- script/beta.ts | 102 ++++++++++++++++++------------------------------- 1 file changed, 37 insertions(+), 65 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 5648c8b8a5..d2d6e9ed41 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,5 +1,6 @@ #!/usr/bin/env bun +import { $ } from "bun" import { Script } from "@opencode-ai/script" interface PR { @@ -8,12 +9,6 @@ interface PR { author: { login: string } } -interface RunResult { - exitCode: number - stdout: string - stderr: string -} - interface FailedPR { number: number title: string @@ -55,10 +50,13 @@ async function main() { const allPrs: PR[] = [] for (const member of Script.team) { - const result = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.nothrow() - if (result.exitCode !== 0) continue - const memberPrs: PR[] = JSON.parse(result.stdout) - allPrs.push(...memberPrs) + try { + const stdout = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.text() + const memberPrs: PR[] = JSON.parse(stdout) + allPrs.push(...memberPrs) + } catch { + // Skip member on error + } } const seen = new Set() @@ -76,16 +74,10 @@ async function main() { } console.log("Fetching latest dev branch...") - const fetchDev = await $`git fetch origin dev`.nothrow() - if (fetchDev.exitCode !== 0) { - throw new Error(`Failed to fetch dev branch: ${fetchDev.stderr}`) - } + await $`git fetch origin dev` console.log("Checking out beta branch...") - const checkoutBeta = await $`git checkout -B beta origin/dev`.nothrow() - if (checkoutBeta.exitCode !== 0) { - throw new Error(`Failed to checkout beta branch: ${checkoutBeta.stderr}`) - } + await $`git checkout -B beta origin/dev` const applied: number[] = [] const failed: FailedPR[] = [] @@ -94,41 +86,52 @@ async function main() { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) console.log(" Fetching PR head...") - const fetch = await run(["git", "fetch", "origin", `pull/${pr.number}/head:pr/${pr.number}`]) - if (fetch.exitCode !== 0) { - console.log(` Failed to fetch: ${fetch.stderr}`) + try { + await $`git fetch origin pull/${pr.number}/head:pr/${pr.number}` + } catch (err) { + console.log(` Failed to fetch: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" }) continue } console.log(" Merging...") - const merge = await run(["git", "merge", "--no-commit", "--no-ff", `pr/${pr.number}`]) - if (merge.exitCode !== 0) { + try { + await $`git merge --no-commit --no-ff pr/${pr.number}` + } catch { console.log(" Failed to merge (conflicts)") - await $`git merge --abort`.nothrow() - await $`git checkout -- .`.nothrow() - await $`git clean -fd`.nothrow() + try { + await $`git merge --abort` + } catch {} + try { + await $`git checkout -- .` + } catch {} + try { + await $`git clean -fd` + } catch {} failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) continue } - const mergeHead = await $`git rev-parse -q --verify MERGE_HEAD`.nothrow() - if (mergeHead.exitCode !== 0) { + try { + await $`git rev-parse -q --verify MERGE_HEAD`.text() + } catch { console.log(" No changes, skipping") continue } - const add = await $`git add -A`.nothrow() - if (add.exitCode !== 0) { + try { + await $`git add -A` + } catch { console.log(" Failed to stage changes") failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" }) continue } const commitMsg = `Apply PR #${pr.number}: ${pr.title}` - const commit = await run(["git", "commit", "-m", commitMsg]) - if (commit.exitCode !== 0) { - console.log(` Failed to commit: ${commit.stderr}`) + try { + await $`git commit -m ${commitMsg}` + } catch (err) { + console.log(` Failed to commit: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" }) continue } @@ -151,10 +154,7 @@ async function main() { } console.log("\nForce pushing beta branch...") - const push = await $`git push origin beta --force --no-verify`.nothrow() - if (push.exitCode !== 0) { - throw new Error(`Failed to push beta branch: ${push.stderr}`) - } + await $`git push origin beta --force --no-verify` console.log("Successfully synced beta branch") } @@ -163,31 +163,3 @@ main().catch((err) => { console.error("Error:", err) process.exit(1) }) - -async function run(args: string[], stdin?: Uint8Array): Promise { - const proc = Bun.spawn(args, { - stdin: stdin ?? "inherit", - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - const stderr = await new Response(proc.stderr).text() - return { exitCode, stdout, stderr } -} - -function $(strings: TemplateStringsArray, ...values: unknown[]) { - const cmd = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "") - return { - async nothrow() { - const proc = Bun.spawn(cmd.split(" "), { - stdout: "pipe", - stderr: "pipe", - }) - const exitCode = await proc.exited - const stdout = await new Response(proc.stdout).text() - const stderr = await new Response(proc.stderr).text() - return { exitCode, stdout, stderr } - }, - } -} From 425abe2fbfc1a432f6b4c19dd89e59f2d7981fff Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:55:40 -0500 Subject: [PATCH 47/53] ci: post PR comments when beta merge fails instead of Discord notifications --- script/beta.ts | 42 ++++++++++++++---------------------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index d2d6e9ed41..264fa9bc61 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -15,33 +15,18 @@ interface FailedPR { reason: string } -async function postToDiscord(failures: FailedPR[]) { - const webhookUrl = process.env.DISCORD_ISSUES_WEBHOOK_URL - if (!webhookUrl) { - console.log("Warning: DISCORD_ISSUES_WEBHOOK_URL not set, skipping Discord notification") - return - } +async function commentOnPR(prNumber: number, reason: string) { + const body = `⚠️ **Blocking Beta Release** - const message = `**Beta Branch Merge Failures** +This PR cannot be merged into the beta branch due to: **${reason}** -The following team PRs failed to merge into the beta branch: +Please resolve this issue to include this PR in the next beta release.` -${failures.map((f) => `- **#${f.number}**: ${f.title} - ${f.reason}`).join("\n")} - -Please resolve these conflicts manually.` - - const content = JSON.stringify({ content: message }) - - const response = await fetch(webhookUrl, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: content, - }) - - if (!response.ok) { - console.error("Failed to post to Discord:", await response.text()) - } else { - console.log("Posted failures to Discord") + try { + await $`gh pr comment ${prNumber} --body ${body}` + console.log(` Posted comment on PR #${prNumber}`) + } catch (err) { + console.log(` Failed to post comment on PR #${prNumber}: ${err}`) } } @@ -91,6 +76,7 @@ async function main() { } catch (err) { console.log(` Failed to fetch: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Fetch failed" }) + await commentOnPR(pr.number, "Fetch failed") continue } @@ -109,6 +95,7 @@ async function main() { await $`git clean -fd` } catch {} failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) + await commentOnPR(pr.number, "Merge conflicts with dev branch") continue } @@ -124,6 +111,7 @@ async function main() { } catch { console.log(" Failed to stage changes") failed.push({ number: pr.number, title: pr.title, reason: "Staging failed" }) + await commentOnPR(pr.number, "Failed to stage changes") continue } @@ -133,6 +121,7 @@ async function main() { } catch (err) { console.log(` Failed to commit: ${err}`) failed.push({ number: pr.number, title: pr.title, reason: "Commit failed" }) + await commentOnPR(pr.number, "Failed to commit changes") continue } @@ -147,10 +136,7 @@ async function main() { if (failed.length > 0) { console.log(`Failed: ${failed.length} PRs`) failed.forEach((f) => console.log(` - PR #${f.number}: ${f.reason}`)) - - await postToDiscord(failed) - - throw new Error(`${failed.length} PR(s) failed to merge. Check Discord for details.`) + throw new Error(`${failed.length} PR(s) failed to merge`) } console.log("\nForce pushing beta branch...") From 4158d7cda87863a9b5a4eea29a40270b8a60a68f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:57:49 -0500 Subject: [PATCH 48/53] ci: add --label beta filter to only process PRs with beta label --- script/beta.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 264fa9bc61..50dc2ad783 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -7,6 +7,7 @@ interface PR { number: number title: string author: { login: string } + labels: Array<{ name: string }> } interface FailedPR { @@ -31,12 +32,13 @@ Please resolve this issue to include this PR in the next beta release.` } async function main() { - console.log("Fetching open PRs from team members...") + console.log("Fetching open PRs with beta label from team members...") const allPrs: PR[] = [] for (const member of Script.team) { try { - const stdout = await $`gh pr list --state open --author ${member} --json number,title,author --limit 100`.text() + const stdout = + await $`gh pr list --state open --author ${member} --label beta --json number,title,author,labels --limit 100`.text() const memberPrs: PR[] = JSON.parse(stdout) allPrs.push(...memberPrs) } catch { @@ -51,7 +53,7 @@ async function main() { return true }) - console.log(`Found ${prs.length} open PRs from team members`) + console.log(`Found ${prs.length} open PRs with beta label from team members`) if (prs.length === 0) { console.log("No team PRs to merge") From 372dcc033c71d0789db25dc7059a5dc9ea3f4cf6 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:58:46 -0500 Subject: [PATCH 49/53] ci: change trigger from scheduled cron to PR labeled events with beta label condition --- .github/workflows/beta.yml | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 6edbb4326c..4b418471f8 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -2,14 +2,16 @@ name: beta on: workflow_dispatch: - schedule: - - cron: "0 * * * *" + pull_request: + types: [labeled] jobs: sync: + if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'beta' runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write + pull-requests: write steps: - name: Checkout repository uses: actions/checkout@v4 @@ -29,5 +31,4 @@ jobs: - name: Sync beta branch env: GH_TOKEN: ${{ steps.setup-git-committer.outputs.token }} - DISCORD_ISSUES_WEBHOOK_URL: ${{ secrets.DISCORD_ISSUES_WEBHOOK_URL }} run: bun script/beta.ts From 7837bbc639c79d74ade3e6c397da3cb34a58b198 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 19:59:05 -0500 Subject: [PATCH 50/53] ci: add synchronize event and check for beta label using contains() --- .github/workflows/beta.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 4b418471f8..c0526ac676 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -3,11 +3,11 @@ name: beta on: workflow_dispatch: pull_request: - types: [labeled] + types: [labeled, synchronize] jobs: sync: - if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'beta' + if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'beta') runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write From f390ac251dc2d7d64a5b1a97b9133fcf65c0a2b1 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:17:15 -0500 Subject: [PATCH 51/53] ci: centralize team list in @opencode-ai/script package and use beta label filter --- packages/script/src/index.ts | 17 +++++++++++++++++ script/beta.ts | 25 ++++--------------------- script/changelog.ts | 19 +++---------------- 3 files changed, 24 insertions(+), 37 deletions(-) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 2d991ff0c3..496bdede2d 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -46,6 +46,20 @@ const VERSION = await (async () => { return `${major}.${minor}.${patch + 1}` })() +const team = [ + "actions-user", + "opencode", + "rekram1-node", + "thdxr", + "kommander", + "jayair", + "fwang", + "adamdotdevin", + "iamdavidhill", + "opencode-agent[bot]", + "R44VC0RP", +] + export const Script = { get channel() { return CHANNEL @@ -59,5 +73,8 @@ export const Script = { get release() { return env.OPENCODE_RELEASE }, + get team() { + return team + }, } console.log(`opencode script`, JSON.stringify(Script, null, 2)) diff --git a/script/beta.ts b/script/beta.ts index 50dc2ad783..53329e4dce 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,7 +1,6 @@ #!/usr/bin/env bun import { $ } from "bun" -import { Script } from "@opencode-ai/script" interface PR { number: number @@ -32,28 +31,12 @@ Please resolve this issue to include this PR in the next beta release.` } async function main() { - console.log("Fetching open PRs with beta label from team members...") + console.log("Fetching open PRs with beta label...") - const allPrs: PR[] = [] - for (const member of Script.team) { - try { - const stdout = - await $`gh pr list --state open --author ${member} --label beta --json number,title,author,labels --limit 100`.text() - const memberPrs: PR[] = JSON.parse(stdout) - allPrs.push(...memberPrs) - } catch { - // Skip member on error - } - } + const stdout = await $`gh pr list --state open --label beta --json number,title,author,labels --limit 100`.text() + const prs: PR[] = JSON.parse(stdout) - const seen = new Set() - const prs = allPrs.filter((pr) => { - if (seen.has(pr.number)) return false - seen.add(pr.number) - return true - }) - - console.log(`Found ${prs.length} open PRs with beta label from team members`) + console.log(`Found ${prs.length} open PRs with beta label`) if (prs.length === 0) { console.log("No team PRs to merge") diff --git a/script/changelog.ts b/script/changelog.ts index 0043cd3d62..5fc30a228b 100755 --- a/script/changelog.ts +++ b/script/changelog.ts @@ -3,20 +3,7 @@ import { $ } from "bun" import { createOpencode } from "@opencode-ai/sdk/v2" import { parseArgs } from "util" - -export const team = [ - "actions-user", - "opencode", - "rekram1-node", - "thdxr", - "kommander", - "jayair", - "fwang", - "adamdotdevin", - "iamdavidhill", - "opencode-agent[bot]", - "R44VC0RP", -] +import { Script } from "@opencode-ai/script" type Release = { tag_name: string @@ -191,7 +178,7 @@ export async function generateChangelog(commits: Commit[], opencode: Awaited Date: Sun, 1 Feb 2026 20:17:50 -0500 Subject: [PATCH 52/53] ci: run beta workflow on hourly schedule only --- .github/workflows/beta.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index c0526ac676..1ba25bce40 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -1,13 +1,11 @@ name: beta on: - workflow_dispatch: - pull_request: - types: [labeled, synchronize] + schedule: + - cron: "0 * * * *" jobs: sync: - if: github.event_name == 'workflow_dispatch' || contains(github.event.pull_request.labels.*.name, 'beta') runs-on: blacksmith-4vcpu-ubuntu-2404 permissions: contents: write From d3d783e23db89fd020430359979a8c815053a80b Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Sun, 1 Feb 2026 20:18:12 -0500 Subject: [PATCH 53/53] ci: allow manual dispatch for beta workflow --- .github/workflows/beta.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/beta.yml b/.github/workflows/beta.yml index 1ba25bce40..20d2bc18d8 100644 --- a/.github/workflows/beta.yml +++ b/.github/workflows/beta.yml @@ -1,6 +1,7 @@ name: beta on: + workflow_dispatch: schedule: - cron: "0 * * * *"