Compare commits

..
Author SHA1 Message Date
opencode edcfd562af release: v1.1.57 2026-02-11 13:45:34 +00:00
Adam ef5ec5dc28 fix(app): terminal copy/paste 2026-02-11 07:19:33 -06:00
Adam c426cb0f14 fix(app): copy path button styles 2026-02-11 07:19:33 -06:00
Shintaro Jokagi dd1862cc2b fix(web): prevent language select label truncation (#13100) 2026-02-11 06:43:48 -06:00
Brendan Allan a25b2af05a desktop: use tracing for logging (#13135) 2026-02-11 11:36:27 +00:00
Jack 8bfd6fdba2 fix: encode non-ASCII directory paths in v1 SDK HTTP headers (#13131) 2026-02-11 05:33:25 -06:00
Filip cf7a1b8d80 feat(desktop): enhance Windows app resolution and UI loading states (#13084) 2026-02-11 04:40:52 -06:00
opencode-agent[bot] 5ba4c0e024 chore: generate 2026-02-11 10:38:52 +00:00
Jun 567e094e6c docs(ko): improve translations for intro, cli, and commands (#13094) 2026-02-11 04:37:52 -06:00
webwww123 b523998329 fix(docs): avoid footer language selector truncation (#13124) 2026-02-11 04:33:50 -06:00
AdamandBrendan Allan 7e1247c420 fix(desktop): server spawn resilience (#13028)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-02-11 09:44:26 +00:00
Brendan Allan 783888131e fix(desktop): read wayland preference from store (#13081) 2026-02-11 08:10:24 +00:00
Brendan Allan 213a87234d feat(desktop): add WSL backend mode (#12914) 2026-02-11 07:49:48 +00:00
Aiden Cline d98bd4bd52 fix: add additional context overflow cases, remove overcorrecting ones (#13077) 2026-02-10 23:30:29 -06:00
Frank 22125d1347 wip: zen 2026-02-11 00:05:04 -05:00
Ariane Emory 8c120f2fab docs: remove 'Migrating to 1.0' documentation section (#13076) 2026-02-10 22:52:02 -06:00
opencode-agent[bot] c6ec2f47ef chore: generate 2026-02-11 01:56:08 +00:00
Aiden ClineandJames Lal 0fd6f365be fix(core): ensure compaction is more reliable, add reserve token buffer to ensure that input window has enough room to compact (#12924)
Co-authored-by: James Lal <james@littlebearlabs.io>
2026-02-10 19:55:22 -06:00
Aiden Cline 60bdb6e9ba tweak: /review prompt to look for behavior changes more explicitly (#13049) 2026-02-10 19:44:42 -06:00
Dax 6e9cd576ea fix(tui): default session sidebar to auto (#13046) 2026-02-11 01:26:01 +00:00
OpeOginni 53ec15a56a fix(tui): improve amazon-bedrock check to include container credentials (#13037) 2026-02-10 18:51:49 -06:00
github-actions[bot] a90b62267f Update VOUCHED list
https://github.com/anomalyco/opencode/issues/12841#issuecomment-3881500535
2026-02-11 00:37:36 +00:00
Frank 24556331c8 wip: zen 2026-02-10 17:56:10 -05:00
Frank 39145b99e8 wip: zen 2026-02-10 17:44:00 -05:00
Frank 0afa6e03a8 wip: zen 2026-02-10 17:36:32 -05:00
Frank 7a3c775dc1 wip: zen 2026-02-10 17:24:03 -05:00
Frank 3ea58bb790 wip: zen 2026-02-10 17:15:01 -05:00
Adam 50c705cd2d fix(docs): locale translations 2026-02-10 22:12:32 +00:00
Frank 3894c217cc wip: zen 2026-02-10 22:12:32 +00:00
Adam 66c2bb8f37 chore: update website stats 2026-02-10 22:12:32 +00:00
opencode 1bbbd51d48 release: v1.1.56 2026-02-10 22:12:25 +00:00
Adam 50f3e74d05 fix(app): task tool rendering 2026-02-10 15:28:46 -06:00
Adam 21475a1dfd fix(docs): invalid markdown 2026-02-10 15:18:57 -06:00
Filip dce4c05fa9 fix(desktop): open apps with executables on Windows (#13022) 2026-02-10 15:10:58 -06:00
Frank 8c56571ef9 zen: log error 2026-02-10 15:55:33 -05:00
Adam 92a77b72fb fix(app): don't close sidebar on session change (#13013) 2026-02-10 14:45:52 -06:00
226 changed files with 3192 additions and 3156 deletions
+1
View File
@@ -15,4 +15,5 @@ kitlangton
kommander
r44vc0rp
rekram1-node
-spider-yamet clawdbot/llm psychosis, spam pinging the team
thdxr
+15 -15
View File
@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.1.55",
"version": "1.1.57",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -73,7 +73,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.1.55",
"version": "1.1.57",
"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.55",
"version": "1.1.57",
"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.55",
"version": "1.1.57",
"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.55",
"version": "1.1.57",
"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.55",
"version": "1.1.57",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -215,7 +215,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.1.55",
"version": "1.1.57",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -244,7 +244,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.1.55",
"version": "1.1.57",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -260,7 +260,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.1.55",
"version": "1.1.57",
"bin": {
"opencode": "./bin/opencode",
},
@@ -366,7 +366,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.1.55",
"version": "1.1.57",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -386,7 +386,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.1.55",
"version": "1.1.57",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -397,7 +397,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.1.55",
"version": "1.1.57",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -410,7 +410,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.1.55",
"version": "1.1.57",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -452,7 +452,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.1.55",
"version": "1.1.57",
"dependencies": {
"zod": "catalog:",
},
@@ -463,7 +463,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.1.55",
"version": "1.1.57",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
+5 -9
View File
@@ -166,14 +166,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY")
let logProcessor
if ($app.stage === "production" || $app.stage === "frank") {
const HONEYCOMB_API_KEY = new sst.Secret("HONEYCOMB_API_KEY")
logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [HONEYCOMB_API_KEY],
})
}
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
link: [new sst.Secret("HONEYCOMB_API_KEY")],
})
new sst.cloudflare.x.SolidStart("Console", {
domain,
@@ -211,7 +207,7 @@ new sst.cloudflare.x.SolidStart("Console", {
transform: {
worker: {
placement: { mode: "smart" },
tailConsumers: logProcessor ? [{ service: logProcessor.nodes.worker.scriptName }] : [],
tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }],
},
},
},
@@ -0,0 +1,36 @@
import { test, expect } from "../fixtures"
import { closeSidebar, hoverSessionItem } from "../actions"
import { projectSwitchSelector, sessionItemSelector } from "../selectors"
test("collapsed sidebar popover stays open when archiving a session", async ({ page, slug, sdk, gotoSession }) => {
const stamp = Date.now()
const one = await sdk.session.create({ title: `e2e sidebar popover archive 1 ${stamp}` }).then((r) => r.data)
const two = await sdk.session.create({ title: `e2e sidebar popover archive 2 ${stamp}` }).then((r) => r.data)
if (!one?.id) throw new Error("Session create did not return an id")
if (!two?.id) throw new Error("Session create did not return an id")
try {
await gotoSession(one.id)
await closeSidebar(page)
const project = page.locator(projectSwitchSelector(slug)).first()
await expect(project).toBeVisible()
await project.hover()
await expect(page.locator(sessionItemSelector(one.id)).first()).toBeVisible()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
const item = await hoverSessionItem(page, one.id)
await item
.getByRole("button", { name: /archive/i })
.first()
.click()
await expect(page.locator(sessionItemSelector(two.id)).first()).toBeVisible()
} finally {
await sdk.session.delete({ sessionID: one.id }).catch(() => undefined)
await sdk.session.delete({ sessionID: two.id }).catch(() => undefined)
}
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.1.55",
"version": "1.1.57",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -43,7 +43,7 @@ function UiI18nBridge(props: ParentProps) {
declare global {
interface Window {
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[] }
__OPENCODE__?: { updaterEnabled?: boolean; serverPassword?: string; deepLinks?: string[]; wsl?: boolean }
}
}
@@ -1,4 +1,4 @@
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { createEffect, createMemo, createResource, onCleanup, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Portal } from "solid-js/web"
import { useParams } from "@solidjs/router"
@@ -18,6 +18,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Spinner } from "@opencode-ai/ui/spinner"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { Popover } from "@opencode-ai/ui/popover"
import { TextField } from "@opencode-ai/ui/text-field"
@@ -166,6 +167,8 @@ export function SessionHeader() {
})
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({ app: undefined as OpenApp | undefined, version: 0 })
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(() => options().find((o) => o.id === prefs.app) ?? options()[0])
@@ -178,20 +181,32 @@ export function SessionHeader() {
setPrefs("app", options()[0]?.id ?? "finder")
})
const openDir = (app: OpenApp) => {
const directory = projectDirectory()
if (!directory) return
if (!canOpen()) return
const [openTask] = createResource(
() => openRequest.app && openRequest.version,
async () => {
const app = openRequest.app
const directory = projectDirectory()
if (!app || !directory || !canOpen()) return
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
Promise.resolve(platform.openPath?.(directory, openWith)).catch((err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
await platform.openPath?.(directory, openWith)
},
)
createEffect(() => {
const err = openTask.error
if (!err) return
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
})
const openDir = (app: OpenApp) => {
if (openTask.loading) return
setOpenRequest({ app, version: openRequest.version + 1 })
}
const copyPath = () => {
@@ -328,39 +343,56 @@ export function SessionHeader() {
<Show
when={canOpen()}
fallback={
<Button
variant="ghost"
class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-2 gap-2 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none"
class="rounded-none h-full py-0 pr-3 pl-2 gap-1.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": openTask.loading,
}}
onClick={() => openDir(current().id)}
disabled={openTask.loading}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<AppIcon id={current().icon} class="size-4" />
<Show when={openTask.loading} fallback={<AppIcon id={current().icon} class="size-4" />}>
<Spinner class="size-3.5 text-icon-base" />
</Show>
</div>
<span class="text-12-regular text-text-strong">Open</span>
</Button>
<div class="self-stretch w-px bg-border-base/70" />
<DropdownMenu gutter={6} placement="bottom-end">
<DropdownMenu
gutter={6}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active"
disabled={openTask.loading}
class="rounded-none h-full w-[24px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": openTask.loading,
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
@@ -375,7 +407,14 @@ export function SessionHeader() {
}}
>
{options().map((o) => (
<DropdownMenu.RadioItem value={o.id} onSelect={() => openDir(o.id)}>
<DropdownMenu.RadioItem
value={o.id}
disabled={openTask.loading}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<AppIcon id={o.icon} class={size(o.icon)} />
</div>
@@ -388,7 +427,12 @@ export function SessionHeader() {
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={copyPath}>
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
@@ -367,6 +367,34 @@ export const SettingsGeneral: Component = () => {
</div>
</div>
<Show when={platform.platform === "desktop" && platform.os === "windows" && platform.getWslEnabled}>
{(_) => {
const [enabledResource, actions] = createResource(() => platform.getWslEnabled?.())
const enabled = () => (enabledResource.state === "pending" ? undefined : enabledResource.latest)
return (
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.desktop.section.wsl")}</h3>
<div class="bg-surface-raised-base px-4 rounded-lg">
<SettingsRow
title={language.t("settings.desktop.wsl.title")}
description={language.t("settings.desktop.wsl.description")}
>
<div data-action="settings-wsl">
<Switch
checked={enabled() ?? false}
disabled={enabledResource.state === "pending"}
onChange={(checked) => platform.setWslEnabled?.(checked)?.finally(() => actions.refetch())}
/>
</div>
</SettingsRow>
</div>
</div>
)
}}
</Show>
{/* Updates Section */}
<div class="flex flex-col gap-1">
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.updates")}</h3>
+24 -29
View File
@@ -130,11 +130,12 @@ export const Terminal = (props: TerminalProps) => {
const t = term
if (!t) return
t.focus()
t.textarea?.focus()
setTimeout(() => t.textarea?.focus(), 0)
}
const handlePointerDown = () => {
const activeElement = document.activeElement
if (activeElement instanceof HTMLElement && activeElement !== container) {
if (activeElement instanceof HTMLElement && activeElement !== container && !container.contains(activeElement)) {
activeElement.blur()
}
focusTerminal()
@@ -204,44 +205,32 @@ export const Terminal = (props: TerminalProps) => {
ghostty = g
term = t
const copy = () => {
const handleCopy = (event: ClipboardEvent) => {
const selection = t.getSelection()
if (!selection) return false
if (!selection) return
const body = document.body
if (body) {
const textarea = document.createElement("textarea")
textarea.value = selection
textarea.setAttribute("readonly", "")
textarea.style.position = "fixed"
textarea.style.opacity = "0"
body.appendChild(textarea)
textarea.select()
const copied = document.execCommand("copy")
body.removeChild(textarea)
if (copied) return true
}
const clipboard = event.clipboardData
if (!clipboard) return
const clipboard = navigator.clipboard
if (clipboard?.writeText) {
clipboard.writeText(selection).catch(() => {})
return true
}
event.preventDefault()
clipboard.setData("text/plain", selection)
}
return false
const handlePaste = (event: ClipboardEvent) => {
const clipboard = event.clipboardData
const text = clipboard?.getData("text/plain") ?? clipboard?.getData("text") ?? ""
if (!text) return
event.preventDefault()
event.stopPropagation()
t.paste(text)
}
t.attachCustomKeyEventHandler((event) => {
const key = event.key.toLowerCase()
if (event.ctrlKey && event.shiftKey && !event.metaKey && key === "c") {
copy()
return true
}
if (event.metaKey && !event.ctrlKey && !event.altKey && key === "c") {
if (!t.hasSelection()) return true
copy()
document.execCommand("copy")
return true
}
@@ -252,6 +241,12 @@ export const Terminal = (props: TerminalProps) => {
return matchKeybind(keybinds, event)
})
container.addEventListener("copy", handleCopy, true)
cleanups.push(() => container.removeEventListener("copy", handleCopy, true))
container.addEventListener("paste", handlePaste, true)
cleanups.push(() => container.removeEventListener("paste", handlePaste, true))
const fit = new mod.FitAddon()
const serializer = new SerializeAddon()
cleanups.push(() => disposeIfDisposable(fit))
+6
View File
@@ -57,6 +57,12 @@ export type Platform = {
/** Set the default server URL to use on app startup (platform-specific) */
setDefaultServerUrl?(url: string | null): Promise<void> | void
/** Get the configured WSL integration (desktop only) */
getWslEnabled?(): Promise<boolean>
/** Set the configured WSL integration (desktop only) */
setWslEnabled?(config: boolean): Promise<void> | void
/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
+3
View File
@@ -508,6 +508,9 @@ export const dict = {
"settings.section.server": "الخادم",
"settings.tab.general": "عام",
"settings.tab.shortcuts": "اختصارات",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "المظهر",
"settings.general.section.notifications": "إشعارات النظام",
+3
View File
@@ -512,6 +512,9 @@ export const dict = {
"settings.section.server": "Servidor",
"settings.tab.general": "Geral",
"settings.tab.shortcuts": "Atalhos",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Aparência",
"settings.general.section.notifications": "Notificações do sistema",
+3
View File
@@ -539,6 +539,9 @@ export const dict = {
"settings.section.server": "Server",
"settings.tab.general": "Opšte",
"settings.tab.shortcuts": "Prečice",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Izgled",
"settings.general.section.notifications": "Sistemske obavijesti",
+3
View File
@@ -512,6 +512,9 @@ export const dict = {
"settings.section.server": "Server",
"settings.tab.general": "Generelt",
"settings.tab.shortcuts": "Genveje",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Udseende",
"settings.general.section.notifications": "Systemmeddelelser",
+3
View File
@@ -556,6 +556,9 @@ export const dict = {
"settings.section.server": "Server",
"settings.tab.general": "Allgemein",
"settings.tab.shortcuts": "Tastenkombinationen",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Erscheinungsbild",
"settings.general.section.notifications": "Systembenachrichtigungen",
+3
View File
@@ -583,6 +583,9 @@ export const dict = {
"settings.section.server": "Server",
"settings.tab.general": "General",
"settings.tab.shortcuts": "Shortcuts",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Appearance",
"settings.general.section.notifications": "System notifications",
+3
View File
@@ -515,6 +515,9 @@ export const dict = {
"settings.section.server": "Servidor",
"settings.tab.general": "General",
"settings.tab.shortcuts": "Atajos",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Apariencia",
"settings.general.section.notifications": "Notificaciones del sistema",
+3
View File
@@ -522,6 +522,9 @@ export const dict = {
"settings.section.server": "Serveur",
"settings.tab.general": "Général",
"settings.tab.shortcuts": "Raccourcis",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Apparence",
"settings.general.section.notifications": "Notifications système",
+3
View File
@@ -507,6 +507,9 @@ export const dict = {
"settings.section.server": "サーバー",
"settings.tab.general": "一般",
"settings.tab.shortcuts": "ショートカット",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "外観",
"settings.general.section.notifications": "システム通知",
+3
View File
@@ -513,6 +513,9 @@ export const dict = {
"settings.section.server": "서버",
"settings.tab.general": "일반",
"settings.tab.shortcuts": "단축키",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "모양",
"settings.general.section.notifications": "시스템 알림",
+3
View File
@@ -515,6 +515,9 @@ export const dict = {
"settings.section.server": "Server",
"settings.tab.general": "Generelt",
"settings.tab.shortcuts": "Snarveier",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Utseende",
"settings.general.section.notifications": "Systemvarsler",
+3
View File
@@ -514,6 +514,9 @@ export const dict = {
"settings.section.server": "Serwer",
"settings.tab.general": "Ogólne",
"settings.tab.shortcuts": "Skróty",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Wygląd",
"settings.general.section.notifications": "Powiadomienia systemowe",
+3
View File
@@ -517,6 +517,9 @@ export const dict = {
"settings.section.server": "Сервер",
"settings.tab.general": "Основные",
"settings.tab.shortcuts": "Горячие клавиши",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "Внешний вид",
"settings.general.section.notifications": "Системные уведомления",
+3
View File
@@ -516,6 +516,9 @@ export const dict = {
"settings.section.server": "เซิร์ฟเวอร์",
"settings.tab.general": "ทั่วไป",
"settings.tab.shortcuts": "ทางลัด",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "รูปลักษณ์",
"settings.general.section.notifications": "การแจ้งเตือนระบบ",
+3
View File
@@ -548,6 +548,9 @@ export const dict = {
"settings.section.server": "服务器",
"settings.tab.general": "通用",
"settings.tab.shortcuts": "快捷键",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "外观",
"settings.general.section.notifications": "系统通知",
+3
View File
@@ -545,6 +545,9 @@ export const dict = {
"settings.section.server": "伺服器",
"settings.tab.general": "一般",
"settings.tab.shortcuts": "快速鍵",
"settings.desktop.section.wsl": "WSL",
"settings.desktop.wsl.title": "WSL integration",
"settings.desktop.wsl.description": "Run the OpenCode server inside WSL on Windows.",
"settings.general.section.appearance": "外觀",
"settings.general.section.notifications": "系統通知",
@@ -54,6 +54,13 @@ export default function Layout(props: ParentProps) {
navigate(`/${params.dir}/session/${sessionID}`)
}
const sessionHref = (sessionID: string) => {
if (params.dir) return `/${params.dir}/session/${sessionID}`
return `/session/${sessionID}`
}
const syncSession = (sessionID: string) => sync.session.sync(sessionID)
return (
<DataProvider
data={sync.data}
@@ -62,6 +69,8 @@ export default function Layout(props: ParentProps) {
onQuestionReply={replyToQuestion}
onQuestionReject={rejectQuestion}
onNavigateToSession={navigateToSession}
onSessionHref={sessionHref}
onSyncSession={syncSession}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
-14
View File
@@ -181,20 +181,6 @@ export default function Layout(props: ParentProps) {
aim.reset()
})
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
() => {
if (layout.sidebar.opened()) return
if (!state.hoverProject) return
aim.reset()
setState("hoverSession", undefined)
setState("hoverProject", undefined)
},
{ defer: true },
),
)
const autoselecting = createMemo(() => {
if (params.dir) return false
if (!state.autoselect) return false
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.1.55",
"version": "1.1.57",
"type": "module",
"license": "MIT",
"scripts": {
+4 -4
View File
@@ -9,8 +9,8 @@ export const config = {
github: {
repoUrl: "https://github.com/anomalyco/opencode",
starsFormatted: {
compact: "95K",
full: "95,000",
compact: "100K",
full: "100,000",
},
},
@@ -22,8 +22,8 @@ export const config = {
// Static stats (used on landing page)
stats: {
contributors: "650",
commits: "8,500",
contributors: "700",
commits: "9,000",
monthlyUsers: "2.5M",
},
} as const
@@ -3,11 +3,13 @@ export class CreditsError extends Error {}
export class MonthlyLimitError extends Error {}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class FreeUsageLimitError extends Error {}
export class SubscriptionUsageLimitError extends Error {
class LimitError extends Error {
retryAfter?: number
constructor(message: string, retryAfter?: number) {
super(message)
this.retryAfter = retryAfter
}
}
export class FreeUsageLimitError extends LimitError {}
export class SubscriptionUsageLimitError extends LimitError {}
@@ -134,20 +134,26 @@ export async function handler(
body: reqBody,
})
// Try another provider => stop retrying if using fallback provider
if (
res.status !== 200 &&
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 &&
// ie. cannot change codex model providers mid-session
modelInfo.stickyProvider !== "strict" &&
modelInfo.fallbackProvider &&
providerInfo.id !== modelInfo.fallbackProvider
) {
return retriableRequest({
excludeProviders: [...retry.excludeProviders, providerInfo.id],
retryCount: retry.retryCount + 1,
if (res.status !== 200) {
logger.metric({
"llm.error.code": res.status,
"llm.error.message": res.statusText,
})
// Try another provider => stop retrying if using fallback provider
if (
// ie. openai 404 error: Item with id 'msg_0ead8b004a3b165d0069436a6b6834819896da85b63b196a3f' not found.
res.status !== 404 &&
// ie. cannot change codex model providers mid-session
modelInfo.stickyProvider !== "strict" &&
modelInfo.fallbackProvider &&
providerInfo.id !== modelInfo.fallbackProvider
) {
return retriableRequest({
excludeProviders: [...retry.excludeProviders, providerInfo.id],
retryCount: retry.retryCount + 1,
})
}
}
return { providerInfo, reqBody, res, startTimestamp }
@@ -307,7 +313,7 @@ export async function handler(
if (error instanceof FreeUsageLimitError || error instanceof SubscriptionUsageLimitError) {
const headers = new Headers()
if (error instanceof SubscriptionUsageLimitError && error.retryAfter) {
if (error.retryAfter) {
headers.set("retry-after", String(error.retryAfter))
}
return new Response(
@@ -28,17 +28,46 @@ export function createRateLimiter(limit: ZenData.RateLimit | undefined, rawIp: s
check: async () => {
const rows = await Database.use((tx) =>
tx
.select({ count: IpRateLimitTable.count })
.select({ interval: IpRateLimitTable.interval, count: IpRateLimitTable.count })
.from(IpRateLimitTable)
.where(and(eq(IpRateLimitTable.ip, ip), inArray(IpRateLimitTable.interval, intervals))),
)
const total = rows.reduce((sum, r) => sum + r.count, 0)
logger.debug(`rate limit total: ${total}`)
if (total >= limitValue) throw new FreeUsageLimitError(`Rate limit exceeded. Please try again later.`)
if (total >= limitValue)
throw new FreeUsageLimitError(
`Rate limit exceeded. Please try again later.`,
limit.period === "day" ? getRetryAfterDay(now) : getRetryAfterHour(rows, intervals, limitValue, now),
)
},
}
}
export function getRetryAfterDay(now: number) {
return Math.ceil((86_400_000 - (now % 86_400_000)) / 1000)
}
export function getRetryAfterHour(
rows: { interval: string; count: number }[],
intervals: string[],
limit: number,
now: number,
) {
const counts = new Map(rows.map((r) => [r.interval, r.count]))
// intervals are ordered newest to oldest: [current, -1h, -2h]
// simulate dropping oldest intervals one at a time
let running = intervals.reduce((sum, i) => sum + (counts.get(i) ?? 0), 0)
for (let i = intervals.length - 1; i >= 0; i--) {
running -= counts.get(intervals[i]) ?? 0
if (running < limit) {
// interval at index i rolls out of the window (intervals.length - i) hours from the current hour start
const hours = intervals.length - i
return Math.ceil((hours * 3_600_000 - (now % 3_600_000)) / 1000)
}
}
return Math.ceil((3_600_000 - (now % 3_600_000)) / 1000)
}
function buildYYYYMMDD(timestamp: number) {
return new Date(timestamp)
.toISOString()
@@ -0,0 +1,92 @@
import { describe, expect, test } from "bun:test"
import { getRetryAfterDay, getRetryAfterHour } from "../src/routes/zen/util/rateLimiter"
describe("getRetryAfterDay", () => {
test("returns full day at midnight UTC", () => {
const midnight = Date.UTC(2026, 0, 15, 0, 0, 0, 0)
expect(getRetryAfterDay(midnight)).toBe(86_400)
})
test("returns remaining seconds until next UTC day", () => {
const noon = Date.UTC(2026, 0, 15, 12, 0, 0, 0)
expect(getRetryAfterDay(noon)).toBe(43_200)
})
test("rounds up to nearest second", () => {
const almost = Date.UTC(2026, 0, 15, 23, 59, 59, 500)
expect(getRetryAfterDay(almost)).toBe(1)
})
})
describe("getRetryAfterHour", () => {
// 14:30:00 UTC — 30 minutes into the current hour
const now = Date.UTC(2026, 0, 15, 14, 30, 0, 0)
const intervals = ["2026011514", "2026011513", "2026011512"]
test("waits 3 hours when all usage is in current hour", () => {
const rows = [{ interval: "2026011514", count: 10 }]
// only current hour has usage — it won't leave the window for 3 hours from hour start
// 3 * 3600 - 1800 = 9000s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(9000)
})
test("waits 1 hour when dropping oldest interval is sufficient", () => {
const rows = [
{ interval: "2026011514", count: 2 },
{ interval: "2026011512", count: 10 },
]
// total=12, drop oldest (-2h, count=10) -> 2 < 10
// hours = 3 - 2 = 1 -> 1 * 3600 - 1800 = 1800s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
})
test("waits 2 hours when usage spans oldest two intervals", () => {
const rows = [
{ interval: "2026011513", count: 8 },
{ interval: "2026011512", count: 5 },
]
// total=13, drop -2h (5) -> 8, 8 >= 8, drop -1h (8) -> 0 < 8
// hours = 3 - 1 = 2 -> 2 * 3600 - 1800 = 5400s
expect(getRetryAfterHour(rows, intervals, 8, now)).toBe(5400)
})
test("waits 1 hour when oldest interval alone pushes over limit", () => {
const rows = [
{ interval: "2026011514", count: 1 },
{ interval: "2026011513", count: 1 },
{ interval: "2026011512", count: 10 },
]
// total=12, drop -2h (10) -> 2 < 10
// hours = 3 - 2 = 1 -> 1800s
expect(getRetryAfterHour(rows, intervals, 10, now)).toBe(1800)
})
test("waits 2 hours when middle interval keeps total over limit", () => {
const rows = [
{ interval: "2026011514", count: 4 },
{ interval: "2026011513", count: 4 },
{ interval: "2026011512", count: 4 },
]
// total=12, drop -2h (4) -> 8, 8 >= 5, drop -1h (4) -> 4 < 5
// hours = 3 - 1 = 2 -> 5400s
expect(getRetryAfterHour(rows, intervals, 5, now)).toBe(5400)
})
test("rounds up to nearest second", () => {
const offset = Date.UTC(2026, 0, 15, 14, 30, 0, 500)
const rows = [
{ interval: "2026011514", count: 2 },
{ interval: "2026011512", count: 10 },
]
// hours=1 -> 3_600_000 - 1_800_500 = 1_799_500ms -> ceil(1799.5) = 1800
expect(getRetryAfterHour(rows, intervals, 10, offset)).toBe(1800)
})
test("fallback returns time until next hour when rows are empty", () => {
// edge case: rows empty but function called (shouldn't happen in practice)
// loop drops all zeros, running stays 0 which is < any positive limit on first iteration
const rows: { interval: string; count: number }[] = []
// drop -2h (0) -> 0 < 1 -> hours = 3 - 2 = 1 -> 1800s
expect(getRetryAfterHour(rows, intervals, 1, now)).toBe(1800)
})
})
+2 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.1.55",
"version": "1.1.57",
"private": true,
"type": "module",
"license": "MIT",
@@ -19,6 +19,7 @@
"zod": "catalog:"
},
"exports": {
"./*.js": "./src/*.ts",
"./*": "./src/*"
},
"scripts": {
+18 -4
View File
@@ -53,8 +53,6 @@ export namespace ZenData {
weight: z.number().optional(),
disabled: z.boolean().optional(),
storeModel: z.string().optional(),
headers: z.record(z.string(), z.string()).optional(),
bodyModifier: z.record(z.string(), z.string()).optional(),
}),
),
})
@@ -62,13 +60,20 @@ export namespace ZenData {
const ProviderSchema = z.object({
api: z.string(),
apiKey: z.string(),
format: FormatSchema,
format: FormatSchema.optional(),
headerMappings: z.record(z.string(), z.string()).optional(),
family: z.string().optional(),
})
const ProviderFamilySchema = z.object({
headers: z.record(z.string(), z.string()).optional(),
bodyModifier: z.record(z.string(), z.string()).optional(),
})
const ModelsSchema = z.object({
models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
providers: z.record(z.string(), ProviderSchema),
providerFamilies: z.record(z.string(), ProviderFamilySchema),
})
export const validate = fn(ModelsSchema, (input) => {
@@ -98,7 +103,16 @@ export namespace ZenData {
Resource.ZEN_MODELS19.value +
Resource.ZEN_MODELS20.value,
)
return ModelsSchema.parse(json)
const { models, providers, providerFamilies } = ModelsSchema.parse(json)
return {
models,
providers: Object.fromEntries(
Object.entries(providers).map(([id, provider]) => [
id,
{ ...provider, ...(provider.family ? providerFamilies[provider.family] : {}) },
]),
),
}
})
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.1.55",
"version": "1.1.57",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+12 -7
View File
@@ -17,8 +17,7 @@ export default {
)
return
let metrics = {
event_type: "completions",
let data = {
"cf.continent": event.event.request.cf?.continent,
"cf.country": event.event.request.cf?.country,
"cf.city": event.event.request.cf?.city,
@@ -31,22 +30,28 @@ export default {
status: event.event.response?.status ?? 0,
ip: event.event.request.headers["x-real-ip"],
}
const time = new Date(event.eventTimestamp ?? Date.now()).toISOString()
const events = []
for (const log of event.logs) {
for (const message of log.message) {
if (!message.startsWith("_metric:")) continue
metrics = { ...metrics, ...JSON.parse(message.slice(8)) }
const json = JSON.parse(message.slice(8))
data = { ...data, ...json }
if ("llm.error.code" in json) {
events.push({ time, data: { ...data, event_type: "llm.error" } })
}
}
}
console.log(JSON.stringify(metrics, null, 2))
events.push({ time, data: { ...data, event_type: "completions" } })
console.log(JSON.stringify(data, null, 2))
const ret = await fetch("https://api.honeycomb.io/1/events/zen", {
const ret = await fetch("https://api.honeycomb.io/1/batch/zen", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Honeycomb-Event-Time": (event.eventTimestamp ?? Date.now()).toString(),
"X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value,
},
body: JSON.stringify(metrics),
body: JSON.stringify(events),
})
console.log(ret.status)
console.log(await ret.text())
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.1.55",
"version": "1.1.57",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+4
View File
@@ -0,0 +1,4 @@
# Desktop package notes
- Never call `invoke` manually in this package.
- Use the generated bindings in `packages/desktop/src/bindings.ts` for core commands/events.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.1.55",
"version": "1.1.57",
"type": "module",
"license": "MIT",
"scripts": {
+97 -7
View File
@@ -535,8 +535,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
]
@@ -2491,6 +2493,15 @@ dependencies = [
"syn 2.0.110",
]
[[package]]
name = "matchers"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
dependencies = [
"regex-automata",
]
[[package]]
name = "matches"
version = "0.1.10"
@@ -2691,6 +2702,15 @@ dependencies = [
"zbus",
]
[[package]]
name = "nu-ansi-term"
version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "num-conv"
version = "0.1.0"
@@ -3065,6 +3085,7 @@ dependencies = [
name = "opencode-desktop"
version = "0.0.0"
dependencies = [
"chrono",
"comrak",
"dirs",
"futures",
@@ -3096,6 +3117,9 @@ dependencies = [
"tauri-plugin-window-state",
"tauri-specta",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"uuid",
"webkit2gtk",
"windows 0.61.3",
@@ -4412,6 +4436,15 @@ dependencies = [
"digest",
]
[[package]]
name = "sharded-slab"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
dependencies = [
"lazy_static",
]
[[package]]
name = "shared_child"
version = "1.1.1"
@@ -5472,6 +5505,15 @@ dependencies = [
"syn 2.0.110",
]
[[package]]
name = "thread_local"
version = "1.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
dependencies = [
"cfg-if",
]
[[package]]
name = "tiff"
version = "0.10.3"
@@ -5745,9 +5787,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.41"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
@@ -5755,10 +5797,22 @@ dependencies = [
]
[[package]]
name = "tracing-attributes"
version = "0.1.30"
name = "tracing-appender"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903"
checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
dependencies = [
"crossbeam-channel",
"thiserror 2.0.17",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
@@ -5767,11 +5821,41 @@ dependencies = [
[[package]]
name = "tracing-core"
version = "0.1.34"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
"valuable",
]
[[package]]
name = "tracing-log"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
dependencies = [
"log",
"once_cell",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
dependencies = [
"matchers",
"nu-ansi-term",
"once_cell",
"regex-automata",
"sharded-slab",
"smallvec",
"thread_local",
"tracing",
"tracing-core",
"tracing-log",
]
[[package]]
@@ -5964,6 +6048,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "valuable"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "version-compare"
version = "0.2.1"
+4
View File
@@ -47,6 +47,10 @@ specta = "=2.0.0-rc.22"
specta-typescript = "0.0.9"
tauri-specta = { version = "=2.0.0-rc.21", features = ["derive", "typescript"] }
dirs = "6.0.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
tracing-appender = "0.2"
chrono = "0.4"
[target.'cfg(target_os = "linux")'.dependencies]
gtk = "0.18.2"
+149 -60
View File
@@ -1,10 +1,12 @@
use tauri::{AppHandle, Manager, path::BaseDirectory};
use tauri_plugin_shell::{
ShellExt,
process::{Command, CommandChild, CommandEvent},
process::{Command, CommandChild, CommandEvent, TerminatedPayload},
};
use tauri_plugin_store::StoreExt;
use tokio::sync::oneshot;
use crate::{LogState, constants::MAX_LOG_ENTRIES};
use crate::constants::{SETTINGS_STORE, WSL_ENABLED_KEY};
const CLI_INSTALL_DIR: &str = ".opencode/bin";
const CLI_BINARY_NAME: &str = "opencode";
@@ -21,10 +23,10 @@ pub struct Config {
}
pub async fn get_config(app: &AppHandle) -> Option<Config> {
create_command(app, "debug config")
create_command(app, "debug config", &[])
.output()
.await
.inspect_err(|e| eprintln!("Failed to read OC config: {e}"))
.inspect_err(|e| tracing::warn!("Failed to read OC config: {e}"))
.ok()
.and_then(|out| String::from_utf8(out.stdout.to_vec()).ok())
.and_then(|s| serde_json::from_str::<Config>(&s).ok())
@@ -99,12 +101,12 @@ pub fn install_cli(app: tauri::AppHandle) -> Result<String, String> {
pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
if cfg!(debug_assertions) {
println!("Skipping CLI sync for debug build");
tracing::debug!("Skipping CLI sync for debug build");
return Ok(());
}
if !is_cli_installed() {
println!("No CLI installation found, skipping sync");
tracing::info!("No CLI installation found, skipping sync");
return Ok(());
}
@@ -127,21 +129,21 @@ pub fn sync_cli(app: tauri::AppHandle) -> Result<(), String> {
let app_version = app.package_info().version.clone();
if cli_version >= app_version {
println!(
"CLI version {} is up to date (app version: {}), skipping sync",
cli_version, app_version
tracing::info!(
%cli_version, %app_version,
"CLI is up to date, skipping sync"
);
return Ok(());
}
println!(
"CLI version {} is older than app version {}, syncing",
cli_version, app_version
tracing::info!(
%cli_version, %app_version,
"CLI is older than app version, syncing"
);
install_cli(app)?;
println!("Synced installed CLI");
tracing::info!("Synced installed CLI");
Ok(())
}
@@ -150,25 +152,106 @@ fn get_user_shell() -> String {
std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string())
}
pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
fn is_wsl_enabled(app: &tauri::AppHandle) -> bool {
let Ok(store) = app.store(SETTINGS_STORE) else {
return false;
};
store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|value| value.as_bool())
.unwrap_or(false)
}
fn shell_escape(input: &str) -> String {
if input.is_empty() {
return "''".to_string();
}
let mut escaped = String::from("'");
escaped.push_str(&input.replace("'", "'\"'\"'"));
escaped.push('\'');
escaped
}
pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, String)]) -> Command {
let state_dir = app
.path()
.resolve("", BaseDirectory::AppLocalData)
.expect("Failed to resolve app local data dir");
#[cfg(target_os = "windows")]
return app
.shell()
.sidecar("opencode-cli")
.unwrap()
.args(args.split_whitespace())
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir);
let mut envs = vec![
(
"OPENCODE_EXPERIMENTAL_ICON_DISCOVERY".to_string(),
"true".to_string(),
),
(
"OPENCODE_EXPERIMENTAL_FILEWATCHER".to_string(),
"true".to_string(),
),
("OPENCODE_CLIENT".to_string(), "desktop".to_string()),
(
"XDG_STATE_HOME".to_string(),
state_dir.to_string_lossy().to_string(),
),
];
envs.extend(
extra_env
.iter()
.map(|(key, value)| (key.to_string(), value.clone())),
);
#[cfg(not(target_os = "windows"))]
return {
if cfg!(windows) {
if is_wsl_enabled(app) {
tracing::info!("WSL is enabled, spawning CLI server in WSL");
let version = app.package_info().version.to_string();
let mut script = vec![
"set -e".to_string(),
"BIN=\"$HOME/.opencode/bin/opencode\"".to_string(),
"if [ ! -x \"$BIN\" ]; then".to_string(),
format!(
" curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path",
shell_escape(&version)
),
"fi".to_string(),
];
let mut env_prefix = vec![
"OPENCODE_EXPERIMENTAL_ICON_DISCOVERY=true".to_string(),
"OPENCODE_EXPERIMENTAL_FILEWATCHER=true".to_string(),
"OPENCODE_CLIENT=desktop".to_string(),
"XDG_STATE_HOME=\"$HOME/.local/state\"".to_string(),
];
env_prefix.extend(
envs.iter()
.filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_ICON_DISCOVERY")
.filter(|(key, _)| key != "OPENCODE_EXPERIMENTAL_FILEWATCHER")
.filter(|(key, _)| key != "OPENCODE_CLIENT")
.filter(|(key, _)| key != "XDG_STATE_HOME")
.map(|(key, value)| format!("{}={}", key, shell_escape(value))),
);
script.push(format!("{} exec \"$BIN\" {}", env_prefix.join(" "), args));
return app
.shell()
.command("wsl")
.args(["-e", "bash", "-lc", &script.join("\n")]);
} else {
let mut cmd = app
.shell()
.sidecar("opencode-cli")
.unwrap()
.args(args.split_whitespace());
for (key, value) in envs {
cmd = cmd.env(key, value);
}
return cmd;
}
} else {
let sidecar = get_sidecar_path(app);
let shell = get_user_shell();
@@ -178,58 +261,64 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
format!("\"{}\" {}", sidecar.display(), args)
};
app.shell()
.command(&shell)
.env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true")
.env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true")
.env("OPENCODE_CLIENT", "desktop")
.env("XDG_STATE_HOME", &state_dir)
.args(["-il", "-c", &cmd])
};
let mut cmd = app.shell().command(&shell).args(["-il", "-c", &cmd]);
for (key, value) in envs {
cmd = cmd.env(key, value);
}
cmd
}
}
pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> CommandChild {
let log_state = app.state::<LogState>();
let log_state_clone = log_state.inner().clone();
pub fn serve(
app: &AppHandle,
hostname: &str,
port: u32,
password: &str,
) -> (CommandChild, oneshot::Receiver<TerminatedPayload>) {
let (exit_tx, exit_rx) = oneshot::channel::<TerminatedPayload>();
println!("spawning sidecar on port {port}");
tracing::info!(port, "Spawning sidecar");
let envs = [
("OPENCODE_SERVER_USERNAME", "opencode".to_string()),
("OPENCODE_SERVER_PASSWORD", password.to_string()),
];
let (mut rx, child) = create_command(
app,
format!("serve --hostname {hostname} --port {port}").as_str(),
format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(),
&envs,
)
.env("OPENCODE_SERVER_USERNAME", "opencode")
.env("OPENCODE_SERVER_PASSWORD", password)
.spawn()
.expect("Failed to spawn opencode");
tokio::spawn(async move {
let mut exit_tx = Some(exit_tx);
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line_bytes) => {
let line = String::from_utf8_lossy(&line_bytes);
print!("{line}");
// Store log in shared state
if let Ok(mut logs) = log_state_clone.0.lock() {
logs.push_back(format!("[STDOUT] {}", line));
// Keep only the last MAX_LOG_ENTRIES
while logs.len() > MAX_LOG_ENTRIES {
logs.pop_front();
}
}
tracing::info!(target: "sidecar", "{line}");
}
CommandEvent::Stderr(line_bytes) => {
let line = String::from_utf8_lossy(&line_bytes);
eprint!("{line}");
tracing::info!(target: "sidecar", "{line}");
}
CommandEvent::Error(err) => {
tracing::error!(target: "sidecar", "{err}");
}
CommandEvent::Terminated(payload) => {
tracing::info!(
target: "sidecar",
code = ?payload.code,
signal = ?payload.signal,
"Sidecar terminated"
);
// Store log in shared state
if let Ok(mut logs) = log_state_clone.0.lock() {
logs.push_back(format!("[STDERR] {}", line));
// Keep only the last MAX_LOG_ENTRIES
while logs.len() > MAX_LOG_ENTRIES {
logs.pop_front();
}
if let Some(tx) = exit_tx.take() {
let _ = tx.send(payload);
}
}
_ => {}
@@ -237,5 +326,5 @@ pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> Comm
}
});
child
(child, exit_rx)
}
+1 -1
View File
@@ -2,8 +2,8 @@ use tauri_plugin_window_state::StateFlags;
pub const SETTINGS_STORE: &str = "opencode.settings.dat";
pub const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl";
pub const WSL_ENABLED_KEY: &str = "wslEnabled";
pub const UPDATER_ENABLED: bool = option_env!("TAURI_SIGNING_PRIVATE_KEY").is_some();
pub const MAX_LOG_ENTRIES: usize = 200;
pub fn window_state_flags() -> StateFlags {
StateFlags::all() - StateFlags::DECORATIONS - StateFlags::VISIBLE
+6 -6
View File
@@ -15,9 +15,9 @@ use std::io::{Error, Result};
use std::sync::Mutex;
use windows::Win32::Foundation::{CloseHandle, HANDLE};
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation,
SetInformationJobObject,
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
use windows::Win32::System::Threading::{OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE};
@@ -111,7 +111,7 @@ impl JobObjectState {
error: Mutex::new(None),
},
Err(e) => {
eprintln!("Failed to create job object: {e}");
tracing::error!("Failed to create job object: {e}");
Self {
job: Mutex::new(None),
error: Mutex::new(Some(format!("Failed to create job object: {e}"))),
@@ -123,11 +123,11 @@ impl JobObjectState {
pub fn assign_pid(&self, pid: u32) {
if let Some(job) = self.job.lock().unwrap().as_ref() {
if let Err(e) = job.assign_pid(pid) {
eprintln!("Failed to assign process {pid} to job object: {e}");
tracing::error!(pid, "Failed to assign process to job object: {e}");
*self.error.lock().unwrap() =
Some(format!("Failed to assign process to job object: {e}"));
} else {
println!("Assigned process {pid} to job object for automatic cleanup");
tracing::info!(pid, "Assigned process to job object for automatic cleanup");
}
}
}
+534 -76
View File
@@ -4,6 +4,7 @@ mod constants;
mod job_object;
#[cfg(target_os = "linux")]
pub mod linux_display;
mod logging;
mod markdown;
mod server;
mod window_customizer;
@@ -16,13 +17,12 @@ use futures::{
#[cfg(windows)]
use job_object::*;
use std::{
collections::VecDeque,
env,
net::TcpListener,
path::PathBuf,
process::Command,
sync::{Arc, Mutex},
time::Duration,
process::Command,
};
use tauri::{AppHandle, Manager, RunEvent, State, ipc::Channel};
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
@@ -52,6 +52,13 @@ enum InitStep {
Done,
}
#[derive(serde::Deserialize, specta::Type)]
#[serde(rename_all = "snake_case")]
enum WslPathMode {
Windows,
Linux,
}
struct InitState {
current: watch::Receiver<InitStep>,
}
@@ -78,14 +85,11 @@ impl ServerState {
}
}
#[derive(Clone)]
struct LogState(Arc<Mutex<VecDeque<String>>>);
#[tauri::command]
#[specta::specta]
fn kill_sidecar(app: AppHandle) {
let Some(server_state) = app.try_state::<ServerState>() else {
println!("Server not running");
tracing::info!("Server not running");
return;
};
@@ -95,24 +99,17 @@ fn kill_sidecar(app: AppHandle) {
.expect("Failed to acquire mutex lock")
.take()
else {
println!("Server state missing");
tracing::info!("Server state missing");
return;
};
let _ = server_state.kill();
println!("Killed server");
tracing::info!("Killed server");
}
async fn get_logs(app: AppHandle) -> Result<String, String> {
let log_state = app.try_state::<LogState>().ok_or("Log state not found")?;
let logs = log_state
.0
.lock()
.map_err(|_| "Failed to acquire log lock")?;
Ok(logs.iter().cloned().collect::<Vec<_>>().join(""))
fn get_logs() -> String {
logging::tail()
}
#[tauri::command]
@@ -152,12 +149,12 @@ fn check_app_exists(app_name: &str) -> bool {
{
check_windows_app(app_name)
}
#[cfg(target_os = "macos")]
{
check_macos_app(app_name)
}
#[cfg(target_os = "linux")]
{
check_linux_app(app_name)
@@ -166,8 +163,400 @@ fn check_app_exists(app_name: &str) -> bool {
#[cfg(target_os = "windows")]
fn check_windows_app(app_name: &str) -> bool {
// Check if command exists in PATH, including .exe
return true;
resolve_windows_app_path(app_name).is_some()
}
#[cfg(target_os = "windows")]
fn resolve_windows_app_path(app_name: &str) -> Option<String> {
use std::path::{Path, PathBuf};
fn expand_env(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut index = 0;
while let Some(start) = value[index..].find('%') {
let start = index + start;
out.push_str(&value[index..start]);
let Some(end_rel) = value[start + 1..].find('%') else {
out.push_str(&value[start..]);
return out;
};
let end = start + 1 + end_rel;
let key = &value[start + 1..end];
if key.is_empty() {
out.push('%');
index = end + 1;
continue;
}
if let Ok(v) = std::env::var(key) {
out.push_str(&v);
index = end + 1;
continue;
}
out.push_str(&value[start..=end]);
index = end + 1;
}
out.push_str(&value[index..]);
out
}
fn extract_exe(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
return None;
}
if let Some(rest) = value.strip_prefix('"') {
if let Some(end) = rest.find('"') {
let inner = rest[..end].trim();
if inner.to_ascii_lowercase().contains(".exe") {
return Some(inner.to_string());
}
}
}
let lower = value.to_ascii_lowercase();
let end = lower.find(".exe")?;
Some(value[..end + 4].trim().trim_matches('"').to_string())
}
fn candidates(app_name: &str) -> Vec<String> {
let app_name = app_name.trim().trim_matches('"');
if app_name.is_empty() {
return vec![];
}
let mut out = Vec::<String>::new();
let mut push = |value: String| {
let value = value.trim().trim_matches('"').to_string();
if value.is_empty() {
return;
}
if out.iter().any(|v| v.eq_ignore_ascii_case(&value)) {
return;
}
out.push(value);
};
push(app_name.to_string());
let lower = app_name.to_ascii_lowercase();
if !lower.ends_with(".exe") {
push(format!("{app_name}.exe"));
}
let snake = {
let mut s = String::new();
let mut underscore = false;
for c in lower.chars() {
if c.is_ascii_alphanumeric() {
s.push(c);
underscore = false;
continue;
}
if underscore {
continue;
}
s.push('_');
underscore = true;
}
s.trim_matches('_').to_string()
};
if !snake.is_empty() {
push(snake.clone());
if !snake.ends_with(".exe") {
push(format!("{snake}.exe"));
}
}
let alnum = lower
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.collect::<String>();
if !alnum.is_empty() {
push(alnum.clone());
push(format!("{alnum}.exe"));
}
match lower.as_str() {
"sublime text" | "sublime-text" | "sublime_text" | "sublime text.exe" => {
push("subl".to_string());
push("subl.exe".to_string());
push("sublime_text".to_string());
push("sublime_text.exe".to_string());
}
_ => {}
}
out
}
fn reg_app_path(exe: &str) -> Option<String> {
let exe = exe.trim().trim_matches('"');
if exe.is_empty() {
return None;
}
let keys = [
format!(
r"HKCU\Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"
),
format!(
r"HKLM\Software\Microsoft\Windows\CurrentVersion\App Paths\{exe}"
),
format!(
r"HKLM\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\App Paths\{exe}"
),
];
for key in keys {
let Some(output) = Command::new("reg")
.args(["query", &key, "/ve"])
.output()
.ok()
else {
continue;
};
if !output.status.success() {
continue;
}
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let tokens = line.split_whitespace().collect::<Vec<_>>();
let Some(index) = tokens.iter().position(|v| v.starts_with("REG_")) else {
continue;
};
let value = tokens[index + 1..].join(" ");
let Some(exe) = extract_exe(&value) else {
continue;
};
let exe = expand_env(&exe);
let path = Path::new(exe.trim().trim_matches('"'));
if path.exists() {
return Some(path.to_string_lossy().to_string());
}
}
}
None
}
let app_name = app_name.trim().trim_matches('"');
if app_name.is_empty() {
return None;
}
let direct = Path::new(app_name);
if direct.is_absolute() && direct.exists() {
return Some(direct.to_string_lossy().to_string());
}
let key = app_name
.chars()
.filter(|v| v.is_ascii_alphanumeric())
.flat_map(|v| v.to_lowercase())
.collect::<String>();
let has_ext = |path: &Path, ext: &str| {
path.extension()
.and_then(|v| v.to_str())
.map(|v| v.eq_ignore_ascii_case(ext))
.unwrap_or(false)
};
let resolve_cmd = |path: &Path| -> Option<String> {
let bytes = std::fs::read(path).ok()?;
let content = String::from_utf8_lossy(&bytes);
for token in content.split('"') {
let Some(exe) = extract_exe(token) else {
continue;
};
let lower = exe.to_ascii_lowercase();
if let Some(index) = lower.find("%~dp0") {
let base = path.parent()?;
let suffix = &exe[index + 5..];
let mut resolved = PathBuf::from(base);
for part in suffix.replace('/', "\\").split('\\') {
if part.is_empty() || part == "." {
continue;
}
if part == ".." {
let _ = resolved.pop();
continue;
}
resolved.push(part);
}
if resolved.exists() {
return Some(resolved.to_string_lossy().to_string());
}
continue;
}
let resolved = PathBuf::from(expand_env(&exe));
if resolved.exists() {
return Some(resolved.to_string_lossy().to_string());
}
}
None
};
let resolve_where = |query: &str| -> Option<String> {
let output = Command::new("where").arg(query).output().ok()?;
if !output.status.success() {
return None;
}
let paths = String::from_utf8_lossy(&output.stdout)
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.map(PathBuf::from)
.collect::<Vec<_>>();
if paths.is_empty() {
return None;
}
if let Some(path) = paths.iter().find(|path| has_ext(path, "exe")) {
return Some(path.to_string_lossy().to_string());
}
for path in &paths {
if has_ext(path, "cmd") || has_ext(path, "bat") {
if let Some(resolved) = resolve_cmd(path) {
return Some(resolved);
}
}
if path.extension().is_none() {
let cmd = path.with_extension("cmd");
if cmd.exists() {
if let Some(resolved) = resolve_cmd(&cmd) {
return Some(resolved);
}
}
let bat = path.with_extension("bat");
if bat.exists() {
if let Some(resolved) = resolve_cmd(&bat) {
return Some(resolved);
}
}
}
}
if !key.is_empty() {
for path in &paths {
let dirs = [
path.parent(),
path.parent().and_then(|dir| dir.parent()),
path.parent()
.and_then(|dir| dir.parent())
.and_then(|dir| dir.parent()),
];
for dir in dirs.into_iter().flatten() {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let candidate = entry.path();
if !has_ext(&candidate, "exe") {
continue;
}
let Some(stem) = candidate.file_stem().and_then(|v| v.to_str()) else {
continue;
};
let name = stem
.chars()
.filter(|v| v.is_ascii_alphanumeric())
.flat_map(|v| v.to_lowercase())
.collect::<String>();
if name.contains(&key) || key.contains(&name) {
return Some(candidate.to_string_lossy().to_string());
}
}
}
}
}
}
paths.first().map(|path| path.to_string_lossy().to_string())
};
let list = candidates(app_name);
for query in &list {
if let Some(path) = resolve_where(query) {
return Some(path);
}
}
let mut exes = Vec::<String>::new();
for query in &list {
let query = query.trim().trim_matches('"');
if query.is_empty() {
continue;
}
let name = Path::new(query)
.file_name()
.and_then(|v| v.to_str())
.unwrap_or(query);
let exe = if name.to_ascii_lowercase().ends_with(".exe") {
name.to_string()
} else {
format!("{name}.exe")
};
if exes.iter().any(|v| v.eq_ignore_ascii_case(&exe)) {
continue;
}
exes.push(exe);
}
for exe in exes {
if let Some(path) = reg_app_path(&exe) {
return Some(path);
}
}
None
}
#[tauri::command]
#[specta::specta]
fn resolve_app_path(app_name: &str) -> Option<String> {
#[cfg(target_os = "windows")]
{
resolve_windows_app_path(app_name)
}
#[cfg(not(target_os = "windows"))]
{
// On macOS/Linux, just return the app_name as-is since
// the opener plugin handles them correctly
Some(app_name.to_string())
}
}
#[cfg(target_os = "macos")]
@@ -181,13 +570,13 @@ fn check_macos_app(app_name: &str) -> bool {
if let Ok(home) = std::env::var("HOME") {
app_locations.push(format!("{}/Applications/{}.app", home, app_name));
}
for location in app_locations {
if std::path::Path::new(&location).exists() {
return true;
}
}
// Also check if command exists in PATH
Command::new("which")
.arg(app_name)
@@ -238,31 +627,50 @@ fn check_linux_app(app_name: &str) -> bool {
return true;
}
#[tauri::command]
#[specta::specta]
fn wsl_path(path: String, mode: Option<WslPathMode>) -> Result<String, String> {
if !cfg!(windows) {
return Ok(path);
}
let flag = match mode.unwrap_or(WslPathMode::Linux) {
WslPathMode::Windows => "-w",
WslPathMode::Linux => "-u",
};
let output = if path.starts_with('~') {
let suffix = path.strip_prefix('~').unwrap_or("");
let escaped = suffix.replace('"', "\\\"");
let cmd = format!("wslpath {flag} \"$HOME{escaped}\"");
Command::new("wsl")
.args(["-e", "sh", "-lc", &cmd])
.output()
.map_err(|e| format!("Failed to run wslpath: {e}"))?
} else {
Command::new("wsl")
.args(["-e", "wslpath", flag, &path])
.output()
.map_err(|e| format!("Failed to run wslpath: {e}"))?
};
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
return Err("wslpath failed".to_string());
}
return Err(stderr);
}
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let builder = tauri_specta::Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(tauri_specta::collect_commands![
kill_sidecar,
cli::install_cli,
await_initialization,
server::get_default_server_url,
server::set_default_server_url,
get_display_backend,
set_display_backend,
markdown::parse_markdown_command,
check_app_exists
])
.events(tauri_specta::collect_events![LoadingWindowComplete])
.error_handling(tauri_specta::ErrorHandlingMode::Throw);
let builder = make_specta_builder();
#[cfg(debug_assertions)] // <- Only export on non-release builds
builder
.export(
specta_typescript::Typescript::default(),
"../src/bindings.ts",
)
.expect("Failed to export typescript bindings");
export_types(&builder);
#[cfg(all(target_os = "macos", not(debug_assertions)))]
let _ = std::process::Command::new("killall")
@@ -297,10 +705,18 @@ pub fn run() {
.plugin(tauri_plugin_decorum::init())
.invoke_handler(builder.invoke_handler())
.setup(move |app| {
let app = app.handle().clone();
let handle = app.handle().clone();
builder.mount_events(&app);
tauri::async_runtime::spawn(initialize(app));
let log_dir = app
.path()
.app_log_dir()
.expect("failed to resolve app log dir");
// Hold the guard in managed state so it lives for the app's lifetime,
// ensuring all buffered logs are flushed on shutdown.
handle.manage(logging::init(&log_dir));
builder.mount_events(&handle);
tauri::async_runtime::spawn(initialize(handle));
Ok(())
});
@@ -314,19 +730,56 @@ pub fn run() {
.expect("error while running tauri application")
.run(|app, event| {
if let RunEvent::Exit = event {
println!("Received Exit");
tracing::info!("Received Exit");
kill_sidecar(app.clone());
}
});
}
fn make_specta_builder() -> tauri_specta::Builder<tauri::Wry> {
tauri_specta::Builder::<tauri::Wry>::new()
// Then register them (separated by a comma)
.commands(tauri_specta::collect_commands![
kill_sidecar,
cli::install_cli,
await_initialization,
server::get_default_server_url,
server::set_default_server_url,
server::get_wsl_config,
server::set_wsl_config,
get_display_backend,
set_display_backend,
markdown::parse_markdown_command,
check_app_exists,
wsl_path,
resolve_app_path
])
.events(tauri_specta::collect_events![LoadingWindowComplete])
.error_handling(tauri_specta::ErrorHandlingMode::Throw)
}
fn export_types(builder: &tauri_specta::Builder<tauri::Wry>) {
builder
.export(
specta_typescript::Typescript::default(),
"../src/bindings.ts",
)
.expect("Failed to export typescript bindings");
}
#[cfg(test)]
#[test]
fn test_export_types() {
let builder = make_specta_builder();
export_types(&builder);
}
#[derive(tauri_specta::Event, serde::Deserialize, specta::Type)]
struct LoadingWindowComplete;
// #[tracing::instrument(skip_all)]
async fn initialize(app: AppHandle) {
println!("Initializing app");
tracing::info!("Initializing app");
let (init_tx, init_rx) = watch::channel(InitStep::ServerWaiting);
@@ -339,7 +792,7 @@ async fn initialize(app: AppHandle) {
let loading_window_complete = event_once_fut::<LoadingWindowComplete>(&app);
println!("Main and loading windows created");
tracing::info!("Main and loading windows created");
let sqlite_enabled = option_env!("OPENCODE_SQLITE").is_some();
@@ -350,7 +803,7 @@ async fn initialize(app: AppHandle) {
async move {
let mut sqlite_exists = sqlite_file_exists();
println!("Setting up server connection");
tracing::info!("Setting up server connection");
let server_connection = setup_server_connection(app.clone()).await;
// we delay spawning this future so that the timeout is created lazily
@@ -364,16 +817,24 @@ async fn initialize(app: AppHandle) {
let app = app.clone();
Some(
async move {
let Ok(Ok(_)) = timeout(Duration::from_secs(30), health_check.0).await
else {
let _ = child.kill();
return Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
let res = timeout(Duration::from_secs(30), health_check.0).await;
let err = match res {
Ok(Ok(Ok(()))) => None,
Ok(Ok(Err(e))) => Some(e),
Ok(Err(e)) => Some(format!("Health check task failed: {e}")),
Err(_) => Some("Health check timed out".to_string()),
};
println!("CLI health check OK");
if let Some(err) = err {
let _ = child.kill();
return Err(format!(
"Failed to spawn OpenCode Server ({err}). Logs:\n{}",
get_logs()
));
}
tracing::info!("CLI health check OK");
#[cfg(windows)]
{
@@ -401,11 +862,11 @@ async fn initialize(app: AppHandle) {
if let Some(cli_health_check) = cli_health_check {
if sqlite_enabled {
println!("Does sqlite file exist: {sqlite_exists}");
tracing::debug!(sqlite_exists, "Checking sqlite file existence");
if !sqlite_exists {
println!(
"Sqlite file not found at {}, waiting for it to be generated",
opencode_db_path().expect("failed to get db path").display()
tracing::info!(
path = %opencode_db_path().expect("failed to get db path").display(),
"Sqlite file not found, waiting for it to be generated"
);
let _ = init_tx.send(InitStep::SqliteWaiting);
@@ -430,7 +891,7 @@ async fn initialize(app: AppHandle) {
.await
.is_err()
{
println!("Loading task timed out, showing loading window");
tracing::debug!("Loading task timed out, showing loading window");
let app = app.clone();
let loading_window = LoadingWindow::create(&app).expect("Failed to create loading window");
sleep(Duration::from_secs(1)).await;
@@ -443,14 +904,14 @@ async fn initialize(app: AppHandle) {
let _ = loading_task.await;
println!("Loading done, completing initialisation");
tracing::info!("Loading done, completing initialisation");
let _ = init_tx.send(InitStep::Done);
if loading_window.is_some() {
loading_window_complete.await;
println!("Loading window completed");
tracing::info!("Loading window completed");
}
MainWindow::create(&app).expect("Failed to create main window");
@@ -464,9 +925,6 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
#[cfg(any(target_os = "linux", all(debug_assertions, windows)))]
app.deep_link().register_all().ok();
// Initialize log state
app.manage(LogState(Arc::new(Mutex::new(VecDeque::new()))));
#[cfg(windows)]
app.manage(JobObjectState::new());
@@ -476,7 +934,7 @@ fn setup_app(app: &tauri::AppHandle, init_rx: watch::Receiver<InitStep>) {
fn spawn_cli_sync_task(app: AppHandle) {
tokio::spawn(async move {
if let Err(e) = sync_cli(app) {
eprintln!("Failed to sync CLI: {e}");
tracing::error!("Failed to sync CLI: {e}");
}
});
}
@@ -496,12 +954,12 @@ enum ServerConnection {
async fn setup_server_connection(app: AppHandle) -> ServerConnection {
let custom_url = get_saved_server_url(&app).await;
println!("Attempting server connection to custom url: {custom_url:?}");
tracing::info!(?custom_url, "Attempting server connection");
if let Some(url) = custom_url
&& server::check_health_or_ask_retry(&app, &url).await
{
println!("Connected to custom server: {}", url);
tracing::info!(%url, "Connected to custom server");
return ServerConnection::Existing { url: url.clone() };
}
@@ -509,15 +967,15 @@ async fn setup_server_connection(app: AppHandle) -> ServerConnection {
let hostname = "127.0.0.1";
let local_url = format!("http://{hostname}:{local_port}");
println!("Checking health of server '{}'", local_url);
tracing::debug!(url = %local_url, "Checking health of local server");
if server::check_health(&local_url, None).await {
println!("Health check OK, using existing server");
tracing::info!(url = %local_url, "Health check OK, using existing server");
return ServerConnection::Existing { url: local_url };
}
let password = uuid::Uuid::new_v4().to_string();
println!("Spawning new local server");
tracing::info!("Spawning new local server");
let (child, health_check) =
server::spawn_local_server(app, hostname.to_string(), local_port, password.clone());
@@ -14,7 +14,11 @@ struct DisplayConfig {
}
fn dir() -> Option<PathBuf> {
Some(dirs::data_dir()?.join("ai.opencode.desktop"))
Some(dirs::data_dir()?.join(if cfg!(debug_assertions) {
"ai.opencode.desktop.dev"
} else {
"ai.opencode.desktop"
}))
}
fn path() -> Option<PathBuf> {
@@ -22,10 +26,12 @@ fn path() -> Option<PathBuf> {
}
pub fn read_wayland() -> Option<bool> {
let path = path()?;
let raw = std::fs::read_to_string(path).ok()?;
let config = serde_json::from_str::<DisplayConfig>(&raw).ok()?;
config.wayland
let raw = std::fs::read_to_string(path()?).ok()?;
let root = serde_json::from_str::<serde_json::Value>(&raw)
.ok()?
.get(LINUX_DISPLAY_CONFIG_KEY)
.cloned()?;
serde_json::from_value::<DisplayConfig>(root).ok()?.wayland
}
pub fn write_wayland(app: &AppHandle, value: bool) -> Result<(), String> {
+83
View File
@@ -0,0 +1,83 @@
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::{EnvFilter, fmt, layer::SubscriberExt, util::SubscriberInitExt};
const MAX_LOG_AGE_DAYS: u64 = 7;
const TAIL_LINES: usize = 1000;
static LOG_PATH: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
pub fn init(log_dir: &Path) -> WorkerGuard {
std::fs::create_dir_all(log_dir).expect("failed to create log directory");
cleanup(log_dir);
let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S");
let filename = format!("opencode-desktop_{timestamp}.log");
let log_path = log_dir.join(&filename);
LOG_PATH
.set(log_path.clone())
.expect("logging already initialized");
let file = File::create(&log_path).expect("failed to create log file");
let (non_blocking, guard) = tracing_appender::non_blocking(file);
let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| {
if cfg!(debug_assertions) {
EnvFilter::new("opencode_lib=debug,opencode_desktop=debug,sidecar=debug")
} else {
EnvFilter::new("opencode_lib=info,opencode_desktop=info,sidecar=info")
}
});
tracing_subscriber::registry()
.with(filter)
.with(fmt::layer().with_writer(std::io::stderr))
.with(
fmt::layer()
.with_writer(non_blocking)
.with_ansi(false),
)
.init();
guard
}
pub fn tail() -> String {
let Some(path) = LOG_PATH.get() else {
return String::new();
};
let Ok(file) = File::open(path) else {
return String::new();
};
let lines: Vec<String> = BufReader::new(file)
.lines()
.map_while(Result::ok)
.collect();
let start = lines.len().saturating_sub(TAIL_LINES);
lines[start..].join("\n")
}
fn cleanup(log_dir: &Path) {
let cutoff = std::time::SystemTime::now()
- std::time::Duration::from_secs(MAX_LOG_AGE_DAYS * 24 * 60 * 60);
let Ok(entries) = std::fs::read_dir(log_dir) else {
return;
};
for entry in entries.flatten() {
if let Ok(meta) = entry.metadata()
&& let Ok(modified) = meta.modified()
&& modified < cutoff
{
let _ = std::fs::remove_file(entry.path());
}
}
}
+2 -2
View File
@@ -43,7 +43,7 @@ fn configure_display_backend() -> Option<String> {
set_env_if_absent("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
return Some(
"Wayland session detected; forcing X11 backend to avoid compositor protocol errors. \
Set OC_ALLOW_WAYLAND=1 to keep native Wayland."
Set OC_ALLOW_WAYLAND=1 to keep native Wayland."
.into(),
);
}
@@ -86,7 +86,7 @@ fn main() {
#[cfg(target_os = "linux")]
{
if let Some(backend_note) = configure_display_backend() {
eprintln!("{backend_note:?}");
eprintln!("{backend_note}");
}
}
+73 -13
View File
@@ -8,9 +8,20 @@ use tokio::task::JoinHandle;
use crate::{
cli,
constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE},
constants::{DEFAULT_SERVER_URL_KEY, SETTINGS_STORE, WSL_ENABLED_KEY},
};
#[derive(Clone, serde::Serialize, serde::Deserialize, specta::Type, Debug)]
pub struct WslConfig {
pub enabled: bool,
}
impl Default for WslConfig {
fn default() -> Self {
Self { enabled: false }
}
}
#[tauri::command]
#[specta::specta]
pub fn get_default_server_url(app: AppHandle) -> Result<Option<String>, String> {
@@ -48,16 +59,48 @@ pub async fn set_default_server_url(app: AppHandle, url: Option<String>) -> Resu
Ok(())
}
#[tauri::command]
#[specta::specta]
pub fn get_wsl_config(app: AppHandle) -> Result<WslConfig, String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
let enabled = store
.get(WSL_ENABLED_KEY)
.as_ref()
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(WslConfig { enabled })
}
#[tauri::command]
#[specta::specta]
pub fn set_wsl_config(app: AppHandle, config: WslConfig) -> Result<(), String> {
let store = app
.store(SETTINGS_STORE)
.map_err(|e| format!("Failed to open settings store: {}", e))?;
store.set(WSL_ENABLED_KEY, serde_json::Value::Bool(config.enabled));
store
.save()
.map_err(|e| format!("Failed to save settings: {}", e))?;
Ok(())
}
pub async fn get_saved_server_url(app: &tauri::AppHandle) -> Option<String> {
if let Some(url) = get_default_server_url(app.clone()).ok().flatten() {
println!("Using desktop-specific custom URL: {url}");
tracing::info!(%url, "Using desktop-specific custom URL");
return Some(url);
}
if let Some(cli_config) = cli::get_config(app).await
&& let Some(url) = get_server_url_from_config(&cli_config)
{
println!("Using custom server URL from config: {url}");
tracing::info!(%url, "Using custom server URL from config");
return Some(url);
}
@@ -70,26 +113,43 @@ pub fn spawn_local_server(
port: u32,
password: String,
) -> (CommandChild, HealthCheck) {
let child = cli::serve(&app, &hostname, port, &password);
let (child, exit) = cli::serve(&app, &hostname, port, &password);
let health_check = HealthCheck(tokio::spawn(async move {
let url = format!("http://{hostname}:{port}");
let timestamp = Instant::now();
loop {
tokio::time::sleep(Duration::from_millis(100)).await;
if check_health(&url, Some(&password)).await {
println!("Server ready after {:?}", timestamp.elapsed());
break;
let ready = async {
loop {
tokio::time::sleep(Duration::from_millis(100)).await;
if check_health(&url, Some(&password)).await {
tracing::info!(elapsed = ?timestamp.elapsed(), "Server ready");
return Ok(());
}
}
};
let terminated = async {
match exit.await {
Ok(payload) => Err(format!(
"Sidecar terminated before becoming healthy (code={:?} signal={:?})",
payload.code, payload.signal
)),
Err(_) => Err("Sidecar terminated before becoming healthy".to_string()),
}
};
tokio::select! {
res = ready => res,
res = terminated => res,
}
}));
(child, health_check)
}
pub struct HealthCheck(pub JoinHandle<()>);
pub struct HealthCheck(pub JoinHandle<Result<(), String>>);
pub async fn check_health(url: &str, password: Option<&str>) -> bool {
let Ok(url) = reqwest::Url::parse(url) else {
@@ -156,7 +216,7 @@ fn normalize_hostname_for_url(hostname: &str) -> String {
fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
let server = config.server.as_ref()?;
let port = server.port?;
println!("server.port found in OC config: {port}");
tracing::debug!(port, "server.port found in OC config");
let hostname = server
.hostname
.as_ref()
@@ -167,7 +227,7 @@ fn get_server_url_from_config(config: &cli::Config) -> Option<String> {
}
pub async fn check_health_or_ask_retry(app: &AppHandle, url: &str) -> bool {
println!("Checking health for {url}");
tracing::debug!(%url, "Checking health");
loop {
if check_health(url, None).await {
return true;
+10 -1
View File
@@ -1,4 +1,7 @@
use crate::constants::{UPDATER_ENABLED, window_state_flags};
use crate::{
constants::{UPDATER_ENABLED, window_state_flags},
server::get_wsl_config,
};
use std::{ops::Deref, time::Duration};
use tauri::{AppHandle, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
use tauri_plugin_window_state::AppHandleExt;
@@ -22,6 +25,11 @@ impl MainWindow {
return Ok(Self(window));
}
let wsl_enabled = get_wsl_config(app.clone())
.ok()
.map(|v| v.enabled)
.unwrap_or(false);
let window_builder = base_window_config(
WebviewWindowBuilder::new(app, Self::LABEL, WebviewUrl::App("/".into())),
app,
@@ -36,6 +44,7 @@ impl MainWindow {
r#"
window.__OPENCODE__ ??= {{}};
window.__OPENCODE__.updaterEnabled = {UPDATER_ENABLED};
window.__OPENCODE__.wsl = {wsl_enabled};
"#
));
+10
View File
@@ -10,10 +10,14 @@ export const commands = {
awaitInitialization: (events: Channel) => __TAURI_INVOKE<ServerReadyData>("await_initialization", { events }),
getDefaultServerUrl: () => __TAURI_INVOKE<string | null>("get_default_server_url"),
setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE<null>("set_default_server_url", { url }),
getWslConfig: () => __TAURI_INVOKE<WslConfig>("get_wsl_config"),
setWslConfig: (config: WslConfig) => __TAURI_INVOKE<null>("set_wsl_config", { config }),
getDisplayBackend: () => __TAURI_INVOKE<"wayland" | "auto" | null>("get_display_backend"),
setDisplayBackend: (backend: LinuxDisplayBackend) => __TAURI_INVOKE<null>("set_display_backend", { backend }),
parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE<string>("parse_markdown_command", { markdown }),
checkAppExists: (appName: string) => __TAURI_INVOKE<boolean>("check_app_exists", { appName }),
wslPath: (path: string, mode: "windows" | "linux" | null) => __TAURI_INVOKE<string>("wsl_path", { path, mode }),
resolveAppPath: (appName: string) => __TAURI_INVOKE<string | null>("resolve_app_path", { appName }),
};
/** Events */
@@ -33,6 +37,12 @@ export type ServerReadyData = {
password: string | null,
};
export type WslConfig = {
enabled: boolean,
};
export type WslPathMode = "windows" | "linux";
/* Tauri Specta runtime */
function makeEvent<T>(name: string) {
const base = {
+343 -302
View File
@@ -16,7 +16,6 @@ import { open as shellOpen } from "@tauri-apps/plugin-shell"
import { type as ostype } from "@tauri-apps/plugin-os"
import { check, Update } from "@tauri-apps/plugin-updater"
import { getCurrentWindow } from "@tauri-apps/api/window"
import { invoke } from "@tauri-apps/api/core"
import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process"
import { AsyncStorage } from "@solid-primitives/storage"
@@ -30,7 +29,7 @@ import { UPDATER_ENABLED } from "./updater"
import { initI18n, t } from "./i18n"
import pkg from "../package.json"
import "./styles.css"
import { commands, InitStep } from "./bindings"
import { commands, InitStep, type WslConfig } from "./bindings"
import { Channel } from "@tauri-apps/api/core"
import { createMenu } from "./menu"
@@ -59,333 +58,374 @@ const listenForDeepLinks = async () => {
await onOpenUrl((urls) => emitDeepLinks(urls)).catch(() => undefined)
}
const createPlatform = (password: Accessor<string | null>): Platform => ({
platform: "desktop",
os: (() => {
const createPlatform = (password: Accessor<string | null>): Platform => {
const os = (() => {
const type = ostype()
if (type === "macos" || type === "windows" || type === "linux") return type
return undefined
})(),
version: pkg.version,
})()
async openDirectoryPickerDialog(opts) {
const result = await open({
directory: true,
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
})
return result
},
const wslHome = async () => {
if (os !== "windows" || !window.__OPENCODE__?.wsl) return undefined
return commands.wslPath("~", "windows").catch(() => undefined)
}
async openFilePickerDialog(opts) {
const result = await open({
directory: false,
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"),
})
return result
},
async saveFilePickerDialog(opts) {
const result = await save({
title: opts?.title ?? t("desktop.dialog.saveFile"),
defaultPath: opts?.defaultPath,
})
return result
},
openLink(url: string) {
void shellOpen(url).catch(() => undefined)
},
openPath(path: string, app?: string) {
return openerOpenPath(path, app)
},
back() {
window.history.back()
},
forward() {
window.history.forward()
},
storage: (() => {
type StoreLike = {
get(key: string): Promise<string | null | undefined>
set(key: string, value: string): Promise<unknown>
delete(key: string): Promise<unknown>
clear(): Promise<unknown>
keys(): Promise<string[]>
length(): Promise<number>
const handleWslPicker = async <T extends string | string[]>(result: T | null): Promise<T | null> => {
if (!result || !window.__OPENCODE__?.wsl) return result
if (Array.isArray(result)) {
return Promise.all(result.map((path) => commands.wslPath(path, "linux").catch(() => path))) as any
}
return commands.wslPath(result, "linux").catch(() => result) as any
}
const WRITE_DEBOUNCE_MS = 250
return {
platform: "desktop",
os,
version: pkg.version,
const storeCache = new Map<string, Promise<StoreLike>>()
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
const memoryCache = new Map<string, StoreLike>()
async openDirectoryPickerDialog(opts) {
const defaultPath = await wslHome()
const result = await open({
directory: true,
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFolder"),
defaultPath,
})
return await handleWslPicker(result)
},
const flushAll = async () => {
const apis = Array.from(apiCache.values())
await Promise.all(apis.map((api) => api.flush().catch(() => undefined)))
}
async openFilePickerDialog(opts) {
const result = await open({
directory: false,
multiple: opts?.multiple ?? false,
title: opts?.title ?? t("desktop.dialog.chooseFile"),
})
return handleWslPicker(result)
},
if ("addEventListener" in globalThis) {
const handleVisibility = () => {
if (document.visibilityState !== "hidden") return
void flushAll()
async saveFilePickerDialog(opts) {
const result = await save({
title: opts?.title ?? t("desktop.dialog.saveFile"),
defaultPath: opts?.defaultPath,
})
return handleWslPicker(result)
},
openLink(url: string) {
void shellOpen(url).catch(() => undefined)
},
async openPath(path: string, app?: string) {
const os = ostype()
if (os === "windows") {
const resolvedApp = (app && (await commands.resolveAppPath(app))) || app
const resolvedPath = await (async () => {
if (window.__OPENCODE__?.wsl) {
const converted = await commands.wslPath(path, "windows").catch(() => null)
if (converted) return converted
}
return path
})()
return openerOpenPath(resolvedPath, resolvedApp)
}
return openerOpenPath(path, app)
},
back() {
window.history.back()
},
forward() {
window.history.forward()
},
storage: (() => {
type StoreLike = {
get(key: string): Promise<string | null | undefined>
set(key: string, value: string): Promise<unknown>
delete(key: string): Promise<unknown>
clear(): Promise<unknown>
keys(): Promise<string[]>
length(): Promise<number>
}
window.addEventListener("pagehide", () => void flushAll())
document.addEventListener("visibilitychange", handleVisibility)
}
const WRITE_DEBOUNCE_MS = 250
const createMemoryStore = () => {
const data = new Map<string, string>()
const store: StoreLike = {
get: async (key) => data.get(key),
set: async (key, value) => {
data.set(key, value)
},
delete: async (key) => {
data.delete(key)
},
clear: async () => {
data.clear()
},
keys: async () => Array.from(data.keys()),
length: async () => data.size,
const storeCache = new Map<string, Promise<StoreLike>>()
const apiCache = new Map<string, AsyncStorage & { flush: () => Promise<void> }>()
const memoryCache = new Map<string, StoreLike>()
const flushAll = async () => {
const apis = Array.from(apiCache.values())
await Promise.all(apis.map((api) => api.flush().catch(() => undefined)))
}
return store
}
const getStore = (name: string) => {
const cached = storeCache.get(name)
if (cached) return cached
if ("addEventListener" in globalThis) {
const handleVisibility = () => {
if (document.visibilityState !== "hidden") return
void flushAll()
}
const store = Store.load(name).catch(() => {
const cached = memoryCache.get(name)
window.addEventListener("pagehide", () => void flushAll())
document.addEventListener("visibilitychange", handleVisibility)
}
const createMemoryStore = () => {
const data = new Map<string, string>()
const store: StoreLike = {
get: async (key) => data.get(key),
set: async (key, value) => {
data.set(key, value)
},
delete: async (key) => {
data.delete(key)
},
clear: async () => {
data.clear()
},
keys: async () => Array.from(data.keys()),
length: async () => data.size,
}
return store
}
const getStore = (name: string) => {
const cached = storeCache.get(name)
if (cached) return cached
const memory = createMemoryStore()
memoryCache.set(name, memory)
return memory
})
const store = Store.load(name).catch(() => {
const cached = memoryCache.get(name)
if (cached) return cached
storeCache.set(name, store)
return store
}
const memory = createMemoryStore()
memoryCache.set(name, memory)
return memory
})
const createStorage = (name: string) => {
const pending = new Map<string, string | null>()
let timer: ReturnType<typeof setTimeout> | undefined
let flushing: Promise<void> | undefined
storeCache.set(name, store)
return store
}
const flush = async () => {
if (flushing) return flushing
const createStorage = (name: string) => {
const pending = new Map<string, string | null>()
let timer: ReturnType<typeof setTimeout> | undefined
let flushing: Promise<void> | undefined
flushing = (async () => {
const store = await getStore(name)
while (pending.size > 0) {
const batch = Array.from(pending.entries())
pending.clear()
for (const [key, value] of batch) {
if (value === null) {
await store.delete(key).catch(() => undefined)
} else {
await store.set(key, value).catch(() => undefined)
const flush = async () => {
if (flushing) return flushing
flushing = (async () => {
const store = await getStore(name)
while (pending.size > 0) {
const batch = Array.from(pending.entries())
pending.clear()
for (const [key, value] of batch) {
if (value === null) {
await store.delete(key).catch(() => undefined)
} else {
await store.set(key, value).catch(() => undefined)
}
}
}
}
})().finally(() => {
flushing = undefined
})
})().finally(() => {
flushing = undefined
})
return flushing
}
const schedule = () => {
if (timer) return
timer = setTimeout(() => {
timer = undefined
void flush()
}, WRITE_DEBOUNCE_MS)
}
const api: AsyncStorage & { flush: () => Promise<void> } = {
flush,
getItem: async (key: string) => {
const next = pending.get(key)
if (next !== undefined) return next
const store = await getStore(name)
const value = await store.get(key).catch(() => null)
if (value === undefined) return null
return value
},
setItem: async (key: string, value: string) => {
pending.set(key, value)
schedule()
},
removeItem: async (key: string) => {
pending.set(key, null)
schedule()
},
clear: async () => {
pending.clear()
const store = await getStore(name)
await store.clear().catch(() => undefined)
},
key: async (index: number) => {
const store = await getStore(name)
return (await store.keys().catch(() => []))[index]
},
getLength: async () => {
const store = await getStore(name)
return await store.length().catch(() => 0)
},
get length() {
return api.getLength()
},
}
return api
}
return (name = "default.dat") => {
const cached = apiCache.get(name)
if (cached) return cached
const api = createStorage(name)
apiCache.set(name, api)
return api
}
})(),
checkUpdate: async () => {
if (!UPDATER_ENABLED) return { updateAvailable: false }
const next = await check().catch(() => null)
if (!next) return { updateAvailable: false }
const ok = await next
.download()
.then(() => true)
.catch(() => false)
if (!ok) return { updateAvailable: false }
update = next
return { updateAvailable: true, version: next.version }
},
update: async () => {
if (!UPDATER_ENABLED || !update) return
if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
await update.install().catch(() => undefined)
},
restart: async () => {
await commands.killSidecar().catch(() => undefined)
await relaunch()
},
notify: async (title, description, href) => {
const granted = await isPermissionGranted().catch(() => false)
const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
if (permission !== "granted") return
const win = getCurrentWindow()
const focused = await win.isFocused().catch(() => document.hasFocus())
if (focused) return
await Promise.resolve()
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
})
notification.onclick = () => {
const win = getCurrentWindow()
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"))
}
notification.close()
return flushing
}
const schedule = () => {
if (timer) return
timer = setTimeout(() => {
timer = undefined
void flush()
}, WRITE_DEBOUNCE_MS)
}
const api: AsyncStorage & { flush: () => Promise<void> } = {
flush,
getItem: async (key: string) => {
const next = pending.get(key)
if (next !== undefined) return next
const store = await getStore(name)
const value = await store.get(key).catch(() => null)
if (value === undefined) return null
return value
},
setItem: async (key: string, value: string) => {
pending.set(key, value)
schedule()
},
removeItem: async (key: string) => {
pending.set(key, null)
schedule()
},
clear: async () => {
pending.clear()
const store = await getStore(name)
await store.clear().catch(() => undefined)
},
key: async (index: number) => {
const store = await getStore(name)
return (await store.keys().catch(() => []))[index]
},
getLength: async () => {
const store = await getStore(name)
return await store.length().catch(() => 0)
},
get length() {
return api.getLength()
},
}
return api
}
return (name = "default.dat") => {
const cached = apiCache.get(name)
if (cached) return cached
const api = createStorage(name)
apiCache.set(name, api)
return api
}
})(),
checkUpdate: async () => {
if (!UPDATER_ENABLED) return { updateAvailable: false }
const next = await check().catch(() => null)
if (!next) return { updateAvailable: false }
const ok = await next
.download()
.then(() => true)
.catch(() => false)
if (!ok) return { updateAvailable: false }
update = next
return { updateAvailable: true, version: next.version }
},
update: async () => {
if (!UPDATER_ENABLED || !update) return
if (ostype() === "windows") await commands.killSidecar().catch(() => undefined)
await update.install().catch(() => undefined)
},
restart: async () => {
await commands.killSidecar().catch(() => undefined)
await relaunch()
},
notify: async (title, description, href) => {
const granted = await isPermissionGranted().catch(() => false)
const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
if (permission !== "granted") return
const win = getCurrentWindow()
const focused = await win.isFocused().catch(() => document.hasFocus())
if (focused) return
await Promise.resolve()
.then(() => {
const notification = new Notification(title, {
body: description ?? "",
icon: "https://opencode.ai/favicon-96x96-v3.png",
})
notification.onclick = () => {
const win = getCurrentWindow()
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"))
}
notification.close()
}
})
.catch(() => undefined)
},
fetch: (input, init) => {
const pw = password()
const addHeader = (headers: Headers, password: string) => {
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
}
if (input instanceof Request) {
if (pw) addHeader(input.headers, pw)
return tauriFetch(input)
} else {
const headers = new Headers(init?.headers)
if (pw) addHeader(headers, pw)
return tauriFetch(input, {
...(init as any),
headers: headers,
})
}
},
getWslEnabled: async () => {
const next = await commands.getWslConfig().catch(() => null)
if (next) return next.enabled
return window.__OPENCODE__!.wsl ?? false
},
setWslEnabled: async (enabled) => {
await commands.setWslConfig({ enabled })
},
getDefaultServerUrl: async () => {
const result = await commands.getDefaultServerUrl().catch(() => null)
return result
},
setDefaultServerUrl: async (url: string | null) => {
await commands.setDefaultServerUrl(url)
},
getDisplayBackend: async () => {
const result = await commands.getDisplayBackend().catch(() => null)
return result
},
setDisplayBackend: async (backend) => {
await commands.setDisplayBackend(backend)
},
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
webviewZoom,
checkAppExists: async (appName: string) => {
return commands.checkAppExists(appName)
},
async readClipboardImage() {
const image = await readImage().catch(() => null)
if (!image) return null
const bytes = await image.rgba().catch(() => null)
if (!bytes || bytes.length === 0) return null
const size = await image.size().catch(() => null)
if (!size) return null
const canvas = document.createElement("canvas")
canvas.width = size.width
canvas.height = size.height
const ctx = canvas.getContext("2d")
if (!ctx) return null
const imageData = ctx.createImageData(size.width, size.height)
imageData.data.set(bytes)
ctx.putImageData(imageData, 0, 0)
return new Promise<File | null>((resolve) => {
canvas.toBlob((blob) => {
if (!blob) return resolve(null)
resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" }))
}, "image/png")
})
.catch(() => undefined)
},
fetch: (input, init) => {
const pw = password()
const addHeader = (headers: Headers, password: string) => {
headers.append("Authorization", `Basic ${btoa(`opencode:${password}`)}`)
}
if (input instanceof Request) {
if (pw) addHeader(input.headers, pw)
return tauriFetch(input)
} else {
const headers = new Headers(init?.headers)
if (pw) addHeader(headers, pw)
return tauriFetch(input, {
...(init as any),
headers: headers,
})
}
},
getDefaultServerUrl: async () => {
const result = await commands.getDefaultServerUrl().catch(() => null)
return result
},
setDefaultServerUrl: async (url: string | null) => {
await commands.setDefaultServerUrl(url)
},
getDisplayBackend: async () => {
const result = await invoke<DisplayBackend | null>("get_display_backend").catch(() => null)
return result
},
setDisplayBackend: async (backend) => {
await invoke("set_display_backend", { backend }).catch(() => undefined)
},
parseMarkdown: (markdown: string) => commands.parseMarkdownCommand(markdown),
webviewZoom,
checkAppExists: async (appName: string) => {
return commands.checkAppExists(appName)
},
async readClipboardImage() {
const image = await readImage().catch(() => null)
if (!image) return null
const bytes = await image.rgba().catch(() => null)
if (!bytes || bytes.length === 0) return null
const size = await image.size().catch(() => null)
if (!size) return null
const canvas = document.createElement("canvas")
canvas.width = size.width
canvas.height = size.height
const ctx = canvas.getContext("2d")
if (!ctx) return null
const imageData = ctx.createImageData(size.width, size.height)
imageData.data.set(bytes)
ctx.putImageData(imageData, 0, 0)
return new Promise<File | null>((resolve) => {
canvas.toBlob((blob) => {
if (!blob) return resolve(null)
resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" }))
}, "image/png")
})
},
})
},
}
}
let menuTrigger = null as null | ((id: string) => void)
createMenu((id) => {
@@ -395,6 +435,7 @@ void listenForDeepLinks()
render(() => {
const [serverPassword, setServerPassword] = createSignal<string | null>(null)
const platform = createPlatform(() => serverPassword())
function handleClick(e: MouseEvent) {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.1.55",
"version": "1.1.57",
"private": true,
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.1.55"
version = "1.1.57"
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.55/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.57/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.55/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.57/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.55/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.57/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.55/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.57/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.55/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.57/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.1.55",
"version": "1.1.57",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.1.55",
"version": "1.1.57",
"name": "opencode",
"type": "module",
"license": "MIT",
@@ -1,6 +1,6 @@
You are a helpful AI assistant tasked with summarizing conversations.
When asked to summarize, provide a detailed but concise summary of the conversation.
When asked to summarize, provide a detailed but concise summary of the conversation.
Focus on information that would be helpful for continuing the conversation, including:
- What was done
- What is currently being worked on
@@ -10,3 +10,5 @@ Focus on information that would be helpful for continuing the conversation, incl
- Important technical decisions and why they were made
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
Do not respond to any questions in the conversation, only output the summary.
@@ -7,27 +7,6 @@ import { useDialog } from "@tui/ui/dialog"
import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import type { Provider } from "@opencode-ai/sdk/v2"
function pickLatest(models: [string, Provider["models"][string]][]) {
const picks: Record<string, [string, Provider["models"][string]]> = {}
for (const item of models) {
const model = item[0]
const info = item[1]
const key = info.family ?? model
const prev = picks[key]
if (!prev) {
picks[key] = item
continue
}
if (info.release_date !== prev[1].release_date) {
if (info.release_date > prev[1].release_date) picks[key] = item
continue
}
if (model > prev[0]) picks[key] = item
}
return Object.values(picks)
}
export function useConnected() {
const sync = useSync()
@@ -42,7 +21,6 @@ export function DialogModel(props: { providerID?: string }) {
const dialog = useDialog()
const keybind = useKeybind()
const [query, setQuery] = createSignal("")
const [all, setAll] = createSignal(false)
const connected = useConnected()
const providers = createDialogProviderOptions()
@@ -94,8 +72,8 @@ export function DialogModel(props: { providerID?: string }) {
(provider) => provider.id !== "opencode",
(provider) => provider.name,
),
flatMap((provider) => {
const items = pipe(
flatMap((provider) =>
pipe(
provider.models,
entries(),
filter(([_, info]) => info.status !== "deprecated"),
@@ -126,9 +104,8 @@ export function DialogModel(props: { providerID?: string }) {
(x) => x.footer !== "Free",
(x) => x.title,
),
)
return items
}),
),
),
)
const popularProviders = !connected()
@@ -177,13 +154,6 @@ export function DialogModel(props: { providerID?: string }) {
local.model.toggleFavorite(option.value as { providerID: string; modelID: string })
},
},
{
keybind: keybind.all.model_show_all_toggle?.[0],
title: all() ? "Show latest only" : "Show all models",
onTrigger: () => {
setAll((value) => !value)
},
},
]}
onFilter={setQuery}
flat={true}
@@ -141,7 +141,7 @@ export function Session() {
})
const dimensions = useTerminalDimensions()
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "hide")
const [sidebar, setSidebar] = kv.signal<"auto" | "hide">("sidebar", "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const [conceal, setConceal] = createSignal(true)
const [showThinking, setShowThinking] = kv.signal("thinking_visibility", true)
@@ -57,6 +57,8 @@ Use best judgement when processing input.
**Performance** - Only flag if obviously problematic.
- O(n²) on unbounded data, N+1 queries, blocking I/O on hot paths
**Behavior Changes** - If a behavioral change is introduced, raise it (especially if it's possibly unintentional).
---
## Before You Flag Something
+6 -1
View File
@@ -778,7 +778,6 @@ export namespace Config {
stash_delete: z.string().optional().default("ctrl+d").describe("Delete stash entry"),
model_provider_list: z.string().optional().default("ctrl+a").describe("Open provider list from model dialog"),
model_favorite_toggle: z.string().optional().default("ctrl+f").describe("Toggle model favorite status"),
model_show_all_toggle: z.string().optional().default("ctrl+o").describe("Toggle showing all models"),
session_share: z.string().optional().default("none").describe("Share current session"),
session_unshare: z.string().optional().default("none").describe("Unshare current session"),
session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"),
@@ -1162,6 +1161,12 @@ export namespace Config {
.object({
auto: z.boolean().optional().describe("Enable automatic compaction when context is full (default: true)"),
prune: z.boolean().optional().describe("Enable pruning of old tool outputs (default: true)"),
reserved: z
.number()
.int()
.min(0)
.optional()
.describe("Token buffer for compaction. Leaves enough window to avoid overflow during compaction."),
})
.optional(),
experimental: z
+2 -4
View File
@@ -12,15 +12,13 @@ export namespace ProviderError {
/input token count.*exceeds the maximum/i, // Google (Gemini)
/maximum prompt length is \d+/i, // xAI (Grok)
/reduce the length of the messages/i, // Groq
/maximum context length is \d+ tokens/i, // OpenRouter
/maximum context length is \d+ tokens/i, // OpenRouter, DeepSeek
/exceeds the limit of \d+/i, // GitHub Copilot
/exceeds the available context size/i, // llama.cpp server
/greater than the context length/i, // LM Studio
/context window exceeds limit/i, // MiniMax
/exceeded model token limit/i, // Kimi For Coding
/exceeded model token limit/i, // Kimi For Coding, Moonshot
/context[_ ]length[_ ]exceeded/i, // Generic fallback
/too many tokens/i, // Generic fallback
/token limit exceeded/i, // Generic fallback
]
function isOpenAiErrorRetryable(e: APICallError) {
+6 -1
View File
@@ -211,7 +211,12 @@ export namespace Provider {
const awsWebIdentityTokenFile = Env.get("AWS_WEB_IDENTITY_TOKEN_FILE")
if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile) return { autoload: false }
const containerCreds = Boolean(
process.env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI || process.env.AWS_CONTAINER_CREDENTIALS_FULL_URI,
)
if (!profile && !awsAccessKeyId && !awsBearerToken && !awsWebIdentityTokenFile && !containerCreds)
return { autoload: false }
const providerOptions: AmazonBedrockProviderSettings = {
region: defaultRegion,
+5 -23
View File
@@ -5,6 +5,7 @@ import type { JSONSchema } from "zod/v4/core"
import type { Provider } from "./provider"
import type { ModelsDev } from "./models"
import { iife } from "@/util/iife"
import { Flag } from "@/flag/flag"
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
@@ -17,6 +18,8 @@ function mimeToModality(mime: string): Modality | undefined {
}
export namespace ProviderTransform {
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
// Maps npm package to the key the AI SDK expects for providerOptions
function sdkKey(npm: string): string | undefined {
switch (npm) {
@@ -723,29 +726,8 @@ export namespace ProviderTransform {
return { [key]: options }
}
export function maxOutputTokens(
npm: string,
options: Record<string, any>,
modelLimit: number,
globalLimit: number,
): number {
const modelCap = modelLimit || globalLimit
const standardLimit = Math.min(modelCap, globalLimit)
if (npm === "@ai-sdk/anthropic" || npm === "@ai-sdk/google-vertex/anthropic") {
const thinking = options?.["thinking"]
const budgetTokens = typeof thinking?.["budgetTokens"] === "number" ? thinking["budgetTokens"] : 0
const enabled = thinking?.["type"] === "enabled"
if (enabled && budgetTokens > 0) {
// Return text tokens so that text + thinking <= model cap, preferring 32k text when possible.
if (budgetTokens + standardLimit <= modelCap) {
return standardLimit
}
return modelCap - budgetTokens
}
}
return standardLimit
export function maxOutputTokens(model: Provider.Model): number {
return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
}
export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 {
+41 -8
View File
@@ -6,7 +6,6 @@ import { Instance } from "../project/instance"
import { Provider } from "../provider/provider"
import { MessageV2 } from "./message-v2"
import z from "zod"
import { SessionPrompt } from "./prompt"
import { Token } from "../util/token"
import { Log } from "../util/log"
import { SessionProcessor } from "./processor"
@@ -14,6 +13,7 @@ import { fn } from "@/util/fn"
import { Agent } from "@/agent/agent"
import { Plugin } from "@/plugin"
import { Config } from "@/config/config"
import { ProviderTransform } from "@/provider/transform"
export namespace SessionCompaction {
const log = Log.create({ service: "session.compaction" })
@@ -27,15 +27,22 @@ export namespace SessionCompaction {
),
}
const COMPACTION_BUFFER = 20_000
export async function isOverflow(input: { tokens: MessageV2.Assistant["tokens"]; model: Provider.Model }) {
const config = await Config.get()
if (config.compaction?.auto === false) return false
const context = input.model.limit.context
if (context === 0) return false
const count = input.tokens.input + input.tokens.cache.read + input.tokens.output
const output = Math.min(input.model.limit.output, SessionPrompt.OUTPUT_TOKEN_MAX) || SessionPrompt.OUTPUT_TOKEN_MAX
const usable = input.model.limit.input || context - output
return count > usable
const count =
input.tokens.total ||
input.tokens.input + input.tokens.output + input.tokens.cache.read + input.tokens.cache.write
const reserved =
config.compaction?.reserved ?? Math.min(COMPACTION_BUFFER, ProviderTransform.maxOutputTokens(input.model))
const usable = input.model.limit.input ? input.model.limit.input - reserved : context - reserved
return count >= usable
}
export const PRUNE_MINIMUM = 20_000
@@ -139,8 +146,34 @@ export namespace SessionCompaction {
{ sessionID: input.sessionID },
{ context: [], prompt: undefined },
)
const defaultPrompt =
"Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next considering new session will not have access to our conversation."
const defaultPrompt = `Provide a detailed prompt for continuing our conversation above.
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
The summary that you construct will be used so that another agent can read it and continue the work.
When constructing the summary, try to stick to this template:
---
## Goal
[What goal(s) is the user trying to accomplish?]
## Instructions
- [What important instructions did the user give you that are relevant]
- [If there is a plan or spec, include information about it so next agent can continue using it]
## Discoveries
[What notable things were learned during this conversation that would be useful for the next agent to know when continuing the work]
## Accomplished
[What work has been completed, what work is still in progress, and what work is left?]
## Relevant files / directories
[Construct a structured list of relevant files that have been read, edited, or created that pertain to the task at hand. If all the files in a directory are relevant, include the path to the directory.]
---`
const promptText = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
const result = await processor.process({
user: userMessage,
@@ -181,7 +214,7 @@ export namespace SessionCompaction {
sessionID: input.sessionID,
type: "text",
synthetic: true,
text: "Continue if you have next steps",
text: "Continue if you have next steps, or stop and ask for clarification if you are unsure how to proceed.",
time: {
start: Date.now(),
end: Date.now(),
+45 -19
View File
@@ -4,7 +4,7 @@ import { BusEvent } from "@/bus/bus-event"
import { Bus } from "@/bus"
import { Decimal } from "decimal.js"
import z from "zod"
import { type LanguageModelUsage, type ProviderMetadata } from "ai"
import { type ProviderMetadata } from "ai"
import { Config } from "../config/config"
import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
@@ -22,6 +22,8 @@ import { Snapshot } from "@/snapshot"
import type { Provider } from "@/provider/provider"
import { PermissionNext } from "@/permission/next"
import { Global } from "@/global"
import type { LanguageModelV2Usage } from "@ai-sdk/provider"
import { iife } from "@/util/iife"
export namespace Session {
const log = Log.create({ service: "session" })
@@ -439,34 +441,58 @@ export namespace Session {
export const getUsage = fn(
z.object({
model: z.custom<Provider.Model>(),
usage: z.custom<LanguageModelUsage>(),
usage: z.custom<LanguageModelV2Usage>(),
metadata: z.custom<ProviderMetadata>().optional(),
}),
(input) => {
const cacheReadInputTokens = input.usage.cachedInputTokens ?? 0
const cacheWriteInputTokens = (input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0) as number
const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
const adjustedInputTokens = excludesCachedTokens
? (input.usage.inputTokens ?? 0)
: (input.usage.inputTokens ?? 0) - cacheReadInputTokens - cacheWriteInputTokens
const safe = (value: number) => {
if (!Number.isFinite(value)) return 0
return value
}
const inputTokens = safe(input.usage.inputTokens ?? 0)
const outputTokens = safe(input.usage.outputTokens ?? 0)
const reasoningTokens = safe(input.usage.reasoningTokens ?? 0)
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
const cacheWriteInputTokens = safe(
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
// @ts-expect-error
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
// @ts-expect-error
input.metadata?.["venice"]?.["usage"]?.["cacheCreationInputTokens"] ??
0) as number,
)
// OpenRouter provides inputTokens as the total count of input tokens (including cached).
// AFAIK other providers (OpenRouter/OpenAI/Gemini etc.) do it the same way e.g. vercel/ai#8794 (comment)
// Anthropic does it differently though - inputTokens doesn't include cached tokens.
// It looks like OpenCode's cost calculation assumes all providers return inputTokens the same way Anthropic does (I'm guessing getUsage logic was originally implemented with anthropic), so it's causing incorrect cost calculation for OpenRouter and others.
const excludesCachedTokens = !!(input.metadata?.["anthropic"] || input.metadata?.["bedrock"])
const adjustedInputTokens = safe(
excludesCachedTokens ? inputTokens : inputTokens - cacheReadInputTokens - cacheWriteInputTokens,
)
const total = iife(() => {
// Anthropic doesn't provide total_tokens, also ai sdk will vastly undercount if we
// don't compute from components
if (
input.model.api.npm === "@ai-sdk/anthropic" ||
input.model.api.npm === "@ai-sdk/amazon-bedrock" ||
input.model.api.npm === "@ai-sdk/google-vertex/anthropic"
) {
return adjustedInputTokens + outputTokens + cacheReadInputTokens + cacheWriteInputTokens
}
return input.usage.totalTokens
})
const tokens = {
input: safe(adjustedInputTokens),
output: safe(input.usage.outputTokens ?? 0),
reasoning: safe(input.usage?.reasoningTokens ?? 0),
total,
input: adjustedInputTokens,
output: outputTokens,
reasoning: reasoningTokens,
cache: {
write: safe(cacheWriteInputTokens),
read: safe(cacheReadInputTokens),
write: cacheWriteInputTokens,
read: cacheReadInputTokens,
},
}
+2 -10
View File
@@ -25,8 +25,7 @@ import { Auth } from "@/auth"
export namespace LLM {
const log = Log.create({ service: "llm" })
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
export type StreamInput = {
user: MessageV2.User
@@ -149,14 +148,7 @@ export namespace LLM {
)
const maxOutputTokens =
isCodex || provider.id.includes("github-copilot")
? undefined
: ProviderTransform.maxOutputTokens(
input.model.api.npm,
params.options,
input.model.limit.output,
OUTPUT_TOKEN_MAX,
)
isCodex || provider.id.includes("github-copilot") ? undefined : ProviderTransform.maxOutputTokens(input.model)
const tools = await resolveTools(input)
@@ -210,6 +210,7 @@ export namespace MessageV2 {
snapshot: z.string().optional(),
cost: z.number(),
tokens: z.object({
total: z.number().optional(),
input: z.number(),
output: z.number(),
reasoning: z.number(),
@@ -383,6 +384,7 @@ export namespace MessageV2 {
summary: z.boolean().optional(),
cost: z.number(),
tokens: z.object({
total: z.number().optional(),
input: z.number(),
output: z.number(),
reasoning: z.number(),
@@ -342,6 +342,9 @@ export namespace SessionProcessor {
stack: JSON.stringify(e.stack),
})
const error = MessageV2.fromError(e, { providerID: input.model.providerID })
if (MessageV2.ContextOverflowError.isInstance(error)) {
// TODO: Handle context overflow error
}
const retry = SessionRetry.retryable(error)
if (retry !== undefined) {
attempt++
-1
View File
@@ -52,7 +52,6 @@ globalThis.AI_SDK_LOG_WARNINGS = false
export namespace SessionPrompt {
const log = Log.create({ service: "session.prompt" })
export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
const state = Instance.state(
() => {
+1 -2
View File
@@ -59,9 +59,8 @@ export namespace SessionRetry {
}
export function retryable(error: ReturnType<NamedError["toObject"]>) {
// DO NOT retry context overflow errors
// context overflow errors should not be retried
if (MessageV2.ContextOverflowError.isInstance(error)) return undefined
if (MessageV2.APIError.isInstance(error)) {
if (!error.data.isRetryable) return undefined
if (error.data.responseBody?.includes("FreeUsageLimitError"))
@@ -175,100 +175,6 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
})
})
describe("ProviderTransform.maxOutputTokens", () => {
test("returns 32k when modelLimit > 32k", () => {
const modelLimit = 100000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns modelLimit when modelLimit < 32k", () => {
const modelLimit = 16000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/openai", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(16000)
})
describe("azure", () => {
test("returns 32k when modelLimit > 32k", () => {
const modelLimit = 100000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/azure", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns modelLimit when modelLimit < 32k", () => {
const modelLimit = 16000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/azure", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(16000)
})
})
describe("bedrock", () => {
test("returns 32k when modelLimit > 32k", () => {
const modelLimit = 100000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/amazon-bedrock", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns modelLimit when modelLimit < 32k", () => {
const modelLimit = 16000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/amazon-bedrock", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(16000)
})
})
describe("anthropic without thinking options", () => {
test("returns 32k when modelLimit > 32k", () => {
const modelLimit = 100000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns modelLimit when modelLimit < 32k", () => {
const modelLimit = 16000
const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", {}, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(16000)
})
})
describe("anthropic with thinking options", () => {
test("returns 32k when budgetTokens + 32k <= modelLimit", () => {
const modelLimit = 100000
const options = {
thinking: {
type: "enabled",
budgetTokens: 10000,
},
}
const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", options, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
test("returns modelLimit - budgetTokens when budgetTokens + 32k > modelLimit", () => {
const modelLimit = 50000
const options = {
thinking: {
type: "enabled",
budgetTokens: 30000,
},
}
const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", options, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(20000)
})
test("returns 32k when thinking type is not enabled", () => {
const modelLimit = 100000
const options = {
thinking: {
type: "disabled",
budgetTokens: 10000,
},
}
const result = ProviderTransform.maxOutputTokens("@ai-sdk/anthropic", options, modelLimit, OUTPUT_TOKEN_MAX)
expect(result).toBe(OUTPUT_TOKEN_MAX)
})
})
})
describe("ProviderTransform.schema - gemini array items", () => {
test("adds missing items for array properties", () => {
const geminiModel = {
@@ -15,6 +15,7 @@ function createModel(opts: {
output: number
input?: number
cost?: Provider.Model["cost"]
npm?: string
}): Provider.Model {
return {
id: "test-model",
@@ -34,7 +35,7 @@ function createModel(opts: {
input: { text: true, image: false, audio: false, video: false },
output: { text: true, image: false, audio: false, video: false },
},
api: { npm: "@ai-sdk/anthropic" },
api: { npm: opts.npm ?? "@ai-sdk/anthropic" },
options: {},
} as Provider.Model
}
@@ -70,7 +71,7 @@ describe("session.compaction.isOverflow", () => {
directory: tmp.path,
fn: async () => {
const model = createModel({ context: 100_000, output: 32_000 })
const tokens = { input: 50_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
const tokens = { input: 60_000, output: 10_000, reasoning: 0, cache: { read: 10_000, write: 0 } }
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
@@ -112,6 +113,86 @@ describe("session.compaction.isOverflow", () => {
})
})
// ─── Bug reproduction tests ───────────────────────────────────────────
// These tests demonstrate that when limit.input is set, isOverflow()
// does not subtract any headroom for the next model response. This means
// compaction only triggers AFTER we've already consumed the full input
// budget, leaving zero room for the next API call's output tokens.
//
// Compare: without limit.input, usable = context - output (reserves space).
// With limit.input, usable = limit.input (reserves nothing).
//
// Related issues: #10634, #8089, #11086, #12621
// Open PRs: #6875, #12924
test("BUG: no headroom when limit.input is set — compaction should trigger near boundary but does not", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Simulate Claude with prompt caching: input limit = 200K, output limit = 32K
const model = createModel({ context: 200_000, input: 200_000, output: 32_000 })
// We've used 198K tokens total. Only 2K under the input limit.
// On the next turn, the full conversation (198K) becomes input,
// plus the model needs room to generate output — this WILL overflow.
const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } }
// count = 180K + 3K + 15K = 198K
// usable = limit.input = 200K (no output subtracted!)
// 198K > 200K = false → no compaction triggered
// WITHOUT limit.input: usable = 200K - 32K = 168K, and 198K > 168K = true ✓
// WITH limit.input: usable = 200K, and 198K > 200K = false ✗
// With 198K used and only 2K headroom, the next turn will overflow.
// Compaction MUST trigger here.
expect(await SessionCompaction.isOverflow({ tokens, model })).toBe(true)
},
})
})
test("BUG: without limit.input, same token count correctly triggers compaction", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Same model but without limit.input — uses context - output instead
const model = createModel({ context: 200_000, output: 32_000 })
// Same token usage as above
const tokens = { input: 180_000, output: 15_000, reasoning: 0, cache: { read: 3_000, write: 0 } }
// count = 198K
// usable = context - output = 200K - 32K = 168K
// 198K > 168K = true → compaction correctly triggered
const result = await SessionCompaction.isOverflow({ tokens, model })
expect(result).toBe(true) // ← Correct: headroom is reserved
},
})
})
test("BUG: asymmetry — limit.input model allows 30K more usage before compaction than equivalent model without it", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
// Two models with identical context/output limits, differing only in limit.input
const withInputLimit = createModel({ context: 200_000, input: 200_000, output: 32_000 })
const withoutInputLimit = createModel({ context: 200_000, output: 32_000 })
// 170K total tokens — well above context-output (168K) but below input limit (200K)
const tokens = { input: 166_000, output: 10_000, reasoning: 0, cache: { read: 5_000, write: 0 } }
const withLimit = await SessionCompaction.isOverflow({ tokens, model: withInputLimit })
const withoutLimit = await SessionCompaction.isOverflow({ tokens, model: withoutInputLimit })
// Both models have identical real capacity — they should agree:
expect(withLimit).toBe(true) // should compact (170K leaves no room for 32K output)
expect(withoutLimit).toBe(true) // correctly compacts (170K > 168K)
},
})
})
test("returns false when model context limit is 0", async () => {
await using tmp = await tmpdir()
await Instance.provide({
@@ -290,4 +371,53 @@ describe("session.getUsage", () => {
expect(result.cost).toBe(3 + 1.5)
})
test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])(
"computes total from components for %s models",
(npm) => {
const model = createModel({ context: 100_000, output: 32_000, npm })
const usage = {
inputTokens: 1000,
outputTokens: 500,
// These providers typically report total as input + output only,
// excluding cache read/write.
totalTokens: 1500,
cachedInputTokens: 200,
}
if (npm === "@ai-sdk/amazon-bedrock") {
const result = Session.getUsage({
model,
usage,
metadata: {
bedrock: {
usage: {
cacheWriteInputTokens: 300,
},
},
},
})
expect(result.tokens.input).toBe(1000)
expect(result.tokens.cache.read).toBe(200)
expect(result.tokens.cache.write).toBe(300)
expect(result.tokens.total).toBe(2000)
return
}
const result = Session.getUsage({
model,
usage,
metadata: {
anthropic: {
cacheCreationInputTokens: 300,
},
},
})
expect(result.tokens.input).toBe(1000)
expect(result.tokens.cache.read).toBe(200)
expect(result.tokens.cache.write).toBe(300)
expect(result.tokens.total).toBe(2000)
},
)
})
+4 -28
View File
@@ -314,12 +314,7 @@ describe("session.llm.stream", () => {
expect(body.stream).toBe(true)
const maxTokens = (body.max_tokens as number | undefined) ?? (body.max_output_tokens as number | undefined)
const expectedMaxTokens = ProviderTransform.maxOutputTokens(
resolved.api.npm,
ProviderTransform.options({ model: resolved, sessionID }),
resolved.limit.output,
LLM.OUTPUT_TOKEN_MAX,
)
const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved)
expect(maxTokens).toBe(expectedMaxTokens)
const reasoning = (body.reasoningEffort as string | undefined) ?? (body.reasoning_effort as string | undefined)
@@ -442,12 +437,7 @@ describe("session.llm.stream", () => {
expect((body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
const maxTokens = body.max_output_tokens as number | undefined
const expectedMaxTokens = ProviderTransform.maxOutputTokens(
resolved.api.npm,
ProviderTransform.options({ model: resolved, sessionID }),
resolved.limit.output,
LLM.OUTPUT_TOKEN_MAX,
)
const expectedMaxTokens = ProviderTransform.maxOutputTokens(resolved)
expect(maxTokens).toBe(expectedMaxTokens)
},
})
@@ -565,14 +555,7 @@ describe("session.llm.stream", () => {
expect(capture.url.pathname.endsWith("/messages")).toBe(true)
expect(body.model).toBe(resolved.api.id)
expect(body.max_tokens).toBe(
ProviderTransform.maxOutputTokens(
resolved.api.npm,
ProviderTransform.options({ model: resolved, sessionID }),
resolved.limit.output,
LLM.OUTPUT_TOKEN_MAX,
),
)
expect(body.max_tokens).toBe(ProviderTransform.maxOutputTokens(resolved))
expect(body.temperature).toBe(0.4)
expect(body.top_p).toBe(0.9)
},
@@ -677,14 +660,7 @@ describe("session.llm.stream", () => {
expect(capture.url.pathname).toBe(pathSuffix)
expect(config?.temperature).toBe(0.3)
expect(config?.topP).toBe(0.8)
expect(config?.maxOutputTokens).toBe(
ProviderTransform.maxOutputTokens(
resolved.api.npm,
ProviderTransform.options({ model: resolved, sessionID }),
resolved.limit.output,
LLM.OUTPUT_TOKEN_MAX,
),
)
expect(config?.maxOutputTokens).toBe(ProviderTransform.maxOutputTokens(resolved))
},
})
})
@@ -112,6 +112,15 @@ describe("session.retry.retryable", () => {
const error = wrap("not-json")
expect(SessionRetry.retryable(error)).toBeUndefined()
})
test("does not retry context overflow errors", () => {
const error = new MessageV2.ContextOverflowError({
message: "Input exceeds context window of this model",
responseBody: '{"error":{"code":"context_length_exceeded"}}',
}).toObject() as ReturnType<NamedError["toObject"]>
expect(SessionRetry.retryable(error)).toBeUndefined()
})
})
describe("session.message-v2.fromError", () => {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.1.55",
"version": "1.1.57",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.1.55",
"version": "1.1.57",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -21,7 +21,7 @@ export function createOpencodeClient(config?: Config & { directory?: string }) {
if (config?.directory) {
config.headers = {
...config.headers,
"x-opencode-directory": config.directory,
"x-opencode-directory": encodeURIComponent(config.directory),
}
}
+6 -4
View File
@@ -203,6 +203,7 @@ export type AssistantMessage = {
summary?: boolean
cost: number
tokens: {
total?: number
input: number
output: number
reasoning: number
@@ -418,6 +419,7 @@ export type StepFinishPart = {
snapshot?: string
cost: number
tokens: {
total?: number
input: number
output: number
reasoning: number
@@ -1030,10 +1032,6 @@ export type KeybindsConfig = {
* Toggle model favorite status
*/
model_favorite_toggle?: string
/**
* Toggle showing all models
*/
model_show_all_toggle?: string
/**
* Share current session
*/
@@ -1826,6 +1824,10 @@ export type Config = {
* Enable pruning of old tool outputs (default: true)
*/
prune?: boolean
/**
* Token buffer for compaction. Leaves enough window to avoid overflow during compaction.
*/
reserved?: number
}
experimental?: {
disable_paste_summary?: boolean
+12
View File
@@ -6393,6 +6393,9 @@
"tokens": {
"type": "object",
"properties": {
"total": {
"type": "number"
},
"input": {
"type": "number"
},
@@ -7023,6 +7026,9 @@
"tokens": {
"type": "object",
"properties": {
"total": {
"type": "number"
},
"input": {
"type": "number"
},
@@ -9894,6 +9900,12 @@
"prune": {
"description": "Enable pruning of old tool outputs (default: true)",
"type": "boolean"
},
"reserved": {
"description": "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
"type": "integer",
"minimum": 0,
"maximum": 9007199254740991
}
}
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.1.55",
"version": "1.1.57",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.1.55",
"version": "1.1.57",
"type": "module",
"license": "MIT",
"exports": {
+70 -32
View File
@@ -877,6 +877,74 @@ ToolRegistry.register({
const data = useData()
const i18n = useI18n()
const childSessionId = () => props.metadata.sessionId as string | undefined
const href = createMemo(() => {
const sessionId = childSessionId()
if (!sessionId) return
const direct = data.sessionHref?.(sessionId)
if (direct) return direct
if (typeof window === "undefined") return
const path = window.location.pathname
const idx = path.indexOf("/session")
if (idx === -1) return
return `${path.slice(0, idx)}/session/${sessionId}`
})
createEffect(() => {
const sessionId = childSessionId()
if (!sessionId) return
const sync = data.syncSession
if (!sync) return
Promise.resolve(sync(sessionId)).catch(() => undefined)
})
const handleLinkClick = (e: MouseEvent) => {
const sessionId = childSessionId()
const url = href()
if (!sessionId || !url) return
e.stopPropagation()
if (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return
const nav = data.navigateToSession
if (!nav || typeof window === "undefined") return
e.preventDefault()
const before = window.location.pathname + window.location.search + window.location.hash
nav(sessionId)
setTimeout(() => {
const after = window.location.pathname + window.location.search + window.location.hash
if (after === before) window.location.assign(url)
}, 50)
}
const trigger = () => (
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title" class="capitalize">
{i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool })}
</span>
<Show when={props.input.description}>
<Switch>
<Match when={href()}>
{(url) => (
<a data-slot="basic-tool-tool-subtitle" class="clickable" href={url()} onClick={handleLinkClick}>
{props.input.description}
</a>
)}
</Match>
<Match when={true}>
<span data-slot="basic-tool-tool-subtitle">{props.input.description}</span>
</Match>
</Switch>
</Show>
</div>
</div>
)
const childToolParts = createMemo(() => {
const sessionId = childSessionId()
if (!sessionId) return []
@@ -924,13 +992,6 @@ ToolRegistry.register({
})
}
const handleSubtitleClick = () => {
const sessionId = childSessionId()
if (sessionId && data.navigateToSession) {
data.navigateToSession(sessionId)
}
}
const renderChildToolPart = () => {
const toolData = childToolPart()
if (!toolData) return null
@@ -958,21 +1019,7 @@ ToolRegistry.register({
<Switch>
<Match when={childPermission()}>
<>
<Show
when={childToolPart()}
fallback={
<BasicTool
icon="task"
defaultOpen={true}
trigger={{
title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }),
titleClass: "capitalize",
subtitle: props.input.description,
}}
onSubtitleClick={handleSubtitleClick}
/>
}
>
<Show when={childToolPart()} fallback={<BasicTool icon="task" defaultOpen={true} trigger={trigger()} />}>
{renderChildToolPart()}
</Show>
<div data-component="permission-prompt">
@@ -991,16 +1038,7 @@ ToolRegistry.register({
</>
</Match>
<Match when={true}>
<BasicTool
icon="task"
defaultOpen={true}
trigger={{
title: i18n.t("ui.tool.agent", { type: props.input.subagent_type || props.tool }),
titleClass: "capitalize",
subtitle: props.input.description,
}}
onSubtitleClick={handleSubtitleClick}
>
<BasicTool icon="task" defaultOpen={true} trigger={trigger()}>
<div
ref={autoScroll.scrollRef}
onScroll={autoScroll.handleScroll}
+8
View File
@@ -48,6 +48,10 @@ export type QuestionRejectFn = (input: { requestID: string }) => void
export type NavigateToSessionFn = (sessionID: string) => void
export type SessionHrefFn = (sessionID: string) => string
export type SyncSessionFn = (sessionID: string) => void | Promise<void>
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: {
@@ -57,6 +61,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
onQuestionReply?: QuestionReplyFn
onQuestionReject?: QuestionRejectFn
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
onSyncSession?: SyncSessionFn
}) => {
return {
get store() {
@@ -69,6 +75,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
replyToQuestion: props.onQuestionReply,
rejectQuestion: props.onQuestionReject,
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
syncSession: props.onSyncSession,
}
},
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.1.55",
"version": "1.1.57",
"private": true,
"type": "module",
"license": "MIT",
-1
View File
@@ -179,7 +179,6 @@ export default defineConfig({
"enterprise",
"troubleshooting",
"windows-wsl",
"1-0",
{
label: "Usage",
translations: {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.1.55",
"version": "1.1.57",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -110,7 +110,7 @@ const discord = config.social?.find((item) => item.icon === "discord")
}
.doc :global(starlight-lang-select select) {
min-width: 7em;
min-width: 15em;
}
@media (min-width: 30rem) {
-67
View File
@@ -1,67 +0,0 @@
---
title: Migrating to 1.0
description: What's new in OpenCode 1.0.
---
OpenCode 1.0 is a complete rewrite of the TUI.
We moved from the go+bubbletea based TUI which had performance and capability issues to an in-house framework (OpenTUI) written in zig+solidjs.
The new TUI works like the old one since it connects to the same opencode server.
---
## Upgrading
You should not be autoupgraded to 1.0 if you are currently using a previous
version. However some older versions of OpenCode always grab latest.
To upgrade manually, run
```bash
$ opencode upgrade 1.0.0
```
To downgrade back to 0.x, run
```bash
$ opencode upgrade 0.15.31
```
---
## UX changes
The session history is more compressed, only showing full details of the edit and bash tool.
We added a command bar which almost everything flows through. Press ctrl+p to bring it up in any context and see everything you can do.
Added a session sidebar (can be toggled) with useful information.
We removed some functionality that we weren't sure anyone actually used. If something important is missing please open an issue and we'll add it back quickly.
---
## Breaking changes
### Keybinds renamed
- messages_revert -> messages_undo
- switch_agent -> agent_cycle
- switch_agent_reverse -> agent_cycle_reverse
- switch_mode -> agent_cycle
- switch_mode_reverse -> agent_cycle_reverse
### Keybinds removed
- messages_layout_toggle
- messages_next
- messages_previous
- file_diff_toggle
- file_search
- file_close
- file_list
- app_help
- project_init
- tool_details
- thinking_blocks
-67
View File
@@ -1,67 +0,0 @@
---
title: الانتقال إلى 1.0
description: ما الجديد في OpenCode 1.0.
---
OpenCode 1.0 هو إعادة كتابة كاملة لواجهة TUI.
انتقلنا من واجهة TUI المبنية على go+bubbletea، والتي كانت تعاني من مشاكل في الأداء والإمكانات، إلى إطار عمل داخلي (OpenTUI) مكتوب بـ zig+solidjs.
تعمل واجهة TUI الجديدة مثل القديمة لأنها تتصل بخادم opencode نفسه.
---
## الترقية
لا يفترض أن تتم ترقيتك تلقائيا إلى 1.0 إذا كنت تستخدم حاليا إصدارا أقدم.
لكن بعض الإصدارات القديمة من OpenCode كانت تجلب دائما أحدث إصدار.
للترقية يدويا، شغّل
```bash
$ opencode upgrade 1.0.0
```
للرجوع إلى 0.x، شغّل
```bash
$ opencode upgrade 0.15.31
```
---
## تغييرات تجربة الاستخدام
أصبح سجل الجلسة أكثر اختصارا، ولا يعرض التفاصيل الكاملة إلا لأداتي edit و bash.
أضفنا شريط أوامر تمر عبره تقريبا كل العمليات. اضغط ctrl+p لإظهاره في أي سياق ولرؤية كل ما يمكنك فعله.
أضفنا شريطا جانبيا للجلسة (يمكن إظهاره/إخفاؤه) يحتوي على معلومات مفيدة.
أزلنا بعض الوظائف التي لم نكن متأكدين أن أحدا يستخدمها فعلا. إذا كانت هناك ميزة مهمة مفقودة، فالرجاء فتح issue وسنعيدها بسرعة.
---
## تغييرات غير متوافقة
### إعادة تسمية اختصارات المفاتيح
- messages_revert -> messages_undo
- switch_agent -> agent_cycle
- switch_agent_reverse -> agent_cycle_reverse
- switch_mode -> agent_cycle
- switch_mode_reverse -> agent_cycle_reverse
### إزالة اختصارات المفاتيح
- messages_layout_toggle
- messages_next
- messages_previous
- file_diff_toggle
- file_search
- file_close
- file_list
- app_help
- project_init
- tool_details
- thinking_blocks
-61
View File
@@ -1,61 +0,0 @@
---
title: Migracija na 1.0
description: Šta je novo u OpenCode 1.0.
---
OpenCode 1.0 je potpuna revizija TUI-ja.
Prešli smo sa TUI zasnovanog na go+bubbletea koji je imao problema sa performansama i mogućnostima na interni okvir (OpenTUI) napisan u zig+solidjs.
Novi TUI radi kao stari jer se povezuje na isti opencode server.
---
## Upgrade
Ne biste trebali biti automatski nadograđeni na 1.0 ako trenutno koristite prethodnu
verzija. Međutim, neke starije verzije OpenCode uvijek uzimaju najnoviju verziju.
Za ručnu nadogradnju, pokrenite
```bash
$ opencode upgrade 1.0.0
```
Za vraćanje na 0.x, pokrenite
```bash
$ opencode upgrade 0.15.31
```
---
## UX promjene
Historija sesije je komprimovanija i prikazuje samo sve detalje alata za uređivanje i bash.
Dodali smo komandnu traku kroz koju prolazi skoro sve. Pritisnite ctrl+p da prikažete u bilo kom kontekstu i vidite sve što možete učiniti.
Dodatna bočna traka sesije (može se prebaciti) sa korisnim informacijama.
Uklonili smo neke funkcije za koje nismo bili sigurni da ih neko koristi. Ako nešto važno nedostaje, otvorite problem i brzo ćemo ga dodati.
---
## Revolucionarne promjene
### Tastature su preimenovane
- povratne_poruke -> poništite_poruke
- switch_agent -> agent_cycle
- switch_agent_reverse -> agent_cycle_reverse
- switch_mode -> agent_cycle
- switch_mode_reverse -> agent_cycle_reverse
### Linkovi za degustatore su uklonjeni
- messages_layout_toggle
- poruke_sljedeće
- messages_previous
- file_diff_toggle
- file_search
- file_close
- lista_datoteka
- app_help
- project_init
- detalji alata
- razmišljanje_blokovi
+1 -1
View File
@@ -149,7 +149,7 @@ Neke ugrađene komande kao što su `/undo` i `/redo` trenutno nisu podržane.
:::
- Ugrađeni alati (operacije sa datotekama, naredbe terminala, itd.)
- Prilagođeni alati i komande kosih crta
- Prilagođeni alati i slash komande
- MCP serveri konfigurisani u vašoj OpenCode konfiguraciji
- Pravila specifična za projekat `AGENTS.md`
- Prilagođeni formateri i linteri
+1 -1
View File
@@ -593,5 +593,5 @@ Ove varijable okruženja omogućavaju eksperimentalne karakteristike koje se mog
| `OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER` | boolean | Onemogući praćenje datoteka |
| `OPENCODE_EXPERIMENTAL_EXA` | boolean | Omogući eksperimentalne Exa funkcije |
| `OPENCODE_EXPERIMENTAL_LSP_TY` | boolean | Omogući eksperimentalnu provjeru tipa LSP |
| `OPENCODE_EXPERIMENTAL_MARKDOWN` | boolean | Omogući eksperimentalne funkcije smanjenja |
| `OPENCODE_EXPERIMENTAL_MARKDOWN` | boolean | Omogući eksperimentalne Markdown funkcije |
| `OPENCODE_EXPERIMENTAL_PLAN_MODE` | boolean | Omogući režim plana |
+7 -7
View File
@@ -93,7 +93,7 @@ Globalna konfiguracija poništava zadane postavke udaljene organizacije.
---
### Nakon projekta
### Project
Dodajte `opencode.json` u korijen projekta. Konfiguracija projekta ima najveći prioritet među standardnim konfiguracijskim datotekama - ona nadjačava globalne i udaljene konfiguracije.
:::tip
@@ -104,7 +104,7 @@ Ovo je također sigurno provjeriti u Git i koristi istu shemu kao globalna.
---
### Custom track
### Custom config
Navedite prilagođenu putanju konfiguracijske datoteke koristeći varijablu okruženja `OPENCODE_CONFIG`.
@@ -117,7 +117,7 @@ Prilagođena konfiguracija se učitava između globalne i projektne konfiguracij
---
### Prilagođeni imenik
### Custom directory
Navedite prilagođeni konfiguracijski direktorij koristeći `OPENCODE_CONFIG_DIR`
varijabla okruženja. U ovom direktoriju će se tražiti agenti, komande,
@@ -194,7 +194,7 @@ Dostupne opcije:
---
### Uvijek
### Tools
Možete upravljati alatima koje LLM može koristiti putem opcije `tools`.
@@ -281,7 +281,7 @@ Amazon Bedrock podržava konfiguraciju specifičnu za AWS:
---
### Domaći
### Theme
Možete konfigurirati temu koju želite koristiti u svojoj OpenCode konfiguraciji putem opcije `theme`.
@@ -358,7 +358,7 @@ Ovo traje:
---
### komandante
### Command
Možete konfigurirati prilagođene komande za ponavljanje zadataka putem opcije `command`.
@@ -415,7 +415,7 @@ Imajte na umu da ovo funkcionira samo ako nije instalirano pomoću upravitelja p
---
### Trenerke
### Formatters
Možete konfigurirati formatere koda putem opcije `formatter`.
@@ -1,5 +1,5 @@
---
title: Trenerke
title: Formatters
description: OpenCode koristi formatere specifične za jezik.
---
@@ -14,8 +14,8 @@ OpenCode dolazi sa nekoliko ugrađenih formatera za popularne jezike i okvire. I
|-------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| gofmt | .go | `gofmt` komanda dostupna |
| mix | .ex, .exs, .eex, .heex, .leex, .neex, .sface | `mix` komanda dostupna |
| ljepše | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml i [više](https://prettier.io/docs/en/index.html) | `prettier` zavisnost u `package.json` |
| biom | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml i [više](https://biomejs.dev/) | `biome.json(c)` konfiguracijski fajl |
| prettier | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml i [više](https://prettier.io/docs/en/index.html) | `prettier` zavisnost u `package.json` |
| biome | .js, .jsx, .ts, .tsx, .html, .css, .md, .json, .yaml i [više](https://biomejs.dev/) | `biome.json(c)` konfiguracijski fajl |
| zig | .zig, .zon | `zig` komanda dostupna |
| clang-format | .c, .cpp, .h, .hpp, .ino i [više](https://clang.llvm.org/docs/ClangFormat.html) | `.clang-format` konfiguracijski fajl |
| ktlint | .kt, .kts | `ktlint` komanda dostupna |
@@ -25,7 +25,7 @@ OpenCode dolazi sa nekoliko ugrađenih formatera za popularne jezike i okvire. I
| uv | .py, .pyi | `uv` komanda dostupna || rubocop | .rb, .rake, .gemspec, .ru | `rubocop` komanda dostupna |
| standardrb | .rb, .rake, .gemspec, .ru | `standardrb` komanda dostupna |
| htmlbeautifier | .erb, .html.erb | `htmlbeautifier` komanda dostupna |
| zrak | .R | `air` komanda dostupna |
| air | .R | `air` komanda dostupna |
| dart | .dart | `dart` komanda dostupna |
| ocamlformat | .ml, .mli | `ocamlformat` komanda dostupna i `.ocamlformat` konfiguracioni fajl |
| terraform | .tf, .tfvars | `terraform` komanda dostupna |
@@ -61,7 +61,7 @@ Možete prilagoditi formatere kroz `formatter` odjeljak u vašoj OpenCode konfig
```
Svaka konfiguracija formatera podržava sljedeće:
| Nekretnine | Vrsta | Opis
| Svojstvo | Vrsta | Opis
|------------- | -------- | ------------------------------------------------------- |
| `disabled` | boolean | Postavite ovo na `true` da onemogućite formater |
| `command` | string[] | Naredba za pokretanje za formatiranje |
+3 -3
View File
@@ -3,7 +3,7 @@ title: IDE
description: Ekstenzija OpenCode za VS Code, Cursor i druge IDE
---
OpenCode se integriše sa VS kodom, kursorom ili bilo kojim IDE-om koji podržava terminal. Samo pokrenite `opencode` u terminalu da započnete.
OpenCode se integriše sa VS kodom, Cursor-om ili bilo kojim IDE-om koji podržava terminal. Samo pokrenite `opencode` u terminalu da započnete.
---
@@ -40,8 +40,8 @@ Ako se ekstenzija ne uspije automatski instalirati:
- Uvjerite se da koristite `opencode` u integriranom terminalu.
- Potvrdite da je CLI za vaš IDE instaliran:
- Za VS kod: `code` naredbu
- Za kursor: `cursor` naredba
- Za jedrenje na dasci: `windsurf` komanda
- Za Cursor: `cursor` naredba
- Za Windsurf: `windsurf` komanda
- Za VSCodium: `codium` komanda
- Ako ne, pokrenite `Cmd+Shift+P` (Mac) ili `Ctrl+Shift+P` (Windows/Linux) i potražite "Shell Command: Install 'code' command in PATH" (ili ekvivalent za vaš IDE)
- Osigurajte da VS Code ima dozvolu za instaliranje ekstenzija
+2 -2
View File
@@ -8,7 +8,7 @@ import config from "../../../../config.mjs"
export const console = config.console
[**OpenCode**](/) je agent za AI kodiranje otvorenog koda. Dostupan je kao interfejs baziran na terminalu, desktop aplikacija ili IDE ekstenzija.
![OpenCode TUI sa temom otvorenog koda](../../../assets/lander/screenshot.png)
![OpenCode TUI sa opencode temom](../../../assets/lander/screenshot.png)
Hajde da počnemo.
---
@@ -90,7 +90,7 @@ Također ga možete instalirati pomoću sljedećih naredbi:
Za najbolje iskustvo na Windows-u preporučujemo korištenje [Windows Subsystem for Linux (WSL)](/docs/windows-wsl). Pruža bolje performanse i potpunu kompatibilnost sa OpenCode karakteristikama.
:::
- **Upotreba čokolade**
- **Upotreba Chocolatey-a**
```bash
choco install opencode
@@ -140,10 +140,10 @@ Unos prompta aplikacije OpenCode za desktop podržava uobičajene prečice u sti
| `alt+b` | Pomeri kursor za jednu reč unazad |
| `alt+f` | Pomeri kursor za jednu reč unapred |
| `ctrl+d` | Izbriši znak ispod kursora |
| `ctrl+k` | Ubiti do kraja reda |
| `ctrl+k` | Kill do kraja reda |
| `ctrl+u` | Kill do početka reda |
| `ctrl+w` | Ubiti prethodnu riječ |
| `alt+d` | Ubiti sljedeću riječ |
| `ctrl+w` | Kill prethodnu riječ |
| `alt+d` | Kill sljedeću riječ |
| `ctrl+t` | Transponirajte znakove |
| `ctrl+g` | Otkaži iskakanje / poništi odgovor na pokretanje |

Some files were not shown because too many files have changed in this diff Show More