mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-11 20:19:53 -04:00
Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| edcfd562af | |||
| ef5ec5dc28 | |||
| c426cb0f14 | |||
| dd1862cc2b | |||
| a25b2af05a | |||
| 8bfd6fdba2 | |||
| cf7a1b8d80 | |||
| 5ba4c0e024 | |||
| 567e094e6c | |||
| b523998329 | |||
| 7e1247c420 | |||
| 783888131e | |||
| 213a87234d | |||
| d98bd4bd52 | |||
| 22125d1347 | |||
| 8c120f2fab | |||
| c6ec2f47ef | |||
| 0fd6f365be | |||
| 60bdb6e9ba | |||
| 6e9cd576ea | |||
| 53ec15a56a | |||
| a90b62267f | |||
| 24556331c8 | |||
| 39145b99e8 | |||
| 0afa6e03a8 | |||
| 7a3c775dc1 | |||
| 3ea58bb790 | |||
| 50c705cd2d | |||
| 3894c217cc | |||
| 66c2bb8f37 | |||
| 1bbbd51d48 | |||
| 50f3e74d05 | |||
| 21475a1dfd | |||
| dce4c05fa9 | |||
| 8c56571ef9 | |||
| 92a77b72fb | |||
| 4f6b929784 | |||
| 55119559b3 | |||
| fd5531316f |
@@ -15,4 +15,5 @@ kitlangton
|
||||
kommander
|
||||
r44vc0rp
|
||||
rekram1-node
|
||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||
thdxr
|
||||
|
||||
@@ -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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.57",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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": "إشعارات النظام",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "システム通知",
|
||||
|
||||
@@ -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": "시스템 알림",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Системные уведомления",
|
||||
|
||||
@@ -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": "การแจ้งเตือนระบบ",
|
||||
|
||||
@@ -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": "系统通知",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.57",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.57",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
Generated
+97
-7
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.57",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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(
|
||||
() => {
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,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,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": {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.57",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.57",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.1.55",
|
||||
"version": "1.1.57",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -179,7 +179,6 @@ export default defineConfig({
|
||||
"enterprise",
|
||||
"troubleshooting",
|
||||
"windows-wsl",
|
||||
"1-0",
|
||||
{
|
||||
label: "Usage",
|
||||
translations: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -460,7 +460,7 @@ permission:
|
||||
webfetch: deny
|
||||
---
|
||||
|
||||
حلل الشفرة فقط واقترح التغييرات.
|
||||
Only analyze code and suggest changes.
|
||||
```
|
||||
|
||||
يمكنك ضبط الأذونات لأوامر bash محددة.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user