Compare commits

..

9 Commits

Author SHA1 Message Date
xinxin
9c585bb58b docs(providers): clarify npm choice for chat vs responses APIs (#16974)
Co-authored-by: wangxinxin <xinxin.wang@pharmbrain.com>
2026-03-11 10:35:16 -05:00
Aiden Cline
0f6bc8ae71 tweak: adjust way skills are presented to agent to increase likelyhood of skill invocations. (#17053) 2026-03-11 10:24:55 -05:00
Shoubhit Dash
7291e28273 perf(app): trim session render work (#16987) 2026-03-11 18:19:17 +05:30
Filip
db57fe6193 fix(app): make error tool card respect settings (#17005) 2026-03-11 14:52:33 +05:30
Brendan Allan
802416639b ci: setup node in tauri build 2026-03-11 16:09:17 +08:00
opencode-agent[bot]
7ec398d855 chore: generate 2026-03-11 03:34:02 +00:00
Luke Parker
4ab35d2c5c fix(electron): hide Windows background consoles (#16842)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-03-11 13:33:06 +10:00
SOUMITRA-SAHA
b4ae030fc2 fix: add GOOGLE_VERTEX_LOCATION env var support for Vertex AI (#16922)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-03-10 22:32:39 -05:00
Jack
0843964eb3 feat(web): use Feishu for Chinese community links (#16908)
Co-authored-by: Frank <frank@anoma.ly>
2026-03-11 11:07:13 +08:00
25 changed files with 134 additions and 61 deletions

View File

@@ -149,6 +149,10 @@ jobs:
- uses: ./.github/actions/setup-bun
- uses: actions/setup-node@v4
with:
node-version: "24"
- name: Cache apt packages
if: contains(matrix.settings.host, 'ubuntu')
uses: actions/cache@v4

View File

@@ -137,4 +137,4 @@ OpenCode 内置两种 Agent可用 `Tab` 键快速切换:
---
**加入我们的社区** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

View File

@@ -137,4 +137,4 @@ OpenCode 內建了兩種 Agent您可以使用 `Tab` 鍵快速切換。
---
**加入我們的社群** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode)
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true) | [X.com](https://x.com/opencode)

View File

@@ -764,6 +764,7 @@ export function MessageTimeline(props: {
"min-w-0 w-full max-w-full": true,
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
}}
style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
>
<Show when={commentCount() > 0}>
<div class="w-full px-4 md:px-5 pb-2">

View File

@@ -8,6 +8,12 @@ import { useI18n } from "~/context/i18n"
export function Footer() {
const language = useLanguage()
const i18n = useI18n()
const community = createMemo(() => {
const locale = language.locale()
return locale === "zh" || locale === "zht"
? ({ key: "footer.feishu", link: language.route("/feishu") } as const)
: ({ key: "footer.discord", link: language.route("/discord") } as const)
})
const githubData = createAsync(() => github())
const starCount = createMemo(() =>
githubData()?.stars
@@ -32,7 +38,7 @@ export function Footer() {
<a href={language.route("/changelog")}>{i18n.t("footer.changelog")}</a>
</div>
<div data-slot="cell">
<a href={language.route("/discord")}>{i18n.t("footer.discord")}</a>
<a href={community().link}>{i18n.t(community().key)}</a>
</div>
<div data-slot="cell">
<a href={config.social.twitter}>{i18n.t("footer.x")}</a>

View File

@@ -21,6 +21,7 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "Docs",
"footer.changelog": "Changelog",
"footer.feishu": "Feishu",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -24,6 +24,7 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "文档",
"footer.changelog": "更新日志",
"footer.feishu": "飞书",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -24,6 +24,7 @@ export const dict = {
"footer.github": "GitHub",
"footer.docs": "文件",
"footer.changelog": "更新日誌",
"footer.feishu": "飞书",
"footer.discord": "Discord",
"footer.x": "X",

View File

@@ -0,0 +1,7 @@
import { redirect } from "@solidjs/router"
export async function GET() {
return redirect(
"https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=de8k6664-1b5e-43f2-8efd-21d6772647b5&qr_code=true",
)
}

View File

@@ -107,7 +107,7 @@ export function syncCli() {
let version = ""
try {
version = execFileSync(installPath, ["--version"]).toString().trim()
version = execFileSync(installPath, ["--version"], { windowsHide: true }).toString().trim()
} catch {
return
}
@@ -147,7 +147,7 @@ export function spawnCommand(args: string, extraEnv: Record<string, string>) {
console.log(`[cli] Executing: ${cmd} ${cmdArgs.join(" ")}`)
const child = spawn(cmd, cmdArgs, {
env: envs,
detached: true,
detached: process.platform !== "win32",
windowsHide: true,
stdio: ["ignore", "pipe", "pipe"],
})

View File

@@ -11,11 +11,6 @@ const openBrowser = (url: string) => Effect.promise(() => open(url).catch(() =>
const println = (msg: string) => Effect.sync(() => UI.println(msg))
const isActiveOrgChoice = (
active: Option.Option<{ id: AccountID; active_org_id: OrgID | null }>,
choice: { accountID: AccountID; orgID: OrgID },
) => Option.isSome(active) && active.value.id === choice.accountID && active.value.active_org_id === choice.orgID
const loginEffect = Effect.fn("login")(function* (url: string) {
const service = yield* AccountService
@@ -104,10 +99,11 @@ const switchEffect = Effect.fn("switch")(function* () {
if (groups.length === 0) return yield* println("Not logged in")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
const opts = groups.flatMap((group) =>
group.orgs.map((org) => {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
return {
value: { orgID: org.id, accountID: group.account.id, label: org.name },
label: isActive
@@ -136,10 +132,11 @@ const orgsEffect = Effect.fn("orgs")(function* () {
if (!groups.some((group) => group.orgs.length > 0)) return yield* println("No orgs found")
const active = yield* service.active()
const activeOrgID = Option.flatMap(active, (a) => Option.fromNullishOr(a.active_org_id))
for (const group of groups) {
for (const org of group.orgs) {
const isActive = isActiveOrgChoice(active, { accountID: group.account.id, orgID: org.id })
const isActive = Option.isSome(activeOrgID) && activeOrgID.value === org.id
const dot = isActive ? UI.Style.TEXT_SUCCESS + "●" + UI.Style.TEXT_NORMAL : " "
const name = isActive ? UI.Style.TEXT_HIGHLIGHT_BOLD + org.name + UI.Style.TEXT_NORMAL : org.name
const email = UI.Style.TEXT_DIM + group.account.email + UI.Style.TEXT_NORMAL

View File

@@ -114,6 +114,7 @@ export namespace LSP {
return {
process: spawn(item.command[0], item.command.slice(1), {
cwd: root,
windowsHide: true,
env: {
...process.env,
...item.env,

View File

@@ -1,4 +1,4 @@
import { spawn, type ChildProcessWithoutNullStreams } from "child_process"
import { spawn as launch, type ChildProcessWithoutNullStreams } from "child_process"
import path from "path"
import os from "os"
import { Global } from "../global"
@@ -14,6 +14,11 @@ import { Process } from "../util/process"
import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module"
const spawn = ((cmd, args, opts) => {
if (Array.isArray(args)) return launch(cmd, [...args], { ...(opts ?? {}), windowsHide: true })
return launch(cmd, { ...(args ?? {}), windowsHide: true })
}) as typeof launch
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
const pathExists = async (p: string) =>

View File

@@ -67,7 +67,11 @@ export namespace Provider {
const project =
options["project"] ?? Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT")
const location =
options["location"] ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
options["location"] ??
Env.get("GOOGLE_VERTEX_LOCATION") ??
Env.get("GOOGLE_CLOUD_LOCATION") ??
Env.get("VERTEX_LOCATION") ??
"us-central1"
const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com`
return {
@@ -437,7 +441,11 @@ export namespace Provider {
Env.get("GCLOUD_PROJECT")
const location =
provider.options?.location ?? Env.get("GOOGLE_CLOUD_LOCATION") ?? Env.get("VERTEX_LOCATION") ?? "us-central1"
provider.options?.location ??
Env.get("GOOGLE_VERTEX_LOCATION") ??
Env.get("GOOGLE_CLOUD_LOCATION") ??
Env.get("VERTEX_LOCATION") ??
"us-central1"
const autoload = Boolean(project)
if (!autoload) return { autoload: false }

View File

@@ -650,7 +650,12 @@ export namespace SessionPrompt {
await Plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
// Build system prompt, adding structured output instruction if needed
const system = [...(await SystemPrompt.environment(model)), ...(await InstructionPrompt.system())]
const skills = await SystemPrompt.skills(agent)
const system = [
...(await SystemPrompt.environment(model)),
...(skills ? [skills] : []),
...(await InstructionPrompt.system()),
]
const format = lastUser.format ?? { type: "text" }
if (format.type === "json_schema") {
system.push(STRUCTURED_OUTPUT_SYSTEM_PROMPT)
@@ -1629,6 +1634,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const proc = spawn(shell, args, {
cwd,
detached: process.platform !== "win32",
windowsHide: process.platform === "win32",
stdio: ["ignore", "pipe", "pipe"],
env: {
...process.env,

View File

@@ -10,6 +10,9 @@ import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_CODEX from "./prompt/codex_header.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt"
import type { Provider } from "@/provider/provider"
import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission/next"
import { Skill } from "@/skill"
export namespace SystemPrompt {
export function instructions() {
@@ -34,6 +37,7 @@ export namespace SystemPrompt {
`Here is some useful information about the environment you are running in:`,
`<env>`,
` Working directory: ${Instance.directory}`,
` Workspace root folder: ${Instance.worktree}`,
` Is directory a git repo: ${project.vcs === "git" ? "yes" : "no"}`,
` Platform: ${process.platform}`,
` Today's date: ${new Date().toDateString()}`,
@@ -51,4 +55,16 @@ export namespace SystemPrompt {
].join("\n"),
]
}
export async function skills(agent: Agent.Info) {
if (PermissionNext.disabled(["skill"], agent.permission).has("skill")) return
const list = await Skill.available(agent)
return [
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
list.length === 0 ? "No skills are currently available." : "\n" + Skill.fmt(list),
].join("\n")
}
}

View File

@@ -15,7 +15,10 @@ export namespace Shell {
if (process.platform === "win32") {
await new Promise<void>((resolve) => {
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], { stdio: "ignore" })
const killer = spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
stdio: "ignore",
windowsHide: true,
})
killer.once("exit", () => resolve())
killer.once("error", () => resolve())
})

View File

@@ -13,6 +13,9 @@ import { Bus } from "@/bus"
import { Session } from "@/session"
import { Discovery } from "./discovery"
import { Glob } from "../util/glob"
import { pathToFileURL } from "url"
import type { Agent } from "@/agent/agent"
import { PermissionNext } from "@/permission/next"
export namespace Skill {
const log = Log.create({ service: "skill" })
@@ -186,4 +189,24 @@ export namespace Skill {
export async function dirs() {
return state().then((x) => x.dirs)
}
export async function available(agent?: Agent.Info) {
const list = await all()
if (!agent) return list
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
}
export function fmt(list: Info[]) {
return [
"<available_skills>",
...list.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
` </skill>`,
]),
"</available_skills>",
].join("\n")
}
}

View File

@@ -173,6 +173,7 @@ export const BashTool = Tool.define("bash", async () => {
},
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
windowsHide: process.platform === "win32",
})
let output = ""

View File

@@ -3,24 +3,14 @@ import { pathToFileURL } from "url"
import z from "zod"
import { Tool } from "./tool"
import { Skill } from "../skill"
import { PermissionNext } from "../permission/next"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
export const SkillTool = Tool.define("skill", async (ctx) => {
const skills = await Skill.all()
// Filter skills by agent permissions if agent provided
const agent = ctx?.agent
const accessibleSkills = agent
? skills.filter((skill) => {
const rule = PermissionNext.evaluate("skill", skill.name, agent.permission)
return rule.action !== "deny"
})
: skills
const list = await Skill.available(ctx?.agent)
const description =
accessibleSkills.length === 0
list.length === 0
? "Load a specialized skill that provides domain-specific instructions and workflows. No skills are currently available."
: [
"Load a specialized skill that provides domain-specific instructions and workflows.",
@@ -34,18 +24,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
"<available_skills>",
...accessibleSkills.flatMap((skill) => [
` <skill>`,
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
` </skill>`,
]),
"</available_skills>",
Skill.fmt(list),
].join("\n")
const examples = accessibleSkills
const examples = list
.map((skill) => `'${skill.name}'`)
.slice(0, 3)
.join(", ")
@@ -62,7 +44,7 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
const skill = await Skill.get(params.name)
if (!skill) {
const available = await Skill.all().then((x) => Object.keys(x).join(", "))
const available = await Skill.all().then((x) => x.map((skill) => skill.name).join(", "))
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}

View File

@@ -60,6 +60,7 @@ export namespace Process {
cwd: opts.cwd,
env: opts.env === null ? {} : opts.env ? { ...process.env, ...opts.env } : undefined,
stdio: [opts.stdin ?? "ignore", opts.stdout ?? "ignore", opts.stderr ?? "ignore"],
windowsHide: process.platform === "win32",
})
let closed = false

View File

@@ -437,8 +437,8 @@ function groupParts(parts: { messageID: string; part: PartType }[]) {
return result
}
function partByID(parts: readonly PartType[], partID: string) {
return parts.find((part) => part.id === partID)
function index<T extends { id: string }>(items: readonly T[]) {
return new Map(items.map((item) => [item.id, item] as const))
}
function renderable(part: PartType, showReasoningSummaries = true) {
@@ -474,6 +474,13 @@ export function AssistantParts(props: {
const data = useData()
const emptyParts: PartType[] = []
const emptyTools: ToolPart[] = []
const msgs = createMemo(() => index(props.messages))
const part = createMemo(
() =>
new Map(
props.messages.map((message) => [message.id, index(list(data.store.part?.[message.id], emptyParts))] as const),
),
)
const grouped = createMemo(
() =>
@@ -507,7 +514,7 @@ export function AssistantParts(props: {
const entry = entryAccessor()
if (entry.type !== "context") return emptyTools
return entry.refs
.map((ref) => partByID(list(data.store.part?.[ref.messageID], emptyParts), ref.partID))
.map((ref) => part().get(ref.messageID)?.get(ref.partID))
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
},
emptyTools,
@@ -527,23 +534,23 @@ export function AssistantParts(props: {
const message = createMemo(() => {
const entry = entryAccessor()
if (entry.type !== "part") return
return props.messages.find((item) => item.id === entry.ref.messageID)
return msgs().get(entry.ref.messageID)
})
const part = createMemo(() => {
const item = createMemo(() => {
const entry = entryAccessor()
if (entry.type !== "part") return
return partByID(list(data.store.part?.[entry.ref.messageID], emptyParts), entry.ref.partID)
return part().get(entry.ref.messageID)?.get(entry.ref.partID)
})
return (
<Show when={message()}>
<Show when={part()}>
<Show when={item()}>
<Part
part={part()!}
part={item()!}
message={message()!}
showAssistantCopyPartID={props.showAssistantCopyPartID}
turnDurationMs={props.turnDurationMs}
defaultOpen={partDefaultOpen(part()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
defaultOpen={partDefaultOpen(item()!, props.shellToolDefaultOpen, props.editToolDefaultOpen)}
/>
</Show>
</Show>
@@ -695,6 +702,7 @@ export function AssistantMessageDisplay(props: {
showReasoningSummaries?: boolean
}) {
const emptyTools: ToolPart[] = []
const part = createMemo(() => index(props.parts))
const grouped = createMemo(
() =>
groupParts(
@@ -723,7 +731,7 @@ export function AssistantMessageDisplay(props: {
const entry = entryAccessor()
if (entry.type !== "context") return emptyTools
return entry.refs
.map((ref) => partByID(props.parts, ref.partID))
.map((ref) => part().get(ref.partID))
.filter((part): part is ToolPart => !!part && isContextGroupTool(part))
},
emptyTools,
@@ -739,16 +747,16 @@ export function AssistantMessageDisplay(props: {
</Match>
<Match when={entryType() === "part"}>
{(() => {
const part = createMemo(() => {
const item = createMemo(() => {
const entry = entryAccessor()
if (entry.type !== "part") return
return partByID(props.parts, entry.ref.partID)
return part().get(entry.ref.partID)
})
return (
<Show when={part()}>
<Show when={item()}>
<Part
part={part()!}
part={item()!}
message={props.message}
showAssistantCopyPartID={props.showAssistantCopyPartID}
/>
@@ -1190,7 +1198,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
</div>
)
}
return <ToolErrorCard tool={part().tool} error={error()} />
return <ToolErrorCard tool={part().tool} error={error()} defaultOpen={props.defaultOpen} />
}}
</Match>
<Match when={true}>

View File

@@ -9,13 +9,14 @@ import { useI18n } from "../context/i18n"
export interface ToolErrorCardProps extends Omit<ComponentProps<typeof Card>, "children" | "variant"> {
tool: string
error: string
defaultOpen?: boolean
}
export function ToolErrorCard(props: ToolErrorCardProps) {
const i18n = useI18n()
const [open, setOpen] = createSignal(true)
const [open, setOpen] = createSignal(props.defaultOpen ?? false)
const [copied, setCopied] = createSignal(false)
const [split, rest] = splitProps(props, ["tool", "error"])
const [split, rest] = splitProps(props, ["tool", "error", "defaultOpen"])
const name = createMemo(() => {
const map: Record<string, string> = {
read: "ui.tool.read",

View File

@@ -1890,7 +1890,7 @@ You can use any OpenAI-compatible provider with opencode. Most modern AI provide
```
Here are the configuration options:
- **npm**: AI SDK package to use, `@ai-sdk/openai-compatible` for OpenAI-compatible providers
- **npm**: AI SDK package to use, `@ai-sdk/openai-compatible` for OpenAI-compatible providers (for `/v1/chat/completions`). If your provider/model uses `/v1/responses`, use `@ai-sdk/openai`.
- **name**: Display name in UI.
- **models**: Available models.
- **options.baseURL**: API endpoint URL.
@@ -1957,5 +1957,5 @@ If you are having trouble with configuring a provider, check the following:
2. For custom providers, check the opencode config and:
- Make sure the provider ID used in the `/connect` command matches the ID in your opencode config.
- The right npm package is used for the provider. For example, use `@ai-sdk/cerebras` for Cerebras. And for all other OpenAI-compatible providers, use `@ai-sdk/openai-compatible`.
- The right npm package is used for the provider. For example, use `@ai-sdk/cerebras` for Cerebras. And for all other OpenAI-compatible providers, use `@ai-sdk/openai-compatible` (for `/v1/chat/completions`); if a model uses `/v1/responses`, use `@ai-sdk/openai`. For mixed setups under one provider, you can override per model via `provider.npm`.
- Check correct API endpoint is used in the `options.baseURL` field.

View File

@@ -1845,7 +1845,7 @@ Vercel AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic、G
```
以下是配置选项说明:
- **npm**:要使用的 AI SDK 包,对于 OpenAI 兼容的提供商使用 `@ai-sdk/openai-compatible`
- **npm**:要使用的 AI SDK 包,对于 OpenAI 兼容的提供商使用 `@ai-sdk/openai-compatible`(适用于 `/v1/chat/completions`)。如果你的提供商/模型走 `/v1/responses`,请使用 `@ai-sdk/openai`。
- **name**:在 UI 中显示的名称。
- **models**:可用模型。
- **options.baseURL**API 端点 URL。
@@ -1911,5 +1911,5 @@ Vercel AI Gateway 允许你通过统一端点访问来自 OpenAI、Anthropic、G
2. 对于自定义提供商,请检查 OpenCode 配置并确认:
- `/connect` 命令中使用的提供商 ID 与 OpenCode 配置中的 ID 一致。
- 使用了正确的 npm 包。例如Cerebras 应使用 `@ai-sdk/cerebras`。对于其他所有 OpenAI 兼容的提供商,使用 `@ai-sdk/openai-compatible`。
- 使用了正确的 npm 包。例如Cerebras 应使用 `@ai-sdk/cerebras`。对于其他所有 OpenAI 兼容的提供商,使用 `@ai-sdk/openai-compatible``/v1/chat/completions`);如果模型走 `/v1/responses`,请使用 `@ai-sdk/openai`。同一 provider 混用时,可在模型下设置 `provider.npm` 覆盖默认值
- `options.baseURL` 字段中的 API 端点地址正确。