Compare commits

...

39 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
Adam 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 Cline 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
opencode-agent[bot] 4f6b929784 chore: generate 2026-02-10 20:22:31 +00:00
Adam 55119559b3 fix(app): don't scroll code search input 2026-02-10 20:22:31 +00:00
Adam fd5531316f fix(docs): locale translations 2026-02-10 20:22:30 +00:00
433 changed files with 7559 additions and 7819 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.
@@ -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
View File
@@ -1161,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
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
@@ -1822,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": {
+88 -82
View File
@@ -1,5 +1,6 @@
import { type FileContents, File, FileOptions, LineAnnotation, type SelectedLineRange } from "@pierre/diffs"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Portal } from "solid-js/web"
import { createDefaultOptions, styleVariables } from "../pierre"
import { getWorkerPool } from "../pierre/worker"
import { Icon } from "./icon"
@@ -125,11 +126,9 @@ export function Code<T>(props: CodeProps<T>) {
let wrapper!: HTMLDivElement
let container!: HTMLDivElement
let findInput: HTMLInputElement | undefined
let findBar: HTMLDivElement | undefined
let findOverlay!: HTMLDivElement
let findOverlayFrame: number | undefined
let findOverlayScroll: HTMLElement[] = []
let findScroll: HTMLElement | undefined
let observer: MutationObserver | undefined
let renderToken = 0
let selectionFrame: number | undefined
@@ -159,6 +158,8 @@ export function Code<T>(props: CodeProps<T>) {
let findMode: "highlights" | "overlay" = "overlay"
let findHits: Range[] = []
const [findPos, setFindPos] = createSignal<{ top: number; right: number }>({ top: 8, right: 8 })
const file = createMemo(
() =>
new File<T>(
@@ -291,23 +292,26 @@ export function Code<T>(props: CodeProps<T>) {
setFindIndex(0)
}
const getScrollParent = (el: HTMLElement): HTMLElement | null => {
const getScrollParent = (el: HTMLElement): HTMLElement | undefined => {
let parent = el.parentElement
while (parent) {
const style = getComputedStyle(parent)
if (style.overflowY === "auto" || style.overflowY === "scroll") return parent
parent = parent.parentElement
}
return null
}
const positionFindBar = () => {
if (!findBar || !wrapper) return
const scrollTop = findScroll ? findScroll.scrollTop : window.scrollY
findBar.style.position = "absolute"
findBar.style.top = `${scrollTop + 8}px`
findBar.style.right = "8px"
findBar.style.left = ""
if (typeof window === "undefined") return
const root = getScrollParent(wrapper) ?? wrapper
const rect = root.getBoundingClientRect()
const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height"))
const header = Number.isNaN(title) ? 0 : title
setFindPos({
top: Math.round(rect.top) + header - 4,
right: Math.round(window.innerWidth - rect.right) + 8,
})
}
const scanFind = (root: ShadowRoot, query: string) => {
@@ -426,7 +430,6 @@ export function Code<T>(props: CodeProps<T>) {
}
if (opts?.scroll && active) {
scrollToRange(active)
positionFindBar()
}
return
}
@@ -435,7 +438,6 @@ export function Code<T>(props: CodeProps<T>) {
syncOverlayScroll()
if (opts?.scroll && active) {
scrollToRange(active)
positionFindBar()
}
scheduleOverlay()
}
@@ -464,14 +466,12 @@ export function Code<T>(props: CodeProps<T>) {
return
}
scrollToRange(active)
positionFindBar()
return
}
clearHighlightFind()
syncOverlayScroll()
scrollToRange(active)
positionFindBar()
scheduleOverlay()
}
@@ -484,11 +484,9 @@ export function Code<T>(props: CodeProps<T>) {
findCurrent = host
findTarget = host
findScroll = getScrollParent(wrapper) ?? undefined
if (!findOpen()) setFindOpen(true)
requestAnimationFrame(() => {
applyFind({ scroll: true })
positionFindBar()
findInput?.focus()
findInput?.select()
})
@@ -514,18 +512,18 @@ export function Code<T>(props: CodeProps<T>) {
createEffect(() => {
if (!findOpen()) return
findScroll = getScrollParent(wrapper) ?? undefined
const target = findScroll ?? window
const handler = () => positionFindBar()
target.addEventListener("scroll", handler, { passive: true })
window.addEventListener("resize", handler, { passive: true })
handler()
const update = () => positionFindBar()
requestAnimationFrame(update)
window.addEventListener("resize", update, { passive: true })
const root = getScrollParent(wrapper) ?? wrapper
const observer = typeof ResizeObserver === "undefined" ? undefined : new ResizeObserver(() => update())
observer?.observe(root)
onCleanup(() => {
target.removeEventListener("scroll", handler)
window.removeEventListener("resize", handler)
findScroll = undefined
window.removeEventListener("resize", update)
observer?.disconnect()
})
})
@@ -916,6 +914,64 @@ export function Code<T>(props: CodeProps<T>) {
pendingSelectionEnd = false
})
const FindBar = (barProps: { class: string; style?: ComponentProps<"div">["style"] }) => (
<div class={barProps.class} style={barProps.style} onPointerDown={(e) => e.stopPropagation()}>
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
<input
ref={findInput}
placeholder="Find"
value={findQuery()}
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
onInput={(e) => {
setFindQuery(e.currentTarget.value)
setFindIndex(0)
applyFind({ reset: true, scroll: true })
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
closeFind()
return
}
if (e.key !== "Enter") return
e.preventDefault()
stepFind(e.shiftKey ? -1 : 1)
}}
/>
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
</div>
<div class="flex items-center">
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Previous match"
onClick={() => stepFind(-1)}
>
<Icon name="chevron-down" size="small" class="rotate-180" />
</button>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Next match"
onClick={() => stepFind(1)}
>
<Icon name="chevron-down" size="small" />
</button>
</div>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
aria-label="Close search"
onClick={closeFind}
>
<Icon name="close-small" size="small" />
</button>
</div>
)
return (
<div
data-component="code"
@@ -936,65 +992,15 @@ export function Code<T>(props: CodeProps<T>) {
}}
>
<Show when={findOpen()}>
<div
ref={findBar}
class="z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
onPointerDown={(e) => e.stopPropagation()}
>
<Icon name="magnifying-glass" size="small" class="text-text-weak shrink-0" />
<input
ref={findInput}
placeholder="Find"
value={findQuery()}
class="w-40 bg-transparent outline-none text-14-regular text-text-strong placeholder:text-text-weak"
onInput={(e) => {
setFindQuery(e.currentTarget.value)
setFindIndex(0)
applyFind({ reset: true, scroll: true })
}}
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault()
closeFind()
return
}
if (e.key !== "Enter") return
e.preventDefault()
stepFind(e.shiftKey ? -1 : 1)
<Portal>
<FindBar
class="fixed z-50 flex h-8 items-center gap-2 rounded-md border border-border-base bg-background-base px-3 shadow-md"
style={{
top: `${findPos().top}px`,
right: `${findPos().right}px`,
}}
/>
<div class="shrink-0 text-12-regular text-text-weak tabular-nums text-right" style={{ width: "10ch" }}>
{findCount() ? `${findIndex() + 1}/${findCount()}` : "0/0"}
</div>
<div class="flex items-center">
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Previous match"
onClick={() => stepFind(-1)}
>
<Icon name="chevron-down" size="small" class="rotate-180" />
</button>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none"
disabled={findCount() === 0}
aria-label="Next match"
onClick={() => stepFind(1)}
>
<Icon name="chevron-down" size="small" />
</button>
</div>
<button
type="button"
class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong"
aria-label="Close search"
onClick={closeFind}
>
<Icon name="close-small" size="small" />
</button>
</div>
</Portal>
</Show>
<div ref={container} />
<div ref={findOverlay} class="pointer-events-none absolute inset-0 z-0" />
+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
+1 -1
View File
@@ -460,7 +460,7 @@ permission:
webfetch: deny
---
حلل الشفرة فقط واقترح التغييرات.
Only analyze code and suggest changes.
```
يمكنك ضبط الأذونات لأوامر bash محددة.
-62
View File
@@ -1,62 +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
+113 -90
View File
@@ -4,38 +4,42 @@ description: Konfigurirajte i koristite specijalizirane agente.
---
Agenti su specijalizirani AI asistenti koji se mogu konfigurirati za specifične zadatke i tokove posla. Oni vam omogućavaju da kreirate fokusirane alate sa prilagođenim upitima, modelima i pristupom alatima.
:::tip
Koristite agenta plana za analizu koda i pregled prijedloga bez ikakvih promjena koda.
:::
Možete se prebacivati između agenata tokom sesije ili ih pozvati spominjanjem `@`.
---
## Vrsta
## Vrste
Postoje dvije vrste agenata u OpenCode; primarni agensi i subagensi.
Postoje dvije vrste agenata u OpenCode; primarni agenti i podagenti.
---
### Primarni agenti
Primarni agenti su glavni pomoćnici s kojima direktno komunicirate. Možete se kretati kroz njih pomoću tipke **Tab** ili vašeg konfigurisanog povezivanja tipki `switch_agent`. Ovi agenti vode vaš glavni razgovor. Pristup alatima se konfiguriše putem dozvola — na primjer, Build ima omogućene sve alate dok je Plan ograničen.
:::tip
Možete koristiti tipku **Tab** za prebacivanje između primarnih agenata tokom sesije.
:::
OpenCode dolazi sa dva ugrađena primarna agenta, **Build** i **Plan**. Hoćemo
pogledajte ove u nastavku.
OpenCode dolazi sa dva ugrađena primarna agenta, **Build** i **Plan**. Pogledat ćemo ih u nastavku.
---
### Subagent
### Subagenti
Subagenti su specijalizovani pomoćnici koje primarni agenti mogu pozvati za određene zadatke. Možete ih i ručno pozvati **@ spominjanjem** u svojim porukama.
OpenCode dolazi sa dva ugrađena subagenta, **General** i **Explore**. Ovo ćemo pogledati u nastavku.
---
## Embedded
## Ugrađeni
OpenCode dolazi sa dva ugrađena primarna agenta i dva ugrađena subagenta.
@@ -43,54 +47,62 @@ OpenCode dolazi sa dva ugrađena primarna agenta i dva ugrađena subagenta.
### Koristi build
_Način_: `primary`
_Mode_: `primary`
Build je **podrazumevani** primarni agent sa svim omogućenim alatima. Ovo je standardni agent za razvojni rad gdje vam je potreban pun pristup operacijama datoteka i sistemskim komandama.
---
### Koristite plan
### Koristi plan
_Način_: `primary`
Konačan agent dizajniran za planiranje i analizu. Koristimo sistem dozvola kako bismo vam pružili veću kontrolu i spriječili neželjene promjene.
_Mode_: `primary`
Ograničeni agent dizajniran za planiranje i analizu. Koristimo sistem dozvola kako bismo vam pružili veću kontrolu i spriječili neželjene promjene.
Prema zadanim postavkama, sve sljedeće je postavljeno na `ask`:
- `file edits`: Sva upisivanja, zakrpe i uređivanja
- `bash`: Sve bash komande
Ovaj agent je koristan kada želite da LLM analizira kod, predloži promjene ili kreira planove bez stvarnih modifikacija vaše baze koda.
Ovaj agent je koristan kada želite da LLM analizira kod, predloži promjene ili kreira planove bez stvarnih modifikacija vaše baze koda.
---
### Upotreba općenito
### Koristi general
_Način_: `subagent`
Agent opće namjene za istraživanje složenih pitanja i izvršavanje zadataka u više koraka. Ima potpuni pristup alatima (osim zadataka), tako da može mijenjati fajl kada je to potrebno. Koristite ovo za paralelno pokretanje više jedinica rada.
_Mode_: `subagent`
Agent opće namjene za istraživanje složenih pitanja i izvršavanje zadataka u više koraka. Ima potpuni pristup alatima (osim todo), tako da može mijenjati fajlove kada je to potrebno. Koristite ovo za paralelno pokretanje više jedinica rada.
---
### Koristite explore
### Koristi explore
_Mode_: `subagent`
_Način_: `subagent`
Brzi agent samo za čitanje za istraživanje kodnih baza. Nije moguće mijenjati fajlove. Koristite ovo kada trebate brzo pronaći datoteke po uzorku, pretražiti kod za ključne riječi ili odgovoriti na pitanja o bazi kodova.
---
### Koristite zbijanje
### Koristi compaction
_Mode_: `primary`
_Način_: `primary`
Skriveni sistemski agent koji sažima dugi kontekst u manji sažetak. Pokreće se automatski kada je potrebno i ne može se odabrati u korisničkom interfejsu.
---
### Koristite naslov
### Koristi title
_Mode_: `primary`
_Način_: `primary`
Skriveni sistemski agent koji generiše kratke naslove sesija. Pokreće se automatski i ne može se odabrati u korisničkom interfejsu.
---
### Koristi sažetak
### Koristi summary
_Mode_: `primary`
_Način_: `primary`
Skriveni sistemski agent koji kreira sažetke sesije. Pokreće se automatski i ne može se odabrati u korisničkom interfejsu.
---
@@ -98,25 +110,24 @@ Skriveni sistemski agent koji kreira sažetke sesije. Pokreće se automatski i n
## Upotreba
1. Za primarne agente, koristite taster **Tab** za kretanje kroz njih tokom sesije. Također možete koristiti svoju konfiguriranu vezu tipke `switch_agent`.
2. Subagenti se mogu pozvati:
- **Automatski** od strane primarnih agenata za specijalizovane zadatke na osnovu njihovih opisa.
- Ručno **@ spominjanjem** subagenta u vašoj poruci. Na primjer.
- **Automatski** od strane primarnih agenata za specijalizovane zadatke na osnovu njihovih opisa.
- Ručno **@ spominjanjem** subagenta u vašoj poruci. Na primjer.
```txt frame="none"
```txt frame="none"
@general help me search for this function
```
```
3. **Navigacija između sesija**: Kada subagenti kreiraju vlastite podređene sesije, možete se kretati između roditeljske sesije i svih podređenih sesija koristeći:
- **\<Leader>+Right** (ili vaša konfigurirana `session_child_cycle` veza) za kretanje naprijed kroz roditelj → dijete1 → dijete2 → ... → roditelj
- **\<Leader>+Left** (ili vaše konfigurirano povezivanje tipki `session_child_cycle_reverse`) za kretanje unazad kroz roditelj ← dijete1 ← dijete2 ← ... ← roditelj
- **\<Leader>+Desno** (ili vaša konfigurirana `session_child_cycle` veza) za petlju naprijed kroz roditelj → dijete1 → dijete2 → ... → roditelj
- **\<Leader>+Levo** (ili vaše konfigurirano povezivanje tipki `session_child_cycle_reverse`) za kretanje unazad kroz roditelj ← dijete1 ← dijete2 ← ... ← roditelj
Ovo vam omogućava neprimetno prebacivanje između glavnog razgovora i rada specijalizovanog podagenta.
Ovo vam omogućava neprimetno prebacivanje između glavnog razgovora i rada specijalizovanog podagenta.
---
## Konfiguriši
## Konfiguracija
Možete prilagoditi ugrađene agente ili kreirati vlastite kroz konfiguraciju. Agenti se mogu konfigurisati na dva načina:
@@ -170,7 +181,7 @@ Konfigurirajte agente u svom konfiguracijskom fajlu `opencode.json`:
Također možete definirati agente koristeći markdown datoteke. Stavite ih u:
- Globalno: `~/.config/opencode/agents/`
- Po projektu: `.opencode/agents/
- Po projektu: `.opencode/agents/`
```markdown title="~/.config/opencode/agents/review.md"
---
@@ -184,16 +195,17 @@ tools:
bash: false
---
Nalazite se u načinu pregleda koda. Fokusirajte se na:
You are in code review mode. Focus on:
- Kvalitet koda i najbolje prakse
- Potencijalne greške i rubni slučajevi
- Implikacije na performanse
- Sigurnosna pitanja
Dajte konstruktivne povratne informacije bez direktnih promjena.
- Code quality and best practices
- Potential bugs and edge cases
- Performance implications
- Security considerations
Provide constructive feedback without making direct changes.
```
Ime marginalne datoteke postaje ime agenta. Na primjer, `review.md` kreira `review` agenta.
Ime markdown datoteke postaje ime agenta. Na primjer, `review.md` kreira `review` agenta.
---
@@ -203,7 +215,7 @@ Pogledajmo ove opcije konfiguracije detaljno.
---
### Opis
### Description
Koristite opciju `description` da pružite kratak opis onoga što agent radi i kada ga koristiti.
@@ -221,27 +233,30 @@ Ovo je **obavezna** opcija konfiguracije.
---
### Temperatura
### Temperature
Kontrolišite slučajnost i kreativnost odgovora LLM-a pomoću `temperature` konfiguracije.
Niže vrijednosti čine odgovore fokusiranijim i determinističkim, dok više vrijednosti povećavaju kreativnost i varijabilnost.
```json title="opencode.json"
{
"agent": {
"agent": {
"plan": {
"temperatura": 0,1 },
"kreativno": {
"temperatura": 0,8 }
"temperature": 0.1
},
"creative": {
"temperature": 0.8
}
}
}
```
Vrijednosti temperature se obično kreću od 0,0 do 1,0:
Vrijednosti temperature se obično kreću od 0.0 do 1.0:
- **0,0-0,2**: Vrlo fokusirani i deterministički odgovori, idealni za analizu i planiranje koda
- **0,3-0,5**: Uravnoteženi odgovori sa malo kreativnosti, dobro za opšte razvojne zadatke
- **0,6-1,0**: kreativniji i raznovrsniji odgovori, korisni za razmišljanje i istraživanje
- **0.0-0.2**: Vrlo fokusirani i deterministički odgovori, idealni za analizu i planiranje koda
- **0.3-0.5**: Uravnoteženi odgovori sa malo kreativnosti, dobro za opšte razvojne zadatke
- **0.6-1.0**: Kreativniji i raznovrsniji odgovori, korisni za razmišljanje i istraživanje
```json title="opencode.json"
{
@@ -261,35 +276,37 @@ Vrijednosti temperature se obično kreću od 0,0 do 1,0:
}
```
Ako temperatura nije navedena, OpenCode koristi standardne postavke specifične za model; obično 0 za većinu modela, 0,55 za Qwen modele.
Ako temperatura nije navedena, OpenCode koristi standardne postavke specifične za model; obično 0 za većinu modela, 0.55 za Qwen modele.
---
### Maks. stepenice
### Max steps
Kontrolirajte maksimalni broj iteracija agenta koje agent može izvesti prije nego što bude prisiljen da odgovori samo tekstom. Ovo omogućava korisnicima koji žele kontrolirati troškove da postave ograničenje na akcije agenta.
Ako ovo nije postavljeno, agent će nastaviti iterirati sve dok model ne odluči da se zaustavi ili korisnik ne prekine sesiju.
```json title="opencode.json"
{
"agent": {
"brzo mislilac": {
"opis": "Brzo razmišljanje s ograničenim iteracijama",
"prompt": "Vi brzo mislite. Riješite probleme minimalnim koracima.",
"koraci": 5
"quick-thinker": {
"description": "Fast reasoning with limited iterations",
"prompt": "You are a quick thinker. Solve problems with minimal steps.",
"steps": 5
}
}
}
```
Kada se dostigne ograničenje, agent prima poseban sistemski prompt koji ga upućuje da odgovori sa rezimeom svog rada i preporučenim preostalim zadacima.
:::caution
Naslijeđeno polje `maxSteps` je zastarjelo. Umjesto toga koristite `steps`.
:::
---
### Onemogući
### Disable
Postavite na `true` da onemogućite agenta.
@@ -312,7 +329,7 @@ Navedite prilagođenu sistemsku prompt datoteku za ovog agenta sa `prompt` konfi
```json title="opencode.json"
{
"agent": {
"recenzija": {
"review": {
"prompt": "{file:./prompts/code-review.txt}"
}
}
@@ -326,6 +343,7 @@ Ova putanja je relativna u odnosu na mjesto gdje se nalazi konfiguracijski fajl.
### Model
Koristite `model` konfiguraciju da nadjačate model za ovog agenta. Korisno za korištenje različitih modela optimiziranih za različite zadatke. Na primjer, brži model za planiranje, sposobniji model za implementaciju.
:::tip
Ako ne navedete model, primarni agenti koriste [model globalno konfiguriran](/docs/config#models) dok će podagenti koristiti model primarnog agenta koji je pozvao subagenta.
:::
@@ -344,7 +362,7 @@ ID modela u vašoj OpenCode konfiguraciji koristi format `provider/model-id`. Na
---
### Uvijek
### Tools
Kontrolirajte koji su alati dostupni u ovom agentu koristeći konfiguraciju `tools`. Možete omogućiti ili onemogućiti određene alate tako što ćete ih postaviti na `true` ili `false`.
@@ -369,13 +387,14 @@ Kontrolirajte koji su alati dostupni u ovom agentu koristeći konfiguraciju `too
:::note
Konfiguracija specifična za agenta poništava globalnu konfiguraciju.
:::
Također možete koristiti zamjenske znakove za kontrolu više alata odjednom. Na primjer, da onemogućite sve alate sa MCP servera:
```json title="opencode.json"
{
"$schema": "https://opencode.ai/config.json",
"agent": {
"plan": {
"readonly": {
"tools": {
"mymcp_*": false,
"write": false,
@@ -390,7 +409,7 @@ Također možete koristiti zamjenske znakove za kontrolu više alata odjednom. N
---
### Dozvole
### Permissions
Možete konfigurirati dozvole za upravljanje radnjama koje agent može poduzeti. Trenutno se dozvole za alate `edit`, `bash` i `webfetch` mogu konfigurirati na:
@@ -462,7 +481,7 @@ Možete postaviti dozvole za određene bash komande.
}
```
Ovo može poprimiti oblik lopte.
Ovo može koristiti glob uzorak.
```json title="opencode.json" {7}
{
@@ -520,7 +539,7 @@ Opcija `mode` se može postaviti na `primary`, `subagent` ili `all`. Ako `mode`
---
### Skriveno
### Hidden
Sakrij podagenta iz `@` menija za automatsko dovršavanje sa `hidden: true`. Korisno za interne podagente koje bi drugi agenti trebali programski pozvati samo preko Task alata.
@@ -535,14 +554,15 @@ Sakrij podagenta iz `@` menija za automatsko dovršavanje sa `hidden: true`. Kor
}
```
Ovo utiče samo na vidljivost korisnika u meniju za automatsko dovršavanje. Model i dalje može pozvati skrivene agente putem alata Zadatak ako dozvole dozvoljavaju.
Ovo utiče samo na vidljivost korisnika u meniju za automatsko dovršavanje. Skriveni agenti se i dalje mogu pozvati od strane modela putem alata Task ako dozvole to dozvoljavaju.
:::note
Odnosi se samo na `mode: subagent` agente.
:::
---
### Dozvole za zadatak
### Task permissions
Kontrolirajte koje podagente agent može pozvati preko Task alata sa `permission.task`. Koristi glob uzorke za fleksibilno uparivanje.
@@ -564,28 +584,31 @@ Kontrolirajte koje podagente agent može pozvati preko Task alata sa `permission
```
Kada se postavi na `deny`, subagent se u potpunosti uklanja iz opisa alata za zadatak, tako da ga model neće pokušati pozvati.
:::tip
Pravila se procjenjuju po redoslijedu i **pobjeđuje **poslednje odgovarajuće pravilo\*_. U gornjem primjeru, `orchestrator-planner` odgovara i `_`(odbije) i`orchestrator-_`(dozvoli), ali pošto`orchestrator-_`dolazi nakon`\*`, rezultat je `allow`.
Pravila se procjenjuju po redoslijedu i **posljednje odgovarajuće pravilo pobjeđuje**. U gornjem primjeru, `orchestrator-planner` odgovara i `*` (deny) i `orchestrator-*` (allow), ali pošto `orchestrator-*` dolazi nakon `*`, rezultat je `allow`.
:::
:::tip
Korisnici uvijek mogu pozvati bilo kojeg subagenta direktno preko `@` menija za autodovršavanje, čak i ako bi dozvole za zadatak agenta to uskratile.
:::
---
### Boja
### Color
Prilagodite vizualni izgled agenta u korisničkom sučelju s opcijom `color`. Ovo utiče na to kako se agent pojavljuje u interfejsu.
Koristite važeću heksadecimalnu boju (npr. `#FF5733`) ili boju teme: `primary`, `secondary`, `accent`, `success`, `warning`, `error`, `info`.
```json title="opencode.json"
{
"agent": {
"kreativno": {
"boja": "#ff6b6b"
"creative": {
"color": "#ff6b6b"
},
"code-reviewer": {
"boja": "akcent"
"color": "accent"
}
}
}
@@ -593,7 +616,7 @@ Koristite važeću heksadecimalnu boju (npr. `#FF5733`) ili boju teme: `primary`
---
### Leglo P
### Top P
Kontrolirajte raznolikost odgovora s opcijom `top_p`. Alternativa temperaturi za kontrolu nasumice.
@@ -607,29 +630,31 @@ Kontrolirajte raznolikost odgovora s opcijom `top_p`. Alternativa temperaturi za
}
```
Vrijednosti se kreću od 0,0 do 1,0. Niže vrijednosti su više fokusirane, više vrijednosti raznovrsnije.
Vrijednosti se kreću od 0.0 do 1.0. Niže vrijednosti su više fokusirane, više vrijednosti raznovrsnije.
---
### Dodatni
### Additional
Sve druge opcije koje navedete u konfiguraciji agenta će biti **direktno proslijeđene** dobavljaču kao opcije modela. Ovo vam omogućava da koristite karakteristike i parametre specifične za provajdera.
Na primjer, sa OpenAI-jevim modelima rezonovanja, možete kontrolisati napor rasuđivanja:
```json title="opencode.json" {6,7}
{
"agent": {
"duboki mislilac": {
"opis": "Agent koji koristi veliki napor u razmišljanju za složene probleme",
"deep-thinker": {
"description": "Agent that uses high reasoning effort for complex problems",
"model": "openai/gpt-5",
"reasoningEffort": "visoko",
"textVerbosity": "niska"
"reasoningEffort": "high",
"textVerbosity": "low"
}
}
}
```
Ove dodatne opcije su specifične za model i dobavljača. U dokumentaciji vašeg provajdera provjerite dostupne parametre.
:::tip
Pokrenite `opencode models` da vidite listu dostupnih modela.
:::
@@ -659,9 +684,9 @@ Ova interaktivna komanda će:
Evo nekoliko uobičajenih slučajeva upotrebe različitih agenata.
- **Build agent**: Potpuni razvojni rad sa svim omogućenim alatima
- **Agent za plan**: Analiza i planiranje bez unošenja promjena
- **Agent za pregled**: Pregled koda sa pristupom samo za čitanje plus alati za dokumentaciju
- **Agent za otklanjanje grešaka**: Fokusiran na istragu sa omogućenim bash i alatima za čitanje
- **Plan agent**: Analiza i planiranje bez unošenja promjena
- **Review agent**: Code review sa pristupom samo za čitanje plus alati za dokumentaciju
- **Debug agent**: Fokusiran na istragu sa omogućenim bash i alatima za čitanje
- **Docs agent**: Pisanje dokumentacije sa operacijama datoteka, ali bez sistemskih naredbi
---
@@ -669,6 +694,7 @@ Evo nekoliko uobičajenih slučajeva upotrebe različitih agenata.
## Primjeri
Evo nekoliko primjera agenata koji bi vam mogli biti korisni.
:::tip
Imate li agenta kojeg biste željeli podijeliti? [Pošalji PR](https://github.com/anomalyco/opencode).
:::
@@ -685,13 +711,14 @@ tools:
bash: false
---
Vi ste tehnički pisac. Kreirajte jasnu, sveobuhvatnu dokumentaciju.
Fokusirajte se na:
You are a technical writer. Create clear, comprehensive documentation.
- Jasna objašnjenja
- Pravilna struktura
- Primjeri kodova
- Jezik prilagođen korisniku
Focus on:
- Clear explanations
- Proper structure
- Code examples
- User-friendly language
```
---
@@ -717,7 +744,3 @@ Look for:
- Dependency vulnerabilities
- Configuration security issues
```
```
```
+30 -51
View File
@@ -9,7 +9,6 @@ OpenCode CLI po defaultu pokreće [TUI](/docs/tui) kada se pokrene bez ikakvih a
```bash
opencode
```
Ali takođe prihvata komande kao što je dokumentovano na ovoj stranici. Ovo vam omogućava programsku interakciju sa OpenCode.
@@ -26,7 +25,6 @@ Pokrenite korisnički interfejs OpenCode terminala.
```bash
opencode [project]
```
#### Zastave
@@ -44,7 +42,7 @@ opencode [project]
---
## komandante
## Commands
OpenCode CLI takođe ima sledeće komande.
@@ -60,13 +58,12 @@ opencode agent [command]
---
### prilog
### attach
Priključite terminal na već pokrenut OpenCode backend server pokrenut putem `serve` ili `web` komandi.
```bash
opencode attach [url]
```
Ovo omogućava korištenje TUI-ja sa udaljenim OpenCode backend-om. na primjer:
@@ -88,20 +85,19 @@ opencode attach http://10.20.30.40:4096
---
#### kreiraj
#### create
Kreirajte novog agenta s prilagođenom konfiguracijom.
```bash
opencode agent create
```
Ova komanda će vas voditi kroz kreiranje novog agenta sa prilagođenim sistemskim promptom i konfiguracijom alata.
---
#### lista
#### list
Navedite sve dostupne agente.
@@ -117,12 +113,11 @@ Naredba za upravljanje vjerodajnicama i prijavom za provajdere.
```bash
opencode auth [command]
```
---
#### aplikacija
#### login
OpenCode pokreće lista provajdera na [Models.dev](https://models.dev), tako da možete koristiti `opencode auth login` da konfigurirate API ključeve za bilo kojeg provajdera kojeg želite koristiti. Ovo je pohranjeno u `~/.local/share/opencode/auth.json`.
@@ -134,13 +129,12 @@ Kada se OpenCode pokrene, učitava dobavljače iz datoteke vjerodajnica. I ako p
---
#### lista
#### list
Navodi sve autentifikovane dobavljače pohranjene u datoteci akreditiva.
```bash
opencode auth lista
opencode auth list
```
Ili kratka verzija.
@@ -151,13 +145,12 @@ opencode auth ls
---
#### odjava
#### logout
Odjavljuje vas s provajdera tako što ga briše iz datoteke vjerodajnica.
```bash
opencode auth logout
```
---
@@ -172,20 +165,19 @@ opencode github [command]
---
#### instaliraj
#### install
Instalirajte GitHub agenta u svoje spremište.
```bash
opencode github instalacija
opencode github install
```
Ovo postavlja neophodni tok rada GitHub Actions i vodi vas kroz proces konfiguracije. [Saznajte više](/docs/github).
---
#### trči
#### run
Pokrenite GitHub agent. Ovo se obično koristi u GitHub akcijama.
@@ -208,12 +200,11 @@ Upravljajte serverima protokola konteksta modela.
```bash
opencode mcp [command]
```
---
#### dodaj
#### add
Dodajte MCP server svojoj konfiguraciji.
@@ -225,13 +216,12 @@ Ova komanda će vas voditi kroz dodavanje lokalnog ili udaljenog MCP servera.
---
#### lista
#### list
Navedite sve konfigurirane MCP servere i njihov status veze.
```bash
opencode mcp lista
opencode mcp list
```
Ili koristite kratku verziju.
@@ -248,7 +238,6 @@ Autentifikujte se sa MCP serverom koji je omogućen za OAuth.
```bash
opencode mcp auth [name]
```
Ako ne navedete ime servera, od vas će biti zatraženo da izaberete neki od dostupnih servera koji podržavaju OAuth.
@@ -262,12 +251,11 @@ Ili koristite kratku verziju.
```bash
opencode mcp auth ls
```
---
#### odjava
#### logout
Uklonite OAuth vjerodajnice za MCP server.
@@ -277,18 +265,17 @@ opencode mcp logout [name]
---
#### otklanjanje grešaka
#### debug
Otklanjanje grešaka OAuth veze sa MCP serverom.
```bash
opencode mcp debug <name>
```
---
### model
### models
Navedite sve dostupne modele konfiguriranih provajdera.
@@ -302,7 +289,6 @@ Opciono možete proslijediti ID provajdera za filtriranje modela po tom dobavlja
```bash
opencode models anthropic
```
#### Zastave
@@ -320,13 +306,12 @@ opencode models --refresh
---
### trči
### run
Pokrenite opencode u neinteraktivnom modu tako što ćete direktno proslijediti prompt.
```bash
opencode run [message..]
```
Ovo je korisno za skriptiranje, automatizaciju ili kada želite brz odgovor bez pokretanja punog TUI-ja. Na primjer.
@@ -340,9 +325,9 @@ Također možete priključiti pokrenutu `opencode serve` instancu kako biste izb
```bash
# Start a headless server in one terminal
opencode serve
# U drugom terminalu, pokrenite komande koje se vezuju za njega
opencode run --attach http://localhost:4096 "Objasni async/await u JavaScriptu"
# In another terminal, run commands that attach to it
opencode run --attach http://localhost:4096 "Explain async/await in JavaScript"
```
#### Zastave
@@ -364,7 +349,7 @@ opencode run --attach http://localhost:4096 "Objasni async/await u JavaScriptu"
---
### poslužiti
### serve
Pokrenite OpenCode server bez glave za pristup API-ju. Pogledajte [server docs](/docs/server) za kompletan HTTP interfejs.
@@ -385,18 +370,17 @@ Ovo pokreće HTTP server koji pruža API pristup funkcionalnosti otvorenog koda
---
### sesija
### session
Upravljajte OpenCode sesijama.
```bash
opencode sesija [naredba]
opencode session [command]
```
---
#### lista
#### list
Navedite sve OpenCode sesije.
@@ -413,13 +397,12 @@ opencode session list
---
### statistika
### stats
Prikaži statistiku upotrebe tokena i troškova za vaše OpenCode sesije.
```bash
opencode stats
```
#### Zastave
@@ -433,7 +416,7 @@ opencode stats
---
### izvoz
### export
Izvezite podatke sesije kao JSON.
@@ -445,13 +428,12 @@ Ako ne unesete ID sesije, od vas će biti zatraženo da odaberete neku od dostup
---
### uvoz
### import
Uvezite podatke sesije iz JSON datoteke ili OpenCode dijeljenog URL-a.
```bash
opencode import <file>
```
Možete uvesti iz lokalne datoteke ili OpenCode dijeljenog URL-a.
@@ -469,7 +451,6 @@ Pokrenite OpenCode server bez glave sa web interfejsom.
```bash
opencode web
```
Ovo pokreće HTTP server i otvara web pretraživač za pristup OpenCode preko web interfejsa. Postavite `OPENCODE_SERVER_PASSWORD` da omogućite HTTP osnovnu auth (korisničko ime je zadano na `opencode`).
@@ -505,13 +486,12 @@ Ova komanda pokreće ACP server koji komunicira preko stdin/stdout koristeći nd
---
### deinstaliraj
### uninstall
Deinstalirajte OpenCode i uklonite sve povezane datoteke.
```bash
opencode uninstall
```
#### Zastave
@@ -537,7 +517,6 @@ Za nadogradnju na najnoviju verziju.
```bash
opencode upgrade
```
Za nadogradnju na određenu verziju.
@@ -592,7 +571,7 @@ OpenCode se može konfigurirati pomoću varijabli okruženja.
| `OPENCODE_DISABLE_FILETIME_CHECK` | boolean | Onemogući provjeru vremena datoteke radi optimizacije |
| `OPENCODE_CLIENT` | string | Identifikator klijenta (zadano na `cli`) |
| `OPENCODE_ENABLE_EXA` | boolean | Omogući Exa alate za web pretraživanje |
| `OPENCODE_SERVER_PASSWORD` | string | Omogući osnovnu autorizaciju za `OPENCODE_GIT_BASH_PATH`/`OPENCODE_CONFIG` |
| `OPENCODE_SERVER_PASSWORD` | string | Omogući osnovnu autorizaciju za `serve`/`web` |
| `OPENCODE_SERVER_USERNAME` | string | Poništi osnovno korisničko ime autentifikacije (zadano `opencode`) |
| `OPENCODE_MODELS_URL` | string | Prilagođeni URL za dohvaćanje konfiguracije modela |
@@ -614,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 +7,6 @@ Prilagođene komande vam omogućavaju da odredite prompt koji želite da pokrene
```bash frame="none"
/my-command
```
Prilagođene komande su dodatak ugrađenim komandama kao što su `/init`, `/undo`, `/redo`, `/share`, `/help`. [Saznajte više](/docs/tui#commands).
@@ -35,7 +34,6 @@ Koristite komandu tako što ćete upisati `/` nakon čega slijedi naziv komande.
```bash frame="none"
"/test"
```
---
@@ -71,7 +69,6 @@ Sada možete pokrenuti ovu naredbu u TUI:
```bash frame="none"
/test
```
---
@@ -98,7 +95,6 @@ Ime markdown datoteke postaje ime naredbe. Na primjer, `test.md` vam omogućava
```bash frame="none"
/test
```
---
@@ -126,7 +122,6 @@ Pokrenite naredbu s argumentima:
```bash frame="none"
/component Button
```
I `$ARGUMENTS` će biti zamijenjen sa `Button`.
@@ -152,7 +147,6 @@ Pokrenite naredbu:
```bash frame="none"
/create-file config.json src "{ \"key\": \"value\" }"
```
Ovo zamjenjuje:
@@ -186,9 +180,10 @@ Ili da vidite nedavne promjene:
description: Review recent changes
---
Nedavna git urezivanja:
Recent git commits:
!`git log --oneline -10`
Pregledajte ove promjene i predložite bilo kakva poboljšanja.
Review these changes and suggest any improvements.
```
Naredbe se pokreću u korijenskom direktoriju vašeg projekta i njihov izlaz postaje dio prompta.
@@ -226,7 +221,7 @@ Opcija `template` definira prompt koji će biti poslan LLM-u kada se naredba izv
{
"command": {
"test": {
"template": "Pokrenite kompletan testni paket sa izvještajem o pokrivenosti i pokažite sve greške.\nFokusirajte se na neuspjele testove i predložite popravke."
"template": "Run the full test suite with coverage report and show any failures.\nFocus on the failing tests and suggest fixes."
}
}
}
+8 -9
View File
@@ -14,7 +14,7 @@ OpenCode podržava i **JSON** i **JSONC** (JSON sa komentarima) formate.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
// Konfiguracija teme
// Theme configuration
"theme": "opencode",
"model": "anthropic/claude-sonnet-4-5",
"autoupdate": true,
@@ -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,
@@ -127,7 +127,6 @@ prate istu strukturu.
```bash
export OPENCODE_CONFIG_DIR=/path/to/my/config-directory
opencode run "Hello world"
```
Prilagođeni direktorij se učitava nakon direktorija globalne konfiguracije i `.opencode`, tako da **može nadjačati** njihove postavke.
@@ -195,7 +194,7 @@ Dostupne opcije:
---
### Uvijek
### Tools
Možete upravljati alatima koje LLM može koristiti putem opcije `tools`.
@@ -282,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`.
@@ -359,7 +358,7 @@ Ovo traje:
---
### komandante
### Command
Možete konfigurirati prilagođene komande za ponavljanje zadataka putem opcije `command`.
@@ -416,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`.
@@ -27,7 +27,7 @@ Mogu se definisati:
Najlakši način za kreiranje alata je korištenje pomoćnika `tool()` koji pruža sigurnost tipa i validaciju.
```ts title=".opencode/tools/database.ts" {1}
import { tool } from "@opencodei/plugin"
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "Query the project database",
@@ -50,7 +50,7 @@ export default tool({
Također možete izvesti više alata iz jedne datoteke. Svaki izvoz postaje **poseban alat** pod nazivom **`<filename>_<exportname>`**:
```ts title=".opencode/tools/math.ts"
import { tool } from "@opencodei/plugin"
import { tool } from "@opencode-ai/plugin"
export const add = tool({
description: "Add two numbers",
@@ -113,7 +113,7 @@ export default {
Alati primaju kontekst o trenutnoj sesiji:
```ts title=".opencode/tools/project.ts" {8}
import { tool } from "@opencodei/plugin"
import { tool } from "@opencode-ai/plugin"
export default tool({
description: "Get project information",
@@ -149,7 +149,7 @@ print(a + b)
Zatim kreirajte definiciju alata koja ga poziva:
```ts title=".opencode/tools/python-add.ts" {10}
import { tool } from "@opencodei/plugin"
import { tool } from "@opencode-ai/plugin"
import path from "path"
export default tool({

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