Compare commits

..

8 Commits

Author SHA1 Message Date
Dax Raad
deaf9c956f sync 2026-02-19 16:53:22 -05:00
Dax Raad
3e0dc15b59 fix: remove unnecessary braces from favicon glob pattern
The pattern **/{favicon}.{ico,png,svg,jpg,jpeg,webp} doesn't work with
the npm glob package. Changed to **/favicon.{ico,png,svg,jpg,jpeg,webp}
which correctly matches favicon files with any of the specified extensions.
2026-02-19 13:19:06 -05:00
Dax Raad
01b5e6487c test: rewrite glob tests based on Glob utility behavior
- Add tests for symlink following (default false, true when enabled)
- Add tests for dot option (include/exclude dotfiles)
- Add tests for scanSync
- Verify directories excluded by default (nodir: true)
- Verify directories included when include: 'all'
2026-02-19 13:13:05 -05:00
Dax Raad
9657d1bbfd fix: restore followSymlinks behavior in Glob utility
Add symlink: true to all locations that previously had followSymlinks: true:
- theme.tsx: custom themes
- config.ts: commands, agents, modes, plugins
- skill.ts: external, opencode, and custom skills
- registry.ts: custom tools

Also fix nodir to default to true (exclude directories) when include is not explicitly set to 'all'.
2026-02-19 13:11:38 -05:00
Dax Raad
bbfb7e95e0 refactor: migrate from Bun.Glob to npm glob package
Replace Bun.Glob usage with a new Glob utility wrapper around the npm 'glob' package.
This moves us off Bun-specific APIs toward standard Node.js compatible solutions.

Changes:
- Add new src/util/glob.ts utility module with scan(), scanSync(), and match()
- Default include option is 'file' (only returns files, not directories)
- Add symlink option (default: false) to control symlink following
- Migrate all 12 files using Bun.Glob to use the new Glob utility
- Add comprehensive tests for the glob utility

Breaking changes:
- Removed support for include: 'dir' option (use include: 'all' and filter manually)
- symlink now defaults to false (was true in most Bun.Glob usages)

Files migrated:
- src/util/log.ts
- src/util/filesystem.ts
- src/tool/truncation.ts
- src/session/instruction.ts
- src/storage/json-migration.ts
- src/storage/storage.ts
- src/project/project.ts
- src/cli/cmd/tui/context/theme.tsx
- src/config/config.ts
- src/tool/registry.ts
- src/skill/skill.ts
- src/file/ignore.ts
2026-02-19 12:38:24 -05:00
Adam
56dda4c98c chore: cleanup 2026-02-19 11:12:56 -06:00
Adam
6b8902e8b9 fix(app): navigate to last session on project nav 2026-02-19 11:12:56 -06:00
Frank
08a2d002b8 zen: gemini 3.1 pro 2026-02-19 11:43:14 -05:00
64 changed files with 967 additions and 1574 deletions

View File

@@ -15,6 +15,7 @@
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"glob": "13.0.5",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",
@@ -321,6 +322,7 @@
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"hono": "catalog:",
@@ -2694,7 +2696,7 @@
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
"glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="],
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
@@ -3074,7 +3076,7 @@
"lower-case": ["lower-case@2.0.2", "", { "dependencies": { "tslib": "^2.0.3" } }, "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg=="],
"lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"lru.min": ["lru.min@1.1.4", "", {}, "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA=="],
@@ -4786,14 +4788,14 @@
"openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="],
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
"p-locate/p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
"path-scurry/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
@@ -4866,10 +4868,10 @@
"unifont/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"vitest/tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
"vitest/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="],
@@ -5226,8 +5228,6 @@
"astro/unstorage/h3": ["h3@1.15.5", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-xEyq3rSl+dhGX2Lm0+eFQIAzlDN6Fs0EcC4f7BNUmzaRX/PTzeuM+Tr2lHB8FoXggsQIeXLj8EDVgs5ywxyxmg=="],
"astro/unstorage/lru-cache": ["lru-cache@11.2.6", "", {}, "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ=="],
"astro/unstorage/ofetch": ["ofetch@1.5.1", "", { "dependencies": { "destr": "^2.0.5", "node-fetch-native": "^1.6.7", "ufo": "^1.6.1" } }, "sha512-2W4oUZlVaqAPAil6FUg/difl6YhqhUR7x2eZY4bQCko22UXg3hptq9KLQdqFClV+Wu85UX7hNtdGTngi/1BxcA=="],
"aws-sdk/xml2js/sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="],
@@ -5358,6 +5358,8 @@
"type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"vite-plugin-icons-spritesheet/glob/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
"wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="],
"wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="],

View File

@@ -70,6 +70,7 @@
"@actions/artifact": "5.0.1",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"glob": "13.0.5",
"husky": "9.1.7",
"prettier": "3.6.2",
"semver": "^7.6.0",

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises"
import net from "node:net"
import os from "node:os"
import path from "node:path"
import { Process } from "../../opencode/src/util/process"
async function freePort() {
return await new Promise<number>((resolve, reject) => {
@@ -84,8 +85,8 @@ const runnerEnv = {
PLAYWRIGHT_PORT: String(webPort),
} satisfies Record<string, string>
let seed: ReturnType<typeof Bun.spawn> | undefined
let runner: ReturnType<typeof Bun.spawn> | undefined
let seed: Process.Child | undefined
let runner: Process.Child | undefined
let server: { stop: () => Promise<void> | void } | undefined
let inst: { Instance: { disposeAll: () => Promise<void> | void } } | undefined
let cleaned = false
@@ -131,7 +132,7 @@ process.once("unhandledRejection", (error) => {
let code = 1
try {
seed = Bun.spawn(["bun", "script/seed-e2e.ts"], {
seed = Process.spawn(["bun", "script/seed-e2e.ts"], {
cwd: opencodeDir,
env: serverEnv,
stdout: "inherit",
@@ -160,7 +161,7 @@ try {
console.log(`opencode server listening on http://127.0.0.1:${serverPort}`)
await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`)
runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], {
runner = Process.spawn(["bun", "test:e2e", ...extraArgs], {
cwd: appDir,
env: runnerEnv,
stdout: "inherit",

View File

@@ -61,6 +61,7 @@ import {
displayName,
errorMessage,
getDraggableId,
projectSessionTarget,
sortedRootSessions,
syncWorkspaceOrder,
workspaceKey,
@@ -82,6 +83,7 @@ export default function Layout(props: ParentProps) {
Persist.global("layout.page", ["layout.page.v1"]),
createStore({
lastSession: {} as { [directory: string]: string },
lastSessionAt: {} as { [directory: string]: number },
activeProject: undefined as string | undefined,
activeWorkspace: undefined as string | undefined,
workspaceOrder: {} as Record<string, string[]>,
@@ -1077,8 +1079,16 @@ export default function Layout(props: ParentProps) {
function navigateToProject(directory: string | undefined) {
if (!directory) return
server.projects.touch(directory)
const lastSession = store.lastSession[directory]
navigateWithSidebarReset(`/${base64Encode(directory)}${lastSession ? `/session/${lastSession}` : ""}`)
const project = layout.projects
.list()
.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
const target = projectSessionTarget({
directory,
project,
lastSession: store.lastSession,
lastSessionAt: store.lastSessionAt,
})
navigateWithSidebarReset(`/${base64Encode(target.directory)}${target.id ? `/session/${target.id}` : ""}`)
}
function navigateToSession(session: Session | undefined) {
@@ -1433,6 +1443,7 @@ export default function Layout(props: ParentProps) {
const directory = decode64(dir)
if (!directory) return
setStore("lastSession", directory, id)
setStore("lastSessionAt", directory, Date.now())
notification.session.markViewed(id)
const expanded = untrack(() => store.workspaceExpanded[directory])
if (expanded === false) {

View File

@@ -1,6 +1,13 @@
import { describe, expect, test } from "bun:test"
import { collectOpenProjectDeepLinks, drainPendingDeepLinks, parseDeepLink } from "./deep-links"
import { displayName, errorMessage, getDraggableId, syncWorkspaceOrder, workspaceKey } from "./helpers"
import {
displayName,
errorMessage,
getDraggableId,
projectSessionTarget,
syncWorkspaceOrder,
workspaceKey,
} from "./helpers"
describe("layout deep links", () => {
test("parses open-project deep links", () => {
@@ -89,4 +96,34 @@ describe("layout workspace helpers", () => {
expect(errorMessage(new Error("broken"), "fallback")).toBe("broken")
expect(errorMessage("unknown", "fallback")).toBe("fallback")
})
test("picks newest session across project workspaces", () => {
const result = projectSessionTarget({
directory: "/root",
project: { worktree: "/root", sandboxes: ["/root/a", "/root/b"] },
lastSession: {
"/root": "root-session",
"/root/a": "sandbox-a",
"/root/b": "sandbox-b",
},
lastSessionAt: {
"/root": 1,
"/root/a": 3,
"/root/b": 2,
},
})
expect(result).toEqual({ directory: "/root/a", id: "sandbox-a", at: 3 })
})
test("falls back to project route when no session exists", () => {
const result = projectSessionTarget({
directory: "/root",
project: { worktree: "/root", sandboxes: ["/root/a"] },
lastSession: {},
lastSessionAt: {},
})
expect(result).toEqual({ directory: "/root" })
})
})

View File

@@ -62,6 +62,24 @@ export const errorMessage = (err: unknown, fallback: string) => {
return fallback
}
export function projectSessionTarget(input: {
directory: string
project?: { worktree: string; sandboxes?: string[] }
lastSession: Record<string, string>
lastSessionAt: Record<string, number>
}): { directory: string; id?: string; at?: number } {
const dirs = input.project ? [input.project.worktree, ...(input.project.sandboxes ?? [])] : [input.directory]
const best = dirs.reduce<{ directory: string; id: string; at: number } | undefined>((result, directory) => {
const id = input.lastSession[directory]
if (!id) return result
const at = input.lastSessionAt[directory] ?? 0
if (result && result.at >= at) return result
return { directory, id, at }
}, undefined)
if (best) return best
return { directory: input.directory }
}
export const syncWorkspaceOrder = (local: string, dirs: string[], existing?: string[]) => {
if (!existing) return dirs
const keep = existing.filter((d) => d !== local && dirs.includes(d))

View File

@@ -368,7 +368,7 @@ export function MessageTimeline(props: {
class="relative min-w-0 w-full h-full overflow-y-auto session-scroller"
style={{
"--session-title-height": showHeader() ? "40px" : "0px",
"--sticky-accordion-top": showHeader() ? "64px" : "0px",
"--sticky-accordion-top": showHeader() ? "48px" : "0px",
}}
>
<Show when={showHeader()}>

View File

@@ -7,7 +7,7 @@
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"dev:remote": "VITE_AUTH_URL=https://auth.dev.opencode.ai VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51RtuLNE7fOCwHSD4mewwzFejyytjdGoSDK7CAvhbffwaZnPbNb2rwJICw6LTOXCmWO320fSNXvb5NzI08RZVkAxd00syfqrW7t bun sst shell --stage=dev bun dev",
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json ./.output/public/tui.json",
"build": "./script/generate-sitemap.ts && vite build && ../../opencode/script/schema.ts ./.output/public/config.json",
"start": "vite start"
},
"dependencies": {

View File

@@ -107,6 +107,7 @@
"diff": "catalog:",
"drizzle-orm": "1.0.0-beta.12-a5629fb",
"fuzzysort": "3.1.0",
"glob": "13.0.5",
"google-auth-library": "10.5.0",
"gray-matter": "4.0.3",
"hono": "catalog:",

View File

@@ -2,62 +2,46 @@
import { z } from "zod"
import { Config } from "../src/config/config"
import { TuiConfig } from "../src/config/tui"
function generate(schema: z.ZodType) {
const result = z.toJSONSchema(schema, {
io: "input", // Generate input shape (treats optional().default() as not required)
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
override(ctx) {
const schema = ctx.jsonSchema
const file = process.argv[2]
console.log(file)
// Preserve strictness: set additionalProperties: false for objects
if (
schema &&
typeof schema === "object" &&
schema.type === "object" &&
schema.additionalProperties === undefined
) {
schema.additionalProperties = false
const result = z.toJSONSchema(Config.Info, {
io: "input", // Generate input shape (treats optional().default() as not required)
/**
* We'll use the `default` values of the field as the only value in `examples`.
* This will ensure no docs are needed to be read, as the configuration is
* self-documenting.
*
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
*/
override(ctx) {
const schema = ctx.jsonSchema
// Preserve strictness: set additionalProperties: false for objects
if (schema && typeof schema === "object" && schema.type === "object" && schema.additionalProperties === undefined) {
schema.additionalProperties = false
}
// Add examples and default descriptions for string fields with defaults
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
}
// Add examples and default descriptions for string fields with defaults
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
if (!schema.examples) {
schema.examples = [schema.default]
}
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
},
}) as Record<string, unknown> & {
allowComments?: boolean
allowTrailingCommas?: boolean
}
// used for json lsps since config supports jsonc
result.allowComments = true
result.allowTrailingCommas = true
return result
schema.description = [schema.description || "", `default: \`${schema.default}\``]
.filter(Boolean)
.join("\n\n")
.trim()
}
},
}) as Record<string, unknown> & {
allowComments?: boolean
allowTrailingCommas?: boolean
}
const configFile = process.argv[2]
const tuiFile = process.argv[3]
// used for json lsps since config supports jsonc
result.allowComments = true
result.allowTrailingCommas = true
console.log(configFile)
await Bun.write(configFile, JSON.stringify(generate(Config.Info), null, 2))
if (tuiFile) {
console.log(tuiFile)
await Bun.write(tuiFile, JSON.stringify(generate(TuiConfig.Info), null, 2))
}
await Bun.write(file, JSON.stringify(result, null, 2))

View File

@@ -8,16 +8,17 @@ import { readableStreamToText } from "bun"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { proxied } from "@/util/proxied"
import { Process } from "../util/process"
export namespace BunProc {
const log = Log.create({ service: "bun" })
export async function run(cmd: string[], options?: Bun.SpawnOptions.OptionsObject<any, any, any>) {
export async function run(cmd: string[], options?: Process.Options) {
log.info("running", {
cmd: [which(), ...cmd],
...options,
})
const result = Bun.spawn([which(), ...cmd], {
const result = Process.spawn([which(), ...cmd], {
...options,
stdout: "pipe",
stderr: "pipe",
@@ -28,16 +29,8 @@ export namespace BunProc {
},
})
const code = await result.exited
const stdout = result.stdout
? typeof result.stdout === "number"
? result.stdout
: await readableStreamToText(result.stdout)
: undefined
const stderr = result.stderr
? typeof result.stderr === "number"
? result.stderr
: await readableStreamToText(result.stderr)
: undefined
const stdout = result.stdout ? await readableStreamToText(result.stdout) : undefined
const stderr = result.stderr ? await readableStreamToText(result.stderr) : undefined
log.info("done", {
code,
stdout,

View File

@@ -1,5 +1,6 @@
import { readableStreamToText, semver } from "bun"
import { Log } from "../util/log"
import { Process } from "../util/process"
export namespace PackageRegistry {
const log = Log.create({ service: "bun" })
@@ -9,7 +10,7 @@ export namespace PackageRegistry {
}
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
const result = Bun.spawn([which(), "info", pkg, field], {
const result = Process.spawn([which(), "info", pkg, field], {
cwd,
stdout: "pipe",
stderr: "pipe",

View File

@@ -11,6 +11,7 @@ import { Global } from "../../global"
import { Plugin } from "../../plugin"
import { Instance } from "../../project/instance"
import type { Hooks } from "@opencode-ai/plugin"
import { Process } from "../../util/process"
type PluginAuth = NonNullable<Hooks["auth"]>
@@ -263,8 +264,7 @@ export const AuthLoginCommand = cmd({
if (args.url) {
const wellknown = await fetch(`${args.url}/.well-known/opencode`).then((x) => x.json() as any)
prompts.log.info(`Running \`${wellknown.auth.command.join(" ")}\``)
const proc = Bun.spawn({
cmd: wellknown.auth.command,
const proc = Process.spawn(wellknown.auth.command, {
stdout: "pipe",
})
const exit = await proc.exited
@@ -273,6 +273,11 @@ export const AuthLoginCommand = cmd({
prompts.outro("Done")
return
}
if (!proc.stdout) {
prompts.log.error("Failed")
prompts.outro("Done")
return
}
const token = await new Response(proc.stdout).text()
await Auth.set(args.url, {
type: "wellknown",

View File

@@ -6,6 +6,7 @@ import { UI } from "../ui"
import { Locale } from "../../util/locale"
import { Flag } from "../../flag/flag"
import { Filesystem } from "../../util/filesystem"
import { Process } from "../../util/process"
import { EOL } from "os"
import path from "path"
@@ -102,13 +103,17 @@ export const SessionListCommand = cmd({
const shouldPaginate = process.stdout.isTTY && !args.maxCount && args.format === "table"
if (shouldPaginate) {
const proc = Bun.spawn({
cmd: pagerCmd(),
const proc = Process.spawn(pagerCmd(), {
stdin: "pipe",
stdout: "inherit",
stderr: "inherit",
})
if (!proc.stdin) {
console.log(output)
return
}
proc.stdin.write(output)
proc.stdin.end()
await proc.exited

View File

@@ -38,8 +38,6 @@ import { ArgsProvider, useArgs, type Args } from "./context/args"
import open from "open"
import { writeHeapSnapshot } from "v8"
import { PromptRefProvider, usePromptRef } from "./context/prompt"
import { TuiConfigProvider } from "./context/tui-config"
import { TuiConfig } from "@/config/tui"
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
// can't set raw mode if not a TTY
@@ -106,7 +104,6 @@ import type { EventSource } from "./context/sdk"
export function tui(input: {
url: string
args: Args
config: TuiConfig.Info
directory?: string
fetch?: typeof fetch
headers?: RequestInit["headers"]
@@ -141,37 +138,35 @@ export function tui(input: {
<KVProvider>
<ToastProvider>
<RouteProvider>
<TuiConfigProvider config={input.config}>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</TuiConfigProvider>
<SDKProvider
url={input.url}
directory={input.directory}
fetch={input.fetch}
headers={input.headers}
events={input.events}
>
<SyncProvider>
<ThemeProvider mode={mode}>
<LocalProvider>
<KeybindProvider>
<PromptStashProvider>
<DialogProvider>
<CommandProvider>
<FrecencyProvider>
<PromptHistoryProvider>
<PromptRefProvider>
<App />
</PromptRefProvider>
</PromptHistoryProvider>
</FrecencyProvider>
</CommandProvider>
</DialogProvider>
</PromptStashProvider>
</KeybindProvider>
</LocalProvider>
</ThemeProvider>
</SyncProvider>
</SDKProvider>
</RouteProvider>
</ToastProvider>
</KVProvider>

View File

@@ -2,9 +2,6 @@ import { cmd } from "../cmd"
import { UI } from "@/cli/ui"
import { tui } from "./app"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
import { existsSync } from "fs"
export const AttachCommand = cmd({
command: "attach <url>",
@@ -66,13 +63,8 @@ export const AttachCommand = cmd({
const auth = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
return { Authorization: auth }
})()
const config = await Instance.provide({
directory: directory && existsSync(directory) ? directory : process.cwd(),
fn: () => TuiConfig.get(),
})
await tui({
url: args.url,
config,
args: {
continue: args.continue,
sessionID: args.session,

View File

@@ -80,11 +80,11 @@ const TIPS = [
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
"Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
"Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
"Create {highlight}opencode.json{/highlight} in project root for project-specific settings",
"Place settings in {highlight}~/.config/opencode/opencode.json{/highlight} for global config",
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
"Configure {highlight}model{/highlight} in config to set your default model",
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
"Override any keybind in config via the {highlight}keybinds{/highlight} section",
"Set any keybind to {highlight}none{/highlight} to disable it completely",
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
"OpenCode auto-handles OAuth for remote MCP servers requiring auth",
@@ -140,7 +140,7 @@ const TIPS = [
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling",
"Enable {highlight}tui.scroll_acceleration{/highlight} for smooth macOS-style scrolling",
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",

View File

@@ -1,4 +1,5 @@
import { createMemo } from "solid-js"
import { useSync } from "@tui/context/sync"
import { Keybind } from "@/util/keybind"
import { pipe, mapValues } from "remeda"
import type { KeybindsConfig } from "@opencode-ai/sdk/v2"
@@ -6,15 +7,14 @@ import type { ParsedKey, Renderable } from "@opentui/core"
import { createStore } from "solid-js/store"
import { useKeyboard, useRenderer } from "@opentui/solid"
import { createSimpleContext } from "./helper"
import { useTuiConfig } from "./tui-config"
export const { use: useKeybind, provider: KeybindProvider } = createSimpleContext({
name: "Keybind",
init: () => {
const config = useTuiConfig()
const keybinds = createMemo<Record<string, Keybind.Info[]>>(() => {
const sync = useSync()
const keybinds = createMemo(() => {
return pipe(
(config.keybinds ?? {}) as Record<string, string>,
sync.data.config.keybinds ?? {},
mapValues((value) => Keybind.parse(value)),
)
})

View File

@@ -1,7 +1,9 @@
import { SyntaxStyle, RGBA, type TerminalColors } from "@opentui/core"
import path from "path"
import { createEffect, createMemo, onMount } from "solid-js"
import { useSync } from "@tui/context/sync"
import { createSimpleContext } from "./helper"
import { Glob } from "../../../../util/glob"
import aura from "./theme/aura.json" with { type: "json" }
import ayu from "./theme/ayu.json" with { type: "json" }
import catppuccin from "./theme/catppuccin.json" with { type: "json" }
@@ -40,7 +42,6 @@ import { useRenderer } from "@opentui/solid"
import { createStore, produce } from "solid-js/store"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { useTuiConfig } from "./tui-config"
type ThemeColors = {
primary: RGBA
@@ -279,17 +280,17 @@ function ansiToRgba(code: number): RGBA {
export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
name: "Theme",
init: (props: { mode: "dark" | "light" }) => {
const config = useTuiConfig()
const sync = useSync()
const kv = useKV()
const [store, setStore] = createStore({
themes: DEFAULT_THEMES,
mode: kv.get("theme_mode", props.mode),
active: (config.theme ?? kv.get("theme", "opencode")) as string,
active: (sync.data.config.theme ?? kv.get("theme", "opencode")) as string,
ready: false,
})
createEffect(() => {
const theme = config.theme
const theme = sync.data.config.theme
if (theme) setStore("active", theme)
})
@@ -391,7 +392,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
},
})
const CUSTOM_THEME_GLOB = new Bun.Glob("themes/*.json")
async function getCustomThemes() {
const directories = [
Global.Path.config,
@@ -405,11 +405,11 @@ async function getCustomThemes() {
const result: Record<string, ThemeJson> = {}
for (const dir of directories) {
for await (const item of CUSTOM_THEME_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("themes/*.json", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const name = path.basename(item, ".json")
result[name] = await Filesystem.readJson(item)

View File

@@ -1,9 +0,0 @@
import { TuiConfig } from "@/config/tui"
import { createSimpleContext } from "./helper"
export const { use: useTuiConfig, provider: TuiConfigProvider } = createSimpleContext({
name: "TuiConfig",
init: (props: { config: TuiConfig.Info }) => {
return props.config
},
})

View File

@@ -78,7 +78,6 @@ import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
addDefaultParsers(parsers.parsers)
@@ -101,7 +100,6 @@ const context = createContext<{
showDetails: () => boolean
diffWrapMode: () => "word" | "none"
sync: ReturnType<typeof useSync>
tui: ReturnType<typeof useTuiConfig>
}>()
function use() {
@@ -114,7 +112,6 @@ export function Session() {
const route = useRouteData("session")
const { navigate } = useRoute()
const sync = useSync()
const tuiConfig = useTuiConfig()
const kv = useKV()
const { theme } = useTheme()
const promptRef = usePromptRef()
@@ -167,7 +164,7 @@ export function Session() {
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const scrollAcceleration = createMemo(() => {
const tui = tuiConfig
const tui = sync.data.config.tui
if (tui?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
@@ -979,7 +976,6 @@ export function Session() {
showDetails,
diffWrapMode,
sync,
tui: tuiConfig,
}}
>
<box flexDirection="row">
@@ -1924,7 +1920,7 @@ function Edit(props: ToolProps<typeof EditTool>) {
const { theme, syntax } = useTheme()
const view = createMemo(() => {
const diffStyle = ctx.tui.diff_style
const diffStyle = ctx.sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
// Default to "auto" behavior
return ctx.width > 120 ? "split" : "unified"
@@ -1995,7 +1991,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
const files = createMemo(() => props.metadata.files ?? [])
const view = createMemo(() => {
const diffStyle = ctx.tui.diff_style
const diffStyle = ctx.sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
return ctx.width > 120 ? "split" : "unified"
})

View File

@@ -15,7 +15,6 @@ import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
import { useTuiConfig } from "../../context/tui-config"
type PermissionStage = "permission" | "always" | "reject"
@@ -49,14 +48,14 @@ function EditBody(props: { request: PermissionRequest }) {
const themeState = useTheme()
const theme = themeState.theme
const syntax = themeState.syntax
const config = useTuiConfig()
const sync = useSync()
const dimensions = useTerminalDimensions()
const filepath = createMemo(() => (props.request.metadata?.filepath as string) ?? "")
const diff = createMemo(() => (props.request.metadata?.diff as string) ?? "")
const view = createMemo(() => {
const diffStyle = config.diff_style
const diffStyle = sync.data.config.tui?.diff_style
if (diffStyle === "stacked") return "unified"
return dimensions().width > 120 ? "split" : "unified"
})

View File

@@ -12,8 +12,6 @@ import { Filesystem } from "@/util/filesystem"
import type { Event } from "@opencode-ai/sdk/v2"
import type { EventSource } from "./context/sdk"
import { win32DisableProcessedInput, win32InstallCtrlCGuard } from "./win32"
import { TuiConfig } from "@/config/tui"
import { Instance } from "@/project/instance"
declare global {
const OPENCODE_WORKER_PATH: string
@@ -137,10 +135,6 @@ export const TuiThreadCommand = cmd({
if (!args.prompt) return piped
return piped ? piped + "\n" + args.prompt : args.prompt
})
const config = await Instance.provide({
directory: cwd,
fn: () => TuiConfig.get(),
})
// Check if server should be started (port or hostname explicitly set in CLI or config)
const networkOpts = await resolveNetworkOptions(args)
@@ -169,8 +163,6 @@ export const TuiThreadCommand = cmd({
const tuiPromise = tui({
url,
config,
directory: cwd,
fetch: customFetch,
events,
args: {

View File

@@ -5,6 +5,7 @@ import { lazy } from "../../../../util/lazy.js"
import { tmpdir } from "os"
import path from "path"
import { Filesystem } from "../../../../util/filesystem"
import { Process } from "../../../../util/process"
/**
* Writes text to clipboard via OSC 52 escape sequence.
@@ -87,7 +88,8 @@ export namespace Clipboard {
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
console.log("clipboard: using wl-copy")
return async (text: string) => {
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
const proc = Process.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
@@ -96,11 +98,12 @@ export namespace Clipboard {
if (Bun.which("xclip")) {
console.log("clipboard: using xclip")
return async (text: string) => {
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
const proc = Process.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
@@ -109,11 +112,12 @@ export namespace Clipboard {
if (Bun.which("xsel")) {
console.log("clipboard: using xsel")
return async (text: string) => {
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
const proc = Process.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
@@ -125,7 +129,7 @@ export namespace Clipboard {
console.log("clipboard: using powershell")
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Bun.spawn(
const proc = Process.spawn(
[
"powershell.exe",
"-NonInteractive",
@@ -140,6 +144,7 @@ export namespace Clipboard {
},
)
if (!proc.stdin) return
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})

View File

@@ -4,6 +4,7 @@ import { tmpdir } from "node:os"
import { join } from "node:path"
import { CliRenderer } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
export namespace Editor {
export async function open(opts: { value: string; renderer: CliRenderer }): Promise<string | undefined> {
@@ -17,8 +18,7 @@ export namespace Editor {
opts.renderer.suspend()
opts.renderer.currentRenderBuffer.clear()
const parts = editor.split(" ")
const proc = Bun.spawn({
cmd: [...parts, filepath],
const proc = Process.spawn([...parts, filepath], {
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",

View File

@@ -3,6 +3,7 @@ import path from "path"
import { pathToFileURL } from "url"
import os from "os"
import z from "zod"
import { Filesystem } from "../util/filesystem"
import { ModelsDev } from "../provider/models"
import { mergeDeep, pipe, unique } from "remeda"
import { Global } from "../global"
@@ -27,12 +28,11 @@ import { constants, existsSync } from "fs"
import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { proxied } from "@/util/proxied"
import { iife } from "@/util/iife"
import { Control } from "@/control"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -41,7 +41,7 @@ export namespace Config {
// Managed settings directory for enterprise deployments (highest priority, admin-controlled)
// These settings override all user and project settings
function systemManagedConfigDir(): string {
function getManagedConfigDir(): string {
switch (process.platform) {
case "darwin":
return "/Library/Application Support/opencode"
@@ -52,14 +52,10 @@ export namespace Config {
}
}
export function managedConfigDir() {
return process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || systemManagedConfigDir()
}
const managedDir = managedConfigDir()
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR || getManagedConfigDir()
// Custom merge function that concatenates array fields instead of replacing them
function mergeConfigConcatArrays(target: Info, source: Info): Info {
function merge(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
@@ -94,7 +90,7 @@ export namespace Config {
const remoteConfig = wellknown.config ?? {}
// Add $schema to prevent load() from trying to write back to a non-existent file
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = mergeConfigConcatArrays(
result = merge(
result,
await load(JSON.stringify(remoteConfig), {
dir: path.dirname(`${key}/.well-known/opencode`),
@@ -110,18 +106,21 @@ export namespace Config {
}
// Global user config overrides remote config.
result = mergeConfigConcatArrays(result, await global())
result = merge(result, await global())
// Custom config path overrides global config.
if (Flag.OPENCODE_CONFIG) {
result = mergeConfigConcatArrays(result, await loadFile(Flag.OPENCODE_CONFIG))
result = merge(result, await loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
}
// Project config overrides global and remote config.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)) {
result = mergeConfigConcatArrays(result, await loadFile(file))
for (const file of ["opencode.jsonc", "opencode.json"]) {
const found = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
for (const resolved of found.toReversed()) {
result = merge(result, await loadFile(resolved))
}
}
}
@@ -129,10 +128,31 @@ export namespace Config {
result.mode = result.mode || {}
result.plugin = result.plugin || []
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
const directories = [
Global.Path.config,
// Only scan project .opencode/ directories when project discovery is enabled
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Instance.directory,
stop: Instance.worktree,
}),
)
: []),
// Always scan ~/.opencode/ (user home directory)
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
]
// .opencode directory config overrides (project and global) config sources.
if (Flag.OPENCODE_CONFIG_DIR) {
directories.push(Flag.OPENCODE_CONFIG_DIR)
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
}
@@ -142,7 +162,7 @@ export namespace Config {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
result = mergeConfigConcatArrays(result, await loadFile(path.join(dir, file)))
result = merge(result, await loadFile(path.join(dir, file)))
// to satisfy the type checker
result.agent ??= {}
result.mode ??= {}
@@ -165,7 +185,7 @@ export namespace Config {
// Inline config content overrides all non-managed config sources.
if (process.env.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(
result = merge(
result,
await load(process.env.OPENCODE_CONFIG_CONTENT, {
dir: Instance.directory,
@@ -179,9 +199,9 @@ export namespace Config {
// Kept separate from directories array to avoid write operations when installing plugins
// which would fail on system directories requiring elevated permissions
// This way it only loads config file and not skills/plugins/commands
if (existsSync(managedDir)) {
if (existsSync(managedConfigDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
result = merge(result, await loadFile(path.join(managedConfigDir, file)))
}
}
@@ -220,6 +240,8 @@ export namespace Config {
result.share = "auto"
}
if (!result.keybinds) result.keybinds = Info.shape.keybinds.parse({})
// Apply flag overrides for compaction settings
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
result.compaction = { ...result.compaction, auto: false }
@@ -282,7 +304,7 @@ export namespace Config {
}
}
export async function needsInstall(dir: string) {
async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
@@ -330,14 +352,13 @@ export namespace Config {
return ext.length ? file.slice(0, -ext.length) : file
}
const COMMAND_GLOB = new Bun.Glob("{command,commands}/**/*.md")
async function loadCommand(dir: string) {
const result: Record<string, Command> = {}
for await (const item of COMMAND_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{command,commands}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -369,15 +390,14 @@ export namespace Config {
return result
}
const AGENT_GLOB = new Bun.Glob("{agent,agents}/**/*.md")
async function loadAgent(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of AGENT_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{agent,agents}/**/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -409,14 +429,13 @@ export namespace Config {
return result
}
const MODE_GLOB = new Bun.Glob("{mode,modes}/*.md")
async function loadMode(dir: string) {
const result: Record<string, Agent> = {}
for await (const item of MODE_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{mode,modes}/*.md", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
const md = await ConfigMarkdown.parse(item).catch(async (err) => {
const message = ConfigMarkdown.FrontmatterError.isInstance(err)
@@ -446,15 +465,14 @@ export namespace Config {
return result
}
const PLUGIN_GLOB = new Bun.Glob("{plugin,plugins}/*.{ts,js}")
async function loadPlugin(dir: string) {
const plugins: string[] = []
for await (const item of PLUGIN_GLOB.scan({
absolute: true,
followSymlinks: true,
dot: true,
for (const item of await Glob.scan("{plugin,plugins}/*.{ts,js}", {
cwd: dir,
absolute: true,
dot: true,
symlink: true,
})) {
plugins.push(pathToFileURL(item).href)
}
@@ -909,6 +927,20 @@ export namespace Config {
ref: "KeybindsConfig",
})
export const TUI = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
.object({
enabled: z.boolean().describe("Enable scroll acceleration"),
})
.optional()
.describe("Scroll acceleration settings"),
diff_style: z
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
})
export const Server = z
.object({
port: z.number().int().positive().optional().describe("Port to listen on"),
@@ -983,7 +1015,10 @@ export namespace Config {
export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
theme: z.string().optional().describe("Theme name to use for the interface"),
keybinds: Keybinds.optional().describe("Custom keybind configurations"),
logLevel: Log.Level.optional().describe("Log level"),
tui: TUI.optional().describe("TUI specific settings"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
command: z
.record(z.string(), Command)
@@ -1203,37 +1238,86 @@ export namespace Config {
return result
})
export const { readFile } = ConfigPaths
async function loadFile(filepath: string): Promise<Info> {
log.info("loading", { path: filepath })
const text = await readFile(filepath)
let text = await Filesystem.readText(filepath).catch((err: any) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
if (!text) return {}
return load(text, { path: filepath })
}
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
const original = text
const configDir = "path" in options ? path.dirname(options.path) : options.dir
const source = "path" in options ? options.path : options.source
const isFile = "path" in options
const data = await ConfigPaths.parseText(
text,
"path" in options ? options.path : { source: options.source, dir: options.dir },
)
const normalized = (() => {
if (!data || typeof data !== "object" || Array.isArray(data)) return data
const copy = { ...(data as Record<string, unknown>) }
const hadLegacy = "theme" in copy || "keybinds" in copy || "tui" in copy
if (!hadLegacy) return copy
delete copy.theme
delete copy.keybinds
delete copy.tui
log.warn("tui keys in opencode config are deprecated; move them to tui.json", { path: source })
return copy
})()
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const parsed = Info.safeParse(normalized)
const fileMatches = text.match(/\{file:[^}]+\}/g)
if (fileMatches) {
const lines = text.split("\n")
for (const match of fileMatches) {
const lineIndex = lines.findIndex((line) => line.includes(match))
if (lineIndex !== -1 && lines[lineIndex].trim().startsWith("//")) {
continue
}
let filePath = match.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Bun.file(resolvedPath)
.text()
.catch((error) => {
const errMsg = `bad file reference: "${match}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: source,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: source, message: errMsg }, { cause: error })
})
).trim()
text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1))
}
}
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: source,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
const parsed = Info.safeParse(data)
if (parsed.success) {
if (!parsed.data.$schema && isFile) {
parsed.data.$schema = "https://opencode.ai/config.json"
@@ -1257,7 +1341,13 @@ export namespace Config {
issues: parsed.error.issues,
})
}
export const { JsonError, InvalidError } = ConfigPaths
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const ConfigDirectoryTypoError = NamedError.create(
"ConfigDirectoryTypoError",
@@ -1268,6 +1358,15 @@ export namespace Config {
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
export async function get() {
return state().then((x) => x.config)
}

View File

@@ -1,155 +0,0 @@
import path from "path"
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
import { unique } from "remeda"
import z from "zod"
import { ConfigPaths } from "./paths"
import { TuiInfo, TuiOptions } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { Filesystem } from "@/util/filesystem"
import { Global } from "@/global"
const log = Log.create({ service: "tui.migrate" })
const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
const LegacyTheme = TuiInfo.shape.theme.optional()
const LegacyRecord = z.record(z.string(), z.unknown()).optional()
const TuiLegacy = z
.object({
scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
diff_style: TuiOptions.shape.diff_style.catch(undefined),
})
.strip()
interface MigrateInput {
directories: string[]
custom?: string
managed: string
}
/**
* Migrates tui-specific keys (theme, keybinds, tui) from opencode.json files
* into dedicated tui.json files. Migration is performed per-directory and
* skips only locations where a tui.json already exists.
*/
export async function migrateTuiConfig(input: MigrateInput) {
const opencode = await opencodeFiles(input)
for (const file of opencode) {
const source = await Filesystem.readText(file).catch((error) => {
log.warn("failed to read config for tui migration", { path: file, error })
return undefined
})
if (!source) continue
const errors: JsoncParseError[] = []
const data = parseJsonc(source, errors, { allowTrailingComma: true })
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
const extracted = {
theme: theme.success ? theme.data : undefined,
keybinds: keybinds.success ? keybinds.data : undefined,
tui: legacyTui.success ? legacyTui.data : undefined,
}
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
const target = path.join(path.dirname(file), "tui.json")
const targetExists = await Filesystem.exists(target)
if (targetExists) continue
const payload: Record<string, unknown> = {
$schema: TUI_SCHEMA_URL,
}
if (extracted.theme !== undefined) payload.theme = extracted.theme
if (extracted.keybinds !== undefined) payload.keybinds = extracted.keybinds
if (tui) Object.assign(payload, tui)
const wrote = await Bun.write(target, JSON.stringify(payload, null, 2))
.then(() => true)
.catch((error) => {
log.warn("failed to write tui migration target", { from: file, to: target, error })
return false
})
if (!wrote) continue
const stripped = await backupAndStripLegacy(file, source)
if (!stripped) {
log.warn("tui config migrated but source file was not stripped", { from: file, to: target })
continue
}
log.info("migrated tui config", { from: file, to: target })
}
}
function normalizeTui(data: Record<string, unknown>) {
const parsed = TuiLegacy.parse(data)
if (
parsed.scroll_speed === undefined &&
parsed.diff_style === undefined &&
parsed.scroll_acceleration === undefined
) {
return
}
return parsed
}
async function backupAndStripLegacy(file: string, source: string) {
const backup = file + ".tui-migration.bak"
const hasBackup = await Filesystem.exists(backup)
const backed = hasBackup
? true
: await Bun.write(backup, source)
.then(() => true)
.catch((error) => {
log.warn("failed to backup source config during tui migration", { path: file, backup, error })
return false
})
if (!backed) return false
const text = ["theme", "keybinds", "tui"].reduce((acc, key) => {
const edits = modify(acc, [key], undefined, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
},
})
if (!edits.length) return acc
return applyEdits(acc, edits)
}, source)
return Bun.write(file, text)
.then(() => {
log.info("stripped tui keys from server config", { path: file, backup })
return true
})
.catch((error) => {
log.warn("failed to strip legacy tui keys from server config", { path: file, backup, error })
return false
})
}
async function opencodeFiles(input: { directories: string[]; managed: string }) {
const project = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("opencode", Instance.directory, Instance.worktree)
const files = [...project, ...ConfigPaths.fileInDirectory(Global.Path.config, "opencode")]
for (const dir of unique(input.directories)) {
files.push(...ConfigPaths.fileInDirectory(dir, "opencode"))
}
if (Flag.OPENCODE_CONFIG) files.push(Flag.OPENCODE_CONFIG)
files.push(...ConfigPaths.fileInDirectory(input.managed, "opencode"))
const existing = await Promise.all(
unique(files).map(async (file) => {
const ok = await Filesystem.exists(file)
return ok ? file : undefined
}),
)
return existing.filter((file): file is string => !!file)
}

View File

@@ -1,174 +0,0 @@
import path from "path"
import os from "os"
import z from "zod"
import { type ParseError as JsoncParseError, parse as parseJsonc, printParseErrorCode } from "jsonc-parser"
import { NamedError } from "@opencode-ai/util/error"
import { Filesystem } from "@/util/filesystem"
import { Flag } from "@/flag/flag"
import { Global } from "@/global"
export namespace ConfigPaths {
export async function projectFiles(name: string, directory: string, worktree: string) {
const files: string[] = []
for (const file of [`${name}.jsonc`, `${name}.json`]) {
const found = await Filesystem.findUp(file, directory, worktree)
for (const resolved of found.toReversed()) {
files.push(resolved)
}
}
return files
}
export async function directories(directory: string, worktree: string) {
return [
Global.Path.config,
...(!Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: directory,
stop: worktree,
}),
)
: []),
...(await Array.fromAsync(
Filesystem.up({
targets: [".opencode"],
start: Global.Path.home,
stop: Global.Path.home,
}),
)),
...(Flag.OPENCODE_CONFIG_DIR ? [Flag.OPENCODE_CONFIG_DIR] : []),
]
}
export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)]
}
export const JsonError = NamedError.create(
"ConfigJsonError",
z.object({
path: z.string(),
message: z.string().optional(),
}),
)
export const InvalidError = NamedError.create(
"ConfigInvalidError",
z.object({
path: z.string(),
issues: z.custom<z.core.$ZodIssue[]>().optional(),
message: z.string().optional(),
}),
)
/** Read a config file, returning undefined for missing files and throwing JsonError for other failures. */
export async function readFile(filepath: string) {
return Filesystem.readText(filepath).catch((err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") return
throw new JsonError({ path: filepath }, { cause: err })
})
}
type ParseSource = string | { source: string; dir: string }
function source(input: ParseSource) {
return typeof input === "string" ? input : input.source
}
function dir(input: ParseSource) {
return typeof input === "string" ? path.dirname(input) : input.dir
}
/** Apply {env:VAR} and {file:path} substitutions to config text. */
async function substitute(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => {
return process.env[varName] || ""
})
const fileMatches = Array.from(text.matchAll(/\{file:[^}]+\}/g))
if (!fileMatches.length) return text
const configDir = dir(input)
const configSource = source(input)
let out = ""
let cursor = 0
for (const match of fileMatches) {
const token = match[0]
const index = match.index!
out += text.slice(cursor, index)
const lineStart = text.lastIndexOf("\n", index - 1) + 1
const prefix = text.slice(lineStart, index).trimStart()
if (prefix.startsWith("//")) {
out += token
cursor = index + token.length
continue
}
let filePath = token.replace(/^\{file:/, "").replace(/\}$/, "")
if (filePath.startsWith("~/")) {
filePath = path.join(os.homedir(), filePath.slice(2))
}
const resolvedPath = path.isAbsolute(filePath) ? filePath : path.resolve(configDir, filePath)
const fileContent = (
await Filesystem.readText(resolvedPath).catch((error: NodeJS.ErrnoException) => {
if (missing === "empty") return ""
const errMsg = `bad file reference: "${token}"`
if (error.code === "ENOENT") {
throw new InvalidError(
{
path: configSource,
message: errMsg + ` ${resolvedPath} does not exist`,
},
{ cause: error },
)
}
throw new InvalidError({ path: configSource, message: errMsg }, { cause: error })
})
).trim()
out += JSON.stringify(fileContent).slice(1, -1)
cursor = index + token.length
}
out += text.slice(cursor)
return out
}
/** Substitute and parse JSONC text, throwing JsonError on syntax errors. */
export async function parseText(text: string, input: ParseSource, missing: "error" | "empty" = "error") {
const configSource = source(input)
text = await substitute(text, input, missing)
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
if (errors.length) {
const lines = text.split("\n")
const errorDetails = errors
.map((e) => {
const beforeOffset = text.substring(0, e.offset).split("\n")
const line = beforeOffset.length
const column = beforeOffset[beforeOffset.length - 1].length + 1
const problemLine = lines[line - 1]
const error = `${printParseErrorCode(e.error)} at line ${line}, column ${column}`
if (!problemLine) return error
return `${error}\n Line ${line}: ${problemLine}\n${"".padStart(column + 9)}^`
})
.join("\n")
throw new JsonError({
path: configSource,
message: `\n--- JSONC Input ---\n${text}\n--- Errors ---\n${errorDetails}\n--- End ---`,
})
}
return data
}
}

View File

@@ -1,34 +0,0 @@
import z from "zod"
import { Config } from "./config"
const KeybindOverride = z
.object(
Object.fromEntries(Object.keys(Config.Keybinds.shape).map((key) => [key, z.string().optional()])) as Record<
string,
z.ZodOptional<z.ZodString>
>,
)
.strict()
export const TuiOptions = z.object({
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
scroll_acceleration: z
.object({
enabled: z.boolean().describe("Enable scroll acceleration"),
})
.optional()
.describe("Scroll acceleration settings"),
diff_style: z
.enum(["auto", "stacked"])
.optional()
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
})
export const TuiInfo = z
.object({
$schema: z.string().optional(),
theme: z.string().optional(),
keybinds: KeybindOverride.optional(),
})
.extend(TuiOptions.shape)
.strict()

View File

@@ -1,118 +0,0 @@
import { existsSync } from "fs"
import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Config } from "./config"
import { ConfigPaths } from "./paths"
import { migrateTuiConfig } from "./migrate-tui-config"
import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { Global } from "@/global"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo
export type Info = z.output<typeof Info>
function mergeInfo(target: Info, source: Info): Info {
return mergeDeep(target, source)
}
function customPath() {
return Flag.OPENCODE_TUI_CONFIG
}
const state = Instance.state(async () => {
let projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
const custom = customPath()
const managed = Config.managedConfigDir()
await migrateTuiConfig({ directories, custom, managed })
// Re-compute after migration since migrateTuiConfig may have created new tui.json files
projectFiles = Flag.OPENCODE_DISABLE_PROJECT_CONFIG
? []
: await ConfigPaths.projectFiles("tui", Instance.directory, Instance.worktree)
let result: Info = {}
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
result = mergeInfo(result, await loadFile(file))
}
if (custom) {
result = mergeInfo(result, await loadFile(custom))
log.debug("loaded custom tui config", { path: custom })
}
for (const file of projectFiles) {
result = mergeInfo(result, await loadFile(file))
}
for (const dir of unique(directories)) {
if (!dir.endsWith(".opencode") && dir !== Flag.OPENCODE_CONFIG_DIR) continue
for (const file of ConfigPaths.fileInDirectory(dir, "tui")) {
result = mergeInfo(result, await loadFile(file))
}
}
if (existsSync(managed)) {
for (const file of ConfigPaths.fileInDirectory(managed, "tui")) {
result = mergeInfo(result, await loadFile(file))
}
}
result.keybinds = Config.Keybinds.parse(result.keybinds ?? {})
return {
config: result,
}
})
export async function get() {
return state().then((x) => x.config)
}
async function loadFile(filepath: string): Promise<Info> {
const text = await ConfigPaths.readFile(filepath)
if (!text) return {}
return load(text, filepath).catch((error) => {
log.warn("failed to load tui config", { path: filepath, error })
return {}
})
}
async function load(text: string, configFilepath: string): Promise<Info> {
const data = await ConfigPaths.parseText(text, configFilepath, "empty")
if (!data || typeof data !== "object" || Array.isArray(data)) return {}
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
// (mirroring the old opencode.json shape) still get their settings applied.
const normalized = (() => {
const copy = { ...(data as Record<string, unknown>) }
if (!("tui" in copy)) return copy
if (!copy.tui || typeof copy.tui !== "object" || Array.isArray(copy.tui)) {
delete copy.tui
return copy
}
const tui = copy.tui as Record<string, unknown>
delete copy.tui
return {
...tui,
...copy,
}
})()
const parsed = Info.safeParse(normalized)
if (!parsed.success) {
log.warn("invalid tui config", { path: configFilepath, issues: parsed.error.issues })
return {}
}
return parsed.data
}
}

View File

@@ -1,4 +1,5 @@
import { sep } from "node:path"
import { Glob } from "../util/glob"
export namespace FileIgnore {
const FOLDERS = new Set([
@@ -53,19 +54,17 @@ export namespace FileIgnore {
"**/.nyc_output/**",
]
const FILE_GLOBS = FILES.map((p) => new Bun.Glob(p))
export const PATTERNS = [...FILES, ...FOLDERS]
export function match(
filepath: string,
opts?: {
extra?: Bun.Glob[]
whitelist?: Bun.Glob[]
extra?: string[]
whitelist?: string[]
},
) {
for (const glob of opts?.whitelist || []) {
if (glob.match(filepath)) return false
for (const pattern of opts?.whitelist || []) {
if (Glob.match(pattern, filepath)) return false
}
const parts = filepath.split(sep)
@@ -74,8 +73,8 @@ export namespace FileIgnore {
}
const extra = opts?.extra || []
for (const glob of [...FILE_GLOBS, ...extra]) {
if (glob.match(filepath)) return true
for (const pattern of [...FILES, ...extra]) {
if (Glob.match(pattern, filepath)) return true
}
return false

View File

@@ -7,6 +7,7 @@ import { NamedError } from "@opencode-ai/util/error"
import { lazy } from "../util/lazy"
import { $ } from "bun"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { ZipReader, BlobReader, BlobWriter } from "@zip.js/zip.js"
import { Log } from "@/util/log"
@@ -153,7 +154,7 @@ export namespace Ripgrep {
if (platformKey.endsWith("-darwin")) args.push("--include=*/rg")
if (platformKey.endsWith("-linux")) args.push("--wildcards", "*/rg")
const proc = Bun.spawn(args, {
const proc = Process.spawn(args, {
cwd: Global.Path.bin,
stderr: "pipe",
stdout: "pipe",
@@ -162,7 +163,7 @@ export namespace Ripgrep {
if (proc.exitCode !== 0)
throw new ExtractionFailedError({
filepath,
stderr: await Bun.readableStreamToText(proc.stderr),
stderr: proc.stderr ? await Bun.readableStreamToText(proc.stderr) : "",
})
}
if (config.extension === "zip") {
@@ -227,8 +228,7 @@ export namespace Ripgrep {
}
}
// Bun.spawn should throw this, but it incorrectly reports that the executable does not exist.
// See https://github.com/oven-sh/bun/issues/24012
// Guard against invalid cwd to provide a consistent ENOENT error.
if (!(await fs.stat(input.cwd).catch(() => undefined))?.isDirectory()) {
throw Object.assign(new Error(`No such file or directory: '${input.cwd}'`), {
code: "ENOENT",
@@ -237,14 +237,17 @@ export namespace Ripgrep {
})
}
const proc = Bun.spawn(args, {
const proc = Process.spawn(args, {
cwd: input.cwd,
stdout: "pipe",
stderr: "ignore",
maxBuffer: 1024 * 1024 * 20,
signal: input.signal,
})
if (!proc.stdout) {
throw new Error("Process output not available")
}
const reader = proc.stdout.getReader()
const decoder = new TextDecoder()
let buffer = ""

View File

@@ -7,7 +7,6 @@ export namespace Flag {
export const OPENCODE_AUTO_SHARE = truthy("OPENCODE_AUTO_SHARE")
export const OPENCODE_GIT_BASH_PATH = process.env["OPENCODE_GIT_BASH_PATH"]
export const OPENCODE_CONFIG = process.env["OPENCODE_CONFIG"]
export declare const OPENCODE_TUI_CONFIG: string | undefined
export declare const OPENCODE_CONFIG_DIR: string | undefined
export const OPENCODE_CONFIG_CONTENT = process.env["OPENCODE_CONFIG_CONTENT"]
export const OPENCODE_DISABLE_AUTOUPDATE = truthy("OPENCODE_DISABLE_AUTOUPDATE")
@@ -75,17 +74,6 @@ Object.defineProperty(Flag, "OPENCODE_DISABLE_PROJECT_CONFIG", {
configurable: false,
})
// Dynamic getter for OPENCODE_TUI_CONFIG
// This must be evaluated at access time, not module load time,
// because tests and external tooling may set this env var at runtime
Object.defineProperty(Flag, "OPENCODE_TUI_CONFIG", {
get() {
return process.env["OPENCODE_TUI_CONFIG"]
},
enumerable: true,
configurable: false,
})
// Dynamic getter for OPENCODE_CONFIG_DIR
// This must be evaluated at access time, not module load time,
// because external tooling may set this env var at runtime

View File

@@ -2,6 +2,7 @@ import { readableStreamToText } from "bun"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
import { Flag } from "@/flag/flag"
export interface Info {
@@ -213,11 +214,12 @@ export const rlang: Info = {
if (airPath == null) return false
try {
const proc = Bun.spawn(["air", "--help"], {
const proc = Process.spawn(["air", "--help"], {
stdout: "pipe",
stderr: "pipe",
})
await proc.exited
if (!proc.stdout) return false
const output = await readableStreamToText(proc.stdout)
// Check for "Air: An R language server and formatter"
@@ -238,7 +240,7 @@ export const uvformat: Info = {
async enabled() {
if (await ruff.enabled()) return false
if (Bun.which("uv") !== null) {
const proc = Bun.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const proc = Process.spawn(["uv", "format", "--help"], { stderr: "pipe", stdout: "pipe" })
const code = await proc.exited
return code === 0
}

View File

@@ -8,6 +8,7 @@ import * as Formatter from "./formatter"
import { Config } from "../config/config"
import { mergeDeep } from "remeda"
import { Instance } from "../project/instance"
import { Process } from "../util/process"
export namespace Format {
const log = Log.create({ service: "format" })
@@ -110,13 +111,15 @@ export namespace Format {
for (const item of await getFormatter(ext)) {
log.info("running", { command: item.command })
try {
const proc = Bun.spawn({
cmd: item.command.map((x) => x.replace("$FILE", file)),
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
})
const proc = Process.spawn(
item.command.map((x) => x.replace("$FILE", file)),
{
cwd: Instance.directory,
env: { ...process.env, ...item.environment },
stdout: "ignore",
stderr: "ignore",
},
)
const exit = await proc.exited
if (exit !== 0)
log.error("failed", {

View File

@@ -10,6 +10,7 @@ import { Filesystem } from "../util/filesystem"
import { Instance } from "../project/instance"
import { Flag } from "../flag/flag"
import { Archive } from "../util/archive"
import { Process } from "../util/process"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -133,7 +134,7 @@ export namespace LSPServer {
)
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "@vue/language-server"], {
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -263,14 +264,16 @@ export namespace LSPServer {
}
if (lintBin) {
const proc = Bun.spawn([lintBin, "--help"], { stdout: "pipe" })
const proc = Process.spawn([lintBin, "--help"], { stdout: "pipe" })
await proc.exited
const help = await readableStreamToText(proc.stdout)
if (help.includes("--lsp")) {
return {
process: spawn(lintBin, ["--lsp"], {
cwd: root,
}),
if (proc.stdout) {
const help = await readableStreamToText(proc.stdout)
if (help.includes("--lsp")) {
return {
process: spawn(lintBin, ["--lsp"], {
cwd: root,
}),
}
}
}
}
@@ -372,8 +375,7 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing gopls")
const proc = Bun.spawn({
cmd: ["go", "install", "golang.org/x/tools/gopls@latest"],
const proc = Process.spawn(["go", "install", "golang.org/x/tools/gopls@latest"], {
env: { ...process.env, GOBIN: Global.Path.bin },
stdout: "pipe",
stderr: "pipe",
@@ -414,8 +416,7 @@ export namespace LSPServer {
}
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing rubocop")
const proc = Bun.spawn({
cmd: ["gem", "install", "rubocop", "--bindir", Global.Path.bin],
const proc = Process.spawn(["gem", "install", "rubocop", "--bindir", Global.Path.bin], {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
@@ -513,7 +514,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "pyright"], {
await Process.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -746,8 +747,7 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing csharp-ls via dotnet tool")
const proc = Bun.spawn({
cmd: ["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin],
const proc = Process.spawn(["dotnet", "tool", "install", "csharp-ls", "--tool-path", Global.Path.bin], {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
@@ -786,8 +786,7 @@ export namespace LSPServer {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
log.info("installing fsautocomplete via dotnet tool")
const proc = Bun.spawn({
cmd: ["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin],
const proc = Process.spawn(["dotnet", "tool", "install", "fsautocomplete", "--tool-path", Global.Path.bin], {
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
@@ -1047,7 +1046,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "svelte-language-server"], {
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -1094,7 +1093,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -1339,7 +1338,7 @@ export namespace LSPServer {
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "yaml-language-server"], {
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -1518,7 +1517,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "intelephense"], {
await Process.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -1615,7 +1614,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "bash-language-server"], {
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
@@ -1827,7 +1826,7 @@ export namespace LSPServer {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Bun.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
env: {
...process.env,

View File

@@ -13,6 +13,7 @@ import { iife } from "@/util/iife"
import { GlobalBus } from "@/bus/global"
import { existsSync } from "fs"
import { git } from "../util/git"
import { Glob } from "../util/glob"
export namespace Project {
const log = Log.create({ service: "project" })
@@ -262,16 +263,11 @@ export namespace Project {
if (input.vcs !== "git") return
if (input.icon?.override) return
if (input.icon?.url) return
const glob = new Bun.Glob("**/{favicon}.{ico,png,svg,jpg,jpeg,webp}")
const matches = await Array.fromAsync(
glob.scan({
cwd: input.worktree,
absolute: true,
onlyFiles: true,
followSymlinks: false,
dot: false,
}),
)
const matches = await Glob.scan("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
cwd: input.worktree,
absolute: true,
include: "file",
})
const shortest = matches.sort((a, b) => a.length - b.length)[0]
if (!shortest) return
const buffer = await Filesystem.readBytes(shortest)

View File

@@ -6,6 +6,7 @@ import { Config } from "../config/config"
import { Instance } from "../project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "../util/log"
import { Glob } from "../util/glob"
import type { MessageV2 } from "./message-v2"
const log = Log.create({ service: "instruction" })
@@ -98,13 +99,11 @@ export namespace InstructionPrompt {
instruction = path.join(os.homedir(), instruction.slice(2))
}
const matches = path.isAbsolute(instruction)
? await Array.fromAsync(
new Bun.Glob(path.basename(instruction)).scan({
cwd: path.dirname(instruction),
absolute: true,
onlyFiles: true,
}),
).catch(() => [])
? await Glob.scan(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
}).catch(() => [])
: await resolveRelative(instruction)
matches.forEach((p) => {
paths.add(path.resolve(p))

View File

@@ -12,6 +12,7 @@ import { Flag } from "@/flag/flag"
import { Bus } from "@/bus"
import { Session } from "@/session"
import { Discovery } from "./discovery"
import { Glob } from "../util/glob"
export namespace Skill {
const log = Log.create({ service: "skill" })
@@ -44,10 +45,9 @@ export namespace Skill {
// External skill directories to search for (project-level and global)
// These follow the directory layout used by Claude Code and other agents.
const EXTERNAL_DIRS = [".claude", ".agents"]
const EXTERNAL_SKILL_GLOB = new Bun.Glob("skills/**/SKILL.md")
const OPENCODE_SKILL_GLOB = new Bun.Glob("{skill,skills}/**/SKILL.md")
const SKILL_GLOB = new Bun.Glob("**/SKILL.md")
const EXTERNAL_SKILL_PATTERN = "skills/**/SKILL.md"
const OPENCODE_SKILL_PATTERN = "{skill,skills}/**/SKILL.md"
const SKILL_PATTERN = "**/SKILL.md"
export const state = Instance.state(async () => {
const skills: Record<string, Info> = {}
@@ -88,15 +88,13 @@ export namespace Skill {
}
const scanExternal = async (root: string, scope: "global" | "project") => {
return Array.fromAsync(
EXTERNAL_SKILL_GLOB.scan({
cwd: root,
absolute: true,
onlyFiles: true,
followSymlinks: true,
dot: true,
}),
)
return Glob.scan(EXTERNAL_SKILL_PATTERN, {
cwd: root,
absolute: true,
include: "file",
dot: true,
symlink: true,
})
.then((matches) => Promise.all(matches.map(addSkill)))
.catch((error) => {
log.error(`failed to scan ${scope} skills`, { dir: root, error })
@@ -123,12 +121,13 @@ export namespace Skill {
// Scan .opencode/skill/ directories
for (const dir of await Config.directories()) {
for await (const match of OPENCODE_SKILL_GLOB.scan({
const matches = await Glob.scan(OPENCODE_SKILL_PATTERN, {
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
@@ -142,12 +141,13 @@ export namespace Skill {
log.warn("skill path not found", { path: resolved })
continue
}
for await (const match of SKILL_GLOB.scan({
const matches = await Glob.scan(SKILL_PATTERN, {
cwd: resolved,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}
@@ -157,12 +157,13 @@ export namespace Skill {
const list = await Discovery.pull(url)
for (const dir of list) {
dirs.add(dir)
for await (const match of SKILL_GLOB.scan({
const matches = await Glob.scan(SKILL_PATTERN, {
cwd: dir,
absolute: true,
onlyFiles: true,
followSymlinks: true,
})) {
include: "file",
symlink: true,
})
for (const match of matches) {
await addSkill(match)
}
}

View File

@@ -8,6 +8,7 @@ import { SessionShareTable } from "../share/share.sql"
import path from "path"
import { existsSync } from "fs"
import { Filesystem } from "../util/filesystem"
import { Glob } from "../util/glob"
export namespace JsonMigration {
const log = Log.create({ service: "json-migration" })
@@ -71,12 +72,7 @@ export namespace JsonMigration {
const now = Date.now()
async function list(pattern: string) {
const items: string[] = []
const scan = new Bun.Glob(pattern)
for await (const file of scan.scan({ cwd: storageDir, absolute: true })) {
items.push(file)
}
return items
return Glob.scan(pattern, { cwd: storageDir, absolute: true })
}
async function read(files: string[], start: number, end: number) {

View File

@@ -8,6 +8,7 @@ import { Lock } from "../util/lock"
import { $ } from "bun"
import { NamedError } from "@opencode-ai/util/error"
import z from "zod"
import { Glob } from "../util/glob"
export namespace Storage {
const log = Log.create({ service: "storage" })
@@ -25,17 +26,20 @@ export namespace Storage {
async (dir) => {
const project = path.resolve(dir, "../project")
if (!(await Filesystem.isDir(project))) return
for await (const projectDir of new Bun.Glob("*").scan({
const projectDirs = await Glob.scan("*", {
cwd: project,
onlyFiles: false,
})) {
include: "all",
})
for (const projectDir of projectDirs) {
const fullPath = path.join(project, projectDir)
if (!(await Filesystem.isDir(fullPath))) continue
log.info(`migrating project ${projectDir}`)
let projectID = projectDir
const fullProjectDir = path.join(project, projectDir)
let worktree = "/"
if (projectID !== "global") {
for await (const msgFile of new Bun.Glob("storage/session/message/*/*.json").scan({
for (const msgFile of await Glob.scan("storage/session/message/*/*.json", {
cwd: path.join(project, projectDir),
absolute: true,
})) {
@@ -71,7 +75,7 @@ export namespace Storage {
})
log.info(`migrating sessions for project ${projectID}`)
for await (const sessionFile of new Bun.Glob("storage/session/info/*.json").scan({
for (const sessionFile of await Glob.scan("storage/session/info/*.json", {
cwd: fullProjectDir,
absolute: true,
})) {
@@ -83,7 +87,7 @@ export namespace Storage {
const session = await Filesystem.readJson<any>(sessionFile)
await Filesystem.writeJson(dest, session)
log.info(`migrating messages for session ${session.id}`)
for await (const msgFile of new Bun.Glob(`storage/session/message/${session.id}/*.json`).scan({
for (const msgFile of await Glob.scan(`storage/session/message/${session.id}/*.json`, {
cwd: fullProjectDir,
absolute: true,
})) {
@@ -96,12 +100,10 @@ export namespace Storage {
await Filesystem.writeJson(dest, message)
log.info(`migrating parts for message ${message.id}`)
for await (const partFile of new Bun.Glob(`storage/session/part/${session.id}/${message.id}/*.json`).scan(
{
cwd: fullProjectDir,
absolute: true,
},
)) {
for (const partFile of await Glob.scan(`storage/session/part/${session.id}/${message.id}/*.json`, {
cwd: fullProjectDir,
absolute: true,
})) {
const dest = path.join(dir, "part", message.id, path.basename(partFile))
const part = await Filesystem.readJson(partFile)
log.info("copying", {
@@ -116,7 +118,7 @@ export namespace Storage {
}
},
async (dir) => {
for await (const item of new Bun.Glob("session/*/*.json").scan({
for (const item of await Glob.scan("session/*/*.json", {
cwd: dir,
absolute: true,
})) {
@@ -202,16 +204,13 @@ export namespace Storage {
})
}
const glob = new Bun.Glob("**/*")
export async function list(prefix: string[]) {
const dir = await state().then((x) => x.dir)
try {
const result = await Array.fromAsync(
glob.scan({
cwd: path.join(dir, ...prefix),
onlyFiles: true,
}),
).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
const result = await Glob.scan("**/*", {
cwd: path.join(dir, ...prefix),
include: "file",
}).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
result.sort()
return result
} catch {

View File

@@ -2,6 +2,7 @@ import z from "zod"
import { Tool } from "./tool"
import { Filesystem } from "../util/filesystem"
import { Ripgrep } from "../file/ripgrep"
import { Process } from "../util/process"
import DESCRIPTION from "./grep.txt"
import { Instance } from "../project/instance"
@@ -44,12 +45,16 @@ export const GrepTool = Tool.define("grep", {
}
args.push(searchPath)
const proc = Bun.spawn([rgPath, ...args], {
const proc = Process.spawn([rgPath, ...args], {
stdout: "pipe",
stderr: "pipe",
signal: ctx.abort,
})
if (!proc.stdout || !proc.stderr) {
throw new Error("Process output not available")
}
const output = await new Response(proc.stdout).text()
const errorOutput = await new Response(proc.stderr).text()
const exitCode = await proc.exited

View File

@@ -27,16 +27,18 @@ import { LspTool } from "./lsp"
import { Truncate } from "./truncation"
import { PlanExitTool, PlanEnterTool } from "./plan"
import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
export const state = Instance.state(async () => {
const custom = [] as Tool.Info[]
const glob = new Bun.Glob("{tool,tools}/*.{js,ts}")
const matches = await Config.directories().then((dirs) =>
dirs.flatMap((dir) => [...glob.scanSync({ cwd: dir, absolute: true, followSymlinks: true, dot: true })]),
dirs.flatMap((dir) =>
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
),
)
if (matches.length) await Config.waitForDependencies()
for (const match of matches) {

View File

@@ -6,6 +6,7 @@ import { PermissionNext } from "../permission/next"
import type { Agent } from "../agent/agent"
import { Scheduler } from "../scheduler"
import { Filesystem } from "../util/filesystem"
import { Glob } from "../util/glob"
export namespace Truncate {
export const MAX_LINES = 2000
@@ -34,8 +35,7 @@ export namespace Truncate {
export async function cleanup() {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - RETENTION_MS))
const glob = new Bun.Glob("tool_*")
const entries = await Array.fromAsync(glob.scan({ cwd: DIR, onlyFiles: true })).catch(() => [] as string[])
const entries = await Glob.scan("tool_*", { cwd: DIR, include: "file" }).catch(() => [] as string[])
for (const entry of entries) {
if (Identifier.timestamp(entry) >= cutoff) continue
await fs.unlink(path.join(DIR, entry)).catch(() => {})

View File

@@ -5,6 +5,7 @@ import { realpathSync } from "fs"
import { dirname, join, relative } from "path"
import { Readable } from "stream"
import { pipeline } from "stream/promises"
import { Glob } from "./glob"
export namespace Filesystem {
// Fast sync version for metadata checks
@@ -156,16 +157,13 @@ export namespace Filesystem {
const result = []
while (true) {
try {
const glob = new Bun.Glob(pattern)
for await (const match of glob.scan({
const matches = await Glob.scan(pattern, {
cwd: current,
absolute: true,
onlyFiles: true,
followSymlinks: true,
include: "file",
dot: true,
})) {
result.push(match)
}
})
result.push(...matches)
} catch {
// Skip invalid glob patterns
}

View File

@@ -1,5 +1,6 @@
import { $ } from "bun"
import { Flag } from "../flag/flag"
import { Process } from "./process"
export interface GitResult {
exitCode: number
@@ -14,12 +15,12 @@ export interface GitResult {
* Uses Bun's lightweight `$` shell by default. When the process is running
* as an ACP client, child processes inherit the parent's stdin pipe which
* carries protocol data on Windows this causes git to deadlock. In that
* case we fall back to `Bun.spawn` with `stdin: "ignore"`.
* case we fall back to `Process.spawn` with `stdin: "ignore"`.
*/
export async function git(args: string[], opts: { cwd: string; env?: Record<string, string> }): Promise<GitResult> {
if (Flag.OPENCODE_CLIENT === "acp") {
try {
const proc = Bun.spawn(["git", ...args], {
const proc = Process.spawn(["git", ...args], {
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",

View File

@@ -0,0 +1,34 @@
import { glob, globSync, type GlobOptions } from "glob"
import { minimatch } from "minimatch"
export namespace Glob {
export interface Options {
cwd?: string
absolute?: boolean
include?: "file" | "all"
dot?: boolean
symlink?: boolean
}
function toGlobOptions(options: Options): GlobOptions {
return {
cwd: options.cwd,
absolute: options.absolute,
dot: options.dot,
follow: options.symlink ?? false,
nodir: options.include !== "all",
}
}
export async function scan(pattern: string, options: Options = {}): Promise<string[]> {
return glob(pattern, toGlobOptions(options)) as Promise<string[]>
}
export function scanSync(pattern: string, options: Options = {}): string[] {
return globSync(pattern, toGlobOptions(options)) as string[]
}
export function match(pattern: string, filepath: string): boolean {
return minimatch(filepath, pattern, { dot: true })
}
}

View File

@@ -3,6 +3,7 @@ import fs from "fs/promises"
import { createWriteStream } from "fs"
import { Global } from "../global"
import z from "zod"
import { Glob } from "./glob"
export namespace Log {
export const Level = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]).meta({ ref: "LogLevel", description: "Log level" })
@@ -77,13 +78,11 @@ export namespace Log {
}
async function cleanup(dir: string) {
const glob = new Bun.Glob("????-??-??T??????.log")
const files = await Array.fromAsync(
glob.scan({
cwd: dir,
absolute: true,
}),
)
const files = await Glob.scan("????-??-??T??????.log", {
cwd: dir,
absolute: true,
include: "file",
})
if (files.length <= 5) return
const filesToDelete = files.slice(0, -10)

View File

@@ -0,0 +1,73 @@
import { spawn as childSpawn } from "child_process"
import { Readable } from "stream"
export namespace Process {
export type Stdio = "inherit" | "pipe" | "ignore"
export interface Options {
cwd?: string
env?: NodeJS.ProcessEnv
stdin?: Stdio
stdout?: Stdio
stderr?: Stdio
signal?: AbortSignal
}
export interface Child {
stdin: NodeJS.WritableStream | null
stdout: ReadableStream<Uint8Array> | null
stderr: ReadableStream<Uint8Array> | null
exited: Promise<number>
kill(signal?: NodeJS.Signals | number): boolean
readonly exitCode: number | null
readonly pid: number | undefined
}
export function spawn(cmd: string[], options: Options = {}): Child {
if (cmd.length === 0) throw new Error("Command is required")
options.signal?.throwIfAborted()
const proc = childSpawn(cmd[0], cmd.slice(1), {
cwd: options.cwd,
env: options.env,
stdio: [options.stdin ?? "ignore", options.stdout ?? "inherit", options.stderr ?? "inherit"],
})
const abort = () => {
if (proc.killed) return
proc.kill()
}
let code: number | null = null
const exited = new Promise<number>((resolve, reject) => {
const done = () => options.signal?.removeEventListener("abort", abort)
proc.once("exit", (exitCode, signal) => {
done()
code = exitCode ?? (signal ? 1 : 0)
resolve(code)
})
proc.once("error", (error) => {
done()
reject(error)
})
})
if (options.signal) {
options.signal.addEventListener("abort", abort, { once: true })
}
return {
stdin: proc.stdin,
stdout: proc.stdout ? (Readable.toWeb(proc.stdout) as unknown as ReadableStream<Uint8Array>) : null,
stderr: proc.stderr ? (Readable.toWeb(proc.stderr) as unknown as ReadableStream<Uint8Array>) : null,
exited,
kill: (signal) => proc.kill(signal),
get exitCode() {
return code ?? proc.exitCode
},
get pid() {
return proc.pid
},
}
}
}

View File

@@ -56,28 +56,6 @@ test("loads JSON config file", async () => {
})
})
test("ignores legacy tui keys in opencode config", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
model: "test/model",
theme: "legacy",
tui: { scroll_speed: 4 },
})
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.model).toBe("test/model")
expect((config as Record<string, unknown>).theme).toBeUndefined()
expect((config as Record<string, unknown>).tui).toBeUndefined()
},
})
})
test("loads JSONC config file", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
@@ -132,14 +110,14 @@ test("merges multiple config files with correct precedence", async () => {
test("handles environment variable substitution", async () => {
const originalEnv = process.env["TEST_VAR"]
process.env["TEST_VAR"] = "test-user"
process.env["TEST_VAR"] = "test_theme"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
username: "{env:TEST_VAR}",
theme: "{env:TEST_VAR}",
})
},
})
@@ -147,7 +125,7 @@ test("handles environment variable substitution", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.username).toBe("test-user")
expect(config.theme).toBe("test_theme")
},
})
} finally {
@@ -170,7 +148,7 @@ test("preserves env variables when adding $schema to config", async () => {
await Filesystem.write(
path.join(dir, "opencode.json"),
JSON.stringify({
username: "{env:PRESERVE_VAR}",
theme: "{env:PRESERVE_VAR}",
}),
)
},
@@ -179,7 +157,7 @@ test("preserves env variables when adding $schema to config", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.username).toBe("secret_value")
expect(config.theme).toBe("secret_value")
// Read the file to verify the env variable was preserved
const content = await Filesystem.readText(path.join(tmp.path, "opencode.json"))
@@ -200,10 +178,10 @@ test("preserves env variables when adding $schema to config", async () => {
test("handles file inclusion substitution", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "included.txt"), "test-user")
await Filesystem.write(path.join(dir, "included.txt"), "test_theme")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
username: "{file:included.txt}",
theme: "{file:included.txt}",
})
},
})
@@ -211,7 +189,7 @@ test("handles file inclusion substitution", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.username).toBe("test-user")
expect(config.theme).toBe("test_theme")
},
})
})
@@ -222,7 +200,7 @@ test("handles file inclusion with replacement tokens", async () => {
await Filesystem.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`")
await writeConfig(dir, {
$schema: "https://opencode.ai/config.json",
username: "{file:included.md}",
theme: "{file:included.md}",
})
},
})
@@ -230,7 +208,7 @@ test("handles file inclusion with replacement tokens", async () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.username).toBe("const out = await Bun.$`echo hi`")
expect(config.theme).toBe("const out = await Bun.$`echo hi`")
},
})
})
@@ -1065,6 +1043,7 @@ test("managed settings override project settings", async () => {
$schema: "https://opencode.ai/config.json",
autoupdate: true,
disabled_providers: [],
theme: "dark",
})
},
})
@@ -1081,6 +1060,7 @@ test("managed settings override project settings", async () => {
const config = await Config.get()
expect(config.autoupdate).toBe(false)
expect(config.disabled_providers).toEqual(["openai"])
expect(config.theme).toBe("dark")
},
})
})
@@ -1829,7 +1809,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
process.env["TEST_CONFIG_VAR"] = "test_api_key_12345"
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json",
username: "{env:TEST_CONFIG_VAR}",
theme: "{env:TEST_CONFIG_VAR}",
})
try {
@@ -1838,7 +1818,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.username).toBe("test_api_key_12345")
expect(config.theme).toBe("test_api_key_12345")
},
})
} finally {
@@ -1861,10 +1841,10 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Filesystem.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
await Bun.write(path.join(dir, "api_key.txt"), "secret_key_from_file")
process.env["OPENCODE_CONFIG_CONTENT"] = JSON.stringify({
$schema: "https://opencode.ai/config.json",
username: "{file:./api_key.txt}",
theme: "{file:./api_key.txt}",
})
},
})
@@ -1872,7 +1852,7 @@ describe("OPENCODE_CONFIG_CONTENT token substitution", () => {
directory: tmp.path,
fn: async () => {
const config = await Config.get()
expect(config.username).toBe("secret_key_from_file")
expect(config.theme).toBe("secret_key_from_file")
},
})
} finally {

View File

@@ -1,510 +0,0 @@
import { afterEach, expect, test } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { TuiConfig } from "../../src/config/tui"
import { Global } from "../../src/global"
import { Filesystem } from "../../src/util/filesystem"
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
afterEach(async () => {
delete process.env.OPENCODE_CONFIG
delete process.env.OPENCODE_TUI_CONFIG
await fs.rm(path.join(Global.Path.config, "tui.json"), { force: true }).catch(() => {})
await fs.rm(path.join(Global.Path.config, "tui.jsonc"), { force: true }).catch(() => {})
await fs.rm(managedConfigDir, { force: true, recursive: true }).catch(() => {})
})
test("loads tui config with the same precedence order as server config paths", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project" }, null, 2))
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await Bun.write(
path.join(dir, ".opencode", "tui.json"),
JSON.stringify({ theme: "local", diff_style: "stacked" }, null, 2),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("local")
expect(config.diff_style).toBe("stacked")
},
})
})
test("migrates tui-specific keys from opencode.json when tui.json does not exist", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
theme: "migrated-theme",
tui: { scroll_speed: 5 },
keybinds: { app_exit: "ctrl+q" },
},
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(5)
expect(config.keybinds?.app_exit).toBe("ctrl+q")
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
expect(JSON.parse(text)).toMatchObject({
theme: "migrated-theme",
scroll_speed: 5,
})
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBeUndefined()
expect(server.keybinds).toBeUndefined()
expect(server.tui).toBeUndefined()
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
},
})
})
test("migrates project legacy tui keys even when global tui.json already exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ theme: "global" }, null, 2))
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
theme: "project-migrated",
tui: { scroll_speed: 2 },
},
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("project-migrated")
expect(config.scroll_speed).toBe(2)
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBeUndefined()
expect(server.tui).toBeUndefined()
},
})
})
test("drops unknown legacy tui keys during migration", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.json"),
JSON.stringify(
{
theme: "migrated-theme",
tui: { scroll_speed: 2, foo: 1 },
},
null,
2,
),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("migrated-theme")
expect(config.scroll_speed).toBe(2)
const text = await Filesystem.readText(path.join(tmp.path, "tui.json"))
const migrated = JSON.parse(text)
expect(migrated.scroll_speed).toBe(2)
expect(migrated.foo).toBeUndefined()
},
})
})
test("skips migration when opencode.jsonc is syntactically invalid", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.jsonc"),
`{
"theme": "broken-theme",
"tui": { "scroll_speed": 2 }
"username": "still-broken"
}`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBeUndefined()
expect(config.scroll_speed).toBeUndefined()
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(false)
expect(await Filesystem.exists(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))).toBe(false)
const source = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc"))
expect(source).toContain('"theme": "broken-theme"')
expect(source).toContain('"tui": { "scroll_speed": 2 }')
},
})
})
test("skips migration when tui.json already exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "legacy" }, null, 2))
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.diff_style).toBe("stacked")
expect(config.theme).toBeUndefined()
const server = JSON.parse(await Filesystem.readText(path.join(tmp.path, "opencode.json")))
expect(server.theme).toBe("legacy")
expect(await Filesystem.exists(path.join(tmp.path, "opencode.json.tui-migration.bak"))).toBe(false)
},
})
})
test("continues loading tui config when legacy source cannot be stripped", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "readonly-theme" }, null, 2))
},
})
const source = path.join(tmp.path, "opencode.json")
await fs.chmod(source, 0o444)
try {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("readonly-theme")
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
const server = JSON.parse(await Filesystem.readText(source))
expect(server.theme).toBe("readonly-theme")
},
})
} finally {
await fs.chmod(source, 0o644)
}
})
test("migration backup preserves JSONC comments", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "opencode.jsonc"),
`{
// top-level comment
"theme": "jsonc-theme",
"tui": {
// nested comment
"scroll_speed": 1.5
}
}`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
await TuiConfig.get()
const backup = await Filesystem.readText(path.join(tmp.path, "opencode.jsonc.tui-migration.bak"))
expect(backup).toContain("// top-level comment")
expect(backup).toContain("// nested comment")
expect(backup).toContain('"theme": "jsonc-theme"')
expect(backup).toContain('"scroll_speed": 1.5')
},
})
})
test("migrates legacy tui keys across multiple opencode.json levels", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const nested = path.join(dir, "apps", "client")
await fs.mkdir(nested, { recursive: true })
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ theme: "root-theme" }, null, 2))
await Bun.write(path.join(nested, "opencode.json"), JSON.stringify({ theme: "nested-theme" }, null, 2))
},
})
await Instance.provide({
directory: path.join(tmp.path, "apps", "client"),
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("nested-theme")
expect(await Filesystem.exists(path.join(tmp.path, "tui.json"))).toBe(true)
expect(await Filesystem.exists(path.join(tmp.path, "apps", "client", "tui.json"))).toBe(true)
},
})
})
test("flattens nested tui key inside tui.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
theme: "outer",
tui: { scroll_speed: 3, diff_style: "stacked" },
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.scroll_speed).toBe(3)
expect(config.diff_style).toBe("stacked")
// top-level keys take precedence over nested tui keys
expect(config.theme).toBe("outer")
},
})
})
test("top-level keys in tui.json take precedence over nested tui key", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
diff_style: "auto",
tui: { diff_style: "stacked", scroll_speed: 2 },
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.diff_style).toBe("auto")
expect(config.scroll_speed).toBe(2)
},
})
})
test("project config takes precedence over OPENCODE_TUI_CONFIG (matches OPENCODE_CONFIG)", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project", diff_style: "auto" }))
const custom = path.join(dir, "custom-tui.json")
await Bun.write(custom, JSON.stringify({ theme: "custom", diff_style: "stacked" }))
process.env.OPENCODE_TUI_CONFIG = custom
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
// project tui.json overrides the custom path, same as server config precedence
expect(config.theme).toBe("project")
// project also set diff_style, so that wins
expect(config.diff_style).toBe("auto")
},
})
})
test("merges keybind overrides across precedence layers", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(Global.Path.config, "tui.json"), JSON.stringify({ keybinds: { app_exit: "ctrl+q" } }))
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ keybinds: { theme_list: "ctrl+k" } }))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.keybinds?.app_exit).toBe("ctrl+q")
expect(config.keybinds?.theme_list).toBe("ctrl+k")
},
})
})
test("OPENCODE_TUI_CONFIG provides settings when no project config exists", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const custom = path.join(dir, "custom-tui.json")
await Bun.write(custom, JSON.stringify({ theme: "from-env", diff_style: "stacked" }))
process.env.OPENCODE_TUI_CONFIG = custom
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("from-env")
expect(config.diff_style).toBe("stacked")
},
})
})
test("does not derive tui path from OPENCODE_CONFIG", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
const customDir = path.join(dir, "custom")
await fs.mkdir(customDir, { recursive: true })
await Bun.write(path.join(customDir, "opencode.json"), JSON.stringify({ model: "test/model" }))
await Bun.write(path.join(customDir, "tui.json"), JSON.stringify({ theme: "should-not-load" }))
process.env.OPENCODE_CONFIG = path.join(customDir, "opencode.json")
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBeUndefined()
},
})
})
test("applies env and file substitutions in tui.json", async () => {
const original = process.env.TUI_THEME_TEST
process.env.TUI_THEME_TEST = "env-theme"
try {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "keybind.txt"), "ctrl+q")
await Bun.write(
path.join(dir, "tui.json"),
JSON.stringify({
theme: "{env:TUI_THEME_TEST}",
keybinds: { app_exit: "{file:keybind.txt}" },
}),
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("env-theme")
expect(config.keybinds?.app_exit).toBe("ctrl+q")
},
})
} finally {
if (original === undefined) delete process.env.TUI_THEME_TEST
else process.env.TUI_THEME_TEST = original
}
})
test("applies file substitutions when first identical token is in a commented line", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "theme.txt"), "resolved-theme")
await Bun.write(
path.join(dir, "tui.jsonc"),
`{
// "theme": "{file:theme.txt}",
"theme": "{file:theme.txt}"
}`,
)
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("resolved-theme")
},
})
})
test("loads managed tui config and gives it highest precedence", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), JSON.stringify({ theme: "project-theme" }, null, 2))
await fs.mkdir(managedConfigDir, { recursive: true })
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-theme" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-theme")
},
})
})
test("loads .opencode/tui.json", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await fs.mkdir(path.join(dir, ".opencode"), { recursive: true })
await Bun.write(path.join(dir, ".opencode", "tui.json"), JSON.stringify({ diff_style: "stacked" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.diff_style).toBe("stacked")
},
})
})
test("gracefully falls back when tui.json has invalid JSON", async () => {
await using tmp = await tmpdir({
init: async (dir) => {
await Bun.write(path.join(dir, "tui.json"), "{ invalid json }")
await fs.mkdir(managedConfigDir, { recursive: true })
await Bun.write(path.join(managedConfigDir, "tui.json"), JSON.stringify({ theme: "managed-fallback" }, null, 2))
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const config = await TuiConfig.get()
expect(config.theme).toBe("managed-fallback")
expect(config.keybinds).toBeDefined()
},
})
})

View File

@@ -0,0 +1,164 @@
import { describe, test, expect } from "bun:test"
import path from "path"
import fs from "fs/promises"
import { Glob } from "../../src/util/glob"
import { tmpdir } from "../fixture/fixture"
describe("Glob", () => {
describe("scan()", () => {
test("finds files matching pattern", async () => {
await using tmp = await tmpdir()
await fs.writeFile(path.join(tmp.path, "a.txt"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "b.txt"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "c.md"), "", "utf-8")
const results = await Glob.scan("*.txt", { cwd: tmp.path })
expect(results.sort()).toEqual(["a.txt", "b.txt"])
})
test("returns absolute paths when absolute option is true", async () => {
await using tmp = await tmpdir()
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
const results = await Glob.scan("*.txt", { cwd: tmp.path, absolute: true })
expect(results[0]).toBe(path.join(tmp.path, "file.txt"))
})
test("excludes directories by default", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "subdir"))
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
const results = await Glob.scan("*", { cwd: tmp.path })
expect(results).toEqual(["file.txt"])
})
test("excludes directories when include is 'file'", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "subdir"))
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
const results = await Glob.scan("*", { cwd: tmp.path, include: "file" })
expect(results).toEqual(["file.txt"])
})
test("includes directories when include is 'all'", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "subdir"))
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
const results = await Glob.scan("*", { cwd: tmp.path, include: "all" })
expect(results.sort()).toEqual(["file.txt", "subdir"])
})
test("handles nested patterns", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "nested"), { recursive: true })
await fs.writeFile(path.join(tmp.path, "nested", "deep.txt"), "", "utf-8")
const results = await Glob.scan("**/*.txt", { cwd: tmp.path })
expect(results).toEqual(["nested/deep.txt"])
})
test("returns empty array for no matches", async () => {
await using tmp = await tmpdir()
const results = await Glob.scan("*.nonexistent", { cwd: tmp.path })
expect(results).toEqual([])
})
test("does not follow symlinks by default", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "realdir"))
await fs.writeFile(path.join(tmp.path, "realdir", "file.txt"), "", "utf-8")
await fs.symlink(path.join(tmp.path, "realdir"), path.join(tmp.path, "linkdir"))
const results = await Glob.scan("**/*.txt", { cwd: tmp.path })
expect(results).toEqual(["realdir/file.txt"])
})
test("follows symlinks when symlink option is true", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "realdir"))
await fs.writeFile(path.join(tmp.path, "realdir", "file.txt"), "", "utf-8")
await fs.symlink(path.join(tmp.path, "realdir"), path.join(tmp.path, "linkdir"))
const results = await Glob.scan("**/*.txt", { cwd: tmp.path, symlink: true })
expect(results.sort()).toEqual(["linkdir/file.txt", "realdir/file.txt"])
})
test("includes dotfiles when dot option is true", async () => {
await using tmp = await tmpdir()
await fs.writeFile(path.join(tmp.path, ".hidden"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "visible"), "", "utf-8")
const results = await Glob.scan("*", { cwd: tmp.path, dot: true })
expect(results.sort()).toEqual([".hidden", "visible"])
})
test("excludes dotfiles when dot option is false", async () => {
await using tmp = await tmpdir()
await fs.writeFile(path.join(tmp.path, ".hidden"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "visible"), "", "utf-8")
const results = await Glob.scan("*", { cwd: tmp.path, dot: false })
expect(results).toEqual(["visible"])
})
})
describe("scanSync()", () => {
test("finds files matching pattern synchronously", async () => {
await using tmp = await tmpdir()
await fs.writeFile(path.join(tmp.path, "a.txt"), "", "utf-8")
await fs.writeFile(path.join(tmp.path, "b.txt"), "", "utf-8")
const results = Glob.scanSync("*.txt", { cwd: tmp.path })
expect(results.sort()).toEqual(["a.txt", "b.txt"])
})
test("respects options", async () => {
await using tmp = await tmpdir()
await fs.mkdir(path.join(tmp.path, "subdir"))
await fs.writeFile(path.join(tmp.path, "file.txt"), "", "utf-8")
const results = Glob.scanSync("*", { cwd: tmp.path, include: "all" })
expect(results.sort()).toEqual(["file.txt", "subdir"])
})
})
describe("match()", () => {
test("matches simple patterns", () => {
expect(Glob.match("*.txt", "file.txt")).toBe(true)
expect(Glob.match("*.txt", "file.js")).toBe(false)
})
test("matches directory patterns", () => {
expect(Glob.match("**/*.js", "src/index.js")).toBe(true)
expect(Glob.match("**/*.js", "src/index.ts")).toBe(false)
})
test("matches dot files", () => {
expect(Glob.match(".*", ".gitignore")).toBe(true)
expect(Glob.match("**/*.md", ".github/README.md")).toBe(true)
})
test("matches brace expansion", () => {
expect(Glob.match("*.{js,ts}", "file.js")).toBe(true)
expect(Glob.match("*.{js,ts}", "file.ts")).toBe(true)
expect(Glob.match("*.{js,ts}", "file.py")).toBe(false)
})
})
})

View File

@@ -1219,6 +1219,21 @@
}
}
[data-component="apply-patch-tool"] {
> [data-component="collapsible"].tool-collapsible {
gap: 0px;
}
> [data-component="collapsible"] > [data-slot="collapsible-trigger"][aria-expanded="true"] {
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
height: 40px;
padding-bottom: 8px;
background-color: var(--background-stronger);
}
}
[data-component="accordion"][data-scope="apply-patch"] {
[data-slot="accordion-trigger"] {
background-color: var(--background-stronger) !important;

View File

@@ -1611,97 +1611,100 @@ ToolRegistry.register({
})
return (
<BasicTool
{...props}
icon="code-lines"
defer
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
}}
>
<Show when={files().length > 0}>
<Accordion
multiple
data-scope="apply-patch"
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={files()}>
{(file) => {
const active = createMemo(() => expanded().includes(file.filePath))
const [visible, setVisible] = createSignal(false)
<div data-component="apply-patch-tool">
<BasicTool
{...props}
icon="code-lines"
defer
trigger={{
title: i18n.t("ui.tool.patch"),
subtitle: subtitle(),
}}
>
<Show when={files().length > 0}>
<Accordion
multiple
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "40px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={files()}>
{(file) => {
const active = createMemo(() => expanded().includes(file.filePath))
const [visible, setVisible] = createSignal(false)
createEffect(() => {
if (!active()) {
setVisible(false)
return
}
createEffect(() => {
if (!active()) {
setVisible(false)
return
}
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
requestAnimationFrame(() => {
if (!active()) return
setVisible(true)
})
})
})
return (
<Accordion.Item value={file.filePath} data-type={file.type}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: file.relativePath, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={file.relativePath.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(file.relativePath)}</span>
return (
<Accordion.Item value={file.filePath} data-type={file.type}>
<StickyAccordionHeader>
<Accordion.Trigger>
<div data-slot="apply-patch-trigger-content">
<div data-slot="apply-patch-file-info">
<FileIcon node={{ path: file.relativePath, type: "file" }} />
<div data-slot="apply-patch-file-name-container">
<Show when={file.relativePath.includes("/")}>
<span data-slot="apply-patch-directory">{`\u202A${getDirectory(file.relativePath)}\u202C`}</span>
</Show>
<span data-slot="apply-patch-filename">{getFilename(file.relativePath)}</span>
</div>
</div>
<div data-slot="apply-patch-trigger-actions">
<Switch>
<Match when={file.type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={file.type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={file.type === "move"}>
<span data-slot="apply-patch-change" data-type="modified">
{i18n.t("ui.patch.action.moved")}
</span>
</Match>
<Match when={true}>
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</div>
</div>
<div data-slot="apply-patch-trigger-actions">
<Switch>
<Match when={file.type === "add"}>
<span data-slot="apply-patch-change" data-type="added">
{i18n.t("ui.patch.action.created")}
</span>
</Match>
<Match when={file.type === "delete"}>
<span data-slot="apply-patch-change" data-type="removed">
{i18n.t("ui.patch.action.deleted")}
</span>
</Match>
<Match when={file.type === "move"}>
<span data-slot="apply-patch-change" data-type="modified">
{i18n.t("ui.patch.action.moved")}
</span>
</Match>
<Match when={true}>
<DiffChanges changes={{ additions: file.additions, deletions: file.deletions }} />
</Match>
</Switch>
<Icon name="chevron-grabber-vertical" size="small" />
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={diffComponent}
before={{ name: file.filePath, contents: file.before }}
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
/>
</div>
</div>
</Accordion.Trigger>
</StickyAccordionHeader>
<Accordion.Content>
<Show when={visible()}>
<div data-component="apply-patch-file-diff">
<Dynamic
component={diffComponent}
before={{ name: file.filePath, contents: file.before }}
after={{ name: file.movePath ?? file.filePath, contents: file.after }}
/>
</div>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</Show>
</BasicTool>
</Show>
</Accordion.Content>
</Accordion.Item>
)
}}
</For>
</Accordion>
</Show>
</BasicTool>
</div>
)
},
})

View File

@@ -81,6 +81,17 @@
min-width: 0;
}
[data-slot="session-turn-diffs"]
> [data-component="collapsible"]
> [data-slot="collapsible-trigger"][aria-expanded="true"] {
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
height: 40px;
padding-bottom: 8px;
background-color: var(--background-stronger);
}
[data-component="session-turn-diffs-trigger"] {
width: 100%;
display: flex;
@@ -124,7 +135,7 @@
}
[data-component="session-turn-diffs-content"] {
padding-top: 8px;
padding-top: 0px;
display: flex;
flex-direction: column;
}

View File

@@ -318,6 +318,7 @@ export function SessionTurn(
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "40px" }}
value={expanded()}
onChange={(value) => setExpanded(Array.isArray(value) ? value : value ? [value] : [])}
>

View File

@@ -1,14 +1,6 @@
[data-component="sticky-accordion-header"] {
--sticky-accordion-top: 0px;
position: sticky;
top: var(--sticky-accordion-top);
}
[data-slot="accordion-item"]:first-child [data-component="sticky-accordion-header"] {
top: calc(var(--sticky-accordion-top, 0px) + var(--sticky-accordion-offset, 0px));
z-index: 10;
background-color: var(--background-stronger);
}
[data-component="sticky-accordion-header"][data-expanded],
[data-slot="accordion-item"][data-expanded] [data-component="sticky-accordion-header"] {
z-index: 10;
}

View File

@@ -558,7 +558,6 @@ OpenCode can be configured using environment variables.
| `OPENCODE_AUTO_SHARE` | boolean | Automatically share sessions |
| `OPENCODE_GIT_BASH_PATH` | string | Path to Git Bash executable on Windows |
| `OPENCODE_CONFIG` | string | Path to config file |
| `OPENCODE_TUI_CONFIG` | string | Path to TUI config file |
| `OPENCODE_CONFIG_DIR` | string | Path to config directory |
| `OPENCODE_CONFIG_CONTENT` | string | Inline json config content |
| `OPENCODE_DISABLE_AUTOUPDATE` | boolean | Disable automatic update checks |

View File

@@ -14,11 +14,10 @@ OpenCode supports both **JSON** and **JSONC** (JSON with Comments) formats.
```jsonc title="opencode.jsonc"
{
"$schema": "https://opencode.ai/config.json",
// Theme configuration
"theme": "opencode",
"model": "anthropic/claude-sonnet-4-5",
"autoupdate": true,
"server": {
"port": 4096,
},
}
```
@@ -35,7 +34,7 @@ Configuration files are **merged together**, not replaced.
Configuration files are merged together, not replaced. Settings from the following config locations are combined. Later configs override earlier ones only for conflicting keys. Non-conflicting settings from all configs are preserved.
For example, if your global config sets `autoupdate: true` and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include both settings.
For example, if your global config sets `theme: "opencode"` and `autoupdate: true`, and your project config sets `model: "anthropic/claude-sonnet-4-5"`, the final configuration will include all three settings.
---
@@ -96,9 +95,7 @@ You can enable specific servers in your local config:
### Global
Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide server/runtime preferences like providers, models, and permissions.
For TUI-specific settings, use `~/.config/opencode/tui.json`.
Place your global OpenCode config in `~/.config/opencode/opencode.json`. Use global config for user-wide preferences like themes, providers, or keybinds.
Global config overrides remote organizational defaults.
@@ -108,8 +105,6 @@ Global config overrides remote organizational defaults.
Add `opencode.json` in your project root. Project config has the highest precedence among standard config files - it overrides both global and remote configs.
For project-specific TUI settings, add `tui.json` alongside it.
:::tip
Place project specific config in the root of your project.
:::
@@ -151,9 +146,7 @@ The custom directory is loaded after the global config and `.opencode` directori
## Schema
The server/runtime config schema is defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
TUI config uses [**`opencode.ai/tui.json`**](https://opencode.ai/tui.json).
The config file has a schema that's defined in [**`opencode.ai/config.json`**](https://opencode.ai/config.json).
Your editor should be able to validate and autocomplete based on the schema.
@@ -161,24 +154,28 @@ Your editor should be able to validate and autocomplete based on the schema.
### TUI
Use a dedicated `tui.json` (or `tui.jsonc`) file for TUI-specific settings.
You can configure TUI-specific settings through the `tui` option.
```json title="tui.json"
```json title="opencode.json"
{
"$schema": "https://opencode.ai/tui.json",
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto"
"$schema": "https://opencode.ai/config.json",
"tui": {
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto"
}
}
```
Use `OPENCODE_TUI_CONFIG` to point to a custom TUI config file.
Available options:
Legacy `theme`, `keybinds`, and `tui` keys in `opencode.json` are deprecated and automatically migrated when possible.
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration. **Takes precedence over `scroll_speed`.**
- `scroll_speed` - Custom scroll speed multiplier (default: `3`, minimum: `1`). Ignored if `scroll_acceleration.enabled` is `true`.
- `diff_style` - Control diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows single column.
[Learn more about TUI configuration here](/docs/tui#configure).
[Learn more about using the TUI here](/docs/tui).
---
@@ -304,12 +301,12 @@ Bearer tokens (`AWS_BEARER_TOKEN_BEDROCK` or `/connect`) take precedence over pr
### Themes
Set your UI theme in `tui.json`.
You can configure the theme you want to use in your OpenCode config through the `theme` option.
```json title="tui.json"
```json title="opencode.json"
{
"$schema": "https://opencode.ai/tui.json",
"theme": "tokyonight"
"$schema": "https://opencode.ai/config.json",
"theme": ""
}
```
@@ -409,11 +406,11 @@ You can also define commands using markdown files in `~/.config/opencode/command
### Keybinds
Customize keybinds in `tui.json`.
You can customize your keybinds through the `keybinds` option.
```json title="tui.json"
```json title="opencode.json"
{
"$schema": "https://opencode.ai/tui.json",
"$schema": "https://opencode.ai/config.json",
"keybinds": {}
}
```

View File

@@ -3,11 +3,11 @@ title: Keybinds
description: Customize your keybinds.
---
OpenCode has a list of keybinds that you can customize through `tui.json`.
OpenCode has a list of keybinds that you can customize through the OpenCode config.
```json title="tui.json"
```json title="opencode.json"
{
"$schema": "https://opencode.ai/tui.json",
"$schema": "https://opencode.ai/config.json",
"keybinds": {
"leader": "ctrl+x",
"app_exit": "ctrl+c,ctrl+d,<leader>q",
@@ -117,11 +117,11 @@ You don't need to use a leader key for your keybinds but we recommend doing so.
## Disable keybind
You can disable a keybind by adding the key to `tui.json` with a value of "none".
You can disable a keybind by adding the key to your config with a value of "none".
```json title="tui.json"
```json title="opencode.json"
{
"$schema": "https://opencode.ai/tui.json",
"$schema": "https://opencode.ai/config.json",
"keybinds": {
"session_compact": "none"
}

View File

@@ -61,11 +61,11 @@ The system theme is for users who:
## Using a theme
You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in `tui.json`.
You can select a theme by bringing up the theme select with the `/theme` command. Or you can specify it in your [config](/docs/config).
```json title="tui.json" {3}
```json title="opencode.json" {3}
{
"$schema": "https://opencode.ai/tui.json",
"$schema": "https://opencode.ai/config.json",
"theme": "tokyonight"
}
```

View File

@@ -355,34 +355,24 @@ Some editors need command-line arguments to run in blocking mode. The `--wait` f
## Configure
You can customize TUI behavior through `tui.json` (or `tui.jsonc`).
You can customize TUI behavior through your OpenCode config file.
```json title="tui.json"
```json title="opencode.json"
{
"$schema": "https://opencode.ai/tui.json",
"theme": "opencode",
"keybinds": {
"leader": "ctrl+x"
},
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
},
"diff_style": "auto"
"$schema": "https://opencode.ai/config.json",
"tui": {
"scroll_speed": 3,
"scroll_acceleration": {
"enabled": true
}
}
}
```
This is separate from `opencode.json`, which configures server/runtime behavior.
### Options
- `theme` - Sets your UI theme. [Learn more](/docs/themes).
- `keybinds` - Customizes keyboard shortcuts. [Learn more](/docs/keybinds).
- `scroll_acceleration.enabled` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `0.001`, supports decimal values). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
- `diff_style` - Controls diff rendering. `"auto"` adapts to terminal width, `"stacked"` always shows a single-column layout.
Use `OPENCODE_TUI_CONFIG` to load a custom TUI config path.
- `scroll_acceleration` - Enable macOS-style scroll acceleration for smooth, natural scrolling. When enabled, scroll speed increases with rapid scrolling gestures and stays precise for slower movements. **This setting takes precedence over `scroll_speed` and overrides it when enabled.**
- `scroll_speed` - Controls how fast the TUI scrolls when using scroll commands (minimum: `1`). Defaults to `3`. **Note: This is ignored if `scroll_acceleration.enabled` is set to `true`.**
---

View File

@@ -81,6 +81,7 @@ You can also access our models through the following API endpoints.
| Claude Sonnet 4 | claude-sonnet-4 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Haiku 4.5 | claude-haiku-4-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Claude Haiku 3.5 | claude-3-5-haiku | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` |
| Gemini 3.1 Pro | gemini-3.1-pro | `https://opencode.ai/zen/v1/models/gemini-3.1-pro` | `@ai-sdk/google` |
| Gemini 3 Pro | gemini-3-pro | `https://opencode.ai/zen/v1/models/gemini-3-pro` | `@ai-sdk/google` |
| Gemini 3 Flash | gemini-3-flash | `https://opencode.ai/zen/v1/models/gemini-3-flash` | `@ai-sdk/google` |
| MiniMax M2.5 | minimax-m2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
@@ -144,6 +145,8 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Claude Sonnet 4 (> 200K tokens) | $6.00 | $22.50 | $0.60 | $7.50 |
| Claude Haiku 4.5 | $1.00 | $5.00 | $0.10 | $1.25 |
| Claude Haiku 3.5 | $0.80 | $4.00 | $0.08 | $1.00 |
| Gemini 3.1 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3.1 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Pro (≤ 200K tokens) | $2.00 | $12.00 | $0.20 | - |
| Gemini 3 Pro (> 200K tokens) | $4.00 | $18.00 | $0.40 | - |
| Gemini 3 Flash | $0.50 | $3.00 | $0.05 | - |

View File

@@ -1,5 +1,7 @@
#!/usr/bin/env bun
import { Process } from "../packages/opencode/src/util/process"
async function sendToPostHog(event: string, properties: Record<string, any>) {
const key = process.env["POSTHOG_KEY"]
@@ -59,7 +61,7 @@ async function fetchNpmDownloads(packageName: string): Promise<number> {
console.warn(`Failed to fetch npm downloads for ${packageName}: ${response.status}`)
return 0
}
const data: NpmDownloadsRange = await response.json()
const data = (await response.json()) as NpmDownloadsRange
return data.downloads.reduce((total, day) => total + day.downloads, 0)
} catch (error) {
console.warn(`Error fetching npm downloads for ${packageName}:`, error)
@@ -80,7 +82,7 @@ async function fetchReleases(): Promise<Release[]> {
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`)
}
const batch: Release[] = await response.json()
const batch = (await response.json()) as Release[]
if (batch.length === 0) break
releases.push(...batch)
@@ -137,12 +139,14 @@ async function save(githubTotal: number, npmDownloads: number) {
const lines = content.trim().split("\n")
for (let i = lines.length - 1; i >= 0; i--) {
const line = lines[i].trim()
const item = lines[i]
if (!item) continue
const line = item.trim()
if (line.startsWith("|") && !line.includes("Date") && !line.includes("---")) {
const match = line.match(
/\|\s*[\d-]+\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|\s*([\d,]+)\s*(?:\([^)]*\))?\s*\|/,
)
if (match) {
if (match?.[1] && match[2] && match[3]) {
previousGithub = parseInt(match[1].replace(/,/g, ""))
previousNpm = parseInt(match[2].replace(/,/g, ""))
previousTotal = parseInt(match[3].replace(/,/g, ""))
@@ -181,7 +185,7 @@ async function save(githubTotal: number, npmDownloads: number) {
}
await Bun.write(file, content + line)
await Bun.spawn(["bunx", "prettier", "--write", file]).exited
await Process.spawn(["bunx", "prettier", "--write", file]).exited
console.log(
`\nAppended stats to ${file}: GitHub ${githubTotal.toLocaleString()}${githubChangeStr}, npm ${npmDownloads.toLocaleString()}${npmChangeStr}, Total ${total.toLocaleString()}${totalChangeStr}`,