From 97f3c746f3faaa5111c56a1e04cd5dd02be4c2eb Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 22 Apr 2026 00:35:48 -0400 Subject: [PATCH 01/16] feat: update codex plugin to support 5.5 (#23789) --- packages/opencode/src/plugin/codex.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index c61cb7850900..84d314f476ff 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -374,6 +374,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-mini", + "gpt-5.5", ]) for (const [modelId, model] of Object.entries(provider.models)) { if (modelId.includes("codex")) continue From 69e2f3b7ba12ce45ba2964ca3df2fe7c5a22a793 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 15:18:51 +1000 Subject: [PATCH 02/16] chore: bump Bun to 1.3.13 (#23791) --- bun.lock | 6 +++--- package.json | 4 ++-- packages/containers/bun-node/Dockerfile | 2 +- packages/ui/src/components/timeline-playground.stories.tsx | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/bun.lock b/bun.lock index 77ab24240bb9..64b32feac4eb 100644 --- a/bun.lock +++ b/bun.lock @@ -688,7 +688,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.11", + "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", "@types/luxon": "3.7.1", "@types/node": "22.13.9", @@ -2302,7 +2302,7 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], - "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="], @@ -2720,7 +2720,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="], diff --git a/package.json b/package.json index 06bf9c91aef0..f918bcd025f5 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.11", + "packageManager": "bun@1.3.13", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop-electron dev", @@ -30,7 +30,7 @@ "@effect/opentelemetry": "4.0.0-beta.48", "@effect/platform-node": "4.0.0-beta.48", "@npmcli/arborist": "9.4.0", - "@types/bun": "1.3.11", + "@types/bun": "1.3.12", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile index 485375dd9f61..d6f4729bf51e 100644 --- a/packages/containers/bun-node/Dockerfile +++ b/packages/containers/bun-node/Dockerfile @@ -4,7 +4,7 @@ FROM ${REGISTRY}/build/base:24.04 SHELL ["/bin/bash", "-lc"] ARG NODE_VERSION=24.4.0 -ARG BUN_VERSION=1.3.11 +ARG BUN_VERSION=1.3.13 ENV BUN_INSTALL=/opt/bun ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx index c071db303b7a..72f5730612c5 100644 --- a/packages/ui/src/components/timeline-playground.stories.tsx +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -318,7 +318,7 @@ const TOOL_SAMPLES = { tool: "bash", input: { command: "bun test --filter session", description: "Run session tests" }, output: - "bun test v1.3.11\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", + "bun test v1.3.13\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", title: "Run session tests", metadata: { command: "bun test --filter session" }, }, From a45d9a9b0aee7f2852bda832313ff7fed5063415 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:36:20 +0800 Subject: [PATCH 03/16] fix(app): improve icon override handling in project edit dialog (#23768) --- .../src/components/dialog-edit-project.tsx | 36 ++++++++++--------- packages/app/src/context/layout.tsx | 2 +- .../app/src/pages/layout/sidebar-items.tsx | 4 ++- 3 files changed, 23 insertions(+), 19 deletions(-) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index ea5d70065adc..621d56646df1 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -26,8 +26,8 @@ export function DialogEditProject(props: { project: LocalProject }) { const [store, setStore] = createStore({ name: defaultName(), - color: props.project.icon?.color || "pink", - iconUrl: props.project.icon?.override || "", + color: props.project.icon?.color, + iconOverride: props.project.icon?.override, startup: props.project.commands?.start ?? "", dragOver: false, iconHover: false, @@ -39,7 +39,7 @@ export function DialogEditProject(props: { project: LocalProject }) { if (!file.type.startsWith("image/")) return const reader = new FileReader() reader.onload = (e) => { - setStore("iconUrl", e.target?.result as string) + setStore("iconOverride", e.target?.result as string) setStore("iconHover", false) } reader.readAsDataURL(file) @@ -68,7 +68,7 @@ export function DialogEditProject(props: { project: LocalProject }) { } function clearIcon() { - setStore("iconUrl", "") + setStore("iconOverride", "") } const saveMutation = useMutation(() => ({ @@ -81,17 +81,17 @@ export function DialogEditProject(props: { project: LocalProject }) { projectID: props.project.id, directory: props.project.worktree, name, - icon: { color: store.color, override: store.iconUrl }, + icon: { color: store.color || "", override: store.iconOverride || "" }, commands: { start }, }) - globalSync.project.icon(props.project.worktree, store.iconUrl || undefined) + globalSync.project.icon(props.project.worktree, store.iconOverride || undefined) dialog.close() return } globalSync.project.meta(props.project.worktree, { name, - icon: { color: store.color, override: store.iconUrl || undefined }, + icon: { color: store.color || undefined, override: store.iconOverride || undefined }, commands: { start: start || undefined }, }) dialog.close() @@ -130,13 +130,13 @@ export function DialogEditProject(props: { project: LocalProject }) { classList={{ "border-text-interactive-base bg-surface-info-base/20": store.dragOver, "border-border-base hover:border-border-strong": !store.dragOver, - "overflow-hidden": !!store.iconUrl, + "overflow-hidden": !!store.iconOverride, }} onDrop={handleDrop} onDragOver={handleDragOver} onDragLeave={handleDragLeave} onClick={() => { - if (store.iconUrl && store.iconHover) { + if (store.iconOverride && store.iconHover) { clearIcon() } else { iconInput?.click() @@ -144,7 +144,7 @@ export function DialogEditProject(props: { project: LocalProject }) { }} > {language.t("dialog.project.edit.icon.alt")} @@ -165,8 +165,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
@@ -174,8 +174,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
@@ -198,7 +198,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
- +
@@ -215,7 +215,9 @@ export function DialogEditProject(props: { project: LocalProject }) { "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": store.color !== color, }} - onClick={() => setStore("color", color)} + onClick={() => { + setStore("color", store.color === color ? undefined : color) + }} > Date: Wed, 22 Apr 2026 06:13:39 +0000 Subject: [PATCH 04/16] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 21279a327d0a..c09604610638 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-NczRp8MPppkqP8PQfWMUWJ/Wofvf2YVy5m4i22Pi3jg=", - "aarch64-linux": "sha256-QIxGOu8Fj+sWgc9hKvm1BLiIErxEtd17SPlwZGac9sQ=", - "aarch64-darwin": "sha256-Rb9qbMM+ARn0iBCaZurwcoUBCplbMXEZwrXVKextp3I=", - "x86_64-darwin": "sha256-KVxOKkaVV7W+K4reEk14MTLgmtoqwCYDqDNXNeS6ync=" + "x86_64-linux": "sha256-AgHhYsiygxbsBo3JN4HqHXKAwh8n1qeuSCe2qqxlxW4=", + "aarch64-linux": "sha256-h2lpWRQ5EDYnjpqZXtUAp1mxKLQxJ4m8MspgSY8Ev78=", + "aarch64-darwin": "sha256-xnd91+WyeAqn06run2ajsekxJvTMiLsnqNPe/rR8VTM=", + "x86_64-darwin": "sha256-rXpz45IOjGEk73xhP9VY86eOj2CZBg2l1vzwzTIOOOQ=" } } From bb696485b645fc323ef7deeefa25685bc14da856 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:03:34 +1000 Subject: [PATCH 05/16] fix: preserve BOM in text tool round-trips (#23797) --- packages/opencode/src/format/index.ts | 17 +++-- packages/opencode/src/patch/index.ts | 18 +++--- packages/opencode/src/tool/apply_patch.ts | 32 +++++++--- packages/opencode/src/tool/edit.ts | 29 ++++++--- packages/opencode/src/tool/write.ts | 15 +++-- packages/opencode/src/util/bom.ts | 31 +++++++++ packages/opencode/test/format/format.test.ts | 34 +++++++++- .../opencode/test/tool/apply_patch.test.ts | 28 +++++++++ packages/opencode/test/tool/edit.test.ts | 63 +++++++++++++++++++ packages/opencode/test/tool/write.test.ts | 48 ++++++++++++++ 10 files changed, 276 insertions(+), 39 deletions(-) create mode 100644 packages/opencode/src/util/bom.ts diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts index 85934ce9c9a3..53a2c10119b1 100644 --- a/packages/opencode/src/format/index.ts +++ b/packages/opencode/src/format/index.ts @@ -25,7 +25,7 @@ export type Status = z.infer export interface Interface { readonly init: () => Effect.Effect readonly status: () => Effect.Effect - readonly file: (filepath: string) => Effect.Effect + readonly file: (filepath: string) => Effect.Effect } export class Service extends Context.Service()("@opencode/Format") {} @@ -70,16 +70,19 @@ export const layer = Layer.effect( } }), ) - return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! })) + return checks + .filter((x): x is { item: Formatter.Info; cmd: string[] } => x.cmd !== false) + .map((x) => ({ item: x.item, cmd: x.cmd })) } function formatFile(filepath: string) { return Effect.gen(function* () { log.info("formatting", { file: filepath }) - const ext = path.extname(filepath) + const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath))) - for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) { - if (cmd === false) continue + if (!formatters.length) return false + + for (const { item, cmd } of formatters) { log.info("running", { command: cmd }) const replaced = cmd.map((x) => x.replace("$FILE", filepath)) const dir = yield* InstanceState.directory @@ -113,6 +116,8 @@ export const layer = Layer.effect( }) } } + + return true }) } @@ -188,7 +193,7 @@ export const layer = Layer.effect( const file = Effect.fn("Format.file")(function* (filepath: string) { const { formatFile } = yield* InstanceState.get(state) - yield* formatFile(filepath) + return yield* formatFile(filepath) }) return Service.of({ init, status, file }) diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 19e1d7555bb0..3662f9e908ae 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -3,6 +3,7 @@ import * as path from "path" import * as fs from "fs/promises" import { readFileSync } from "fs" import { Log } from "../util" +import * as Bom from "../util/bom" const log = Log.create({ service: "patch" }) @@ -305,18 +306,19 @@ export function maybeParseApplyPatch( interface ApplyPatchFileUpdate { unified_diff: string content: string + bom: boolean } export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate { // Read original file content - let originalContent: string + let originalContent: ReturnType try { - originalContent = readFileSync(filePath, "utf-8") + originalContent = Bom.split(readFileSync(filePath, "utf-8")) } catch (error) { throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error }) } - let originalLines = originalContent.split("\n") + let originalLines = originalContent.text.split("\n") // Drop trailing empty element for consistent line counting if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") { @@ -331,14 +333,16 @@ export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFile newLines.push("") } - const newContent = newLines.join("\n") + const next = Bom.split(newLines.join("\n")) + const newContent = next.text // Generate unified diff - const unifiedDiff = generateUnifiedDiff(originalContent, newContent) + const unifiedDiff = generateUnifiedDiff(originalContent.text, newContent) return { unified_diff: unifiedDiff, content: newContent, + bom: originalContent.bom || next.bom, } } @@ -553,13 +557,13 @@ export async function applyHunksToFiles(hunks: Hunk[]): Promise { await fs.mkdir(moveDir, { recursive: true }) } - await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8") + await fs.writeFile(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8") await fs.unlink(hunk.path) modified.push(hunk.move_path) log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`) } else { // Regular update - await fs.writeFile(hunk.path, fileUpdate.content, "utf-8") + await fs.writeFile(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8") modified.push(hunk.path) log.info(`Updated file: ${hunk.path}`) } diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 7da7dd255c52..e36d5a65d801 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -14,6 +14,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import DESCRIPTION from "./apply_patch.txt" import { File } from "../file" import { Format } from "../format" +import * as Bom from "@/util/bom" const PatchParams = z.object({ patchText: z.string().describe("The full patch text that describes all changes to be made"), @@ -59,6 +60,7 @@ export const ApplyPatchTool = Tool.define( diff: string additions: number deletions: number + bom: boolean }> = [] let totalDiff = "" @@ -72,11 +74,12 @@ export const ApplyPatchTool = Tool.define( const oldContent = "" const newContent = hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` - const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + const next = Bom.split(newContent) + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, next.text)) let additions = 0 let deletions = 0 - for (const change of diffLines(oldContent, newContent)) { + for (const change of diffLines(oldContent, next.text)) { if (change.added) additions += change.count || 0 if (change.removed) deletions += change.count || 0 } @@ -84,11 +87,12 @@ export const ApplyPatchTool = Tool.define( fileChanges.push({ filePath, oldContent, - newContent, + newContent: next.text, type: "add", diff, additions, deletions, + bom: next.bom, }) totalDiff += diff + "\n" @@ -104,13 +108,16 @@ export const ApplyPatchTool = Tool.define( ) } - const oldContent = yield* afs.readFileString(filePath) + const source = yield* Bom.readFile(afs, filePath) + const oldContent = source.text let newContent = oldContent + let bom = source.bom // Apply the update chunks to get new content try { const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) newContent = fileUpdate.content + bom = fileUpdate.bom } catch (error) { return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`)) } @@ -136,6 +143,7 @@ export const ApplyPatchTool = Tool.define( diff, additions, deletions, + bom, }) totalDiff += diff + "\n" @@ -143,8 +151,8 @@ export const ApplyPatchTool = Tool.define( } case "delete": { - const contentToDelete = yield* afs - .readFileString(filePath) + const source = yield* Bom + .readFile(afs, filePath) .pipe( Effect.catch((error) => Effect.fail( @@ -154,6 +162,7 @@ export const ApplyPatchTool = Tool.define( ), ), ) + const contentToDelete = source.text const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) const deletions = contentToDelete.split("\n").length @@ -166,6 +175,7 @@ export const ApplyPatchTool = Tool.define( diff: deleteDiff, additions: 0, deletions, + bom: source.bom, }) totalDiff += deleteDiff + "\n" @@ -207,12 +217,12 @@ export const ApplyPatchTool = Tool.define( case "add": // Create parent directories (recursive: true is safe on existing/root dirs) - yield* afs.writeWithDirs(change.filePath, change.newContent) + yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom)) updates.push({ file: change.filePath, event: "add" }) break case "update": - yield* afs.writeWithDirs(change.filePath, change.newContent) + yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom)) updates.push({ file: change.filePath, event: "change" }) break @@ -220,7 +230,7 @@ export const ApplyPatchTool = Tool.define( if (change.movePath) { // Create parent directories (recursive: true is safe on existing/root dirs) - yield* afs.writeWithDirs(change.movePath!, change.newContent) + yield* afs.writeWithDirs(change.movePath!, Bom.join(change.newContent, change.bom)) yield* afs.remove(change.filePath) updates.push({ file: change.filePath, event: "unlink" }) updates.push({ file: change.movePath, event: "add" }) @@ -234,7 +244,9 @@ export const ApplyPatchTool = Tool.define( } if (edited) { - yield* format.file(edited) + if (yield* format.file(edited)) { + yield* Bom.syncFile(afs, edited, change.bom) + } yield* bus.publish(File.Event.Edited, { file: edited }) } } diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 2c6c2c13084a..858d14e043fe 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -18,6 +18,7 @@ import { Instance } from "../project/instance" import { Snapshot } from "@/snapshot" import { assertExternalDirectoryEffect } from "./external-directory" import { AppFileSystem } from "@opencode-ai/shared/filesystem" +import * as Bom from "@/util/bom" function normalizeLineEndings(text: string): string { return text.replaceAll("\r\n", "\n") @@ -84,7 +85,11 @@ export const EditTool = Tool.define( Effect.gen(function* () { if (params.oldString === "") { const existed = yield* afs.existsSafe(filePath) - contentNew = params.newString + const source = existed ? yield* Bom.readFile(afs, filePath) : { bom: false, text: "" } + const next = Bom.split(params.newString) + const desiredBom = source.bom || next.bom + contentOld = source.text + contentNew = next.text diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) yield* ctx.ask({ permission: "edit", @@ -95,8 +100,10 @@ export const EditTool = Tool.define( diff, }, }) - yield* afs.writeWithDirs(filePath, params.newString) - yield* format.file(filePath) + yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom)) + if (yield* format.file(filePath)) { + contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) + } yield* bus.publish(File.Event.Edited, { file: filePath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filePath, @@ -108,13 +115,16 @@ export const EditTool = Tool.define( const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined))) if (!info) throw new Error(`File ${filePath} not found`) if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`) - contentOld = yield* afs.readFileString(filePath) + const source = yield* Bom.readFile(afs, filePath) + contentOld = source.text const ending = detectLineEnding(contentOld) const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending) - const next = convertToLineEnding(normalizeLineEndings(params.newString), ending) + const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending) - contentNew = replace(contentOld, old, next, params.replaceAll) + const next = Bom.split(replace(contentOld, old, replacement, params.replaceAll)) + const desiredBom = source.bom || next.bom + contentNew = next.text diff = trimDiff( createTwoFilesPatch( @@ -134,14 +144,15 @@ export const EditTool = Tool.define( }, }) - yield* afs.writeWithDirs(filePath, contentNew) - yield* format.file(filePath) + yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom)) + if (yield* format.file(filePath)) { + contentNew = yield* Bom.syncFile(afs, filePath, desiredBom) + } yield* bus.publish(File.Event.Edited, { file: filePath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change", }) - contentNew = yield* afs.readFileString(filePath) diff = trimDiff( createTwoFilesPatch( filePath, diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index 741091b21d3c..79ed58519831 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -13,6 +13,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem" import { Instance } from "../project/instance" import { trimDiff } from "./edit" import { assertExternalDirectoryEffect } from "./external-directory" +import * as Bom from "@/util/bom" const MAX_PROJECT_DIAGNOSTICS_FILES = 5 @@ -38,9 +39,13 @@ export const WriteTool = Tool.define( yield* assertExternalDirectoryEffect(ctx, filepath) const exists = yield* fs.existsSafe(filepath) - const contentOld = exists ? yield* fs.readFileString(filepath) : "" + const source = exists ? yield* Bom.readFile(fs, filepath) : { bom: false, text: "" } + const next = Bom.split(params.content) + const desiredBom = source.bom || next.bom + const contentOld = source.text + const contentNew = next.text - const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content)) + const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew)) yield* ctx.ask({ permission: "edit", patterns: [path.relative(Instance.worktree, filepath)], @@ -51,8 +56,10 @@ export const WriteTool = Tool.define( }, }) - yield* fs.writeWithDirs(filepath, params.content) - yield* format.file(filepath) + yield* fs.writeWithDirs(filepath, Bom.join(contentNew, desiredBom)) + if (yield* format.file(filepath)) { + yield* Bom.syncFile(fs, filepath, desiredBom) + } yield* bus.publish(File.Event.Edited, { file: filepath }) yield* bus.publish(FileWatcher.Event.Updated, { file: filepath, diff --git a/packages/opencode/src/util/bom.ts b/packages/opencode/src/util/bom.ts new file mode 100644 index 000000000000..484228f3d415 --- /dev/null +++ b/packages/opencode/src/util/bom.ts @@ -0,0 +1,31 @@ +import { Effect } from "effect" +import { AppFileSystem } from "@opencode-ai/shared/filesystem" + +const BOM_CODE = 0xfeff +const BOM = String.fromCharCode(BOM_CODE) + +export function split(text: string) { + if (text.charCodeAt(0) !== BOM_CODE) return { bom: false, text } + return { bom: true, text: text.slice(1) } +} + +export function join(text: string, bom: boolean) { + const stripped = split(text).text + if (!bom) return stripped + return BOM + stripped +} + +export const readFile = Effect.fn("Bom.readFile")(function* (fs: AppFileSystem.Interface, filePath: string) { + return split(new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath))) +}) + +export const syncFile = Effect.fn("Bom.syncFile")(function* ( + fs: AppFileSystem.Interface, + filePath: string, + bom: boolean, +) { + const current = yield* readFile(fs, filePath) + if (current.bom === bom) return current.text + yield* fs.writeWithDirs(filePath, join(current.text, bom)) + return current.text +}) diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts index 5530e195b268..2f6f235aa165 100644 --- a/packages/opencode/test/format/format.test.ts +++ b/packages/opencode/test/format/format.test.ts @@ -126,6 +126,24 @@ describe("Format", () => { it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void))) + it.live("file() returns false when no formatter runs", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const file = `${dir}/test.txt` + yield* Effect.promise(() => Bun.write(file, "x")) + + const formatted = yield* Format.Service.use((fmt) => fmt.file(file)) + expect(formatted).toBe(false) + }), + { + config: { + formatter: false, + }, + }, + ), + ) + it.live("status() initializes formatter state per directory", () => Effect.gen(function* () { const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), { @@ -219,7 +237,7 @@ describe("Format", () => { yield* Format.Service.use((fmt) => Effect.gen(function* () { yield* fmt.init() - yield* fmt.file(file) + expect(yield* fmt.file(file)).toBe(true) }), ) @@ -229,11 +247,21 @@ describe("Format", () => { config: { formatter: { first: { - command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')", + "$FILE", + ], extensions: [".seq"], }, second: { - command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')", + "$FILE", + ], extensions: [".seq"], }, }, diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index ebfa9a531eec..7ce483726b69 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -195,6 +195,34 @@ describe("tool.apply_patch freeform", () => { }) }) + test("does not invent a first-line diff for BOM files", async () => { + await using fixture = await tmpdir() + const { ctx, calls } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const bom = String.fromCharCode(0xfeff) + const target = path.join(fixture.path, "example.cs") + await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8") + + const patchText = "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" + + await execute({ patchText }, ctx) + + expect(calls.length).toBe(1) + const shown = calls[0].metadata.files[0]?.patch ?? "" + expect(shown).not.toContain(bom) + expect(shown).not.toContain("-using System;") + expect(shown).not.toContain("+using System;") + + const content = await fs.readFile(target, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n") + }, + }) + }) + test("inserts lines with insert-only hunk", async () => { await using fixture = await tmpdir() const { ctx } = makeCtx() diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts index b5fbc0a67dde..82e1b4a7fd4b 100644 --- a/packages/opencode/test/tool/edit.test.ts +++ b/packages/opencode/test/tool/edit.test.ts @@ -96,6 +96,37 @@ describe("tool.edit", () => { }) }) + test("preserves BOM when oldString is empty on existing files", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "existing.cs") + const bom = String.fromCharCode(0xfeff) + await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "", + newString: "using Up;\n", + }, + ctx, + ), + ) + + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") + + const content = await fs.readFile(filepath, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }, + }) + }) + test("creates new file with nested directories", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nested", "dir", "file.txt") @@ -183,6 +214,38 @@ describe("tool.edit", () => { }) }) + test("replaces the first visible line in BOM files", async () => { + await using tmp = await tmpdir() + const filepath = path.join(tmp.path, "existing.cs") + const bom = String.fromCharCode(0xfeff) + await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8") + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const edit = await resolve() + const result = await Effect.runPromise( + edit.execute( + { + filePath: filepath, + oldString: "using System;", + newString: "using Up;", + }, + ctx, + ), + ) + + expect(result.metadata.diff).toContain("-using System;") + expect(result.metadata.diff).toContain("+using Up;") + expect(result.metadata.diff).not.toContain(bom) + + const content = await fs.readFile(filepath, "utf-8") + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\nclass Test {}\n") + }, + }) + }) + test("throws error when file does not exist", async () => { await using tmp = await tmpdir() const filepath = path.join(tmp.path, "nonexistent.txt") diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts index 50d3b57527f9..36131f9596a3 100644 --- a/packages/opencode/test/tool/write.test.ts +++ b/packages/opencode/test/tool/write.test.ts @@ -114,6 +114,54 @@ describe("tool.write", () => { ), ) + it.live("preserves BOM when overwriting existing files", () => + provideTmpdirInstance((dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "existing.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + ), + ) + + it.live("restores BOM after formatter strips it", () => + provideTmpdirInstance( + (dir) => + Effect.gen(function* () { + const filepath = path.join(dir, "formatted.cs") + const bom = String.fromCharCode(0xfeff) + yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")) + + yield* run({ filePath: filepath, content: "using Up;\n" }) + + const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8")) + expect(content.charCodeAt(0)).toBe(0xfeff) + expect(content.slice(1)).toBe("using Up;\n") + }), + { + config: { + formatter: { + stripbom: { + extensions: [".cs"], + command: [ + "node", + "-e", + "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')", + "$FILE", + ], + }, + }, + }, + }, + ), + ) + it.live("returns diff in metadata for existing files", () => provideTmpdirInstance((dir) => Effect.gen(function* () { From bfb954e7116bd3b9b43a30a35f02fae302062455 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 22 Apr 2026 08:06:06 +0000 Subject: [PATCH 06/16] chore: generate --- packages/opencode/src/tool/apply_patch.ts | 16 +++++++--------- packages/opencode/test/tool/apply_patch.test.ts | 3 ++- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index e36d5a65d801..a4cf1e853f3c 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -151,17 +151,15 @@ export const ApplyPatchTool = Tool.define( } case "delete": { - const source = yield* Bom - .readFile(afs, filePath) - .pipe( - Effect.catch((error) => - Effect.fail( - new Error( - `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`, - ), + const source = yield* Bom.readFile(afs, filePath).pipe( + Effect.catch((error) => + Effect.fail( + new Error( + `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`, ), ), - ) + ), + ) const contentToDelete = source.text const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 7ce483726b69..fa88432136a5 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -206,7 +206,8 @@ describe("tool.apply_patch freeform", () => { const target = path.join(fixture.path, "example.cs") await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8") - const patchText = "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" + const patchText = + "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch" await execute({ patchText }, ctx) From 0595c289046d7f45d82a563ad0c76b3ccfca050b Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:17:35 +1000 Subject: [PATCH 07/16] test: fix cross-spawn stderr race on Windows CI (#23808) --- packages/opencode/test/effect/cross-spawn-spawner.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 5990635aa211..201d99866782 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -169,7 +169,10 @@ describe("cross-spawn spawner", () => { 'process.stderr.write("stderr\\n", done)', ].join("\n"), ) - const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)]) + const [stdout, stderr] = yield* Effect.all( + [decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], + { concurrency: 2 }, + ) expect(stdout).toBe("stdout") expect(stderr).toBe("stderr") }), From 6aa475fcac39cacda4730142314985c64b200bb5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 22 Apr 2026 08:18:44 +0000 Subject: [PATCH 08/16] chore: generate --- packages/opencode/test/effect/cross-spawn-spawner.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts index 201d99866782..b4e52529c1de 100644 --- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts +++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts @@ -169,10 +169,9 @@ describe("cross-spawn spawner", () => { 'process.stderr.write("stderr\\n", done)', ].join("\n"), ) - const [stdout, stderr] = yield* Effect.all( - [decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], - { concurrency: 2 }, - ) + const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], { + concurrency: 2, + }) expect(stdout).toBe("stdout") expect(stderr).toBe("stderr") }), From 88c5f6bb19ecac5c60e9c42dcb2c497a416d390b Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 22 Apr 2026 17:09:00 +0800 Subject: [PATCH 09/16] fix: consolidate project avatar source logic (#23819) --- .../src/components/dialog-edit-project.tsx | 20 +++++++++++++------ .../app/src/pages/layout/sidebar-items.tsx | 16 ++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 621d56646df1..8eb12daf52e5 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -12,6 +12,7 @@ import { type LocalProject, getAvatarColors } from "@/context/layout" import { getFilename } from "@opencode-ai/shared/util/path" import { Avatar } from "@opencode-ai/ui/avatar" import { useLanguage } from "@/context/language" +import { getProjectAvatarSource } from "@/pages/layout/sidebar-items" const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const @@ -144,7 +145,11 @@ export function DialogEditProject(props: { project: LocalProject }) { }} > } > - {language.t("dialog.project.edit.icon.alt")} + {(src) => ( + {language.t("dialog.project.edit.icon.alt")} + )}
{ + if (store.color === color && !props.project.icon?.url) return setStore("color", store.color === color ? undefined : color) }} > diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 88d50db3ed48..5170311a7b32 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -19,6 +19,14 @@ import { childSessionOnPath, hasProjectPermissions } from "./helpers" const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" +export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) { + return id === OPENCODE_PROJECT_ID + ? "https://opencode.ai/favicon.svg" + : icon?.color + ? undefined + : icon?.override || icon?.url +} + export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const globalSync = useGlobalSync() const notification = useNotification() @@ -42,13 +50,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
Date: Wed, 22 Apr 2026 19:34:40 +0900 Subject: [PATCH 10/16] docs: document agent variants and built-in presets --- packages/web/src/content/docs/agents.mdx | 29 ++++++++++++++++++++++++ packages/web/src/content/docs/models.mdx | 22 +++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 5522f77aae61..5bfa99a752a9 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -148,6 +148,7 @@ Configure agents in your `opencode.json` config file: "build": { "mode": "primary", "model": "anthropic/claude-sonnet-4-20250514", + "variant": "high", "prompt": "{file:./prompts/build.txt}", "tools": { "write": true, @@ -192,6 +193,7 @@ You can also define agents using markdown files. Place them in: description: Reviews code for quality and best practices mode: subagent model: anthropic/claude-sonnet-4-20250514 +variant: high temperature: 0.1 tools: write: false @@ -366,6 +368,33 @@ The model ID in your OpenCode config uses the format `provider/model-id`. For ex --- +### Variant + +Use the `variant` config to set the default model variant for an agent. + +This is useful when the same model supports multiple built-in variants, such as OpenAI reasoning effort levels or Anthropic thinking budgets. + +```json title="opencode.json" +{ + "agent": { + "build": { + "model": "openai/gpt-5", + "variant": "high" + }, + "plan": { + "model": "openai/gpt-5", + "variant": "low" + } + } +} +``` + +The variant applies when the agent is using its configured `model`. If you change models in the UI, the available variants and active selection may change too. + +For built-in variants and custom variant definitions, see [Models](/docs/models#variants). + +--- + ### Tools (deprecated) `tools` is **deprecated**. Prefer the agent's [`permission`](#permissions) field for new configs, updates and more fine-grained control. diff --git a/packages/web/src/content/docs/models.mdx b/packages/web/src/content/docs/models.mdx index b135e7554d46..b58e01654d75 100644 --- a/packages/web/src/content/docs/models.mdx +++ b/packages/web/src/content/docs/models.mdx @@ -133,12 +133,32 @@ You can also define custom variants that extend built-in ones. Variants let you } ``` +You can use variants globally, switch them at runtime from the UI, or pin one on a specific agent with the agent `variant` option: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "agent": { + "build": { + "model": "openai/gpt-5", + "variant": "high" + }, + "plan": { + "model": "openai/gpt-5", + "variant": "low" + } + } +} +``` + --- ## Variants Many models support multiple variants with different configurations. OpenCode ships with built-in default variants for popular providers. +Variants are named presets. In practice, they usually map to provider-specific model options like reasoning effort, thinking budgets, or verbosity settings. + ### Built-in variants OpenCode ships with default variants for many providers: @@ -197,7 +217,7 @@ You can override existing variants or add your own: ### Cycle variants -Use the keybind `variant_cycle` to quickly switch between variants. [Learn more](/docs/keybinds). +Use the keybind `variant_cycle` to quickly switch between variants, or `variant_list` to open the variant picker. [Learn more](/docs/keybinds). --- From c5f05048757c9b6b5c02a4addd171156937f9e1b Mon Sep 17 00:00:00 2001 From: jjangga0214 Date: Wed, 22 Apr 2026 19:43:48 +0900 Subject: [PATCH 11/16] docs: clarify variant keybind behavior --- packages/web/src/content/docs/agents.mdx | 2 +- packages/web/src/content/docs/models.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 5bfa99a752a9..6872dac6920a 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -389,7 +389,7 @@ This is useful when the same model supports multiple built-in variants, such as } ``` -The variant applies when the agent is using its configured `model`. If you change models in the UI, the available variants and active selection may change too. +The variant applies when the agent is using its configured `model`. If you change models in the UI, the available variants and active selection may change too. To switch variants at runtime, use the `variant_cycle` keybind or bind `variant_list` yourself in `tui.json`. For built-in variants and custom variant definitions, see [Models](/docs/models#variants). diff --git a/packages/web/src/content/docs/models.mdx b/packages/web/src/content/docs/models.mdx index b58e01654d75..d90d74754bd1 100644 --- a/packages/web/src/content/docs/models.mdx +++ b/packages/web/src/content/docs/models.mdx @@ -217,7 +217,7 @@ You can override existing variants or add your own: ### Cycle variants -Use the keybind `variant_cycle` to quickly switch between variants, or `variant_list` to open the variant picker. [Learn more](/docs/keybinds). +Use the keybind `variant_cycle` to quickly switch between variants. `variant_list` is also available as a picker action, but it is unbound by default and must be mapped in your keybinds. [Learn more](/docs/keybinds). --- From 492810b932aa4385502ba5d595b02be98a2b915a Mon Sep 17 00:00:00 2001 From: jjangga0214 Date: Wed, 22 Apr 2026 20:21:31 +0900 Subject: [PATCH 12/16] feat(keybinds): add cmd/command alias and macOS display support - Add cmd/command as alias for super in keybind parse - Display super as cmd on macOS in toString - Add tests for cmd alias parsing and macOS display --- packages/opencode/src/util/keybind.test.ts | 18 ++++++++++++++++++ packages/opencode/src/util/keybind.ts | 4 +++- packages/web/src/content/docs/keybinds.mdx | 2 ++ 3 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/util/keybind.test.ts diff --git a/packages/opencode/src/util/keybind.test.ts b/packages/opencode/src/util/keybind.test.ts new file mode 100644 index 000000000000..e9f40698600a --- /dev/null +++ b/packages/opencode/src/util/keybind.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "bun:test" +import { parse, toString } from "./keybind" + +describe("keybind", () => { + test("parses cmd as super", () => { + expect(parse("cmd+p")).toEqual(parse("super+p")) + expect(parse("command+p")).toEqual(parse("super+p")) + }) + + test("renders super as cmd on macOS", () => { + const original = process.platform + Object.defineProperty(process, "platform", { value: "darwin" }) + + expect(toString(parse("super+p")[0])).toBe("cmd+p") + + Object.defineProperty(process, "platform", { value: original }) + }) +}) diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts index 10a68c4b2ab1..e2b211d839e7 100644 --- a/packages/opencode/src/util/keybind.ts +++ b/packages/opencode/src/util/keybind.ts @@ -37,7 +37,7 @@ export function toString(info: Info | undefined): string { if (info.ctrl) parts.push("ctrl") if (info.meta) parts.push("alt") - if (info.super) parts.push("super") + if (info.super) parts.push(process.platform === "darwin" ? "cmd" : "super") if (info.shift) parts.push("shift") if (info.name) { if (info.name === "delete") parts.push("del") @@ -79,6 +79,8 @@ export function parse(key: string): Info[] { info.meta = true break case "super": + case "cmd": + case "command": info.super = true break case "shift": diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 5488aaf81cbe..9f3e561d2889 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -5,6 +5,8 @@ description: Customize your keybinds. OpenCode has a list of keybinds that you can customize through `tui.json`. +On macOS, you can use `cmd` as an alias for `super` in your config. In the UI, keybinds using `super` are displayed as `cmd` on macOS. + ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", From 1a225f320b6bdf2e2314de2d01eba025ce3aa406 Mon Sep 17 00:00:00 2001 From: jjangga0214 Date: Wed, 22 Apr 2026 20:24:21 +0900 Subject: [PATCH 13/16] feat(keybinds): add cmd/command alias and macOS display support - Add cmd/command as alias for super in keybind parse - Display super as cmd on macOS in toString - Add tests for cmd alias parsing and macOS display --- packages/opencode/src/util/keybind.test.ts | 18 ----------------- packages/opencode/src/util/keybind.ts | 2 +- packages/opencode/test/util/keybind.test.ts | 22 +++++++++++++++++++++ packages/web/src/content/docs/keybinds.mdx | 2 -- 4 files changed, 23 insertions(+), 21 deletions(-) delete mode 100644 packages/opencode/src/util/keybind.test.ts create mode 100644 packages/opencode/test/util/keybind.test.ts diff --git a/packages/opencode/src/util/keybind.test.ts b/packages/opencode/src/util/keybind.test.ts deleted file mode 100644 index e9f40698600a..000000000000 --- a/packages/opencode/src/util/keybind.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { parse, toString } from "./keybind" - -describe("keybind", () => { - test("parses cmd as super", () => { - expect(parse("cmd+p")).toEqual(parse("super+p")) - expect(parse("command+p")).toEqual(parse("super+p")) - }) - - test("renders super as cmd on macOS", () => { - const original = process.platform - Object.defineProperty(process, "platform", { value: "darwin" }) - - expect(toString(parse("super+p")[0])).toBe("cmd+p") - - Object.defineProperty(process, "platform", { value: original }) - }) -}) diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts index e2b211d839e7..e9fa3062e8cf 100644 --- a/packages/opencode/src/util/keybind.ts +++ b/packages/opencode/src/util/keybind.ts @@ -37,7 +37,7 @@ export function toString(info: Info | undefined): string { if (info.ctrl) parts.push("ctrl") if (info.meta) parts.push("alt") - if (info.super) parts.push(process.platform === "darwin" ? "cmd" : "super") + if (info.super) parts.push("super") if (info.shift) parts.push("shift") if (info.name) { if (info.name === "delete") parts.push("del") diff --git a/packages/opencode/test/util/keybind.test.ts b/packages/opencode/test/util/keybind.test.ts new file mode 100644 index 000000000000..74e413d39ed9 --- /dev/null +++ b/packages/opencode/test/util/keybind.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test" +import { parse, toString } from "../../src/util/keybind" + +it("parse recognizes cmd as alias for super", () => { + const result = parse("cmd+k") + expect(result).toHaveLength(1) + expect(result[0]!.super).toBe(true) + expect(result[0]!.name).toBe("k") +}) + +it("parse recognizes command as alias for super", () => { + const result = parse("command+shift+a") + expect(result).toHaveLength(1) + expect(result[0]!.super).toBe(true) + expect(result[0]!.shift).toBe(true) + expect(result[0]!.name).toBe("a") +}) + +it("toString displays super key", () => { + const result = toString({ ctrl: false, meta: false, shift: false, super: true, leader: false, name: "k" }) + expect(result).toMatch(/^(cmd|super)\+k$/) +}) \ No newline at end of file diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 9f3e561d2889..5488aaf81cbe 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -5,8 +5,6 @@ description: Customize your keybinds. OpenCode has a list of keybinds that you can customize through `tui.json`. -On macOS, you can use `cmd` as an alias for `super` in your config. In the UI, keybinds using `super` are displayed as `cmd` on macOS. - ```json title="tui.json" { "$schema": "https://opencode.ai/tui.json", From 276d0ff8b6734c3f7f6ab619ade0e4ab8a63680e Mon Sep 17 00:00:00 2001 From: jjangga0214 Date: Thu, 23 Apr 2026 09:17:17 +0900 Subject: [PATCH 14/16] docs: clarify agent variants and variant keybinds Document agent.variant with JSON and Markdown examples, tighten Models variants (cycle vs list, per-agent pinning), and add a keybinds section for model/variant actions. Parse cmd/command as super; show super as cmd in Keybind.toString on macOS. Made-with: Cursor --- packages/opencode/src/util/keybind.ts | 6 +++- packages/opencode/test/keybind.test.ts | 38 +++++++++++++++++++--- packages/web/src/content/docs/agents.mdx | 18 ++++++++-- packages/web/src/content/docs/keybinds.mdx | 8 +++++ packages/web/src/content/docs/models.mdx | 8 +++-- 5 files changed, 69 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts index e9fa3062e8cf..e481fc8c2f31 100644 --- a/packages/opencode/src/util/keybind.ts +++ b/packages/opencode/src/util/keybind.ts @@ -9,6 +9,10 @@ export type Info = Pick leader: boolean // our custom field } +function superDisplayName(): "cmd" | "super" { + return process.platform === "darwin" ? "cmd" : "super" +} + export function match(a: Info | undefined, b: Info): boolean { if (!a) return false const normalizedA = { ...a, super: a.super ?? false } @@ -37,7 +41,7 @@ export function toString(info: Info | undefined): string { if (info.ctrl) parts.push("ctrl") if (info.meta) parts.push("alt") - if (info.super) parts.push("super") + if (info.super) parts.push(superDisplayName()) if (info.shift) parts.push("shift") if (info.name) { if (info.name === "delete") parts.push("del") diff --git a/packages/opencode/test/keybind.test.ts b/packages/opencode/test/keybind.test.ts index 1e900a602048..83d6a7b6c212 100644 --- a/packages/opencode/test/keybind.test.ts +++ b/packages/opencode/test/keybind.test.ts @@ -1,6 +1,8 @@ import { describe, test, expect } from "bun:test" import { Keybind } from "../src/util" +const SUPER_LABEL = process.platform === "darwin" ? "cmd" : "super" + describe("Keybind.toString", () => { test("should convert simple key to string", () => { const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" } @@ -71,22 +73,22 @@ describe("Keybind.toString", () => { test("should convert super modifier to string", () => { const info: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - expect(Keybind.toString(info)).toBe("super+z") + expect(Keybind.toString(info)).toBe(`${SUPER_LABEL}+z`) }) test("should convert super+shift modifier to string", () => { const info: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } - expect(Keybind.toString(info)).toBe("super+shift+z") + expect(Keybind.toString(info)).toBe(`${SUPER_LABEL}+shift+z`) }) test("should handle super with ctrl modifier", () => { const info: Keybind.Info = { ctrl: true, meta: false, shift: false, super: true, leader: false, name: "a" } - expect(Keybind.toString(info)).toBe("ctrl+super+a") + expect(Keybind.toString(info)).toBe(`ctrl+${SUPER_LABEL}+a`) }) test("should handle super with all modifiers", () => { const info: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "x" } - expect(Keybind.toString(info)).toBe("ctrl+alt+super+shift+x") + expect(Keybind.toString(info)).toBe(`ctrl+alt+${SUPER_LABEL}+shift+x`) }) test("should handle undefined super field (omitted)", () => { @@ -384,6 +386,34 @@ describe("Keybind.parse", () => { ]) }) + test("should parse cmd as super modifier", () => { + const result = Keybind.parse("cmd+z") + expect(result).toEqual([ + { + ctrl: false, + meta: false, + shift: false, + super: true, + leader: false, + name: "z", + }, + ]) + }) + + test("should parse command as super modifier", () => { + const result = Keybind.parse("command+z") + expect(result).toEqual([ + { + ctrl: false, + meta: false, + shift: false, + super: true, + leader: false, + name: "z", + }, + ]) + }) + test("should parse super with shift modifier", () => { const result = Keybind.parse("super+shift+z") expect(result).toEqual([ diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 6872dac6920a..f003a90d6472 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -372,10 +372,11 @@ The model ID in your OpenCode config uses the format `provider/model-id`. For ex Use the `variant` config to set the default model variant for an agent. -This is useful when the same model supports multiple built-in variants, such as OpenAI reasoning effort levels or Anthropic thinking budgets. +This is useful when the same model supports multiple built-in variants, such as OpenAI reasoning effort levels or Anthropic thinking budgets. Variants are defined on the model in config; see [Models — Variants](/docs/models#variants). ```json title="opencode.json" { + "$schema": "https://opencode.ai/config.json", "agent": { "build": { "model": "openai/gpt-5", @@ -389,7 +390,20 @@ This is useful when the same model supports multiple built-in variants, such as } ``` -The variant applies when the agent is using its configured `model`. If you change models in the UI, the available variants and active selection may change too. To switch variants at runtime, use the `variant_cycle` keybind or bind `variant_list` yourself in `tui.json`. +The variant applies when the agent is using its configured `model`. If you change models in the UI, the available variants and active selection may change too. To switch variants at runtime, use the `variant_cycle` keybind; you can also bind `variant_list` in `tui.json` for a picker ([Keybinds](/docs/keybinds)). + +In a Markdown agent, set `variant` in the frontmatter: + +```markdown title="~/.config/opencode/agents/fast-plan.md" +--- +description: Planning with a lighter variant +mode: primary +model: openai/gpt-5 +variant: low +--- + +You are a planning assistant. Prefer concise plans. +``` For built-in variants and custom variant definitions, see [Models](/docs/models#variants). diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 5488aaf81cbe..f2a299f38aa9 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -107,6 +107,14 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. --- +## Model and variant keybinds + +- **`model_list`** — Open the model picker (default uses the leader key; see the example above). +- **`variant_cycle`** — Cycle variants for the current model (for example reasoning presets on the same model ID). +- **`variant_list`** — Open a variant picker. In the default config it is set to `"none"`; add a combo under `keybinds.variant_list` in `tui.json` if you want this action. You can use `super`, `cmd`, or `command` interchangeably when writing keybinds; on macOS, shortcuts that use the super key are shown as **`cmd`** in the UI. + +--- + ## Leader key OpenCode uses a `leader` key for most keybinds. This avoids conflicts in your terminal. diff --git a/packages/web/src/content/docs/models.mdx b/packages/web/src/content/docs/models.mdx index d90d74754bd1..9d0e01ee16d7 100644 --- a/packages/web/src/content/docs/models.mdx +++ b/packages/web/src/content/docs/models.mdx @@ -155,7 +155,9 @@ You can use variants globally, switch them at runtime from the UI, or pin one on ## Variants -Many models support multiple variants with different configurations. OpenCode ships with built-in default variants for popular providers. +A **variant** is a named set of model options for the same underlying model (for example different reasoning budgets or verbosity). You define variants under `provider..models..variants` in config, and you can switch between them in the UI or pin a default. + +You can set a **per-agent default** with `agent..variant` so Build and Plan (or custom agents) start on different variants without changing global defaults. See [Agents — Variant](/docs/agents#variant). Variants are named presets. In practice, they usually map to provider-specific model options like reasoning effort, thinking budgets, or verbosity settings. @@ -217,7 +219,9 @@ You can override existing variants or add your own: ### Cycle variants -Use the keybind `variant_cycle` to quickly switch between variants. `variant_list` is also available as a picker action, but it is unbound by default and must be mapped in your keybinds. [Learn more](/docs/keybinds). +Use the keybind `variant_cycle` to cycle the active variant for the current model. [Keybinds](/docs/keybinds) documents the default and how to customize keybinds. + +The `variant_list` action opens a variant picker. It is **not bound by default** (`"none"` in the default `tui.json`); assign a shortcut under `keybinds.variant_list` if you want it. --- From d6f8b7f7c180631982912998812dd27873f80f20 Mon Sep 17 00:00:00 2001 From: jjangga0214 Date: Thu, 23 Apr 2026 09:24:53 +0900 Subject: [PATCH 15/16] docs: clarify agent variants and built-in presets Drop keybind runtime changes so the branch is documentation-only; keep Agents/Models/Keybinds docs for agent.variant, built-in variants, and variant_cycle / variant_list. Made-with: Cursor --- packages/opencode/src/util/keybind.ts | 8 +---- packages/opencode/test/keybind.test.ts | 38 +++------------------- packages/web/src/content/docs/keybinds.mdx | 2 +- 3 files changed, 6 insertions(+), 42 deletions(-) diff --git a/packages/opencode/src/util/keybind.ts b/packages/opencode/src/util/keybind.ts index e481fc8c2f31..10a68c4b2ab1 100644 --- a/packages/opencode/src/util/keybind.ts +++ b/packages/opencode/src/util/keybind.ts @@ -9,10 +9,6 @@ export type Info = Pick leader: boolean // our custom field } -function superDisplayName(): "cmd" | "super" { - return process.platform === "darwin" ? "cmd" : "super" -} - export function match(a: Info | undefined, b: Info): boolean { if (!a) return false const normalizedA = { ...a, super: a.super ?? false } @@ -41,7 +37,7 @@ export function toString(info: Info | undefined): string { if (info.ctrl) parts.push("ctrl") if (info.meta) parts.push("alt") - if (info.super) parts.push(superDisplayName()) + if (info.super) parts.push("super") if (info.shift) parts.push("shift") if (info.name) { if (info.name === "delete") parts.push("del") @@ -83,8 +79,6 @@ export function parse(key: string): Info[] { info.meta = true break case "super": - case "cmd": - case "command": info.super = true break case "shift": diff --git a/packages/opencode/test/keybind.test.ts b/packages/opencode/test/keybind.test.ts index 83d6a7b6c212..1e900a602048 100644 --- a/packages/opencode/test/keybind.test.ts +++ b/packages/opencode/test/keybind.test.ts @@ -1,8 +1,6 @@ import { describe, test, expect } from "bun:test" import { Keybind } from "../src/util" -const SUPER_LABEL = process.platform === "darwin" ? "cmd" : "super" - describe("Keybind.toString", () => { test("should convert simple key to string", () => { const info: Keybind.Info = { ctrl: false, meta: false, shift: false, leader: false, name: "f" } @@ -73,22 +71,22 @@ describe("Keybind.toString", () => { test("should convert super modifier to string", () => { const info: Keybind.Info = { ctrl: false, meta: false, shift: false, super: true, leader: false, name: "z" } - expect(Keybind.toString(info)).toBe(`${SUPER_LABEL}+z`) + expect(Keybind.toString(info)).toBe("super+z") }) test("should convert super+shift modifier to string", () => { const info: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" } - expect(Keybind.toString(info)).toBe(`${SUPER_LABEL}+shift+z`) + expect(Keybind.toString(info)).toBe("super+shift+z") }) test("should handle super with ctrl modifier", () => { const info: Keybind.Info = { ctrl: true, meta: false, shift: false, super: true, leader: false, name: "a" } - expect(Keybind.toString(info)).toBe(`ctrl+${SUPER_LABEL}+a`) + expect(Keybind.toString(info)).toBe("ctrl+super+a") }) test("should handle super with all modifiers", () => { const info: Keybind.Info = { ctrl: true, meta: true, shift: true, super: true, leader: false, name: "x" } - expect(Keybind.toString(info)).toBe(`ctrl+alt+${SUPER_LABEL}+shift+x`) + expect(Keybind.toString(info)).toBe("ctrl+alt+super+shift+x") }) test("should handle undefined super field (omitted)", () => { @@ -386,34 +384,6 @@ describe("Keybind.parse", () => { ]) }) - test("should parse cmd as super modifier", () => { - const result = Keybind.parse("cmd+z") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - super: true, - leader: false, - name: "z", - }, - ]) - }) - - test("should parse command as super modifier", () => { - const result = Keybind.parse("command+z") - expect(result).toEqual([ - { - ctrl: false, - meta: false, - shift: false, - super: true, - leader: false, - name: "z", - }, - ]) - }) - test("should parse super with shift modifier", () => { const result = Keybind.parse("super+shift+z") expect(result).toEqual([ diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index f2a299f38aa9..b0906dbb35bb 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -111,7 +111,7 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. - **`model_list`** — Open the model picker (default uses the leader key; see the example above). - **`variant_cycle`** — Cycle variants for the current model (for example reasoning presets on the same model ID). -- **`variant_list`** — Open a variant picker. In the default config it is set to `"none"`; add a combo under `keybinds.variant_list` in `tui.json` if you want this action. You can use `super`, `cmd`, or `command` interchangeably when writing keybinds; on macOS, shortcuts that use the super key are shown as **`cmd`** in the UI. +- **`variant_list`** — Open a variant picker. In the default config it is set to `"none"`; add a combo under `keybinds.variant_list` in `tui.json` if you want this action. --- From 046709dddbdf59f5508e4e40214832dcd995fb57 Mon Sep 17 00:00:00 2001 From: jjangga0214 Date: Thu, 23 Apr 2026 09:25:34 +0900 Subject: [PATCH 16/16] chore: remove stray keybind util tests (docs-only PR) Made-with: Cursor --- packages/opencode/test/util/keybind.test.ts | 22 --------------------- 1 file changed, 22 deletions(-) delete mode 100644 packages/opencode/test/util/keybind.test.ts diff --git a/packages/opencode/test/util/keybind.test.ts b/packages/opencode/test/util/keybind.test.ts deleted file mode 100644 index 74e413d39ed9..000000000000 --- a/packages/opencode/test/util/keybind.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { describe, expect, it } from "bun:test" -import { parse, toString } from "../../src/util/keybind" - -it("parse recognizes cmd as alias for super", () => { - const result = parse("cmd+k") - expect(result).toHaveLength(1) - expect(result[0]!.super).toBe(true) - expect(result[0]!.name).toBe("k") -}) - -it("parse recognizes command as alias for super", () => { - const result = parse("command+shift+a") - expect(result).toHaveLength(1) - expect(result[0]!.super).toBe(true) - expect(result[0]!.shift).toBe(true) - expect(result[0]!.name).toBe("a") -}) - -it("toString displays super key", () => { - const result = toString({ ctrl: false, meta: false, shift: false, super: true, leader: false, name: "k" }) - expect(result).toMatch(/^(cmd|super)\+k$/) -}) \ No newline at end of file