mirror of
https://github.com/anomalyco/opencode.git
synced 2026-03-27 09:04:41 +00:00
Compare commits
25 Commits
production
...
effect/too
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eeb1750fa | ||
|
|
b242a8d8e4 | ||
|
|
9c6f1edfd7 | ||
|
|
ef7d1f7efa | ||
|
|
b7a06e1939 | ||
|
|
311ba4179a | ||
|
|
ad3b350672 | ||
|
|
590523dcd1 | ||
|
|
b8fb75a94a | ||
|
|
98a31e30cc | ||
|
|
c333e914ee | ||
|
|
c7760b433b | ||
|
|
2e6ac8ff49 | ||
|
|
1ebc92fd36 | ||
|
|
9f94bdb496 | ||
|
|
28f5176ffd | ||
|
|
38450443b1 | ||
|
|
da1d37274f | ||
|
|
17e8f577d6 | ||
|
|
c7d23098d1 | ||
|
|
bcf18edde4 | ||
|
|
9a2482ac09 | ||
|
|
54443bfb7e | ||
|
|
ec20efc11a | ||
|
|
83ed1c4414 |
@@ -7,15 +7,17 @@ create UPCOMING_CHANGELOG.md
|
||||
it should have sections
|
||||
|
||||
```
|
||||
# TUI
|
||||
## TUI
|
||||
|
||||
# Desktop
|
||||
## Desktop
|
||||
|
||||
# Core
|
||||
## Core
|
||||
|
||||
# Misc
|
||||
## Misc
|
||||
```
|
||||
|
||||
go through each PR merged since the last tag
|
||||
fetch the latest github release for this repository to determine the last release version.
|
||||
|
||||
find each PR that was merged since the last release
|
||||
|
||||
for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section.
|
||||
|
||||
42
bun.lock
42
bun.lock
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -79,7 +79,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -140,7 +140,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "2.0.0",
|
||||
"@ai-sdk/openai": "2.0.2",
|
||||
@@ -164,7 +164,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -188,7 +188,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -221,7 +221,7 @@
|
||||
},
|
||||
"packages/desktop-electron": {
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/app": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@opencode-ai/util": "workspace:*",
|
||||
@@ -281,7 +281,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -422,7 +422,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"zod": "catalog:",
|
||||
@@ -446,7 +446,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.90.10",
|
||||
"@tsconfig/node22": "catalog:",
|
||||
@@ -457,7 +457,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -492,7 +492,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -538,7 +538,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -549,7 +549,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -599,7 +599,7 @@
|
||||
},
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@effect/platform-node": "4.0.0-beta.37",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
@@ -623,7 +623,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.35",
|
||||
"effect": "4.0.0-beta.37",
|
||||
"fuzzysort": "3.1.0",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
@@ -981,9 +981,9 @@
|
||||
|
||||
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.35", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.35", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35", "ioredis": "^5.7.0" } }, "sha512-HPc2xZASl9F9y/xJ01bQgFD6Jf9XP4Fcv/BlVTvG0Yr/uN63lwKZYr/VXor5K5krHfBDeCBD8y7/SICPYZoq3A=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.37", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.37", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.37", "ioredis": "^5.7.0" } }, "sha512-dCfTNYGAT+1K+nu/0jw3FL/0DJXcobZCJs9SD5XJbj1DewWPhR9/AptP6zLGj8vdP8hXem6Aa53nze3HSujW3w=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.35", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35" } }, "sha512-9bPqNV988itKJ7MQoJuzmR014DB9EZRDOnhJt/+iJlb8qLoR9HnCzNJb9gfBdYhFmVYc8DMsQxG81rdJzpv9tg=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.40", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.40" } }, "sha512-WMRVG7T8ZDALKCOacsx2ZZj3Ccaoq8YGeD9q7ZL4q8RwQv8Nmrl+4+KZl95/zHCqXzgK9oUJOlBfQ7CZr6PQOQ=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -2759,7 +2759,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.35", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-64j8dgJmoEMeq6Y3WLYcZIRqPZ5E/lqnULCf6QW5te3hQ/sa13UodWLGwBEviEqBoq72U8lArhVX+T7ntzhJGQ=="],
|
||||
"effect": ["effect@4.0.0-beta.37", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AVMXXtb6n62W4uvo1EvT7FJ41HfDvQRX8IY2FGPvfP361dtBArKK2JtE5vmFXTsxkW90WUdvJZYpVATGIzr/BA=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-0VwVhbOtK1r16cVSZcHaI/8fUPc6aYQiUnh7Q3bSHqs=",
|
||||
"aarch64-linux": "sha256-z5b234MIS0QqDYLopyaT2hd9CAtEbcSo28y0eMfPsBs=",
|
||||
"aarch64-darwin": "sha256-sn16mtZIhF9OSBrfAHpDCJO6Nt19mdoxvYAOnwWgwDk=",
|
||||
"x86_64-darwin": "sha256-FaZpwGuWzfypA28ct86xAnW2RuFFUiXjPkr5wVTLN/o="
|
||||
"x86_64-linux": "sha256-YI/VXZYi/5BEKRGWCHVqEBmMgBP5VMVJyL06OJlfQxY=",
|
||||
"aarch64-linux": "sha256-HvGPC4TuLnCNAty8nr+JwnPkV+MtrPnso3VPmgCe06Y=",
|
||||
"aarch64-darwin": "sha256-DKzYPvFsKy8utQZbiWWPWukPEle/SuFQz1FakWzObA8=",
|
||||
"x86_64-darwin": "sha256-311yDcV1P3gaFh75j3uoe3eTuZJn48E7OVgNjLxSpIo="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/platform-node": "4.0.0-beta.35",
|
||||
"@effect/platform-node": "4.0.0-beta.37",
|
||||
"@types/bun": "1.3.11",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
@@ -45,7 +45,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||
"effect": "4.0.0-beta.35",
|
||||
"effect": "4.0.0-beta.37",
|
||||
"ai": "5.0.124",
|
||||
"hono": "4.10.7",
|
||||
"hono-openapi": "1.1.2",
|
||||
|
||||
@@ -465,10 +465,13 @@ export async function waitSession(page: Page, input: { directory: string; sessio
|
||||
if (!slug) return false
|
||||
const resolved = await resolveSlug(slug).catch(() => undefined)
|
||||
if (!resolved || resolved.directory !== target) return false
|
||||
if (input.sessionID && sessionIDFromUrl(page.url()) !== input.sessionID) return false
|
||||
const current = sessionIDFromUrl(page.url())
|
||||
if (input.sessionID && current !== input.sessionID) return false
|
||||
if (!input.sessionID && current) return false
|
||||
|
||||
const state = await probeSession(page)
|
||||
if (input.sessionID && (!state || state.sessionID !== input.sessionID)) return false
|
||||
if (!input.sessionID && state?.sessionID) return false
|
||||
if (state?.dir) {
|
||||
const dir = await resolveDirectory(state.dir).catch(() => state.dir ?? "")
|
||||
if (dir !== target) return false
|
||||
|
||||
@@ -19,7 +19,8 @@ export const promptVariantSelector = '[data-component="prompt-variant-control"]'
|
||||
export const settingsLanguageSelectSelector = '[data-action="settings-language"]'
|
||||
export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]'
|
||||
export const settingsThemeSelector = '[data-action="settings-theme"]'
|
||||
export const settingsFontSelector = '[data-action="settings-font"]'
|
||||
export const settingsCodeFontSelector = '[data-action="settings-code-font"]'
|
||||
export const settingsUIFontSelector = '[data-action="settings-ui-font"]'
|
||||
export const settingsNotificationsAgentSelector = '[data-action="settings-notifications-agent"]'
|
||||
export const settingsNotificationsPermissionsSelector = '[data-action="settings-notifications-permissions"]'
|
||||
export const settingsNotificationsErrorsSelector = '[data-action="settings-notifications-errors"]'
|
||||
|
||||
@@ -93,7 +93,7 @@ async function todoDock(page: any, sessionID: string) {
|
||||
|
||||
const write = async (driver: ComposerDriverState | undefined) => {
|
||||
await page.evaluate(
|
||||
(input) => {
|
||||
(input: { event: string; sessionID: string; driver: ComposerDriverState | undefined }) => {
|
||||
const win = window as ComposerWindow
|
||||
const composer = win.__opencode_e2e?.composer
|
||||
if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled")
|
||||
@@ -118,7 +118,7 @@ async function todoDock(page: any, sessionID: string) {
|
||||
}
|
||||
|
||||
const read = () =>
|
||||
page.evaluate((sessionID) => {
|
||||
page.evaluate((sessionID: string) => {
|
||||
const win = window as ComposerWindow
|
||||
return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null
|
||||
}, sessionID) as Promise<ComposerProbeState | null>
|
||||
@@ -186,6 +186,8 @@ async function withMockPermission<T>(
|
||||
opts: { child?: any } | undefined,
|
||||
fn: (state: { resolved: () => Promise<void> }) => Promise<T>,
|
||||
) {
|
||||
const listUrl = /\/permission(?:\?.*)?$/
|
||||
const replyUrls = [/\/session\/[^/]+\/permissions\/[^/?]+(?:\?.*)?$/, /\/permission\/[^/]+\/reply(?:\?.*)?$/]
|
||||
let pending = [
|
||||
{
|
||||
...request,
|
||||
@@ -204,7 +206,8 @@ async function withMockPermission<T>(
|
||||
|
||||
const reply = async (route: any) => {
|
||||
const url = new URL(route.request().url())
|
||||
const id = url.pathname.split("/").pop()
|
||||
const parts = url.pathname.split("/").filter(Boolean)
|
||||
const id = parts.at(-1) === "reply" ? parts.at(-2) : parts.at(-1)
|
||||
pending = pending.filter((item) => item.id !== id)
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
@@ -213,8 +216,10 @@ async function withMockPermission<T>(
|
||||
})
|
||||
}
|
||||
|
||||
await page.route("**/permission", list)
|
||||
await page.route("**/session/*/permissions/*", reply)
|
||||
await page.route(listUrl, list)
|
||||
for (const item of replyUrls) {
|
||||
await page.route(item, reply)
|
||||
}
|
||||
|
||||
const sessionList = opts?.child
|
||||
? async (route: any) => {
|
||||
@@ -242,8 +247,10 @@ async function withMockPermission<T>(
|
||||
try {
|
||||
return await fn(state)
|
||||
} finally {
|
||||
await page.unroute("**/permission", list)
|
||||
await page.unroute("**/session/*/permissions/*", reply)
|
||||
await page.unroute(listUrl, list)
|
||||
for (const item of replyUrls) {
|
||||
await page.unroute(item, reply)
|
||||
}
|
||||
if (sessionList) await page.unroute("**/session?*", sessionList)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,17 @@ type Footer = {
|
||||
type Probe = {
|
||||
dir?: string
|
||||
sessionID?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string; name?: string }
|
||||
variant?: string | null
|
||||
pick?: {
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
variant?: string | null
|
||||
}
|
||||
variants?: string[]
|
||||
models?: Array<{ providerID: string; modelID: string; name: string }>
|
||||
agents?: Array<{ name: string }>
|
||||
}
|
||||
|
||||
const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
@@ -50,6 +60,86 @@ async function probe(page: Page): Promise<Probe | null> {
|
||||
})
|
||||
}
|
||||
|
||||
async function currentModel(page: Page) {
|
||||
await expect.poll(() => probe(page).then(modelKey), { timeout: 30_000 }).not.toBe(null)
|
||||
const value = await probe(page).then(modelKey)
|
||||
if (!value) throw new Error("Failed to resolve current model key")
|
||||
return value
|
||||
}
|
||||
|
||||
async function waitControl(page: Page, key: "setAgent" | "setModel" | "setVariant") {
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate((key) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
return !!win.__opencode_e2e?.model?.controls?.[key]
|
||||
}, key),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
.toBe(true)
|
||||
}
|
||||
|
||||
async function pickAgent(page: Page, value: string) {
|
||||
await waitControl(page, "setAgent")
|
||||
await page.evaluate((value) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: {
|
||||
setAgent?: (value: string | undefined) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const fn = win.__opencode_e2e?.model?.controls?.setAgent
|
||||
if (!fn) throw new Error("Model e2e agent control is not enabled")
|
||||
fn(value)
|
||||
}, value)
|
||||
}
|
||||
|
||||
async function pickModel(page: Page, value: { providerID: string; modelID: string }) {
|
||||
await waitControl(page, "setModel")
|
||||
await page.evaluate((value) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: {
|
||||
setModel?: (value: { providerID: string; modelID: string } | undefined) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const fn = win.__opencode_e2e?.model?.controls?.setModel
|
||||
if (!fn) throw new Error("Model e2e model control is not enabled")
|
||||
fn(value)
|
||||
}, value)
|
||||
}
|
||||
|
||||
async function pickVariant(page: Page, value: string) {
|
||||
await waitControl(page, "setVariant")
|
||||
await page.evaluate((value) => {
|
||||
const win = window as Window & {
|
||||
__opencode_e2e?: {
|
||||
model?: {
|
||||
controls?: {
|
||||
setVariant?: (value: string | undefined) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const fn = win.__opencode_e2e?.model?.controls?.setVariant
|
||||
if (!fn) throw new Error("Model e2e variant control is not enabled")
|
||||
fn(value)
|
||||
}, value)
|
||||
}
|
||||
|
||||
async function read(page: Page): Promise<Footer> {
|
||||
return {
|
||||
agent: await text(page.locator(`${promptAgentSelector} [data-slot="select-select-trigger-value"]`).first()),
|
||||
@@ -82,31 +172,15 @@ async function waitModel(page: Page, value: string) {
|
||||
async function choose(page: Page, root: string, value: string) {
|
||||
const select = page.locator(root)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
|
||||
const item = page
|
||||
.locator('[data-slot="select-select-item"]')
|
||||
.filter({ hasText: new RegExp(`^\\s*${escape(value)}\\s*$`) })
|
||||
.first()
|
||||
await expect(item).toBeVisible()
|
||||
await item.click()
|
||||
await pickAgent(page, value)
|
||||
}
|
||||
|
||||
async function variantCount(page: Page) {
|
||||
const select = page.locator(promptVariantSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
const count = await page.locator('[data-slot="select-select-item"]').count()
|
||||
await page.keyboard.press("Escape")
|
||||
return count
|
||||
return (await probe(page))?.variants?.length ?? 0
|
||||
}
|
||||
|
||||
async function agents(page: Page) {
|
||||
const select = page.locator(promptAgentSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-action], [data-slot="select-select-trigger"]').first().click()
|
||||
const labels = await page.locator('[data-slot="select-select-item-label"]').allTextContents()
|
||||
await page.keyboard.press("Escape")
|
||||
return labels.map((item) => item.trim()).filter(Boolean)
|
||||
return ((await probe(page))?.agents ?? []).map((item) => item.name).filter(Boolean)
|
||||
}
|
||||
|
||||
async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
||||
@@ -132,48 +206,23 @@ async function ensureVariant(page: Page, directory: string): Promise<Footer> {
|
||||
|
||||
async function chooseDifferentVariant(page: Page): Promise<Footer> {
|
||||
const current = await read(page)
|
||||
const select = page.locator(promptVariantSelector)
|
||||
await expect(select).toBeVisible()
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
const next = (await probe(page))?.variants?.find((item) => item !== current.variant)
|
||||
if (!next) throw new Error("Current model has no alternate variant to select")
|
||||
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
const count = await items.count()
|
||||
if (count < 2) throw new Error("Current model has no alternate variant to select")
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = items.nth(i)
|
||||
const next = await text(item.locator('[data-slot="select-select-item-label"]').first())
|
||||
if (!next || next === current.variant) continue
|
||||
await item.click()
|
||||
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
|
||||
}
|
||||
|
||||
throw new Error("Failed to choose a different variant")
|
||||
await pickVariant(page, next)
|
||||
return waitFooter(page, { agent: current.agent, model: current.model, variant: next })
|
||||
}
|
||||
|
||||
async function chooseOtherModel(page: Page): Promise<Footer> {
|
||||
const current = await read(page)
|
||||
const button = page.locator(`${promptModelSelector} [data-action="prompt-model"]`)
|
||||
await expect(button).toBeVisible()
|
||||
await button.click()
|
||||
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog).toBeVisible()
|
||||
const items = dialog.locator('[data-slot="list-item"]')
|
||||
const count = await items.count()
|
||||
expect(count).toBeGreaterThan(1)
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const item = items.nth(i)
|
||||
const selected = (await item.getAttribute("data-selected")) === "true"
|
||||
if (selected) continue
|
||||
await item.click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect.poll(async () => (await read(page)).model !== current.model, { timeout: 30_000 }).toBe(true)
|
||||
return read(page)
|
||||
}
|
||||
|
||||
throw new Error("Failed to choose a different model")
|
||||
async function chooseOtherModel(page: Page, skip: string[] = []): Promise<Footer> {
|
||||
const current = await currentModel(page)
|
||||
const next = (await probe(page))?.models?.find((item) => {
|
||||
const key = `${item.providerID}:${item.modelID}`
|
||||
return key !== current && !skip.includes(key)
|
||||
})
|
||||
if (!next) throw new Error("Failed to choose a different model")
|
||||
await pickModel(page, { providerID: next.providerID, modelID: next.modelID })
|
||||
await expect.poll(async () => (await read(page)).model, { timeout: 30_000 }).toBe(next.name)
|
||||
return read(page)
|
||||
}
|
||||
|
||||
async function goto(page: Page, directory: string, sessionID?: string) {
|
||||
@@ -249,17 +298,14 @@ async function newWorkspaceSession(page: Page, slug: string) {
|
||||
return waitSession(page, { directory: next.directory }).then((item) => item.directory)
|
||||
}
|
||||
|
||||
test("session model and variant restore per session without leaking into new sessions", async ({
|
||||
page,
|
||||
withProject,
|
||||
}) => {
|
||||
test("session model restore per session without leaking into new sessions", async ({ page, withProject }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
|
||||
await withProject(async ({ directory, gotoSession, trackSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await ensureVariant(page, directory)
|
||||
const firstState = await chooseDifferentVariant(page)
|
||||
const firstState = await chooseOtherModel(page)
|
||||
const firstKey = await currentModel(page)
|
||||
const first = await submit(page, `session variant ${Date.now()}`)
|
||||
trackSession(first)
|
||||
await waitUser(directory, first)
|
||||
@@ -269,10 +315,10 @@ test("session model and variant restore per session without leaking into new ses
|
||||
await waitFooter(page, firstState)
|
||||
|
||||
await gotoSession()
|
||||
const fresh = await ensureVariant(page, directory)
|
||||
expect(fresh.variant).not.toBe(firstState.variant)
|
||||
const fresh = await read(page)
|
||||
expect(fresh.model).not.toBe(firstState.model)
|
||||
|
||||
const secondState = await chooseOtherModel(page)
|
||||
const secondState = await chooseOtherModel(page, [firstKey])
|
||||
const second = await submit(page, `session model ${Date.now()}`)
|
||||
trackSession(second)
|
||||
await waitUser(directory, second)
|
||||
@@ -294,8 +340,8 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
|
||||
await withProject(async ({ directory: root, slug, gotoSession, trackDirectory, trackSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
await ensureVariant(page, root)
|
||||
const firstState = await chooseDifferentVariant(page)
|
||||
const firstState = await chooseOtherModel(page)
|
||||
const firstKey = await currentModel(page)
|
||||
const first = await submit(page, `root session ${Date.now()}`)
|
||||
trackSession(first, root)
|
||||
await waitUser(root, first)
|
||||
@@ -307,7 +353,8 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
|
||||
const oneDir = await newWorkspaceSession(page, one.slug)
|
||||
trackDirectory(oneDir)
|
||||
|
||||
const secondState = await chooseOtherModel(page)
|
||||
const secondState = await chooseOtherModel(page, [firstKey])
|
||||
const secondKey = await currentModel(page)
|
||||
const second = await submit(page, `workspace one ${Date.now()}`)
|
||||
trackSession(second, oneDir)
|
||||
await waitUser(oneDir, second)
|
||||
@@ -316,8 +363,7 @@ test("session model restore across workspaces", async ({ page, withProject }) =>
|
||||
const twoDir = await newWorkspaceSession(page, two.slug)
|
||||
trackDirectory(twoDir)
|
||||
|
||||
await ensureVariant(page, twoDir)
|
||||
const thirdState = await chooseDifferentVariant(page)
|
||||
const thirdState = await chooseOtherModel(page, [firstKey, secondKey])
|
||||
const third = await submit(page, `workspace two ${Date.now()}`)
|
||||
trackSession(third, twoDir)
|
||||
await waitUser(twoDir, third)
|
||||
|
||||
@@ -2,7 +2,7 @@ import { test, expect, settingsKey } from "../fixtures"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
import {
|
||||
settingsColorSchemeSelector,
|
||||
settingsFontSelector,
|
||||
settingsCodeFontSelector,
|
||||
settingsLanguageSelectSelector,
|
||||
settingsNotificationsAgentSelector,
|
||||
settingsNotificationsErrorsSelector,
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
settingsSoundsErrorsSelector,
|
||||
settingsSoundsPermissionsSelector,
|
||||
settingsThemeSelector,
|
||||
settingsUIFontSelector,
|
||||
settingsUpdatesStartupSelector,
|
||||
} from "../selectors"
|
||||
|
||||
@@ -152,74 +153,28 @@ test("legacy oc-1 theme migrates to oc-2", async ({ page, gotoSession }) => {
|
||||
.toBeNull()
|
||||
})
|
||||
|
||||
test("changing font persists in localStorage and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
test("typing a code font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const select = dialog.locator(settingsFontSelector)
|
||||
await expect(select).toBeVisible()
|
||||
const input = dialog.locator(settingsCodeFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
|
||||
|
||||
const initialFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||
})
|
||||
const initialFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const initialUIFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(initialFontFamily).toContain("IBM Plex Mono")
|
||||
|
||||
await select.locator('[data-slot="select-select-trigger"]').click()
|
||||
const next = "Test Mono"
|
||||
|
||||
const items = page.locator('[data-slot="select-select-item"]')
|
||||
await items.nth(2).click()
|
||||
|
||||
await page.waitForTimeout(100)
|
||||
|
||||
const stored = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
expect(stored?.appearance?.font).not.toBe("ibm-plex-mono")
|
||||
|
||||
const newFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono")
|
||||
})
|
||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||
})
|
||||
|
||||
test("color scheme and font rehydrate after reload", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
|
||||
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
|
||||
await expect(colorSchemeSelect).toBeVisible()
|
||||
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
|
||||
const fontSelect = dialog.locator(settingsFontSelector)
|
||||
await expect(fontSelect).toBeVisible()
|
||||
|
||||
const initialFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
|
||||
const initialSettings = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const currentFont =
|
||||
(await fontSelect.locator('[data-slot="select-select-trigger-value"]').textContent())?.trim() ?? ""
|
||||
await fontSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||
|
||||
const fontItems = page.locator('[data-slot="select-select-item"]')
|
||||
expect(await fontItems.count()).toBeGreaterThan(1)
|
||||
|
||||
if (currentFont) {
|
||||
await fontItems.filter({ hasNotText: currentFont }).first().click()
|
||||
}
|
||||
if (!currentFont) {
|
||||
await fontItems.nth(1).click()
|
||||
}
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially(next)
|
||||
await expect(input).toHaveValue(next)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -230,7 +185,218 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: expect.any(String),
|
||||
font: next,
|
||||
},
|
||||
})
|
||||
|
||||
const newFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const newUIFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(newFontFamily).toContain(next)
|
||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||
expect(newUIFamily).toBe(initialUIFamily)
|
||||
})
|
||||
|
||||
test("typing a UI font with spaces persists and updates CSS variable", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsUIFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
await expect(input).toHaveAttribute("placeholder", "Inter")
|
||||
|
||||
const initialFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
const initialCodeFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
expect(initialFontFamily).toContain("Inter")
|
||||
|
||||
const next = "Test Sans"
|
||||
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially(next)
|
||||
await expect(input).toHaveValue(next)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: next,
|
||||
},
|
||||
})
|
||||
|
||||
const newFontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
const newCodeFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
expect(newFontFamily).toContain(next)
|
||||
expect(newFontFamily).not.toBe(initialFontFamily)
|
||||
expect(newCodeFamily).toBe(initialCodeFamily)
|
||||
})
|
||||
|
||||
test("clearing the code font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsCodeFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially("Reset Mono")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: "Reset Mono",
|
||||
},
|
||||
})
|
||||
|
||||
await input.clear()
|
||||
await input.press("Space")
|
||||
await expect(input).toHaveValue("")
|
||||
await expect(input).toHaveAttribute("placeholder", "IBM Plex Mono")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: "",
|
||||
},
|
||||
})
|
||||
|
||||
const fontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
expect(fontFamily).toContain("IBM Plex Mono")
|
||||
expect(fontFamily).not.toContain("Reset Mono")
|
||||
})
|
||||
|
||||
test("clearing the UI font field restores the default placeholder and stack", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
const input = dialog.locator(settingsUIFontSelector)
|
||||
await expect(input).toBeVisible()
|
||||
|
||||
await input.click()
|
||||
await input.clear()
|
||||
await input.pressSequentially("Reset Sans")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: "Reset Sans",
|
||||
},
|
||||
})
|
||||
|
||||
await input.clear()
|
||||
await input.press("Space")
|
||||
await expect(input).toHaveValue("")
|
||||
await expect(input).toHaveAttribute("placeholder", "Inter")
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
uiFont: "",
|
||||
},
|
||||
})
|
||||
|
||||
const fontFamily = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(fontFamily).toContain("Inter")
|
||||
expect(fontFamily).not.toContain("Reset Sans")
|
||||
})
|
||||
|
||||
test("color scheme, code font, and UI font rehydrate after reload", async ({ page, gotoSession }) => {
|
||||
await gotoSession()
|
||||
|
||||
const dialog = await openSettings(page)
|
||||
|
||||
const colorSchemeSelect = dialog.locator(settingsColorSchemeSelector)
|
||||
await expect(colorSchemeSelect).toBeVisible()
|
||||
await colorSchemeSelect.locator('[data-slot="select-select-trigger"]').click()
|
||||
await page.locator('[data-slot="select-select-item"]').filter({ hasText: "Dark" }).click()
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
|
||||
const code = dialog.locator(settingsCodeFontSelector)
|
||||
const ui = dialog.locator(settingsUIFontSelector)
|
||||
await expect(code).toBeVisible()
|
||||
await expect(ui).toBeVisible()
|
||||
|
||||
const initialMono = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const initialSans = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
|
||||
const initialSettings = await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const mono = initialSettings?.appearance?.font === "Reload Mono" ? "Reload Mono 2" : "Reload Mono"
|
||||
const sans = initialSettings?.appearance?.uiFont === "Reload Sans" ? "Reload Sans 2" : "Reload Sans"
|
||||
|
||||
await code.click()
|
||||
await code.clear()
|
||||
await code.pressSequentially(mono)
|
||||
await expect(code).toHaveValue(mono)
|
||||
|
||||
await ui.click()
|
||||
await ui.clear()
|
||||
await ui.pressSequentially(sans)
|
||||
await expect(ui).toHaveValue(sans)
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate((key) => {
|
||||
const raw = localStorage.getItem(key)
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: mono,
|
||||
uiFont: sans,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -239,11 +405,18 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||
return raw ? JSON.parse(raw) : null
|
||||
}, settingsKey)
|
||||
|
||||
const updatedFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
expect(updatedFontFamily).not.toBe(initialFontFamily)
|
||||
expect(updatedSettings?.appearance?.font).not.toBe(initialSettings?.appearance?.font)
|
||||
const updatedMono = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const updatedSans = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(updatedMono).toContain(mono)
|
||||
expect(updatedMono).not.toBe(initialMono)
|
||||
expect(updatedSans).toContain(sans)
|
||||
expect(updatedSans).not.toBe(initialSans)
|
||||
expect(updatedSettings?.appearance?.font).toBe(mono)
|
||||
expect(updatedSettings?.appearance?.uiFont).toBe(sans)
|
||||
|
||||
await closeDialog(page, dialog)
|
||||
await page.reload()
|
||||
@@ -259,7 +432,8 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||
})
|
||||
.toMatchObject({
|
||||
appearance: {
|
||||
font: updatedSettings?.appearance?.font,
|
||||
font: mono,
|
||||
uiFont: sans,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -270,17 +444,32 @@ test("color scheme and font rehydrate after reload", async ({ page, gotoSession
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
return await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
})
|
||||
.not.toBe(initialFontFamily)
|
||||
.toContain(mono)
|
||||
|
||||
const rehydratedFontFamily = await page.evaluate(() => {
|
||||
return getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim()
|
||||
})
|
||||
expect(rehydratedFontFamily).not.toBe(initialFontFamily)
|
||||
expect(rehydratedSettings?.appearance?.font).toBe(updatedSettings?.appearance?.font)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
return await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
})
|
||||
.toContain(sans)
|
||||
|
||||
const rehydratedMono = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-mono").trim(),
|
||||
)
|
||||
const rehydratedSans = await page.evaluate(() =>
|
||||
getComputedStyle(document.documentElement).getPropertyValue("--font-family-sans").trim(),
|
||||
)
|
||||
expect(rehydratedMono).toContain(mono)
|
||||
expect(rehydratedMono).not.toBe(initialMono)
|
||||
expect(rehydratedSans).toContain(sans)
|
||||
expect(rehydratedSans).not.toBe(initialSans)
|
||||
expect(rehydratedSettings?.appearance?.font).toBe(mono)
|
||||
expect(rehydratedSettings?.appearance?.uiFont).toBe(sans)
|
||||
})
|
||||
|
||||
test("toggling notification agent switch updates localStorage", async ({ page, gotoSession }) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -37,7 +37,6 @@ import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { NotificationProvider } from "@/context/notification"
|
||||
import { PermissionProvider } from "@/context/permission"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
@@ -77,11 +76,6 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
function MarkedProviderWithNativeParser(props: ParentProps) {
|
||||
const platform = usePlatform()
|
||||
return <MarkedProvider nativeParser={platform.parseMarkdown}>{props.children}</MarkedProvider>
|
||||
}
|
||||
|
||||
function QueryProvider(props: ParentProps) {
|
||||
const client = new QueryClient()
|
||||
return <QueryClientProvider client={client}>{props.children}</QueryClientProvider>
|
||||
@@ -144,9 +138,9 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
<ErrorBoundary fallback={(error) => <ErrorPage error={error} />}>
|
||||
<QueryProvider>
|
||||
<DialogProvider>
|
||||
<MarkedProviderWithNativeParser>
|
||||
<MarkedProvider>
|
||||
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
|
||||
</MarkedProviderWithNativeParser>
|
||||
</MarkedProvider>
|
||||
</DialogProvider>
|
||||
</QueryProvider>
|
||||
</ErrorBoundary>
|
||||
|
||||
@@ -15,13 +15,20 @@ import { Link } from "@/components/link"
|
||||
import { useGlobalSDK } from "@/context/global-sdk"
|
||||
import { useGlobalSync } from "@/context/global-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string }) {
|
||||
const dialog = useDialog()
|
||||
const globalSync = useGlobalSync()
|
||||
const globalSDK = useGlobalSDK()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders()
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
const alive = { value: true }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
@@ -33,7 +40,11 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
timer.current = undefined
|
||||
})
|
||||
|
||||
const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!)
|
||||
const provider = createMemo(
|
||||
() =>
|
||||
providers.all().find((x) => x.id === props.provider) ??
|
||||
globalSync.data.provider.all.find((x) => x.id === props.provider)!,
|
||||
)
|
||||
const fallback = createMemo<ProviderAuthMethod[]>(() => [
|
||||
{
|
||||
type: "api" as const,
|
||||
@@ -333,7 +344,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
|
||||
function goBack() {
|
||||
if (methods().length === 1) {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
all()
|
||||
return
|
||||
}
|
||||
if (store.authorization) {
|
||||
@@ -344,7 +355,7 @@ export function DialogConnectProvider(props: { provider: string }) {
|
||||
dispatch({ type: "method.reset" })
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
all()
|
||||
}
|
||||
|
||||
function MethodSelection() {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { Component, createEffect, createMemo, on, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
const statusLabels = {
|
||||
@@ -18,6 +20,48 @@ export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({
|
||||
done: false,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync.data.mcp_ready,
|
||||
(ready, prev) => {
|
||||
if (!ready && prev) setState("done", false)
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (state.done || state.loading) return
|
||||
if (sync.data.mcp_ready) {
|
||||
setState("done", true)
|
||||
return
|
||||
}
|
||||
|
||||
setState("loading", true)
|
||||
void sdk.client.mcp
|
||||
.status()
|
||||
.then((result) => {
|
||||
sync.set("mcp", result.data ?? {})
|
||||
sync.set("mcp_ready", true)
|
||||
setState("done", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setState("done", true)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
setState("loading", false)
|
||||
})
|
||||
})
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync.data.mcp ?? {})
|
||||
|
||||
@@ -8,8 +8,6 @@ import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { type Component, Show } from "solid-js"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -21,6 +19,18 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
const providers = useProviders()
|
||||
const language = useLanguage()
|
||||
|
||||
const connect = (provider: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogConnectProvider provider={provider} />)
|
||||
})
|
||||
}
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
let listRef: ListRef | undefined
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") return
|
||||
@@ -91,7 +101,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
dialog.show(() => <DialogConnectProvider provider={x.id} />)
|
||||
connect(x.id)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
@@ -122,9 +132,7 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
variant="ghost"
|
||||
class="w-full justify-start px-[11px] py-3.5 gap-4.5 text-14-medium"
|
||||
icon="dot-grid"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}}
|
||||
onClick={all}
|
||||
>
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</Button>
|
||||
|
||||
@@ -10,8 +10,6 @@ import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogManageModels } from "./dialog-manage-models"
|
||||
import { ModelTooltip } from "./model-tooltip"
|
||||
import { useLanguage } from "@/context/language"
|
||||
|
||||
@@ -107,12 +105,16 @@ export function ModelSelectorPopover(props: {
|
||||
|
||||
const handleManage = () => {
|
||||
setStore("open", false)
|
||||
dialog.show(() => <DialogManageModels />)
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModels />)
|
||||
})
|
||||
}
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
setStore("open", false)
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -193,26 +195,29 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
const provider = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
const manage = () => {
|
||||
void import("./dialog-manage-models").then((x) => {
|
||||
dialog.show(() => <x.DialogManageModels />)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={language.t("dialog.model.select.title")}
|
||||
action={
|
||||
<Button
|
||||
class="h-7 -my-1 text-14-medium"
|
||||
icon="plus-small"
|
||||
tabIndex={-1}
|
||||
onClick={() => dialog.show(() => <DialogSelectProvider />)}
|
||||
>
|
||||
<Button class="h-7 -my-1 text-14-medium" icon="plus-small" tabIndex={-1} onClick={provider}>
|
||||
{language.t("command.provider.connect")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ModelList provider={props.provider} model={props.model} onSelect={() => dialog.close()} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="ml-3 mt-5 mb-6 text-text-base self-start"
|
||||
onClick={() => dialog.show(() => <DialogManageModels />)}
|
||||
>
|
||||
<Button variant="ghost" class="ml-3 mt-5 mb-6 text-text-base self-start" onClick={manage}>
|
||||
{language.t("dialog.model.manage")}
|
||||
</Button>
|
||||
</Dialog>
|
||||
|
||||
@@ -27,7 +27,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { ModelSelectorPopover } from "@/components/dialog-select-model"
|
||||
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -1494,7 +1493,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
size="normal"
|
||||
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
|
||||
style={control()}
|
||||
onClick={() => dialog.show(() => <DialogSelectModelUnpaid model={local.model} />)}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-model-unpaid").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModelUnpaid model={local.model} />)
|
||||
})
|
||||
}}
|
||||
>
|
||||
<Show when={local.model.current()?.provider?.id}>
|
||||
<ProviderIcon
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { getSessionContextMetrics } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
@@ -32,6 +33,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders()
|
||||
const { params, tabs, view } = useSessionLayout()
|
||||
|
||||
const variant = createMemo(() => props.variant ?? "button")
|
||||
@@ -50,7 +52,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
||||
const context = createMemo(() => metrics().context)
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(metrics().totalCost)
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Markdown } from "@opencode-ai/ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@opencode-ai/sdk/v2/client"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContextMetrics } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
@@ -92,6 +93,7 @@ const emptyUserMessages: UserMessage[] = []
|
||||
export function SessionContextTab() {
|
||||
const sync = useSync()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders()
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
|
||||
@@ -130,7 +132,7 @@ export function SessionContextTab() {
|
||||
}),
|
||||
)
|
||||
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), sync.data.provider.all))
|
||||
const metrics = createMemo(() => getSessionContextMetrics(messages(), providers.all()))
|
||||
const ctx = createMemo(() => metrics().context)
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
|
||||
@@ -4,12 +4,21 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Select } from "@opencode-ai/ui/select"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings, monoFontFamily } from "@/context/settings"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
monoInput,
|
||||
sansDefault,
|
||||
sansFontFamily,
|
||||
sansInput,
|
||||
useSettings,
|
||||
} from "@/context/settings"
|
||||
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
|
||||
import { Link } from "./link"
|
||||
import { SettingsList } from "./settings-list"
|
||||
@@ -25,13 +34,6 @@ type ThemeOption = {
|
||||
name: string
|
||||
}
|
||||
|
||||
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||
|
||||
function loadFont() {
|
||||
font ??= import("@opencode-ai/ui/font-loader")
|
||||
return font
|
||||
}
|
||||
|
||||
// To prevent audio from overlapping/playing very quickly when navigating the settings menus,
|
||||
// delay the playback by 100ms during quick selection changes and pause existing sounds.
|
||||
const stopDemoSound = () => {
|
||||
@@ -149,25 +151,10 @@ export const SettingsGeneral: Component = () => {
|
||||
})),
|
||||
)
|
||||
|
||||
const fontOptions = [
|
||||
{ value: "ibm-plex-mono", label: "font.option.ibmPlexMono" },
|
||||
{ value: "cascadia-code", label: "font.option.cascadiaCode" },
|
||||
{ value: "fira-code", label: "font.option.firaCode" },
|
||||
{ value: "hack", label: "font.option.hack" },
|
||||
{ value: "inconsolata", label: "font.option.inconsolata" },
|
||||
{ value: "intel-one-mono", label: "font.option.intelOneMono" },
|
||||
{ value: "iosevka", label: "font.option.iosevka" },
|
||||
{ value: "jetbrains-mono", label: "font.option.jetbrainsMono" },
|
||||
{ value: "meslo-lgs", label: "font.option.mesloLgs" },
|
||||
{ value: "roboto-mono", label: "font.option.robotoMono" },
|
||||
{ value: "source-code-pro", label: "font.option.sourceCodePro" },
|
||||
{ value: "ubuntu-mono", label: "font.option.ubuntuMono" },
|
||||
{ value: "geist-mono", label: "font.option.geistMono" },
|
||||
] as const
|
||||
const fontOptionsList = [...fontOptions]
|
||||
|
||||
const noneSound = { id: "none", label: "sound.option.none" } as const
|
||||
const soundOptions = [noneSound, ...SOUND_OPTIONS]
|
||||
const mono = () => monoInput(settings.appearance.font())
|
||||
const sans = () => sansInput(settings.appearance.uiFont())
|
||||
|
||||
const soundSelectProps = (
|
||||
enabled: () => boolean,
|
||||
@@ -334,31 +321,50 @@ export const SettingsGeneral: Component = () => {
|
||||
/>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.uiFont.title")}
|
||||
description={language.t("settings.general.row.uiFont.description")}
|
||||
>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextField
|
||||
data-action="settings-ui-font"
|
||||
label={language.t("settings.general.row.uiFont.title")}
|
||||
hideLabel
|
||||
type="text"
|
||||
value={sans()}
|
||||
onChange={(value) => settings.appearance.setUIFont(value)}
|
||||
placeholder={sansDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="text-12-regular"
|
||||
style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.font.title")}
|
||||
description={language.t("settings.general.row.font.description")}
|
||||
>
|
||||
<Select
|
||||
data-action="settings-font"
|
||||
options={fontOptionsList}
|
||||
current={fontOptionsList.find((o) => o.value === settings.appearance.font())}
|
||||
value={(o) => o.value}
|
||||
label={(o) => language.t(o.label)}
|
||||
onHighlight={(option) => {
|
||||
void loadFont().then((x) => x.ensureMonoFont(option?.value))
|
||||
}}
|
||||
onSelect={(option) => option && settings.appearance.setFont(option.value)}
|
||||
variant="secondary"
|
||||
size="small"
|
||||
triggerVariant="settings"
|
||||
triggerStyle={{ "font-family": monoFontFamily(settings.appearance.font()), "min-width": "180px" }}
|
||||
>
|
||||
{(option) => (
|
||||
<span style={{ "font-family": monoFontFamily(option?.value) }}>
|
||||
{option ? language.t(option.label) : ""}
|
||||
</span>
|
||||
)}
|
||||
</Select>
|
||||
<div class="w-full sm:w-[220px]">
|
||||
<TextField
|
||||
data-action="settings-code-font"
|
||||
label={language.t("settings.general.row.font.title")}
|
||||
hideLabel
|
||||
type="text"
|
||||
value={mono()}
|
||||
onChange={(value) => settings.appearance.setFont(value)}
|
||||
placeholder={monoDefault}
|
||||
spellcheck={false}
|
||||
autocorrect="off"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
class="text-12-regular"
|
||||
style={{ "font-family": monoFontFamily(settings.appearance.font()) }}
|
||||
/>
|
||||
</div>
|
||||
</SettingsRow>
|
||||
</SettingsList>
|
||||
</div>
|
||||
|
||||
443
packages/app/src/components/status-popover-body.tsx
Normal file
443
packages/app/src/components/status-popover-body.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
const pollMs = 10_000
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
if (parts.length === 1) return value
|
||||
return (
|
||||
<>
|
||||
{parts[0]}
|
||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
||||
{parts.slice(1).join(file)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const listServersByHealth = (
|
||||
list: ServerConnection.Any[],
|
||||
active: ServerConnection.Key | undefined,
|
||||
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
) => {
|
||||
if (!list.length) return list
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
if (ServerConnection.key(a) === active) return -1
|
||||
if (ServerConnection.key(b) === active) return 1
|
||||
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) {
|
||||
setStatus(reconcile({}))
|
||||
return
|
||||
}
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
const refresh = async () => {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
setStatus(reconcile(results))
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const id = setInterval(() => void refresh(), pollMs)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
clearInterval(id)
|
||||
})
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [state, setState] = createStore({
|
||||
url: undefined as string | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
state.tick
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setState("url", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setState("url", next ? normalizeServerUrl(next) : undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState("url", normalizeServerUrl(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
const u = state.url
|
||||
if (!u) return
|
||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||
},
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggleMutation = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
|
||||
const sync = useSync()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
|
||||
const [load, setLoad] = createStore({
|
||||
lspDone: false,
|
||||
lspLoading: false,
|
||||
mcpDone: false,
|
||||
mcpLoading: false,
|
||||
})
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!props.shown()) return
|
||||
|
||||
if (!sync.data.mcp_ready && !load.mcpDone && !load.mcpLoading) {
|
||||
setLoad("mcpLoading", true)
|
||||
void sdk.client.mcp
|
||||
.status()
|
||||
.then((result) => {
|
||||
sync.set("mcp", result.data ?? {})
|
||||
sync.set("mcp_ready", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoad("mcpDone", true)
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoad("mcpLoading", false)
|
||||
})
|
||||
}
|
||||
|
||||
if (!sync.data.lsp_ready && !load.lspDone && !load.lspLoading) {
|
||||
setLoad("lspLoading", true)
|
||||
void sdk.client.lsp
|
||||
.status()
|
||||
.then((result) => {
|
||||
sync.set("lsp", result.data ?? [])
|
||||
sync.set("lsp_ready", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
setLoad("lspDone", true)
|
||||
fail(err)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoad("lspLoading", false)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
})
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers, props.shown)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
|
||||
return (
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<Tabs
|
||||
aria-label={language.t("status.popover.ariaLabel")}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
data-component="tabs"
|
||||
data-active="servers"
|
||||
defaultValue="servers"
|
||||
variant="alt"
|
||||
>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.plugins")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="servers">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={sortedServers()}>
|
||||
{(s) => {
|
||||
const key = ServerConnection.key(s)
|
||||
const blocked = () => health[key]?.healthy === false
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"hover:bg-surface-raised-base-hover": !blocked(),
|
||||
"cursor-not-allowed": blocked(),
|
||||
}}
|
||||
aria-disabled={blocked()}
|
||||
onClick={() => {
|
||||
if (blocked()) return
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={health[key]} />
|
||||
<ServerRow
|
||||
conn={s}
|
||||
dimmed={blocked()}
|
||||
status={health[key]}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={key === defaultServer.key()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="mcp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={mcpNames().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.mcp.empty")}</div>
|
||||
}
|
||||
>
|
||||
<For each={mcpNames()}>
|
||||
{(name) => {
|
||||
const status = () => mcpStatus(name)
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base":
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
onChange={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="lsp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={lspItems().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
|
||||
}
|
||||
>
|
||||
<For each={lspItems()}>
|
||||
{(item) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": item.status === "connected",
|
||||
"bg-icon-critical-base": item.status === "error",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,202 +1,24 @@
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Popover } from "@opencode-ai/ui/popover"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { type Accessor, createEffect, createMemo, createSignal, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
|
||||
import { Suspense, createMemo, createSignal, lazy, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health"
|
||||
|
||||
const pollMs = 10_000
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
if (parts.length === 1) return value
|
||||
return (
|
||||
<>
|
||||
{parts[0]}
|
||||
<code class="bg-surface-raised-base px-1.5 py-0.5 rounded-sm text-text-base">{file}</code>
|
||||
{parts.slice(1).join(file)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const listServersByHealth = (
|
||||
list: ServerConnection.Any[],
|
||||
active: ServerConnection.Key | undefined,
|
||||
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
) => {
|
||||
if (!list.length) return list
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
if (ServerConnection.key(a) === active) return -1
|
||||
if (ServerConnection.key(b) === active) return 1
|
||||
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
||||
|
||||
createEffect(() => {
|
||||
if (!enabled()) {
|
||||
setStatus(reconcile({}))
|
||||
return
|
||||
}
|
||||
const list = servers()
|
||||
let dead = false
|
||||
|
||||
const refresh = async () => {
|
||||
const results: Record<string, ServerHealth> = {}
|
||||
await Promise.all(
|
||||
list.map(async (conn) => {
|
||||
results[ServerConnection.key(conn)] = await checkServerHealth(conn.http)
|
||||
}),
|
||||
)
|
||||
if (dead) return
|
||||
setStatus(reconcile(results))
|
||||
}
|
||||
|
||||
void refresh()
|
||||
const id = setInterval(() => void refresh(), pollMs)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
clearInterval(id)
|
||||
})
|
||||
})
|
||||
|
||||
return status
|
||||
}
|
||||
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [state, setState] = createStore({
|
||||
url: undefined as string | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
state.tick
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setState("url", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setState("url", next ? normalizeServerUrl(next) : undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState("url", normalizeServerUrl(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
const u = state.url
|
||||
if (!u) return
|
||||
return ServerConnection.key({ type: "http", http: { url: u } })
|
||||
},
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
|
||||
const useMcpToggleMutation = () => {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: async (name: string) => {
|
||||
const status = sync.data.mcp[name]
|
||||
await (status?.status === "connected" ? sdk.client.mcp.disconnect({ name }) : sdk.client.mcp.connect({ name }))
|
||||
const result = await sdk.client.mcp.status()
|
||||
if (result.data) sync.set("mcp", result.data)
|
||||
},
|
||||
onError: (err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
|
||||
export function StatusPopover() {
|
||||
const sync = useSync()
|
||||
const server = useServer()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const server = useServer()
|
||||
const sync = useSync()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
let dialogRun = 0
|
||||
let dialogDead = false
|
||||
onCleanup(() => {
|
||||
dialogDead = true
|
||||
dialogRun += 1
|
||||
})
|
||||
const servers = createMemo(() => {
|
||||
const current = server.current
|
||||
const list = server.list
|
||||
if (!current) return list
|
||||
if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list]
|
||||
return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))]
|
||||
})
|
||||
const health = useServerHealth(servers, shown)
|
||||
const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health))
|
||||
const toggleMcp = useMcpToggleMutation()
|
||||
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
|
||||
const mcpNames = createMemo(() => Object.keys(sync.data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync.data.mcp?.[name]?.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync.data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const plugins = createMemo(() => sync.data.config.plugin ?? [])
|
||||
const pluginCount = createMemo(() => plugins().length)
|
||||
const pluginEmpty = createMemo(() => pluginEmptyMessage(language.t("dialog.plugins.empty"), "opencode.json"))
|
||||
const overallHealthy = createMemo(() => {
|
||||
const ready = createMemo(() => server.healthy() === false || sync.data.mcp_ready)
|
||||
const healthy = createMemo(() => {
|
||||
const serverHealthy = server.healthy() === true
|
||||
const anyMcpIssue = mcpNames().some((name) => {
|
||||
const status = mcpStatus(name)
|
||||
return status !== "connected" && status !== "disabled"
|
||||
})
|
||||
return serverHealthy && !anyMcpIssue
|
||||
const mcp = Object.values(sync.data.mcp ?? {})
|
||||
const issue = mcp.some((item) => item.status !== "connected" && item.status !== "disabled")
|
||||
return serverHealthy && !issue
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -218,9 +40,9 @@ export function StatusPopover() {
|
||||
<div
|
||||
classList={{
|
||||
"absolute -top-px -right-px size-1.5 rounded-full": true,
|
||||
"bg-icon-success-base": overallHealthy(),
|
||||
"bg-icon-critical-base": !overallHealthy() && server.healthy() !== undefined,
|
||||
"bg-border-weak-base": server.healthy() === undefined,
|
||||
"bg-icon-success-base": ready() && healthy(),
|
||||
"bg-icon-critical-base": server.healthy() === false || (ready() && !healthy()),
|
||||
"bg-border-weak-base": server.healthy() === undefined || !ready(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -230,205 +52,15 @@ export function StatusPopover() {
|
||||
placement="bottom-end"
|
||||
shift={-168}
|
||||
>
|
||||
<div class="flex items-center gap-1 w-[360px] rounded-xl shadow-[var(--shadow-lg-border-base)]">
|
||||
<Tabs
|
||||
aria-label={language.t("status.popover.ariaLabel")}
|
||||
class="tabs bg-background-strong rounded-xl overflow-hidden"
|
||||
data-component="tabs"
|
||||
data-active="servers"
|
||||
defaultValue="servers"
|
||||
variant="alt"
|
||||
<Show when={shown()}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="w-[360px] h-14 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]" />
|
||||
}
|
||||
>
|
||||
<Tabs.List data-slot="tablist" class="bg-transparent border-b-0 px-4 pt-2 pb-0 gap-4 h-10">
|
||||
<Tabs.Trigger value="servers" data-slot="tab" class="text-12-regular">
|
||||
{sortedServers().length > 0 ? `${sortedServers().length} ` : ""}
|
||||
{language.t("status.popover.tab.servers")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="mcp" data-slot="tab" class="text-12-regular">
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
{language.t("status.popover.tab.plugins")}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Content value="servers">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<For each={sortedServers()}>
|
||||
{(s) => {
|
||||
const key = ServerConnection.key(s)
|
||||
const isBlocked = () => health[key]?.healthy === false
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-1.5 py-1.5 rounded-md transition-colors text-left"
|
||||
classList={{
|
||||
"hover:bg-surface-raised-base-hover": !isBlocked(),
|
||||
"cursor-not-allowed": isBlocked(),
|
||||
}}
|
||||
aria-disabled={isBlocked()}
|
||||
onClick={() => {
|
||||
if (isBlocked()) return
|
||||
navigate("/")
|
||||
queueMicrotask(() => server.setActive(key))
|
||||
}}
|
||||
>
|
||||
<ServerHealthIndicator health={health[key]} />
|
||||
<ServerRow
|
||||
conn={s}
|
||||
dimmed={isBlocked()}
|
||||
status={health[key]}
|
||||
class="flex items-center gap-2 w-full min-w-0"
|
||||
nameClass="text-14-regular text-text-base truncate"
|
||||
versionClass="text-12-regular text-text-weak truncate"
|
||||
badge={
|
||||
<Show when={key === defaultServer.key()}>
|
||||
<span class="text-11-regular text-text-base bg-surface-base px-1.5 py-0.5 rounded-md">
|
||||
{language.t("common.default")}
|
||||
</span>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="flex-1" />
|
||||
<Show when={server.current && key === ServerConnection.key(server.current)}>
|
||||
<Icon name="check" size="small" class="text-icon-weak shrink-0" />
|
||||
</Show>
|
||||
</ServerRow>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
class="mt-3 self-start h-8 px-3 py-1.5"
|
||||
onClick={() => {
|
||||
const run = ++dialogRun
|
||||
void import("./dialog-select-server").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectServer />, defaultServer.refresh)
|
||||
})
|
||||
}}
|
||||
>
|
||||
{language.t("status.popover.action.manageServers")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="mcp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={mcpNames().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">
|
||||
{language.t("dialog.mcp.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={mcpNames()}>
|
||||
{(name) => {
|
||||
const status = () => mcpStatus(name)
|
||||
const enabled = () => status() === "connected"
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
|
||||
onClick={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": status() === "connected",
|
||||
"bg-icon-critical-base": status() === "failed",
|
||||
"bg-border-weak-base": status() === "disabled",
|
||||
"bg-icon-warning-base":
|
||||
status() === "needs_auth" || status() === "needs_client_registration",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate flex-1">{name}</span>
|
||||
<div onClick={(event) => event.stopPropagation()}>
|
||||
<Switch
|
||||
checked={enabled()}
|
||||
disabled={toggleMcp.isPending && toggleMcp.variables === name}
|
||||
onChange={() => {
|
||||
if (toggleMcp.isPending) return
|
||||
toggleMcp.mutate(name)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="lsp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={lspItems().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">
|
||||
{language.t("dialog.lsp.empty")}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={lspItems()}>
|
||||
{(item) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": item.status === "connected",
|
||||
"bg-icon-critical-base": item.status === "error",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={plugins().length > 0}
|
||||
fallback={<div class="text-14-regular text-text-base text-center my-auto">{pluginEmpty()}</div>}
|
||||
>
|
||||
<For each={plugins()}>
|
||||
{(plugin) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div class="size-1.5 rounded-full shrink-0 bg-icon-success-base" />
|
||||
<span class="text-14-regular text-text-base truncate">{plugin}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
</Tabs>
|
||||
</div>
|
||||
<Body shown={shown} />
|
||||
</Suspense>
|
||||
</Show>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { useGlobalSDK } from "./global-sdk"
|
||||
import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap"
|
||||
import { bootstrapDirectory, bootstrapGlobal, clearProviderRev } from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer"
|
||||
import { createRefreshQueue } from "./global-sync/queue"
|
||||
@@ -154,6 +154,7 @@ function createGlobalSync() {
|
||||
queue.clear(directory)
|
||||
sessionMeta.delete(directory)
|
||||
sdkCache.delete(directory)
|
||||
clearProviderRev(directory)
|
||||
clearSessionPrefetchDirectory(directory)
|
||||
},
|
||||
translate: language.t,
|
||||
@@ -252,6 +253,7 @@ function createGlobalSync() {
|
||||
directory,
|
||||
global: {
|
||||
config: globalStore.config,
|
||||
path: globalStore.path,
|
||||
project: globalStore.project,
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
@@ -311,7 +313,10 @@ function createGlobalSync() {
|
||||
loadLsp: () => {
|
||||
sdkFor(directory)
|
||||
.lsp.status()
|
||||
.then((x) => setStore("lsp", x.data ?? []))
|
||||
.then((x) => {
|
||||
setStore("lsp", x.data ?? [])
|
||||
setStore("lsp_ready", true)
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
ProviderAuthResponse,
|
||||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
@@ -52,6 +53,12 @@ function errors(list: PromiseSettledResult<unknown>[]) {
|
||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||
}
|
||||
|
||||
const providerRev = new Map<string, number>()
|
||||
|
||||
export function clearProviderRev(directory: string) {
|
||||
providerRev.delete(directory)
|
||||
}
|
||||
|
||||
function runAll(list: Array<() => Promise<unknown>>) {
|
||||
return Promise.allSettled(list.map((item) => item()))
|
||||
}
|
||||
@@ -144,6 +151,40 @@ function projectID(directory: string, projects: Project[]) {
|
||||
return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id
|
||||
}
|
||||
|
||||
function mergeSession(setStore: SetStoreFunction<State>, session: Session) {
|
||||
setStore("session", (list) => {
|
||||
const next = list.slice()
|
||||
const idx = next.findIndex((item) => item.id >= session.id)
|
||||
if (idx === -1) return [...next, session]
|
||||
if (next[idx]?.id === session.id) {
|
||||
next[idx] = session
|
||||
return next
|
||||
}
|
||||
next.splice(idx, 0, session)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
function warmSessions(input: {
|
||||
ids: string[]
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
sdk: OpencodeClient
|
||||
}) {
|
||||
const known = new Set(input.store.session.map((item) => item.id))
|
||||
const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id))
|
||||
if (ids.length === 0) return Promise.resolve()
|
||||
return Promise.all(
|
||||
ids.map((sessionID) =>
|
||||
retry(() => input.sdk.session.get({ sessionID })).then((x) => {
|
||||
const session = x.data
|
||||
if (!session?.id) return
|
||||
mergeSession(input.setStore, session)
|
||||
}),
|
||||
),
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
sdk: OpencodeClient
|
||||
@@ -154,19 +195,29 @@ export async function bootstrapDirectory(input: {
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
global: {
|
||||
config: Config
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: ProviderListResponse
|
||||
}
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
|
||||
if (seededProject) input.setStore("project", seededProject)
|
||||
if (seededPath) input.setStore("path", seededPath)
|
||||
if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) {
|
||||
input.setStore("provider", input.global.provider)
|
||||
}
|
||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||
input.setStore("config", input.global.config)
|
||||
}
|
||||
if (loading || input.store.provider.all.length === 0) {
|
||||
input.setStore("provider_ready", false)
|
||||
}
|
||||
input.setStore("mcp_ready", false)
|
||||
input.setStore("mcp", {})
|
||||
input.setStore("lsp_ready", false)
|
||||
input.setStore("lsp", [])
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
const fast = [
|
||||
@@ -177,13 +228,15 @@ export async function bootstrapDirectory(input: {
|
||||
() => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", normalizeAgentList(x.data)))),
|
||||
() => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.path.get().then((x) => {
|
||||
input.setStore("path", x.data!)
|
||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
seededPath
|
||||
? Promise.resolve()
|
||||
: retry(() =>
|
||||
input.sdk.path.get().then((x) => {
|
||||
input.setStore("path", x.data!)
|
||||
const next = projectID(x.data?.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
}),
|
||||
),
|
||||
() => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))),
|
||||
() =>
|
||||
retry(() =>
|
||||
@@ -197,61 +250,66 @@ export async function bootstrapDirectory(input: {
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.permission.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession(
|
||||
(x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID),
|
||||
)
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.permission)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("permission", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, permissions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"permission",
|
||||
sessionID,
|
||||
reconcile(
|
||||
permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.question.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
return warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }).then(() =>
|
||||
batch(() => {
|
||||
for (const sessionID of Object.keys(input.store.question)) {
|
||||
if (grouped[sessionID]) continue
|
||||
input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
input.setStore(
|
||||
"question",
|
||||
sessionID,
|
||||
reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
]
|
||||
|
||||
const slow = [
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.provider.list().then((x) => {
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
input.sdk.mcp.status().then((x) => {
|
||||
input.setStore("mcp", x.data!)
|
||||
input.setStore("mcp_ready", true)
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))),
|
||||
() => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))),
|
||||
]
|
||||
|
||||
const errs = errors(await runAll(fast))
|
||||
@@ -278,4 +336,23 @@ export async function bootstrapDirectory(input: {
|
||||
}
|
||||
|
||||
if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete")
|
||||
|
||||
const rev = (providerRev.get(input.directory) ?? 0) + 1
|
||||
providerRev.set(input.directory, rev)
|
||||
void retry(() => input.sdk.provider.list())
|
||||
.then((x) => {
|
||||
if (providerRev.get(input.directory) !== rev) return
|
||||
input.setStore("provider", normalizeProviderList(x.data!))
|
||||
input.setStore("provider_ready", true)
|
||||
})
|
||||
.catch((err) => {
|
||||
if (providerRev.get(input.directory) !== rev) return
|
||||
console.error("Failed to refresh provider list", err)
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -160,6 +160,7 @@ export function createChildStoreManager(input: {
|
||||
project: "",
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
provider_ready: false,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
config: {},
|
||||
path: { state: "", config: "", worktree: "", directory: "", home: "" },
|
||||
@@ -173,7 +174,9 @@ export function createChildStoreManager(input: {
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp_ready: false,
|
||||
mcp: {},
|
||||
lsp_ready: false,
|
||||
lsp: [],
|
||||
vcs: vcsStore.value,
|
||||
limit: 5,
|
||||
|
||||
@@ -38,6 +38,7 @@ export type State = {
|
||||
project: string
|
||||
projectMeta: ProjectMeta | undefined
|
||||
icon: string | undefined
|
||||
provider_ready: boolean
|
||||
provider: ProviderListResponse
|
||||
config: Config
|
||||
path: Path
|
||||
@@ -58,9 +59,11 @@ export type State = {
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
[name: string]: McpStatus
|
||||
}
|
||||
lsp_ready: boolean
|
||||
lsp: LspStatus[]
|
||||
vcs: VcsInfo | undefined
|
||||
limit: number
|
||||
|
||||
@@ -390,10 +390,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
}
|
||||
|
||||
if (modelEnabled()) {
|
||||
const probe = Symbol("model-probe")
|
||||
|
||||
modelProbe.bind(probe, {
|
||||
setAgent: agent.set,
|
||||
setModel: model.set,
|
||||
setVariant: model.variant.set,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const agent = result.agent.current()
|
||||
const model = result.model.current()
|
||||
modelProbe.set({
|
||||
modelProbe.set(probe, {
|
||||
dir: sdk.directory,
|
||||
sessionID: id(),
|
||||
last: store.last,
|
||||
@@ -411,10 +419,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
pick: scope(),
|
||||
base: undefined,
|
||||
current: store.current,
|
||||
variants: result.model.variant.list(),
|
||||
models: result.model
|
||||
.list()
|
||||
.filter((item) => result.model.visible({ providerID: item.provider.id, modelID: item.id }))
|
||||
.map((item) => ({
|
||||
providerID: item.provider.id,
|
||||
modelID: item.id,
|
||||
name: item.name,
|
||||
})),
|
||||
agents: result.agent.list().map((item) => ({ name: item.name })),
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => modelProbe.clear())
|
||||
onCleanup(() => modelProbe.clear(probe))
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface Settings {
|
||||
appearance: {
|
||||
fontSize: number
|
||||
font: string
|
||||
uiFont: string
|
||||
}
|
||||
keybinds: Record<string, string>
|
||||
permissions: {
|
||||
@@ -42,13 +43,56 @@ export interface Settings {
|
||||
sounds: SoundSettings
|
||||
}
|
||||
|
||||
export const monoDefault = "IBM Plex Mono"
|
||||
export const sansDefault = "Inter"
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
|
||||
|
||||
const monoBase = `"${monoDefault}", "IBM Plex Mono Fallback", ${monoFallback}`
|
||||
const sansBase = `"${sansDefault}", "Inter Fallback", ${sansFallback}`
|
||||
const monoKey = "ibm-plex-mono"
|
||||
|
||||
function input(font: string | undefined, key?: string) {
|
||||
if (!font || font === key || !font.trim()) return ""
|
||||
return font
|
||||
}
|
||||
|
||||
function family(font: string) {
|
||||
if (/^[\w-]+$/.test(font)) return font
|
||||
return `"${font.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
|
||||
}
|
||||
|
||||
function stack(font: string | undefined, base: string, key?: string) {
|
||||
const value = input(font, key).trim()
|
||||
if (!value) return base
|
||||
return `${family(value)}, ${base}`
|
||||
}
|
||||
|
||||
export function monoInput(font: string | undefined) {
|
||||
return input(font, monoKey)
|
||||
}
|
||||
|
||||
export function sansInput(font: string | undefined) {
|
||||
return input(font)
|
||||
}
|
||||
|
||||
export function monoFontFamily(font: string | undefined) {
|
||||
return stack(font, monoBase, monoKey)
|
||||
}
|
||||
|
||||
export function sansFontFamily(font: string | undefined) {
|
||||
return stack(font, sansBase)
|
||||
}
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
general: {
|
||||
autoSave: true,
|
||||
releaseNotes: true,
|
||||
followup: "steer",
|
||||
showReasoningSummaries: false,
|
||||
shellToolPartsExpanded: true,
|
||||
shellToolPartsExpanded: false,
|
||||
editToolPartsExpanded: false,
|
||||
},
|
||||
updates: {
|
||||
@@ -56,7 +100,8 @@ const defaultSettings: Settings = {
|
||||
},
|
||||
appearance: {
|
||||
fontSize: 14,
|
||||
font: "ibm-plex-mono",
|
||||
font: "",
|
||||
uiFont: "",
|
||||
},
|
||||
keybinds: {},
|
||||
permissions: {
|
||||
@@ -77,40 +122,10 @@ const defaultSettings: Settings = {
|
||||
},
|
||||
}
|
||||
|
||||
const monoFallback =
|
||||
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
|
||||
|
||||
const monoFonts: Record<string, string> = {
|
||||
"ibm-plex-mono": `"IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"cascadia-code": `"Cascadia Code Nerd Font", "Cascadia Code NF", "Cascadia Mono NF", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"fira-code": `"Fira Code Nerd Font", "FiraMono Nerd Font", "FiraMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
hack: `"Hack Nerd Font", "Hack Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
inconsolata: `"Inconsolata Nerd Font", "Inconsolata Nerd Font Mono","IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"intel-one-mono": `"Intel One Mono Nerd Font", "IntoneMono Nerd Font", "IntoneMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
iosevka: `"Iosevka Nerd Font", "Iosevka Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"jetbrains-mono": `"JetBrains Mono Nerd Font", "JetBrainsMono Nerd Font Mono", "JetBrainsMonoNL Nerd Font", "JetBrainsMonoNL Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"meslo-lgs": `"Meslo LGS Nerd Font", "MesloLGS Nerd Font", "MesloLGM Nerd Font", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"roboto-mono": `"Roboto Mono Nerd Font", "RobotoMono Nerd Font", "RobotoMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"source-code-pro": `"Source Code Pro Nerd Font", "SauceCodePro Nerd Font", "SauceCodePro Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"ubuntu-mono": `"Ubuntu Mono Nerd Font", "UbuntuMono Nerd Font", "UbuntuMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
"geist-mono": `"GeistMono Nerd Font", "GeistMono Nerd Font Mono", "IBM Plex Mono", "IBM Plex Mono Fallback", ${monoFallback}`,
|
||||
}
|
||||
|
||||
export function monoFontFamily(font: string | undefined) {
|
||||
return monoFonts[font ?? defaultSettings.appearance.font] ?? monoFonts[defaultSettings.appearance.font]
|
||||
}
|
||||
|
||||
function withFallback<T>(read: () => T | undefined, fallback: T) {
|
||||
return createMemo(() => read() ?? fallback)
|
||||
}
|
||||
|
||||
let font: Promise<typeof import("@opencode-ai/ui/font-loader")> | undefined
|
||||
|
||||
function loadFont() {
|
||||
font ??= import("@opencode-ai/ui/font-loader")
|
||||
return font
|
||||
}
|
||||
|
||||
export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({
|
||||
name: "Settings",
|
||||
init: () => {
|
||||
@@ -118,11 +133,9 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
|
||||
createEffect(() => {
|
||||
if (typeof document === "undefined") return
|
||||
const id = store.appearance?.font ?? defaultSettings.appearance.font
|
||||
if (id !== defaultSettings.appearance.font) {
|
||||
void loadFont().then((x) => x.ensureMonoFont(id))
|
||||
}
|
||||
document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id))
|
||||
const root = document.documentElement
|
||||
root.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font))
|
||||
root.style.setProperty("--font-family-sans", sansFontFamily(store.appearance?.uiFont))
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -178,7 +191,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
|
||||
},
|
||||
font: withFallback(() => store.appearance?.font, defaultSettings.appearance.font),
|
||||
setFont(value: string) {
|
||||
setStore("appearance", "font", value)
|
||||
setStore("appearance", "font", value.trim() ? value : "")
|
||||
},
|
||||
uiFont: withFallback(() => store.appearance?.uiFont, defaultSettings.appearance.uiFont),
|
||||
setUIFont(value: string) {
|
||||
setStore("appearance", "uiFont", value.trim() ? value : "")
|
||||
},
|
||||
},
|
||||
keybinds: {
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useProviders() {
|
||||
const providers = () => {
|
||||
if (dir()) {
|
||||
const [projectStore] = globalSync.child(dir())
|
||||
if (projectStore.provider.all.length > 0) return projectStore.provider
|
||||
if (projectStore.provider_ready) return projectStore.provider
|
||||
}
|
||||
return globalSync.data.provider
|
||||
}
|
||||
|
||||
@@ -564,8 +564,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "اختر ما إذا كان OpenCode يتبع سمة النظام أو الفاتح أو الداكن",
|
||||
"settings.general.row.theme.title": "السمة",
|
||||
"settings.general.row.theme.description": "تخصيص سمة OpenCode.",
|
||||
"settings.general.row.font.title": "الخط",
|
||||
"settings.general.row.font.description": "تخصيص الخط الأحادي المستخدم في كتل التعليمات البرمجية",
|
||||
"settings.general.row.font.title": "خط الكود",
|
||||
"settings.general.row.font.description": "خصّص الخط المستخدم في كتل التعليمات البرمجية والطرفيات",
|
||||
"settings.general.row.uiFont.title": "خط الواجهة",
|
||||
"settings.general.row.uiFont.description": "خصّص الخط المستخدم في الواجهة بأكملها",
|
||||
"settings.general.row.followup.title": "سلوك المتابعة",
|
||||
"settings.general.row.followup.description": "اختر ما إذا كانت طلبات المتابعة توجه فورًا أو تنتظر في قائمة انتظار",
|
||||
"settings.general.row.followup.option.queue": "قائمة انتظار",
|
||||
@@ -592,19 +594,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "جارٍ التحقق...",
|
||||
"settings.updates.toast.latest.title": "أنت على آخر إصدار",
|
||||
"settings.updates.toast.latest.description": "أنت تستخدم أحدث إصدار من OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "بلا",
|
||||
"sound.option.alert01": "تنبيه 01",
|
||||
"sound.option.alert02": "تنبيه 02",
|
||||
|
||||
@@ -571,8 +571,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Escolha se o OpenCode segue o tema do sistema, claro ou escuro",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Personalize como o OpenCode é tematizado.",
|
||||
"settings.general.row.font.title": "Fonte",
|
||||
"settings.general.row.font.description": "Personalize a fonte monoespaçada usada em blocos de código",
|
||||
"settings.general.row.font.title": "Fonte de código",
|
||||
"settings.general.row.font.description": "Personalize a fonte usada em blocos de código e terminais",
|
||||
"settings.general.row.uiFont.title": "Fonte da interface",
|
||||
"settings.general.row.uiFont.description": "Personalize a fonte usada em toda a interface",
|
||||
"settings.general.row.followup.title": "Comportamento de acompanhamento",
|
||||
"settings.general.row.followup.description":
|
||||
"Escolha se os prompts de acompanhamento orientam imediatamente ou esperam na fila",
|
||||
@@ -600,19 +602,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Verificando...",
|
||||
"settings.updates.toast.latest.title": "Você está atualizado",
|
||||
"settings.updates.toast.latest.description": "Você está usando a versão mais recente do OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Nenhum",
|
||||
"sound.option.alert01": "Alerta 01",
|
||||
"sound.option.alert02": "Alerta 02",
|
||||
|
||||
@@ -636,8 +636,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Odaberi da li OpenCode prati sistemsku, svijetlu ili tamnu temu",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Prilagodi temu OpenCode-a.",
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Prilagodi monospace font koji se koristi u blokovima koda",
|
||||
"settings.general.row.font.title": "Font za kod",
|
||||
"settings.general.row.font.description": "Prilagodi font koji se koristi u blokovima koda i terminalima",
|
||||
"settings.general.row.uiFont.title": "UI font",
|
||||
"settings.general.row.uiFont.description": "Prilagodi font koji se koristi u cijelom interfejsu",
|
||||
"settings.general.row.followup.title": "Ponašanje nadovezivanja",
|
||||
"settings.general.row.followup.description": "Odaberi da li upiti nadovezivanja usmjeravaju odmah ili čekaju u redu",
|
||||
"settings.general.row.followup.option.queue": "Red čekanja",
|
||||
@@ -667,19 +669,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Provjera...",
|
||||
"settings.updates.toast.latest.title": "Sve je ažurno",
|
||||
"settings.updates.toast.latest.description": "Koristiš najnoviju verziju OpenCode-a.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Nijedan",
|
||||
"sound.option.alert01": "Upozorenje 01",
|
||||
"sound.option.alert02": "Upozorenje 02",
|
||||
|
||||
@@ -631,8 +631,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Vælg om OpenCode følger systemets, lyst eller mørkt tema",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Tilpas hvordan OpenCode er temabestemt.",
|
||||
"settings.general.row.font.title": "Skrifttype",
|
||||
"settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke",
|
||||
"settings.general.row.font.title": "Kode-skrifttype",
|
||||
"settings.general.row.font.description": "Tilpas skrifttypen, der bruges i kodeblokke og terminaler",
|
||||
"settings.general.row.uiFont.title": "UI-skrifttype",
|
||||
"settings.general.row.uiFont.description": "Tilpas skrifttypen, der bruges i hele brugerfladen",
|
||||
"settings.general.row.followup.title": "Opfølgningsadfærd",
|
||||
"settings.general.row.followup.description": "Vælg om opfølgende forespørgsler skal styre straks eller vente i kø",
|
||||
"settings.general.row.followup.option.queue": "Kø",
|
||||
@@ -662,19 +664,6 @@ export const dict = {
|
||||
"settings.updates.toast.latest.title": "Du er opdateret",
|
||||
"settings.updates.toast.latest.description": "Du kører den nyeste version af OpenCode.",
|
||||
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Ingen",
|
||||
"sound.option.alert01": "Alarm 01",
|
||||
"sound.option.alert02": "Alarm 02",
|
||||
|
||||
@@ -581,8 +581,10 @@ export const dict = {
|
||||
"Wählen Sie, ob OpenCode dem System-, hellen oder dunklen Thema folgt",
|
||||
"settings.general.row.theme.title": "Thema",
|
||||
"settings.general.row.theme.description": "Das Thema von OpenCode anpassen.",
|
||||
"settings.general.row.font.title": "Schriftart",
|
||||
"settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen",
|
||||
"settings.general.row.font.title": "Code-Schriftart",
|
||||
"settings.general.row.font.description": "Die in Codeblöcken und Terminals verwendete Schriftart anpassen",
|
||||
"settings.general.row.uiFont.title": "UI-Schriftart",
|
||||
"settings.general.row.uiFont.description": "Die im gesamten Interface verwendete Schriftart anpassen",
|
||||
"settings.general.row.followup.title": "Verhalten bei Folgefragen",
|
||||
"settings.general.row.followup.description":
|
||||
"Wählen Sie, ob Folgefragen sofort steuern oder in einer Warteschlange warten",
|
||||
@@ -611,19 +613,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Wird geprüft...",
|
||||
"settings.updates.toast.latest.title": "Du bist auf dem neuesten Stand",
|
||||
"settings.updates.toast.latest.description": "Du verwendest die aktuelle Version von OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Keine",
|
||||
"sound.option.alert01": "Alarm 01",
|
||||
"sound.option.alert02": "Alarm 02",
|
||||
|
||||
@@ -729,8 +729,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Choose whether OpenCode follows the system, light, or dark theme",
|
||||
"settings.general.row.theme.title": "Theme",
|
||||
"settings.general.row.theme.description": "Customise how OpenCode is themed.",
|
||||
"settings.general.row.font.title": "Font",
|
||||
"settings.general.row.font.description": "Customise the mono font used in code blocks",
|
||||
"settings.general.row.font.title": "Code Font",
|
||||
"settings.general.row.font.description": "Customise the font used in code blocks and terminals",
|
||||
"settings.general.row.uiFont.title": "UI Font",
|
||||
"settings.general.row.uiFont.description": "Customise the font used throughout the interface",
|
||||
"settings.general.row.followup.title": "Follow-up behavior",
|
||||
"settings.general.row.followup.description": "Choose whether follow-up prompts steer immediately or wait in a queue",
|
||||
"settings.general.row.followup.option.queue": "Queue",
|
||||
@@ -760,19 +762,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Checking...",
|
||||
"settings.updates.toast.latest.title": "You're up to date",
|
||||
"settings.updates.toast.latest.description": "You're running the latest version of OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "None",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
|
||||
@@ -639,8 +639,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Elige si OpenCode sigue el tema del sistema, claro u oscuro",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Personaliza el tema de OpenCode.",
|
||||
"settings.general.row.font.title": "Fuente",
|
||||
"settings.general.row.font.description": "Personaliza la fuente monoespaciada usada en bloques de código",
|
||||
"settings.general.row.font.title": "Fuente de código",
|
||||
"settings.general.row.font.description": "Personaliza la fuente usada en bloques de código y terminales",
|
||||
"settings.general.row.uiFont.title": "Fuente de la interfaz",
|
||||
"settings.general.row.uiFont.description": "Personaliza la fuente usada en toda la interfaz",
|
||||
"settings.general.row.followup.title": "Comportamiento de seguimiento",
|
||||
"settings.general.row.followup.description":
|
||||
"Elige si los prompts de seguimiento se dirigen inmediatamente o esperan en una cola",
|
||||
@@ -672,19 +674,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Buscando...",
|
||||
"settings.updates.toast.latest.title": "Estás al día",
|
||||
"settings.updates.toast.latest.description": "Estás usando la última versión de OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Ninguno",
|
||||
"sound.option.alert01": "Alerta 01",
|
||||
"sound.option.alert02": "Alerta 02",
|
||||
|
||||
@@ -578,8 +578,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Choisissez si OpenCode suit le thème système, clair ou sombre",
|
||||
"settings.general.row.theme.title": "Thème",
|
||||
"settings.general.row.theme.description": "Personnaliser le thème d'OpenCode.",
|
||||
"settings.general.row.font.title": "Police",
|
||||
"settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code",
|
||||
"settings.general.row.font.title": "Police de code",
|
||||
"settings.general.row.font.description": "Personnaliser la police utilisée dans les blocs de code et les terminaux",
|
||||
"settings.general.row.uiFont.title": "Police de l'interface",
|
||||
"settings.general.row.uiFont.description": "Personnaliser la police utilisée dans toute l'interface",
|
||||
"settings.general.row.followup.title": "Comportement de suivi",
|
||||
"settings.general.row.followup.description":
|
||||
"Choisissez si les messages de suivi dirigent immédiatement ou attendent dans une file d'attente",
|
||||
@@ -608,19 +610,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Vérification...",
|
||||
"settings.updates.toast.latest.title": "Vous êtes à jour",
|
||||
"settings.updates.toast.latest.description": "Vous utilisez la dernière version d'OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Aucun",
|
||||
"sound.option.alert01": "Alerte 01",
|
||||
"sound.option.alert02": "Alerte 02",
|
||||
|
||||
@@ -568,8 +568,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "OpenCodeがシステム、ライト、またはダークテーマに従うかを選択します",
|
||||
"settings.general.row.theme.title": "テーマ",
|
||||
"settings.general.row.theme.description": "OpenCodeのテーマをカスタマイズします。",
|
||||
"settings.general.row.font.title": "フォント",
|
||||
"settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします",
|
||||
"settings.general.row.font.title": "コードフォント",
|
||||
"settings.general.row.font.description": "コードブロックとターミナルで使用するフォントをカスタマイズします",
|
||||
"settings.general.row.uiFont.title": "UIフォント",
|
||||
"settings.general.row.uiFont.description": "インターフェース全体で使用するフォントをカスタマイズします",
|
||||
"settings.general.row.followup.title": "フォローアップの動作",
|
||||
"settings.general.row.followup.description":
|
||||
"フォローアッププロンプトを即座に実行するか、キューで待機させるかを選択します",
|
||||
@@ -597,19 +599,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "確認中...",
|
||||
"settings.updates.toast.latest.title": "最新です",
|
||||
"settings.updates.toast.latest.description": "OpenCode は最新バージョンです。",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "なし",
|
||||
"sound.option.alert01": "アラート 01",
|
||||
"sound.option.alert02": "アラート 02",
|
||||
|
||||
@@ -569,8 +569,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "OpenCode가 시스템, 라이트 또는 다크 테마를 따를지 선택하세요",
|
||||
"settings.general.row.theme.title": "테마",
|
||||
"settings.general.row.theme.description": "OpenCode 테마 사용자 지정",
|
||||
"settings.general.row.font.title": "글꼴",
|
||||
"settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정",
|
||||
"settings.general.row.font.title": "코드 글꼴",
|
||||
"settings.general.row.font.description": "코드 블록과 터미널에 사용되는 글꼴을 사용자 지정",
|
||||
"settings.general.row.uiFont.title": "UI 글꼴",
|
||||
"settings.general.row.uiFont.description": "인터페이스 전반에 사용되는 글꼴을 사용자 지정",
|
||||
"settings.general.row.followup.title": "후속 조치 동작",
|
||||
"settings.general.row.followup.description": "후속 프롬프트를 즉시 실행할지 대기열에 넣을지 선택하세요",
|
||||
"settings.general.row.followup.option.queue": "대기열",
|
||||
@@ -597,19 +599,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "확인 중...",
|
||||
"settings.updates.toast.latest.title": "최신 상태입니다",
|
||||
"settings.updates.toast.latest.description": "현재 최신 버전의 OpenCode를 사용 중입니다.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "없음",
|
||||
"sound.option.alert01": "알림 01",
|
||||
"sound.option.alert02": "알림 02",
|
||||
|
||||
@@ -639,8 +639,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Velg om OpenCode skal følge systemets, lyst eller mørkt tema",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "Tilpass hvordan OpenCode er tematisert.",
|
||||
"settings.general.row.font.title": "Skrift",
|
||||
"settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker",
|
||||
"settings.general.row.font.title": "Kodefont",
|
||||
"settings.general.row.font.description": "Tilpass skrifttypen som brukes i kodeblokker og terminaler",
|
||||
"settings.general.row.uiFont.title": "UI-skrift",
|
||||
"settings.general.row.uiFont.description": "Tilpass skrifttypen som brukes i hele grensesnittet",
|
||||
"settings.general.row.followup.title": "Oppfølgingsadferd",
|
||||
"settings.general.row.followup.description": "Velg om oppfølgingsspørsmål skal kjøres umiddelbart eller vente i kø",
|
||||
"settings.general.row.followup.option.queue": "Kø",
|
||||
@@ -668,19 +670,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Sjekker...",
|
||||
"settings.updates.toast.latest.title": "Du er oppdatert",
|
||||
"settings.updates.toast.latest.description": "Du bruker den nyeste versjonen av OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Ingen",
|
||||
"sound.option.alert01": "Varsel 01",
|
||||
"sound.option.alert02": "Varsel 02",
|
||||
|
||||
@@ -570,8 +570,10 @@ export const dict = {
|
||||
"Wybierz, czy OpenCode ma używać motywu systemowego, jasnego czy ciemnego",
|
||||
"settings.general.row.theme.title": "Motyw",
|
||||
"settings.general.row.theme.description": "Dostosuj motyw OpenCode.",
|
||||
"settings.general.row.font.title": "Czcionka",
|
||||
"settings.general.row.font.description": "Dostosuj czcionkę mono używaną w blokach kodu",
|
||||
"settings.general.row.font.title": "Czcionka kodu",
|
||||
"settings.general.row.font.description": "Dostosuj czcionkę używaną w blokach kodu i terminalach",
|
||||
"settings.general.row.uiFont.title": "Czcionka interfejsu",
|
||||
"settings.general.row.uiFont.description": "Dostosuj czcionkę używaną w całym interfejsie",
|
||||
"settings.general.row.followup.title": "Zachowanie kontynuacji",
|
||||
"settings.general.row.followup.description": "Wybierz, czy kontynuacja ma być natychmiastowa, czy czekać w kolejce",
|
||||
"settings.general.row.followup.option.queue": "Kolejka",
|
||||
@@ -598,19 +600,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Sprawdzanie...",
|
||||
"settings.updates.toast.latest.title": "Masz najnowszą wersję",
|
||||
"settings.updates.toast.latest.description": "Korzystasz z najnowszej wersji OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Brak",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
|
||||
@@ -636,8 +636,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "Выберите, следует ли OpenCode системной, светлой или тёмной теме",
|
||||
"settings.general.row.theme.title": "Тема",
|
||||
"settings.general.row.theme.description": "Настройте оформление OpenCode.",
|
||||
"settings.general.row.font.title": "Шрифт",
|
||||
"settings.general.row.font.description": "Настройте моноширинный шрифт для блоков кода",
|
||||
"settings.general.row.font.title": "Шрифт кода",
|
||||
"settings.general.row.font.description": "Настройте шрифт, используемый в блоках кода и терминалах",
|
||||
"settings.general.row.uiFont.title": "Шрифт интерфейса",
|
||||
"settings.general.row.uiFont.description": "Настройте шрифт, используемый во всем интерфейсе",
|
||||
"settings.general.row.followup.title": "Поведение уточняющих вопросов",
|
||||
"settings.general.row.followup.description":
|
||||
"Выберите, отправлять ли уточняющие вопросы сразу или помещать их в очередь",
|
||||
@@ -668,19 +670,6 @@ export const dict = {
|
||||
"settings.updates.action.checking": "Проверка...",
|
||||
"settings.updates.toast.latest.title": "У вас последняя версия",
|
||||
"settings.updates.toast.latest.description": "Вы используете последнюю версию OpenCode.",
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "Нет",
|
||||
"sound.option.alert01": "Alert 01",
|
||||
"sound.option.alert02": "Alert 02",
|
||||
|
||||
@@ -630,8 +630,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "เลือกว่าจะให้ OpenCode ใช้ธีมตามระบบ สว่าง หรือมืด",
|
||||
"settings.general.row.theme.title": "ธีม",
|
||||
"settings.general.row.theme.description": "ปรับแต่งวิธีการที่ OpenCode มีธีม",
|
||||
"settings.general.row.font.title": "ฟอนต์",
|
||||
"settings.general.row.font.description": "ปรับแต่งฟอนต์โมโนที่ใช้ในบล็อกโค้ด",
|
||||
"settings.general.row.font.title": "ฟอนต์โค้ด",
|
||||
"settings.general.row.font.description": "ปรับแต่งฟอนต์ที่ใช้ในบล็อกโค้ดและเทอร์มินัล",
|
||||
"settings.general.row.uiFont.title": "ฟอนต์ UI",
|
||||
"settings.general.row.uiFont.description": "ปรับแต่งฟอนต์ที่ใช้ทั่วทั้งอินเทอร์เฟซ",
|
||||
"settings.general.row.followup.title": "พฤติกรรมการติดตามผล",
|
||||
"settings.general.row.followup.description": "เลือกว่าจะให้พร้อมท์ติดตามผลทำงานทันทีหรือรอในคิว",
|
||||
"settings.general.row.followup.option.queue": "คิว",
|
||||
@@ -659,19 +661,6 @@ export const dict = {
|
||||
"settings.updates.toast.latest.title": "คุณเป็นเวอร์ชันล่าสุดแล้ว",
|
||||
"settings.updates.toast.latest.description": "คุณกำลังใช้งาน OpenCode เวอร์ชันล่าสุด",
|
||||
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "ไม่มี",
|
||||
"sound.option.alert01": "เสียงเตือน 01",
|
||||
"sound.option.alert02": "เสียงเตือน 02",
|
||||
|
||||
@@ -643,8 +643,10 @@ export const dict = {
|
||||
"OpenCode'un sistem, açık veya koyu temayı takip etip etmeyeceğini seçin",
|
||||
"settings.general.row.theme.title": "Tema",
|
||||
"settings.general.row.theme.description": "OpenCode'un temasını özelleştirin.",
|
||||
"settings.general.row.font.title": "Yazı Tipi",
|
||||
"settings.general.row.font.description": "Kod bloklarında kullanılan monospace yazı tipini özelleştirin",
|
||||
"settings.general.row.font.title": "Kod Yazı Tipi",
|
||||
"settings.general.row.font.description": "Kod bloklarında ve terminallerde kullanılan yazı tipini özelleştirin",
|
||||
"settings.general.row.uiFont.title": "Arayüz Yazı Tipi",
|
||||
"settings.general.row.uiFont.description": "Arayüz genelinde kullanılan yazı tipini özelleştirin",
|
||||
"settings.general.row.followup.title": "Takip davranışı",
|
||||
"settings.general.row.followup.description":
|
||||
"Takip komutlarının hemen yönlendirilmesini mi yoksa sırada beklemesini mi istediğinizi seçin",
|
||||
@@ -677,20 +679,6 @@ export const dict = {
|
||||
"settings.updates.toast.latest.title": "Güncelsiniz",
|
||||
"settings.updates.toast.latest.description": "OpenCode'un en son sürümünü kullanıyorsunuz.",
|
||||
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
|
||||
"sound.option.none": "Yok",
|
||||
"sound.option.alert01": "Uyarı 01",
|
||||
"sound.option.alert02": "Uyarı 02",
|
||||
|
||||
@@ -630,8 +630,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "选择 OpenCode 跟随系统、浅色或深色主题",
|
||||
"settings.general.row.theme.title": "主题",
|
||||
"settings.general.row.theme.description": "自定义 OpenCode 的主题。",
|
||||
"settings.general.row.font.title": "字体",
|
||||
"settings.general.row.font.description": "自定义代码块使用的等宽字体",
|
||||
"settings.general.row.font.title": "代码字体",
|
||||
"settings.general.row.font.description": "自定义代码块和终端使用的字体",
|
||||
"settings.general.row.uiFont.title": "界面字体",
|
||||
"settings.general.row.uiFont.description": "自定义整个界面使用的字体",
|
||||
"settings.general.row.followup.title": "跟进消息行为",
|
||||
"settings.general.row.followup.description": "选择跟进提示是立即引导还是在队列中等待",
|
||||
"settings.general.row.followup.option.queue": "排队",
|
||||
@@ -657,20 +659,6 @@ export const dict = {
|
||||
"settings.updates.toast.latest.title": "已是最新版本",
|
||||
"settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。",
|
||||
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
|
||||
"sound.option.none": "无",
|
||||
"sound.option.alert01": "警报 01",
|
||||
"sound.option.alert02": "警报 02",
|
||||
|
||||
@@ -625,8 +625,10 @@ export const dict = {
|
||||
"settings.general.row.colorScheme.description": "選擇 OpenCode 要跟隨系統、淺色或深色主題",
|
||||
"settings.general.row.theme.title": "主題",
|
||||
"settings.general.row.theme.description": "自訂 OpenCode 的主題。",
|
||||
"settings.general.row.font.title": "字型",
|
||||
"settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型",
|
||||
"settings.general.row.font.title": "程式碼字型",
|
||||
"settings.general.row.font.description": "自訂程式碼區塊和終端機使用的字型",
|
||||
"settings.general.row.uiFont.title": "介面字型",
|
||||
"settings.general.row.uiFont.description": "自訂整個介面使用的字型",
|
||||
"settings.general.row.followup.title": "後續追問行為",
|
||||
"settings.general.row.followup.description": "選擇後續追問提示是立即引導還是進入佇列等待",
|
||||
"settings.general.row.followup.option.queue": "佇列",
|
||||
@@ -654,19 +656,6 @@ export const dict = {
|
||||
"settings.updates.toast.latest.title": "已是最新版本",
|
||||
"settings.updates.toast.latest.description": "你正在使用最新版本的 OpenCode。",
|
||||
|
||||
"font.option.ibmPlexMono": "IBM Plex Mono",
|
||||
"font.option.cascadiaCode": "Cascadia Code",
|
||||
"font.option.firaCode": "Fira Code",
|
||||
"font.option.hack": "Hack",
|
||||
"font.option.inconsolata": "Inconsolata",
|
||||
"font.option.intelOneMono": "Intel One Mono",
|
||||
"font.option.iosevka": "Iosevka",
|
||||
"font.option.jetbrainsMono": "JetBrains Mono",
|
||||
"font.option.mesloLgs": "Meslo LGS",
|
||||
"font.option.robotoMono": "Roboto Mono",
|
||||
"font.option.sourceCodePro": "Source Code Pro",
|
||||
"font.option.ubuntuMono": "Ubuntu Mono",
|
||||
"font.option.geistMono": "Geist Mono",
|
||||
"sound.option.none": "無",
|
||||
"sound.option.alert01": "警報 01",
|
||||
"sound.option.alert02": "警報 02",
|
||||
|
||||
@@ -12,6 +12,7 @@ import { decode64 } from "@/utils/base64"
|
||||
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const slug = createMemo(() => base64Encode(props.directory))
|
||||
|
||||
@@ -22,6 +23,12 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
void sync.session.sync(id)
|
||||
})
|
||||
|
||||
return (
|
||||
<DataProvider
|
||||
data={sync.data}
|
||||
|
||||
@@ -26,8 +26,8 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { base64Encode, checksum } from "@opencode-ai/util/encode"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { checksum } from "@opencode-ai/util/encode"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch"
|
||||
@@ -317,7 +317,6 @@ export default function Page() {
|
||||
const sync = useSync()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const settings = useSettings()
|
||||
const prompt = usePrompt()
|
||||
@@ -712,7 +711,6 @@ export default function Page() {
|
||||
return Date.now() - info.at > SESSION_PREFETCH_TTL
|
||||
})()
|
||||
const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined)
|
||||
|
||||
untrack(() => {
|
||||
void sync.session.sync(id)
|
||||
})
|
||||
@@ -1556,26 +1554,6 @@ export default function Page() {
|
||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||
|
||||
const fork = (input: { sessionID: string; messageID: string }) => {
|
||||
const value = draft(input.messageID)
|
||||
const dir = base64Encode(sdk.directory)
|
||||
return sdk.client.session
|
||||
.fork(input)
|
||||
.then((result) => {
|
||||
const next = result.data
|
||||
if (!next) {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
})
|
||||
return
|
||||
}
|
||||
prompt.set(value, undefined, { dir, id: next.id })
|
||||
navigate(`/${dir}/session/${next.id}`)
|
||||
})
|
||||
.catch(fail)
|
||||
}
|
||||
|
||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||
if (reverting()) return
|
||||
return revertMutation.mutateAsync(input)
|
||||
@@ -1594,7 +1572,7 @@ export default function Page() {
|
||||
.map((item) => ({ id: item.id, text: line(item.id) }))
|
||||
})
|
||||
|
||||
const actions = { fork, revert }
|
||||
const actions = { revert }
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = params.id
|
||||
|
||||
@@ -943,7 +943,10 @@ export function MessageTimeline(props: {
|
||||
"min-w-0 w-full max-w-full": true,
|
||||
"md:max-w-200 2xl:max-w-[1000px]": props.centered,
|
||||
}}
|
||||
style={{ "content-visibility": "auto", "contain-intrinsic-size": "auto 500px" }}
|
||||
style={{
|
||||
"content-visibility": active() ? undefined : "auto",
|
||||
"contain-intrinsic-size": active() ? undefined : "auto 500px",
|
||||
}}
|
||||
>
|
||||
<Show when={commentCount() > 0}>
|
||||
<div class="w-full px-4 md:px-5 pb-2">
|
||||
|
||||
@@ -13,7 +13,6 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
@@ -293,9 +292,11 @@ export function SessionSidePanel(props: {
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
class="!rounded-md"
|
||||
onClick={() =>
|
||||
dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||
}
|
||||
onClick={() => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
|
||||
})
|
||||
}}
|
||||
aria-label={language.t("command.file.open")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
|
||||
@@ -11,10 +11,6 @@ import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { DialogSelectFile } from "@/components/dialog-select-file"
|
||||
import { DialogSelectModel } from "@/components/dialog-select-model"
|
||||
import { DialogSelectMcp } from "@/components/dialog-select-mcp"
|
||||
import { DialogFork } from "@/components/dialog-fork"
|
||||
import { showToast } from "@opencode-ai/ui/toast"
|
||||
import { findLast } from "@opencode-ai/util/array"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
@@ -257,7 +253,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
description: language.t("palette.search.placeholder"),
|
||||
keybind: "mod+k,mod+p",
|
||||
slash: "open",
|
||||
onSelect: () => dialog.show(() => <DialogSelectFile onOpenFile={showAllFiles} />),
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-file").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectFile onOpenFile={showAllFiles} />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
fileCommand({
|
||||
id: "tab.close",
|
||||
@@ -351,7 +351,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
description: language.t("command.model.choose.description"),
|
||||
keybind: "mod+'",
|
||||
slash: "model",
|
||||
onSelect: () => dialog.show(() => <DialogSelectModel model={local.model} />),
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-model").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectModel model={local.model} />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
mcpCommand({
|
||||
id: "mcp.toggle",
|
||||
@@ -359,7 +363,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
description: language.t("command.mcp.toggle.description"),
|
||||
keybind: "mod+;",
|
||||
slash: "mcp",
|
||||
onSelect: () => dialog.show(() => <DialogSelectMcp />),
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-select-mcp").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectMcp />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
agentCommand({
|
||||
id: "agent.cycle",
|
||||
@@ -487,7 +495,11 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
description: language.t("command.session.fork.description"),
|
||||
slash: "fork",
|
||||
disabled: !params.id || visibleUserMessages().length === 0,
|
||||
onSelect: () => dialog.show(() => <DialogFork />),
|
||||
onSelect: () => {
|
||||
void import("@/components/dialog-fork").then((x) => {
|
||||
dialog.show(() => <x.DialogFork />)
|
||||
})
|
||||
},
|
||||
}),
|
||||
...share,
|
||||
]
|
||||
|
||||
@@ -3,6 +3,14 @@ type ModelKey = {
|
||||
modelID: string
|
||||
}
|
||||
|
||||
type ModelItem = ModelKey & {
|
||||
name: string
|
||||
}
|
||||
|
||||
type AgentItem = {
|
||||
name: string
|
||||
}
|
||||
|
||||
type State = {
|
||||
agent?: string
|
||||
model?: ModelKey | null
|
||||
@@ -26,6 +34,9 @@ export type ModelProbeState = {
|
||||
pick?: State
|
||||
base?: State
|
||||
current?: string
|
||||
variants?: string[]
|
||||
models?: ModelItem[]
|
||||
agents?: AgentItem[]
|
||||
}
|
||||
|
||||
export type ModelWindow = Window & {
|
||||
@@ -33,6 +44,11 @@ export type ModelWindow = Window & {
|
||||
model?: {
|
||||
enabled?: boolean
|
||||
current?: ModelProbeState
|
||||
controls?: {
|
||||
setAgent?: (name: string | undefined) => void
|
||||
setModel?: (value: ModelKey | undefined) => void
|
||||
setVariant?: (value: string | undefined) => void
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -45,6 +61,8 @@ const clone = (state?: State) => {
|
||||
}
|
||||
}
|
||||
|
||||
let active: symbol | undefined
|
||||
|
||||
export const modelEnabled = () => {
|
||||
if (typeof window === "undefined") return false
|
||||
return (window as ModelWindow).__opencode_e2e?.model?.enabled === true
|
||||
@@ -56,9 +74,15 @@ const root = () => {
|
||||
}
|
||||
|
||||
export const modelProbe = {
|
||||
set(input: ModelProbeState) {
|
||||
bind(id: symbol, input: NonNullable<NonNullable<ModelWindow["__opencode_e2e"]>["model"]>["controls"]) {
|
||||
const state = root()
|
||||
if (!state) return
|
||||
active = id
|
||||
state.controls = input
|
||||
},
|
||||
set(id: symbol, input: ModelProbeState) {
|
||||
const state = root()
|
||||
if (!state || active !== id) return
|
||||
state.current = {
|
||||
...input,
|
||||
model: input.model ? { ...input.model } : undefined,
|
||||
@@ -70,11 +94,16 @@ export const modelProbe = {
|
||||
: undefined,
|
||||
pick: clone(input.pick),
|
||||
base: clone(input.base),
|
||||
variants: input.variants?.slice(),
|
||||
models: input.models?.map((item) => ({ ...item })),
|
||||
agents: input.agents?.map((item) => ({ ...item })),
|
||||
}
|
||||
},
|
||||
clear() {
|
||||
clear(id: symbol) {
|
||||
const state = root()
|
||||
if (!state) return
|
||||
if (!state || active !== id) return
|
||||
active = undefined
|
||||
state.current = undefined
|
||||
state.controls = undefined
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop-electron",
|
||||
"private": true,
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "opencode"
|
||||
name = "OpenCode"
|
||||
description = "The open source coding agent."
|
||||
version = "1.3.2"
|
||||
version = "1.3.3"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/anomalyco/opencode"
|
||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.2/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.3/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -49,6 +49,10 @@ See `specs/effect-migration.md` for the compact pattern reference and examples.
|
||||
- Prefer `Path.Path`, `Config`, `Clock`, and `DateTime` when those concerns are already inside Effect code.
|
||||
- For background loops or scheduled tasks, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
||||
|
||||
## Effect.cached for deduplication
|
||||
|
||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation rather than storing `Fiber | undefined` or `Promise | undefined` manually. See `specs/effect-migration.md` for the full pattern.
|
||||
|
||||
## Instance.bind — ALS for native callbacks
|
||||
|
||||
`Instance.bind(fn)` captures the current Instance AsyncLocalStorage context and restores it synchronously when called.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.3.2",
|
||||
"version": "1.3.3",
|
||||
"name": "opencode",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -63,6 +63,32 @@ console.log(`Loaded ${migrations.length} migrations`)
|
||||
const singleFlag = process.argv.includes("--single")
|
||||
const baselineFlag = process.argv.includes("--baseline")
|
||||
const skipInstall = process.argv.includes("--skip-install")
|
||||
const skipEmbedWebUi = process.argv.includes("--skip-embed-web-ui")
|
||||
|
||||
const createEmbeddedWebUIBundle = async () => {
|
||||
console.log(`Building Web UI to embed in the binary`)
|
||||
const appDir = path.join(import.meta.dirname, "../../app")
|
||||
const dist = path.join(appDir, "dist")
|
||||
await $`bun run --cwd ${appDir} build`
|
||||
const files = (await Array.fromAsync(new Bun.Glob("**/*").scan({ cwd: dist })))
|
||||
.map((file) => file.replaceAll("\\", "/"))
|
||||
.sort()
|
||||
const imports = files.map((file, i) => {
|
||||
const spec = path.relative(dir, path.join(dist, file)).replaceAll("\\", "/")
|
||||
return `import file_${i} from ${JSON.stringify(spec.startsWith(".") ? spec : `./${spec}`)} with { type: "file" };`
|
||||
})
|
||||
const entries = files.map((file, i) => ` ${JSON.stringify(file)}: file_${i},`)
|
||||
return [
|
||||
`// Import all files as file_$i with type: "file"`,
|
||||
...imports,
|
||||
`// Export with original mappings`,
|
||||
`export default {`,
|
||||
...entries,
|
||||
`}`,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
const embeddedFileMap = skipEmbedWebUi ? null : await createEmbeddedWebUIBundle()
|
||||
|
||||
const allTargets: {
|
||||
os: string
|
||||
@@ -192,7 +218,10 @@ for (const item of targets) {
|
||||
execArgv: [`--user-agent=opencode/${Script.version}`, "--use-system-ca", "--"],
|
||||
windows: {},
|
||||
},
|
||||
entrypoints: ["./src/index.ts", parserWorker, workerPath],
|
||||
files: {
|
||||
...(embeddedFileMap ? { "opencode-web-ui.gen.ts": embeddedFileMap } : {}),
|
||||
},
|
||||
entrypoints: ["./src/index.ts", parserWorker, workerPath, ...(embeddedFileMap ? ["opencode-web-ui.gen.ts"] : [])],
|
||||
define: {
|
||||
OPENCODE_VERSION: `'${Script.version}'`,
|
||||
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
|
||||
|
||||
@@ -8,8 +8,8 @@ Use `InstanceState` (from `src/effect/instance-state.ts`) for services that need
|
||||
|
||||
Use `makeRuntime` (from `src/effect/run-service.ts`) to create a per-service `ManagedRuntime` that lazily initializes and shares layers via a global `memoMap`. Returns `{ runPromise, runFork, runCallback }`.
|
||||
|
||||
- Global services (no per-directory state): Account, Auth, Installation, Truncate
|
||||
- Instance-scoped (per-directory state via InstanceState): File, FileTime, FileWatcher, Format, Permission, Question, Skill, Snapshot, Vcs, ProviderAuth
|
||||
- Global services (no per-directory state): Account, Auth, AppFileSystem, Installation, Truncate, Worktree
|
||||
- Instance-scoped (per-directory state via InstanceState): Agent, Bus, Command, Config, File, FileTime, FileWatcher, Format, LSP, MCP, Permission, Plugin, ProviderAuth, Pty, Question, SessionStatus, Skill, Snapshot, ToolRegistry, Vcs
|
||||
|
||||
Rule of thumb: if two open directories should not share one copy of the service, it needs `InstanceState`.
|
||||
|
||||
@@ -121,6 +121,32 @@ yield *
|
||||
|
||||
The key insight: don't split init into a separate method with a `started` flag. Put everything in the `InstanceState.make` closure and let `ScopedCache` handle the run-once semantics.
|
||||
|
||||
## Effect.cached for deduplication
|
||||
|
||||
Use `Effect.cached` when multiple concurrent callers should share a single in-flight computation. It memoizes the result and deduplicates concurrent fibers — second caller joins the first caller's fiber instead of starting a new one.
|
||||
|
||||
```ts
|
||||
// Inside the layer — yield* to initialize the memo
|
||||
let cached = yield * Effect.cached(loadExpensive())
|
||||
|
||||
const get = Effect.fn("Foo.get")(function* () {
|
||||
return yield* cached // concurrent callers share the same fiber
|
||||
})
|
||||
|
||||
// To invalidate: swap in a fresh memo
|
||||
const invalidate = Effect.fn("Foo.invalidate")(function* () {
|
||||
cached = yield* Effect.cached(loadExpensive())
|
||||
})
|
||||
```
|
||||
|
||||
Prefer `Effect.cached` over these patterns:
|
||||
|
||||
- Storing a `Fiber.Fiber | undefined` with manual check-and-fork (e.g. `file/index.ts` `ensure`)
|
||||
- Storing a `Promise<void>` task for deduplication (e.g. `skill/index.ts` `ensure`)
|
||||
- `let cached: X | undefined` with check-and-load (races when two callers see `undefined` before either resolves)
|
||||
|
||||
`Effect.cached` handles the run-once + concurrent-join semantics automatically. For invalidatable caches, reassign with `yield* Effect.cached(...)` — the old memo is discarded.
|
||||
|
||||
## Scheduled Tasks
|
||||
|
||||
For loops or periodic work, use `Effect.repeat` or `Effect.schedule` with `Effect.forkScoped` in the layer definition.
|
||||
@@ -155,36 +181,39 @@ That is fine for leaf files like `schema.ts`. Keep the service surface in the ow
|
||||
Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
||||
|
||||
- [x] `Account` — `account/index.ts`
|
||||
- [x] `Agent` — `agent/agent.ts`
|
||||
- [x] `AppFileSystem` — `filesystem/index.ts`
|
||||
- [x] `Auth` — `auth/index.ts` (uses `zod()` helper for Schema→Zod interop)
|
||||
- [x] `Bus` — `bus/index.ts`
|
||||
- [x] `Command` — `command/index.ts`
|
||||
- [x] `Config` — `config/config.ts`
|
||||
- [x] `Discovery` — `skill/discovery.ts` (dependency-only layer, no standalone runtime)
|
||||
- [x] `File` — `file/index.ts`
|
||||
- [x] `FileTime` — `file/time.ts`
|
||||
- [x] `FileWatcher` — `file/watcher.ts`
|
||||
- [x] `Format` — `format/index.ts`
|
||||
- [x] `Installation` — `installation/index.ts`
|
||||
- [x] `LSP` — `lsp/index.ts`
|
||||
- [x] `MCP` — `mcp/index.ts`
|
||||
- [x] `McpAuth` — `mcp/auth.ts`
|
||||
- [x] `Permission` — `permission/index.ts`
|
||||
- [x] `Plugin` — `plugin/index.ts`
|
||||
- [x] `Project` — `project/project.ts`
|
||||
- [x] `ProviderAuth` — `provider/auth.ts`
|
||||
- [x] `Pty` — `pty/index.ts`
|
||||
- [x] `Question` — `question/index.ts`
|
||||
- [x] `SessionStatus` — `session/status.ts`
|
||||
- [x] `Skill` — `skill/index.ts`
|
||||
- [x] `Snapshot` — `snapshot/index.ts`
|
||||
- [x] `ToolRegistry` — `tool/registry.ts`
|
||||
- [x] `Truncate` — `tool/truncate.ts`
|
||||
- [x] `Vcs` — `project/vcs.ts`
|
||||
- [x] `Discovery` — `skill/discovery.ts`
|
||||
- [x] `SessionStatus`
|
||||
- [x] `Worktree` — `worktree/index.ts`
|
||||
|
||||
Still open and likely worth migrating:
|
||||
|
||||
- [x] `Plugin`
|
||||
- [x] `ToolRegistry`
|
||||
- [ ] `Pty`
|
||||
- [x] `Worktree`
|
||||
- [x] `Bus`
|
||||
- [x] `Command`
|
||||
- [ ] `Config`
|
||||
- [ ] `Session`
|
||||
- [ ] `SessionProcessor`
|
||||
- [ ] `SessionPrompt`
|
||||
- [ ] `SessionCompaction`
|
||||
- [ ] `Provider`
|
||||
- [x] `Project`
|
||||
- [ ] `LSP`
|
||||
- [x] `MCP`
|
||||
|
||||
@@ -72,13 +72,14 @@ export namespace Agent {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = () => Effect.promise(() => Config.get())
|
||||
const config = yield* Config.Service
|
||||
const auth = yield* Auth.Service
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Agent.state")(function* (ctx) {
|
||||
const cfg = yield* config()
|
||||
const skillDirs = yield* Effect.promise(() => Skill.dirs())
|
||||
const cfg = yield* config.get()
|
||||
const skillDirs = yield* skill.dirs()
|
||||
const whitelistedDirs = [Truncate.GLOB, ...skillDirs.map((dir) => path.join(dir, "*"))]
|
||||
|
||||
const defaults = Permission.fromConfig({
|
||||
@@ -281,7 +282,7 @@ export namespace Agent {
|
||||
})
|
||||
|
||||
const list = Effect.fnUntraced(function* () {
|
||||
const cfg = yield* config()
|
||||
const cfg = yield* config.get()
|
||||
return pipe(
|
||||
agents,
|
||||
values(),
|
||||
@@ -293,7 +294,7 @@ export namespace Agent {
|
||||
})
|
||||
|
||||
const defaultAgent = Effect.fnUntraced(function* () {
|
||||
const c = yield* config()
|
||||
const c = yield* config.get()
|
||||
if (c.default_agent) {
|
||||
const agent = agents[c.default_agent]
|
||||
if (!agent) throw new Error(`default agent "${c.default_agent}" not found`)
|
||||
@@ -328,7 +329,7 @@ export namespace Agent {
|
||||
description: string
|
||||
model?: { providerID: ProviderID; modelID: ModelID }
|
||||
}) {
|
||||
const cfg = yield* config()
|
||||
const cfg = yield* config.get()
|
||||
const model = input.model ?? (yield* Effect.promise(() => Provider.defaultModel()))
|
||||
const resolved = yield* Effect.promise(() => Provider.getModel(model.providerID, model.modelID))
|
||||
const language = yield* Effect.promise(() => Provider.getLanguage(resolved))
|
||||
@@ -391,7 +392,11 @@ export namespace Agent {
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Auth.layer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Auth.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
|
||||
@@ -6,11 +6,13 @@ import { InstanceBootstrap } from "@/project/bootstrap"
|
||||
import { Rpc } from "@/util/rpc"
|
||||
import { upgrade } from "@/cli/upgrade"
|
||||
import { Config } from "@/config/config"
|
||||
import { Bus } from "@/bus"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { createOpencodeClient, type Event } from "@opencode-ai/sdk/v2"
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { writeHeapSnapshot } from "node:v8"
|
||||
import { WorkspaceID } from "@/control-plane/schema"
|
||||
|
||||
await Log.init({
|
||||
print: process.argv.includes("--print-logs"),
|
||||
@@ -50,39 +52,49 @@ const startEventStream = (input: { directory: string; workspaceID?: string }) =>
|
||||
eventStream.abort = abort
|
||||
const signal = abort.signal
|
||||
|
||||
const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
const auth = getAuthorizationHeader()
|
||||
if (auth) request.headers.set("Authorization", auth)
|
||||
return Server.Default().fetch(request)
|
||||
}) as typeof globalThis.fetch
|
||||
|
||||
const sdk = createOpencodeClient({
|
||||
baseUrl: "http://opencode.internal",
|
||||
directory: input.directory,
|
||||
experimental_workspaceID: input.workspaceID,
|
||||
fetch: fetchFn,
|
||||
signal,
|
||||
})
|
||||
|
||||
;(async () => {
|
||||
while (!signal.aborted) {
|
||||
const events = await Promise.resolve(
|
||||
sdk.event.subscribe(
|
||||
{},
|
||||
{
|
||||
signal,
|
||||
},
|
||||
),
|
||||
).catch(() => undefined)
|
||||
const shouldReconnect = await Instance.provide({
|
||||
directory: input.directory,
|
||||
init: InstanceBootstrap,
|
||||
fn: () =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
Rpc.emit("event", {
|
||||
type: "server.connected",
|
||||
properties: {},
|
||||
} satisfies Event)
|
||||
|
||||
if (!events) {
|
||||
await sleep(250)
|
||||
continue
|
||||
}
|
||||
let settled = false
|
||||
const settle = (value: boolean) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
signal.removeEventListener("abort", onAbort)
|
||||
unsub()
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
for await (const event of events.stream) {
|
||||
Rpc.emit("event", event as Event)
|
||||
const unsub = Bus.subscribeAll((event) => {
|
||||
Rpc.emit("event", event as Event)
|
||||
if (event.type === Bus.InstanceDisposed.type) {
|
||||
settle(true)
|
||||
}
|
||||
})
|
||||
|
||||
const onAbort = () => {
|
||||
settle(false)
|
||||
}
|
||||
|
||||
signal.addEventListener("abort", onAbort, { once: true })
|
||||
}),
|
||||
}).catch((error) => {
|
||||
Log.Default.error("event stream subscribe error", {
|
||||
error: error instanceof Error ? error.message : error,
|
||||
})
|
||||
return false
|
||||
})
|
||||
|
||||
if (!shouldReconnect || signal.aborted) {
|
||||
break
|
||||
}
|
||||
|
||||
if (!signal.aborted) {
|
||||
@@ -137,8 +149,7 @@ export const rpc = {
|
||||
})
|
||||
},
|
||||
async reload() {
|
||||
Config.global.reset()
|
||||
await Instance.disposeAll()
|
||||
await Config.invalidate(true)
|
||||
},
|
||||
async setWorkspace(input: { workspaceID?: string }) {
|
||||
startEventStream({ directory: process.cwd(), workspaceID: input.workspaceID })
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { cmd } from "./cmd"
|
||||
import { withNetworkOptions, resolveNetworkOptions } from "../network"
|
||||
import { WorkspaceServer } from "../../control-plane/workspace-server/server"
|
||||
|
||||
export const WorkspaceServeCommand = cmd({
|
||||
command: "workspace-serve",
|
||||
builder: (yargs) => withNetworkOptions(yargs),
|
||||
describe: "starts a remote workspace event server",
|
||||
handler: async (args) => {
|
||||
const opts = await resolveNetworkOptions(args)
|
||||
const server = WorkspaceServer.Listen(opts)
|
||||
console.log(`workspace event server listening on http://${server.hostname}:${server.port}/event`)
|
||||
await new Promise(() => {})
|
||||
await server.stop()
|
||||
},
|
||||
})
|
||||
@@ -37,7 +37,7 @@ export function withNetworkOptions<T>(yargs: Argv<T>) {
|
||||
}
|
||||
|
||||
export async function resolveNetworkOptions(args: NetworkOptions) {
|
||||
const config = await Config.global()
|
||||
const config = await Config.getGlobal()
|
||||
const portExplicitlySet = process.argv.includes("--port")
|
||||
const hostnameExplicitlySet = process.argv.includes("--hostname")
|
||||
const mdnsExplicitlySet = process.argv.includes("--mdns")
|
||||
|
||||
@@ -4,7 +4,7 @@ import { Flag } from "@/flag/flag"
|
||||
import { Installation } from "@/installation"
|
||||
|
||||
export async function upgrade() {
|
||||
const config = await Config.global()
|
||||
const config = await Config.getGlobal()
|
||||
const method = await Installation.method()
|
||||
const latest = await Installation.latest(method).catch(() => {})
|
||||
if (!latest) return
|
||||
|
||||
@@ -75,8 +75,12 @@ export namespace Command {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const mcp = yield* MCP.Service
|
||||
const skill = yield* Skill.Service
|
||||
|
||||
const init = Effect.fn("Command.state")(function* (ctx) {
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfg = yield* config.get()
|
||||
const commands: Record<string, Info> = {}
|
||||
|
||||
commands[Default.INIT] = {
|
||||
@@ -114,7 +118,7 @@ export namespace Command {
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, prompt] of Object.entries(yield* Effect.promise(() => MCP.prompts()))) {
|
||||
for (const [name, prompt] of Object.entries(yield* mcp.prompts())) {
|
||||
commands[name] = {
|
||||
name,
|
||||
source: "mcp",
|
||||
@@ -139,14 +143,14 @@ export namespace Command {
|
||||
}
|
||||
}
|
||||
|
||||
for (const skill of yield* Effect.promise(() => Skill.all())) {
|
||||
if (commands[skill.name]) continue
|
||||
commands[skill.name] = {
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
for (const item of yield* skill.all()) {
|
||||
if (commands[item.name]) continue
|
||||
commands[item.name] = {
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
source: "skill",
|
||||
get template() {
|
||||
return skill.content
|
||||
return item.content
|
||||
},
|
||||
hints: [],
|
||||
}
|
||||
@@ -173,7 +177,13 @@ export namespace Command {
|
||||
}),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(MCP.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get(name: string) {
|
||||
return runPromise((svc) => svc.get(name))
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Log } from "../util/log"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import { pathToFileURL } from "url"
|
||||
import { createRequire } from "module"
|
||||
import os from "os"
|
||||
import z from "zod"
|
||||
import { ModelsDev } from "../provider/models"
|
||||
import { mergeDeep, pipe, unique } from "remeda"
|
||||
import { Global } from "../global"
|
||||
import fs from "fs/promises"
|
||||
import { lazy } from "../util/lazy"
|
||||
import fsNode from "fs/promises"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Auth } from "../auth"
|
||||
@@ -20,7 +19,7 @@ import {
|
||||
parse as parseJsonc,
|
||||
printParseErrorCode,
|
||||
} from "jsonc-parser"
|
||||
import { Instance } from "../project/instance"
|
||||
import { Instance, type InstanceContext } from "../project/instance"
|
||||
import { LSPServer } from "../lsp/server"
|
||||
import { BunProc } from "@/bun"
|
||||
import { Installation } from "@/installation"
|
||||
@@ -38,6 +37,10 @@ import { ConfigPaths } from "./paths"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { Process } from "@/util/process"
|
||||
import { Lock } from "@/util/lock"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
|
||||
|
||||
export namespace Config {
|
||||
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
|
||||
@@ -75,201 +78,6 @@ export namespace Config {
|
||||
return merged
|
||||
}
|
||||
|
||||
export const state = Instance.state(async () => {
|
||||
const auth = await Auth.all()
|
||||
|
||||
// Config loading order (low -> high precedence): https://opencode.ai/docs/config#precedence-order
|
||||
// 1) Remote .well-known/opencode (org defaults)
|
||||
// 2) Global config (~/.config/opencode/opencode.json{,c})
|
||||
// 3) Custom config (OPENCODE_CONFIG)
|
||||
// 4) Project config (opencode.json{,c})
|
||||
// 5) .opencode directories (.opencode/agents/, .opencode/commands/, .opencode/plugins/, .opencode/opencode.json{,c})
|
||||
// 6) Inline config (OPENCODE_CONFIG_CONTENT)
|
||||
// Managed config directory is enterprise-only and always overrides everything above.
|
||||
let result: Info = {}
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
const url = key.replace(/\/+$/, "")
|
||||
process.env[value.key] = value.token
|
||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||
const response = await fetch(`${url}/.well-known/opencode`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
||||
}
|
||||
const wellknown = (await response.json()) as any
|
||||
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,
|
||||
await load(JSON.stringify(remoteConfig), {
|
||||
dir: path.dirname(`${url}/.well-known/opencode`),
|
||||
source: `${url}/.well-known/opencode`,
|
||||
}),
|
||||
)
|
||||
log.debug("loaded remote config from well-known", { url })
|
||||
}
|
||||
}
|
||||
|
||||
// Global user config overrides remote config.
|
||||
result = mergeConfigConcatArrays(result, await global())
|
||||
|
||||
// Custom config path overrides global config.
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
result = mergeConfigConcatArrays(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))
|
||||
}
|
||||
}
|
||||
|
||||
result.agent = result.agent || {}
|
||||
result.mode = result.mode || {}
|
||||
result.plugin = result.plugin || []
|
||||
|
||||
const directories = await ConfigPaths.directories(Instance.directory, Instance.worktree)
|
||||
|
||||
// .opencode directory config overrides (project and global) config sources.
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
const deps = []
|
||||
|
||||
for (const dir of unique(directories)) {
|
||||
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)))
|
||||
// to satisfy the type checker
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
result.plugin ??= []
|
||||
}
|
||||
}
|
||||
|
||||
deps.push(
|
||||
iife(async () => {
|
||||
const shouldInstall = await needsInstall(dir)
|
||||
if (shouldInstall) await installDependencies(dir)
|
||||
}),
|
||||
)
|
||||
|
||||
result.command = mergeDeep(result.command ?? {}, await loadCommand(dir))
|
||||
result.agent = mergeDeep(result.agent, await loadAgent(dir))
|
||||
result.agent = mergeDeep(result.agent, await loadMode(dir))
|
||||
result.plugin.push(...(await loadPlugin(dir)))
|
||||
}
|
||||
|
||||
// Inline config content overrides all non-managed config sources.
|
||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
await load(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||
dir: Instance.directory,
|
||||
source: "OPENCODE_CONFIG_CONTENT",
|
||||
}),
|
||||
)
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
const active = await Account.active()
|
||||
if (active?.active_org_id) {
|
||||
try {
|
||||
const [config, token] = await Promise.all([
|
||||
Account.config(active.id, active.active_org_id),
|
||||
Account.token(active.id),
|
||||
])
|
||||
if (token) {
|
||||
process.env["OPENCODE_CONSOLE_TOKEN"] = token
|
||||
Env.set("OPENCODE_CONSOLE_TOKEN", token)
|
||||
}
|
||||
|
||||
if (config) {
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
await load(JSON.stringify(config), {
|
||||
dir: path.dirname(`${active.url}/api/config`),
|
||||
source: `${active.url}/api/config`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
} catch (err: any) {
|
||||
log.debug("failed to fetch remote account config", { error: err?.message ?? err })
|
||||
}
|
||||
}
|
||||
|
||||
// Load managed config files last (highest priority) - enterprise admin-controlled
|
||||
// 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)) {
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
result = mergeConfigConcatArrays(result, await loadFile(path.join(managedDir, file)))
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate deprecated mode field to agent field
|
||||
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||
result.agent = mergeDeep(result.agent ?? {}, {
|
||||
[name]: {
|
||||
...mode,
|
||||
mode: "primary" as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
}
|
||||
|
||||
// Backwards compatibility: legacy top-level `tools` config
|
||||
if (result.tools) {
|
||||
const perms: Record<string, Config.PermissionAction> = {}
|
||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||
const action: Config.PermissionAction = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||
perms.edit = action
|
||||
continue
|
||||
}
|
||||
perms[tool] = action
|
||||
}
|
||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||
}
|
||||
|
||||
if (!result.username) result.username = os.userInfo().username
|
||||
|
||||
// Handle migration from autoshare to share field
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
}
|
||||
|
||||
// Apply flag overrides for compaction settings
|
||||
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
||||
result.compaction = { ...result.compaction, auto: false }
|
||||
}
|
||||
if (Flag.OPENCODE_DISABLE_PRUNE) {
|
||||
result.compaction = { ...result.compaction, prune: false }
|
||||
}
|
||||
|
||||
result.plugin = deduplicatePlugins(result.plugin ?? [])
|
||||
|
||||
return {
|
||||
config: result,
|
||||
directories,
|
||||
deps,
|
||||
}
|
||||
})
|
||||
|
||||
export async function waitForDependencies() {
|
||||
const deps = await state().then((x) => x.deps)
|
||||
await Promise.all(deps)
|
||||
}
|
||||
|
||||
export async function installDependencies(dir: string) {
|
||||
const pkg = path.join(dir, "package.json")
|
||||
const targetVersion = Installation.isLocal() ? "*" : Installation.VERSION
|
||||
@@ -325,7 +133,7 @@ export namespace Config {
|
||||
|
||||
async function isWritable(dir: string) {
|
||||
try {
|
||||
await fs.access(dir, constants.W_OK)
|
||||
await fsNode.access(dir, constants.W_OK)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
@@ -1234,123 +1042,23 @@ export namespace Config {
|
||||
|
||||
export type Info = z.output<typeof Info>
|
||||
|
||||
export const global = lazy(async () => {
|
||||
let result: Info = pipe(
|
||||
{},
|
||||
mergeDeep(await loadFile(path.join(Global.Path.config, "config.json"))),
|
||||
mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.json"))),
|
||||
mergeDeep(await loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
||||
)
|
||||
|
||||
const legacy = path.join(Global.Path.config, "config")
|
||||
if (existsSync(legacy)) {
|
||||
await import(pathToFileURL(legacy).href, {
|
||||
with: {
|
||||
type: "toml",
|
||||
},
|
||||
})
|
||||
.then(async (mod) => {
|
||||
const { provider, model, ...rest } = mod.default
|
||||
if (provider && model) result.model = `${provider}/${model}`
|
||||
result["$schema"] = "https://opencode.ai/config.json"
|
||||
result = mergeDeep(result, rest)
|
||||
await Filesystem.writeJson(path.join(Global.Path.config, "config.json"), result)
|
||||
await fs.unlink(legacy)
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
export const { readFile } = ConfigPaths
|
||||
|
||||
async function loadFile(filepath: string): Promise<Info> {
|
||||
log.info("loading", { path: filepath })
|
||||
const text = await readFile(filepath)
|
||||
if (!text) return {}
|
||||
return load(text, { path: filepath })
|
||||
type State = {
|
||||
config: Info
|
||||
directories: string[]
|
||||
deps: Promise<void>[]
|
||||
}
|
||||
|
||||
async function load(text: string, options: { path: string } | { dir: string; source: string }) {
|
||||
const original = text
|
||||
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
|
||||
})()
|
||||
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema && isFile) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||
await Filesystem.write(options.path, updated).catch(() => {})
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin && isFile) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
const plugin = data.plugin[i]
|
||||
try {
|
||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||
} catch (e) {
|
||||
try {
|
||||
// import.meta.resolve sometimes fails with newly created node_modules
|
||||
const require = createRequire(options.path)
|
||||
const resolvedPath = require.resolve(plugin)
|
||||
data.plugin[i] = pathToFileURL(resolvedPath).href
|
||||
} catch {
|
||||
// Ignore, plugin might be a generic string identifier like "mcp-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
throw new InvalidError({
|
||||
path: source,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
}
|
||||
export const { JsonError, InvalidError } = ConfigPaths
|
||||
|
||||
export const ConfigDirectoryTypoError = NamedError.create(
|
||||
"ConfigDirectoryTypoError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
dir: z.string(),
|
||||
suggestion: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
export async function get() {
|
||||
return state().then((x) => x.config)
|
||||
export interface Interface {
|
||||
readonly get: () => Effect.Effect<Info>
|
||||
readonly getGlobal: () => Effect.Effect<Info>
|
||||
readonly update: (config: Info) => Effect.Effect<void>
|
||||
readonly updateGlobal: (config: Info) => Effect.Effect<Info>
|
||||
readonly invalidate: (wait?: boolean) => Effect.Effect<void>
|
||||
readonly directories: () => Effect.Effect<string[]>
|
||||
readonly waitForDependencies: () => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export async function getGlobal() {
|
||||
return global()
|
||||
}
|
||||
|
||||
export async function update(config: Info) {
|
||||
const filepath = path.join(Instance.directory, "config.json")
|
||||
const existing = await loadFile(filepath)
|
||||
await Filesystem.writeJson(filepath, mergeDeep(existing, config))
|
||||
await Instance.dispose()
|
||||
}
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Config") {}
|
||||
|
||||
function globalConfigFile() {
|
||||
const candidates = ["opencode.jsonc", "opencode.json", "config.json"].map((file) =>
|
||||
@@ -1417,47 +1125,425 @@ export namespace Config {
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateGlobal(config: Info) {
|
||||
const filepath = globalConfigFile()
|
||||
const before = await Filesystem.readText(filepath).catch((err: any) => {
|
||||
if (err.code === "ENOENT") return "{}"
|
||||
throw new JsonError({ path: filepath }, { cause: err })
|
||||
})
|
||||
export const { JsonError, InvalidError } = ConfigPaths
|
||||
|
||||
const next = await (async () => {
|
||||
if (!filepath.endsWith(".jsonc")) {
|
||||
const existing = parseConfig(before, filepath)
|
||||
const merged = mergeDeep(existing, config)
|
||||
await Filesystem.writeJson(filepath, merged)
|
||||
return merged
|
||||
}
|
||||
export const ConfigDirectoryTypoError = NamedError.create(
|
||||
"ConfigDirectoryTypoError",
|
||||
z.object({
|
||||
path: z.string(),
|
||||
dir: z.string(),
|
||||
suggestion: z.string(),
|
||||
}),
|
||||
)
|
||||
|
||||
const updated = patchJsonc(before, config)
|
||||
const merged = parseConfig(updated, filepath)
|
||||
await Filesystem.write(filepath, updated)
|
||||
return merged
|
||||
})()
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Auth.Service | Account.Service> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const authSvc = yield* Auth.Service
|
||||
const accountSvc = yield* Account.Service
|
||||
|
||||
global.reset()
|
||||
|
||||
void Instance.disposeAll()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
const readConfigFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
return yield* fs.readFileString(filepath).pipe(
|
||||
Effect.catchIf(
|
||||
(e) => e.reason._tag === "NotFound",
|
||||
() => Effect.succeed(undefined),
|
||||
),
|
||||
Effect.orDie,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
return next
|
||||
const loadConfig = Effect.fnUntraced(function* (
|
||||
text: string,
|
||||
options: { path: string } | { dir: string; source: string },
|
||||
) {
|
||||
const original = text
|
||||
const source = "path" in options ? options.path : options.source
|
||||
const isFile = "path" in options
|
||||
const data = yield* Effect.promise(() =>
|
||||
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
|
||||
})()
|
||||
|
||||
const parsed = Info.safeParse(normalized)
|
||||
if (parsed.success) {
|
||||
if (!parsed.data.$schema && isFile) {
|
||||
parsed.data.$schema = "https://opencode.ai/config.json"
|
||||
const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",')
|
||||
yield* fs.writeFileString(options.path, updated).pipe(Effect.catch(() => Effect.void))
|
||||
}
|
||||
const data = parsed.data
|
||||
if (data.plugin && isFile) {
|
||||
for (let i = 0; i < data.plugin.length; i++) {
|
||||
const plugin = data.plugin[i]
|
||||
try {
|
||||
data.plugin[i] = import.meta.resolve!(plugin, options.path)
|
||||
} catch (e) {
|
||||
try {
|
||||
const require = createRequire(options.path)
|
||||
const resolvedPath = require.resolve(plugin)
|
||||
data.plugin[i] = pathToFileURL(resolvedPath).href
|
||||
} catch {
|
||||
// Ignore, plugin might be a generic string identifier like "mcp-server"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
throw new InvalidError({
|
||||
path: source,
|
||||
issues: parsed.error.issues,
|
||||
})
|
||||
})
|
||||
|
||||
const loadFile = Effect.fnUntraced(function* (filepath: string) {
|
||||
log.info("loading", { path: filepath })
|
||||
const text = yield* readConfigFile(filepath)
|
||||
if (!text) return {} as Info
|
||||
return yield* loadConfig(text, { path: filepath })
|
||||
})
|
||||
|
||||
const loadGlobal = Effect.fnUntraced(function* () {
|
||||
let result: Info = pipe(
|
||||
{},
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "config.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.json"))),
|
||||
mergeDeep(yield* loadFile(path.join(Global.Path.config, "opencode.jsonc"))),
|
||||
)
|
||||
|
||||
const legacy = path.join(Global.Path.config, "config")
|
||||
if (existsSync(legacy)) {
|
||||
yield* Effect.promise(() =>
|
||||
import(pathToFileURL(legacy).href, { with: { type: "toml" } })
|
||||
.then(async (mod) => {
|
||||
const { provider, model, ...rest } = mod.default
|
||||
if (provider && model) result.model = `${provider}/${model}`
|
||||
result["$schema"] = "https://opencode.ai/config.json"
|
||||
result = mergeDeep(result, rest)
|
||||
await fsNode.writeFile(path.join(Global.Path.config, "config.json"), JSON.stringify(result, null, 2))
|
||||
await fsNode.unlink(legacy)
|
||||
})
|
||||
.catch(() => {}),
|
||||
)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const [cachedGlobal, invalidateGlobal] = yield* Effect.cachedInvalidateWithTTL(
|
||||
loadGlobal().pipe(
|
||||
Effect.tapError((error) =>
|
||||
Effect.sync(() => log.error("failed to load global config, using defaults", { error: String(error) })),
|
||||
),
|
||||
Effect.orElseSucceed((): Info => ({})),
|
||||
),
|
||||
Duration.infinity,
|
||||
)
|
||||
|
||||
const getGlobal = Effect.fn("Config.getGlobal")(function* () {
|
||||
return yield* cachedGlobal
|
||||
})
|
||||
|
||||
const loadInstanceState = Effect.fnUntraced(function* (ctx: InstanceContext) {
|
||||
const auth = yield* authSvc.all().pipe(Effect.orDie)
|
||||
|
||||
let result: Info = {}
|
||||
for (const [key, value] of Object.entries(auth)) {
|
||||
if (value.type === "wellknown") {
|
||||
const url = key.replace(/\/+$/, "")
|
||||
process.env[value.key] = value.token
|
||||
log.debug("fetching remote config", { url: `${url}/.well-known/opencode` })
|
||||
const response = yield* Effect.promise(() => fetch(`${url}/.well-known/opencode`))
|
||||
if (!response.ok) {
|
||||
throw new Error(`failed to fetch remote config from ${url}: ${response.status}`)
|
||||
}
|
||||
const wellknown = (yield* Effect.promise(() => response.json())) as any
|
||||
const remoteConfig = wellknown.config ?? {}
|
||||
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
yield* loadConfig(JSON.stringify(remoteConfig), {
|
||||
dir: path.dirname(`${url}/.well-known/opencode`),
|
||||
source: `${url}/.well-known/opencode`,
|
||||
}),
|
||||
)
|
||||
log.debug("loaded remote config from well-known", { url })
|
||||
}
|
||||
}
|
||||
|
||||
result = mergeConfigConcatArrays(result, yield* getGlobal())
|
||||
|
||||
if (Flag.OPENCODE_CONFIG) {
|
||||
result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG))
|
||||
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
|
||||
}
|
||||
|
||||
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
|
||||
for (const file of yield* Effect.promise(() =>
|
||||
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
|
||||
)) {
|
||||
result = mergeConfigConcatArrays(result, yield* loadFile(file))
|
||||
}
|
||||
}
|
||||
|
||||
result.agent = result.agent || {}
|
||||
result.mode = result.mode || {}
|
||||
result.plugin = result.plugin || []
|
||||
|
||||
const directories = yield* Effect.promise(() => ConfigPaths.directories(ctx.directory, ctx.worktree))
|
||||
|
||||
if (Flag.OPENCODE_CONFIG_DIR) {
|
||||
log.debug("loading config from OPENCODE_CONFIG_DIR", { path: Flag.OPENCODE_CONFIG_DIR })
|
||||
}
|
||||
|
||||
const deps: Promise<void>[] = []
|
||||
|
||||
for (const dir of unique(directories)) {
|
||||
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, yield* loadFile(path.join(dir, file)))
|
||||
result.agent ??= {}
|
||||
result.mode ??= {}
|
||||
result.plugin ??= []
|
||||
}
|
||||
}
|
||||
|
||||
deps.push(
|
||||
iife(async () => {
|
||||
const shouldInstall = await needsInstall(dir)
|
||||
if (shouldInstall) await installDependencies(dir)
|
||||
}),
|
||||
)
|
||||
|
||||
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(dir)))
|
||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
|
||||
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
|
||||
result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
|
||||
}
|
||||
|
||||
if (process.env.OPENCODE_CONFIG_CONTENT) {
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
|
||||
dir: ctx.directory,
|
||||
source: "OPENCODE_CONFIG_CONTENT",
|
||||
}),
|
||||
)
|
||||
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
|
||||
}
|
||||
|
||||
const active = Option.getOrUndefined(yield* accountSvc.active().pipe(Effect.orDie))
|
||||
if (active?.active_org_id) {
|
||||
yield* Effect.gen(function* () {
|
||||
const [configOpt, tokenOpt] = yield* Effect.all(
|
||||
[accountSvc.config(active.id, active.active_org_id!), accountSvc.token(active.id)],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const token = Option.getOrUndefined(tokenOpt)
|
||||
if (token) {
|
||||
process.env["OPENCODE_CONSOLE_TOKEN"] = token
|
||||
Env.set("OPENCODE_CONSOLE_TOKEN", token)
|
||||
}
|
||||
|
||||
const config = Option.getOrUndefined(configOpt)
|
||||
if (config) {
|
||||
result = mergeConfigConcatArrays(
|
||||
result,
|
||||
yield* loadConfig(JSON.stringify(config), {
|
||||
dir: path.dirname(`${active.url}/api/config`),
|
||||
source: `${active.url}/api/config`,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}).pipe(
|
||||
Effect.catch((err) => {
|
||||
log.debug("failed to fetch remote account config", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
return Effect.void
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
if (existsSync(managedDir)) {
|
||||
for (const file of ["opencode.jsonc", "opencode.json"]) {
|
||||
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, mode] of Object.entries(result.mode ?? {})) {
|
||||
result.agent = mergeDeep(result.agent ?? {}, {
|
||||
[name]: {
|
||||
...mode,
|
||||
mode: "primary" as const,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
}
|
||||
|
||||
if (result.tools) {
|
||||
const perms: Record<string, Config.PermissionAction> = {}
|
||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||
const action: Config.PermissionAction = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch" || tool === "multiedit") {
|
||||
perms.edit = action
|
||||
continue
|
||||
}
|
||||
perms[tool] = action
|
||||
}
|
||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||
}
|
||||
|
||||
if (!result.username) result.username = os.userInfo().username
|
||||
|
||||
if (result.autoshare === true && !result.share) {
|
||||
result.share = "auto"
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_DISABLE_AUTOCOMPACT) {
|
||||
result.compaction = { ...result.compaction, auto: false }
|
||||
}
|
||||
if (Flag.OPENCODE_DISABLE_PRUNE) {
|
||||
result.compaction = { ...result.compaction, prune: false }
|
||||
}
|
||||
|
||||
result.plugin = deduplicatePlugins(result.plugin ?? [])
|
||||
|
||||
return {
|
||||
config: result,
|
||||
directories,
|
||||
deps,
|
||||
}
|
||||
})
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Config.state")(function* (ctx) {
|
||||
return yield* loadInstanceState(ctx)
|
||||
}),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Config.get")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.config)
|
||||
})
|
||||
|
||||
const directories = Effect.fn("Config.directories")(function* () {
|
||||
return yield* InstanceState.use(state, (s) => s.directories)
|
||||
})
|
||||
|
||||
const waitForDependencies = Effect.fn("Config.waitForDependencies")(function* () {
|
||||
yield* InstanceState.useEffect(state, (s) => Effect.promise(() => Promise.all(s.deps).then(() => undefined)))
|
||||
})
|
||||
|
||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||
const file = path.join(Instance.directory, "config.json")
|
||||
const existing = yield* loadFile(file)
|
||||
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
|
||||
yield* Effect.promise(() => Instance.dispose())
|
||||
})
|
||||
|
||||
const invalidate = Effect.fn("Config.invalidate")(function* (wait?: boolean) {
|
||||
yield* invalidateGlobal
|
||||
const task = Instance.disposeAll()
|
||||
.catch(() => undefined)
|
||||
.finally(() =>
|
||||
GlobalBus.emit("event", {
|
||||
directory: "global",
|
||||
payload: {
|
||||
type: Event.Disposed.type,
|
||||
properties: {},
|
||||
},
|
||||
}),
|
||||
)
|
||||
if (wait) yield* Effect.promise(() => task)
|
||||
else void task
|
||||
})
|
||||
|
||||
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
|
||||
const file = globalConfigFile()
|
||||
const before = (yield* readConfigFile(file)) ?? "{}"
|
||||
|
||||
let next: Info
|
||||
if (!file.endsWith(".jsonc")) {
|
||||
const existing = parseConfig(before, file)
|
||||
const merged = mergeDeep(existing, config)
|
||||
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
|
||||
next = merged
|
||||
} else {
|
||||
const updated = patchJsonc(before, config)
|
||||
next = parseConfig(updated, file)
|
||||
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
|
||||
}
|
||||
|
||||
yield* invalidate()
|
||||
return next
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
get,
|
||||
getGlobal,
|
||||
update,
|
||||
updateGlobal,
|
||||
invalidate,
|
||||
directories,
|
||||
waitForDependencies,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Auth.layer),
|
||||
Layer.provide(Account.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function get() {
|
||||
return runPromise((svc) => svc.get())
|
||||
}
|
||||
|
||||
export async function getGlobal() {
|
||||
return runPromise((svc) => svc.getGlobal())
|
||||
}
|
||||
|
||||
export async function update(config: Info) {
|
||||
return runPromise((svc) => svc.update(config))
|
||||
}
|
||||
|
||||
export async function updateGlobal(config: Info) {
|
||||
return runPromise((svc) => svc.updateGlobal(config))
|
||||
}
|
||||
|
||||
export async function invalidate(wait = false) {
|
||||
return runPromise((svc) => svc.invalidate(wait))
|
||||
}
|
||||
|
||||
export async function directories() {
|
||||
return state().then((x) => x.directories)
|
||||
return runPromise((svc) => svc.directories())
|
||||
}
|
||||
|
||||
export async function waitForDependencies() {
|
||||
return runPromise((svc) => svc.waitForDependencies())
|
||||
}
|
||||
}
|
||||
Filesystem.write
|
||||
Filesystem.write
|
||||
|
||||
@@ -33,13 +33,14 @@ export const WorktreeAdaptor: Adaptor = {
|
||||
await Worktree.remove({ directory: config.directory })
|
||||
},
|
||||
async fetch(info, input: RequestInfo | URL, init?: RequestInit) {
|
||||
const { Server } = await import("../../server/server")
|
||||
|
||||
const config = Config.parse(info)
|
||||
const { WorkspaceServer } = await import("../workspace-server/server")
|
||||
const url = input instanceof Request || input instanceof URL ? input : new URL(input, "http://opencode.internal")
|
||||
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined))
|
||||
headers.set("x-opencode-directory", config.directory)
|
||||
|
||||
const request = new Request(url, { ...init, headers })
|
||||
return WorkspaceServer.App().fetch(request)
|
||||
return Server.Default().fetch(request)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import { Context } from "../util/context"
|
||||
import type { WorkspaceID } from "./schema"
|
||||
|
||||
interface Context {
|
||||
workspaceID?: WorkspaceID
|
||||
}
|
||||
|
||||
const context = Context.create<Context>("workspace")
|
||||
|
||||
export const WorkspaceContext = {
|
||||
async provide<R>(input: { workspaceID?: WorkspaceID; fn: () => R }): Promise<R> {
|
||||
return context.provide({ workspaceID: input.workspaceID }, async () => {
|
||||
return input.fn()
|
||||
})
|
||||
},
|
||||
|
||||
get workspaceID() {
|
||||
try {
|
||||
return context.use().workspaceID
|
||||
} catch (e) {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,23 +1,38 @@
|
||||
import type { MiddlewareHandler } from "hono"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { getAdaptor } from "./adaptors"
|
||||
import { WorkspaceID } from "./schema"
|
||||
import { Workspace } from "./workspace"
|
||||
import { WorkspaceContext } from "./workspace-context"
|
||||
|
||||
// This middleware forwards all non-GET requests if the workspace is a
|
||||
// remote. The remote workspace needs to handle session mutations
|
||||
type Rule = { method?: string; path: string; exact?: boolean; action: "local" | "forward" }
|
||||
|
||||
const RULES: Array<Rule> = [
|
||||
{ path: "/session/status", action: "forward" },
|
||||
{ method: "GET", path: "/session", action: "local" },
|
||||
]
|
||||
|
||||
function local(method: string, path: string) {
|
||||
for (const rule of RULES) {
|
||||
if (rule.method && rule.method !== method) continue
|
||||
const match = rule.exact ? path === rule.path : path === rule.path || path.startsWith(rule.path + "/")
|
||||
if (match) return rule.action === "local"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function routeRequest(req: Request) {
|
||||
// Right now, we need to forward all requests to the workspace
|
||||
// because we don't have syncing. In the future all GET requests
|
||||
// which don't mutate anything will be handled locally
|
||||
//
|
||||
// if (req.method === "GET") return
|
||||
const url = new URL(req.url)
|
||||
const raw = url.searchParams.get("workspace") || req.headers.get("x-opencode-workspace")
|
||||
|
||||
if (!WorkspaceContext.workspaceID) return
|
||||
if (!raw) return
|
||||
|
||||
const workspace = await Workspace.get(WorkspaceContext.workspaceID)
|
||||
if (local(req.method, url.pathname)) return
|
||||
|
||||
const workspaceID = WorkspaceID.make(raw)
|
||||
|
||||
const workspace = await Workspace.get(workspaceID)
|
||||
if (!workspace) {
|
||||
return new Response(`Workspace not found: ${WorkspaceContext.workspaceID}`, {
|
||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||
status: 500,
|
||||
headers: {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
@@ -27,11 +42,14 @@ async function routeRequest(req: Request) {
|
||||
|
||||
const adaptor = await getAdaptor(workspace.type)
|
||||
|
||||
return adaptor.fetch(workspace, `${new URL(req.url).pathname}${new URL(req.url).search}`, {
|
||||
const headers = new Headers(req.headers)
|
||||
headers.delete("x-opencode-workspace")
|
||||
|
||||
return adaptor.fetch(workspace, `${url.pathname}${url.search}`, {
|
||||
method: req.method,
|
||||
body: req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer(),
|
||||
signal: req.signal,
|
||||
headers: req.headers,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
import { GlobalBus } from "../../bus/global"
|
||||
import { Hono } from "hono"
|
||||
import { streamSSE } from "hono/streaming"
|
||||
|
||||
export function WorkspaceServerRoutes() {
|
||||
return new Hono().get("/event", async (c) => {
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
const send = async (event: unknown) => {
|
||||
await stream.writeSSE({
|
||||
data: JSON.stringify(event),
|
||||
})
|
||||
}
|
||||
const handler = async (event: { directory?: string; payload: unknown }) => {
|
||||
await send(event.payload)
|
||||
}
|
||||
GlobalBus.on("event", handler)
|
||||
await send({ type: "server.connected", properties: {} })
|
||||
const heartbeat = setInterval(() => {
|
||||
void send({ type: "server.heartbeat", properties: {} })
|
||||
}, 10_000)
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
stream.onAbort(() => {
|
||||
clearInterval(heartbeat)
|
||||
GlobalBus.off("event", handler)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Hono } from "hono"
|
||||
import { Instance } from "../../project/instance"
|
||||
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||
import { SessionRoutes } from "../../server/routes/session"
|
||||
import { WorkspaceServerRoutes } from "./routes"
|
||||
import { WorkspaceContext } from "../workspace-context"
|
||||
import { WorkspaceID } from "../schema"
|
||||
|
||||
export namespace WorkspaceServer {
|
||||
export function App() {
|
||||
const session = new Hono()
|
||||
.use(async (c, next) => {
|
||||
// Right now, we need handle all requests because we don't
|
||||
// have syncing. In the future all GET requests will handled
|
||||
// by the control plane
|
||||
//
|
||||
// if (c.req.method === "GET") return c.notFound()
|
||||
await next()
|
||||
})
|
||||
.route("/", SessionRoutes())
|
||||
|
||||
return new Hono()
|
||||
.use(async (c, next) => {
|
||||
const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
|
||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory")
|
||||
if (rawWorkspaceID == null) {
|
||||
throw new Error("workspaceID parameter is required")
|
||||
}
|
||||
if (raw == null) {
|
||||
throw new Error("directory parameter is required")
|
||||
}
|
||||
|
||||
const directory = (() => {
|
||||
try {
|
||||
return decodeURIComponent(raw)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
})()
|
||||
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID: WorkspaceID.make(rawWorkspaceID),
|
||||
async fn() {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
.route("/session", session)
|
||||
.route("/", WorkspaceServerRoutes())
|
||||
}
|
||||
|
||||
export function Listen(opts: { hostname: string; port: number }) {
|
||||
return Bun.serve({
|
||||
hostname: opts.hostname,
|
||||
port: opts.port,
|
||||
fetch: App().fetch,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type * as Arr from "effect/Array"
|
||||
import { NodeSink, NodeStream } from "@effect/platform-node"
|
||||
import { NodeFileSystem, NodeSink, NodeStream } from "@effect/platform-node"
|
||||
import * as NodePath from "@effect/platform-node/NodePath"
|
||||
import * as Deferred from "effect/Deferred"
|
||||
import * as Effect from "effect/Effect"
|
||||
import * as Exit from "effect/Exit"
|
||||
@@ -474,3 +475,5 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
|
||||
ChildProcessSpawner,
|
||||
make,
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect, ScopedCache, Scope } from "effect"
|
||||
import { Instance, type Shape } from "@/project/instance"
|
||||
import { Instance, type InstanceContext } from "@/project/instance"
|
||||
import { registerDisposer } from "./instance-registry"
|
||||
|
||||
const TypeId = "~opencode/InstanceState"
|
||||
@@ -11,7 +11,7 @@ export interface InstanceState<A, E = never, R = never> {
|
||||
|
||||
export namespace InstanceState {
|
||||
export const make = <A, E = never, R = never>(
|
||||
init: (ctx: Shape) => Effect.Effect<A, E, R | Scope.Scope>,
|
||||
init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
|
||||
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
||||
Effect.gen(function* () {
|
||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||
|
||||
@@ -70,6 +70,8 @@ export namespace FileWatcher {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("FileWatcher.state")(
|
||||
function* () {
|
||||
@@ -117,7 +119,7 @@ export namespace FileWatcher {
|
||||
)
|
||||
}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfg = yield* config.get()
|
||||
const cfgIgnores = cfg.watcher?.ignore ?? []
|
||||
|
||||
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
|
||||
@@ -159,7 +161,9 @@ export namespace FileWatcher {
|
||||
}),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export function init() {
|
||||
return runPromise((svc) => svc.init())
|
||||
|
||||
@@ -70,6 +70,7 @@ export namespace Flag {
|
||||
export const OPENCODE_EXPERIMENTAL_MARKDOWN = !falsy("OPENCODE_EXPERIMENTAL_MARKDOWN")
|
||||
export const OPENCODE_MODELS_URL = process.env["OPENCODE_MODELS_URL"]
|
||||
export const OPENCODE_MODELS_PATH = process.env["OPENCODE_MODELS_PATH"]
|
||||
export const OPENCODE_DISABLE_EMBEDDED_WEB_UI = truthy("OPENCODE_DISABLE_EMBEDDED_WEB_UI")
|
||||
export const OPENCODE_DB = process.env["OPENCODE_DB"]
|
||||
export const OPENCODE_DISABLE_CHANNEL_DB = truthy("OPENCODE_DISABLE_CHANNEL_DB")
|
||||
export const OPENCODE_SKIP_MIGRATIONS = truthy("OPENCODE_SKIP_MIGRATIONS")
|
||||
|
||||
@@ -35,12 +35,14 @@ export namespace Format {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make(
|
||||
Effect.fn("Format.state")(function* (_ctx) {
|
||||
const enabled: Record<string, boolean> = {}
|
||||
const formatters: Record<string, Formatter.Info> = {}
|
||||
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfg = yield* config.get()
|
||||
|
||||
if (cfg.formatter !== false) {
|
||||
for (const item of Object.values(Formatter)) {
|
||||
@@ -167,7 +169,9 @@ export namespace Format {
|
||||
}),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function init() {
|
||||
return runPromise((s) => s.init())
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Installation } from "./installation"
|
||||
import { NamedError } from "@opencode-ai/util/error"
|
||||
import { FormatError } from "./cli/error"
|
||||
import { ServeCommand } from "./cli/cmd/serve"
|
||||
import { WorkspaceServeCommand } from "./cli/cmd/workspace-serve"
|
||||
import { Filesystem } from "./util/filesystem"
|
||||
import { DebugCommand } from "./cli/cmd/debug"
|
||||
import { StatsCommand } from "./cli/cmd/stats"
|
||||
@@ -47,7 +46,7 @@ process.on("uncaughtException", (e) => {
|
||||
})
|
||||
})
|
||||
|
||||
let cli = yargs(hideBin(process.argv))
|
||||
const cli = yargs(hideBin(process.argv))
|
||||
.parserConfiguration({ "populate--": true })
|
||||
.scriptName("opencode")
|
||||
.wrap(100)
|
||||
@@ -145,12 +144,6 @@ let cli = yargs(hideBin(process.argv))
|
||||
.command(PrCommand)
|
||||
.command(SessionCommand)
|
||||
.command(DbCommand)
|
||||
|
||||
if (Installation.isLocal()) {
|
||||
cli = cli.command(WorkspaceServeCommand)
|
||||
}
|
||||
|
||||
cli = cli
|
||||
.fail((msg, err) => {
|
||||
if (
|
||||
msg?.startsWith("Unknown argument") ||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Effect, Layer, Schema, ServiceMap, Stream } from "effect"
|
||||
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
@@ -341,9 +340,7 @@ export namespace Installation {
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -11,6 +11,9 @@ import { Instance } from "../project/instance"
|
||||
import { Flag } from "@/flag/flag"
|
||||
import { Process } from "../util/process"
|
||||
import { spawn as lspspawn } from "./launch"
|
||||
import { Effect, Layer, ServiceMap } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
|
||||
export namespace LSP {
|
||||
const log = Log.create({ service: "lsp" })
|
||||
@@ -62,92 +65,6 @@ export namespace LSP {
|
||||
})
|
||||
export type DocumentSymbol = z.infer<typeof DocumentSymbol>
|
||||
|
||||
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
|
||||
// If experimental flag is enabled, disable pyright
|
||||
if (servers["pyright"]) {
|
||||
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
|
||||
delete servers["pyright"]
|
||||
}
|
||||
} else {
|
||||
// If experimental flag is disabled, disable ty
|
||||
if (servers["ty"]) {
|
||||
delete servers["ty"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const state = Instance.state(
|
||||
async () => {
|
||||
const clients: LSPClient.Info[] = []
|
||||
const servers: Record<string, LSPServer.Info> = {}
|
||||
const cfg = await Config.get()
|
||||
|
||||
if (cfg.lsp === false) {
|
||||
log.info("all LSPs are disabled")
|
||||
return {
|
||||
broken: new Set<string>(),
|
||||
servers,
|
||||
clients,
|
||||
spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
|
||||
}
|
||||
}
|
||||
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
servers[server.id] = server
|
||||
}
|
||||
|
||||
filterExperimentalServers(servers)
|
||||
|
||||
for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
|
||||
const existing = servers[name]
|
||||
if (item.disabled) {
|
||||
log.info(`LSP server ${name} is disabled`)
|
||||
delete servers[name]
|
||||
continue
|
||||
}
|
||||
servers[name] = {
|
||||
...existing,
|
||||
id: name,
|
||||
root: existing?.root ?? (async () => Instance.directory),
|
||||
extensions: item.extensions ?? existing?.extensions ?? [],
|
||||
spawn: async (root) => {
|
||||
return {
|
||||
process: lspspawn(item.command[0], item.command.slice(1), {
|
||||
cwd: root,
|
||||
env: {
|
||||
...process.env,
|
||||
...item.env,
|
||||
},
|
||||
}),
|
||||
initialization: item.initialization,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
log.info("enabled LSP servers", {
|
||||
serverIds: Object.values(servers)
|
||||
.map((server) => server.id)
|
||||
.join(", "),
|
||||
})
|
||||
|
||||
return {
|
||||
broken: new Set<string>(),
|
||||
servers,
|
||||
clients,
|
||||
spawning: new Map<string, Promise<LSPClient.Info | undefined>>(),
|
||||
}
|
||||
},
|
||||
async (state) => {
|
||||
await Promise.all(state.clients.map((client) => client.shutdown()))
|
||||
},
|
||||
)
|
||||
|
||||
export async function init() {
|
||||
return state()
|
||||
}
|
||||
|
||||
export const Status = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
@@ -160,168 +77,6 @@ export namespace LSP {
|
||||
})
|
||||
export type Status = z.infer<typeof Status>
|
||||
|
||||
export async function status() {
|
||||
return state().then((x) => {
|
||||
const result: Status[] = []
|
||||
for (const client of x.clients) {
|
||||
result.push({
|
||||
id: client.serverID,
|
||||
name: x.servers[client.serverID].id,
|
||||
root: path.relative(Instance.directory, client.root),
|
||||
status: "connected",
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
async function getClients(file: string) {
|
||||
const s = await state()
|
||||
|
||||
// Only spawn LSP clients for files within the instance directory
|
||||
if (!Instance.containsPath(file)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const extension = path.parse(file).ext || file
|
||||
const result: LSPClient.Info[] = []
|
||||
|
||||
async function schedule(server: LSPServer.Info, root: string, key: string) {
|
||||
const handle = await server
|
||||
.spawn(root)
|
||||
.then((value) => {
|
||||
if (!value) s.broken.add(key)
|
||||
return value
|
||||
})
|
||||
.catch((err) => {
|
||||
s.broken.add(key)
|
||||
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!handle) return undefined
|
||||
log.info("spawned lsp server", { serverID: server.id })
|
||||
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
}).catch(async (err) => {
|
||||
s.broken.add(key)
|
||||
await Process.stop(handle.process)
|
||||
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!client) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (existing) {
|
||||
await Process.stop(handle.process)
|
||||
return existing
|
||||
}
|
||||
|
||||
s.clients.push(client)
|
||||
return client
|
||||
}
|
||||
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
|
||||
const root = await server.root(file)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
|
||||
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (match) {
|
||||
result.push(match)
|
||||
continue
|
||||
}
|
||||
|
||||
const inflight = s.spawning.get(root + server.id)
|
||||
if (inflight) {
|
||||
const client = await inflight
|
||||
if (!client) continue
|
||||
result.push(client)
|
||||
continue
|
||||
}
|
||||
|
||||
const task = schedule(server, root, root + server.id)
|
||||
s.spawning.set(root + server.id, task)
|
||||
|
||||
task.finally(() => {
|
||||
if (s.spawning.get(root + server.id) === task) {
|
||||
s.spawning.delete(root + server.id)
|
||||
}
|
||||
})
|
||||
|
||||
const client = await task
|
||||
if (!client) continue
|
||||
|
||||
result.push(client)
|
||||
Bus.publish(Event.Updated, {})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function hasClients(file: string) {
|
||||
const s = await state()
|
||||
const extension = path.parse(file).ext || file
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
const root = await server.root(file)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export async function touchFile(input: string, waitForDiagnostics?: boolean) {
|
||||
log.info("touching file", { file: input })
|
||||
const clients = await getClients(input)
|
||||
await Promise.all(
|
||||
clients.map(async (client) => {
|
||||
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
|
||||
await client.notify.open({ path: input })
|
||||
return wait
|
||||
}),
|
||||
).catch((err) => {
|
||||
log.error("failed to touch file", { err, file: input })
|
||||
})
|
||||
}
|
||||
|
||||
export async function diagnostics() {
|
||||
const results: Record<string, LSPClient.Diagnostic[]> = {}
|
||||
for (const result of await runAll(async (client) => client.diagnostics)) {
|
||||
for (const [path, diagnostics] of result.entries()) {
|
||||
const arr = results[path] || []
|
||||
arr.push(...diagnostics)
|
||||
results[path] = arr
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
export async function hover(input: { file: string; line: number; character: number }) {
|
||||
return run(input.file, (client) => {
|
||||
return client.connection
|
||||
.sendRequest("textDocument/hover", {
|
||||
textDocument: {
|
||||
uri: pathToFileURL(input.file).href,
|
||||
},
|
||||
position: {
|
||||
line: input.line,
|
||||
character: input.character,
|
||||
},
|
||||
})
|
||||
.catch(() => null)
|
||||
})
|
||||
}
|
||||
|
||||
enum SymbolKind {
|
||||
File = 1,
|
||||
Module = 2,
|
||||
@@ -362,115 +117,427 @@ export namespace LSP {
|
||||
SymbolKind.Enum,
|
||||
]
|
||||
|
||||
export async function workspaceSymbol(query: string) {
|
||||
return runAll((client) =>
|
||||
client.connection
|
||||
.sendRequest("workspace/symbol", {
|
||||
query,
|
||||
const filterExperimentalServers = (servers: Record<string, LSPServer.Info>) => {
|
||||
if (Flag.OPENCODE_EXPERIMENTAL_LSP_TY) {
|
||||
if (servers["pyright"]) {
|
||||
log.info("LSP server pyright is disabled because OPENCODE_EXPERIMENTAL_LSP_TY is enabled")
|
||||
delete servers["pyright"]
|
||||
}
|
||||
} else {
|
||||
if (servers["ty"]) {
|
||||
delete servers["ty"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type LocInput = { file: string; line: number; character: number }
|
||||
|
||||
interface State {
|
||||
clients: LSPClient.Info[]
|
||||
servers: Record<string, LSPServer.Info>
|
||||
broken: Set<string>
|
||||
spawning: Map<string, Promise<LSPClient.Info | undefined>>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
readonly hasClients: (file: string) => Effect.Effect<boolean>
|
||||
readonly touchFile: (input: string, waitForDiagnostics?: boolean) => Effect.Effect<void>
|
||||
readonly diagnostics: () => Effect.Effect<Record<string, LSPClient.Diagnostic[]>>
|
||||
readonly hover: (input: LocInput) => Effect.Effect<any>
|
||||
readonly definition: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly references: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly implementation: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly documentSymbol: (uri: string) => Effect.Effect<(LSP.DocumentSymbol | LSP.Symbol)[]>
|
||||
readonly workspaceSymbol: (query: string) => Effect.Effect<LSP.Symbol[]>
|
||||
readonly prepareCallHierarchy: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly incomingCalls: (input: LocInput) => Effect.Effect<any[]>
|
||||
readonly outgoingCalls: (input: LocInput) => Effect.Effect<any[]>
|
||||
}
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/LSP") {}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("LSP.state")(function* () {
|
||||
const cfg = yield* config.get()
|
||||
|
||||
const servers: Record<string, LSPServer.Info> = {}
|
||||
|
||||
if (cfg.lsp === false) {
|
||||
log.info("all LSPs are disabled")
|
||||
} else {
|
||||
for (const server of Object.values(LSPServer)) {
|
||||
servers[server.id] = server
|
||||
}
|
||||
|
||||
filterExperimentalServers(servers)
|
||||
|
||||
for (const [name, item] of Object.entries(cfg.lsp ?? {})) {
|
||||
const existing = servers[name]
|
||||
if (item.disabled) {
|
||||
log.info(`LSP server ${name} is disabled`)
|
||||
delete servers[name]
|
||||
continue
|
||||
}
|
||||
servers[name] = {
|
||||
...existing,
|
||||
id: name,
|
||||
root: existing?.root ?? (async () => Instance.directory),
|
||||
extensions: item.extensions ?? existing?.extensions ?? [],
|
||||
spawn: async (root) => ({
|
||||
process: lspspawn(item.command[0], item.command.slice(1), {
|
||||
cwd: root,
|
||||
env: { ...process.env, ...item.env },
|
||||
}),
|
||||
initialization: item.initialization,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
log.info("enabled LSP servers", {
|
||||
serverIds: Object.values(servers)
|
||||
.map((server) => server.id)
|
||||
.join(", "),
|
||||
})
|
||||
}
|
||||
|
||||
const s: State = {
|
||||
clients: [],
|
||||
servers,
|
||||
broken: new Set(),
|
||||
spawning: new Map(),
|
||||
}
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.promise(async () => {
|
||||
await Promise.all(s.clients.map((client) => client.shutdown()))
|
||||
}),
|
||||
)
|
||||
|
||||
return s
|
||||
}),
|
||||
)
|
||||
|
||||
const getClients = Effect.fnUntraced(function* (file: string) {
|
||||
if (!Instance.containsPath(file)) return [] as LSPClient.Info[]
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
const result: LSPClient.Info[] = []
|
||||
|
||||
async function schedule(server: LSPServer.Info, root: string, key: string) {
|
||||
const handle = await server
|
||||
.spawn(root)
|
||||
.then((value) => {
|
||||
if (!value) s.broken.add(key)
|
||||
return value
|
||||
})
|
||||
.catch((err) => {
|
||||
s.broken.add(key)
|
||||
log.error(`Failed to spawn LSP server ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!handle) return undefined
|
||||
log.info("spawned lsp server", { serverID: server.id })
|
||||
|
||||
const client = await LSPClient.create({
|
||||
serverID: server.id,
|
||||
server: handle,
|
||||
root,
|
||||
}).catch(async (err) => {
|
||||
s.broken.add(key)
|
||||
await Process.stop(handle.process)
|
||||
log.error(`Failed to initialize LSP client ${server.id}`, { error: err })
|
||||
return undefined
|
||||
})
|
||||
|
||||
if (!client) return undefined
|
||||
|
||||
const existing = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (existing) {
|
||||
await Process.stop(handle.process)
|
||||
return existing
|
||||
}
|
||||
|
||||
s.clients.push(client)
|
||||
return client
|
||||
}
|
||||
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
|
||||
const root = await server.root(file)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
|
||||
const match = s.clients.find((x) => x.root === root && x.serverID === server.id)
|
||||
if (match) {
|
||||
result.push(match)
|
||||
continue
|
||||
}
|
||||
|
||||
const inflight = s.spawning.get(root + server.id)
|
||||
if (inflight) {
|
||||
const client = await inflight
|
||||
if (!client) continue
|
||||
result.push(client)
|
||||
continue
|
||||
}
|
||||
|
||||
const task = schedule(server, root, root + server.id)
|
||||
s.spawning.set(root + server.id, task)
|
||||
|
||||
task.finally(() => {
|
||||
if (s.spawning.get(root + server.id) === task) {
|
||||
s.spawning.delete(root + server.id)
|
||||
}
|
||||
})
|
||||
|
||||
const client = await task
|
||||
if (!client) continue
|
||||
|
||||
result.push(client)
|
||||
Bus.publish(Event.Updated, {})
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
.then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind)))
|
||||
.then((result: any) => result.slice(0, 10))
|
||||
.catch(() => []),
|
||||
).then((result) => result.flat() as LSP.Symbol[])
|
||||
}
|
||||
})
|
||||
|
||||
export async function documentSymbol(uri: string) {
|
||||
const file = fileURLToPath(uri)
|
||||
return run(file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/documentSymbol", {
|
||||
textDocument: {
|
||||
uri,
|
||||
},
|
||||
const run = Effect.fnUntraced(function* <T>(file: string, fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const clients = yield* getClients(file)
|
||||
return yield* Effect.promise(() => Promise.all(clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const runAll = Effect.fnUntraced(function* <T>(fn: (client: LSPClient.Info) => Promise<T>) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(() => Promise.all(s.clients.map((x) => fn(x))))
|
||||
})
|
||||
|
||||
const init = Effect.fn("LSP.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
})
|
||||
|
||||
const status = Effect.fn("LSP.status")(function* () {
|
||||
const s = yield* InstanceState.get(state)
|
||||
const result: Status[] = []
|
||||
for (const client of s.clients) {
|
||||
result.push({
|
||||
id: client.serverID,
|
||||
name: s.servers[client.serverID].id,
|
||||
root: path.relative(Instance.directory, client.root),
|
||||
status: "connected",
|
||||
})
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
const hasClients = Effect.fn("LSP.hasClients")(function* (file: string) {
|
||||
const s = yield* InstanceState.get(state)
|
||||
return yield* Effect.promise(async () => {
|
||||
const extension = path.parse(file).ext || file
|
||||
for (const server of Object.values(s.servers)) {
|
||||
if (server.extensions.length && !server.extensions.includes(extension)) continue
|
||||
const root = await server.root(file)
|
||||
if (!root) continue
|
||||
if (s.broken.has(root + server.id)) continue
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
.then((result) => result.flat() as (LSP.DocumentSymbol | LSP.Symbol)[])
|
||||
.then((result) => result.filter(Boolean))
|
||||
}
|
||||
})
|
||||
|
||||
export async function definition(input: { file: string; line: number; character: number }) {
|
||||
return run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/definition", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
const touchFile = Effect.fn("LSP.touchFile")(function* (input: string, waitForDiagnostics?: boolean) {
|
||||
log.info("touching file", { file: input })
|
||||
const clients = yield* getClients(input)
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
clients.map(async (client) => {
|
||||
const wait = waitForDiagnostics ? client.waitForDiagnostics({ path: input }) : Promise.resolve()
|
||||
await client.notify.open({ path: input })
|
||||
return wait
|
||||
}),
|
||||
).catch((err) => {
|
||||
log.error("failed to touch file", { err, file: input })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const diagnostics = Effect.fn("LSP.diagnostics")(function* () {
|
||||
const results: Record<string, LSPClient.Diagnostic[]> = {}
|
||||
const all = yield* runAll(async (client) => client.diagnostics)
|
||||
for (const result of all) {
|
||||
for (const [p, diags] of result.entries()) {
|
||||
const arr = results[p] || []
|
||||
arr.push(...diags)
|
||||
results[p] = arr
|
||||
}
|
||||
}
|
||||
return results
|
||||
})
|
||||
|
||||
const hover = Effect.fn("LSP.hover")(function* (input: LocInput) {
|
||||
return yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/hover", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
})
|
||||
|
||||
const definition = Effect.fn("LSP.definition")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/definition", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const references = Effect.fn("LSP.references")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/references", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
context: { includeDeclaration: true },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const implementation = Effect.fn("LSP.implementation")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/implementation", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const documentSymbol = Effect.fn("LSP.documentSymbol")(function* (uri: string) {
|
||||
const file = fileURLToPath(uri)
|
||||
const results = yield* run(file, (client) =>
|
||||
client.connection.sendRequest("textDocument/documentSymbol", { textDocument: { uri } }).catch(() => []),
|
||||
)
|
||||
return (results.flat() as (LSP.DocumentSymbol | LSP.Symbol)[]).filter(Boolean)
|
||||
})
|
||||
|
||||
const workspaceSymbol = Effect.fn("LSP.workspaceSymbol")(function* (query: string) {
|
||||
const results = yield* runAll((client) =>
|
||||
client.connection
|
||||
.sendRequest("workspace/symbol", { query })
|
||||
.then((result: any) => result.filter((x: LSP.Symbol) => kinds.includes(x.kind)))
|
||||
.then((result: any) => result.slice(0, 10))
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat() as LSP.Symbol[]
|
||||
})
|
||||
|
||||
const prepareCallHierarchy = Effect.fn("LSP.prepareCallHierarchy")(function* (input: LocInput) {
|
||||
const results = yield* run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => []),
|
||||
)
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
const callHierarchyRequest = Effect.fnUntraced(function* (
|
||||
input: LocInput,
|
||||
direction: "callHierarchy/incomingCalls" | "callHierarchy/outgoingCalls",
|
||||
) {
|
||||
const results = yield* run(input.file, async (client) => {
|
||||
const items = (await client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => [])) as any[]
|
||||
if (!items?.length) return []
|
||||
return client.connection.sendRequest(direction, { item: items[0] }).catch(() => [])
|
||||
})
|
||||
.catch(() => null),
|
||||
).then((result) => result.flat().filter(Boolean))
|
||||
}
|
||||
return results.flat().filter(Boolean)
|
||||
})
|
||||
|
||||
export async function references(input: { file: string; line: number; character: number }) {
|
||||
return run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/references", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
context: { includeDeclaration: true },
|
||||
})
|
||||
.catch(() => []),
|
||||
).then((result) => result.flat().filter(Boolean))
|
||||
}
|
||||
const incomingCalls = Effect.fn("LSP.incomingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/incomingCalls")
|
||||
})
|
||||
|
||||
export async function implementation(input: { file: string; line: number; character: number }) {
|
||||
return run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/implementation", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => null),
|
||||
).then((result) => result.flat().filter(Boolean))
|
||||
}
|
||||
const outgoingCalls = Effect.fn("LSP.outgoingCalls")(function* (input: LocInput) {
|
||||
return yield* callHierarchyRequest(input, "callHierarchy/outgoingCalls")
|
||||
})
|
||||
|
||||
export async function prepareCallHierarchy(input: { file: string; line: number; character: number }) {
|
||||
return run(input.file, (client) =>
|
||||
client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => []),
|
||||
).then((result) => result.flat().filter(Boolean))
|
||||
}
|
||||
return Service.of({
|
||||
init,
|
||||
status,
|
||||
hasClients,
|
||||
touchFile,
|
||||
diagnostics,
|
||||
hover,
|
||||
definition,
|
||||
references,
|
||||
implementation,
|
||||
documentSymbol,
|
||||
workspaceSymbol,
|
||||
prepareCallHierarchy,
|
||||
incomingCalls,
|
||||
outgoingCalls,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export async function incomingCalls(input: { file: string; line: number; character: number }) {
|
||||
return run(input.file, async (client) => {
|
||||
const items = (await client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => [])) as any[]
|
||||
if (!items?.length) return []
|
||||
return client.connection.sendRequest("callHierarchy/incomingCalls", { item: items[0] }).catch(() => [])
|
||||
}).then((result) => result.flat().filter(Boolean))
|
||||
}
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer))
|
||||
|
||||
export async function outgoingCalls(input: { file: string; line: number; character: number }) {
|
||||
return run(input.file, async (client) => {
|
||||
const items = (await client.connection
|
||||
.sendRequest("textDocument/prepareCallHierarchy", {
|
||||
textDocument: { uri: pathToFileURL(input.file).href },
|
||||
position: { line: input.line, character: input.character },
|
||||
})
|
||||
.catch(() => [])) as any[]
|
||||
if (!items?.length) return []
|
||||
return client.connection.sendRequest("callHierarchy/outgoingCalls", { item: items[0] }).catch(() => [])
|
||||
}).then((result) => result.flat().filter(Boolean))
|
||||
}
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
async function runAll<T>(input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
|
||||
const clients = await state().then((x) => x.clients)
|
||||
const tasks = clients.map((x) => input(x))
|
||||
return Promise.all(tasks)
|
||||
}
|
||||
export const init = async () => runPromise((svc) => svc.init())
|
||||
|
||||
async function run<T>(file: string, input: (client: LSPClient.Info) => Promise<T>): Promise<T[]> {
|
||||
const clients = await getClients(file)
|
||||
const tasks = clients.map((x) => input(x))
|
||||
return Promise.all(tasks)
|
||||
}
|
||||
export const status = async () => runPromise((svc) => svc.status())
|
||||
|
||||
export const hasClients = async (file: string) => runPromise((svc) => svc.hasClients(file))
|
||||
|
||||
export const touchFile = async (input: string, waitForDiagnostics?: boolean) =>
|
||||
runPromise((svc) => svc.touchFile(input, waitForDiagnostics))
|
||||
|
||||
export const diagnostics = async () => runPromise((svc) => svc.diagnostics())
|
||||
|
||||
export const hover = async (input: LocInput) => runPromise((svc) => svc.hover(input))
|
||||
|
||||
export const definition = async (input: LocInput) => runPromise((svc) => svc.definition(input))
|
||||
|
||||
export const references = async (input: LocInput) => runPromise((svc) => svc.references(input))
|
||||
|
||||
export const implementation = async (input: LocInput) => runPromise((svc) => svc.implementation(input))
|
||||
|
||||
export const documentSymbol = async (uri: string) => runPromise((svc) => svc.documentSymbol(uri))
|
||||
|
||||
export const workspaceSymbol = async (query: string) => runPromise((svc) => svc.workspaceSymbol(query))
|
||||
|
||||
export const prepareCallHierarchy = async (input: LocInput) => runPromise((svc) => svc.prepareCallHierarchy(input))
|
||||
|
||||
export const incomingCalls = async (input: LocInput) => runPromise((svc) => svc.incomingCalls(input))
|
||||
|
||||
export const outgoingCalls = async (input: LocInput) => runPromise((svc) => svc.outgoingCalls(input))
|
||||
|
||||
export namespace Diagnostic {
|
||||
export function pretty(diagnostic: LSPClient.Diagnostic) {
|
||||
|
||||
@@ -24,13 +24,11 @@ import { BusEvent } from "../bus/bus-event"
|
||||
import { Bus } from "@/bus"
|
||||
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||
import open from "open"
|
||||
import { Effect, Layer, Option, ServiceMap, Stream } from "effect"
|
||||
import { Effect, Exit, Layer, Option, ServiceMap, Stream } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import * as NodePath from "@effect/platform-node/NodePath"
|
||||
|
||||
export namespace MCP {
|
||||
const log = Log.create({ service: "mcp" })
|
||||
@@ -129,6 +127,8 @@ export namespace MCP {
|
||||
return typeof entry === "object" && entry !== null && "type" in entry
|
||||
}
|
||||
|
||||
const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
|
||||
// Convert MCP tool definition to AI SDK Tool type
|
||||
function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool {
|
||||
const inputSchema = mcpTool.inputSchema
|
||||
@@ -160,233 +160,48 @@ export namespace MCP {
|
||||
})
|
||||
}
|
||||
|
||||
async function defs(key: string, client: MCPClient, timeout?: number) {
|
||||
const result = await withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT).catch((err) => {
|
||||
log.error("failed to get tools from client", { key, error: err })
|
||||
return undefined
|
||||
})
|
||||
return result?.tools
|
||||
function defs(key: string, client: MCPClient, timeout?: number) {
|
||||
return Effect.tryPromise({
|
||||
try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT),
|
||||
catch: (err) => (err instanceof Error ? err : new Error(String(err))),
|
||||
}).pipe(
|
||||
Effect.map((result) => result.tools),
|
||||
Effect.catch((err) => {
|
||||
log.error("failed to get tools from client", { key, error: err })
|
||||
return Effect.succeed(undefined)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchFromClient<T extends { name: string }>(
|
||||
function fetchFromClient<T extends { name: string }>(
|
||||
clientName: string,
|
||||
client: Client,
|
||||
listFn: (c: Client) => Promise<T[]>,
|
||||
label: string,
|
||||
): Promise<Record<string, T & { client: string }> | undefined> {
|
||||
const items = await listFn(client).catch((e: any) => {
|
||||
log.error(`failed to get ${label}`, { clientName, error: e.message })
|
||||
return undefined
|
||||
})
|
||||
if (!items) return undefined
|
||||
|
||||
const out: Record<string, T & { client: string }> = {}
|
||||
const sanitizedClient = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
for (const item of items) {
|
||||
const sanitizedName = item.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
out[sanitizedClient + ":" + sanitizedName] = { ...item, client: clientName }
|
||||
}
|
||||
return out
|
||||
) {
|
||||
return Effect.tryPromise({
|
||||
try: () => listFn(client),
|
||||
catch: (e: any) => {
|
||||
log.error(`failed to get ${label}`, { clientName, error: e.message })
|
||||
return e
|
||||
},
|
||||
}).pipe(
|
||||
Effect.map((items) => {
|
||||
const out: Record<string, T & { client: string }> = {}
|
||||
const sanitizedClient = sanitize(clientName)
|
||||
for (const item of items) {
|
||||
out[sanitizedClient + ":" + sanitize(item.name)] = { ...item, client: clientName }
|
||||
}
|
||||
return out
|
||||
}),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
}
|
||||
|
||||
async function create(key: string, mcp: Config.Mcp) {
|
||||
if (mcp.enabled === false) {
|
||||
log.info("mcp server disabled", { key })
|
||||
return {
|
||||
mcpClient: undefined,
|
||||
status: { status: "disabled" as const },
|
||||
}
|
||||
}
|
||||
|
||||
log.info("found", { key, type: mcp.type })
|
||||
let mcpClient: MCPClient | undefined
|
||||
let status: Status | undefined = undefined
|
||||
|
||||
if (mcp.type === "remote") {
|
||||
// OAuth is enabled by default for remote servers unless explicitly disabled with oauth: false
|
||||
const oauthDisabled = mcp.oauth === false
|
||||
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
|
||||
let authProvider: McpOAuthProvider | undefined
|
||||
|
||||
if (!oauthDisabled) {
|
||||
authProvider = new McpOAuthProvider(
|
||||
key,
|
||||
mcp.url,
|
||||
{
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
log.info("oauth redirect requested", { key, url: url.toString() })
|
||||
// Store the URL - actual browser opening is handled by startAuth
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const transports: Array<{ name: string; transport: TransportWithAuth }> = [
|
||||
{
|
||||
name: "StreamableHTTP",
|
||||
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
|
||||
authProvider,
|
||||
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "SSE",
|
||||
transport: new SSEClientTransport(new URL(mcp.url), {
|
||||
authProvider,
|
||||
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
let lastError: Error | undefined
|
||||
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
||||
for (const { name, transport } of transports) {
|
||||
try {
|
||||
const client = new Client({
|
||||
name: "opencode",
|
||||
version: Installation.VERSION,
|
||||
})
|
||||
await withTimeout(client.connect(transport), connectTimeout)
|
||||
mcpClient = client
|
||||
log.info("connected", { key, transport: name })
|
||||
status = { status: "connected" }
|
||||
break
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error : new Error(String(error))
|
||||
|
||||
// Handle OAuth-specific errors.
|
||||
// The SDK throws UnauthorizedError when auth() returns 'REDIRECT',
|
||||
// but may also throw plain Errors when auth() fails internally
|
||||
// (e.g. during discovery, registration, or state generation).
|
||||
// When an authProvider is attached, treat both cases as auth-related.
|
||||
const isAuthError =
|
||||
error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth"))
|
||||
if (isAuthError) {
|
||||
log.info("mcp server requires authentication", { key, transport: name })
|
||||
|
||||
// Check if this is a "needs registration" error
|
||||
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
|
||||
status = {
|
||||
status: "needs_client_registration" as const,
|
||||
error: "Server does not support dynamic client registration. Please provide clientId in config.",
|
||||
}
|
||||
// Show toast for needs_client_registration
|
||||
Bus.publish(TuiEvent.ToastShow, {
|
||||
title: "MCP Authentication Required",
|
||||
message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`,
|
||||
variant: "warning",
|
||||
duration: 8000,
|
||||
}).catch((e) => log.debug("failed to show toast", { error: e }))
|
||||
} else {
|
||||
// Store transport for later finishAuth call
|
||||
pendingOAuthTransports.set(key, transport)
|
||||
status = { status: "needs_auth" as const }
|
||||
// Show toast for needs_auth
|
||||
Bus.publish(TuiEvent.ToastShow, {
|
||||
title: "MCP Authentication Required",
|
||||
message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
|
||||
variant: "warning",
|
||||
duration: 8000,
|
||||
}).catch((e) => log.debug("failed to show toast", { error: e }))
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
log.debug("transport connection failed", {
|
||||
key,
|
||||
transport: name,
|
||||
url: mcp.url,
|
||||
error: lastError.message,
|
||||
})
|
||||
status = {
|
||||
status: "failed" as const,
|
||||
error: lastError.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mcp.type === "local") {
|
||||
const [cmd, ...args] = mcp.command
|
||||
const cwd = Instance.directory
|
||||
const transport = new StdioClientTransport({
|
||||
stderr: "pipe",
|
||||
command: cmd,
|
||||
args,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
...mcp.environment,
|
||||
},
|
||||
})
|
||||
transport.stderr?.on("data", (chunk: Buffer) => {
|
||||
log.info(`mcp stderr: ${chunk.toString()}`, { key })
|
||||
})
|
||||
|
||||
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
||||
try {
|
||||
const client = new Client({
|
||||
name: "opencode",
|
||||
version: Installation.VERSION,
|
||||
})
|
||||
await withTimeout(client.connect(transport), connectTimeout)
|
||||
mcpClient = client
|
||||
status = {
|
||||
status: "connected",
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("local mcp startup failed", {
|
||||
key,
|
||||
command: mcp.command,
|
||||
cwd,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
status = {
|
||||
status: "failed" as const,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!status) {
|
||||
status = {
|
||||
status: "failed" as const,
|
||||
error: "Unknown error",
|
||||
}
|
||||
}
|
||||
|
||||
if (!mcpClient) {
|
||||
return {
|
||||
mcpClient: undefined,
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
const listed = await defs(key, mcpClient, mcp.timeout)
|
||||
if (!listed) {
|
||||
await mcpClient.close().catch((error) => {
|
||||
log.error("Failed to close MCP client", {
|
||||
error,
|
||||
})
|
||||
})
|
||||
return {
|
||||
mcpClient: undefined,
|
||||
status: { status: "failed" as const, error: "Failed to get tools" },
|
||||
}
|
||||
}
|
||||
|
||||
log.info("create() successfully created client", { key, toolCount: listed.length })
|
||||
return {
|
||||
mcpClient,
|
||||
status,
|
||||
defs: listed,
|
||||
}
|
||||
interface CreateResult {
|
||||
mcpClient?: MCPClient
|
||||
status: Status
|
||||
defs?: MCPToolDef[]
|
||||
}
|
||||
|
||||
// --- Effect Service ---
|
||||
@@ -431,6 +246,196 @@ export namespace MCP {
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const auth = yield* McpAuth.Service
|
||||
const bus = yield* Bus.Service
|
||||
|
||||
type Transport = StdioClientTransport | StreamableHTTPClientTransport | SSEClientTransport
|
||||
|
||||
/**
|
||||
* Connect a client via the given transport with resource safety:
|
||||
* on failure the transport is closed; on success the caller owns it.
|
||||
*/
|
||||
const connectTransport = (transport: Transport, timeout: number) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.succeed(transport),
|
||||
(t) =>
|
||||
Effect.tryPromise({
|
||||
try: () => {
|
||||
const client = new Client({ name: "opencode", version: Installation.VERSION })
|
||||
return withTimeout(client.connect(t), timeout).then(() => client)
|
||||
},
|
||||
catch: (e) => (e instanceof Error ? e : new Error(String(e))),
|
||||
}),
|
||||
(t, exit) => (Exit.isFailure(exit) ? Effect.tryPromise(() => t.close()).pipe(Effect.ignore) : Effect.void),
|
||||
)
|
||||
|
||||
const DISABLED_RESULT: CreateResult = { status: { status: "disabled" } }
|
||||
|
||||
const connectRemote = Effect.fn("MCP.connectRemote")(function* (
|
||||
key: string,
|
||||
mcp: Config.Mcp & { type: "remote" },
|
||||
) {
|
||||
const oauthDisabled = mcp.oauth === false
|
||||
const oauthConfig = typeof mcp.oauth === "object" ? mcp.oauth : undefined
|
||||
let authProvider: McpOAuthProvider | undefined
|
||||
|
||||
if (!oauthDisabled) {
|
||||
authProvider = new McpOAuthProvider(
|
||||
key,
|
||||
mcp.url,
|
||||
{
|
||||
clientId: oauthConfig?.clientId,
|
||||
clientSecret: oauthConfig?.clientSecret,
|
||||
scope: oauthConfig?.scope,
|
||||
},
|
||||
{
|
||||
onRedirect: async (url) => {
|
||||
log.info("oauth redirect requested", { key, url: url.toString() })
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const transports: Array<{ name: string; transport: TransportWithAuth }> = [
|
||||
{
|
||||
name: "StreamableHTTP",
|
||||
transport: new StreamableHTTPClientTransport(new URL(mcp.url), {
|
||||
authProvider,
|
||||
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "SSE",
|
||||
transport: new SSEClientTransport(new URL(mcp.url), {
|
||||
authProvider,
|
||||
requestInit: mcp.headers ? { headers: mcp.headers } : undefined,
|
||||
}),
|
||||
},
|
||||
]
|
||||
|
||||
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
||||
let lastStatus: Status | undefined
|
||||
|
||||
for (const { name, transport } of transports) {
|
||||
const result = yield* connectTransport(transport, connectTimeout).pipe(
|
||||
Effect.map((client) => ({ client, transportName: name })),
|
||||
Effect.catch((error) => {
|
||||
const lastError = error instanceof Error ? error : new Error(String(error))
|
||||
const isAuthError =
|
||||
error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth"))
|
||||
|
||||
if (isAuthError) {
|
||||
log.info("mcp server requires authentication", { key, transport: name })
|
||||
|
||||
if (lastError.message.includes("registration") || lastError.message.includes("client_id")) {
|
||||
lastStatus = {
|
||||
status: "needs_client_registration" as const,
|
||||
error: "Server does not support dynamic client registration. Please provide clientId in config.",
|
||||
}
|
||||
return bus
|
||||
.publish(TuiEvent.ToastShow, {
|
||||
title: "MCP Authentication Required",
|
||||
message: `Server "${key}" requires a pre-registered client ID. Add clientId to your config.`,
|
||||
variant: "warning",
|
||||
duration: 8000,
|
||||
})
|
||||
.pipe(Effect.ignore, Effect.as(undefined))
|
||||
} else {
|
||||
pendingOAuthTransports.set(key, transport)
|
||||
lastStatus = { status: "needs_auth" as const }
|
||||
return bus
|
||||
.publish(TuiEvent.ToastShow, {
|
||||
title: "MCP Authentication Required",
|
||||
message: `Server "${key}" requires authentication. Run: opencode mcp auth ${key}`,
|
||||
variant: "warning",
|
||||
duration: 8000,
|
||||
})
|
||||
.pipe(Effect.ignore, Effect.as(undefined))
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("transport connection failed", {
|
||||
key,
|
||||
transport: name,
|
||||
url: mcp.url,
|
||||
error: lastError.message,
|
||||
})
|
||||
lastStatus = { status: "failed" as const, error: lastError.message }
|
||||
return Effect.succeed(undefined)
|
||||
}),
|
||||
)
|
||||
if (result) {
|
||||
log.info("connected", { key, transport: result.transportName })
|
||||
return { client: result.client as MCPClient | undefined, status: { status: "connected" } as Status }
|
||||
}
|
||||
// If this was an auth error, stop trying other transports
|
||||
if (lastStatus?.status === "needs_auth" || lastStatus?.status === "needs_client_registration") break
|
||||
}
|
||||
|
||||
return {
|
||||
client: undefined as MCPClient | undefined,
|
||||
status: (lastStatus ?? { status: "failed", error: "Unknown error" }) as Status,
|
||||
}
|
||||
})
|
||||
|
||||
const connectLocal = Effect.fn("MCP.connectLocal")(function* (key: string, mcp: Config.Mcp & { type: "local" }) {
|
||||
const [cmd, ...args] = mcp.command
|
||||
const cwd = Instance.directory
|
||||
const transport = new StdioClientTransport({
|
||||
stderr: "pipe",
|
||||
command: cmd,
|
||||
args,
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
...(cmd === "opencode" ? { BUN_BE_BUN: "1" } : {}),
|
||||
...mcp.environment,
|
||||
},
|
||||
})
|
||||
transport.stderr?.on("data", (chunk: Buffer) => {
|
||||
log.info(`mcp stderr: ${chunk.toString()}`, { key })
|
||||
})
|
||||
|
||||
const connectTimeout = mcp.timeout ?? DEFAULT_TIMEOUT
|
||||
return yield* connectTransport(transport, connectTimeout).pipe(
|
||||
Effect.map((client): { client: MCPClient | undefined; status: Status } => ({
|
||||
client,
|
||||
status: { status: "connected" },
|
||||
})),
|
||||
Effect.catch((error): Effect.Effect<{ client: MCPClient | undefined; status: Status }> => {
|
||||
const msg = error instanceof Error ? error.message : String(error)
|
||||
log.error("local mcp startup failed", { key, command: mcp.command, cwd, error: msg })
|
||||
return Effect.succeed({ client: undefined, status: { status: "failed", error: msg } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const create = Effect.fn("MCP.create")(function* (key: string, mcp: Config.Mcp) {
|
||||
if (mcp.enabled === false) {
|
||||
log.info("mcp server disabled", { key })
|
||||
return DISABLED_RESULT
|
||||
}
|
||||
|
||||
log.info("found", { key, type: mcp.type })
|
||||
|
||||
const { client: mcpClient, status } =
|
||||
mcp.type === "remote"
|
||||
? yield* connectRemote(key, mcp as Config.Mcp & { type: "remote" })
|
||||
: yield* connectLocal(key, mcp as Config.Mcp & { type: "local" })
|
||||
|
||||
if (!mcpClient) {
|
||||
return { status } satisfies CreateResult
|
||||
}
|
||||
|
||||
const listed = yield* defs(key, mcpClient, mcp.timeout)
|
||||
if (!listed) {
|
||||
yield* Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore)
|
||||
return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult
|
||||
}
|
||||
|
||||
log.info("create() successfully created client", { key, toolCount: listed.length })
|
||||
return { mcpClient, status, defs: listed } satisfies CreateResult
|
||||
})
|
||||
const cfgSvc = yield* Config.Service
|
||||
|
||||
const descendants = Effect.fnUntraced(
|
||||
function* (pid: number) {
|
||||
@@ -463,20 +468,18 @@ export namespace MCP {
|
||||
log.info("tools list changed notification received", { server: name })
|
||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||
|
||||
const listed = await defs(name, client, timeout)
|
||||
const listed = await Effect.runPromise(defs(name, client, timeout))
|
||||
if (!listed) return
|
||||
if (s.clients[name] !== client || s.status[name]?.status !== "connected") return
|
||||
|
||||
s.defs[name] = listed
|
||||
await Bus.publish(ToolsChanged, { server: name }).catch((error) =>
|
||||
log.warn("failed to publish tools changed", { server: name, error }),
|
||||
)
|
||||
await Effect.runPromise(bus.publish(ToolsChanged, { server: name }).pipe(Effect.ignore))
|
||||
})
|
||||
}
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
Effect.fn("MCP.state")(function* () {
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
const s: State = {
|
||||
status: {},
|
||||
@@ -498,13 +501,13 @@ export namespace MCP {
|
||||
return
|
||||
}
|
||||
|
||||
const result = yield* Effect.promise(() => create(key, mcp).catch(() => undefined))
|
||||
const result = yield* create(key, mcp).pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
if (!result) return
|
||||
|
||||
s.status[key] = result.status
|
||||
if (result.mcpClient) {
|
||||
s.clients[key] = result.mcpClient
|
||||
s.defs[key] = result.defs
|
||||
s.defs[key] = result.defs!
|
||||
watch(s, key, result.mcpClient, mcp.timeout)
|
||||
}
|
||||
}),
|
||||
@@ -542,14 +545,13 @@ export namespace MCP {
|
||||
const client = s.clients[name]
|
||||
delete s.defs[name]
|
||||
if (!client) return Effect.void
|
||||
return Effect.promise(() =>
|
||||
client.close().catch((error: any) => log.error("failed to close MCP client", { name, error })),
|
||||
)
|
||||
return Effect.tryPromise(() => client.close()).pipe(Effect.ignore)
|
||||
}
|
||||
|
||||
const status = Effect.fn("MCP.status")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
const result: Record<string, Status> = {}
|
||||
|
||||
@@ -568,14 +570,7 @@ export namespace MCP {
|
||||
|
||||
const createAndStore = Effect.fn("MCP.createAndStore")(function* (name: string, mcp: Config.Mcp) {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const result = yield* Effect.promise(() => create(name, mcp))
|
||||
|
||||
if (!result) {
|
||||
yield* closeClient(s, name)
|
||||
delete s.clients[name]
|
||||
s.status[name] = { status: "failed" as const, error: "unknown error" }
|
||||
return s.status[name]
|
||||
}
|
||||
const result = yield* create(name, mcp)
|
||||
|
||||
s.status[name] = result.status
|
||||
if (!result.mcpClient) {
|
||||
@@ -586,7 +581,7 @@ export namespace MCP {
|
||||
|
||||
yield* closeClient(s, name)
|
||||
s.clients[name] = result.mcpClient
|
||||
s.defs[name] = result.defs
|
||||
s.defs[name] = result.defs!
|
||||
watch(s, name, result.mcpClient, mcp.timeout)
|
||||
return result.status
|
||||
})
|
||||
@@ -616,7 +611,8 @@ export namespace MCP {
|
||||
const tools = Effect.fn("MCP.tools")(function* () {
|
||||
const result: Record<string, Tool> = {}
|
||||
const s = yield* InstanceState.get(cache)
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const config = cfg.mcp ?? {}
|
||||
const defaultTimeout = cfg.experimental?.mcp_timeout
|
||||
|
||||
@@ -639,9 +635,7 @@ export namespace MCP {
|
||||
|
||||
const timeout = entry?.timeout ?? defaultTimeout
|
||||
for (const mcpTool of listed) {
|
||||
const sanitizedClientName = clientName.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const sanitizedToolName = mcpTool.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
result[sanitizedClientName + "_" + sanitizedToolName] = convertMcpTool(mcpTool, client, timeout)
|
||||
result[sanitize(clientName) + "_" + sanitize(mcpTool.name)] = convertMcpTool(mcpTool, client, timeout)
|
||||
}
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
@@ -649,30 +643,27 @@ export namespace MCP {
|
||||
return result
|
||||
})
|
||||
|
||||
function collectFromConnected<T>(
|
||||
function collectFromConnected<T extends { name: string }>(
|
||||
s: State,
|
||||
fetchFn: (clientName: string, client: Client) => Promise<Record<string, T> | undefined>,
|
||||
listFn: (c: Client) => Promise<T[]>,
|
||||
label: string,
|
||||
) {
|
||||
return Effect.forEach(
|
||||
Object.entries(s.clients).filter(([name]) => s.status[name]?.status === "connected"),
|
||||
([clientName, client]) =>
|
||||
Effect.promise(async () => Object.entries((await fetchFn(clientName, client)) ?? {})),
|
||||
fetchFromClient(clientName, client, listFn, label).pipe(Effect.map((items) => Object.entries(items ?? {}))),
|
||||
{ concurrency: "unbounded" },
|
||||
).pipe(Effect.map((results) => Object.fromEntries<T>(results.flat())))
|
||||
).pipe(Effect.map((results) => Object.fromEntries<T & { client: string }>(results.flat())))
|
||||
}
|
||||
|
||||
const prompts = Effect.fn("MCP.prompts")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
return yield* collectFromConnected(s, (name, client) =>
|
||||
fetchFromClient(name, client, (c) => c.listPrompts().then((r) => r.prompts), "prompts"),
|
||||
)
|
||||
return yield* collectFromConnected(s, (c) => c.listPrompts().then((r) => r.prompts), "prompts")
|
||||
})
|
||||
|
||||
const resources = Effect.fn("MCP.resources")(function* () {
|
||||
const s = yield* InstanceState.get(cache)
|
||||
return yield* collectFromConnected(s, (name, client) =>
|
||||
fetchFromClient(name, client, (c) => c.listResources().then((r) => r.resources), "resources"),
|
||||
)
|
||||
return yield* collectFromConnected(s, (c) => c.listResources().then((r) => r.resources), "resources")
|
||||
})
|
||||
|
||||
const withClient = Effect.fnUntraced(function* <A>(
|
||||
@@ -713,7 +704,7 @@ export namespace MCP {
|
||||
})
|
||||
|
||||
const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) {
|
||||
const cfg = yield* Effect.promise(() => Config.get())
|
||||
const cfg = yield* cfgSvc.get()
|
||||
const mcpConfig = cfg.mcp?.[mcpName]
|
||||
if (!mcpConfig || !isMcpConfigured(mcpConfig)) return undefined
|
||||
return mcpConfig
|
||||
@@ -750,19 +741,21 @@ export namespace MCP {
|
||||
|
||||
const transport = new StreamableHTTPClientTransport(new URL(mcpConfig.url), { authProvider })
|
||||
|
||||
return yield* Effect.promise(async () => {
|
||||
try {
|
||||
return yield* Effect.tryPromise({
|
||||
try: () => {
|
||||
const client = new Client({ name: "opencode", version: Installation.VERSION })
|
||||
await client.connect(transport)
|
||||
return { authorizationUrl: "", oauthState }
|
||||
} catch (error) {
|
||||
return client.connect(transport).then(() => ({ authorizationUrl: "", oauthState }))
|
||||
},
|
||||
catch: (error) => error,
|
||||
}).pipe(
|
||||
Effect.catch((error) => {
|
||||
if (error instanceof UnauthorizedError && capturedUrl) {
|
||||
pendingOAuthTransports.set(mcpName, transport)
|
||||
return { authorizationUrl: capturedUrl.toString(), oauthState }
|
||||
return Effect.succeed({ authorizationUrl: capturedUrl.toString(), oauthState })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
})
|
||||
return Effect.die(error)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const authenticate = Effect.fn("MCP.authenticate")(function* (mcpName: string) {
|
||||
@@ -791,7 +784,7 @@ export namespace MCP {
|
||||
),
|
||||
Effect.catch(() => {
|
||||
log.warn("failed to open browser, user must open URL manually", { mcpName })
|
||||
return Effect.promise(() => Bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }))
|
||||
return bus.publish(BrowserOpenFailed, { mcpName, url: authorizationUrl }).pipe(Effect.ignore)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -811,10 +804,7 @@ export namespace MCP {
|
||||
if (!transport) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`)
|
||||
|
||||
const result = yield* Effect.tryPromise({
|
||||
try: async () => {
|
||||
await transport.finishAuth(authorizationCode)
|
||||
return true
|
||||
},
|
||||
try: () => transport.finishAuth(authorizationCode).then(() => true as const),
|
||||
catch: (error) => {
|
||||
log.error("failed to finish oauth", { mcpName, error })
|
||||
return error
|
||||
@@ -885,12 +875,12 @@ export namespace MCP {
|
||||
|
||||
// --- Per-service runtime ---
|
||||
|
||||
const defaultLayer = layer.pipe(
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(McpAuth.layer),
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -194,7 +194,7 @@ export namespace Plugin {
|
||||
}),
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function trigger<
|
||||
|
||||
@@ -7,13 +7,14 @@ import { Context } from "../util/context"
|
||||
import { Project } from "./project"
|
||||
import { State } from "./state"
|
||||
|
||||
export interface Shape {
|
||||
export interface InstanceContext {
|
||||
directory: string
|
||||
worktree: string
|
||||
project: Project.Info
|
||||
}
|
||||
const context = Context.create<Shape>("instance")
|
||||
const cache = new Map<string, Promise<Shape>>()
|
||||
|
||||
const context = Context.create<InstanceContext>("instance")
|
||||
const cache = new Map<string, Promise<InstanceContext>>()
|
||||
|
||||
const disposal = {
|
||||
all: undefined as Promise<void> | undefined,
|
||||
@@ -52,7 +53,7 @@ function boot(input: { directory: string; init?: () => Promise<any>; project?: P
|
||||
})
|
||||
}
|
||||
|
||||
function track(directory: string, next: Promise<Shape>) {
|
||||
function track(directory: string, next: Promise<InstanceContext>) {
|
||||
const task = next.catch((error) => {
|
||||
if (cache.get(directory) === task) cache.delete(directory)
|
||||
throw error
|
||||
|
||||
@@ -111,7 +111,7 @@ export namespace Project {
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
|
||||
@@ -155,7 +155,7 @@ export namespace Project {
|
||||
const scope = yield* Scope.Scope
|
||||
|
||||
const readCachedProjectId = Effect.fnUntraced(function* (dir: string) {
|
||||
return yield* fsys.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||
return yield* fs.readFileString(pathSvc.join(dir, "opencode")).pipe(
|
||||
Effect.map((x) => x.trim()),
|
||||
Effect.map(ProjectID.make),
|
||||
Effect.catch(() => Effect.succeed(undefined)),
|
||||
@@ -169,7 +169,7 @@ export namespace Project {
|
||||
type DiscoveryResult = { id: ProjectID; worktree: string; sandbox: string; vcs: Info["vcs"] }
|
||||
|
||||
const data: DiscoveryResult = yield* Effect.gen(function* () {
|
||||
const dotgitMatches = yield* fsys.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgitMatches = yield* fs.up({ targets: [".git"], start: directory }).pipe(Effect.orDie)
|
||||
const dotgit = dotgitMatches[0]
|
||||
|
||||
if (!dotgit) {
|
||||
@@ -222,7 +222,7 @@ export namespace Project {
|
||||
|
||||
id = roots[0] ? ProjectID.make(roots[0]) : undefined
|
||||
if (id) {
|
||||
yield* fsys.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
|
||||
yield* fs.writeFileString(pathSvc.join(worktree, ".git", "opencode"), id).pipe(Effect.ignore)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ export namespace Project {
|
||||
result.sandboxes = yield* Effect.forEach(
|
||||
result.sandboxes,
|
||||
(s) =>
|
||||
fsys.exists(s).pipe(
|
||||
fs.exists(s).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((exists) => (exists ? s : undefined)),
|
||||
),
|
||||
@@ -329,7 +329,7 @@ export namespace Project {
|
||||
if (input.icon?.override) return
|
||||
if (input.icon?.url) return
|
||||
|
||||
const matches = yield* fsys
|
||||
const matches = yield* fs
|
||||
.glob("**/favicon.{ico,png,svg,jpg,jpeg,webp}", {
|
||||
cwd: input.worktree,
|
||||
absolute: true,
|
||||
@@ -339,7 +339,7 @@ export namespace Project {
|
||||
const shortest = matches.sort((a, b) => a.length - b.length)[0]
|
||||
if (!shortest) return
|
||||
|
||||
const buffer = yield* fsys.readFile(shortest).pipe(Effect.orDie)
|
||||
const buffer = yield* fs.readFile(shortest).pipe(Effect.orDie)
|
||||
const base64 = Buffer.from(buffer).toString("base64")
|
||||
const mime = AppFileSystem.mimeType(shortest)
|
||||
const url = `data:${mime};base64,${base64}`
|
||||
@@ -400,7 +400,7 @@ export namespace Project {
|
||||
return yield* Effect.forEach(
|
||||
data.sandboxes,
|
||||
(dir) =>
|
||||
fsys.isDir(dir).pipe(
|
||||
fs.isDir(dir).pipe(
|
||||
Effect.orDie,
|
||||
Effect.map((ok) => (ok ? dir : undefined)),
|
||||
),
|
||||
@@ -457,9 +457,8 @@ export namespace Project {
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -273,7 +273,7 @@ export namespace Pty {
|
||||
if (input.size) {
|
||||
session.process.resize(input.size.cols, input.size.rows)
|
||||
}
|
||||
yield* Effect.promise(() => Bus.publish(Event.Updated, { info: session.info }))
|
||||
void Bus.publish(Event.Updated, { info: session.info })
|
||||
return session.info
|
||||
})
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ export const EventRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("event connected")
|
||||
c.header("Cache-Control", "no-cache, no-transform")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamSSE(c, async (stream) => {
|
||||
|
||||
@@ -118,6 +118,7 @@ export const GlobalRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("global event connected")
|
||||
c.header("Cache-Control", "no-cache, no-transform")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
|
||||
@@ -157,6 +158,7 @@ export const GlobalRoutes = lazy(() =>
|
||||
}),
|
||||
async (c) => {
|
||||
log.info("global sync event connected")
|
||||
c.header("Cache-Control", "no-cache, no-transform")
|
||||
c.header("X-Accel-Buffering", "no")
|
||||
c.header("X-Content-Type-Options", "nosniff")
|
||||
return streamEvents(c, (q) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto"
|
||||
import { Log } from "../util/log"
|
||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
||||
import { Hono } from "hono"
|
||||
import { compress } from "hono/compress"
|
||||
import { cors } from "hono/cors"
|
||||
import { proxy } from "hono/proxy"
|
||||
import { basicAuth } from "hono/basic-auth"
|
||||
@@ -19,7 +20,6 @@ import { Auth } from "../auth"
|
||||
import { Flag } from "../flag/flag"
|
||||
import { Command } from "../command"
|
||||
import { Global } from "../global"
|
||||
import { WorkspaceContext } from "../control-plane/workspace-context"
|
||||
import { WorkspaceID } from "../control-plane/schema"
|
||||
import { ProviderID } from "../provider/schema"
|
||||
import { WorkspaceRouterMiddleware } from "../control-plane/workspace-router-middleware"
|
||||
@@ -56,6 +56,20 @@ initProjectors()
|
||||
|
||||
export namespace Server {
|
||||
const log = Log.create({ service: "server" })
|
||||
const DEFAULT_CSP =
|
||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||
? Promise.resolve(null)
|
||||
: // @ts-expect-error - generated file at build time
|
||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||
|
||||
const zipped = compress()
|
||||
|
||||
const skipCompress = (path: string, method: string) => {
|
||||
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return true
|
||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const Default = lazy(() => createApp({}))
|
||||
|
||||
@@ -109,6 +123,7 @@ export namespace Server {
|
||||
})
|
||||
.use(
|
||||
cors({
|
||||
maxAge: 86_400,
|
||||
origin(input) {
|
||||
if (!input) return
|
||||
|
||||
@@ -133,6 +148,10 @@ export namespace Server {
|
||||
},
|
||||
}),
|
||||
)
|
||||
.use((c, next) => {
|
||||
if (skipCompress(c.req.path, c.req.method)) return next()
|
||||
return zipped(c, next)
|
||||
})
|
||||
.route("/global", GlobalRoutes())
|
||||
.put(
|
||||
"/auth/:providerID",
|
||||
@@ -198,7 +217,6 @@ export namespace Server {
|
||||
)
|
||||
.use(async (c, next) => {
|
||||
if (c.req.path === "/log") return next()
|
||||
const rawWorkspaceID = c.req.query("workspace") || c.req.header("x-opencode-workspace")
|
||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||
const directory = Filesystem.resolve(
|
||||
(() => {
|
||||
@@ -210,20 +228,14 @@ export namespace Server {
|
||||
})(),
|
||||
)
|
||||
|
||||
return WorkspaceContext.provide({
|
||||
workspaceID: rawWorkspaceID ? WorkspaceID.make(rawWorkspaceID) : undefined,
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return Instance.provide({
|
||||
directory,
|
||||
init: InstanceBootstrap,
|
||||
async fn() {
|
||||
return next()
|
||||
},
|
||||
})
|
||||
return next()
|
||||
},
|
||||
})
|
||||
})
|
||||
.use(WorkspaceRouterMiddleware)
|
||||
.get(
|
||||
"/doc",
|
||||
openAPIRouteHandler(app, {
|
||||
@@ -246,6 +258,7 @@ export namespace Server {
|
||||
}),
|
||||
),
|
||||
)
|
||||
.use(WorkspaceRouterMiddleware)
|
||||
.route("/project", ProjectRoutes())
|
||||
.route("/pty", PtyRoutes())
|
||||
.route("/config", ConfigRoutes())
|
||||
@@ -504,24 +517,40 @@ export namespace Server {
|
||||
},
|
||||
)
|
||||
.all("/*", async (c) => {
|
||||
const embeddedWebUI = await embeddedUIPromise
|
||||
const path = c.req.path
|
||||
|
||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||
...c.req,
|
||||
headers: {
|
||||
...c.req.raw.headers,
|
||||
host: "app.opencode.ai",
|
||||
},
|
||||
})
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? (await response.clone().text()).match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
return response
|
||||
})
|
||||
if (embeddedWebUI) {
|
||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||
if (!match) return c.json({ error: "Not Found" }, 404)
|
||||
const file = Bun.file(match)
|
||||
if (await file.exists()) {
|
||||
c.header("Content-Type", file.type)
|
||||
if (file.type.startsWith("text/html")) {
|
||||
c.header("Content-Security-Policy", DEFAULT_CSP)
|
||||
}
|
||||
return c.body(await file.arrayBuffer())
|
||||
} else {
|
||||
return c.json({ error: "Not Found" }, 404)
|
||||
}
|
||||
} else {
|
||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||
...c.req,
|
||||
headers: {
|
||||
...c.req.raw.headers,
|
||||
host: "app.opencode.ai",
|
||||
},
|
||||
})
|
||||
const match = response.headers.get("content-type")?.includes("text/html")
|
||||
? (await response.clone().text()).match(
|
||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||
)
|
||||
: undefined
|
||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||
response.headers.set("Content-Security-Policy", csp(hash))
|
||||
return response
|
||||
}
|
||||
}) as unknown as Hono
|
||||
}
|
||||
|
||||
export async function openapi() {
|
||||
|
||||
@@ -23,7 +23,6 @@ import { SessionPrompt } from "./prompt"
|
||||
import { fn } from "@/util/fn"
|
||||
import { Command } from "../command"
|
||||
import { Snapshot } from "@/snapshot"
|
||||
import { WorkspaceContext } from "../control-plane/workspace-context"
|
||||
import { ProjectID } from "../project/schema"
|
||||
import { WorkspaceID } from "../control-plane/schema"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
@@ -494,8 +493,8 @@ export namespace Session {
|
||||
const project = Instance.project
|
||||
const conditions = [eq(SessionTable.project_id, project.id)]
|
||||
|
||||
if (WorkspaceContext.workspaceID) {
|
||||
conditions.push(eq(SessionTable.workspace_id, WorkspaceContext.workspaceID))
|
||||
if (input?.workspaceID) {
|
||||
conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
|
||||
}
|
||||
if (input?.directory) {
|
||||
conditions.push(eq(SessionTable.directory, input.directory))
|
||||
|
||||
116
packages/opencode/src/session/prompt/gpt.txt
Normal file
116
packages/opencode/src/session/prompt/gpt.txt
Normal file
@@ -0,0 +1,116 @@
|
||||
You are OpenCode, You and the user share the same workspace and collaborate to achieve the user's goals.
|
||||
|
||||
You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail.
|
||||
|
||||
## Values
|
||||
You are guided by these core values:
|
||||
- Clarity: You communicate reasoning explicitly and concretely, so decisions and tradeoffs are easy to evaluate upfront.
|
||||
- Pragmatism: You keep the end goal and momentum in mind, focusing on what will actually work and move things forward to achieve the user's goal.
|
||||
- Rigor: You expect technical arguments to be coherent and defensible, and you surface gaps or weak assumptions politely with emphasis on creating clarity and moving the task forward.
|
||||
|
||||
## Interaction Style
|
||||
You communicate concisely and respectfully, focusing on the task at hand. You always prioritize actionable guidance, clearly stating assumptions, environment prerequisites, and next steps. Unless explicitly asked, you avoid excessively verbose explanations about your work.
|
||||
|
||||
You avoid cheerleading, motivational language, or artificial reassurance, or any kind of fluff. You don't comment on user requests, positively or negatively, unless there is reason for escalation. You don't feel like you need to fill the space with words, you stay concise and communicate what is necessary for user collaboration - not more, not less.
|
||||
|
||||
## Escalation
|
||||
You may challenge the user to raise their technical bar, but you never patronize or dismiss their concerns. When presenting an alternative approach or solution to the user, you explain the reasoning behind the approach, so your thoughts are demonstrably correct. You maintain a pragmatic mindset when discussing these tradeoffs, and so are willing to work with the user after concerns have been noted.
|
||||
|
||||
|
||||
# General
|
||||
As an expert coding agent, your primary focus is writing code, answering questions, and helping the user complete their task in the current environment. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer.
|
||||
|
||||
- When searching for text or files, prefer using Glob and Grep tools (they are powered by `rg`)
|
||||
- Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly.
|
||||
|
||||
## Editing constraints
|
||||
|
||||
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
|
||||
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
|
||||
- Always use apply_patch for manual code edits. Do not use cat or any other commands when creating or editing files. Formatting commands or bulk edits don't need to be done with apply_patch.
|
||||
- Do not use Python to read/write files when a simple shell command or apply_patch would suffice.
|
||||
- You may be in a dirty git worktree.
|
||||
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
|
||||
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
|
||||
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
|
||||
* If the changes are in unrelated files, just ignore them and don't revert them.
|
||||
- Do not amend a commit unless explicitly requested to do so.
|
||||
- While you are working, you might notice unexpected changes that you didn't make. It's likely the user made them, or were autogenerated. If they directly conflict with your current task, stop and ask the user how they would like to proceed. Otherwise, focus on the task at hand.
|
||||
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
|
||||
- You struggle using the git interactive console. **ALWAYS** prefer using non-interactive git commands.
|
||||
|
||||
## Special user requests
|
||||
|
||||
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
|
||||
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
|
||||
|
||||
## Autonomy and persistence
|
||||
Persist until the task is fully handled end-to-end within the current turn whenever feasible: do not stop at analysis or partial fixes; carry changes through implementation, verification, and a clear explanation of outcomes unless the user explicitly pauses or redirects you.
|
||||
|
||||
Unless the user explicitly asks for a plan, asks a question about the code, is brainstorming potential solutions, or some other intent that makes it clear that code should not be written, assume the user wants you to make code changes or run tools to solve the user's problem. In these cases, it's bad to output your proposed solution in a message, you should go ahead and actually implement the change. If you encounter challenges or blockers, you should attempt to resolve them yourself.
|
||||
|
||||
## Frontend tasks
|
||||
|
||||
When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
|
||||
- Ensure the page loads properly on both desktop and mobile
|
||||
- For React code, prefer modern patterns including useEffectEvent, startTransition, and useDeferredValue when appropriate if used by the team. Do not add useMemo/useCallback by default unless already used; follow the repo's React Compiler guidance.
|
||||
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
|
||||
|
||||
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
|
||||
|
||||
# Working with the user
|
||||
|
||||
You interact with the user through a terminal. You have 2 ways of communicating with the users:
|
||||
- Share intermediary updates in `commentary` channel.
|
||||
- After you have completed all your work, send a message to the `final` channel.
|
||||
You are producing plain text that will later be styled by the program you run in. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value. Follow the formatting rules exactly.
|
||||
|
||||
## Formatting rules
|
||||
|
||||
- You may format with GitHub-flavored Markdown.
|
||||
- Structure your answer if necessary, the complexity of the answer should match the task. If the task is simple, your answer should be a one-liner. Order sections from general to specific to supporting.
|
||||
- Never use nested bullets. Keep lists flat (single level). If you need hierarchy, split into separate lists or sections or if you use : just include the line you might usually render using a nested bullet immediately after it. For numbered lists, only use the `1. 2. 3.` style markers (with a period), never `1)`.
|
||||
- Headers are optional, only use them when you think they are necessary. If you do use them, use short Title Case (1-3 words) wrapped in **…**. Don't add a blank line.
|
||||
- Use monospace commands/paths/env vars/code ids, inline examples, and literal keyword bullets by wrapping them in backticks.
|
||||
- Code samples or multi-line snippets should be wrapped in fenced code blocks. Include an info string as often as possible.
|
||||
- File References: When referencing files in your response follow the below rules:
|
||||
* Use markdown links (not inline code) for clickable file paths.
|
||||
* Each reference should have a stand alone path. Even if it's the same file.
|
||||
* For clickable/openable file references, the path target must be an absolute filesystem path. Labels may be short (for example, `[app.ts](/abs/path/app.ts)`).
|
||||
* Optionally include line/column (1‑based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
|
||||
* Do not use URIs like file://, vscode://, or https://.
|
||||
* Do not provide range of lines
|
||||
- Don’t use emojis or em dashes unless explicitly instructed.
|
||||
|
||||
## Final answer instructions
|
||||
|
||||
Always favor conciseness in your final answer - you should usually avoid long-winded explanations and focus only on the most important details. For casual chit-chat, just chat. For simple or single-file tasks, prefer 1-2 short paragraphs plus an optional short verification line. Do not default to bullets. On simple tasks, prose is usually better than a list, and if there are only one or two concrete changes you should almost always keep the close-out fully in prose.
|
||||
|
||||
On larger tasks, use at most 2-3 high-level sections when helpful. Each section can be a short paragraph or a few flat bullets. Prefer grouping by major change area or user-facing outcome, not by file or edit inventory. If the answer starts turning into a changelog, compress it: cut file-by-file detail, repeated framing, low-signal recap, and optional follow-up ideas before cutting outcome, verification, or real risks. Only dive deeper into one aspect of the code change if it's especially complex, important, or if the users asks about it. This also holds true for PR explanations, codebase walkthroughs, or architectural decisions: provide a high-level walkthrough unless specifically asked and cap answers at 2-3 sections.
|
||||
|
||||
Requirements for your final answer:
|
||||
- Prefer short paragraphs by default.
|
||||
- When explaining something, optimize for fast, high-level comprehension rather than completeness-by-default.
|
||||
- Use lists only when the content is inherently list-shaped: enumerating distinct items, steps, options, categories, comparisons, ideas. Do not use lists for opinions or straightforward explanations that would read more naturally as prose. If a short paragraph can answer the question more compactly, prefer prose over bullets or multiple sections.
|
||||
- Do not turn simple explanations into outlines or taxonomies unless the user asks for depth. If a list is used, each bullet should be a complete standalone point.
|
||||
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”, "You're right to call that out") or framing phrases.
|
||||
- When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
|
||||
- Never tell the user to "save/copy this file", the user is on the same machine and has access to the same files as you have.
|
||||
- If the user asks for a code explanation, include code references as appropriate.
|
||||
- If you weren't able to do something, for example run tests, tell the user.
|
||||
- Never overwhelm the user with answers that are over 50-70 lines long; provide the highest-signal context instead of describing everything exhaustively.
|
||||
|
||||
## Intermediary updates
|
||||
|
||||
- Intermediary updates go to the `commentary` channel.
|
||||
- User updates are short updates while you are working, they are NOT final answers.
|
||||
- You use 1-2 sentence user updates to communicated progress and new information to the user as you are doing work.
|
||||
- Do not begin responses with conversational interjections or meta commentary. Avoid openers such as acknowledgements (“Done —”, “Got it”, “Great question, ”) or framing phrases.
|
||||
- Before exploring or doing substantial work, you start with a user update acknowledging the request and explaining your first step. You should include your understanding of the user request and explain what you will do. Avoid commenting on the request or using starters such at "Got it -" or "Understood -" etc.
|
||||
- You provide user updates frequently, every 30s.
|
||||
- When exploring, e.g. searching, reading files you provide user updates as you go, explaining what context you are gathering and what you've learned. Vary your sentence structure when providing these updates to avoid sounding repetitive - in particular, don't start each sentence the same way.
|
||||
- When working for a while, keep updates informative and varied, but stay concise.
|
||||
- After you have sufficient context, and the work is substantial you provide a longer plan (this is the only user update that may be longer than 2 sentences and can contain formatting).
|
||||
- Before performing file edits of any kind, you provide updates explaining what edits you are making.
|
||||
- As you are thinking, you very frequently provide updates even if not taking any actions, informing the user of your progress. You interrupt your thinking and send multiple updates in a row if thinking for more than 100 words.
|
||||
- Tone of your updates MUST match your personality.
|
||||
@@ -6,6 +6,7 @@ import PROMPT_ANTHROPIC from "./prompt/anthropic.txt"
|
||||
import PROMPT_DEFAULT from "./prompt/default.txt"
|
||||
import PROMPT_BEAST from "./prompt/beast.txt"
|
||||
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
||||
import PROMPT_GPT from "./prompt/gpt.txt"
|
||||
|
||||
import PROMPT_CODEX from "./prompt/codex.txt"
|
||||
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
||||
@@ -18,7 +19,12 @@ export namespace SystemPrompt {
|
||||
export function provider(model: Provider.Model) {
|
||||
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
|
||||
return [PROMPT_BEAST]
|
||||
if (model.api.id.includes("gpt")) return [PROMPT_CODEX]
|
||||
if (model.api.id.includes("gpt")) {
|
||||
if (model.api.id.includes("codex")) {
|
||||
return [PROMPT_CODEX]
|
||||
}
|
||||
return [PROMPT_GPT]
|
||||
}
|
||||
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
|
||||
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
|
||||
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
|
||||
|
||||
@@ -60,403 +60,397 @@ export namespace Snapshot {
|
||||
|
||||
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Snapshot") {}
|
||||
|
||||
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner> =
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | Config.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const config = yield* Config.Service
|
||||
const locks = new Map<string, Semaphore.Semaphore>()
|
||||
|
||||
const lock = (key: string) => {
|
||||
const hit = locks.get(key)
|
||||
if (hit) return hit
|
||||
const lock = (key: string) => {
|
||||
const hit = locks.get(key)
|
||||
if (hit) return hit
|
||||
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(key, next)
|
||||
return next
|
||||
}
|
||||
const next = Semaphore.makeUnsafe(1)
|
||||
locks.set(key, next)
|
||||
return next
|
||||
}
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||
const state = {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
|
||||
vcs: ctx.project.vcs,
|
||||
}
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Snapshot.state")(function* (ctx) {
|
||||
const state = {
|
||||
directory: ctx.directory,
|
||||
worktree: ctx.worktree,
|
||||
gitdir: path.join(Global.Path.data, "snapshot", ctx.project.id, Hash.fast(ctx.worktree)),
|
||||
vcs: ctx.project.vcs,
|
||||
}
|
||||
|
||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||
const args = (cmd: string[]) => ["--git-dir", state.gitdir, "--work-tree", state.worktree, ...cmd]
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||
const proc = ChildProcess.make("git", cmd, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
})
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[
|
||||
Stream.mkString(Stream.decodeText(handle.stdout)),
|
||||
Stream.mkString(Stream.decodeText(handle.stderr)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
|
||||
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (state.vcs !== "git") return false
|
||||
return (yield* Effect.promise(() => Config.get())).snapshot !== false
|
||||
})
|
||||
|
||||
const excludes = Effect.fnUntraced(function* () {
|
||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: state.worktree,
|
||||
const git = Effect.fnUntraced(
|
||||
function* (cmd: string[], opts?: { cwd?: string; env?: Record<string, string> }) {
|
||||
const proc = ChildProcess.make("git", cmd, {
|
||||
cwd: opts?.cwd,
|
||||
env: opts?.env,
|
||||
extendEnv: true,
|
||||
})
|
||||
const file = result.text.trim()
|
||||
if (!file) return
|
||||
if (!(yield* exists(file))) return
|
||||
return file
|
||||
})
|
||||
|
||||
const sync = Effect.fnUntraced(function* (list: string[] = []) {
|
||||
const file = yield* excludes()
|
||||
const target = path.join(state.gitdir, "info", "exclude")
|
||||
const text = [
|
||||
file ? (yield* read(file)).trimEnd() : "",
|
||||
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
const [diff, other] = yield* Effect.all(
|
||||
[
|
||||
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
],
|
||||
const handle = yield* spawner.spawn(proc)
|
||||
const [text, stderr] = yield* Effect.all(
|
||||
[Stream.mkString(Stream.decodeText(handle.stdout)), Stream.mkString(Stream.decodeText(handle.stderr))],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
if (diff.code !== 0 || other.code !== 0) {
|
||||
log.warn("failed to list snapshot files", {
|
||||
diffCode: diff.code,
|
||||
diffStderr: diff.stderr,
|
||||
otherCode: other.code,
|
||||
otherStderr: other.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
const code = yield* handle.exitCode
|
||||
return { code, text, stderr } satisfies GitResult
|
||||
},
|
||||
Effect.scoped,
|
||||
Effect.catch((err) =>
|
||||
Effect.succeed({
|
||||
code: ChildProcessSpawner.ExitCode(1),
|
||||
text: "",
|
||||
stderr: String(err),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const tracked = diff.text.split("\0").filter(Boolean)
|
||||
const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
|
||||
if (!all.length) return
|
||||
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
|
||||
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
|
||||
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
|
||||
const locked = <A, E, R>(fx: Effect.Effect<A, E, R>) => lock(state.gitdir).withPermits(1)(fx)
|
||||
|
||||
const large = (yield* Effect.all(
|
||||
all.map((item) =>
|
||||
fs
|
||||
.stat(path.join(state.directory, item))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(
|
||||
Effect.map((stat) => {
|
||||
if (!stat || stat.type !== "File") return
|
||||
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
||||
return size > limit ? item : undefined
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)).filter((item): item is string => Boolean(item))
|
||||
yield* sync(large)
|
||||
const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to add snapshot files", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}
|
||||
const enabled = Effect.fnUntraced(function* () {
|
||||
if (state.vcs !== "git") return false
|
||||
return (yield* config.get()).snapshot !== false
|
||||
})
|
||||
|
||||
const excludes = Effect.fnUntraced(function* () {
|
||||
const result = yield* git(["rev-parse", "--path-format=absolute", "--git-path", "info/exclude"], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
const file = result.text.trim()
|
||||
if (!file) return
|
||||
if (!(yield* exists(file))) return
|
||||
return file
|
||||
})
|
||||
|
||||
const cleanup = Effect.fnUntraced(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(state.gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
const sync = Effect.fnUntraced(function* (list: string[] = []) {
|
||||
const file = yield* excludes()
|
||||
const target = path.join(state.gitdir, "info", "exclude")
|
||||
const text = [
|
||||
file ? (yield* read(file)).trimEnd() : "",
|
||||
...list.map((item) => `/${item.replaceAll("\\", "/")}`),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie)
|
||||
yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie)
|
||||
})
|
||||
|
||||
const add = Effect.fnUntraced(function* () {
|
||||
yield* sync()
|
||||
const [diff, other] = yield* Effect.all(
|
||||
[
|
||||
git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const track = Effect.fnUntraced(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(state.gitdir)
|
||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||
})
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||
return hash
|
||||
git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], {
|
||||
cwd: state.directory,
|
||||
}),
|
||||
)
|
||||
})
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
if (diff.code !== 0 || other.code !== 0) {
|
||||
log.warn("failed to list snapshot files", {
|
||||
diffCode: diff.code,
|
||||
diffStderr: diff.stderr,
|
||||
otherCode: other.code,
|
||||
otherStderr: other.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
const tracked = diff.text.split("\0").filter(Boolean)
|
||||
const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)]))
|
||||
if (!all.length) return
|
||||
|
||||
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
const large = (yield* Effect.all(
|
||||
all.map((item) =>
|
||||
fs
|
||||
.stat(path.join(state.directory, item))
|
||||
.pipe(Effect.catch(() => Effect.void))
|
||||
.pipe(
|
||||
Effect.map((stat) => {
|
||||
if (!stat || stat.type !== "File") return
|
||||
const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size
|
||||
return size > limit ? item : undefined
|
||||
}),
|
||||
),
|
||||
),
|
||||
{ concurrency: 8 },
|
||||
)).filter((item): item is string => Boolean(item))
|
||||
yield* sync(large)
|
||||
const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to add snapshot files", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const cleanup = Effect.fnUntraced(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
if (!(yield* exists(state.gitdir))) return
|
||||
const result = yield* git(args(["gc", `--prune=${prune}`]), { cwd: state.directory })
|
||||
if (result.code !== 0) {
|
||||
log.warn("cleanup failed", {
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
return
|
||||
}
|
||||
log.info("cleanup", { prune })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||
const track = Effect.fnUntraced(function* () {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
if (!(yield* enabled())) return
|
||||
const existed = yield* exists(state.gitdir)
|
||||
yield* fs.ensureDir(state.gitdir).pipe(Effect.orDie)
|
||||
if (!existed) {
|
||||
yield* git(["init"], {
|
||||
env: { GIT_DIR: state.gitdir, GIT_WORK_TREE: state.worktree },
|
||||
})
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.autocrlf", "false"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.longpaths", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.symlinks", "true"])
|
||||
yield* git(["--git-dir", state.gitdir, "config", "core.fsmonitor", "false"])
|
||||
log.info("initialized")
|
||||
}
|
||||
yield* add()
|
||||
const result = yield* git(args(["write-tree"]), { cwd: state.directory })
|
||||
const hash = result.text.trim()
|
||||
log.info("tracking", { hash, cwd: state.directory, git: state.gitdir })
|
||||
return hash
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const patch = Effect.fnUntraced(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", { hash, exitCode: result.code })
|
||||
return { hash, files: [] }
|
||||
}
|
||||
return {
|
||||
hash,
|
||||
files: result.text
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((x) => x.trim())
|
||||
.filter(Boolean)
|
||||
.map((x) => path.join(state.worktree, x).replaceAll("\\", "/")),
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const restore = Effect.fnUntraced(function* (snapshot: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
log.info("restore", { commit: snapshot })
|
||||
const result = yield* git([...core, ...args(["read-tree", snapshot])], { cwd: state.worktree })
|
||||
if (result.code === 0) {
|
||||
const checkout = yield* git([...core, ...args(["checkout-index", "-a", "-f"])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (checkout.code === 0) return
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: checkout.code,
|
||||
stderr: checkout.stderr,
|
||||
})
|
||||
return
|
||||
}
|
||||
log.error("failed to restore snapshot", {
|
||||
snapshot,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const revert = Effect.fnUntraced(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const seen = new Set<string>()
|
||||
for (const item of patches) {
|
||||
for (const file of item.files) {
|
||||
if (seen.has(file)) continue
|
||||
seen.add(file)
|
||||
log.info("reverting", { file, hash: item.hash })
|
||||
const result = yield* git([...core, ...args(["checkout", item.hash, "--", file])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(state.worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
const rel = path.relative(state.worktree, file)
|
||||
const tree = yield* git([...core, ...args(["ls-tree", item.hash, "--", rel])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
}
|
||||
if (tree.code === 0 && tree.text.trim()) {
|
||||
log.info("file existed in snapshot but checkout failed, keeping", { file })
|
||||
} else {
|
||||
log.info("file did not exist in snapshot, deleting", { file })
|
||||
yield* remove(file)
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git(
|
||||
[...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])],
|
||||
{
|
||||
cwd: state.worktree,
|
||||
},
|
||||
)
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[
|
||||
...quote,
|
||||
...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."]),
|
||||
],
|
||||
{ cwd: state.directory },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
})
|
||||
|
||||
return { cleanup, track, patch, restore, revert, diff, diffFull }
|
||||
}),
|
||||
)
|
||||
const diff = Effect.fnUntraced(function* (hash: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
yield* add()
|
||||
const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], {
|
||||
cwd: state.worktree,
|
||||
})
|
||||
if (result.code !== 0) {
|
||||
log.warn("failed to get diff", {
|
||||
hash,
|
||||
exitCode: result.code,
|
||||
stderr: result.stderr,
|
||||
})
|
||||
return ""
|
||||
}
|
||||
return result.text.trim()
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Snapshot.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
|
||||
}),
|
||||
track: Effect.fn("Snapshot.track")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.track())
|
||||
}),
|
||||
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
|
||||
}),
|
||||
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
|
||||
}),
|
||||
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
|
||||
}),
|
||||
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
|
||||
}),
|
||||
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
const diffFull = Effect.fnUntraced(function* (from: string, to: string) {
|
||||
return yield* locked(
|
||||
Effect.gen(function* () {
|
||||
const result: Snapshot.FileDiff[] = []
|
||||
const status = new Map<string, "added" | "deleted" | "modified">()
|
||||
|
||||
const statuses = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--name-status", "--no-renames", from, to, "--", "."])],
|
||||
{ cwd: state.directory },
|
||||
)
|
||||
|
||||
for (const line of statuses.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [code, file] = line.split("\t")
|
||||
if (!code || !file) continue
|
||||
status.set(file, code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified")
|
||||
}
|
||||
|
||||
const numstat = yield* git(
|
||||
[...quote, ...args(["diff", "--no-ext-diff", "--no-renames", "--numstat", from, to, "--", "."])],
|
||||
{
|
||||
cwd: state.directory,
|
||||
},
|
||||
)
|
||||
|
||||
for (const line of numstat.text.trim().split("\n")) {
|
||||
if (!line) continue
|
||||
const [adds, dels, file] = line.split("\t")
|
||||
if (!file) continue
|
||||
const binary = adds === "-" && dels === "-"
|
||||
const [before, after] = binary
|
||||
? ["", ""]
|
||||
: yield* Effect.all(
|
||||
[
|
||||
git([...cfg, ...args(["show", `${from}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
git([...cfg, ...args(["show", `${to}:${file}`])]).pipe(Effect.map((item) => item.text)),
|
||||
],
|
||||
{ concurrency: 2 },
|
||||
)
|
||||
const additions = binary ? 0 : parseInt(adds)
|
||||
const deletions = binary ? 0 : parseInt(dels)
|
||||
result.push({
|
||||
file,
|
||||
before,
|
||||
after,
|
||||
additions: Number.isFinite(additions) ? additions : 0,
|
||||
deletions: Number.isFinite(deletions) ? deletions : 0,
|
||||
status: status.get(file) ?? "modified",
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
yield* cleanup().pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
log.error("cleanup loop failed", { cause: Cause.pretty(cause) })
|
||||
return Effect.void
|
||||
}),
|
||||
Effect.repeat(Schedule.spaced(Duration.hours(1))),
|
||||
Effect.delay(Duration.minutes(1)),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
|
||||
return { cleanup, track, patch, restore, revert, diff, diffFull }
|
||||
}),
|
||||
)
|
||||
|
||||
return Service.of({
|
||||
init: Effect.fn("Snapshot.init")(function* () {
|
||||
yield* InstanceState.get(state)
|
||||
}),
|
||||
cleanup: Effect.fn("Snapshot.cleanup")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.cleanup())
|
||||
}),
|
||||
track: Effect.fn("Snapshot.track")(function* () {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.track())
|
||||
}),
|
||||
patch: Effect.fn("Snapshot.patch")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.patch(hash))
|
||||
}),
|
||||
restore: Effect.fn("Snapshot.restore")(function* (snapshot: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.restore(snapshot))
|
||||
}),
|
||||
revert: Effect.fn("Snapshot.revert")(function* (patches: Snapshot.Patch[]) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.revert(patches))
|
||||
}),
|
||||
diff: Effect.fn("Snapshot.diff")(function* (hash: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diff(hash))
|
||||
}),
|
||||
diffFull: Effect.fn("Snapshot.diffFull")(function* (from: string, to: string) {
|
||||
return yield* InstanceState.useEffect(state, (s) => s.diffFull(from, to))
|
||||
}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodeFileSystem.layer), // needed by CrossSpawnSpawner
|
||||
Layer.provide(NodePath.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -54,6 +54,9 @@ export namespace ToolRegistry {
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const plugin = yield* Plugin.Service
|
||||
|
||||
const cache = yield* InstanceState.make<State>(
|
||||
Effect.fn("ToolRegistry.state")(function* (ctx) {
|
||||
const custom: Tool.Info[] = []
|
||||
@@ -82,35 +85,34 @@ export namespace ToolRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
const matches = await Config.directories().then((dirs) =>
|
||||
dirs.flatMap((dir) =>
|
||||
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
||||
),
|
||||
const dirs = yield* config.directories()
|
||||
const matches = dirs.flatMap((dir) =>
|
||||
Glob.scanSync("{tool,tools}/*.{js,ts}", { cwd: dir, absolute: true, dot: true, symlink: true }),
|
||||
)
|
||||
if (matches.length) yield* config.waitForDependencies()
|
||||
for (const match of matches) {
|
||||
const namespace = path.basename(match, path.extname(match))
|
||||
const mod = yield* Effect.promise(() =>
|
||||
import(process.platform === "win32" ? match : pathToFileURL(match).href),
|
||||
)
|
||||
if (matches.length) await Config.waitForDependencies()
|
||||
for (const match of matches) {
|
||||
const namespace = path.basename(match, path.extname(match))
|
||||
const mod = await import(process.platform === "win32" ? match : pathToFileURL(match).href)
|
||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||
}
|
||||
for (const [id, def] of Object.entries<ToolDefinition>(mod)) {
|
||||
custom.push(fromPlugin(id === "default" ? namespace : `${namespace}_${id}`, def))
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = await Plugin.list()
|
||||
for (const plugin of plugins) {
|
||||
for (const [id, def] of Object.entries(plugin.tool ?? {})) {
|
||||
custom.push(fromPlugin(id, def))
|
||||
}
|
||||
const plugins = yield* plugin.list()
|
||||
for (const p of plugins) {
|
||||
for (const [id, def] of Object.entries(p.tool ?? {})) {
|
||||
custom.push(fromPlugin(id, def))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return { custom }
|
||||
}),
|
||||
)
|
||||
|
||||
async function all(custom: Tool.Info[]): Promise<Tool.Info[]> {
|
||||
const cfg = await Config.get()
|
||||
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
|
||||
const cfg = yield* config.get()
|
||||
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
|
||||
return [
|
||||
@@ -134,7 +136,7 @@ export namespace ToolRegistry {
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
|
||||
...custom,
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const register = Effect.fn("ToolRegistry.register")(function* (tool: Tool.Info) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
@@ -148,7 +150,7 @@ export namespace ToolRegistry {
|
||||
|
||||
const ids = Effect.fn("ToolRegistry.ids")(function* () {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const tools = yield* Effect.promise(() => all(state.custom))
|
||||
const tools = yield* all(state.custom)
|
||||
return tools.map((t) => t.id)
|
||||
})
|
||||
|
||||
@@ -157,40 +159,37 @@ export namespace ToolRegistry {
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
const state = yield* InstanceState.get(cache)
|
||||
const allTools = yield* Effect.promise(() => all(state.custom))
|
||||
return yield* Effect.promise(() =>
|
||||
Promise.all(
|
||||
allTools
|
||||
.filter((tool) => {
|
||||
// Enable websearch/codesearch for zen users OR via enable flag
|
||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
const allTools = yield* all(state.custom)
|
||||
const filtered = allTools.filter((tool) => {
|
||||
if (tool.id === "codesearch" || tool.id === "websearch") {
|
||||
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
|
||||
}
|
||||
|
||||
// use apply tool in same format as codex
|
||||
const usePatch =
|
||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||
if (tool.id === "apply_patch") return usePatch
|
||||
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
||||
const usePatch =
|
||||
model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4")
|
||||
if (tool.id === "apply_patch") return usePatch
|
||||
if (tool.id === "edit" || tool.id === "write") return !usePatch
|
||||
|
||||
return true
|
||||
})
|
||||
.map(async (tool) => {
|
||||
using _ = log.time(tool.id)
|
||||
const next = await tool.init({ agent })
|
||||
const output = {
|
||||
description: next.description,
|
||||
parameters: next.parameters,
|
||||
}
|
||||
await Plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||
return {
|
||||
id: tool.id,
|
||||
...next,
|
||||
description: output.description,
|
||||
parameters: output.parameters,
|
||||
}
|
||||
}),
|
||||
),
|
||||
return true
|
||||
})
|
||||
return yield* Effect.forEach(
|
||||
filtered,
|
||||
Effect.fnUntraced(function* (tool) {
|
||||
using _ = log.time(tool.id)
|
||||
const next = yield* Effect.promise(() => tool.init({ agent }))
|
||||
const output = {
|
||||
description: next.description,
|
||||
parameters: next.parameters,
|
||||
}
|
||||
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||
return {
|
||||
id: tool.id,
|
||||
...next,
|
||||
description: output.description,
|
||||
parameters: output.parameters,
|
||||
} as Awaited<ReturnType<Tool.Info["init"]>> & { id: string }
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -198,7 +197,11 @@ export namespace ToolRegistry {
|
||||
}),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, layer)
|
||||
export const defaultLayer = Layer.unwrap(
|
||||
Effect.sync(() => layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Plugin.defaultLayer))),
|
||||
)
|
||||
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
export async function register(tool: Tool.Info) {
|
||||
return runPromise((svc) => svc.register(tool))
|
||||
@@ -214,7 +217,7 @@ export namespace ToolRegistry {
|
||||
modelID: ModelID
|
||||
},
|
||||
agent?: Agent.Info,
|
||||
) {
|
||||
): Promise<(Awaited<ReturnType<Tool.Info["init"]>> & { id: string })[]> {
|
||||
return runPromise((svc) => svc.tools(model, agent))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,10 @@ import { Log } from "../util/log"
|
||||
import { Slug } from "@opencode-ai/util/slug"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { GlobalBus } from "@/bus/global"
|
||||
import { Effect, FileSystem, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||
import { Effect, Layer, Path, Scope, ServiceMap, Stream } from "effect"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { NodePath } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@/filesystem"
|
||||
import { makeRuntime } from "@/effect/run-service"
|
||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||
|
||||
@@ -167,14 +168,15 @@ export namespace Worktree {
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
|
||||
AppFileSystem.Service | Path.Path | ChildProcessSpawner.ChildProcessSpawner | Project.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const fsys = yield* FileSystem.FileSystem
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const pathSvc = yield* Path.Path
|
||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||
const project = yield* Project.Service
|
||||
|
||||
const git = Effect.fnUntraced(
|
||||
function* (args: string[], opts?: { cwd?: string }) {
|
||||
@@ -201,7 +203,7 @@ export namespace Worktree {
|
||||
const branch = `opencode/${name}`
|
||||
const directory = pathSvc.join(root, name)
|
||||
|
||||
if (yield* fsys.exists(directory).pipe(Effect.orDie)) continue
|
||||
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
|
||||
|
||||
const ref = `refs/heads/${branch}`
|
||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
|
||||
@@ -218,7 +220,7 @@ export namespace Worktree {
|
||||
}
|
||||
|
||||
const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
|
||||
yield* fsys.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||
yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||
|
||||
const base = name ? slugify(name) : ""
|
||||
return yield* candidate(root, base || undefined)
|
||||
@@ -232,7 +234,7 @@ export namespace Worktree {
|
||||
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
||||
}
|
||||
|
||||
yield* Effect.promise(() => Project.addSandbox(Instance.project.id, info.directory).catch(() => undefined))
|
||||
yield* project.addSandbox(Instance.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
|
||||
})
|
||||
|
||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||
@@ -297,7 +299,7 @@ export namespace Worktree {
|
||||
|
||||
const canonical = Effect.fnUntraced(function* (input: string) {
|
||||
const abs = pathSvc.resolve(input)
|
||||
const real = yield* fsys.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
|
||||
const real = yield* fs.realPath(abs).pipe(Effect.catch(() => Effect.succeed(abs)))
|
||||
const normalized = pathSvc.normalize(real)
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized
|
||||
})
|
||||
@@ -334,7 +336,7 @@ export namespace Worktree {
|
||||
})
|
||||
|
||||
function stopFsmonitor(target: string) {
|
||||
return fsys.exists(target).pipe(
|
||||
return fs.exists(target).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((exists) => (exists ? git(["fsmonitor--daemon", "stop"], { cwd: target }) : Effect.void)),
|
||||
)
|
||||
@@ -364,7 +366,7 @@ export namespace Worktree {
|
||||
const entry = yield* locateWorktree(entries, directory)
|
||||
|
||||
if (!entry?.path) {
|
||||
const directoryExists = yield* fsys.exists(directory).pipe(Effect.orDie)
|
||||
const directoryExists = yield* fs.exists(directory).pipe(Effect.orDie)
|
||||
if (directoryExists) {
|
||||
yield* stopFsmonitor(directory)
|
||||
yield* cleanDirectory(directory)
|
||||
@@ -464,7 +466,7 @@ export namespace Worktree {
|
||||
const target = yield* canonical(pathSvc.resolve(root, entry))
|
||||
if (target === base) return
|
||||
if (!target.startsWith(`${base}${pathSvc.sep}`)) return
|
||||
yield* fsys.remove(target, { recursive: true }).pipe(Effect.ignore)
|
||||
yield* fs.remove(target, { recursive: true }).pipe(Effect.ignore)
|
||||
}),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
@@ -603,8 +605,9 @@ export namespace Worktree {
|
||||
)
|
||||
|
||||
const defaultLayer = layer.pipe(
|
||||
Layer.provide(CrossSpawnSpawner.layer),
|
||||
Layer.provide(NodeFileSystem.layer),
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Project.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(NodePath.layer),
|
||||
)
|
||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { test, expect, describe, mock, afterEach, spyOn } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { AccessToken, Account, AccountID, OrgID } from "../../src/account"
|
||||
import { AppFileSystem } from "../../src/filesystem"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||
|
||||
/** Infra layer that provides FileSystem, Path, ChildProcessSpawner for test fixtures */
|
||||
const infra = CrossSpawnSpawner.defaultLayer.pipe(
|
||||
Layer.provideMerge(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer)),
|
||||
)
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { pathToFileURL } from "url"
|
||||
@@ -12,6 +22,14 @@ import { ProjectID } from "../../src/project/schema"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
import { BunProc } from "../../src/bun"
|
||||
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
|
||||
const emptyAuth = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
})
|
||||
|
||||
// Get managed config directory from environment (set in preload.ts)
|
||||
const managedConfigDir = process.env.OPENCODE_TEST_MANAGED_CONFIG_DIR!
|
||||
|
||||
@@ -34,7 +52,7 @@ async function check(map: (dir: string) => string) {
|
||||
await using tmp = await tmpdir({ git: true, config: { snapshot: true } })
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = globalTmp.path
|
||||
Config.global.reset()
|
||||
await Config.invalidate()
|
||||
try {
|
||||
await writeConfig(globalTmp.path, {
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
@@ -52,7 +70,7 @@ async function check(map: (dir: string) => string) {
|
||||
} finally {
|
||||
await Instance.disposeAll()
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
Config.global.reset()
|
||||
await Config.invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,43 +264,44 @@ test("preserves env variables when adding $schema to config", async () => {
|
||||
})
|
||||
|
||||
test("resolves env templates in account config with account token", async () => {
|
||||
const originalActive = Account.active
|
||||
const originalConfig = Account.config
|
||||
const originalToken = Account.token
|
||||
const originalControlToken = process.env["OPENCODE_CONSOLE_TOKEN"]
|
||||
|
||||
Account.active = mock(async () => ({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
active_org_id: OrgID.make("org-1"),
|
||||
}))
|
||||
const fakeAccount = Layer.mock(Account.Service)({
|
||||
active: () =>
|
||||
Effect.succeed(
|
||||
Option.some({
|
||||
id: AccountID.make("account-1"),
|
||||
email: "user@example.com",
|
||||
url: "https://control.example.com",
|
||||
active_org_id: OrgID.make("org-1"),
|
||||
}),
|
||||
),
|
||||
config: () =>
|
||||
Effect.succeed(
|
||||
Option.some({
|
||||
provider: { opencode: { options: { apiKey: "{env:OPENCODE_CONSOLE_TOKEN}" } } },
|
||||
}),
|
||||
),
|
||||
token: () => Effect.succeed(Option.some(AccessToken.make("st_test_token"))),
|
||||
})
|
||||
|
||||
Account.config = mock(async () => ({
|
||||
provider: {
|
||||
opencode: {
|
||||
options: {
|
||||
apiKey: "{env:OPENCODE_CONSOLE_TOKEN}",
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
Account.token = mock(async () => AccessToken.make("st_test_token"))
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(fakeAccount),
|
||||
Layer.provideMerge(infra),
|
||||
)
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir()
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
|
||||
},
|
||||
})
|
||||
await provideTmpdirInstance(() =>
|
||||
Config.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* svc.get()
|
||||
expect(config.provider?.["opencode"]?.options?.apiKey).toBe("st_test_token")
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
Account.active = originalActive
|
||||
Account.config = originalConfig
|
||||
Account.token = originalToken
|
||||
if (originalControlToken !== undefined) {
|
||||
process.env["OPENCODE_CONSOLE_TOKEN"] = originalControlToken
|
||||
} else {
|
||||
@@ -1588,7 +1607,7 @@ test("local .opencode config can override MCP from project config", async () =>
|
||||
test("project config overrides remote well-known config", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let fetchedUrl: string | undefined
|
||||
const mockFetch = mock((url: string | URL | Request) => {
|
||||
globalThis.fetch = mock((url: string | URL | Request) => {
|
||||
const urlStr = url.toString()
|
||||
if (urlStr.includes(".well-known/opencode")) {
|
||||
fetchedUrl = urlStr
|
||||
@@ -1596,13 +1615,7 @@ test("project config overrides remote well-known config", async () => {
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
config: {
|
||||
mcp: {
|
||||
jira: {
|
||||
type: "remote",
|
||||
url: "https://jira.example.com/mcp",
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: false } },
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
@@ -1610,60 +1623,46 @@ test("project config overrides remote well-known config", async () => {
|
||||
)
|
||||
}
|
||||
return originalFetch(url)
|
||||
})
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const originalAuthAll = Auth.all
|
||||
Auth.all = mock(() =>
|
||||
Promise.resolve({
|
||||
"https://example.com": {
|
||||
type: "wellknown" as const,
|
||||
key: "TEST_TOKEN",
|
||||
token: "test-token",
|
||||
},
|
||||
}),
|
||||
const fakeAuth = Layer.mock(Auth.Service)({
|
||||
all: () =>
|
||||
Effect.succeed({
|
||||
"https://example.com": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
)
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
// Project config enables jira (overriding remote default)
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
mcp: {
|
||||
jira: {
|
||||
type: "remote",
|
||||
url: "https://jira.example.com/mcp",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
await provideTmpdirInstance(
|
||||
() =>
|
||||
Config.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const config = yield* svc.get()
|
||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
expect(config.mcp?.jira?.enabled).toBe(true)
|
||||
}),
|
||||
)
|
||||
),
|
||||
{
|
||||
git: true,
|
||||
config: { mcp: { jira: { type: "remote", url: "https://jira.example.com/mcp", enabled: true } } },
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await Config.get()
|
||||
// Verify fetch was called for wellknown config
|
||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
// Project config (enabled: true) should override remote (enabled: false)
|
||||
expect(config.mcp?.jira?.enabled).toBe(true)
|
||||
},
|
||||
})
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
Auth.all = originalAuthAll
|
||||
}
|
||||
})
|
||||
|
||||
test("wellknown URL with trailing slash is normalized", async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let fetchedUrl: string | undefined
|
||||
const mockFetch = mock((url: string | URL | Request) => {
|
||||
globalThis.fetch = mock((url: string | URL | Request) => {
|
||||
const urlStr = url.toString()
|
||||
if (urlStr.includes(".well-known/opencode")) {
|
||||
fetchedUrl = urlStr
|
||||
@@ -1671,13 +1670,7 @@ test("wellknown URL with trailing slash is normalized", async () => {
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
config: {
|
||||
mcp: {
|
||||
slack: {
|
||||
type: "remote",
|
||||
url: "https://slack.example.com/mcp",
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
mcp: { slack: { type: "remote", url: "https://slack.example.com/mcp", enabled: true } },
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
@@ -1685,43 +1678,35 @@ test("wellknown URL with trailing slash is normalized", async () => {
|
||||
)
|
||||
}
|
||||
return originalFetch(url)
|
||||
})
|
||||
globalThis.fetch = mockFetch as unknown as typeof fetch
|
||||
}) as unknown as typeof fetch
|
||||
|
||||
const originalAuthAll = Auth.all
|
||||
Auth.all = mock(() =>
|
||||
Promise.resolve({
|
||||
"https://example.com/": {
|
||||
type: "wellknown" as const,
|
||||
key: "TEST_TOKEN",
|
||||
token: "test-token",
|
||||
},
|
||||
}),
|
||||
const fakeAuth = Layer.mock(Auth.Service)({
|
||||
all: () =>
|
||||
Effect.succeed({
|
||||
"https://example.com/": new Auth.WellKnown({ type: "wellknown", key: "TEST_TOKEN", token: "test-token" }),
|
||||
}),
|
||||
})
|
||||
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(fakeAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provideMerge(infra),
|
||||
)
|
||||
|
||||
try {
|
||||
await using tmp = await tmpdir({
|
||||
git: true,
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
await provideTmpdirInstance(
|
||||
() =>
|
||||
Config.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
yield* svc.get()
|
||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
await Config.get()
|
||||
// Trailing slash should be stripped — no double slash in the fetch URL
|
||||
expect(fetchedUrl).toBe("https://example.com/.well-known/opencode")
|
||||
},
|
||||
})
|
||||
),
|
||||
{ git: true },
|
||||
).pipe(Effect.scoped, Effect.provide(layer), Effect.runPromise)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
Auth.all = originalAuthAll
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { WorkspaceID } from "../../src/control-plane/schema"
|
||||
import { Hono } from "hono"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { WorkspaceTable } from "../../src/control-plane/workspace.sql"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WorkspaceContext } from "../../src/control-plane/workspace-context"
|
||||
import { Database } from "../../src/storage/db"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import * as adaptors from "../../src/control-plane/adaptors"
|
||||
import type { Adaptor } from "../../src/control-plane/types"
|
||||
import { Flag } from "../../src/flag/flag"
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const original = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
// @ts-expect-error don't do this normally, but it works
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
|
||||
afterEach(() => {
|
||||
// @ts-expect-error don't do this normally, but it works
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = original
|
||||
})
|
||||
|
||||
type State = {
|
||||
workspace?: "first" | "second"
|
||||
calls: Array<{ method: string; url: string; body?: string }>
|
||||
}
|
||||
|
||||
const remote = { type: "testing", name: "remote-a" } as unknown as typeof WorkspaceTable.$inferInsert
|
||||
|
||||
async function setup(state: State) {
|
||||
const TestAdaptor: Adaptor = {
|
||||
configure(config) {
|
||||
return config
|
||||
},
|
||||
async create() {
|
||||
throw new Error("not used")
|
||||
},
|
||||
async remove() {},
|
||||
|
||||
async fetch(_config: unknown, input: RequestInfo | URL, init?: RequestInit) {
|
||||
const url =
|
||||
input instanceof Request || input instanceof URL
|
||||
? input.toString()
|
||||
: new URL(input, "http://workspace.test").toString()
|
||||
const request = new Request(url, init)
|
||||
const body = request.method === "GET" || request.method === "HEAD" ? undefined : await request.text()
|
||||
state.calls.push({
|
||||
method: request.method,
|
||||
url: `${new URL(request.url).pathname}${new URL(request.url).search}`,
|
||||
body,
|
||||
})
|
||||
return new Response("proxied", { status: 202 })
|
||||
},
|
||||
}
|
||||
|
||||
adaptors.installAdaptor("testing", TestAdaptor)
|
||||
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await Project.fromDirectory(tmp.path)
|
||||
|
||||
const id1 = WorkspaceID.ascending()
|
||||
const id2 = WorkspaceID.ascending()
|
||||
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(WorkspaceTable)
|
||||
.values([
|
||||
{
|
||||
id: id1,
|
||||
branch: "main",
|
||||
project_id: project.id,
|
||||
type: remote.type,
|
||||
name: remote.name,
|
||||
},
|
||||
{
|
||||
id: id2,
|
||||
branch: "main",
|
||||
project_id: project.id,
|
||||
type: "worktree",
|
||||
directory: tmp.path,
|
||||
name: "local",
|
||||
},
|
||||
])
|
||||
.run(),
|
||||
)
|
||||
|
||||
const { WorkspaceRouterMiddleware } = await import("../../src/control-plane/workspace-router-middleware")
|
||||
const app = new Hono().use(WorkspaceRouterMiddleware)
|
||||
|
||||
return {
|
||||
id1,
|
||||
id2,
|
||||
app,
|
||||
async request(input: RequestInfo | URL, init?: RequestInit) {
|
||||
return Instance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () =>
|
||||
WorkspaceContext.provide({
|
||||
workspaceID: state.workspace === "first" ? id1 : id2,
|
||||
fn: () => app.request(input, init),
|
||||
}),
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("control-plane/session-proxy-middleware", () => {
|
||||
test("forwards non-GET session requests for workspaces", async () => {
|
||||
const state: State = {
|
||||
workspace: "first",
|
||||
calls: [],
|
||||
}
|
||||
|
||||
const ctx = await setup(state)
|
||||
|
||||
ctx.app.post("/session/foo", (c) => c.text("local", 200))
|
||||
const response = await ctx.request("http://workspace.test/session/foo?x=1", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ hello: "world" }),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(202)
|
||||
expect(await response.text()).toBe("proxied")
|
||||
expect(state.calls).toEqual([
|
||||
{
|
||||
method: "POST",
|
||||
url: "/session/foo?x=1",
|
||||
body: '{"hello":"world"}',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
// It will behave this way when we have syncing
|
||||
//
|
||||
// test("does not forward GET requests", async () => {
|
||||
// const state: State = {
|
||||
// workspace: "first",
|
||||
// calls: [],
|
||||
// }
|
||||
|
||||
// const ctx = await setup(state)
|
||||
|
||||
// ctx.app.get("/session/foo", (c) => c.text("local", 200))
|
||||
// const response = await ctx.request("http://workspace.test/session/foo?x=1")
|
||||
|
||||
// expect(response.status).toBe(200)
|
||||
// expect(await response.text()).toBe("local")
|
||||
// expect(state.calls).toEqual([])
|
||||
// })
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user