From 7ccf223c847564f5f2a032a92493c8c67e6a822d Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:43:20 -0600 Subject: [PATCH 01/46] chore: cleanup --- .../src/pages/session/session-mobile-tabs.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx index 0ee3bd377d..73aebc079a 100644 --- a/packages/app/src/pages/session/session-mobile-tabs.tsx +++ b/packages/app/src/pages/session/session-mobile-tabs.tsx @@ -13,11 +13,21 @@ export function SessionMobileTabs(props: { return ( - - + + {props.t("session.tab.session")} - + {props.hasReview ? props.t("session.review.filesChanged", { count: props.reviewCount }) : props.t("session.review.change.other")} From 70303d0b4272fee94f412c851de133fb3a45464f Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:48:09 -0600 Subject: [PATCH 02/46] chore: cleanup --- packages/app/src/components/session-context-usage.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index ec4bd2687f..c6e60d3ede 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -15,9 +15,11 @@ interface SessionContextUsageProps { function openSessionContext(args: { view: ReturnType["view"]> + layout: ReturnType tabs: ReturnType["tabs"]> }) { if (!args.view.reviewPanel.opened()) args.view.reviewPanel.open() + if (args.layout.fileTree.opened() && args.layout.fileTree.tab() !== "all") args.layout.fileTree.setTab("all") args.tabs.open("context") args.tabs.setActive("context") } @@ -52,6 +54,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { if (!params.id) return openSessionContext({ view: view(), + layout, tabs: tabs(), }) } From ff3b174c423d89b39ee8154863840e48c8aac371 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 14:58:25 -0600 Subject: [PATCH 03/46] fix(app): normalize oauth error messages --- .../components/dialog-connect-provider.tsx | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 4d24b23158..90f4f41f7c 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -103,6 +103,24 @@ export function DialogConnectProvider(props: { provider: string }) { return value.label ?? "" } + function formatError(value: unknown, fallback: string): string { + if (value && typeof value === "object" && "data" in value) { + const data = (value as { data?: { message?: unknown } }).data + if (typeof data?.message === "string" && data.message) return data.message + } + if (value && typeof value === "object" && "error" in value) { + const nested = formatError((value as { error?: unknown }).error, "") + if (nested) return nested + } + if (value && typeof value === "object" && "message" in value) { + const message = (value as { message?: unknown }).message + if (typeof message === "string" && message) return message + } + if (value instanceof Error && value.message) return value.message + if (typeof value === "string" && value) return value + return fallback + } + async function selectMethod(index: number) { if (timer.current !== undefined) { clearTimeout(timer.current) @@ -141,7 +159,7 @@ export function DialogConnectProvider(props: { provider: string }) { }) .catch((e) => { if (!alive.value) return - dispatch({ type: "auth.error", error: String(e) }) + dispatch({ type: "auth.error", error: formatError(e, language.t("common.requestFailed")) }) }) } } @@ -328,8 +346,7 @@ export function DialogConnectProvider(props: { provider: string }) { await complete() return } - const message = result.error instanceof Error ? result.error.message : String(result.error) - setFormStore("error", message || language.t("provider.connect.oauth.code.invalid")) + setFormStore("error", formatError(result.error, language.t("provider.connect.oauth.code.invalid"))) } return ( @@ -385,7 +402,7 @@ export function DialogConnectProvider(props: { provider: string }) { if (!alive.value) return if (!result.ok) { - const message = result.error instanceof Error ? result.error.message : String(result.error) + const message = formatError(result.error, language.t("common.requestFailed")) dispatch({ type: "auth.error", error: message }) return } From 4e0f509e7b7d84395a541bdfa658f6c98f588221 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:03:02 -0600 Subject: [PATCH 04/46] feat(app): option to turn off sound effects --- packages/app/e2e/selectors.ts | 3 + packages/app/e2e/settings/settings.spec.ts | 25 +++++++ .../app/src/components/settings-general.tsx | 69 +++++++++++++------ packages/app/src/context/notification.tsx | 8 ++- packages/app/src/context/settings.tsx | 21 ++++++ packages/app/src/pages/layout.tsx | 4 +- 6 files changed, 106 insertions(+), 24 deletions(-) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 842433891e..52c9007ea1 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -10,8 +10,11 @@ export const settingsNotificationsAgentSelector = '[data-action="settings-notifi export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]' export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]' export const settingsSoundsAgentSelector = '[data-action="settings-sounds-agent"]' +export const settingsSoundsAgentEnabledSelector = '[data-action="settings-sounds-agent-enabled"]' export const settingsSoundsPermissionsSelector = '[data-action="settings-sounds-permissions"]' +export const settingsSoundsPermissionsEnabledSelector = '[data-action="settings-sounds-permissions-enabled"]' export const settingsSoundsErrorsSelector = '[data-action="settings-sounds-errors"]' +export const settingsSoundsErrorsEnabledSelector = '[data-action="settings-sounds-errors-enabled"]' export const settingsUpdatesStartupSelector = '[data-action="settings-updates-startup"]' export const settingsReleaseNotesSelector = '[data-action="settings-release-notes"]' diff --git a/packages/app/e2e/settings/settings.spec.ts b/packages/app/e2e/settings/settings.spec.ts index 42534968b2..9fbcf79f5e 100644 --- a/packages/app/e2e/settings/settings.spec.ts +++ b/packages/app/e2e/settings/settings.spec.ts @@ -9,6 +9,7 @@ import { settingsNotificationsPermissionsSelector, settingsReleaseNotesSelector, settingsSoundsAgentSelector, + settingsSoundsAgentEnabledSelector, settingsSoundsErrorsSelector, settingsSoundsPermissionsSelector, settingsThemeSelector, @@ -335,6 +336,30 @@ test("changing sound agent selection persists in localStorage", async ({ page, g expect(stored?.sounds?.agent).not.toBe("staplebops-01") }) +test("disabling agent sound disables sound selection", async ({ page, gotoSession }) => { + await gotoSession() + + const dialog = await openSettings(page) + const select = dialog.locator(settingsSoundsAgentSelector) + const switchContainer = dialog.locator(settingsSoundsAgentEnabledSelector) + const trigger = select.locator('[data-slot="select-select-trigger"]') + await expect(select).toBeVisible() + await expect(switchContainer).toBeVisible() + await expect(trigger).toBeEnabled() + + await switchContainer.locator('[data-slot="switch-control"]').click() + await page.waitForTimeout(100) + + await expect(trigger).toBeDisabled() + + const stored = await page.evaluate((key) => { + const raw = localStorage.getItem(key) + return raw ? JSON.parse(raw) : null + }, settingsKey) + + expect(stored?.sounds?.agentEnabled).toBe(false) +}) + test("changing permissions and errors sounds updates localStorage", async ({ page, gotoSession }) => { await gotoSession() diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index c673cab801..439f542bb1 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -306,39 +306,66 @@ export const SettingsGeneral: Component = () => { title={language.t("settings.general.sounds.agent.title")} description={language.t("settings.general.sounds.agent.description")} > - settings.sounds.agent(), + (id) => settings.sounds.setAgent(id), + )} + /> + - settings.sounds.permissions(), + (id) => settings.sounds.setPermissions(id), + )} + /> + - settings.sounds.errors(), + (id) => settings.sounds.setErrors(id), + )} + /> + diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index bf880d115e..04bc2fdaaa 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -233,7 +233,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (!session) return if (session.parentID) return - playSound(soundSrc(settings.sounds.agent())) + if (settings.sounds.agentEnabled()) { + playSound(soundSrc(settings.sounds.agent())) + } append({ directory, @@ -260,7 +262,9 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (meta.disposed) return if (session?.parentID) return - playSound(soundSrc(settings.sounds.errors())) + if (settings.sounds.errorsEnabled()) { + playSound(soundSrc(settings.sounds.errors())) + } const error = "error" in event.properties ? event.properties.error : undefined append({ diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index a8efb1eace..d72d4ceb1e 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -10,8 +10,11 @@ export interface NotificationSettings { } export interface SoundSettings { + agentEnabled: boolean agent: string + permissionsEnabled: boolean permissions: string + errorsEnabled: boolean errors: string } @@ -57,8 +60,11 @@ const defaultSettings: Settings = { errors: false, }, sounds: { + agentEnabled: true, agent: "staplebops-01", + permissionsEnabled: true, permissions: "staplebops-02", + errorsEnabled: true, errors: "nope-03", }, } @@ -168,14 +174,29 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont }, }, sounds: { + agentEnabled: withFallback(() => store.sounds?.agentEnabled, defaultSettings.sounds.agentEnabled), + setAgentEnabled(value: boolean) { + setStore("sounds", "agentEnabled", value) + }, agent: withFallback(() => store.sounds?.agent, defaultSettings.sounds.agent), setAgent(value: string) { setStore("sounds", "agent", value) }, + permissionsEnabled: withFallback( + () => store.sounds?.permissionsEnabled, + defaultSettings.sounds.permissionsEnabled, + ), + setPermissionsEnabled(value: boolean) { + setStore("sounds", "permissionsEnabled", value) + }, permissions: withFallback(() => store.sounds?.permissions, defaultSettings.sounds.permissions), setPermissions(value: string) { setStore("sounds", "permissions", value) }, + errorsEnabled: withFallback(() => store.sounds?.errorsEnabled, defaultSettings.sounds.errorsEnabled), + setErrorsEnabled(value: boolean) { + setStore("sounds", "errorsEnabled", value) + }, errors: withFallback(() => store.sounds?.errors, defaultSettings.sounds.errors), setErrors(value: string) { setStore("sounds", "errors", value) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 5f001177ff..7eb064f425 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -388,7 +388,9 @@ export default function Layout(props: ParentProps) { alertedAtBySession.set(sessionKey, now) if (e.details.type === "permission.asked") { - playSound(soundSrc(settings.sounds.permissions())) + if (settings.sounds.permissionsEnabled()) { + playSound(soundSrc(settings.sounds.permissions())) + } if (settings.notifications.permissions()) { void platform.notify(title, description, href) } From 548608b7ad1252af3181201ef764b16c05d0b786 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 15:15:27 -0600 Subject: [PATCH 05/46] fix(app): terminal pty isolation --- packages/app/src/components/terminal.tsx | 8 ++- .../app/src/utils/terminal-writer.test.ts | 33 ++++++++++ packages/app/src/utils/terminal-writer.ts | 27 ++++++++ packages/opencode/src/pty/index.ts | 66 ++++++++++++++----- packages/opencode/src/server/routes/pty.ts | 21 +++++- .../test/pty/pty-output-isolation.test.ts | 54 +++++++++++++++ 6 files changed, 190 insertions(+), 19 deletions(-) create mode 100644 packages/app/src/utils/terminal-writer.test.ts create mode 100644 packages/app/src/utils/terminal-writer.ts create mode 100644 packages/opencode/test/pty/pty-output-isolation.test.ts diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index f6bb0b48a6..ccf7012d20 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -10,6 +10,7 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco import { useLanguage } from "@/context/language" import { showToast } from "@opencode-ai/ui/toast" import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters" +import { terminalWriter } from "@/utils/terminal-writer" const TOGGLE_TERMINAL_ID = "terminal.toggle" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" @@ -160,6 +161,7 @@ export const Terminal = (props: TerminalProps) => { const start = typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined let cursor = start ?? 0 + let output: ReturnType | undefined const cleanup = () => { if (!cleanups.length) return @@ -300,7 +302,7 @@ export const Terminal = (props: TerminalProps) => { fontSize: 14, fontFamily: monoFontFamily(settings.appearance.font()), allowTransparency: false, - convertEol: true, + convertEol: false, theme: terminalColors(), scrollback: 10_000, ghostty: g, @@ -312,6 +314,7 @@ export const Terminal = (props: TerminalProps) => { } ghostty = g term = t + output = terminalWriter((data) => t.write(data)) t.attachCustomKeyEventHandler((event) => { const key = event.key.toLowerCase() @@ -416,7 +419,7 @@ export const Terminal = (props: TerminalProps) => { const data = typeof event.data === "string" ? event.data : "" if (!data) return - t.write(data) + output?.push(data) cursor += data.length } socket.addEventListener("message", handleMessage) @@ -459,6 +462,7 @@ export const Terminal = (props: TerminalProps) => { onCleanup(() => { disposed = true + output?.flush() persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) cleanup() }) diff --git a/packages/app/src/utils/terminal-writer.test.ts b/packages/app/src/utils/terminal-writer.test.ts new file mode 100644 index 0000000000..d48dd4f4ed --- /dev/null +++ b/packages/app/src/utils/terminal-writer.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test" +import { terminalWriter } from "./terminal-writer" + +describe("terminalWriter", () => { + test("buffers and flushes once per schedule", () => { + const calls: string[] = [] + const scheduled: VoidFunction[] = [] + const writer = terminalWriter( + (data) => calls.push(data), + (flush) => scheduled.push(flush), + ) + + writer.push("a") + writer.push("b") + writer.push("c") + + expect(calls).toEqual([]) + expect(scheduled).toHaveLength(1) + + scheduled[0]?.() + expect(calls).toEqual(["abc"]) + }) + + test("flush is a no-op when empty", () => { + const calls: string[] = [] + const writer = terminalWriter( + (data) => calls.push(data), + (flush) => flush(), + ) + writer.flush() + expect(calls).toEqual([]) + }) +}) diff --git a/packages/app/src/utils/terminal-writer.ts b/packages/app/src/utils/terminal-writer.ts new file mode 100644 index 0000000000..b6caff789c --- /dev/null +++ b/packages/app/src/utils/terminal-writer.ts @@ -0,0 +1,27 @@ +export function terminalWriter( + write: (data: string) => void, + schedule: (flush: VoidFunction) => void = queueMicrotask, +) { + let chunks: string[] | undefined + let scheduled = false + + const flush = () => { + scheduled = false + const items = chunks + if (!items?.length) return + chunks = undefined + write(items.join("")) + } + + const push = (data: string) => { + if (!data) return + if (chunks) chunks.push(data) + else chunks = [data] + + if (scheduled) return + scheduled = true + schedule(flush) + } + + return { push, flush } +} diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 7a07e3ef32..a9052a79eb 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -4,7 +4,6 @@ import { type IPty } from "bun-pty" import z from "zod" import { Identifier } from "../id/id" import { Log } from "../util/log" -import type { WSContext } from "hono/ws" import { Instance } from "../project/instance" import { lazy } from "@opencode-ai/util/lazy" import { Shell } from "@/shell/shell" @@ -17,6 +16,22 @@ export namespace Pty { const BUFFER_CHUNK = 64 * 1024 const encoder = new TextEncoder() + type Socket = { + readyState: number + send: (data: string | Uint8Array | ArrayBuffer) => void + close: (code?: number, reason?: string) => void + } + + const sockets = new WeakMap() + let socketCounter = 0 + + const tagSocket = (ws: Socket) => { + if (!ws || typeof ws !== "object") return + const next = (socketCounter = (socketCounter + 1) % Number.MAX_SAFE_INTEGER) + sockets.set(ws, next) + return next + } + // WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }). const meta = (cursor: number) => { const json = JSON.stringify({ cursor }) @@ -81,7 +96,7 @@ export namespace Pty { buffer: string bufferCursor: number cursor: number - subscribers: Set + subscribers: Map } const state = Instance.state( @@ -91,8 +106,12 @@ export namespace Pty { try { session.process.kill() } catch {} - for (const ws of session.subscribers) { - ws.close() + for (const ws of session.subscribers.keys()) { + try { + ws.close() + } catch { + // ignore + } } } sessions.clear() @@ -154,18 +173,26 @@ export namespace Pty { buffer: "", bufferCursor: 0, cursor: 0, - subscribers: new Set(), + subscribers: new Map(), } state().set(id, session) ptyProcess.onData((data) => { session.cursor += data.length - for (const ws of session.subscribers) { + for (const [ws, id] of session.subscribers) { if (ws.readyState !== 1) { session.subscribers.delete(ws) continue } - ws.send(data) + if (typeof ws === "object" && sockets.get(ws) !== id) { + session.subscribers.delete(ws) + continue + } + try { + ws.send(data) + } catch { + session.subscribers.delete(ws) + } } session.buffer += data @@ -177,14 +204,15 @@ export namespace Pty { ptyProcess.onExit(({ exitCode }) => { log.info("session exited", { id, exitCode }) session.info.status = "exited" - for (const ws of session.subscribers) { - ws.close() + for (const ws of session.subscribers.keys()) { + try { + ws.close() + } catch { + // ignore + } } session.subscribers.clear() Bus.publish(Event.Exited, { id, exitCode }) - for (const ws of session.subscribers) { - ws.close() - } state().delete(id) }) Bus.publish(Event.Created, { info }) @@ -211,9 +239,14 @@ export namespace Pty { try { session.process.kill() } catch {} - for (const ws of session.subscribers) { - ws.close() + for (const ws of session.subscribers.keys()) { + try { + ws.close() + } catch { + // ignore + } } + session.subscribers.clear() state().delete(id) Bus.publish(Event.Deleted, { id }) } @@ -232,7 +265,7 @@ export namespace Pty { } } - export function connect(id: string, ws: WSContext, cursor?: number) { + export function connect(id: string, ws: Socket, cursor?: number) { const session = state().get(id) if (!session) { ws.close() @@ -272,7 +305,8 @@ export namespace Pty { return } - session.subscribers.add(ws) + const socketId = tagSocket(ws) + if (typeof socketId === "number") session.subscribers.set(ws, socketId) return { onMessage: (message: string | ArrayBuffer) => { session.process.write(String(message)) diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts index 1085c1175b..10bf51cb99 100644 --- a/packages/opencode/src/server/routes/pty.ts +++ b/packages/opencode/src/server/routes/pty.ts @@ -160,9 +160,25 @@ export const PtyRoutes = lazy(() => })() let handler: ReturnType if (!Pty.get(id)) throw new Error("Session not found") + + type Socket = { + readyState: number + send: (data: string | Uint8Array | ArrayBuffer) => void + close: (code?: number, reason?: string) => void + } + + const isSocket = (value: unknown): value is Socket => { + if (!value || typeof value !== "object") return false + if (!("readyState" in value)) return false + if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false + if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false + return typeof (value as { readyState?: unknown }).readyState === "number" + } + return { onOpen(_event, ws) { - handler = Pty.connect(id, ws, cursor) + const socket = isSocket(ws.raw) ? ws.raw : ws + handler = Pty.connect(id, socket, cursor) }, onMessage(event) { handler?.onMessage(String(event.data)) @@ -170,6 +186,9 @@ export const PtyRoutes = lazy(() => onClose() { handler?.onClose() }, + onError() { + handler?.onClose() + }, } }), ), diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts new file mode 100644 index 0000000000..b80d373458 --- /dev/null +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "bun:test" +import { Instance } from "../../src/project/instance" +import { Pty } from "../../src/pty" +import { tmpdir } from "../fixture/fixture" + +describe("pty", () => { + test("does not leak output when websocket objects are reused", async () => { + await using dir = await tmpdir({ git: true }) + + await Instance.provide({ + directory: dir.path, + fn: async () => { + const a = await Pty.create({ command: "cat", title: "a" }) + const b = await Pty.create({ command: "cat", title: "b" }) + try { + const outA: string[] = [] + const outB: string[] = [] + + const ws = { + readyState: 1, + send: (data: unknown) => { + outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + }, + close: () => { + // no-op (simulate abrupt drop) + }, + } + + // Connect "a" first with ws. + Pty.connect(a.id, ws as any) + + // Now "reuse" the same ws object for another connection. + ws.send = (data: unknown) => { + outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + } + Pty.connect(b.id, ws as any) + + // Clear connect metadata writes. + outA.length = 0 + outB.length = 0 + + // Output from a must never show up in b. + Pty.write(a.id, "AAA\n") + await Bun.sleep(100) + + expect(outB.join("")).not.toContain("AAA") + } finally { + await Pty.remove(a.id) + await Pty.remove(b.id) + } + }, + }) + }) +}) From 11dd281c92d88726aa4a5da762b8f9300572ccf1 Mon Sep 17 00:00:00 2001 From: Aman Kalra <49478659+amankalra172@users.noreply.github.com> Date: Thu, 12 Feb 2026 22:22:35 +0100 Subject: [PATCH 06/46] docs: update STACKIT provider documentation with typo fix (#13357) Co-authored-by: amankalra172 --- packages/web/src/content/docs/providers.mdx | 32 +++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index e7befcf026..7e0ee1a2dd 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1478,6 +1478,38 @@ SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon --- +### STACKIT + +STACKIT AI Model Serving provides fully managed soverign hosting environment for AI models, focusing on LLMs like Llama, Mistral, and Qwen, with maximum data sovereignty on European infrastructure. + +1. Head over to [STACKIT Portal](https://portal.stackit.cloud), navigate to **AI Model Serving**, and create an auth token for your project. + + :::tip + You need a STACKIT customer account, user account, and project before creating auth tokens. + ::: + +2. Run the `/connect` command and search for **STACKIT**. + + ```txt + /connect + ``` + +3. Enter your STACKIT AI Model Serving auth token. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select from available models like _Qwen3-VL 235B_ or _Llama 3.3 70B_. + + ```txt + /models + ``` +--- + ### OVHcloud AI Endpoints 1. Head over to the [OVHcloud panel](https://ovh.com/manager). Navigate to the `Public Cloud` section, `AI & Machine Learning` > `AI Endpoints` and in `API Keys` tab, click **Create a new API key**. From 20dcff1e2e73c19b3184bbd181b533409c4567e7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 12 Feb 2026 21:23:32 +0000 Subject: [PATCH 07/46] chore: generate --- packages/web/src/content/docs/providers.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 7e0ee1a2dd..db473ad36b 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1508,6 +1508,7 @@ STACKIT AI Model Serving provides fully managed soverign hosting environment for ```txt /models ``` + --- ### OVHcloud AI Endpoints From c0814da785d40273f36eda835c4cfd583cf20d75 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Thu, 12 Feb 2026 22:29:58 +0100 Subject: [PATCH 08/46] do not open console on error (#13374) --- packages/opencode/src/cli/cmd/tui/app.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index dbad3f699f..7b5a2278cb 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -180,6 +180,7 @@ export function tui(input: { exitOnCtrlC: false, useKittyKeyboard: {}, autoFocus: false, + openConsoleOnError: false, consoleOptions: { keyBindings: [{ name: "y", ctrl: true, action: "copy-selection" }], onCopySelection: (text) => { From a8f2884521e755cea9b9e4e52406267bcbda15d2 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 13 Feb 2026 07:38:27 +1000 Subject: [PATCH 09/46] feat: windows selection behavior, manual ctrl+c (#13315) --- packages/opencode/src/cli/cmd/tui/app.tsx | 53 ++++++++++++++----- .../opencode/src/cli/cmd/tui/ui/dialog.tsx | 47 ++++++++++------ .../src/cli/cmd/tui/util/selection.ts | 25 +++++++++ packages/opencode/src/flag/flag.ts | 12 +++-- 4 files changed, 105 insertions(+), 32 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/util/selection.ts diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 7b5a2278cb..ab3d096892 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -1,6 +1,7 @@ import { render, useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { Clipboard } from "@tui/util/clipboard" -import { TextAttributes } from "@opentui/core" +import { Selection } from "@tui/util/selection" +import { MouseButton, TextAttributes } from "@opentui/core" import { RouteProvider, useRoute } from "@tui/context/route" import { Switch, Match, createEffect, untrack, ErrorBoundary, createSignal, onMount, batch, Show, on } from "solid-js" import { win32DisableProcessedInput, win32FlushInputBuffer, win32InstallCtrlCGuard } from "./win32" @@ -210,6 +211,35 @@ function App() { const exit = useExit() const promptRef = usePromptRef() + useKeyboard((evt) => { + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + if (!renderer.getSelection()) return + + // Windows Terminal-like behavior: + // - Ctrl+C copies and dismisses selection + // - Esc dismisses selection + // - Most other key input dismisses selection and is passed through + if (evt.ctrl && evt.name === "c") { + if (!Selection.copy(renderer, toast)) { + renderer.clearSelection() + return + } + + evt.preventDefault() + evt.stopPropagation() + return + } + + if (evt.name === "escape") { + renderer.clearSelection() + evt.preventDefault() + evt.stopPropagation() + return + } + + renderer.clearSelection() + }) + // Wire up console copy-to-clipboard via opentui's onCopySelection callback renderer.console.onCopySelection = async (text: string) => { if (!text || text.length === 0) return @@ -217,6 +247,7 @@ function App() { await Clipboard.copy(text) .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) .catch(toast.error) + renderer.clearSelection() } const [terminalTitleEnabled, setTerminalTitleEnabled] = createSignal(kv.get("terminal_title_enabled", true)) @@ -703,19 +734,15 @@ function App() { width={dimensions().width} height={dimensions().height} backgroundColor={theme.background} - onMouseUp={async () => { - if (Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) { - renderer.clearSelection() - return - } - const text = renderer.getSelection()?.getSelectedText() - if (text && text.length > 0) { - await Clipboard.copy(text) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) - renderer.clearSelection() - } + onMouseDown={(evt) => { + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + if (evt.button !== MouseButton.RIGHT) return + + if (!Selection.copy(renderer, toast)) return + evt.preventDefault() + evt.stopPropagation() }} + onMouseUp={Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? undefined : () => Selection.copy(renderer, toast)} > diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx index 0b57ad29cf..8cebd9cba5 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog.tsx @@ -1,10 +1,11 @@ import { useKeyboard, useRenderer, useTerminalDimensions } from "@opentui/solid" import { batch, createContext, Show, useContext, type JSX, type ParentProps } from "solid-js" import { useTheme } from "@tui/context/theme" -import { Renderable, RGBA } from "@opentui/core" +import { MouseButton, Renderable, RGBA } from "@opentui/core" import { createStore } from "solid-js/store" -import { Clipboard } from "@tui/util/clipboard" import { useToast } from "./toast" +import { Flag } from "@/flag/flag" +import { Selection } from "@tui/util/selection" export function Dialog( props: ParentProps<{ @@ -16,10 +17,18 @@ export function Dialog( const { theme } = useTheme() const renderer = useRenderer() + let dismiss = false + return ( { - if (renderer.getSelection()) return + onMouseDown={() => { + dismiss = !!renderer.getSelection() + }} + onMouseUp={() => { + if (dismiss) { + dismiss = false + return + } props.onClose?.() }} width={dimensions().width} @@ -32,8 +41,8 @@ export function Dialog( backgroundColor={RGBA.fromInts(0, 0, 0, 150)} > { - if (renderer.getSelection()) return + onMouseUp={(e) => { + dismiss = false e.stopPropagation() }} width={props.size === "large" ? 80 : 60} @@ -56,8 +65,13 @@ function init() { size: "medium" as "medium" | "large", }) + const renderer = useRenderer() + useKeyboard((evt) => { - if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && store.stack.length > 0) { + if (store.stack.length === 0) return + if (evt.defaultPrevented) return + if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()) return + if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) { const current = store.stack.at(-1)! current.onClose?.() setStore("stack", store.stack.slice(0, -1)) @@ -67,7 +81,6 @@ function init() { } }) - const renderer = useRenderer() let focus: Renderable | null function refocus() { setTimeout(() => { @@ -138,15 +151,17 @@ export function DialogProvider(props: ParentProps) { {props.children} { - const text = renderer.getSelection()?.getSelectedText() - if (text && text.length > 0) { - await Clipboard.copy(text) - .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) - .catch(toast.error) - renderer.clearSelection() - } + onMouseDown={(evt) => { + if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return + if (evt.button !== MouseButton.RIGHT) return + + if (!Selection.copy(renderer, toast)) return + evt.preventDefault() + evt.stopPropagation() }} + onMouseUp={ + !Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT ? () => Selection.copy(renderer, toast) : undefined + } > value.clear()} size={value.size}> diff --git a/packages/opencode/src/cli/cmd/tui/util/selection.ts b/packages/opencode/src/cli/cmd/tui/util/selection.ts new file mode 100644 index 0000000000..1230852dcc --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/util/selection.ts @@ -0,0 +1,25 @@ +import { Clipboard } from "./clipboard" + +type Toast = { + show: (input: { message: string; variant: "info" | "success" | "warning" | "error" }) => void + error: (err: unknown) => void +} + +type Renderer = { + getSelection: () => { getSelectedText: () => string } | null + clearSelection: () => void +} + +export namespace Selection { + export function copy(renderer: Renderer, toast: Toast): boolean { + const text = renderer.getSelection()?.getSelectedText() + if (!text) return false + + Clipboard.copy(text) + .then(() => toast.show({ message: "Copied to clipboard", variant: "info" })) + .catch(toast.error) + + renderer.clearSelection() + return true + } +} diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index b11058b340..8c999a1c01 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -1,6 +1,10 @@ +function truthyValue(value: string | undefined) { + const v = value?.toLowerCase() + return v === "true" || v === "1" +} + function truthy(key: string) { - const value = process.env[key]?.toLowerCase() - return value === "true" || value === "1" + return truthyValue(process.env[key]) } export namespace Flag { @@ -37,7 +41,9 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER") export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY") - export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT") + const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] + export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = + copy === undefined ? process.platform === "win32" : truthyValue(copy) export const OPENCODE_ENABLE_EXA = truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA") export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS") From 4018c863e3b4b9857fe9378ae54e406a5cf5ab48 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 13 Feb 2026 07:50:43 +1000 Subject: [PATCH 10/46] fix: baseline CPU detection (#13371) --- bun.lock | 6 +- install | 16 +++- package.json | 4 +- packages/desktop/scripts/predev.ts | 4 +- packages/desktop/scripts/utils.ts | 6 +- packages/opencode/bin/opencode | 113 ++++++++++++++++++++++++++--- 6 files changed, 127 insertions(+), 22 deletions(-) diff --git a/bun.lock b/bun.lock index 6c65d91d08..f3590f53d2 100644 --- a/bun.lock +++ b/bun.lock @@ -522,7 +522,7 @@ "@tailwindcss/vite": "4.1.11", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.5", + "@types/bun": "1.3.9", "@types/luxon": "3.7.1", "@types/node": "22.13.9", "@types/semver": "7.7.1", @@ -1853,7 +1853,7 @@ "@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="], - "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], + "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], @@ -2181,7 +2181,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], "bun-webgpu": ["bun-webgpu@0.1.4", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.4", "bun-webgpu-darwin-x64": "^0.1.4", "bun-webgpu-linux-x64": "^0.1.4", "bun-webgpu-win32-x64": "^0.1.4" } }, "sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ=="], diff --git a/install b/install index 22b7ca39ed..b0716d5320 100755 --- a/install +++ b/install @@ -130,7 +130,7 @@ else needs_baseline=false if [ "$arch" = "x64" ]; then if [ "$os" = "linux" ]; then - if ! grep -qi avx2 /proc/cpuinfo 2>/dev/null; then + if ! grep -qwi avx2 /proc/cpuinfo 2>/dev/null; then needs_baseline=true fi fi @@ -141,6 +141,20 @@ else needs_baseline=true fi fi + + if [ "$os" = "windows" ]; then + ps="(Add-Type -MemberDefinition \"[DllImport(\"\"kernel32.dll\"\")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);\" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)" + out="" + if command -v powershell.exe >/dev/null 2>&1; then + out=$(powershell.exe -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true) + elif command -v pwsh >/dev/null 2>&1; then + out=$(pwsh -NoProfile -NonInteractive -Command "$ps" 2>/dev/null || true) + fi + out=$(echo "$out" | tr -d '\r' | tr '[:upper:]' '[:lower:]' | tr -d '[:space:]') + if [ "$out" != "true" ] && [ "$out" != "1" ]; then + needs_baseline=true + fi + fi fi target="$os-$arch" diff --git a/package.json b/package.json index 61ee419736..c396905d45 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.5", + "packageManager": "bun@1.3.9", "scripts": { "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop tauri dev", @@ -23,7 +23,7 @@ "packages/slack" ], "catalog": { - "@types/bun": "1.3.5", + "@types/bun": "1.3.9", "@octokit/rest": "22.0.0", "@hono/zod-validator": "0.4.2", "ulid": "3.0.1", diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts index 3e14250b1a..072567758f 100644 --- a/packages/desktop/scripts/predev.ts +++ b/packages/desktop/scripts/predev.ts @@ -8,6 +8,8 @@ const sidecarConfig = getCurrentSidecar(RUST_TARGET) const binaryPath = windowsify(`../opencode/dist/${sidecarConfig.ocBinary}/bin/opencode`) -await $`cd ../opencode && bun run build --single` +await (sidecarConfig.ocBinary.includes("-baseline") + ? $`cd ../opencode && bun run build --single --baseline` + : $`cd ../opencode && bun run build --single`) await copyBinaryToSidecarFolder(binaryPath, RUST_TARGET) diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index c3019f0b97..2629eb466c 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -8,17 +8,17 @@ export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; ass }, { rustTarget: "x86_64-apple-darwin", - ocBinary: "opencode-darwin-x64", + ocBinary: "opencode-darwin-x64-baseline", assetExt: "zip", }, { rustTarget: "x86_64-pc-windows-msvc", - ocBinary: "opencode-windows-x64", + ocBinary: "opencode-windows-x64-baseline", assetExt: "zip", }, { rustTarget: "x86_64-unknown-linux-gnu", - ocBinary: "opencode-linux-x64", + ocBinary: "opencode-linux-x64-baseline", assetExt: "tar.gz", }, { diff --git a/packages/opencode/bin/opencode b/packages/opencode/bin/opencode index e35cc00944..d73bbce267 100755 --- a/packages/opencode/bin/opencode +++ b/packages/opencode/bin/opencode @@ -47,20 +47,109 @@ if (!arch) { const base = "opencode-" + platform + "-" + arch const binary = platform === "windows" ? "opencode.exe" : "opencode" +function supportsAvx2() { + if (arch !== "x64") return false + + if (platform === "linux") { + try { + return /(^|\s)avx2(\s|$)/i.test(fs.readFileSync("/proc/cpuinfo", "utf8")) + } catch { + return false + } + } + + if (platform === "darwin") { + try { + const result = childProcess.spawnSync("sysctl", ["-n", "hw.optional.avx2_0"], { + encoding: "utf8", + timeout: 1500, + }) + if (result.status !== 0) return false + return (result.stdout || "").trim() === "1" + } catch { + return false + } + } + + if (platform === "windows") { + const cmd = + '(Add-Type -MemberDefinition "[DllImport(""kernel32.dll"")] public static extern bool IsProcessorFeaturePresent(int ProcessorFeature);" -Name Kernel32 -Namespace Win32 -PassThru)::IsProcessorFeaturePresent(40)' + + for (const exe of ["powershell.exe", "pwsh.exe", "pwsh", "powershell"]) { + try { + const result = childProcess.spawnSync(exe, ["-NoProfile", "-NonInteractive", "-Command", cmd], { + encoding: "utf8", + timeout: 3000, + windowsHide: true, + }) + if (result.status !== 0) continue + const out = (result.stdout || "").trim().toLowerCase() + if (out === "true" || out === "1") return true + if (out === "false" || out === "0") return false + } catch { + continue + } + } + + return false + } + + return false +} + +const names = (() => { + const avx2 = supportsAvx2() + const baseline = arch === "x64" && !avx2 + + if (platform === "linux") { + const musl = (() => { + try { + if (fs.existsSync("/etc/alpine-release")) return true + } catch { + // ignore + } + + try { + const result = childProcess.spawnSync("ldd", ["--version"], { encoding: "utf8" }) + const text = ((result.stdout || "") + (result.stderr || "")).toLowerCase() + if (text.includes("musl")) return true + } catch { + // ignore + } + + return false + })() + + if (musl) { + if (arch === "x64") { + if (baseline) return [`${base}-baseline-musl`, `${base}-musl`, `${base}-baseline`, base] + return [`${base}-musl`, `${base}-baseline-musl`, base, `${base}-baseline`] + } + return [`${base}-musl`, base] + } + + if (arch === "x64") { + if (baseline) return [`${base}-baseline`, base, `${base}-baseline-musl`, `${base}-musl`] + return [base, `${base}-baseline`, `${base}-musl`, `${base}-baseline-musl`] + } + return [base, `${base}-musl`] + } + + if (arch === "x64") { + if (baseline) return [`${base}-baseline`, base] + return [base, `${base}-baseline`] + } + return [base] +})() + function findBinary(startDir) { let current = startDir for (;;) { const modules = path.join(current, "node_modules") if (fs.existsSync(modules)) { - const entries = fs.readdirSync(modules) - for (const entry of entries) { - if (!entry.startsWith(base)) { - continue - } - const candidate = path.join(modules, entry, "bin", binary) - if (fs.existsSync(candidate)) { - return candidate - } + for (const name of names) { + const candidate = path.join(modules, name, "bin", binary) + if (fs.existsSync(candidate)) return candidate } } const parent = path.dirname(current) @@ -74,9 +163,9 @@ function findBinary(startDir) { const resolved = findBinary(scriptDir) if (!resolved) { console.error( - 'It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing the "' + - base + - '" package', + "It seems that your package manager failed to install the right version of the opencode CLI for your platform. You can try manually installing " + + names.map((n) => `\"${n}\"`).join(" or ") + + " package", ) process.exit(1) } From 445e0d76765d745ee59a16eb13eb3206f6037cce Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 12 Feb 2026 22:04:31 +0000 Subject: [PATCH 11/46] 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 a1b41e2f9a..70d7378493 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-saYZlUTkBfg9vp5J1CrJUM1PBXK4xKwyz28RKlT0JWo=", - "aarch64-linux": "sha256-qoiX2CpOD+HSI+eLh3I84TTPdhWdG6MzfkDAXE6ldPo=", - "aarch64-darwin": "sha256-LbAvdaOBuftBoHvQPFwJGr0smg8vH4wNHS6BYdyXdDs=", - "x86_64-darwin": "sha256-bv5qb9Fi8SyrgZFhcdlvYNc4bjyvdyHY3YgUpmkEH2U=" + "x86_64-linux": "sha256-XIf7b6yALzH1/MkGGrsmq2DeXIC9vgD9a7D/dxhi6iU=", + "aarch64-linux": "sha256-mKDCs6QhIelWc3E17zOufaSDTovtjO/Xyh3JtlWl01s=", + "aarch64-darwin": "sha256-wC7bbbIyZ62uMxTr9FElTbEBMrfz0S/ndqwZZ3V9EOA=", + "x86_64-darwin": "sha256-/7Nn65m5Zhvzz0TKsG9nWd2v5WDHQNi3UzCfuAR8SLo=" } } From 93eee0daf40668a487bdbda439147ad13c8d13cc Mon Sep 17 00:00:00 2001 From: Smit Chaudhary Date: Thu, 12 Feb 2026 23:13:48 +0100 Subject: [PATCH 12/46] fix: look for recent model in fallback in cli (#12582) --- packages/opencode/src/provider/provider.ts | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index d76cc902ae..f72d9d0edb 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -14,6 +14,8 @@ import { Env } from "../env" import { Instance } from "../project/instance" import { Flag } from "../flag/flag" import { iife } from "@/util/iife" +import { Global } from "../global" +import path from "path" // Direct imports for bundled providers import { createAmazonBedrock, type AmazonBedrockProviderSettings } from "@ai-sdk/amazon-bedrock" @@ -1229,9 +1231,21 @@ export namespace Provider { const cfg = await Config.get() if (cfg.model) return parseModel(cfg.model) - const provider = await list() - .then((val) => Object.values(val)) - .then((x) => x.find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id))) + const providers = await list() + const recent = (await Bun.file(path.join(Global.Path.state, "model.json")) + .json() + .then((x) => (Array.isArray(x.recent) ? x.recent : [])) + .catch(() => [])) as { providerID: string; modelID: string }[] + for (const entry of recent) { + const provider = providers[entry.providerID] + if (!provider) continue + if (!provider.models[entry.modelID]) continue + return { providerID: entry.providerID, modelID: entry.modelID } + } + + const provider = Object.values(providers).find( + (p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id), + ) if (!provider) throw new Error("no providers found") const [model] = sort(Object.values(provider.models)) if (!model) throw new Error("no models found") From d475fd6137ad669a8a73027d91b516a57846c379 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 12 Feb 2026 22:14:45 +0000 Subject: [PATCH 13/46] chore: generate --- packages/opencode/src/provider/provider.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f72d9d0edb..44bcf8adb3 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1243,9 +1243,7 @@ export namespace Provider { return { providerID: entry.providerID, modelID: entry.modelID } } - const provider = Object.values(providers).find( - (p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id), - ) + const provider = Object.values(providers).find((p) => !cfg.provider || Object.keys(cfg.provider).includes(p.id)) if (!provider) throw new Error("no providers found") const [model] = sort(Object.values(provider.models)) if (!model) throw new Error("no models found") From f66624fe6eba5aa00662c8d0925c5c6795b2b986 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 12 Feb 2026 16:38:51 -0600 Subject: [PATCH 14/46] chore: cleanup flag code (#13389) --- packages/opencode/src/flag/flag.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 8c999a1c01..dfcb88bc51 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -1,10 +1,6 @@ -function truthyValue(value: string | undefined) { - const v = value?.toLowerCase() - return v === "true" || v === "1" -} - function truthy(key: string) { - return truthyValue(process.env[key]) + const value = process.env[key]?.toLowerCase() + return value === "true" || value === "1" } export namespace Flag { @@ -41,9 +37,10 @@ export namespace Flag { export const OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = truthy("OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER") export const OPENCODE_EXPERIMENTAL_ICON_DISCOVERY = OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY") + const copy = process.env["OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT"] export const OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT = - copy === undefined ? process.platform === "win32" : truthyValue(copy) + copy === undefined ? process.platform === "win32" : truthy("OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT") export const OPENCODE_ENABLE_EXA = truthy("OPENCODE_ENABLE_EXA") || OPENCODE_EXPERIMENTAL || truthy("OPENCODE_EXPERIMENTAL_EXA") export const OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS = number("OPENCODE_EXPERIMENTAL_BASH_DEFAULT_TIMEOUT_MS") From 29671c1397b0ecfb9510186a0aae89696896da2a Mon Sep 17 00:00:00 2001 From: Ariane Emory <97994360+ariane-emory@users.noreply.github.com> Date: Thu, 12 Feb 2026 17:59:44 -0500 Subject: [PATCH 15/46] fix: token substitution in OPENCODE_CONFIG_CONTENT (#13384) --- packages/opencode/src/config/config.ts | 8 ++- packages/opencode/src/flag/flag.ts | 13 +++- packages/opencode/test/config/config.test.ts | 65 ++++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 8f0f583ea3..f4d7a840fe 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -175,8 +175,14 @@ export namespace Config { } // Inline config content overrides all non-managed config sources. + // Route through load() to enable {env:} and {file:} token substitution. + // Use a path within Instance.directory so relative {file:} paths resolve correctly. + // The filename "OPENCODE_CONFIG_CONTENT" appears in error messages for clarity. if (Flag.OPENCODE_CONFIG_CONTENT) { - result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT)) + result = mergeConfigConcatArrays( + result, + await load(Flag.OPENCODE_CONFIG_CONTENT, path.join(Instance.directory, "OPENCODE_CONFIG_CONTENT")), + ) log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index dfcb88bc51..641cb3325b 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -8,7 +8,7 @@ export namespace Flag { export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] export declare const OPENCODE_CONFIG_DIR: string | undefined - export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] + export declare const OPENCODE_CONFIG_CONTENT: string | undefined export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE") export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE") @@ -94,3 +94,14 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", { enumerable: true, configurable: false, }) + +// Dynamic getter for OPENCODE_CONFIG_CONTENT +// This must be evaluated at access time, not module load time, +// because external tooling may set this env var at runtime +Object.defineProperty(Flag, "OPENCODE_CONFIG_CONTENT", { + get() { + return process.env["OPENCODE_CONFIG_CONTENT"] + }, + enumerable: true, + configurable: false, +}) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 91b87f6498..331e05d5a7 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1800,3 +1800,68 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { } }) }) + +// OPENCODE_CONFIG_CONTENT should support {env:} and {file:} token substitution +// just like file-based config sources do. +describe("OPENCODE_CONFIG_CONTENT token substitution", () => { + test("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", async () => { + const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"] + const originalTestVar = process.env["TEST_CONFIG_VAR"] + process.env["TEST_CONFIG_VAR"] = "test_api_key_12345" + process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ + $schema: "https://opencode.ai/config.json", + theme: "{env:TEST_CONFIG_VAR}", + }) + + try { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.theme).toBe("test_api_key_12345") + }, + }) + } finally { + if (originalEnv !== undefined) { + process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv + } else { + delete process.env["OPENCODE_CONFIG_CONTENT"] + } + if (originalTestVar !== undefined) { + process.env["TEST_CONFIG_VAR"] = originalTestVar + } else { + delete process.env["TEST_CONFIG_VAR"] + } + } + }) + + test("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", async () => { + const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"] + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file") + process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ + $schema: "https://opencode.ai/config.json", + theme: "{file:./api_key.txt}", + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.theme).toBe("secret_key_from_file") + }, + }) + } finally { + if (originalEnv !== undefined) { + process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv + } else { + delete process.env["OPENCODE_CONFIG_CONTENT"] + } + } + }) +}) From 76db218674496f9ca9e91b49e5718eabf6df7cc0 Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 12 Feb 2026 23:18:40 +0000 Subject: [PATCH 16/46] release: v1.1.64 --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 37 insertions(+), 37 deletions(-) diff --git a/bun.lock b/bun.lock index f3590f53d2..3fe8a4ca07 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -215,7 +215,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -244,7 +244,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -260,7 +260,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.63", + "version": "1.1.64", "bin": { "opencode": "./bin/opencode", }, @@ -366,7 +366,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -386,7 +386,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.63", + "version": "1.1.64", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -397,7 +397,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -410,7 +410,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -452,7 +452,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "zod": "catalog:", }, @@ -463,7 +463,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 90b5a9c300..ebd1a4b35b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.63", + "version": "1.1.64", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index d80de55a24..c5556a4431 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.63", + "version": "1.1.64", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index ccc11ba3a0..498270b952 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.63", + "version": "1.1.64", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index b612f54308..d2117dffb2 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.63", + "version": "1.1.64", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 864c233820..f632ab92fe 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.63", + "version": "1.1.64", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 526610e6eb..da89d36a88 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.63", + "version": "1.1.64", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index a86a549495..31b62e12b1 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.63", + "version": "1.1.64", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 475e6a870d..22aca32bae 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.63" +version = "1.1.64" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.63/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index f2e8e5dc5d..ae9a6d7b3c 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.63", + "version": "1.1.64", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 99a69c3357..f58a3d2fe9 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.63", + "version": "1.1.64", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 7040059f33..d88d5a7ba3 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.1.63", + "version": "1.1.64", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 7a47fbfa66..13d0b549ba 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.1.63", + "version": "1.1.64", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index abede0f9d2..7f0eaff83f 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.63", + "version": "1.1.64", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 34720215f1..6d20e3dfdc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.63", + "version": "1.1.64", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 6bc354049b..078adbe142 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.63", + "version": "1.1.64", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 0d04a5adfe..6f5fe726f5 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.1.63", + "version": "1.1.64", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 61ccf91b44..30c07d3139 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.1.63", + "version": "1.1.64", "publisher": "sst-dev", "repository": { "type": "git", From 991496a753545f2705072d4da537c175dca357e6 Mon Sep 17 00:00:00 2001 From: projectArtur <155688912+ASidorenkoCode@users.noreply.github.com> Date: Fri, 13 Feb 2026 01:20:00 +0100 Subject: [PATCH 17/46] fix: resolve ACP hanging indefinitely in thinking state on Windows (#13222) Co-authored-by: Claude Opus 4.6 Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline --- packages/opencode/src/project/project.ts | 46 ++++++------- packages/opencode/src/snapshot/index.ts | 5 +- packages/opencode/src/util/git.ts | 64 +++++++++++++++++++ .../opencode/test/project/project.test.ts | 59 ++++++++--------- 4 files changed, 112 insertions(+), 62 deletions(-) create mode 100644 packages/opencode/src/util/git.ts diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index f6902de4e1..c79a62c6c9 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -2,7 +2,6 @@ import z from "zod" import fs from "fs/promises" import { Filesystem } from "../util/filesystem" import path from "path" -import { $ } from "bun" import { Storage } from "../storage/storage" import { Log } from "../util/log" import { Flag } from "@/flag/flag" @@ -13,6 +12,7 @@ import { BusEvent } from "@/bus/bus-event" import { iife } from "@/util/iife" import { GlobalBus } from "@/bus/global" import { existsSync } from "fs" +import { git } from "../util/git" export namespace Project { const log = Log.create({ service: "project" }) @@ -55,15 +55,15 @@ export namespace Project { const { id, sandbox, worktree, vcs } = await iife(async () => { const matches = Filesystem.up({ targets: [".git"], start: directory }) - const git = await matches.next().then((x) => x.value) + const dotgit = await matches.next().then((x) => x.value) await matches.return() - if (git) { - let sandbox = path.dirname(git) + if (dotgit) { + let sandbox = path.dirname(dotgit) const gitBinary = Bun.which("git") // cached id calculation - let id = await Bun.file(path.join(git, "opencode")) + let id = await Bun.file(path.join(dotgit, "opencode")) .text() .then((x) => x.trim()) .catch(() => undefined) @@ -79,13 +79,11 @@ export namespace Project { // generate id from root commit if (!id) { - const roots = await $`git rev-list --max-parents=0 --all` - .quiet() - .nothrow() - .cwd(sandbox) - .text() - .then((x) => - x + const roots = await git(["rev-list", "--max-parents=0", "--all"], { + cwd: sandbox, + }) + .then(async (result) => + (await result.text()) .split("\n") .filter(Boolean) .map((x) => x.trim()) @@ -104,7 +102,7 @@ export namespace Project { id = roots[0] if (id) { - void Bun.file(path.join(git, "opencode")) + void Bun.file(path.join(dotgit, "opencode")) .write(id) .catch(() => undefined) } @@ -119,12 +117,10 @@ export namespace Project { } } - const top = await $`git rev-parse --show-toplevel` - .quiet() - .nothrow() - .cwd(sandbox) - .text() - .then((x) => path.resolve(sandbox, x.trim())) + const top = await git(["rev-parse", "--show-toplevel"], { + cwd: sandbox, + }) + .then(async (result) => path.resolve(sandbox, (await result.text()).trim())) .catch(() => undefined) if (!top) { @@ -138,13 +134,11 @@ export namespace Project { sandbox = top - const worktree = await $`git rev-parse --git-common-dir` - .quiet() - .nothrow() - .cwd(sandbox) - .text() - .then((x) => { - const dirname = path.dirname(x.trim()) + const worktree = await git(["rev-parse", "--git-common-dir"], { + cwd: sandbox, + }) + .then(async (result) => { + const dirname = path.dirname((await result.text()).trim()) if (dirname === ".") return sandbox return dirname }) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index b3c8a905c2..a1c2b57812 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -2,6 +2,7 @@ import { $ } from "bun" import path from "path" import fs from "fs/promises" import { Log } from "../util/log" +import { Flag } from "../flag/flag" import { Global } from "../global" import z from "zod" import { Config } from "../config/config" @@ -23,7 +24,7 @@ export namespace Snapshot { } export async function cleanup() { - if (Instance.project.vcs !== "git") return + if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return const cfg = await Config.get() if (cfg.snapshot === false) return const git = gitdir() @@ -48,7 +49,7 @@ export namespace Snapshot { } export async function track() { - if (Instance.project.vcs !== "git") return + if (Instance.project.vcs !== "git" || Flag.OPENCODE_CLIENT === "acp") return const cfg = await Config.get() if (cfg.snapshot === false) return const git = gitdir() diff --git a/packages/opencode/src/util/git.ts b/packages/opencode/src/util/git.ts new file mode 100644 index 0000000000..201def36a8 --- /dev/null +++ b/packages/opencode/src/util/git.ts @@ -0,0 +1,64 @@ +import { $ } from "bun" +import { Flag } from "../flag/flag" + +export interface GitResult { + exitCode: number + text(): string | Promise + stdout: Buffer | ReadableStream + stderr: Buffer | ReadableStream +} + +/** + * Run a git command. + * + * Uses Bun's lightweight `$` shell by default. When the process is running + * as an ACP client, child processes inherit the parent's stdin pipe which + * carries protocol data – on Windows this causes git to deadlock. In that + * case we fall back to `Bun.spawn` with `stdin: "ignore"`. + */ +export async function git(args: string[], opts: { cwd: string; env?: Record }): Promise { + if (Flag.OPENCODE_CLIENT === "acp") { + try { + const proc = Bun.spawn(["git", ...args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + cwd: opts.cwd, + env: opts.env ? { ...process.env, ...opts.env } : process.env, + }) + // Read output concurrently with exit to avoid pipe buffer deadlock + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).arrayBuffer(), + new Response(proc.stderr).arrayBuffer(), + ]) + const stdoutBuf = Buffer.from(stdout) + const stderrBuf = Buffer.from(stderr) + return { + exitCode, + text: () => stdoutBuf.toString(), + stdout: stdoutBuf, + stderr: stderrBuf, + } + } catch (error) { + const stderr = Buffer.from(error instanceof Error ? error.message : String(error)) + return { + exitCode: 1, + text: () => "", + stdout: Buffer.alloc(0), + stderr, + } + } + } + + const env = opts.env ? { ...process.env, ...opts.env } : undefined + let cmd = $`git ${args}`.quiet().nothrow().cwd(opts.cwd) + if (env) cmd = cmd.env(env) + const result = await cmd + return { + exitCode: result.exitCode, + text: () => result.text(), + stdout: result.stdout, + stderr: result.stderr, + } +} diff --git a/packages/opencode/test/project/project.test.ts b/packages/opencode/test/project/project.test.ts index 0e99c5648b..581c63b567 100644 --- a/packages/opencode/test/project/project.test.ts +++ b/packages/opencode/test/project/project.test.ts @@ -8,54 +8,45 @@ import { tmpdir } from "../fixture/fixture" Log.init({ print: false }) -const bunModule = await import("bun") +const gitModule = await import("../../src/util/git") +const originalGit = gitModule.git + type Mode = "none" | "rev-list-fail" | "top-fail" | "common-dir-fail" let mode: Mode = "none" -function render(parts: TemplateStringsArray, vals: unknown[]) { - return parts.reduce((acc, part, i) => `${acc}${part}${i < vals.length ? String(vals[i]) : ""}`, "") -} - -function fakeShell(output: { exitCode: number; stdout: string; stderr: string }) { - const result = { - exitCode: output.exitCode, - stdout: Buffer.from(output.stdout), - stderr: Buffer.from(output.stderr), - text: async () => output.stdout, - } - const shell = { - quiet: () => shell, - nothrow: () => shell, - cwd: () => shell, - env: () => shell, - text: async () => output.stdout, - then: (onfulfilled: (value: typeof result) => unknown, onrejected?: (reason: unknown) => unknown) => - Promise.resolve(result).then(onfulfilled, onrejected), - catch: (onrejected: (reason: unknown) => unknown) => Promise.resolve(result).catch(onrejected), - finally: (onfinally: (() => void) | undefined | null) => Promise.resolve(result).finally(onfinally), - } - return shell -} - -mock.module("bun", () => ({ - ...bunModule, - $: (parts: TemplateStringsArray, ...vals: unknown[]) => { - const cmd = render(parts, vals).replaceAll(",", " ").replace(/\s+/g, " ").trim() +mock.module("../../src/util/git", () => ({ + git: (args: string[], opts: { cwd: string; env?: Record }) => { + const cmd = ["git", ...args].join(" ") if ( mode === "rev-list-fail" && cmd.includes("git rev-list") && cmd.includes("--max-parents=0") && cmd.includes("--all") ) { - return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" }) + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) } if (mode === "top-fail" && cmd.includes("git rev-parse") && cmd.includes("--show-toplevel")) { - return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" }) + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) } if (mode === "common-dir-fail" && cmd.includes("git rev-parse") && cmd.includes("--git-common-dir")) { - return fakeShell({ exitCode: 128, stdout: "", stderr: "fatal" }) + return Promise.resolve({ + exitCode: 128, + text: () => Promise.resolve(""), + stdout: Buffer.from(""), + stderr: Buffer.from("fatal"), + }) } - return (bunModule.$ as any)(parts, ...vals) + return originalGit(args, opts) }, })) From adb0c4d4f94f6260a67bb9a48ef3a7faa6042bf3 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 13 Feb 2026 08:49:52 +0800 Subject: [PATCH 18/46] desktop: only show loading window if sqlite migration is necessary --- packages/desktop/src-tauri/src/lib.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index bec72c04fa..fe71ef029d 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -566,8 +566,8 @@ async fn initialize(app: AppHandle) { // come from any invocation of the sidecar CLI. The progress is captured by a stdout stream interceptor. // Then in the loading task, we wait for sqlite migration to complete before // starting our health check against the server, otherwise long migrations could result in a timeout. - let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some(); - let sqlite_done = (sqlite_enabled && !sqlite_file_exists()).then(|| { + let needs_sqlite_migration = option_env!("OPENCODE_SQLITE").is_some() && !sqlite_file_exists(); + let sqlite_done = needs_sqlite_migration.then(|| { tracing::info!( path = %opencode_db_path().expect("failed to get db path").display(), "Sqlite file not found, waiting for it to be generated" @@ -670,7 +670,7 @@ async fn initialize(app: AppHandle) { .map_err(|_| ()) .shared(); - let loading_window = if sqlite_enabled + let loading_window = if needs_sqlite_migration && timeout(Duration::from_secs(1), loading_task.clone()) .await .is_err() From 0303c29e3ff4f45aff4176e496ecb3f5fa5b611a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:27:16 -0600 Subject: [PATCH 19/46] fix(app): failed to create store --- .../context/global-sync/child-store.test.ts | 39 +++++++++++++++++++ .../src/context/global-sync/child-store.ts | 6 +-- 2 files changed, 42 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/context/global-sync/child-store.test.ts diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts new file mode 100644 index 0000000000..500f0fc70a --- /dev/null +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test" +import { createRoot, getOwner } from "solid-js" +import { createStore } from "solid-js/store" +import type { State } from "./types" +import { createChildStoreManager } from "./child-store" + +const child = () => createStore({} as State) + +describe("createChildStoreManager", () => { + test("does not evict the active directory during mark", () => { + const owner = createRoot((dispose) => { + const current = getOwner() + dispose() + return current + }) + if (!owner) throw new Error("owner required") + + const manager = createChildStoreManager({ + owner, + markStats() {}, + incrementEvictions() {}, + isBooting: () => false, + isLoadingSessions: () => false, + onBootstrap() {}, + onDispose() {}, + }) + + Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { + manager.children[directory] = child() + manager.pin(directory) + }) + + const directory = "/active" + manager.children[directory] = child() + manager.mark(directory) + + expect(manager.children[directory]).toBeDefined() + }) +}) diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index 2feb7fe088..af08c3bd43 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -36,7 +36,7 @@ export function createChildStoreManager(input: { const mark = (directory: string) => { if (!directory) return lifecycle.set(directory, { lastAccessAt: Date.now() }) - runEviction() + runEviction(directory) } const pin = (directory: string) => { @@ -106,7 +106,7 @@ export function createChildStoreManager(input: { return true } - function runEviction() { + function runEviction(skip?: string) { const stores = Object.keys(children) if (stores.length === 0) return const list = pickDirectoriesToEvict({ @@ -116,7 +116,7 @@ export function createChildStoreManager(input: { max: MAX_DIR_STORES, ttl: DIR_IDLE_TTL_MS, now: Date.now(), - }) + }).filter((directory) => directory !== skip) if (list.length === 0) return for (const directory of list) { if (!disposeDirectory(directory)) continue From 8da5fd0a66b2b31f4d77eb8c0949c148b9a7d760 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:35:01 -0600 Subject: [PATCH 20/46] fix(app): worktree delete --- packages/opencode/src/worktree/index.ts | 81 +++++++++++++------ .../test/project/worktree-remove.test.ts | 64 +++++++++++++++ 2 files changed, 119 insertions(+), 26 deletions(-) create mode 100644 packages/opencode/test/project/worktree-remove.test.ts diff --git a/packages/opencode/src/worktree/index.ts b/packages/opencode/src/worktree/index.ts index 88c778cbb8..85d7f6d0e8 100644 --- a/packages/opencode/src/worktree/index.ts +++ b/packages/opencode/src/worktree/index.ts @@ -420,49 +420,78 @@ export namespace Worktree { } const directory = await canonical(input.directory) + const locate = async (stdout: Uint8Array | undefined) => { + const lines = outputText(stdout) + .split("\n") + .map((line) => line.trim()) + const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => { + if (!line) return acc + if (line.startsWith("worktree ")) { + acc.push({ path: line.slice("worktree ".length).trim() }) + return acc + } + const current = acc[acc.length - 1] + if (!current) return acc + if (line.startsWith("branch ")) { + current.branch = line.slice("branch ".length).trim() + } + return acc + }, []) + + return (async () => { + for (const item of entries) { + if (!item.path) continue + const key = await canonical(item.path) + if (key === directory) return item + } + })() + } + + const clean = (target: string) => + fs + .rm(target, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: 100, + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + throw new RemoveFailedError({ message: message || "Failed to remove git worktree 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" }) } - const lines = outputText(list.stdout) - .split("\n") - .map((line) => line.trim()) - const entries = lines.reduce<{ path?: string; branch?: string }[]>((acc, line) => { - if (!line) return acc - if (line.startsWith("worktree ")) { - acc.push({ path: line.slice("worktree ".length).trim() }) - return acc - } - const current = acc[acc.length - 1] - if (!current) return acc - if (line.startsWith("branch ")) { - current.branch = line.slice("branch ".length).trim() - } - return acc - }, []) - - const entry = await (async () => { - for (const item of entries) { - if (!item.path) continue - const key = await canonical(item.path) - if (key === directory) return item - } - })() + const entry = await locate(list.stdout) if (!entry?.path) { const directoryExists = await exists(directory) if (directoryExists) { - await fs.rm(directory, { recursive: true, force: true }) + await clean(directory) } return true } const removed = await $`git worktree remove --force ${entry.path}`.quiet().nothrow().cwd(Instance.worktree) if (removed.exitCode !== 0) { - throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" }) + const next = await $`git worktree list --porcelain`.quiet().nothrow().cwd(Instance.worktree) + if (next.exitCode !== 0) { + throw new RemoveFailedError({ + message: errorText(removed) || errorText(next) || "Failed to remove git worktree", + }) + } + + const stale = await locate(next.stdout) + if (stale?.path) { + throw new RemoveFailedError({ message: errorText(removed) || "Failed to remove git worktree" }) + } } + await clean(entry.path) + const branch = entry.branch?.replace(/^refs\/heads\//, "") if (branch) { const deleted = await $`git branch -D ${branch}`.quiet().nothrow().cwd(Instance.worktree) diff --git a/packages/opencode/test/project/worktree-remove.test.ts b/packages/opencode/test/project/worktree-remove.test.ts new file mode 100644 index 0000000000..32d38fe84d --- /dev/null +++ b/packages/opencode/test/project/worktree-remove.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from "bun:test" +import { $ } from "bun" +import fs from "fs/promises" +import path from "path" +import { Instance } from "../../src/project/instance" +import { Worktree } from "../../src/worktree" +import { tmpdir } from "../fixture/fixture" + +describe("Worktree.remove", () => { + test("continues when git remove exits non-zero after detaching", async () => { + await using tmp = await tmpdir({ git: true }) + const root = tmp.path + const name = `remove-regression-${Date.now().toString(36)}` + const branch = `opencode/${name}` + const dir = path.join(root, "..", name) + + await $`git worktree add --no-checkout -b ${branch} ${dir}`.cwd(root).quiet() + await $`git reset --hard`.cwd(dir).quiet() + + const real = (await $`which git`.quiet().text()).trim() + expect(real).toBeTruthy() + + const bin = path.join(root, "bin") + const shim = path.join(bin, "git") + await fs.mkdir(bin, { recursive: true }) + await Bun.write( + shim, + [ + "#!/bin/bash", + `REAL_GIT=${JSON.stringify(real)}`, + 'if [ "$1" = "worktree" ] && [ "$2" = "remove" ]; then', + ' "$REAL_GIT" "$@" >/dev/null 2>&1', + ' echo "fatal: failed to remove worktree: Directory not empty" >&2', + " exit 1", + "fi", + 'exec "$REAL_GIT" "$@"', + ].join("\n"), + ) + await fs.chmod(shim, 0o755) + + const prev = process.env.PATH ?? "" + process.env.PATH = `${bin}${path.delimiter}${prev}` + + const ok = await (async () => { + try { + return await Instance.provide({ + directory: root, + fn: () => Worktree.remove({ directory: dir }), + }) + } finally { + process.env.PATH = prev + } + })() + + expect(ok).toBe(true) + expect(await Bun.file(dir).exists()).toBe(false) + + const list = await $`git worktree list --porcelain`.cwd(root).quiet().text() + expect(list).not.toContain(`worktree ${dir}`) + + const ref = await $`git show-ref --verify --quiet refs/heads/${branch}`.cwd(root).quiet().nothrow() + expect(ref.exitCode).not.toBe(0) + }) +}) From b525c03d205e37ad7527e6bd1749b324395dd6b7 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:52:20 -0600 Subject: [PATCH 21/46] chore: cleanup --- packages/ui/src/components/toast.css | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/ui/src/components/toast.css b/packages/ui/src/components/toast.css index de547f9c78..4e6504d061 100644 --- a/packages/ui/src/components/toast.css +++ b/packages/ui/src/components/toast.css @@ -21,6 +21,11 @@ padding: 0; max-height: 100%; overflow-y: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } } } @@ -101,6 +106,11 @@ min-width: 0; overflow-x: hidden; overflow-y: auto; + scrollbar-width: none; + + &::-webkit-scrollbar { + display: none; + } } [data-slot="toast-title"] { From 7f95cc64c57b439f58833d0300a1da93b3b893df Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:58:43 -0600 Subject: [PATCH 22/46] fix(app): prompt input quirks --- packages/app/src/components/prompt-input.tsx | 33 +++++++++++++---- .../prompt-input/editor-dom.test.ts | 36 ++++++++++++++++--- .../src/components/prompt-input/editor-dom.ts | 2 -- .../components/prompt-input/history.test.ts | 24 ++++++++++++- .../src/components/prompt-input/history.ts | 7 ++++ 5 files changed, 87 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index abc203aa10..8e8c3c895b 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -38,7 +38,12 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" -import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" +import { + canNavigateHistoryAtCursor, + navigatePromptHistory, + prependHistoryEntry, + promptLength, +} from "./prompt-input/history" import { createPromptSubmit } from "./prompt-input/submit" import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/slash-popover" import { PromptContextItems } from "./prompt-input/context-items" @@ -473,10 +478,7 @@ export const PromptInput: Component = (props) => { const prev = node.previousSibling const next = node.nextSibling const prevIsBr = prev?.nodeType === Node.ELEMENT_NODE && (prev as HTMLElement).tagName === "BR" - const nextIsBr = next?.nodeType === Node.ELEMENT_NODE && (next as HTMLElement).tagName === "BR" - if (!prevIsBr && !nextIsBr) return false - if (nextIsBr && !prevIsBr && prev) return false - return true + return !!prevIsBr && !next } if (node.nodeType !== Node.ELEMENT_NODE) return false const el = node as HTMLElement @@ -496,6 +498,11 @@ export const PromptInput: Component = (props) => { editorRef.appendChild(createPill(part)) } } + + const last = editorRef.lastChild + if (last?.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR") { + editorRef.appendChild(document.createTextNode("\u200B")) + } } createEffect( @@ -729,7 +736,17 @@ export const PromptInput: Component = (props) => { } } if (last.nodeType !== Node.TEXT_NODE) { - range.setStartAfter(last) + const isBreak = last.nodeType === Node.ELEMENT_NODE && (last as HTMLElement).tagName === "BR" + const next = last.nextSibling + const emptyText = next?.nodeType === Node.TEXT_NODE && (next.textContent ?? "") === "" + if (isBreak && (!next || emptyText)) { + const placeholder = next && emptyText ? next : document.createTextNode("\u200B") + if (!next) last.parentNode?.insertBefore(placeholder, null) + placeholder.textContent = "\u200B" + range.setStart(placeholder, 0) + } else { + range.setStartAfter(last) + } } } range.collapse(true) @@ -899,6 +916,8 @@ export const PromptInput: Component = (props) => { .current() .map((part) => ("content" in part ? part.content : "")) .join("") + const direction = event.key === "ArrowUp" ? "up" : "down" + if (!canNavigateHistoryAtCursor(direction, textContent, cursorPosition)) return const isEmpty = textContent.trim() === "" || textLength <= 1 const hasNewlines = textContent.includes("\n") const inHistory = store.historyIndex >= 0 @@ -907,7 +926,7 @@ export const PromptInput: Component = (props) => { const allowUp = isEmpty || atStart || (!hasNewlines && !inHistory) || (inHistory && atEnd) const allowDown = isEmpty || atEnd || (!hasNewlines && !inHistory) || (inHistory && atStart) - if (event.key === "ArrowUp") { + if (direction === "up") { if (!allowUp) return if (navigateHistory("up")) { event.preventDefault() diff --git a/packages/app/src/components/prompt-input/editor-dom.test.ts b/packages/app/src/components/prompt-input/editor-dom.test.ts index fce8b4b953..15e759f44a 100644 --- a/packages/app/src/components/prompt-input/editor-dom.test.ts +++ b/packages/app/src/components/prompt-input/editor-dom.test.ts @@ -2,17 +2,26 @@ import { describe, expect, test } from "bun:test" import { createTextFragment, getCursorPosition, getNodeLength, getTextLength, setCursorPosition } from "./editor-dom" describe("prompt-input editor dom", () => { - test("createTextFragment preserves newlines with br and zero-width placeholders", () => { + test("createTextFragment preserves newlines with consecutive br nodes", () => { const fragment = createTextFragment("foo\n\nbar") const container = document.createElement("div") container.appendChild(fragment) - expect(container.childNodes.length).toBe(5) + expect(container.childNodes.length).toBe(4) + expect(container.childNodes[0]?.textContent).toBe("foo") + expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR") + expect((container.childNodes[2] as HTMLElement).tagName).toBe("BR") + expect(container.childNodes[3]?.textContent).toBe("bar") + }) + + test("createTextFragment keeps trailing newline as terminal break", () => { + const fragment = createTextFragment("foo\n") + const container = document.createElement("div") + container.appendChild(fragment) + + expect(container.childNodes.length).toBe(2) expect(container.childNodes[0]?.textContent).toBe("foo") expect((container.childNodes[1] as HTMLElement).tagName).toBe("BR") - expect(container.childNodes[2]?.textContent).toBe("\u200B") - expect((container.childNodes[3] as HTMLElement).tagName).toBe("BR") - expect(container.childNodes[4]?.textContent).toBe("bar") }) test("length helpers treat breaks as one char and ignore zero-width chars", () => { @@ -48,4 +57,21 @@ describe("prompt-input editor dom", () => { container.remove() }) + + test("setCursorPosition and getCursorPosition round-trip across blank lines", () => { + const container = document.createElement("div") + container.appendChild(document.createTextNode("a")) + container.appendChild(document.createElement("br")) + container.appendChild(document.createElement("br")) + container.appendChild(document.createTextNode("b")) + document.body.appendChild(container) + + setCursorPosition(container, 2) + expect(getCursorPosition(container)).toBe(2) + + setCursorPosition(container, 3) + expect(getCursorPosition(container)).toBe(3) + + container.remove() + }) }) diff --git a/packages/app/src/components/prompt-input/editor-dom.ts b/packages/app/src/components/prompt-input/editor-dom.ts index 3116ceb126..4850a26ece 100644 --- a/packages/app/src/components/prompt-input/editor-dom.ts +++ b/packages/app/src/components/prompt-input/editor-dom.ts @@ -4,8 +4,6 @@ export function createTextFragment(content: string): DocumentFragment { segments.forEach((segment, index) => { if (segment) { fragment.appendChild(document.createTextNode(segment)) - } else if (segments.length > 1) { - fragment.appendChild(document.createTextNode("\u200B")) } if (index < segments.length - 1) { fragment.appendChild(document.createElement("br")) diff --git a/packages/app/src/components/prompt-input/history.test.ts b/packages/app/src/components/prompt-input/history.test.ts index 54be9cb75b..a37fdad677 100644 --- a/packages/app/src/components/prompt-input/history.test.ts +++ b/packages/app/src/components/prompt-input/history.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test" import type { Prompt } from "@/context/prompt" -import { clonePromptParts, navigatePromptHistory, prependHistoryEntry, promptLength } from "./history" +import { + canNavigateHistoryAtCursor, + clonePromptParts, + navigatePromptHistory, + prependHistoryEntry, + promptLength, +} from "./history" const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] @@ -66,4 +72,20 @@ describe("prompt-input history", () => { if (original[1]?.type !== "file") throw new Error("expected file") expect(original[1].selection?.startLine).toBe(1) }) + + test("canNavigateHistoryAtCursor only allows multiline boundaries", () => { + const value = "a\nb\nc" + + expect(canNavigateHistoryAtCursor("up", value, 0)).toBe(true) + expect(canNavigateHistoryAtCursor("down", value, 0)).toBe(false) + + expect(canNavigateHistoryAtCursor("up", value, 2)).toBe(false) + expect(canNavigateHistoryAtCursor("down", value, 2)).toBe(false) + + expect(canNavigateHistoryAtCursor("up", value, 5)).toBe(false) + expect(canNavigateHistoryAtCursor("down", value, 5)).toBe(true) + + expect(canNavigateHistoryAtCursor("up", "abc", 1)).toBe(true) + expect(canNavigateHistoryAtCursor("down", "abc", 1)).toBe(true) + }) }) diff --git a/packages/app/src/components/prompt-input/history.ts b/packages/app/src/components/prompt-input/history.ts index 63164f0ba3..f26f808487 100644 --- a/packages/app/src/components/prompt-input/history.ts +++ b/packages/app/src/components/prompt-input/history.ts @@ -4,6 +4,13 @@ const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] export const MAX_HISTORY = 100 +export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number) { + if (!text.includes("\n")) return true + const position = Math.max(0, Math.min(cursor, text.length)) + if (direction === "up") return !text.slice(0, position).includes("\n") + return !text.slice(position).includes("\n") +} + export function clonePromptParts(prompt: Prompt): Prompt { return prompt.map((part) => { if (part.type === "text") return { ...part } From c9719dff7223aa1fc19540f3cd627c7f40e4bf36 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:04:19 -0600 Subject: [PATCH 23/46] fix(app): notification should navigate to session --- packages/app/src/entry.tsx | 7 ++--- packages/app/src/index.ts | 1 + .../app/src/utils/notification-click.test.ts | 26 +++++++++++++++++++ packages/app/src/utils/notification-click.ts | 12 +++++++++ packages/desktop/src/index.tsx | 14 ++++++---- 5 files changed, 50 insertions(+), 10 deletions(-) create mode 100644 packages/app/src/utils/notification-click.test.ts create mode 100644 packages/app/src/utils/notification-click.ts diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index f041204dcc..3a85086b48 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -4,6 +4,7 @@ import { AppBaseProviders, AppInterface } from "@/app" import { Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" +import { handleNotificationClick } from "@/utils/notification-click" import pkg from "../package.json" const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" @@ -68,11 +69,7 @@ const notify: Platform["notify"] = async (title, description, href) => { }) notification.onclick = () => { - window.focus() - if (href) { - window.history.pushState(null, "", href) - window.dispatchEvent(new PopStateEvent("popstate")) - } + handleNotificationClick(href) notification.close() } } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 59e1431fa8..33c22f099e 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,3 +1,4 @@ export { PlatformProvider, type Platform, type DisplayBackend } from "./context/platform" export { AppBaseProviders, AppInterface } from "./app" export { useCommand } from "./context/command" +export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/utils/notification-click.test.ts b/packages/app/src/utils/notification-click.test.ts new file mode 100644 index 0000000000..76535f83a8 --- /dev/null +++ b/packages/app/src/utils/notification-click.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from "bun:test" +import { handleNotificationClick } from "./notification-click" + +describe("notification click", () => { + test("focuses and navigates when href exists", () => { + const calls: string[] = [] + handleNotificationClick("/abc/session/123", { + focus: () => calls.push("focus"), + location: { + assign: (href) => calls.push(href), + }, + }) + expect(calls).toEqual(["focus", "/abc/session/123"]) + }) + + test("only focuses when href is missing", () => { + const calls: string[] = [] + handleNotificationClick(undefined, { + focus: () => calls.push("focus"), + location: { + assign: (href) => calls.push(href), + }, + }) + expect(calls).toEqual(["focus"]) + }) +}) diff --git a/packages/app/src/utils/notification-click.ts b/packages/app/src/utils/notification-click.ts new file mode 100644 index 0000000000..1234cd1d62 --- /dev/null +++ b/packages/app/src/utils/notification-click.ts @@ -0,0 +1,12 @@ +type WindowTarget = { + focus: () => void + location: { + assign: (href: string) => void + } +} + +export const handleNotificationClick = (href?: string, target: WindowTarget = window) => { + target.focus() + if (!href) return + target.location.assign(href) +} diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 620914dd7e..ff0a093766 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,7 +1,14 @@ // @refresh reload import { webviewZoom } from "./webview-zoom" import { render } from "solid-js/web" -import { AppBaseProviders, AppInterface, PlatformProvider, Platform, useCommand } from "@opencode-ai/app" +import { + AppBaseProviders, + AppInterface, + PlatformProvider, + Platform, + useCommand, + handleNotificationClick, +} from "@opencode-ai/app" import { open, save } from "@tauri-apps/plugin-dialog" import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener" @@ -329,10 +336,7 @@ const createPlatform = (password: Accessor): Platform => { void win.show().catch(() => undefined) void win.unminimize().catch(() => undefined) void win.setFocus().catch(() => undefined) - if (href) { - window.history.pushState(null, "", href) - window.dispatchEvent(new PopStateEvent("popstate")) - } + handleNotificationClick(href) notification.close() } }) From dec304a2737b7accb3bf8b199fb58e81d65026e9 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:05:45 -0600 Subject: [PATCH 24/46] fix(app): emoji as avatar --- packages/ui/src/components/avatar.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx index 76bde1e156..c1617b265c 100644 --- a/packages/ui/src/components/avatar.tsx +++ b/packages/ui/src/components/avatar.tsx @@ -1,5 +1,16 @@ import { type ComponentProps, splitProps, Show } from "solid-js" +const segmenter = + typeof Intl !== "undefined" && "Segmenter" in Intl + ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) + : undefined + +function first(value: string) { + if (!value) return "" + if (!segmenter) return Array.from(value)[0] ?? "" + return segmenter.segment(value)[Symbol.iterator]().next().value?.segment ?? Array.from(value)[0] ?? "" +} + export interface AvatarProps extends ComponentProps<"div"> { fallback: string src?: string @@ -36,7 +47,7 @@ export function Avatar(props: AvatarProps) { ...(!src && split.foreground ? { "--avatar-fg": split.foreground } : {}), }} > - + {(src) => } From e0f1c3c20efb60f19f36e2c8df87dfd30fd2523e Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 13 Feb 2026 10:15:36 +0800 Subject: [PATCH 25/46] cleanup desktop loading page --- packages/desktop/src-tauri/src/lib.rs | 2 ++ packages/desktop/src/loading.tsx | 30 +++++++-------------------- 2 files changed, 9 insertions(+), 23 deletions(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index fe71ef029d..85ea21d38c 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -665,6 +665,8 @@ async fn initialize(app: AppHandle) { } let _ = server_ready_rx.await; + + tracing::info!("Loading task finished"); } }) .map_err(|_| ()) diff --git a/packages/desktop/src/loading.tsx b/packages/desktop/src/loading.tsx index ee29827227..23a8055c9d 100644 --- a/packages/desktop/src/loading.tsx +++ b/packages/desktop/src/loading.tsx @@ -5,7 +5,7 @@ import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" import { Progress } from "@opencode-ai/ui/progress" import "./styles.css" -import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js" import { commands, events, InitStep } from "./bindings" import { Channel } from "@tauri-apps/api/core" @@ -29,36 +29,20 @@ render(() => { channel.onmessage = (next) => setStep(next) commands.awaitInitialization(channel as any).catch(() => undefined) - createEffect(() => { - if (phase() !== "sqlite_waiting") return - + onMount(() => { setLine(0) setPercent(0) const timers = delays.map((ms, i) => setTimeout(() => setLine(i + 1), ms)) - let stop: (() => void) | undefined - let active = true - - void events.sqliteMigrationProgress - .listen((e) => { - if (e.payload.type === "InProgress") setPercent(Math.max(0, Math.min(100, e.payload.value))) - if (e.payload.type === "Done") setPercent(100) - }) - .then((unlisten) => { - if (active) { - stop = unlisten - return - } - - unlisten() - }) - .catch(() => undefined) + const listener = events.sqliteMigrationProgress.listen((e) => { + if (e.payload.type === "InProgress") setPercent(Math.max(0, Math.min(100, e.payload.value))) + if (e.payload.type === "Done") setPercent(100) + }) onCleanup(() => { - active = false + listener.then((cb) => cb()) timers.forEach(clearTimeout) - stop?.() }) }) From fb7b2f6b4d66d14177b5c0168049863842665925 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:19:14 -0600 Subject: [PATCH 26/46] feat(app): toggle all provider models --- .../src/components/dialog-manage-models.tsx | 32 ++++++++++++++++++- packages/app/src/i18n/en.ts | 1 + packages/ui/src/components/list.tsx | 7 ++-- packages/ui/src/components/switch.tsx | 2 +- 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index d4d4af0f10..ace79e38a7 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -1,6 +1,7 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" +import { Tooltip } from "@opencode-ai/ui/tooltip" import { Button } from "@opencode-ai/ui/button" import type { Component } from "solid-js" import { useLocal } from "@/context/local" @@ -18,6 +19,14 @@ export const DialogManageModels: Component = () => { dialog.show(() => ) } const providerRank = (id: string) => popularProviders.indexOf(id) + const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID) + const providerVisible = (providerID: string) => + providerList(providerID).every((x) => local.model.visible({ modelID: x.id, providerID: x.provider.id })) + const setProviderVisibility = (providerID: string, checked: boolean) => { + providerList(providerID).forEach((x) => { + local.model.setVisibility({ modelID: x.id, providerID: x.provider.id }, checked) + }) + } return ( { items={local.model.list()} filterKeys={["provider.name", "name", "id"]} sortBy={(a, b) => a.name.localeCompare(b.name)} - groupBy={(x) => x.provider.name} + groupBy={(x) => x.provider.id} + groupHeader={(group) => { + const provider = group.items[0].provider + return ( + <> + {provider.name} + + setProviderVisibility(provider.id, checked)} + hideLabel + > + {provider.name} + + + + ) + }} sortGroupsBy={(a, b) => { const aRank = providerRank(a.items[0].provider.id) const bRank = providerRank(b.items[0].provider.id) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c138c7b614..99513edaa1 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -109,6 +109,7 @@ export const dict = { "dialog.model.empty": "No model results", "dialog.model.manage": "Manage models", "dialog.model.manage.description": "Customize which models appear in the model selector.", + "dialog.model.manage.provider.toggle": "Toggle all {{provider}} models", "dialog.model.unpaid.freeModels.title": "Free models provided by OpenCode", "dialog.model.unpaid.addMore.title": "Add more models from popular providers", diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index abd5572207..aa2347037e 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -45,6 +45,7 @@ export interface ListProps extends FilteredListProps { itemWrapper?: (item: T, node: JSX.Element) => JSX.Element divider?: boolean add?: ListAddProps + groupHeader?: (group: { category: string; items: T[] }) => JSX.Element } export interface ListRef { @@ -206,7 +207,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) ) } - function GroupHeader(groupProps: { category: string }): JSX.Element { + function GroupHeader(groupProps: { group: { category: string; items: T[] } }): JSX.Element { const [stuck, setStuck] = createSignal(false) const [header, setHeader] = createSignal(undefined) @@ -228,7 +229,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return (
- {groupProps.category} + {props.groupHeader?.(groupProps.group) ?? groupProps.group.category}
) } @@ -323,7 +324,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return (
- +
diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx index a8600aef44..f4f95baf57 100644 --- a/packages/ui/src/components/switch.tsx +++ b/packages/ui/src/components/switch.tsx @@ -10,7 +10,7 @@ export interface SwitchProps extends ParentProps> export function Switch(props: SwitchProps) { const [local, others] = splitProps(props, ["children", "class", "hideLabel", "description"]) return ( - + From dd296f703391aa67ef8cf8340e2712574b380cb1 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Feb 2026 20:20:24 -0600 Subject: [PATCH 27/46] fix(app): reconnect event stream on disconnect --- packages/app/src/context/global-sdk.tsx | 87 ++++++++++++++----------- 1 file changed, 48 insertions(+), 39 deletions(-) diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index 346657e2fb..3f93b76a72 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -46,6 +46,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo type Queued = { directory: string; payload: Event } const FLUSH_FRAME_MS = 16 const STREAM_YIELD_MS = 8 + const RECONNECT_DELAY_MS = 250 let queue: Queued[] = [] let buffer: Queued[] = [] @@ -91,50 +92,58 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo } let streamErrorLogged = false + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)) void (async () => { - const events = await eventSdk.global.event({ - onSseError: (error) => { - if (streamErrorLogged) return - streamErrorLogged = true - console.error("[global-sdk] event stream error", { - url: server.url, - fetch: eventFetch ? "platform" : "webview", - error, + while (!abort.signal.aborted) { + try { + const events = await eventSdk.global.event({ + onSseError: (error) => { + if (streamErrorLogged) return + streamErrorLogged = true + console.error("[global-sdk] event stream error", { + url: server.url, + fetch: eventFetch ? "platform" : "webview", + error, + }) + }, }) - }, - }) - let yielded = Date.now() - for await (const event of events.stream) { - const directory = event.directory ?? "global" - const payload = event.payload - const k = key(directory, payload) - if (k) { - const i = coalesced.get(k) - if (i !== undefined) { - queue[i] = { directory, payload } - continue - } - coalesced.set(k, queue.length) - } - queue.push({ directory, payload }) - schedule() + let yielded = Date.now() + for await (const event of events.stream) { + streamErrorLogged = false + const directory = event.directory ?? "global" + const payload = event.payload + const k = key(directory, payload) + if (k) { + const i = coalesced.get(k) + if (i !== undefined) { + queue[i] = { directory, payload } + continue + } + coalesced.set(k, queue.length) + } + queue.push({ directory, payload }) + schedule() - if (Date.now() - yielded < STREAM_YIELD_MS) continue - yielded = Date.now() - await new Promise((resolve) => setTimeout(resolve, 0)) + if (Date.now() - yielded < STREAM_YIELD_MS) continue + yielded = Date.now() + await wait(0) + } + } catch (error) { + if (!streamErrorLogged) { + streamErrorLogged = true + console.error("[global-sdk] event stream failed", { + url: server.url, + fetch: eventFetch ? "platform" : "webview", + error, + }) + } + } + + if (abort.signal.aborted) return + await wait(RECONNECT_DELAY_MS) } - })() - .finally(flush) - .catch((error) => { - if (streamErrorLogged) return - streamErrorLogged = true - console.error("[global-sdk] event stream failed", { - url: server.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) - }) + })().finally(flush) onCleanup(() => { abort.abort() From b06afd657d59c2c88394513e3b633060ec6f454b Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 13 Feb 2026 10:46:45 +0800 Subject: [PATCH 28/46] ci: remove signpath policy --- .signpath/policies/opencode/test-signing.yml | 7 ------- 1 file changed, 7 deletions(-) delete mode 100644 .signpath/policies/opencode/test-signing.yml diff --git a/.signpath/policies/opencode/test-signing.yml b/.signpath/policies/opencode/test-signing.yml deleted file mode 100644 index 4c9f654cd3..0000000000 --- a/.signpath/policies/opencode/test-signing.yml +++ /dev/null @@ -1,7 +0,0 @@ -github-policies: - runners: - allowed_groups: - - "blacksmith runners 01kbd5v56sg8tz7rea39b7ygpt" - build: - disallow_reruns: false - branch_rulesets: From 1608565c808c9136bdc6930a356649bd9824cc69 Mon Sep 17 00:00:00 2001 From: Spoon <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:52:17 +0100 Subject: [PATCH 29/46] feat(hook): add tool.definition hook for plugins to modify tool description and parameters (#4956) --- packages/opencode/src/tool/registry.ts | 10 +++++++++- packages/plugin/src/index.ts | 4 ++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 5ed5a879b4..9a06cb5993 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -149,9 +149,17 @@ export namespace ToolRegistry { }) .map(async (t) => { using _ = log.time(t.id) + const tool = await t.init({ agent }) + const output = { + description: tool.description, + parameters: tool.parameters, + } + await Plugin.trigger("tool.definition", { toolID: t.id }, output) return { id: t.id, - ...(await t.init({ agent })), + ...tool, + description: output.description, + parameters: output.parameters, } }), ) diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index 664f2c9673..bd4ba53049 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -224,4 +224,8 @@ export interface Hooks { input: { sessionID: string; messageID: string; partID: string }, output: { text: string }, ) => Promise + /** + * Modify tool definitions (description and parameters) sent to LLM + */ + "tool.definition"?: (input: { toolID: string }, output: { description: string; parameters: any }) => Promise } From 98aeb60a7f0e00e251ff02c360829a3679d65717 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:20:33 -0600 Subject: [PATCH 30/46] fix: ensure @-ing a dir uses the read tool instead of dead list tool (#13428) --- packages/opencode/src/session/prompt.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 99d44cd850..be813e823f 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -26,7 +26,6 @@ import { ToolRegistry } from "../tool/registry" import { MCP } from "../mcp" import { LSP } from "../lsp" import { ReadTool } from "../tool/read" -import { ListTool } from "../tool/ls" import { FileTime } from "../file/time" import { Flag } from "../flag/flag" import { ulid } from "ulid" @@ -1198,7 +1197,7 @@ export namespace SessionPrompt { } if (part.mime === "application/x-directory") { - const args = { path: filepath } + const args = { filePath: filepath } const listCtx: Tool.Context = { sessionID: input.sessionID, abort: new AbortController().signal, @@ -1209,7 +1208,7 @@ export namespace SessionPrompt { metadata: async () => {}, ask: async () => {}, } - const result = await ListTool.init().then((t) => t.execute(args, listCtx)) + const result = await ReadTool.init().then((t) => t.execute(args, listCtx)) return [ { id: Identifier.ascending("part"), @@ -1217,7 +1216,7 @@ export namespace SessionPrompt { sessionID: input.sessionID, type: "text", synthetic: true, - text: `Called the list tool with the following input: ${JSON.stringify(args)}`, + text: `Called the Read tool with the following input: ${JSON.stringify(args)}`, }, { id: Identifier.ascending("part"), From 1fb6c0b5b356e3816398ba71ac1b01485697bc31 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 12 Feb 2026 23:24:31 -0600 Subject: [PATCH 31/46] Revert "fix: token substitution in OPENCODE_CONFIG_CONTENT" (#13429) --- packages/opencode/src/config/config.ts | 8 +-- packages/opencode/src/flag/flag.ts | 13 +--- packages/opencode/test/config/config.test.ts | 65 -------------------- 3 files changed, 2 insertions(+), 84 deletions(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index f4d7a840fe..8f0f583ea3 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -175,14 +175,8 @@ export namespace Config { } // Inline config content overrides all non-managed config sources. - // Route through load() to enable {env:} and {file:} token substitution. - // Use a path within Instance.directory so relative {file:} paths resolve correctly. - // The filename "OPENCODE_CONFIG_CONTENT" appears in error messages for clarity. if (Flag.OPENCODE_CONFIG_CONTENT) { - result = mergeConfigConcatArrays( - result, - await load(Flag.OPENCODE_CONFIG_CONTENT, path.join(Instance.directory, "OPENCODE_CONFIG_CONTENT")), - ) + result = mergeConfigConcatArrays(result, JSON.parse(Flag.OPENCODE_CONFIG_CONTENT)) log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") } diff --git a/packages/opencode/src/flag/flag.ts b/packages/opencode/src/flag/flag.ts index 641cb3325b..dfcb88bc51 100644 --- a/packages/opencode/src/flag/flag.ts +++ b/packages/opencode/src/flag/flag.ts @@ -8,7 +8,7 @@ export namespace Flag { export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"] export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"] export declare const OPENCODE_CONFIG_DIR: string | undefined - export declare const OPENCODE_CONFIG_CONTENT: string | undefined + export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"] export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE") export const OPENCODE_DISABLE_PRUNE = truthy("OPENCODE_DISABLE_PRUNE") export const OPENCODE_DISABLE_TERMINAL_TITLE = truthy("OPENCODE_DISABLE_TERMINAL_TITLE") @@ -94,14 +94,3 @@ Object.defineProperty(Flag, "OPENCODE_CLIENT", { enumerable: true, configurable: false, }) - -// Dynamic getter for OPENCODE_CONFIG_CONTENT -// This must be evaluated at access time, not module load time, -// because external tooling may set this env var at runtime -Object.defineProperty(Flag, "OPENCODE_CONFIG_CONTENT", { - get() { - return process.env["OPENCODE_CONFIG_CONTENT"] - }, - enumerable: true, - configurable: false, -}) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 331e05d5a7..91b87f6498 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -1800,68 +1800,3 @@ describe("OPENCODE_DISABLE_PROJECT_CONFIG", () => { } }) }) - -// OPENCODE_CONFIG_CONTENT should support {env:} and {file:} token substitution -// just like file-based config sources do. -describe("OPENCODE_CONFIG_CONTENT token substitution", () => { - test("substitutes {env:} tokens in OPENCODE_CONFIG_CONTENT", async () => { - const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"] - const originalTestVar = process.env["TEST_CONFIG_VAR"] - process.env["TEST_CONFIG_VAR"] = "test_api_key_12345" - process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ - $schema: "https://opencode.ai/config.json", - theme: "{env:TEST_CONFIG_VAR}", - }) - - try { - await using tmp = await tmpdir() - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - expect(config.theme).toBe("test_api_key_12345") - }, - }) - } finally { - if (originalEnv !== undefined) { - process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv - } else { - delete process.env["OPENCODE_CONFIG_CONTENT"] - } - if (originalTestVar !== undefined) { - process.env["TEST_CONFIG_VAR"] = originalTestVar - } else { - delete process.env["TEST_CONFIG_VAR"] - } - } - }) - - test("substitutes {file:} tokens in OPENCODE_CONFIG_CONTENT", async () => { - const originalEnv = process.env["OPENCODE_CONFIG_CONTENT"] - - try { - await using tmp = await tmpdir({ - init: async (dir) => { - await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file") - process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({ - $schema: "https://opencode.ai/config.json", - theme: "{file:./api_key.txt}", - }) - }, - }) - await Instance.provide({ - directory: tmp.path, - fn: async () => { - const config = await Config.get() - expect(config.theme).toBe("secret_key_from_file") - }, - }) - } finally { - if (originalEnv !== undefined) { - process.env["OPENCODE_CONFIG_CONTENT"] = originalEnv - } else { - delete process.env["OPENCODE_CONFIG_CONTENT"] - } - } - }) -}) From 34ebe814ddd130a787455dda089facb23538ca20 Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 13 Feb 2026 05:51:04 +0000 Subject: [PATCH 32/46] release: v1.1.65 --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 37 insertions(+), 37 deletions(-) diff --git a/bun.lock b/bun.lock index 3fe8a4ca07..8b58d4e1cd 100644 --- a/bun.lock +++ b/bun.lock @@ -23,7 +23,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -73,7 +73,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -107,7 +107,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -134,7 +134,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -158,7 +158,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -182,7 +182,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -215,7 +215,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -244,7 +244,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -260,7 +260,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.64", + "version": "1.1.65", "bin": { "opencode": "./bin/opencode", }, @@ -366,7 +366,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -386,7 +386,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.64", + "version": "1.1.65", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -397,7 +397,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -410,7 +410,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -452,7 +452,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "zod": "catalog:", }, @@ -463,7 +463,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index ebd1a4b35b..49ce671b60 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.64", + "version": "1.1.65", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index c5556a4431..3d7ef57851 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.64", + "version": "1.1.65", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 498270b952..0676595c70 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.64", + "version": "1.1.65", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index d2117dffb2..265546fc7f 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.64", + "version": "1.1.65", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index f632ab92fe..0f4bbb6eca 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.64", + "version": "1.1.65", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index da89d36a88..8e4862b30d 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.64", + "version": "1.1.65", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 31b62e12b1..bd2fac19f7 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.64", + "version": "1.1.65", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 22aca32bae..84ae20633e 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.64" +version = "1.1.65" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.65/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.65/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.65/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.65/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.64/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.65/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index ae9a6d7b3c..4c10ab05f8 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.64", + "version": "1.1.65", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f58a3d2fe9..ef4535ca96 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.64", + "version": "1.1.65", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d88d5a7ba3..c373083f58 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.1.64", + "version": "1.1.65", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 13d0b549ba..ff8108b7be 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.1.64", + "version": "1.1.65", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index 7f0eaff83f..78a5702228 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.64", + "version": "1.1.65", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 6d20e3dfdc..5dbbb4605a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.64", + "version": "1.1.65", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 078adbe142..f37bb5c1d1 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.64", + "version": "1.1.65", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 6f5fe726f5..7c6698117a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.1.64", + "version": "1.1.65", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 30c07d3139..d1980decac 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.1.64", + "version": "1.1.65", "publisher": "sst-dev", "repository": { "type": "git", From 0d90a22f9057dd69dca65ab52450f17d47a8656e Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 13 Feb 2026 00:56:11 -0600 Subject: [PATCH 33/46] feat: update some ai sdk packages and uuse adaptive reasoning for opus 4.6 on vertex/bedrock/anthropic (#13439) --- bun.lock | 26 ++++++++++++------ packages/opencode/package.json | 6 ++--- packages/opencode/src/provider/transform.ts | 30 +++++++++++++++++++++ 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/bun.lock b/bun.lock index 8b58d4e1cd..4a054c6483 100644 --- a/bun.lock +++ b/bun.lock @@ -268,12 +268,12 @@ "@actions/core": "1.11.1", "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.14.1", - "@ai-sdk/amazon-bedrock": "3.0.74", - "@ai-sdk/anthropic": "2.0.58", + "@ai-sdk/amazon-bedrock": "3.0.79", + "@ai-sdk/anthropic": "2.0.62", "@ai-sdk/azure": "2.0.91", "@ai-sdk/cerebras": "1.0.36", "@ai-sdk/cohere": "2.0.22", - "@ai-sdk/deepinfra": "1.0.33", + "@ai-sdk/deepinfra": "1.0.36", "@ai-sdk/gateway": "2.0.30", "@ai-sdk/google": "2.0.52", "@ai-sdk/google-vertex": "3.0.98", @@ -565,7 +565,7 @@ "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.14.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-b6r3PS3Nly+Wyw9U+0nOr47bV8tfS476EgyEMhoKvJCZLbgqoDFN7DJwkxL88RR0aiOqOYV1ZnESHqb+RmdH8w=="], - "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="], + "@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.79", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.62", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-GfAQUb1GEmdTjLu5Ud1d5sieNHDpwoQdb4S14KmJlA5RsGREUZ1tfSKngFaiClxFtL0xPSZjePhTMV6Z65A7/g=="], "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-uyyaO4KhxoIKZztREqLPh+6/K3ZJx/rp72JKoUEL9/kC+vfQTThUfPnY/bUryUpcnawx8IY/tSoYNOi/8PCv7w=="], @@ -577,7 +577,7 @@ "@ai-sdk/deepgram": ["@ai-sdk/deepgram@1.0.22", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-lqmINr+1Jy2yGXxnQB6IrC2xMtUY5uK96pyKfqTj1kLlXGatKnJfXF7WTkOGgQrFqIYqpjDz+sPVR3n0KUEUtA=="], - "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.33", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.32", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hn2y8Q+2iZgGNVJyzPsH8EECECryFMVmxBJrBvBWoi8xcJPRyt0fZP5dOSLyGg3q0oxmPS9M0Eq0NNlKot/bYQ=="], + "@ai-sdk/deepinfra": ["@ai-sdk/deepinfra@1.0.36", "", { "dependencies": { "@ai-sdk/openai-compatible": "1.0.33", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LndvRktEgY2IFu4peDJMEXcjhHEEFtM0upLx/J64kCpFHCifalXpK4PPSX3PVndnn0bJzvamO5+fc0z2ooqBZw=="], "@ai-sdk/deepseek": ["@ai-sdk/deepseek@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-NiKjvqXI/96e/7SjZGgQH141PBqggsF7fNbjGTv4RgVWayMXp9mj0Ou2NjAUGwwxJwj/qseY0gXiDCYaHWFBkw=="], @@ -4151,7 +4151,9 @@ "@actions/http-client/undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], - "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="], + "@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="], + + "@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="], "@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="], @@ -4163,7 +4165,9 @@ "@ai-sdk/cerebras/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], - "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], + "@ai-sdk/deepinfra/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.33", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-2KMcR2xAul3u5dGZD7gONgbIki3Hg7Ey+sFu7gsiJ4U2iRU0GDV3ccNq79dTuAEXPDFcOWCUpW8A8jXc0kxJxQ=="], + + "@ai-sdk/deepinfra/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="], "@ai-sdk/fireworks/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.32", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YspqqyJPzHjqWrjt4y/Wgc2aJgCcQj5uIJgZpq2Ar/lH30cEVhgE+keePDbjKpetD9UwNggCj7u6kO3unS23OQ=="], @@ -4453,6 +4457,8 @@ "accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@3.0.74", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.58", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-q83HE3FBb/HPIvjXsehrHOgCuGHPorSMFt6BYnzIYZy8gNnSqV1OWX4oXVsCAuYPPMtYW/KMK35hmoIFV8QKoQ=="], + "ai-gateway-provider/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="], "ai-gateway-provider/@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@3.0.90", "", { "dependencies": { "@ai-sdk/anthropic": "2.0.56", "@ai-sdk/google": "2.0.46", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-C9MLe1KZGg1ZbupV2osygHtL5qngyCDA6ATatunyfTbIe8TXKG8HGni/3O6ifbnI5qxTidIn150Ox7eIFZVMYg=="], @@ -4575,7 +4581,7 @@ "nypm/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], - "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.58", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CkNW5L1Arv8gPtPlEmKd+yf/SG9ucJf0XQdpMG8OiYEtEMc2smuCA+tyCp8zI7IBVg/FE7nUfFHntQFaOjRwJQ=="], + "opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.62", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-I3RhaOEMnWlWnrvjNBOYvUb19Dwf2nw01IruZrVJRDi688886e11wnd5DxrBZLd2V29Gizo3vpOPnnExsA+wTA=="], "opencode/@ai-sdk/openai": ["@ai-sdk/openai@2.0.89", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-4+qWkBCbL9HPKbgrUO/F2uXZ8GqrYxHa8SWEYIzxEJ9zvWw3ISr3t1/27O1i8MGSym+PzEyHBT48EV4LAwWaEw=="], @@ -4995,6 +5001,8 @@ "accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "ai-gateway-provider/@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.8", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.12.0", "@smithy/util-hex-encoding": "^4.2.0", "tslib": "^2.6.2" } }, "sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw=="], + "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.56", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XHJKu0Yvfu9SPzRfsAFESa+9T7f2YJY6TxykKMfRsAwpeWAiX/Gbx5J5uM15AzYC3Rw8tVP3oH+j7jEivENirQ=="], "ai-gateway-provider/@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@2.0.46", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.19" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8PK6u4sGE/kXebd7ZkTp+0aya4kNqzoqpS5m7cHY2NfTK6fhPc6GNvE+MZIZIoHQTp5ed86wGBdeBPpFaaUtyg=="], @@ -5099,6 +5107,8 @@ "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + "opencode/@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.21", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-veuMwTLxsgh31Jjn0SnBABnM1f7ebHhRWcV2ZuY3hP3iJDCZ8VXBaYqcHXoOQDqUXTCas08sKQcHyWK+zl882Q=="], + "opencontrol/@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], "opencontrol/@modelcontextprotocol/sdk/pkce-challenge": ["pkce-challenge@4.1.0", "", {}, "sha512-ZBmhE1C9LcPoH9XZSdwiPtbPHZROwAnMy+kIFQVrnMCxY4Cudlz3gBOpzilgc0jOgRaiT3sIWfpMomW2ar2orQ=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ef4535ca96..82d562bb09 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -51,12 +51,12 @@ "@actions/core": "1.11.1", "@actions/github": "6.0.1", "@agentclientprotocol/sdk": "0.14.1", - "@ai-sdk/amazon-bedrock": "3.0.74", - "@ai-sdk/anthropic": "2.0.58", + "@ai-sdk/amazon-bedrock": "3.0.79", + "@ai-sdk/anthropic": "2.0.62", "@ai-sdk/azure": "2.0.91", "@ai-sdk/cerebras": "1.0.36", "@ai-sdk/cohere": "2.0.22", - "@ai-sdk/deepinfra": "1.0.33", + "@ai-sdk/deepinfra": "1.0.36", "@ai-sdk/gateway": "2.0.30", "@ai-sdk/google": "2.0.52", "@ai-sdk/google-vertex": "3.0.98", diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 876a26fce7..8091f731f0 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -458,6 +458,22 @@ export namespace ProviderTransform { // https://v5.ai-sdk.dev/providers/ai-sdk-providers/anthropic case "@ai-sdk/google-vertex/anthropic": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/google-vertex#anthropic-provider + + if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) { + const efforts = ["low", "medium", "high", "max"] + return Object.fromEntries( + efforts.map((effort) => [ + effort, + { + thinking: { + type: "adaptive", + }, + effort, + }, + ]), + ) + } + return { high: { thinking: { @@ -475,6 +491,20 @@ export namespace ProviderTransform { case "@ai-sdk/amazon-bedrock": // https://v5.ai-sdk.dev/providers/ai-sdk-providers/amazon-bedrock + if (model.api.id.includes("opus-4-6") || model.api.id.includes("opus-4.6")) { + const efforts = ["low", "medium", "high", "max"] + return Object.fromEntries( + efforts.map((effort) => [ + effort, + { + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: effort, + }, + }, + ]), + ) + } // For Anthropic models on Bedrock, use reasoningConfig with budgetTokens if (model.api.id.includes("anthropic")) { return { From 693127d382abed14113f3b7a347851b7a44d74cd Mon Sep 17 00:00:00 2001 From: Rahul Mishra Date: Fri, 13 Feb 2026 12:29:37 +0530 Subject: [PATCH 34/46] feat(cli): add --dir option to run command (#12443) --- packages/opencode/src/cli/cmd/run.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 163a5820d9..0febec3a20 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -274,6 +274,10 @@ export const RunCommand = cmd({ type: "string", describe: "attach to a running opencode server (e.g., http://localhost:4096)", }) + .option("dir", { + type: "string", + describe: "directory to run in, path on remote server if attaching", + }) .option("port", { type: "number", describe: "port for the local server (defaults to random port if no value provided)", @@ -293,6 +297,18 @@ export const RunCommand = cmd({ .map((arg) => (arg.includes(" ") ? `"${arg.replace(/"/g, '\\"')}"` : arg)) .join(" ") + const directory = (() => { + if (!args.dir) return undefined + if (args.attach) return args.dir + try { + process.chdir(args.dir) + return process.cwd() + } catch { + UI.error("Failed to change directory to " + args.dir) + process.exit(1) + } + })() + const files: { type: "file"; url: string; filename: string; mime: string }[] = [] if (args.file) { const list = Array.isArray(args.file) ? args.file : [args.file] @@ -582,7 +598,7 @@ export const RunCommand = cmd({ } if (args.attach) { - const sdk = createOpencodeClient({ baseUrl: args.attach }) + const sdk = createOpencodeClient({ baseUrl: args.attach, directory }) return await execute(sdk) } From b8ee88212639ec63f4fe87555b5e87f74643e76b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 13 Feb 2026 07:06:28 +0000 Subject: [PATCH 35/46] 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 70d7378493..c493161ee6 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-XIf7b6yALzH1/MkGGrsmq2DeXIC9vgD9a7D/dxhi6iU=", - "aarch64-linux": "sha256-mKDCs6QhIelWc3E17zOufaSDTovtjO/Xyh3JtlWl01s=", - "aarch64-darwin": "sha256-wC7bbbIyZ62uMxTr9FElTbEBMrfz0S/ndqwZZ3V9EOA=", - "x86_64-darwin": "sha256-/7Nn65m5Zhvzz0TKsG9nWd2v5WDHQNi3UzCfuAR8SLo=" + "x86_64-linux": "sha256-FsFTitxnN2brebZDBRGJB0NWTOVYDa/QcNRH0ip/Gk4=", + "aarch64-linux": "sha256-knSEqEPyonBUfmGZKTq5Om4HikItWbfPdfT7p6iljzs=", + "aarch64-darwin": "sha256-uRgWfuOlLECRCOszm8XhySiWxu9IdDhpSbosPZPAZVI=", + "x86_64-darwin": "sha256-gHuA+Ud9L+XLvKm5Vp5jCXfZWOtunnmX/lB8vczHsG0=" } } From ebb907d646022d2e7bb8effc164e1f09943d64a9 Mon Sep 17 00:00:00 2001 From: Filip <34747899+neriousy@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:08:13 +0100 Subject: [PATCH 36/46] fix(desktop): performance optimization for showing large diff & files (#13460) --- packages/app/src/pages/session/file-tabs.tsx | 23 +- packages/ui/src/components/code.tsx | 118 +++++++-- packages/ui/src/components/diff.tsx | 38 ++- packages/ui/src/components/session-review.css | 26 ++ packages/ui/src/components/session-review.tsx | 228 +++++++++++------- packages/ui/src/i18n/ar.ts | 5 + packages/ui/src/i18n/br.ts | 5 + packages/ui/src/i18n/bs.ts | 5 + packages/ui/src/i18n/da.ts | 5 + packages/ui/src/i18n/de.ts | 5 + packages/ui/src/i18n/en.ts | 5 + packages/ui/src/i18n/es.ts | 5 + packages/ui/src/i18n/fr.ts | 5 + packages/ui/src/i18n/ja.ts | 5 + packages/ui/src/i18n/ko.ts | 5 + packages/ui/src/i18n/no.ts | 5 + packages/ui/src/i18n/pl.ts | 5 + packages/ui/src/i18n/ru.ts | 5 + packages/ui/src/i18n/th.ts | 5 + packages/ui/src/i18n/zh.ts | 5 + packages/ui/src/i18n/zht.ts | 5 + packages/util/src/encode.ts | 21 ++ 22 files changed, 407 insertions(+), 127 deletions(-) diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 5b3f57dbed..d22fa358b0 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -1,7 +1,7 @@ import { type ValidComponent, createEffect, createMemo, For, Match, on, onCleanup, Show, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Dynamic } from "solid-js/web" -import { checksum } from "@opencode-ai/util/encode" +import { sampledChecksum } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { showToast } from "@opencode-ai/ui/toast" import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" @@ -49,7 +49,7 @@ export function FileTabContent(props: { return props.file.get(p) }) const contents = createMemo(() => state()?.content?.content ?? "") - const cacheKey = createMemo(() => checksum(contents())) + const cacheKey = createMemo(() => sampledChecksum(contents())) const isImage = createMemo(() => { const c = state()?.content return c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml" @@ -163,11 +163,20 @@ export function FileTabContent(props: { return } + const estimateTop = (range: SelectedLineRange) => { + const line = Math.max(range.start, range.end) + const height = 24 + const offset = 2 + return Math.max(0, (line - 1) * height + offset) + } + + const large = contents().length > 500_000 + const next: Record = {} for (const comment of fileComments()) { const marker = findMarker(root, comment.selection) - if (!marker) continue - next[comment.id] = markerTop(el, marker) + if (marker) next[comment.id] = markerTop(el, marker) + else if (large) next[comment.id] = estimateTop(comment.selection) } const removed = Object.keys(note.positions).filter((id) => next[id] === undefined) @@ -194,12 +203,12 @@ export function FileTabContent(props: { } const marker = findMarker(root, range) - if (!marker) { - setNote("draftTop", undefined) + if (marker) { + setNote("draftTop", markerTop(el, marker)) return } - setNote("draftTop", markerTop(el, marker)) + setNote("draftTop", large ? estimateTop(range) : undefined) } const scheduleComments = () => { diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index abe0d7ca9e..837cc53376 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -1,10 +1,27 @@ -import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs" +import { + DEFAULT_VIRTUAL_FILE_METRICS, + type FileContents, + File, + FileOptions, + LineAnnotation, + type SelectedLineRange, + type VirtualFileMetrics, + VirtualizedFile, + Virtualizer, +} from "@pierre/diffs" import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js" import { Portal } from "solid-js/web" import { createDefaultOptions, styleVariables } from "../pierre" import { getWorkerPool } from "../pierre/worker" import { Icon } from "./icon" +const VIRTUALIZE_BYTES = 500_000 +const codeMetrics = { + ...DEFAULT_VIRTUAL_FILE_METRICS, + lineHeight: 24, + fileGap: 0, +} satisfies Partial + type SelectionSide = "additions" | "deletions" export type CodeProps = FileOptions & { @@ -160,16 +177,28 @@ export function Code(props: CodeProps) { const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 }) - const file = createMemo( - () => - new File( - { - ...createDefaultOptions("unified"), - ...others, - }, - getWorkerPool("unified"), - ), - ) + let instance: File | VirtualizedFile | undefined + let virtualizer: Virtualizer | undefined + let virtualRoot: Document | HTMLElement | undefined + + const bytes = createMemo(() => { + const value = local.file.contents as unknown + if (typeof value === "string") return value.length + if (Array.isArray(value)) { + return value.reduce( + (acc, part) => acc + (typeof part === "string" ? part.length + 1 : String(part).length + 1), + 0, + ) + } + if (value == null) return 0 + return String(value).length + }) + const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES) + + const options = createMemo(() => ({ + ...createDefaultOptions("unified"), + ...others, + })) const getRoot = () => { const host = container.querySelector("diffs-container") @@ -577,6 +606,14 @@ export function Code(props: CodeProps) { } const applySelection = (range: SelectedLineRange | null) => { + const current = instance + if (!current) return false + + if (virtual()) { + current.setSelectedLines(range) + return true + } + const root = getRoot() if (!root) return false @@ -584,7 +621,7 @@ export function Code(props: CodeProps) { if (root.querySelectorAll("[data-line]").length < lines) return false if (!range) { - file().setSelectedLines(null) + current.setSelectedLines(null) return true } @@ -592,12 +629,12 @@ export function Code(props: CodeProps) { const end = Math.max(range.start, range.end) if (start < 1 || end > lines) { - file().setSelectedLines(null) + current.setSelectedLines(null) return true } if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) { - file().setSelectedLines(null) + current.setSelectedLines(null) return true } @@ -608,7 +645,7 @@ export function Code(props: CodeProps) { return { start: range.start, end: range.end } })() - file().setSelectedLines(normalized) + current.setSelectedLines(normalized) return true } @@ -619,9 +656,12 @@ export function Code(props: CodeProps) { const token = renderToken - const lines = lineCount() + const lines = virtual() ? undefined : lineCount() - const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines + const isReady = (root: ShadowRoot) => + virtual() + ? root.querySelector("[data-line]") != null + : root.querySelectorAll("[data-line]").length >= (lines ?? 0) const notify = () => { if (token !== renderToken) return @@ -844,20 +884,41 @@ export function Code(props: CodeProps) { } createEffect(() => { - const current = file() + const opts = options() + const workerPool = getWorkerPool("unified") + const isVirtual = virtual() - onCleanup(() => { - current.cleanUp() - }) - }) - - createEffect(() => { observer?.disconnect() observer = undefined + instance?.cleanUp() + instance = undefined + + if (!isVirtual && virtualizer) { + virtualizer.cleanUp() + virtualizer = undefined + virtualRoot = undefined + } + + const v = (() => { + if (!isVirtual) return + if (typeof document === "undefined") return + + const root = getScrollParent(wrapper) ?? document + if (virtualizer && virtualRoot === root) return virtualizer + + virtualizer?.cleanUp() + virtualizer = new Virtualizer() + virtualRoot = root + virtualizer.setup(root, root instanceof Document ? undefined : wrapper) + return virtualizer + })() + + instance = isVirtual && v ? new VirtualizedFile(opts, v, codeMetrics, workerPool) : new File(opts, workerPool) + container.innerHTML = "" const value = text() - file().render({ + instance.render({ file: typeof local.file.contents === "string" ? local.file : { ...local.file, contents: value }, lineAnnotations: local.annotations, containerWrapper: container, @@ -910,6 +971,13 @@ export function Code(props: CodeProps) { onCleanup(() => { observer?.disconnect() + instance?.cleanUp() + instance = undefined + + virtualizer?.cleanUp() + virtualizer = undefined + virtualRoot = undefined + clearOverlayScroll() clearOverlay() if (findCurrent === host) { diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index 0966db75e0..0002232b01 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -1,5 +1,5 @@ -import { checksum } from "@opencode-ai/util/encode" -import { FileDiff, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" +import { sampledChecksum } from "@opencode-ai/util/encode" +import { FileDiff, type FileDiffOptions, type SelectedLineRange, VirtualizedFileDiff } from "@pierre/diffs" import { createMediaQuery } from "@solid-primitives/media" import { createEffect, createMemo, createSignal, onCleanup, splitProps } from "solid-js" import { createDefaultOptions, type DiffProps, styleVariables } from "../pierre" @@ -78,14 +78,29 @@ export function Diff(props: DiffProps) { const mobile = createMediaQuery("(max-width: 640px)") - const options = createMemo(() => { - const opts = { + const large = createMemo(() => { + const before = typeof local.before?.contents === "string" ? local.before.contents : "" + const after = typeof local.after?.contents === "string" ? local.after.contents : "" + return Math.max(before.length, after.length) > 500_000 + }) + + const largeOptions = { + lineDiffType: "none", + maxLineDiffLength: 0, + tokenizeMaxLineLength: 1, + } satisfies Pick, "lineDiffType" | "maxLineDiffLength" | "tokenizeMaxLineLength"> + + const options = createMemo>(() => { + const base = { ...createDefaultOptions(props.diffStyle), ...others, } - if (!mobile()) return opts + + const perf = large() ? { ...base, ...largeOptions } : base + if (!mobile()) return perf + return { - ...opts, + ...perf, disableLineNumbers: true, } }) @@ -528,12 +543,17 @@ export function Diff(props: DiffProps) { createEffect(() => { const opts = options() - const workerPool = getWorkerPool(props.diffStyle) + const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle) const virtualizer = getVirtualizer() const annotations = local.annotations const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" + const cacheKey = (contents: string) => { + if (!large()) return sampledChecksum(contents, contents.length) + return sampledChecksum(contents) + } + instance?.cleanUp() instance = virtualizer ? new VirtualizedFileDiff(opts, virtualizer, virtualMetrics, workerPool) @@ -545,12 +565,12 @@ export function Diff(props: DiffProps) { oldFile: { ...local.before, contents: beforeContents, - cacheKey: checksum(beforeContents), + cacheKey: cacheKey(beforeContents), }, newFile: { ...local.after, contents: afterContents, - cacheKey: checksum(afterContents), + cacheKey: cacheKey(afterContents), }, lineAnnotations: annotations, containerWrapper: container, diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css index 30bfe3b712..46473b75e5 100644 --- a/packages/ui/src/components/session-review.css +++ b/packages/ui/src/components/session-review.css @@ -222,4 +222,30 @@ --line-comment-popover-z: 30; --line-comment-open-z: 6; } + + [data-slot="session-review-large-diff"] { + padding: 12px; + background: var(--background-stronger); + } + + [data-slot="session-review-large-diff-title"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-medium); + color: var(--text-strong); + margin-bottom: 4px; + } + + [data-slot="session-review-large-diff-meta"] { + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + color: var(--text-weak); + word-break: break-word; + } + + [data-slot="session-review-large-diff-actions"] { + display: flex; + gap: 8px; + margin-top: 10px; + } } diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index fe2475548e..5f1e6b1aba 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -17,6 +17,26 @@ import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" +const MAX_DIFF_LINES = 20_000 +const MAX_DIFF_BYTES = 2_000_000 + +function linesOver(text: string, max: number) { + let lines = 1 + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) !== 10) continue + lines++ + if (lines > max) return true + } + return lines > max +} + +function formatBytes(bytes: number) { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B" + if (bytes < 1024) return `${bytes} B` + if (bytes < 1024 * 1024) return `${Math.round((bytes / 1024) * 10) / 10} KB` + return `${Math.round((bytes / (1024 * 1024)) * 10) / 10} MB` +} + export type SessionReviewDiffStyle = "unified" | "split" export type SessionReviewComment = { @@ -326,12 +346,28 @@ export const SessionReview = (props: SessionReviewProps) => { {(diff) => { let wrapper: HTMLDivElement | undefined + const expanded = createMemo(() => open().includes(diff.file)) + const [force, setForce] = createSignal(false) + const comments = createMemo(() => (props.comments ?? []).filter((c) => c.file === diff.file)) const commentedLines = createMemo(() => comments().map((c) => c.selection)) const beforeText = () => (typeof diff.before === "string" ? diff.before : "") const afterText = () => (typeof diff.after === "string" ? diff.after : "") + const tooLarge = createMemo(() => { + if (!expanded()) return false + if (force()) return false + if (isImageFile(diff.file)) return false + + const before = beforeText() + const after = afterText() + + if (before.length > MAX_DIFF_BYTES || after.length > MAX_DIFF_BYTES) return true + if (linesOver(before, MAX_DIFF_LINES) || linesOver(after, MAX_DIFF_LINES)) return true + return false + }) + const isAdded = () => diff.status === "added" || (beforeText().length === 0 && afterText().length > 0) const isDeleted = () => diff.status === "deleted" || (afterText().length === 0 && beforeText().length > 0) @@ -571,94 +607,114 @@ export const SessionReview = (props: SessionReviewProps) => { scheduleAnchors() }} > - - -
- {diff.file} -
-
- -
- - {i18n.t("ui.sessionReview.change.removed")} - -
-
- -
- - {imageStatus() === "loading" ? "Loading..." : "Image"} - -
-
- - { - props.onDiffRendered?.() - scheduleAnchors() - }} - enableLineSelection={props.onLineComment != null} - onLineSelected={handleLineSelected} - onLineSelectionEnd={handleLineSelectionEnd} - selectedLines={selectedLines()} - commentedLines={commentedLines()} - before={{ - name: diff.file!, - contents: typeof diff.before === "string" ? diff.before : "", - }} - after={{ - name: diff.file!, - contents: typeof diff.after === "string" ? diff.after : "", - }} - /> - -
- - - {(comment) => ( - setSelection({ file: comment.file, range: comment.selection })} - onClick={() => { - if (isCommentOpen(comment)) { - setOpened(null) - return - } - - openComment(comment) - }} - open={isCommentOpen(comment)} - comment={comment.comment} - selection={selectionLabel(comment.selection)} - /> - )} - - - - {(range) => ( - - setCommenting(null)} - onSubmit={(comment) => { - props.onLineComment?.({ - file: diff.file, - selection: range(), - comment, - preview: selectionPreview(diff, range()), - }) - setCommenting(null) + + + +
+ {diff.file} +
+
+ +
+ + {i18n.t("ui.sessionReview.change.removed")} + +
+
+ +
+ + {imageStatus() === "loading" + ? i18n.t("ui.sessionReview.image.loading") + : i18n.t("ui.sessionReview.image.placeholder")} + +
+
+ +
+
+ {i18n.t("ui.sessionReview.largeDiff.title")} +
+
+ Limit: {MAX_DIFF_LINES.toLocaleString()} lines / {formatBytes(MAX_DIFF_BYTES)}. + Current: {formatBytes(Math.max(beforeText().length, afterText().length))}. +
+
+ +
+
+
+ + { + props.onDiffRendered?.() + scheduleAnchors() + }} + enableLineSelection={props.onLineComment != null} + onLineSelected={handleLineSelected} + onLineSelectionEnd={handleLineSelectionEnd} + selectedLines={selectedLines()} + commentedLines={commentedLines()} + before={{ + name: diff.file!, + contents: typeof diff.before === "string" ? diff.before : "", + }} + after={{ + name: diff.file!, + contents: typeof diff.after === "string" ? diff.after : "", }} /> -
- )} + + + + + {(comment) => ( + setSelection({ file: comment.file, range: comment.selection })} + onClick={() => { + if (isCommentOpen(comment)) { + setOpened(null) + return + } + + openComment(comment) + }} + open={isCommentOpen(comment)} + comment={comment.comment} + selection={selectionLabel(comment.selection)} + /> + )} + + + + {(range) => ( + + setCommenting(null)} + onSubmit={(comment) => { + props.onLineComment?.({ + file: diff.file, + selection: range(), + comment, + preview: selectionPreview(diff, range()), + }) + setCommenting(null) + }} + /> + + )} +
diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 7ee17e2e01..9a6c8dcbd0 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "مضاف", "ui.sessionReview.change.removed": "محذوف", "ui.sessionReview.change.modified": "معدل", + "ui.sessionReview.image.loading": "جار التحميل...", + "ui.sessionReview.image.placeholder": "صورة", + "ui.sessionReview.largeDiff.title": "Diff كبير جدا لعرضه", + "ui.sessionReview.largeDiff.meta": "الحد: {{lines}} سطر / {{limit}}. الحالي: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "اعرض على أي حال", "ui.lineComment.label.prefix": "تعليق على ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index 6d7449d845..148b0ae174 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "Adicionado", "ui.sessionReview.change.removed": "Removido", "ui.sessionReview.change.modified": "Modificado", + "ui.sessionReview.image.loading": "Carregando...", + "ui.sessionReview.image.placeholder": "Imagem", + "ui.sessionReview.largeDiff.title": "Diff grande demais para renderizar", + "ui.sessionReview.largeDiff.meta": "Limite: {{lines}} linhas / {{limit}}. Atual: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Renderizar mesmo assim", "ui.lineComment.label.prefix": "Comentar em ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts index 24e4c12068..7614af087f 100644 --- a/packages/ui/src/i18n/bs.ts +++ b/packages/ui/src/i18n/bs.ts @@ -12,6 +12,11 @@ export const dict = { "ui.sessionReview.change.added": "Dodano", "ui.sessionReview.change.removed": "Uklonjeno", "ui.sessionReview.change.modified": "Izmijenjeno", + "ui.sessionReview.image.loading": "Učitavanje...", + "ui.sessionReview.image.placeholder": "Slika", + "ui.sessionReview.largeDiff.title": "Diff je prevelik za prikaz", + "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linija / {{limit}}. Trenutno: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Prikaži svejedno", "ui.lineComment.label.prefix": "Komentar na ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index 218f3b26a4..2f49a94344 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -9,6 +9,11 @@ export const dict = { "ui.sessionReview.change.added": "Tilføjet", "ui.sessionReview.change.removed": "Fjernet", "ui.sessionReview.change.modified": "Ændret", + "ui.sessionReview.image.loading": "Indlæser...", + "ui.sessionReview.image.placeholder": "Billede", + "ui.sessionReview.largeDiff.title": "Diff er for stor til at blive vist", + "ui.sessionReview.largeDiff.meta": "Grænse: {{lines}} linjer / {{limit}}. Nuværende: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Vis alligevel", "ui.lineComment.label.prefix": "Kommenter på ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Kommenterer på ", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 921a12c996..44090b7bdb 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -13,6 +13,11 @@ export const dict = { "ui.sessionReview.change.added": "Hinzugefügt", "ui.sessionReview.change.removed": "Entfernt", "ui.sessionReview.change.modified": "Geändert", + "ui.sessionReview.image.loading": "Wird geladen...", + "ui.sessionReview.image.placeholder": "Bild", + "ui.sessionReview.largeDiff.title": "Diff zu groß zum Rendern", + "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} Zeilen / {{limit}}. Aktuell: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Trotzdem rendern", "ui.lineComment.label.prefix": "Kommentar zu ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Kommentiere ", diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 631bc660a6..9b6ab0bd6d 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "Added", "ui.sessionReview.change.removed": "Removed", "ui.sessionReview.change.modified": "Modified", + "ui.sessionReview.image.loading": "Loading...", + "ui.sessionReview.image.placeholder": "Image", + "ui.sessionReview.largeDiff.title": "Diff too large to render", + "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} lines / {{limit}}. Current: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Render anyway", "ui.lineComment.label.prefix": "Comment on ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index 4fd921b606..c2f8ac3b9d 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "Añadido", "ui.sessionReview.change.removed": "Eliminado", "ui.sessionReview.change.modified": "Modificado", + "ui.sessionReview.image.loading": "Cargando...", + "ui.sessionReview.image.placeholder": "Imagen", + "ui.sessionReview.largeDiff.title": "Diff demasiado grande para renderizar", + "ui.sessionReview.largeDiff.meta": "Límite: {{lines}} líneas / {{limit}}. Actual: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Renderizar de todos modos", "ui.lineComment.label.prefix": "Comentar en ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index 537d01bba9..679d56fa76 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "Ajouté", "ui.sessionReview.change.removed": "Supprimé", "ui.sessionReview.change.modified": "Modifié", + "ui.sessionReview.image.loading": "Chargement...", + "ui.sessionReview.image.placeholder": "Image", + "ui.sessionReview.largeDiff.title": "Diff trop volumineux pour être affiché", + "ui.sessionReview.largeDiff.meta": "Limite : {{lines}} lignes / {{limit}}. Actuel : {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Afficher quand même", "ui.lineComment.label.prefix": "Commenter sur ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 6086070bdb..bf85807d00 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -9,6 +9,11 @@ export const dict = { "ui.sessionReview.change.added": "追加", "ui.sessionReview.change.removed": "削除", "ui.sessionReview.change.modified": "変更", + "ui.sessionReview.image.loading": "読み込み中...", + "ui.sessionReview.image.placeholder": "画像", + "ui.sessionReview.largeDiff.title": "差分が大きすぎて表示できません", + "ui.sessionReview.largeDiff.meta": "上限: {{lines}} 行 / {{limit}}。現在: {{current}}。", + "ui.sessionReview.largeDiff.renderAnyway": "それでも表示する", "ui.lineComment.label.prefix": "", "ui.lineComment.label.suffix": "へのコメント", "ui.lineComment.editorLabel.prefix": "", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index fd394dbb7b..aba793a11b 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "추가됨", "ui.sessionReview.change.removed": "삭제됨", "ui.sessionReview.change.modified": "수정됨", + "ui.sessionReview.image.loading": "로딩 중...", + "ui.sessionReview.image.placeholder": "이미지", + "ui.sessionReview.largeDiff.title": "차이가 너무 커서 렌더링할 수 없습니다", + "ui.sessionReview.largeDiff.meta": "제한: {{lines}}줄 / {{limit}}. 현재: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "그래도 렌더링", "ui.lineComment.label.prefix": "", "ui.lineComment.label.suffix": "에 댓글 달기", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index dcb353614d..7982b3ac75 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -11,6 +11,11 @@ export const dict: Record = { "ui.sessionReview.change.added": "Lagt til", "ui.sessionReview.change.removed": "Fjernet", "ui.sessionReview.change.modified": "Endret", + "ui.sessionReview.image.loading": "Laster...", + "ui.sessionReview.image.placeholder": "Bilde", + "ui.sessionReview.largeDiff.title": "Diff er for stor til å gjengi", + "ui.sessionReview.largeDiff.meta": "Grense: {{lines}} linjer / {{limit}}. Nåværende: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Gjengi likevel", "ui.lineComment.label.prefix": "Kommenter på ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index fb10debbb9..2489ac7f2e 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -9,6 +9,11 @@ export const dict = { "ui.sessionReview.change.added": "Dodano", "ui.sessionReview.change.removed": "Usunięto", "ui.sessionReview.change.modified": "Zmodyfikowano", + "ui.sessionReview.image.loading": "Ładowanie...", + "ui.sessionReview.image.placeholder": "Obraz", + "ui.sessionReview.largeDiff.title": "Diff jest zbyt duży, aby go wyrenderować", + "ui.sessionReview.largeDiff.meta": "Limit: {{lines}} linii / {{limit}}. Obecnie: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Renderuj mimo to", "ui.lineComment.label.prefix": "Komentarz do ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Komentowanie: ", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index 417fe0ce8b..8e6bb678f2 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -9,6 +9,11 @@ export const dict = { "ui.sessionReview.change.added": "Добавлено", "ui.sessionReview.change.removed": "Удалено", "ui.sessionReview.change.modified": "Изменено", + "ui.sessionReview.image.loading": "Загрузка...", + "ui.sessionReview.image.placeholder": "Изображение", + "ui.sessionReview.largeDiff.title": "Diff слишком большой для отображения", + "ui.sessionReview.largeDiff.meta": "Лимит: {{lines}} строк / {{limit}}. Текущий: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "Отобразить всё равно", "ui.lineComment.label.prefix": "Комментарий к ", "ui.lineComment.label.suffix": "", "ui.lineComment.editorLabel.prefix": "Комментирование: ", diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts index 68bb0d733d..b036eca2e8 100644 --- a/packages/ui/src/i18n/th.ts +++ b/packages/ui/src/i18n/th.ts @@ -8,6 +8,11 @@ export const dict = { "ui.sessionReview.change.added": "เพิ่ม", "ui.sessionReview.change.removed": "ลบ", "ui.sessionReview.change.modified": "แก้ไข", + "ui.sessionReview.image.loading": "กำลังโหลด...", + "ui.sessionReview.image.placeholder": "รูปภาพ", + "ui.sessionReview.largeDiff.title": "Diff มีขนาดใหญ่เกินไปจนไม่สามารถแสดงผลได้", + "ui.sessionReview.largeDiff.meta": "ขีดจำกัด: {{lines}} บรรทัด / {{limit}}. ปัจจุบัน: {{current}}.", + "ui.sessionReview.largeDiff.renderAnyway": "แสดงผลต่อไป", "ui.lineComment.label.prefix": "แสดงความคิดเห็นบน ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 53beeb1e4f..dcb8062a33 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -12,6 +12,11 @@ export const dict = { "ui.sessionReview.change.added": "已添加", "ui.sessionReview.change.removed": "已移除", "ui.sessionReview.change.modified": "已修改", + "ui.sessionReview.image.loading": "加载中...", + "ui.sessionReview.image.placeholder": "图片", + "ui.sessionReview.largeDiff.title": "差异过大,无法渲染", + "ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。当前:{{current}}。", + "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染", "ui.lineComment.label.prefix": "评论 ", "ui.lineComment.label.suffix": "", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 1449b0530a..271a6ded32 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -12,6 +12,11 @@ export const dict = { "ui.sessionReview.change.added": "已新增", "ui.sessionReview.change.removed": "已移除", "ui.sessionReview.change.modified": "已修改", + "ui.sessionReview.image.loading": "載入中...", + "ui.sessionReview.image.placeholder": "圖片", + "ui.sessionReview.largeDiff.title": "差異過大,無法渲染", + "ui.sessionReview.largeDiff.meta": "限制:{{lines}} 行 / {{limit}}。目前:{{current}}。", + "ui.sessionReview.largeDiff.renderAnyway": "仍然渲染", "ui.lineComment.label.prefix": "評論 ", "ui.lineComment.label.suffix": "", diff --git a/packages/util/src/encode.ts b/packages/util/src/encode.ts index 138cf16086..e4c6e70acb 100644 --- a/packages/util/src/encode.ts +++ b/packages/util/src/encode.ts @@ -28,3 +28,24 @@ export function checksum(content: string): string | undefined { } return (hash >>> 0).toString(36) } + +export function sampledChecksum(content: string, limit = 500_000): string | undefined { + if (!content) return undefined + if (content.length <= limit) return checksum(content) + + const size = 4096 + const points = [ + 0, + Math.floor(content.length * 0.25), + Math.floor(content.length * 0.5), + Math.floor(content.length * 0.75), + content.length - size, + ] + const hashes = points + .map((point) => { + const start = Math.max(0, Math.min(content.length - size, point - Math.floor(size / 2))) + return checksum(content.slice(start, start + size)) ?? "" + }) + .join(":") + return `${content.length}:${hashes}` +} From 9f20e0d14b1d7db2167b2a81523a2521fe1c3b73 Mon Sep 17 00:00:00 2001 From: Jun <87404676+Seungjun0906@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:12:28 +0900 Subject: [PATCH 37/46] fix(web): sync docs locale cookie on alias redirects (#13109) --- packages/app/src/context/language.tsx | 5 +++++ packages/web/src/middleware.ts | 31 +++++++++++++++++++++------ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index a5d894e62e..b21ec6d3cc 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -57,6 +57,10 @@ export type Locale = type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten +function cookie(locale: Locale) { + return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` +} + const LOCALES: readonly Locale[] = [ "en", "zh", @@ -199,6 +203,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont createEffect(() => { if (typeof document !== "object") return document.documentElement.lang = locale() + document.cookie = cookie(locale()) }) return { diff --git a/packages/web/src/middleware.ts b/packages/web/src/middleware.ts index 97d085dfbf..cf9f97b0b1 100644 --- a/packages/web/src/middleware.ts +++ b/packages/web/src/middleware.ts @@ -12,7 +12,28 @@ function docsAlias(pathname: string) { const next = locale === "root" ? `/docs${tail}` : `/docs/${locale}${tail}` if (next === pathname) return null - return next + return { + path: next, + locale, + } +} + +function cookie(locale: string) { + const value = locale === "root" ? "en" : locale + return `oc_locale=${encodeURIComponent(value)}; Path=/; Max-Age=31536000; SameSite=Lax` +} + +function redirect(url: URL, path: string, locale?: string) { + const next = new URL(url.toString()) + next.pathname = path + const headers = new Headers({ + Location: next.toString(), + }) + if (locale) headers.set("Set-Cookie", cookie(locale)) + return new Response(null, { + status: 302, + headers, + }) } function localeFromCookie(header: string | null) { @@ -59,9 +80,7 @@ function localeFromAcceptLanguage(header: string | null) { export const onRequest = defineMiddleware((ctx, next) => { const alias = docsAlias(ctx.url.pathname) if (alias) { - const url = new URL(ctx.request.url) - url.pathname = alias - return ctx.redirect(url.toString(), 302) + return redirect(ctx.url, alias.path, alias.locale) } if (ctx.url.pathname !== "/docs" && ctx.url.pathname !== "/docs/") return next() @@ -71,7 +90,5 @@ export const onRequest = defineMiddleware((ctx, next) => { localeFromAcceptLanguage(ctx.request.headers.get("accept-language")) if (!locale || locale === "root") return next() - const url = new URL(ctx.request.url) - url.pathname = `/docs/${locale}/` - return ctx.redirect(url.toString(), 302) + return redirect(ctx.url, `/docs/${locale}/`) }) From ebe5a2b74a564dd92677f2cdaa8d21280aedf7fa Mon Sep 17 00:00:00 2001 From: Chris Yang <18487241+ysm-dev@users.noreply.github.com> Date: Fri, 13 Feb 2026 20:16:14 +0900 Subject: [PATCH 38/46] fix(app): remount SDK/sync tree when server URL changes (#13437) --- packages/app/src/app.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 3032a795f8..1121c2e955 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -1,5 +1,5 @@ import "@/index.css" -import { ErrorBoundary, Suspense, lazy, type JSX, type ParentProps } from "solid-js" +import { ErrorBoundary, Show, Suspense, lazy, type JSX, type ParentProps } from "solid-js" import { Router, Route, Navigate } from "@solidjs/router" import { MetaProvider } from "@solidjs/meta" import { Font } from "@opencode-ai/ui/font" @@ -156,8 +156,11 @@ export function AppBaseProviders(props: ParentProps) { function ServerKey(props: ParentProps) { const server = useServer() - if (!server.url) return null - return props.children + return ( + + {props.children} + + ) } export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element; isSidecar?: boolean }) { From b1764b2ffdba86c70c6f2777d1342ad87ac6ec41 Mon Sep 17 00:00:00 2001 From: Annopick Date: Fri, 13 Feb 2026 19:18:47 +0800 Subject: [PATCH 39/46] docs: Fix zh-cn translation mistake in tools.mdx (#13407) --- packages/web/src/content/docs/zh-cn/tools.mdx | 126 +++++++++--------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/packages/web/src/content/docs/zh-cn/tools.mdx b/packages/web/src/content/docs/zh-cn/tools.mdx index 1be9d66901..a1a97a3ed7 100644 --- a/packages/web/src/content/docs/zh-cn/tools.mdx +++ b/packages/web/src/content/docs/zh-cn/tools.mdx @@ -24,7 +24,7 @@ Tools allow the LLM to perform actions in your codebase. opencode comes with a s } ``` -您还可以使用万用字元同时控制多个工具。例如,要求 MCP 服务器批准所有工具: +您还可以使用通配符同时控制多个工具。例如,要求 MCP 服务器批准所有工具: ```json title="opencode.json" { @@ -39,15 +39,15 @@ Tools allow the LLM to perform actions in your codebase. opencode comes with a s --- -## 內建 +## 內建工具 以下是 opencode 中可用的所有内置工具。 --- -### 巴什 +### Bash -在专案环境中执行shell命令。 +在专项任务环境中执行shell命令。 ```json title="opencode.json" {4} { @@ -58,13 +58,13 @@ Tools allow the LLM to perform actions in your codebase. opencode comes with a s } ``` -This tool allows the LLM to run terminal commands like `npm install`, `git status`, or any other shell command. +这个工具允许 LLM 运行终端命令,例如:`npm install`, `git status`,或者其他任何终端命令。 --- -### 編輯 +### 编辑 -使用精確的字符串替換修改現有文件。 +使用精确的字符串替换来修改现有文件。 ```json title="opencode.json" {4} { @@ -75,13 +75,13 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -该工具取消替换精确的文字来匹配对文件执行精确编辑。这是 LLM 修改代码的主要方式。 +该工具通过替换完全匹配的文本来对文件进行精确编辑。这是 LLM 修改代码的主要方式。 --- -### 寫 +### 写入 -建立新文件或覆盖現有文件。 +创建新文件或覆盖现有文件。 ```json title="opencode.json" {4} { @@ -92,17 +92,17 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -使用它允许 LLM 创建新文件。如果现有文件已经存在,将会覆盖它们。 +使用此功能可允许 LLM 创建新文件。如果文件已存在,则会覆盖现有文件。 :::note -`write`工具由`edit`许可权控制,该许可权主题所有文件修改(`edit`、`write`、`patch`、`multiedit`)。 +`写入`工具由`编辑`权限控制,涵盖所有文件修改(`编辑`、`写入`、`修补`、`多重编辑`)。 ::: --- -### 讀 +### 读取 -從程式碼庫中讀取文件內容。 +读取代码库中的文件内容。 ```json title="opencode.json" {4} { @@ -113,13 +113,13 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -该工具讀取文件并返回其內容。它支持讀取大文件的特定行范围。 +该工具读取文件并返回其内容。它支持读取大型文件中的特定行范围。 --- ### grep -使用正規表示式搜索文件內容。 +使用正则表达式搜索文件内容。 ```json title="opencode.json" {4} { @@ -130,13 +130,13 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -在您的程式碼庫中快速進行內容搜索。支持完整的正規表示式語法和文件模式过濾。 +快速搜索代码库中的内容。支持完整的正则表达式语法和文件模式过滤。 --- -### 全域性 +### 通配符 -通过模式匹配查询文件。 +通过模式匹配查找文件。 ```json title="opencode.json" {4} { @@ -147,13 +147,13 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -使用 `**/*.js` 或 `src/**/*.ts` 等全域性模式搜索档案。返回按时间排序的匹配档案路径修改。 +使用类似 **/*.js 或 src/**/*.ts 的通配符模式搜索文件。返回按修改时间排序的匹配文件路径。 --- -### 列表 +### 罗列 -列出給定路徑中的文件和目录。 +列出给定路径下的文件和目录。 ```json title="opencode.json" {4} { @@ -164,16 +164,16 @@ This tool allows the LLM to run terminal commands like `npm install`, `git statu } ``` -该工具列出目录內容。它接受全域性模式來过濾結果。 +此工具用于列出目录内容。它接受通配符模式来筛选结果。 --- ### lsp(实验性) -与您配置的LSP服务器交互,通知计划码智慧功能,例如定义、引用、悬停资讯和呼叫层次结构。 +与已配置的 LSP 服务器交互,以获取代码智能功能,例如定义、引用、悬停信息和调用层次结构。 :::note -This tool is only available when `OPENCODE_EXPERIMENTAL_LSP_TOOL=true` (or `OPENCODE_EXPERIMENTAL=true`). +只有当 OPENCODE_EXPERIMENTAL_LSP_TOOL=true(或 OPENCODE_EXPERIMENTAL=true)时,此工具才可用。 ::: ```json title="opencode.json" {4} @@ -187,13 +187,13 @@ This tool is only available when `OPENCODE_EXPERIMENTAL_LSP_TOOL=true` (or `OPEN 支持的操作包括 `goToDefinition`、`findReferences`、`hover`、`documentSymbol`、`workspaceSymbol`、`goToImplementation`、`prepareCallHierarchy`、`incomingCalls` 和 `outgoingCalls`。 -To configure which LSP servers are available for your project, see [LSP Servers](/docs/lsp). +要配置哪些 LSP 服务器可用于您的项目,请参阅 [LSP Servers](/docs/lsp). --- -### 修補 +### 修补 -对文件应用補丁。 +对文件应用补丁。 ```json title="opencode.json" {4} { @@ -204,17 +204,17 @@ To configure which LSP servers are available for your project, see [LSP Servers] } ``` -该工具将補丁文件应用到您的程式碼庫。对于应用來自各種來源的差異和補丁很有帮助。 +此工具可将补丁文件应用到您的代码库。它可用于应用来自各种来源的差异和补丁。 :::note -`patch`工具由`edit`许可权控制,该许可权主题所有文件修改(`edit`、`write`、`patch`、`multiedit`)。 +`修补`工具由`编辑`权限控制,涵盖所有文件修改(`编辑`、`写入`、`修补`、`多重编辑`)。 ::: --- ### 技能 -加载[skill](/docs/skills)(`SKILL.md` 档案)并在对话中返回其内容。 +加载[技能](/docs/skills)(`SKILL.md` 文件)并在对话中返回其内容。 ```json title="opencode.json" {4} { @@ -227,9 +227,9 @@ To configure which LSP servers are available for your project, see [LSP Servers] --- -### 待辦寫入 +### 写入待办 -在編碼会话期間管理待辦事項列表。 +在编码会话过程中管理待办事项列表。 ```json title="opencode.json" {4} { @@ -240,17 +240,17 @@ To configure which LSP servers are available for your project, see [LSP Servers] } ``` -建立和更新任务列表以跟踪复杂操作期间的详细信息。LLM 使用它来组织多步骤任务。 +创建和更新任务列表,以跟踪复杂操作的进度。LLM 利用此功能来组织多步骤任务。 :::note -默认情况下,子代理取消此工具,但您可以手动启用它。 [了解更多](/docs/agents/#permissions) +此工具默认情况下对子代理禁用,但您可以手动启用它。 [了解更多](/docs/agents/#permissions) ::: --- -### 託多雷德 +### 读取待办 -閱讀現有的待辦事項列表。 +阅读现有的待办事项清单。 ```json title="opencode.json" {4} { @@ -261,17 +261,17 @@ To configure which LSP servers are available for your project, see [LSP Servers] } ``` -读取当前完成待办事项列表状态。由 LLM 用于跟踪哪些任务待处理或已已。 +读取当前待办事项列表状态。LLM 使用此信息来跟踪哪些任务处于待处理状态或已完成状态。 :::note -默认情况下,子代理取消此工具,但您可以手动启用它。 [了解更多](/docs/agents/#permissions) +此工具默认情况下对子代理禁用,但您可以手动启用它。 [了解更多](/docs/agents/#permissions) ::: --- -### 網頁抓取 +### 网页获取 -获取網頁內容。 +获取网页内容。 ```json title="opencode.json" {4} { @@ -282,18 +282,18 @@ To configure which LSP servers are available for your project, see [LSP Servers] } ``` -允许 LLM 获取和读取网页。对于查询文件或研究线上资源很有帮助。 +允许LLM获取并读取网页。可用于查找文档或研究在线资源。 --- -### 網路搜索 +### 网页搜索 -在網路上搜索資訊。 +在网上搜索信息。 :::note -仅当使用 opencode 提供或 `OPENCODE_ENABLE_EXA` 程序环境变量设置为任何真值(例如 `true` 或 `1`)时,此工具才可用。 +只有在使用 OpenCode 提供程序时,或者当 OPENCODE_ENABLE_EXA 环境变量被设置为任何真值(例如 true 或 1)时,此工具才可用。 -要在启动 opencode 时启用: +在启动 OpenCode 时启用: ```bash OPENCODE_ENABLE_EXA=1 opencode @@ -310,19 +310,19 @@ OPENCODE_ENABLE_EXA=1 opencode } ``` -使用 Exa AI 执行网路搜索以线上查询相关资讯。对于研究主题、查询时事或收集训练超出数据范围的资讯很有帮助。 +利用 Exa AI 进行网络搜索,查找相关信息。可用于研究特定主题、了解时事新闻或收集超出训练数据范围的信息。 -不需要 API 密钥 — 该工具消耗身份验证即可直接连线到 Exa AI 的托管 MCP 服务。 +无需 API 密钥——该工具无需身份验证即可直接连接到 Exa AI 托管的 MCP 服务。 :::tip -当您需要查询资讯(发现)时,请使用 `websearch`;当您需要从特定 URL 检索内容(搜索)时,请使用 `webfetch`。 +当您需要查找信息时,请使用`网页搜索`;当您需要从特定 URL 检索内容时,请使用`网页获取`。 ::: --- -### 問題 +### 提问 -在执行过程中詢問用户問題。 +在执行过程中向用户提问。 ```json title="opencode.json" {4} { @@ -333,20 +333,20 @@ OPENCODE_ENABLE_EXA=1 opencode } ``` -该工具允许 LLM 在任务期间询问用户问题。它适用于: +该工具允许 LLM 在执行任务期间向用户提问。它在以下方面很有用: -- 收集用户偏好或要求 -- 澄清不明確的指令 -- 就實施选择做出決策 -- 提供选择方向 +- 收集用户偏好或需求 +- 澄清含糊不清的指示 +- 就实施方案做出决定 +- 提供关于选择下一步方向的选项 -每个問題都包含標題、問題文字和選項列表。用户可以從提供的選項中進行选择或輸入自定義答案。当存在多个問題時,用户可以在提交所有答案之前在这些問題之间导航。 +每个问题都包含标题、问题正文和选项列表。用户可以从提供的选项中选择答案,也可以输入自定义答案。如果有多个问题,用户可以在提交所有答案之前在不同问题之间切换。 --- -## 定製工具 +## 自定义工具 -自定义工具可以让您定义LLM可以调用自己的函式。这些是在您的配置文件中定义的并且可以执行任何代码。 +自定义工具允许您定义LLM可以调用的自定义函数。这些函数在您的配置文件中定义,并且可以执行任意代码。 [了解更多](/docs/custom-tools)关于创建自定义工具。 @@ -360,15 +360,15 @@ MCP(模型上下文协议)服务器允许您集成外部工具和服务。 --- -## 内部結構 +## 内部规则 -Internally, tools like `grep`, `glob`, and `list` use [ripgrep](https://github.com/BurntSushi/ripgrep) under the hood. By default, ripgrep respects `.gitignore` patterns, which means files and directories listed in your `.gitignore` will be excluded from searches and listings. +在内部,`grep`、 `通配符` 和 `罗列` 等工具底层都使用了 ripgrep。默认情况下,ripgrep 会遵循 .gitignore 文件中的规则,这意味着 .gitignore 文件中列出的文件和目录将被排除在搜索和列表之外。 --- ### 忽略模式 -要包含通常会被忽略的文件,请在专案根目录中建立 `.ignore` 文件。该文件可以明确允许某些路径。 +为了使工具不跳过那些通常会被忽略的文件,请在项目根目录下创建一个 `.ignore` 文件。该文件内定义的目录可以不会被跳过。 ```text title=".ignore" !node_modules/ @@ -376,4 +376,4 @@ Internally, tools like `grep`, `glob`, and `list` use [ripgrep](https://github.c !build/ ``` -例如,此 `.ignore` 档案允许 ripgrep 在 `node_modules/`、`dist/` 和 `build/` 目录中搜索,即使它们列在 `.gitignore` 中。 +例如,这个 `.ignore` 文件允许 ripgrep 在 `node_modules/`、`dist/` 和 `build/` 目录中搜索,即使它们已在 `.gitignore` 中列出。 From f991a6c0b6bba97be27f3c132c14c5fa78d05536 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 13 Feb 2026 11:19:37 +0000 Subject: [PATCH 40/46] chore: generate --- packages/web/src/content/docs/zh-cn/tools.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/web/src/content/docs/zh-cn/tools.mdx b/packages/web/src/content/docs/zh-cn/tools.mdx index a1a97a3ed7..86190a4e06 100644 --- a/packages/web/src/content/docs/zh-cn/tools.mdx +++ b/packages/web/src/content/docs/zh-cn/tools.mdx @@ -147,7 +147,7 @@ Tools allow the LLM to perform actions in your codebase. opencode comes with a s } ``` -使用类似 **/*.js 或 src/**/*.ts 的通配符模式搜索文件。返回按修改时间排序的匹配文件路径。 +使用类似 **/\*.js 或 src/**/\*.ts 的通配符模式搜索文件。返回按修改时间排序的匹配文件路径。 --- From e242fe19e48f6aa70e5c3f7d54f34d688181edb2 Mon Sep 17 00:00:00 2001 From: eytans Date: Fri, 13 Feb 2026 13:25:47 +0200 Subject: [PATCH 41/46] fix(web): use prompt_async endpoint to avoid timeout over VPN/tunnel (#12749) --- packages/app/e2e/prompt/prompt-async.spec.ts | 43 +++++++++++++++++++ .../app/src/components/prompt-input/submit.ts | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/prompt/prompt-async.spec.ts diff --git a/packages/app/e2e/prompt/prompt-async.spec.ts b/packages/app/e2e/prompt/prompt-async.spec.ts new file mode 100644 index 0000000000..ce9b1a7a3b --- /dev/null +++ b/packages/app/e2e/prompt/prompt-async.spec.ts @@ -0,0 +1,43 @@ +import { test, expect } from "../fixtures" +import { promptSelector } from "../selectors" +import { sessionIDFromUrl } from "../actions" + +// Regression test for Issue #12453: the synchronous POST /message endpoint holds +// the connection open while the agent works, causing "Failed to fetch" over +// VPN/Tailscale. The fix switches to POST /prompt_async which returns immediately. +test("prompt succeeds when sync message endpoint is unreachable", async ({ page, sdk, gotoSession }) => { + test.setTimeout(120_000) + + // Simulate Tailscale/VPN killing the long-lived sync connection + await page.route("**/session/*/message", (route) => route.abort("connectionfailed")) + + await gotoSession() + + const token = `E2E_ASYNC_${Date.now()}` + await page.locator(promptSelector).click() + await page.keyboard.type(`Reply with exactly: ${token}`) + await page.keyboard.press("Enter") + + await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) + const sessionID = sessionIDFromUrl(page.url())! + + try { + // Agent response arrives via SSE despite sync endpoint being dead + await expect + .poll( + async () => { + const messages = await sdk.session.messages({ sessionID, limit: 50 }).then((r) => r.data ?? []) + return messages + .filter((m) => m.info.role === "assistant") + .flatMap((m) => m.parts) + .filter((p) => p.type === "text") + .map((p) => p.text) + .join("\n") + }, + { timeout: 90_000 }, + ) + .toContain(token) + } finally { + await sdk.session.delete({ sessionID }).catch(() => undefined) + } +}) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 49d75a95ec..9a1fba5d5c 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -385,7 +385,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const send = async () => { const ok = await waitForWorktree() if (!ok) return - await client.session.prompt({ + await client.session.promptAsync({ sessionID: session.id, agent, model, From 1c71604e0a2a34786daa99b7002c2f567671051a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:50:12 -0600 Subject: [PATCH 42/46] fix(app): terminal resize --- packages/app/src/components/terminal.tsx | 156 ++++++++++++++++------- 1 file changed, 107 insertions(+), 49 deletions(-) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index ccf7012d20..14413dfda6 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -156,6 +156,10 @@ export const Terminal = (props: TerminalProps) => { let serializeAddon: SerializeAddon let fitAddon: FitAddon let handleResize: () => void + let fitFrame: number | undefined + let sizeTimer: ReturnType | undefined + let pendingSize: { cols: number; rows: number } | undefined + let lastSize: { cols: number; rows: number } | undefined let disposed = false const cleanups: VoidFunction[] = [] const start = @@ -209,6 +213,43 @@ export const Terminal = (props: TerminalProps) => { const [terminalColors, setTerminalColors] = createSignal(getTerminalColors()) + const scheduleFit = () => { + if (disposed) return + if (!fitAddon) return + if (fitFrame !== undefined) return + + fitFrame = requestAnimationFrame(() => { + fitFrame = undefined + if (disposed) return + fitAddon.fit() + }) + } + + const scheduleSize = (cols: number, rows: number) => { + if (disposed) return + if (lastSize?.cols === cols && lastSize?.rows === rows) return + + pendingSize = { cols, rows } + + if (!lastSize) { + lastSize = pendingSize + void pushSize(cols, rows) + return + } + + if (sizeTimer !== undefined) return + sizeTimer = setTimeout(() => { + sizeTimer = undefined + const next = pendingSize + if (!next) return + pendingSize = undefined + if (disposed) return + if (lastSize?.cols === next.cols && lastSize?.rows === next.rows) return + lastSize = next + void pushSize(next.cols, next.rows) + }, 100) + } + createEffect(() => { const colors = getTerminalColors() setTerminalColors(colors) @@ -220,6 +261,16 @@ export const Terminal = (props: TerminalProps) => { const font = monoFontFamily(settings.appearance.font()) if (!term) return setOptionIfSupported(term, "fontFamily", font) + scheduleFit() + }) + + let zoom = platform.webviewZoom?.() + createEffect(() => { + const next = platform.webviewZoom?.() + if (next === undefined) return + if (next === zoom) return + zoom = next + scheduleFit() }) const focusTerminal = () => { @@ -263,25 +314,6 @@ export const Terminal = (props: TerminalProps) => { const once = { value: false } - const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`) - url.searchParams.set("directory", sdk.directory) - url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) - url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - if (window.__OPENCODE__?.serverPassword) { - url.username = "opencode" - url.password = window.__OPENCODE__?.serverPassword - } - const socket = new WebSocket(url) - socket.binaryType = "arraybuffer" - cleanups.push(() => { - if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close() - }) - if (disposed) { - cleanup() - return - } - ws = socket - const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restoreSize = restore && @@ -344,39 +376,16 @@ export const Terminal = (props: TerminalProps) => { focusTerminal() - const startResize = () => { - fit.observeResize() - handleResize = () => fit.fit() - window.addEventListener("resize", handleResize) - cleanups.push(() => window.removeEventListener("resize", handleResize)) + if (typeof document !== "undefined" && document.fonts) { + document.fonts.ready.then(scheduleFit) } - if (restore && restoreSize) { - t.write(restore, () => { - fit.fit() - if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) - startResize() - }) - } else { - fit.fit() - if (restore) { - t.write(restore, () => { - if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) - }) - } - startResize() - } - - const onResize = t.onResize(async (size) => { - if (socket.readyState === WebSocket.OPEN) { - await pushSize(size.cols, size.rows) - } + const onResize = t.onResize((size) => { + scheduleSize(size.cols, size.rows) }) cleanups.push(() => disposeIfDisposable(onResize)) const onData = t.onData((data) => { - if (socket.readyState === WebSocket.OPEN) { - socket.send(data) - } + if (ws?.readyState === WebSocket.OPEN) ws.send(data) }) cleanups.push(() => disposeIfDisposable(onData)) const onKey = t.onKey((key) => { @@ -385,17 +394,64 @@ export const Terminal = (props: TerminalProps) => { } }) cleanups.push(() => disposeIfDisposable(onKey)) + + const startResize = () => { + fit.observeResize() + handleResize = scheduleFit + window.addEventListener("resize", handleResize) + cleanups.push(() => window.removeEventListener("resize", handleResize)) + } + + if (restore && restoreSize) { + t.write(restore, () => { + fit.fit() + scheduleSize(t.cols, t.rows) + if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) + startResize() + }) + } else { + fit.fit() + scheduleSize(t.cols, t.rows) + if (restore) { + t.write(restore, () => { + if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) + }) + } + startResize() + } + // t.onScroll((ydisp) => { // console.log("Scroll position:", ydisp) // }) + const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`) + url.searchParams.set("directory", sdk.directory) + url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + if (window.__OPENCODE__?.serverPassword) { + url.username = "opencode" + url.password = window.__OPENCODE__?.serverPassword + } + const socket = new WebSocket(url) + socket.binaryType = "arraybuffer" + ws = socket + cleanups.push(() => { + if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close() + }) + if (disposed) { + cleanup() + return + } + const handleOpen = () => { local.onConnect?.() - void pushSize(t.cols, t.rows) + scheduleSize(t.cols, t.rows) } socket.addEventListener("open", handleOpen) cleanups.push(() => socket.removeEventListener("open", handleOpen)) + if (socket.readyState === WebSocket.OPEN) handleOpen() + const decoder = new TextDecoder() const handleMessage = (event: MessageEvent) => { @@ -462,6 +518,8 @@ export const Terminal = (props: TerminalProps) => { onCleanup(() => { disposed = true + if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) + if (sizeTimer !== undefined) clearTimeout(sizeTimer) output?.flush() persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) cleanup() @@ -477,7 +535,7 @@ export const Terminal = (props: TerminalProps) => { classList={{ ...(local.classList ?? {}), "select-text": true, - "size-full px-6 py-3 font-mono": true, + "size-full px-6 py-3 font-mono relative overflow-hidden": true, [local.class ?? ""]: !!local.class, }} {...others} From 4f51c0912d76698325862e8fcd7d484b7b9a61fe Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 13 Feb 2026 05:52:33 -0600 Subject: [PATCH 43/46] chore: cleanup --- packages/app/src/components/session/session-header.tsx | 2 +- packages/app/src/pages/session.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index b85b9a536a..f81a2ec440 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -552,7 +552,7 @@ export function SessionHeader() {
-