Compare commits

...
Author SHA1 Message Date
Kit Langton 44e96fd358 Merge branch 'dev' into effect-sync-event 2026-04-02 11:48:33 -04:00
Aiden Cline 510a1e8140 ignore: fix typecheck in dev (#20702) 2026-04-02 15:38:30 +00:00
opencode-agent[bot] 159ede2d5c chore: generate 2026-04-02 15:19:26 +00:00
Noam Bressler 291a857fb8 feat: add optional messageID to ShellInput (#20657) 2026-04-02 10:18:16 -05:00
opencode-agent[bot] 57a5236e71 chore: generate 2026-04-02 15:01:45 +00:00
Aiden Cline 23c8656080 refactor: split up models.dev and config model definitions to prevent coupling (#20605) 2026-04-02 10:00:43 -05:00
opencode-agent[bot] ec3ae17e4d chore: update nix node_modules hashes 2026-04-02 10:23:59 +00:00
Brendan Allan 69d047ae7d cleanup event listeners with solid-primitives/event-listener (#20619) 2026-04-02 09:40:03 +00:00
Brendan Allan 327f62526a use solid-primitives/resize-observer across web code (#20613) 2026-04-02 17:24:10 +08:00
Shoubhit Dash d540d363a7 refactor: simplify solid reactivity across app and web (#20497) 2026-04-02 17:14:05 +08:00
Frank db93891373 zen: friendly trial ended message 2026-04-02 03:15:35 -04:00
Brendan Allan 0f488996b3 fix(node): set OPENCODE_CHANNEL during build (#20616) 2026-04-02 06:05:36 +00:00
opencode-agent[bot] a6f524ca08 chore: update nix node_modules hashes 2026-04-02 04:47:27 +00:00
Frank 811c7e2494 cli: update usage exceeded error 2026-04-02 00:25:23 -04:00
opencode-agent[bot] ebaa99aba2 chore: generate 2026-04-02 04:06:47 +00:00
dpuyosa d66e6dc25f feat(opencode): Add Venice AI package as dependency (#20570) 2026-04-01 23:05:49 -05:00
Kit Langton 89c0db86b9 fix(sync): restore ALS for published events 2026-04-02 00:04:56 -04:00
Kit Langton a68395bfef fix(sync): bind transaction inside effect 2026-04-01 23:58:06 -04:00
Kit Langton 2e6d7bb517 fix(sync): keep event application synchronous 2026-04-01 23:49:22 -04:00
Kit Langton dc719269b6 refactor(sync): effectify sync event 2026-04-01 23:31:36 -04:00
Kit Langton 336d28f112 fix(cli): restore colored help logo (#20592) 2026-04-02 03:21:07 +00:00
Kit Langton 916afb5220 refactor(account): share token freshness helper (#20591) 2026-04-02 02:57:45 +00:00
Aaron ZhuandAaron Zhu 5daf2fa7f0 fix(session): compaction agent responds in same language as conversation (#20581)
Co-authored-by: Aaron Zhu <aaron@Aarons-MacBook-Air.local>
2026-04-01 21:44:16 -05:00
733a3bd031 fix(core): prevent agent loop from stopping after tool calls with OpenAI-compatible providers (#14973)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-01 21:34:01 -05:00
Kit Langton 2e8e278441 fix(cli): use simple logo in CLI (#20585) 2026-04-02 02:27:09 +00:00
Kit Langton 0bae38c062 refactor(instruction): migrate to Effect service pattern (#20542) 2026-04-01 22:22:51 -04:00
Kit Langton a09b086729 test(app): block real llm calls in e2e prompts (#20579) 2026-04-01 22:22:43 -04:00
Aiden Cline df1c6c9e8d tui: add consent dialog when sharing for the first time (#20525) 2026-04-02 01:58:57 +00:00
opencode-agent[bot] 789d86f7b0 chore: generate 2026-04-02 01:56:34 +00:00
Kit Langton e148b318b7 fix(build): replace require() with dynamic import() in cross-spawn-spawner (#20580) 2026-04-01 21:55:35 -04:00
MCandAiden Cline 0cad775427 chore: add User-Agent headers for Cloudflare providers (#20538)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-04-01 20:02:17 -05:00
Kit Langton 00d6841f84 fix(account): refresh console tokens before expiry (#20558) 2026-04-02 00:25:24 +00:00
Sebastian 8a8f7b3e90 flock npm.add (#20557) 2026-04-02 00:21:26 +00:00
Kit Langton c526caae7b fix: show model display name in message footer and transcript (#20539) 2026-04-02 00:17:38 +00:00
Kit Langton b1c07488bd refactor(revert): yield SessionSummary.Service directly (#20541) 2026-04-01 20:10:59 -04:00
Kit Langton 92f8e03160 fix(test): use effect helper in snapshot race test (#20567) 2026-04-01 20:05:47 -04:00
Sebastian f6fd43e574 Refactor plugin/config loading, add theme-only plugin package support (#20556) 2026-04-01 23:50:22 +00:00
opencode-agent[bot] 854484babf chore: generate 2026-04-01 23:49:44 +00:00
Kit Langton e4ff1ea778 refactor(bash): use Effect ChildProcess for bash tool execution (#20496) 2026-04-01 19:48:47 -04:00
Kit Langton 26fb6b8788 refactor: add Effect-returning versions of MessageV2 functions (#20374) 2026-04-01 19:48:36 -04:00
opencode-agent[bot] 4214ae205d chore: generate 2026-04-01 23:48:30 +00:00
Kit Langton d9d4f895bc fix(test): auto-acknowledge tool-result follow-ups in mock LLM server (#20528) 2026-04-01 23:47:26 +00:00
Kit Langton 48db7cf07a fix(opencode): batch snapshot revert without reordering (#20564) 2026-04-01 23:46:06 +00:00
Luke Parker 802d165572 chore(tui): clean up scroll config follow-up (#20561) 2026-04-02 09:36:49 +10:00
Luke Parker f7f41dc3a0 fix(tui): apply scroll configuration uniformly across all scrollboxes (#14735) 2026-04-02 09:15:19 +10:00
Aiden Cline 1fcfb69bf7 feat: add new provider plugin hook for resolving models and sync models from github models endpoint (falls back to models.dev) (#20533) 2026-04-01 23:04:14 +00:00
Luke Parker fa96cb9c6e Fix selection expansion by retaining focused input selections during global key events (#20205) 2026-04-02 08:43:40 +10:00
Sebastian cc30bfc94b resolve subpath only packages for plugins (#20555) 2026-04-01 22:14:36 +00:00
880c0a7477 fix: normalize filepath in FileTime to prevent Windows path mismatch (#20367)
Co-authored-by: JosXa <info@josxa.dev>
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-04-02 07:45:50 +10:00
Frank eabf3caeb9 zen: sync 2026-04-01 17:41:04 -04:00
c9326fc199 refactor: replace BunProc with Npm module using @npmcli/arborist (#18308)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-04-01 21:01:37 +00:00
Frank d7481f4593 wip: zen 2026-04-01 14:17:31 -04:00
Kit Langton f3f728ec27 test(app): fix isolated backend follow-ups (#20513) 2026-04-01 17:43:19 +00:00
156 changed files with 6040 additions and 3079 deletions
-3
View File
@@ -100,9 +100,6 @@ jobs:
run: bun --cwd packages/app test:e2e:local run: bun --cwd packages/app test:e2e:local
env: env:
CI: true CI: true
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
OPENCODE_E2E_MODEL: opencode/claude-haiku-4-5
OPENCODE_E2E_REQUIRE_PAID: "true"
timeout-minutes: 30 timeout-minutes: 30
- name: Upload Playwright artifacts - name: Upload Playwright artifacts
+632 -466
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-bjfe8/aD0hvUQQEfaNdmKV/Y3dzpf8oz1OUJdgf61WI=", "x86_64-linux": "sha256-SQVfq41OQdGCgWuWqyqIN6aggL0r3Hzn2hJ9BwPJN+I=",
"aarch64-linux": "sha256-iU9v+ekSCB/qTUG+pOOpSMhPh+0hWnWU5jzDNllEkxU=", "aarch64-linux": "sha256-4w/1HhxsTzPFTHNf4JlnKle6Boz1gVTEedWG64T8E/M=",
"aarch64-darwin": "sha256-SgNydQLeAjbX0J49f2VKcgKg2Y30pK826R2qQJBMWE4=", "aarch64-darwin": "sha256-uMd+pU1u1yqP4OP/9461Tyy3zwwv/llr+rlllLjM98A=",
"x86_64-darwin": "sha256-/rzwNuI9x55qi0UcU7QvPUTupErmkt62T09g1omXkQk=" "x86_64-darwin": "sha256-BhIW3FPqKkM2vGfCrxXUvj5tarey33Q7dxCuaj5A+yU="
} }
} }
+22 -1
View File
@@ -1,5 +1,5 @@
import { base64Decode, base64Encode } from "@opencode-ai/util/encode" import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { expect, type Locator, type Page } from "@playwright/test" import { expect, type Locator, type Page, type Route } from "@playwright/test"
import fs from "node:fs/promises" import fs from "node:fs/promises"
import os from "node:os" import os from "node:os"
import path from "node:path" import path from "node:path"
@@ -43,6 +43,27 @@ export async function defocus(page: Page) {
.catch(() => undefined) .catch(() => undefined)
} }
export async function withNoReplyPrompt<T>(page: Page, fn: () => Promise<T>) {
const url = "**/session/*/prompt_async"
const route = async (input: Route) => {
const body = input.request().postDataJSON()
await input.continue({
postData: JSON.stringify({ ...body, noReply: true }),
headers: {
...input.request().headers(),
"content-type": "application/json",
},
})
}
await page.route(url, route)
try {
return await fn()
} finally {
await page.unroute(url, route)
}
}
async function terminalID(term: Locator) { async function terminalID(term: Locator) {
const id = await term.getAttribute(terminalAttr) const id = await term.getAttribute(terminalAttr)
if (id) return id if (id) return id
+13 -2
View File
@@ -44,6 +44,14 @@ async function waitForHealth(url: string, probe = "/global/health") {
throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`) throw new Error(`Timed out waiting for backend health at ${url}${probe}${last ? ` (${last})` : ""}`)
} }
async function waitExit(proc: ReturnType<typeof spawn>, timeout = 10_000) {
if (proc.exitCode !== null) return
await Promise.race([
new Promise<void>((resolve) => proc.once("exit", () => resolve())),
new Promise<void>((resolve) => setTimeout(resolve, timeout)),
])
}
const LOG_CAP = 100 const LOG_CAP = 100
function cap(input: string[]) { function cap(input: string[]) {
@@ -62,7 +70,6 @@ export async function startBackend(label: string): Promise<Handle> {
const opencodeDir = path.join(repoDir, "packages", "opencode") const opencodeDir = path.join(repoDir, "packages", "opencode")
const env = { const env = {
...process.env, ...process.env,
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true", OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true", OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
@@ -117,7 +124,11 @@ export async function startBackend(label: string): Promise<Handle> {
async stop() { async stop() {
if (proc.exitCode === null) { if (proc.exitCode === null) {
proc.kill("SIGTERM") proc.kill("SIGTERM")
await new Promise((resolve) => proc.once("exit", () => resolve(undefined))).catch(() => undefined) await waitExit(proc)
}
if (proc.exitCode === null) {
proc.kill("SIGKILL")
await waitExit(proc)
} }
await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined) await fs.rm(sandbox, { recursive: true, force: true }).catch(() => undefined)
}, },
+19
View File
@@ -15,6 +15,7 @@ import {
waitSlug, waitSlug,
waitSession, waitSession,
} from "./actions" } from "./actions"
import { openaiModel, withMockOpenAI } from "./prompt/mock"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils" import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
type LLMFixture = { type LLMFixture = {
@@ -47,6 +48,7 @@ type LLMFixture = {
wait: (count: number) => Promise<void> wait: (count: number) => Promise<void>
inputs: () => Promise<Record<string, unknown>[]> inputs: () => Promise<Record<string, unknown>[]>
pending: () => Promise<number> pending: () => Promise<number>
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
} }
export const settingsKey = "settings.v3" export const settingsKey = "settings.v3"
@@ -83,6 +85,7 @@ type TestFixtures = {
gotoSession: (sessionID?: string) => Promise<void> gotoSession: (sessionID?: string) => Promise<void>
withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T> withProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T> withBackendProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
withMockProject: <T>(callback: (project: ProjectHandle) => Promise<T>, options?: ProjectOptions) => Promise<T>
} }
type WorkerFixtures = { type WorkerFixtures = {
@@ -132,6 +135,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
wait: (count) => rt.runPromise(svc.wait(count)), wait: (count) => rt.runPromise(svc.wait(count)),
inputs: () => rt.runPromise(svc.inputs), inputs: () => rt.runPromise(svc.inputs),
pending: () => rt.runPromise(svc.pending), pending: () => rt.runPromise(svc.pending),
misses: () => rt.runPromise(svc.misses),
}) })
} finally { } finally {
await rt.dispose() await rt.dispose()
@@ -193,6 +197,21 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }), runProject(page, callback, { ...options, serverUrl: backend.url, sdk: backend.sdk }),
) )
}, },
withMockProject: async ({ page, llm, backend }, use) => {
await use((callback, options) =>
withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: () =>
runProject(page, callback, {
...options,
model: options?.model ?? openaiModel,
serverUrl: backend.url,
sdk: backend.sdk,
}),
}),
)
},
}) })
async function runProject<T>( async function runProject<T>(
+1 -1
View File
@@ -2,7 +2,7 @@ import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { clickListItem } from "../actions" import { clickListItem } from "../actions"
test("smoke model selection updates prompt footer", async ({ page, gotoSession }) => { test.fixme("smoke model selection updates prompt footer", async ({ page, gotoSession }) => {
await gotoSession() await gotoSession()
await page.locator(promptSelector).click() await page.locator(promptSelector).click()
@@ -10,6 +10,7 @@ import {
waitSession, waitSession,
waitSessionSaved, waitSessionSaved,
waitSlug, waitSlug,
withNoReplyPrompt,
} from "../actions" } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils" import { dirSlug, resolveDirectory } from "../utils"
@@ -81,8 +82,10 @@ test("switching back to a project opens the latest workspace session", async ({
// Create a session by sending a prompt // Create a session by sending a prompt
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await withNoReplyPrompt(page, async () => {
await prompt.fill("test") await prompt.fill("test")
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
})
// Wait for the URL to update with the new session ID // Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("") await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
@@ -9,6 +9,7 @@ import {
waitSession, waitSession,
waitSessionSaved, waitSessionSaved,
waitSlug, waitSlug,
withNoReplyPrompt,
} from "../actions" } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors" import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils" import { createSdk } from "../utils"
@@ -58,8 +59,10 @@ async function createSessionFromWorkspace(
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await withNoReplyPrompt(page, async () => {
await prompt.fill(text) await prompt.fill(text)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
})
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("") await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url()) const sessionID = sessionIDFromUrl(page.url())
+10
View File
@@ -16,6 +16,16 @@ export function promptMatch(token: string) {
return (hit: Hit) => bodyText(hit).includes(token) return (hit: Hit) => bodyText(hit).includes(token)
} }
/**
* Match requests whose body contains the exact serialized tool input.
* The seed prompts embed JSON.stringify(input) in the prompt text, which
* gets escaped again inside the JSON body — so we double-escape to match.
*/
export function inputMatch(input: unknown) {
const escaped = JSON.stringify(JSON.stringify(input)).slice(1, -1)
return (hit: Hit) => bodyText(hit).includes(escaped)
}
export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) { export async function withMockOpenAI<T>(input: { serverUrl: string; llmUrl: string; fn: () => Promise<T> }) {
const sdk = createSdk(undefined, input.serverUrl) const sdk = createSdk(undefined, input.serverUrl)
const prev = await sdk.global.config.get().then((res) => res.data ?? {}) const prev = await sdk.global.config.get().then((res) => res.data ?? {})
+13 -55
View File
@@ -3,9 +3,11 @@ import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { assistantText, sessionIDFromUrl } from "../actions" import { assistantText, sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors" import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock" import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim() const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
type Sdk = ReturnType<typeof createSdk>
const isBash = (part: unknown): part is ToolPart => { const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false if (!part || typeof part !== "object") return false
@@ -14,47 +16,15 @@ const isBash = (part: unknown): part is ToolPart => {
return "state" in part return "state" in part
} }
async function edge(page: Page, pos: "start" | "end") {
await page.locator(promptSelector).evaluate((el: HTMLDivElement, pos: "start" | "end") => {
const selection = window.getSelection()
if (!selection) return
const walk = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
const nodes: Text[] = []
for (let node = walk.nextNode(); node; node = walk.nextNode()) {
nodes.push(node as Text)
}
if (nodes.length === 0) {
const node = document.createTextNode("")
el.appendChild(node)
nodes.push(node)
}
const node = pos === "start" ? nodes[0]! : nodes[nodes.length - 1]!
const range = document.createRange()
range.setStart(node, pos === "start" ? 0 : (node.textContent ?? "").length)
range.collapse(true)
selection.removeAllRanges()
selection.addRange(range)
}, pos)
}
async function wait(page: Page, value: string) { async function wait(page: Page, value: string) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value) await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
} }
async function reply( async function reply(sdk: Sdk, sessionID: string, token: string) {
sdk: { session: { messages: Parameters<typeof assistantText>[0]["session"] } }, await expect.poll(() => assistantText(sdk, sessionID), { timeout: 90_000 }).toContain(token)
sessionID: string,
token: string,
) {
await expect
.poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 })
.toContain(token)
} }
async function shell(sdk: Parameters<typeof withSession>[0], sessionID: string, cmd: string, token: string) { async function shell(sdk: Sdk, sessionID: string, cmd: string, token: string) {
await expect await expect
.poll( .poll(
async () => { async () => {
@@ -142,13 +112,9 @@ test("prompt history restores unsent draft with arrow navigation", async ({
}) })
}) })
test("shell history stays separate from normal prompt history", async ({ page, llm, backend, withBackendProject }) => { test.fixme("shell history stays separate from normal prompt history", async ({ page, sdk, gotoSession }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
await withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: async () => {
const firstToken = `E2E_SHELL_ONE_${Date.now()}` const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}` const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}` const normalToken = `E2E_NORMAL_${Date.now()}`
@@ -156,11 +122,8 @@ test("shell history stays separate from normal prompt history", async ({ page, l
const second = `echo ${secondToken}` const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}` const normal = `Reply with exactly: ${normalToken}`
await llm.textMatch(titleMatch, "E2E Title") await gotoSession()
await llm.textMatch(promptMatch(normalToken), normalToken)
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await prompt.click() await prompt.click()
@@ -171,15 +134,17 @@ test("shell history stays separate from normal prompt history", async ({ page, l
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 }) await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())! const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID) await shell(sdk, sessionID, first, firstToken)
await shell(project.sdk, sessionID, first, firstToken)
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
await page.keyboard.type(second) await page.keyboard.type(second)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await shell(project.sdk, sessionID, second, secondToken) await shell(sdk, sessionID, second, secondToken)
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click() await prompt.click()
await page.keyboard.type("!") await page.keyboard.type("!")
@@ -202,16 +167,9 @@ test("shell history stays separate from normal prompt history", async ({ page, l
await page.keyboard.type(normal) await page.keyboard.type(normal)
await page.keyboard.press("Enter") await page.keyboard.press("Enter")
await wait(page, "") await wait(page, "")
await reply(project.sdk, sessionID, normalToken) await reply(sdk, sessionID, normalToken)
await prompt.click() await prompt.click()
await page.keyboard.press("ArrowUp") await page.keyboard.press("ArrowUp")
await wait(page, normal) await wait(page, normal)
},
{
model: openaiModel,
},
)
},
})
}) })
@@ -27,6 +27,7 @@ test("/share and /unshare update session share state", async ({ page, withBacken
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => { await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
project.trackSession(session.id)
const prompt = page.locator(promptSelector) const prompt = page.locator(promptSelector)
await seed(project.sdk, session.id) await seed(project.sdk, session.id)
@@ -1,7 +1,9 @@
import { seedSessionTask, withSession } from "../actions" import { seedSessionTask, withSession } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { inputMatch } from "../prompt/mock"
import { promptSelector } from "../selectors"
test("task tool child-session link does not trigger stale show errors", async ({ page, withBackendProject }) => { test("task tool child-session link does not trigger stale show errors", async ({ page, llm, withMockProject }) => {
test.setTimeout(120_000) test.setTimeout(120_000)
const errs: string[] = [] const errs: string[] = []
@@ -10,17 +12,22 @@ test("task tool child-session link does not trigger stale show errors", async ({
} }
page.on("pageerror", onError) page.on("pageerror", onError)
await withBackendProject(async ({ gotoSession, trackSession, sdk }) => { try {
await withMockProject(async ({ gotoSession, trackSession, sdk }) => {
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => { await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
trackSession(session.id) const taskInput = {
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session", description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.", prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
subagent_type: "general",
}
await llm.toolMatch(inputMatch(taskInput), "task", taskInput)
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: taskInput.description,
prompt: taskInput.prompt,
}) })
trackSession(child.sessionID) trackSession(child.sessionID)
try {
await gotoSession(session.id) await gotoSession(session.id)
const link = page const link = page
@@ -31,11 +38,11 @@ test("task tool child-session link does not trigger stale show errors", async ({
await link.click() await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 }) await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000) await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
expect(errs).toEqual([]) await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
})
})
} finally { } finally {
page.off("pageerror", onError) page.off("pageerror", onError)
} }
})
})
}) })
@@ -14,6 +14,7 @@ import {
sessionTodoToggleButtonSelector, sessionTodoToggleButtonSelector,
} from "../selectors" } from "../selectors"
import { modKey } from "../utils" import { modKey } from "../utils"
import { inputMatch } from "../prompt/mock"
type Sdk = Parameters<typeof clearSessionDockSeed>[0] type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" } type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
@@ -22,12 +23,13 @@ async function withDockSession<T>(
sdk: Sdk, sdk: Sdk,
title: string, title: string,
fn: (session: { id: string; title: string }) => Promise<T>, fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[] }, opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
) { ) {
const session = await sdk.session const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title }) .create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data) .then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id") if (!session?.id) throw new Error("Session create did not return an id")
opts?.trackSession?.(session.id)
try { try {
return await fn(session) return await fn(session)
} finally { } finally {
@@ -35,6 +37,17 @@ async function withDockSession<T>(
} }
} }
const defaultQuestions = [
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
]
test.setTimeout(120_000) test.setTimeout(120_000)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) { async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
@@ -258,7 +271,10 @@ async function withMockPermission<T>(
test("default dock shows prompt input", async ({ page, withBackendProject }) => { test("default dock shows prompt input", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock default", async (session) => { await withDockSession(
project.sdk,
"e2e composer dock default",
async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
@@ -268,7 +284,9 @@ test("default dock shows prompt input", async ({ page, withBackendProject }) =>
await page.locator(promptSelector).click() await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused() await expect(page.locator(promptSelector)).toBeFocused()
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
@@ -285,24 +303,19 @@ test("auto-accept toggle works before first submit", async ({ page, withBackendP
}) })
}) })
test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => { test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => {
await withBackendProject(async (project) => { await withMockProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question", async (session) => { await withDockSession(
project.sdk,
"e2e composer dock question",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => { await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: [ questions: defaultQuestions,
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
@@ -313,28 +326,25 @@ test("blocked question flow unblocks after submit", async ({ page, withBackendPr
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => { test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => {
await withBackendProject(async (project) => { await withMockProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question keyboard", async (session) => { await withDockSession(
project.sdk,
"e2e composer dock question keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => { await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: [ questions: defaultQuestions,
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
@@ -351,28 +361,25 @@ test("blocked question flow supports keyboard shortcuts", async ({ page, withBac
await page.keyboard.press(`${modKey}+Enter`) await page.keyboard.press(`${modKey}+Enter`)
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
test("blocked question flow supports escape dismiss", async ({ page, withBackendProject }) => { test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => {
await withBackendProject(async (project) => { await withMockProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question escape", async (session) => { await withDockSession(
project.sdk,
"e2e composer dock question escape",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => { await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: session.id, sessionID: session.id,
questions: [ questions: defaultQuestions,
{
header: "Need input",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
@@ -384,13 +391,18 @@ test("blocked question flow supports escape dismiss", async ({ page, withBackend
await page.keyboard.press("Escape") await page.keyboard.press("Escape")
await expectQuestionOpen(page) await expectQuestionOpen(page)
}) })
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => { test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock permission once", async (session) => { await withDockSession(
project.sdk,
"e2e composer dock permission once",
async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
@@ -413,13 +425,18 @@ test("blocked permission flow supports allow once", async ({ page, withBackendPr
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => { test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock permission reject", async (session) => { await withDockSession(
project.sdk,
"e2e composer dock permission reject",
async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
@@ -441,13 +458,18 @@ test("blocked permission flow supports reject", async ({ page, withBackendProjec
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => { test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock permission always", async (session) => { await withDockSession(
project.sdk,
"e2e composer dock permission always",
async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
await withMockPermission( await withMockPermission(
@@ -470,16 +492,32 @@ test("blocked permission flow supports allow always", async ({ page, withBackend
await expectPermissionOpen(page) await expectPermissionOpen(page)
}, },
) )
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
test("child session question request blocks parent dock and unblocks after submit", async ({ test("child session question request blocks parent dock and unblocks after submit", async ({
page, page,
withBackendProject, llm,
withMockProject,
}) => { }) => {
await withBackendProject(async (project) => { const questions = [
await withDockSession(project.sdk, "e2e composer dock child question parent", async (session) => { {
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
]
await withMockProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock child question parent",
async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
const child = await project.sdk.session const child = await project.sdk.session
@@ -489,21 +527,14 @@ test("child session question request blocks parent dock and unblocks after submi
}) })
.then((r) => r.data) .then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id") if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try { try {
await withDockSeed(project.sdk, child.id, async () => { await withDockSeed(project.sdk, child.id, async () => {
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, { await seedSessionQuestion(project.sdk, {
sessionID: child.id, sessionID: child.id,
questions: [ questions,
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
}) })
const dock = page.locator(questionDockSelector) const dock = page.locator(questionDockSelector)
@@ -517,7 +548,9 @@ test("child session question request blocks parent dock and unblocks after submi
} finally { } finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id }) await cleanupSession({ sdk: project.sdk, sessionID: child.id })
} }
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
@@ -526,7 +559,10 @@ test("child session permission request blocks parent dock and supports allow onc
withBackendProject, withBackendProject,
}) => { }) => {
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock child permission parent", async (session) => { await withDockSession(
project.sdk,
"e2e composer dock child permission parent",
async (session) => {
await project.gotoSession(session.id) await project.gotoSession(session.id)
await setAutoAccept(page, false) await setAutoAccept(page, false)
@@ -537,6 +573,7 @@ test("child session permission request blocks parent dock and supports allow onc
}) })
.then((r) => r.data) .then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id") if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try { try {
await withMockPermission( await withMockPermission(
@@ -563,13 +600,18 @@ test("child session permission request blocks parent dock and supports allow onc
} finally { } finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id }) await cleanupSession({ sdk: project.sdk, sessionID: child.id })
} }
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => { test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock todo", async (session) => { await withDockSession(
project.sdk,
"e2e composer dock todo",
async (session) => {
const dock = await todoDock(page, session.id) const dock = await todoDock(page, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible() await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
@@ -595,25 +637,32 @@ test("todo dock transitions and collapse behavior", async ({ page, withBackendPr
} finally { } finally {
await dock.clear() await dock.clear()
} }
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => { test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMockProject }) => {
await withBackendProject(async (project) => { const questions = [
await withDockSession(project.sdk, "e2e composer dock keyboard", async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: [
{ {
header: "Need input", header: "Need input",
question: "Pick one option", question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }], options: [{ label: "Continue", description: "Continue now" }],
}, },
], ]
await withMockProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions,
}) })
await expectQuestionBlocked(page) await expectQuestionBlocked(page)
@@ -622,6 +671,8 @@ test("keyboard focus stays off prompt while blocked", async ({ page, withBackend
await page.keyboard.type("abc") await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0) await expect(page.locator(promptSelector)).toHaveCount(0)
}) })
}) },
{ trackSession: project.trackSession },
)
}) })
}) })
@@ -8,11 +8,11 @@ import {
waitSession, waitSession,
waitSessionIdle, waitSessionIdle,
waitSlug, waitSlug,
withNoReplyPrompt,
} from "../actions" } from "../actions"
import { import {
promptAgentSelector, promptAgentSelector,
promptModelSelector, promptModelSelector,
promptSelector,
promptVariantSelector, promptVariantSelector,
workspaceItemSelector, workspaceItemSelector,
workspaceNewSessionSelector, workspaceNewSessionSelector,
@@ -231,11 +231,14 @@ async function goto(page: Page, directory: string, sessionID?: string) {
} }
async function submit(page: Page, value: string) { async function submit(page: Page, value: string) {
const prompt = page.locator(promptSelector) const prompt = page.locator('[data-component="prompt-input"]')
await expect(prompt).toBeVisible() await expect(prompt).toBeVisible()
await withNoReplyPrompt(page, async () => {
await prompt.click() await prompt.click()
await prompt.fill(value) await prompt.fill(value)
await prompt.press("Enter") await prompt.press("Enter")
})
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("") await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const id = sessionIDFromUrl(page.url()) const id = sessionIDFromUrl(page.url())
+40 -33
View File
@@ -1,6 +1,6 @@
import { waitSessionIdle, withSession } from "../actions" import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures" import { test, expect } from "../fixtures"
import { createSdk } from "../utils" import { inputMatch } from "../prompt/mock"
const count = 14 const count = 14
@@ -40,7 +40,14 @@ function edit(file: string, prev: string, next: string) {
) )
} }
async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patchText: string) { async function patchWithMock(
llm: Parameters<typeof test>[0]["llm"],
sdk: Parameters<typeof withSession>[0],
sessionID: string,
patchText: string,
) {
const callsBefore = await llm.calls()
await llm.toolMatch(inputMatch({ patchText }), "apply_patch", { patchText })
await sdk.session.promptAsync({ await sdk.session.promptAsync({
sessionID, sessionID,
agent: "build", agent: "build",
@@ -54,6 +61,11 @@ async function patch(sdk: ReturnType<typeof createSdk>, sessionID: string, patch
parts: [{ type: "text", text: "Apply the provided patch exactly once." }], parts: [{ type: "text", text: "Apply the provided patch exactly once." }],
}) })
// Wait for the agent loop to actually start before checking idle.
// promptAsync is fire-and-forget — without this, waitSessionIdle can
// return immediately because the session status is still undefined.
await expect.poll(() => llm.calls().then((c) => c > callsBefore), { timeout: 30_000 }).toBe(true)
await waitSessionIdle(sdk, sessionID, 120_000) await waitSessionIdle(sdk, sessionID, 120_000)
} }
@@ -233,8 +245,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
} }
} }
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => { test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, withMockProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
const tag = `review-comment-${Date.now()}` const tag = `review-comment-${Date.now()}`
@@ -243,16 +254,15 @@ test("review applies inline comment clicks without horizontal overflow", async (
await page.setViewportSize({ width: 1280, height: 900 }) await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => { await withMockProject(async (project) => {
const sdk = createSdk(project.directory) await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
project.trackSession(session.id)
await withSession(sdk, `e2e review comment ${tag}`, async (session) => { await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length return diff.length
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -283,8 +293,7 @@ test("review applies inline comment clicks without horizontal overflow", async (
}) })
}) })
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => { test("review file comments submit on click without clipping actions", async ({ page, llm, withMockProject }) => {
test.skip(true, "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
const tag = `review-file-comment-${Date.now()}` const tag = `review-file-comment-${Date.now()}`
@@ -293,16 +302,15 @@ test("review file comments submit on click without clipping actions", async ({ p
await page.setViewportSize({ width: 1280, height: 900 }) await page.setViewportSize({ width: 1280, height: 900 })
await withProject(async (project) => { await withMockProject(async (project) => {
const sdk = createSdk(project.directory) await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
project.trackSession(session.id)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => { await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length return diff.length
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -334,8 +342,7 @@ test("review file comments submit on click without clipping actions", async ({ p
}) })
}) })
test("review keeps scroll position after a live diff update", async ({ page, withProject }) => { test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, withMockProject }) => {
test.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.setTimeout(180_000) test.setTimeout(180_000)
const tag = `review-${Date.now()}` const tag = `review-${Date.now()}`
@@ -345,16 +352,15 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
await page.setViewportSize({ width: 1600, height: 1000 }) await page.setViewportSize({ width: 1600, height: 1000 })
await withProject(async (project) => { await withMockProject(async (project) => {
const sdk = createSdk(project.directory) await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
project.trackSession(session.id)
await withSession(sdk, `e2e review ${tag}`, async (session) => { await patchWithMock(llm, project.sdk, session.id, seed(list))
await patch(sdk, session.id, seed(list))
await expect await expect
.poll( .poll(
async () => { async () => {
const info = await sdk.session.get({ sessionID: session.id }).then((res) => res.data) const info = await project.sdk.session.get({ sessionID: session.id }).then((res) => res.data)
return info?.summary?.files ?? 0 return info?.summary?.files ?? 0
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -364,7 +370,7 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
return diff.length return diff.length
}, },
{ timeout: 60_000 }, { timeout: 60_000 },
@@ -381,15 +387,16 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first() const view = page.locator('[data-slot="session-review-scroll"] .scroll-view__viewport').first()
await expect(view).toBeVisible() await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ }) const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, { await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
timeout: 60_000,
})
await expand(page) await expand(page)
await waitMark(page, hit.file, hit.mark) await waitMark(page, hit.file, hit.mark)
const row = page const row = page
.getByRole("heading", { level: 3, name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) }) .getByRole("heading", {
level: 3,
name: new RegExp(hit.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
})
.first() .first()
await expect(row).toBeVisible() await expect(row).toBeVisible()
await row.evaluate((el) => el.scrollIntoView({ block: "center" })) await row.evaluate((el) => el.scrollIntoView({ block: "center" }))
@@ -398,12 +405,12 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
const prev = await spot(page, hit.file) const prev = await spot(page, hit.file)
if (!prev) throw new Error(`missing review row for ${hit.file}`) if (!prev) throw new Error(`missing review row for ${hit.file}`)
await patch(sdk, session.id, edit(hit.file, hit.mark, next)) await patchWithMock(llm, project.sdk, session.id, edit(hit.file, hit.mark, next))
await expect await expect
.poll( .poll(
async () => { async () => {
const diff = await sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? []) const diff = await project.sdk.session.diff({ sessionID: session.id }).then((res) => res.data ?? [])
const item = diff.find((item) => item.file === hit.file) const item = diff.find((item) => item.file === hit.file)
return typeof item?.after === "string" ? item.after : "" return typeof item?.after === "string" ? item.after : ""
}, },
@@ -58,6 +58,7 @@ test("slash undo sets revert and restores prior prompt", async ({ page, withBack
const sdk = project.sdk const sdk = project.sdk
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -90,6 +91,7 @@ test("slash redo clears revert and restores latest state", async ({ page, withBa
const sdk = project.sdk const sdk = project.sdk
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => { await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const seeded = await seedConversation({ page, sdk, sessionID: session.id, token }) const seeded = await seedConversation({ page, sdk, sessionID: session.id, token })
@@ -138,6 +140,7 @@ test("slash undo/redo traverses multi-step revert stack", async ({ page, withBac
const sdk = project.sdk const sdk = project.sdk
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => { await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const first = await seedConversation({ const first = await seedConversation({
+4
View File
@@ -38,6 +38,7 @@ test("session can be renamed via header menu", async ({ page, withBackendProject
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withSession(project.sdk, originalTitle, async (session) => { await withSession(project.sdk, originalTitle, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle) await expect(page.getByRole("heading", { level: 1 }).first()).toHaveText(originalTitle)
@@ -73,6 +74,7 @@ test("session can be archived via header menu", async ({ page, withBackendProjec
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
@@ -100,6 +102,7 @@ test("session can be deleted via header menu", async ({ page, withBackendProject
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
const menu = await openSessionMoreMenu(page, session.id) const menu = await openSessionMoreMenu(page, session.id)
@@ -133,6 +136,7 @@ test("session can be shared and unshared via header button", async ({ page, with
await withBackendProject(async (project) => { await withBackendProject(async (project) => {
await withSession(project.sdk, title, async (session) => { await withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id) await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id) await project.gotoSession(session.id)
+2 -1
View File
@@ -46,9 +46,10 @@
"@solid-primitives/active-element": "2.1.3", "@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2", "@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2", "@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1", "@solid-primitives/i18n": "2.2.1",
"@solid-primitives/media": "2.3.3", "@solid-primitives/media": "2.3.3",
"@solid-primitives/resize-observer": "2.1.3", "@solid-primitives/resize-observer": "2.1.5",
"@solid-primitives/scroll": "2.1.3", "@solid-primitives/scroll": "2.1.3",
"@solid-primitives/storage": "catalog:", "@solid-primitives/storage": "catalog:",
"@solid-primitives/timer": "1.4.4", "@solid-primitives/timer": "1.4.4",
+2 -2
View File
@@ -1,6 +1,7 @@
import { useIsRouting, useLocation } from "@solidjs/router" import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js" import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -349,13 +350,12 @@ export function DebugBar() {
syncHeap() syncHeap()
start() start()
document.addEventListener("visibilitychange", vis) makeEventListener(document, "visibilitychange", vis)
onCleanup(() => { onCleanup(() => {
if (one !== 0) cancelAnimationFrame(one) if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two) if (two !== 0) cancelAnimationFrame(two)
stop() stop()
document.removeEventListener("visibilitychange", vis)
for (const ob of obs) ob.disconnect() for (const ob of obs) ob.disconnect()
}) })
}) })
@@ -1,4 +1,5 @@
import { onCleanup, onMount } from "solid-js" import { onMount } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt" import { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
@@ -181,15 +182,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
} }
onMount(() => { onMount(() => {
document.addEventListener("dragover", handleGlobalDragOver) makeEventListener(document, "dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave) makeEventListener(document, "dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop) makeEventListener(document, "drop", handleGlobalDrop)
})
onCleanup(() => {
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
}) })
return { return {
@@ -1,11 +1,11 @@
import { Tooltip } from "@opencode-ai/ui/tooltip" import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { import {
children, children,
createEffect, createEffect,
createMemo, createMemo,
createSignal, createSignal,
type JSXElement, type JSXElement,
onCleanup,
onMount, onMount,
type ParentProps, type ParentProps,
Show, Show,
@@ -46,12 +46,9 @@ export function ServerRow(props: ServerRowProps) {
}) })
onMount(() => { onMount(() => {
check()
if (typeof ResizeObserver !== "function") return if (typeof ResizeObserver !== "function") return
const observer = new ResizeObserver(check) createResizeObserver([nameRef, versionRef], check)
if (nameRef) observer.observe(nameRef) check()
if (versionRef) observer.observe(versionRef)
onCleanup(() => observer.disconnect())
}) })
const tooltipValue = () => ( const tooltipValue = () => (
@@ -1,5 +1,6 @@
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js" import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode-ai/ui/button" import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon" import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -250,8 +251,7 @@ function useKeyCapture(input: {
input.stop() input.stop()
} }
document.addEventListener("keydown", handle, true) makeEventListener(document, "keydown", handle, { capture: true })
onCleanup(() => document.removeEventListener("keydown", handle, true))
}) })
} }
+2 -5
View File
@@ -2,6 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useDialog } from "@opencode-ai/ui/context/dialog" import { useDialog } from "@opencode-ai/ui/context/dialog"
import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js" import { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { dict as en } from "@/i18n/en" import { dict as en } from "@/i18n/en"
@@ -378,11 +379,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
} }
onMount(() => { onMount(() => {
document.addEventListener("keydown", handleKeyDown) makeEventListener(document, "keydown", handleKeyDown)
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
}) })
function register(cb: () => CommandOption[]): void function register(cb: () => CommandOption[]): void
+6 -10
View File
@@ -1,7 +1,8 @@
import type { Event } from "@opencode-ai/sdk/v2/client" import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { createGlobalEmitter } from "@solid-primitives/event-bus" import { createGlobalEmitter } from "@solid-primitives/event-bus"
import { batch, onCleanup } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener"
import { batch, onCleanup, onMount } from "solid-js"
import z from "zod" import z from "zod"
import { createSdkForServer } from "@/utils/server" import { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language" import { useLanguage } from "./language"
@@ -206,21 +207,16 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
clearHeartbeat() clearHeartbeat()
} }
const onVisibility = () => { onMount(() => {
if (typeof document === "undefined") return makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return if (document.visibilityState !== "visible") return
if (!started) return if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort() attempt?.abort()
} })
if (typeof document !== "undefined") { })
document.addEventListener("visibilitychange", onVisibility)
}
onCleanup(() => { onCleanup(() => {
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
stop() stop()
abort.abort() abort.abort()
flush() flush()
+3 -4
View File
@@ -1,6 +1,7 @@
import { createStore, produce } from "solid-js/store" import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context" import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useGlobalSync } from "./global-sync" import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk" import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server" import { useServer } from "./server"
@@ -366,12 +367,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
flush() flush()
} }
window.addEventListener("pagehide", flush) makeEventListener(window, "pagehide", flush)
document.addEventListener("visibilitychange", handleVisibility) makeEventListener(document, "visibilitychange", handleVisibility)
onCleanup(() => { onCleanup(() => {
window.removeEventListener("pagehide", flush)
document.removeEventListener("visibilitychange", handleVisibility)
scroll.dispose() scroll.dispose()
}) })
}) })
+7 -14
View File
@@ -12,6 +12,7 @@ import {
untrack, untrack,
type Accessor, type Accessor,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate, useParams } from "@solidjs/router" import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout" import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync" import { useGlobalSync } from "@/context/global-sync"
@@ -215,18 +216,11 @@ export default function Layout(props: ParentProps) {
if (document.visibilityState !== "hidden") return if (document.visibilityState !== "hidden") return
reset() reset()
} }
window.addEventListener("pointerup", stop) makeEventListener(window, "pointerup", stop)
window.addEventListener("pointercancel", stop) makeEventListener(window, "pointercancel", stop)
window.addEventListener("blur", stop) makeEventListener(window, "blur", stop)
window.addEventListener("blur", blur) makeEventListener(window, "blur", blur)
document.addEventListener("visibilitychange", hide) makeEventListener(document, "visibilitychange", hide)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
})
}) })
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
@@ -1394,8 +1388,7 @@ export default function Layout(props: ParentProps) {
} }
handleDeepLinks(drainPendingDeepLinks(window)) handleDeepLinks(drainPendingDeepLinks(window))
window.addEventListener(deepLinkEvent, handler as EventListener) makeEventListener(window, deepLinkEvent, handler as EventListener)
onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
}) })
async function renameProject(project: LocalProject, next: string) { async function renameProject(project: LocalProject, next: string) {
+4 -5
View File
@@ -14,6 +14,7 @@ import {
onMount, onMount,
untrack, untrack,
} from "solid-js" } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer" import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
@@ -329,10 +330,9 @@ export default function Page() {
const { params, sessionKey, tabs, view } = useSessionLayout() const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => { createEffect(() => {
if (!untrack(() => prompt.ready())) return if (!prompt.ready()) return
prompt.ready()
untrack(() => { untrack(() => {
if (params.id || !prompt.ready()) return if (params.id) return
const text = searchParams.prompt const text = searchParams.prompt
if (!text) return if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
@@ -1688,11 +1688,10 @@ export default function Page() {
) )
onMount(() => { onMount(() => {
document.addEventListener("keydown", handleKeyDown) makeEventListener(document, "keydown", handleKeyDown)
}) })
onCleanup(() => { onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
@@ -13,6 +13,7 @@ import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import type { SessionComposerState } from "@/pages/session/composer/session-composer-state" import type { SessionComposerState } from "@/pages/session/composer/session-composer-state"
import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock" import { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { FollowupDraft } from "@/components/prompt-input/submit" import type { FollowupDraft } from "@/components/prompt-input/submit"
import { createResizeObserver } from "@solid-primitives/resize-observer"
export function SessionComposerRegion(props: { export function SessionComposerRegion(props: {
state: SessionComposerState state: SessionComposerState
@@ -115,13 +116,9 @@ export function SessionComposerRegion(props: {
createEffect(() => { createEffect(() => {
const el = store.body const el = store.body
if (!el) return if (!el) return
const update = () => { const update = () => setStore("height", el.getBoundingClientRect().height)
setStore("height", el.getBoundingClientRect().height) createResizeObserver(store.body, update)
}
update() update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
}) })
return ( return (
@@ -1,5 +1,6 @@
import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js" import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2" import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { showToast } from "@opencode-ai/ui/toast" import { showToast } from "@opencode-ai/ui/toast"
@@ -86,8 +87,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
pull() pull()
} }
window.addEventListener(composerEvent, onEvent) makeEventListener(window, composerEvent, onEvent)
onCleanup(() => window.removeEventListener(composerEvent, onEvent))
}) })
const todos = createMemo((): Todo[] => { const todos = createMemo((): Todo[] => {
@@ -8,6 +8,8 @@ import { showToast } from "@opencode-ai/ui/toast"
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2" import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
import { useLanguage } from "@/context/language" import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>() const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
@@ -172,17 +174,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
} }
update() update()
window.addEventListener("resize", update)
makeEventListener(window, "resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]') const dock = root?.closest('[data-component="session-prompt-dock"]')
const scroller = document.querySelector(".scroll-view__viewport") const scroller = document.querySelector(".scroll-view__viewport")
const observer = new ResizeObserver(update) createResizeObserver([dock, scroller], update)
if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller)
onCleanup(() => { onCleanup(() => {
window.removeEventListener("resize", update)
observer.disconnect()
if (raf !== undefined) cancelAnimationFrame(raf) if (raf !== undefined) cancelAnimationFrame(raf)
}) })
@@ -6,6 +6,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { useSpring } from "@opencode-ai/ui/motion-spring" import { useSpring } from "@opencode-ai/ui/motion-spring"
import { TextReveal } from "@opencode-ai/ui/text-reveal" import { TextReveal } from "@opencode-ai/ui/text-reveal"
import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Index, createEffect, createMemo, on, onCleanup } from "solid-js" import { Index, createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { composerEnabled, composerProbe } from "@/testing/session-composer" import { composerEnabled, composerProbe } from "@/testing/session-composer"
@@ -91,9 +92,7 @@ export function SessionTodoDock(props: {
setStore("height", el.getBoundingClientRect().height) setStore("height", el.getBoundingClientRect().height)
} }
update() update()
const observer = new ResizeObserver(update) createResizeObserver(el, update)
observer.observe(el)
onCleanup(() => observer.disconnect())
}) })
createEffect(() => { createEffect(() => {
+16 -24
View File
@@ -1,6 +1,7 @@
import { createEffect, createMemo, Match, on, onCleanup, Switch } from "solid-js" import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web" import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileSearchHandle } from "@opencode-ai/ui/file" import type { FileSearchHandle } from "@opencode-ai/ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file" import { useFileComponent } from "@opencode-ai/ui/context/file"
import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { cloneSelectedLineRange, previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
@@ -59,7 +60,7 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
let scrollFrame: number | undefined let scrollFrame: number | undefined
let restoreFrame: number | undefined let restoreFrame: number | undefined
let pending: ScrollPos | undefined let pending: ScrollPos | undefined
let code: HTMLElement[] = [] const [code, setCode] = createSignal<HTMLElement[]>([])
const getCode = () => { const getCode = () => {
const el = scroll const el = scroll
@@ -106,17 +107,9 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
const sync = () => { const sync = () => {
const next = getCode() const next = getCode()
if (next.length === code.length && next.every((el, i) => el === code[i])) return const current = code()
if (next.length === current.length && next.every((el, i) => el === current[i])) return
for (const item of code) { setCode(next)
item.removeEventListener("scroll", onCodeScroll)
}
code = next
for (const item of code) {
item.addEventListener("scroll", onCodeScroll)
}
} }
const restore = () => { const restore = () => {
@@ -128,14 +121,14 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
sync() sync()
if (code.length > 0) { if (code().length > 0) {
for (const item of code) { for (const item of code()) {
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
} }
} }
if (el.scrollTop !== pos.y) el.scrollTop = pos.y if (el.scrollTop !== pos.y) el.scrollTop = pos.y
if (code.length > 0) return if (code().length > 0) return
if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x if (el.scrollLeft !== pos.x) el.scrollLeft = pos.x
} }
@@ -149,24 +142,24 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
} }
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (code.length === 0) sync() if (code().length === 0) sync()
save({ save({
x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft, x: code()[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop, y: event.currentTarget.scrollTop,
}) })
} }
createEffect(() => {
for (const item of code()) makeEventListener(item, "scroll", onCodeScroll)
})
const setViewport = (el: HTMLDivElement) => { const setViewport = (el: HTMLDivElement) => {
scroll = el scroll = el
restore() restore()
} }
onCleanup(() => { onCleanup(() => {
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame) if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
}) })
@@ -358,8 +351,7 @@ export function FileTabContent(props: { tab: string }) {
find?.focus() find?.focus()
} }
window.addEventListener("keydown", onKeyDown, { capture: true }) makeEventListener(window, "keydown", onKeyDown, { capture: true })
onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
}) })
createEffect( createEffect(
+4 -8
View File
@@ -1,5 +1,6 @@
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/utils/same" import { same } from "@/utils/same"
const emptyTabs: string[] = [] const emptyTabs: string[] = []
@@ -171,14 +172,9 @@ export const createSizing = () => {
} }
onMount(() => { onMount(() => {
window.addEventListener("pointerup", stop) makeEventListener(window, "pointerup", stop)
window.addEventListener("pointercancel", stop) makeEventListener(window, "pointercancel", stop)
window.addEventListener("blur", stop) makeEventListener(window, "blur", stop)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
})
}) })
onCleanup(() => { onCleanup(() => {
+7 -13
View File
@@ -1,4 +1,5 @@
import { createEffect, onCleanup, type JSX } from "solid-js" import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileDiff } from "@opencode-ai/sdk/v2" import type { FileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review" import { SessionReview } from "@opencode-ai/ui/session-review"
import type { import type {
@@ -123,13 +124,6 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
onCleanup(() => { onCleanup(() => {
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame) if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
if (scroll) {
scroll.removeEventListener("wheel", handleInteraction, { capture: true })
scroll.removeEventListener("mousewheel", handleInteraction, { capture: true })
scroll.removeEventListener("pointerdown", handleInteraction, { capture: true })
scroll.removeEventListener("touchstart", handleInteraction, { capture: true })
scroll.removeEventListener("keydown", handleInteraction, { capture: true })
}
}) })
return ( return (
@@ -138,11 +132,11 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
empty={props.empty} empty={props.empty}
scrollRef={(el) => { scrollRef={(el) => {
scroll = el scroll = el
el.addEventListener("wheel", handleInteraction, { passive: true, capture: true }) makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true })
el.addEventListener("mousewheel", handleInteraction, { passive: true, capture: true }) makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true })
el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true }) makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true })
el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true }) makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true })
el.addEventListener("keydown", handleInteraction, { passive: true, capture: true }) makeEventListener(el, "keydown", handleInteraction, { capture: true })
props.onScrollRef?.(el) props.onScrollRef?.(el)
queueRestore() queueRestore()
}} }}
@@ -1,5 +1,6 @@
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -50,12 +51,8 @@ export function TerminalPanel() {
const port = window.visualViewport const port = window.visualViewport
sync() sync()
window.addEventListener("resize", sync) makeEventListener(window, "resize", sync)
port?.addEventListener("resize", sync) if (port) makeEventListener(port, "resize", sync)
onCleanup(() => {
window.removeEventListener("resize", sync)
port?.removeEventListener("resize", sync)
})
}) })
createEffect(() => { createEffect(() => {
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import { bodyText, inputMatch, promptMatch } from "../../e2e/prompt/mock"
function hit(body: Record<string, unknown>) {
return { body }
}
describe("promptMatch", () => {
test("matches token in serialized body", () => {
const match = promptMatch("hello")
expect(match(hit({ messages: [{ role: "user", content: "say hello" }] }))).toBe(true)
expect(match(hit({ messages: [{ role: "user", content: "say goodbye" }] }))).toBe(false)
})
})
describe("inputMatch", () => {
test("matches exact tool input in chat completions body", () => {
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
const match = inputMatch(input)
// The seed prompt embeds JSON.stringify(input) in the user message
const prompt = `Use this JSON input: ${JSON.stringify(input)}`
const body = { messages: [{ role: "user", content: prompt }] }
expect(match(hit(body))).toBe(true)
})
test("matches exact tool input in responses API body", () => {
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
const match = inputMatch(input)
const prompt = `Use this JSON input: ${JSON.stringify(input)}`
const body = { model: "test", input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }] }
expect(match(hit(body))).toBe(true)
})
test("matches patchText with newlines", () => {
const patchText = "*** Begin Patch\n*** Add File: test.txt\n+line1\n*** End Patch"
const match = inputMatch({ patchText })
const prompt = `Use this JSON input: ${JSON.stringify({ patchText })}`
const body = { messages: [{ role: "user", content: prompt }] }
expect(match(hit(body))).toBe(true)
// Also works in responses API format
const respBody = { model: "test", input: [{ role: "user", content: [{ type: "input_text", text: prompt }] }] }
expect(match(hit(respBody))).toBe(true)
})
test("does not match unrelated requests", () => {
const input = { questions: [{ header: "Need input" }] }
const match = inputMatch(input)
expect(match(hit({ messages: [{ role: "user", content: "hello" }] }))).toBe(false)
expect(match(hit({ model: "test", input: [] }))).toBe(false)
})
test("does not match partial input", () => {
const input = { questions: [{ header: "Need input", question: "Pick one" }] }
const match = inputMatch(input)
// Only header, missing question
const partial = `Use this JSON input: ${JSON.stringify({ questions: [{ header: "Need input" }] })}`
const body = { messages: [{ role: "user", content: partial }] }
expect(match(hit(body))).toBe(false)
})
})
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, test } from "bun:test"
import path from "node:path"
import { fileURLToPath } from "node:url"
const dir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../e2e")
function hasPrompt(src: string) {
if (!src.includes("withProject(")) return false
if (src.includes("withNoReplyPrompt(")) return false
if (src.includes("session.promptAsync({") && !src.includes("noReply: true")) return true
if (!src.includes("promptSelector")) return false
return src.includes('keyboard.press("Enter")') || src.includes('prompt.press("Enter")')
}
describe("e2e llm guard", () => {
test("withProject specs do not submit prompt replies", async () => {
const bad: string[] = []
for await (const file of new Bun.Glob("**/*.spec.ts").scan({ cwd: dir, absolute: true })) {
const src = await Bun.file(file).text()
if (!hasPrompt(src)) continue
bad.push(path.relative(dir, file))
}
expect(bad).toEqual([])
})
})
+2
View File
@@ -363,6 +363,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}", "لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
"zen.api.error.modelDisabled": "النموذج معطل", "zen.api.error.modelDisabled": "النموذج معطل",
"zen.api.error.trialEnded":
"انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم", "black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم",
"black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.", "black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.",
+2
View File
@@ -371,6 +371,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}", "Você atingiu seu limite de gastos mensais de ${{amount}}. Gerencie seus limites aqui: {{membersUrl}}",
"zen.api.error.modelDisabled": "O modelo está desabilitado", "zen.api.error.modelDisabled": "O modelo está desabilitado",
"zen.api.error.trialEnded":
"A promoção gratuita do {{model}} terminou. Você pode continuar usando o modelo assinando o OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo", "black.meta.title": "OpenCode Black | Acesse os melhores modelos de codificação do mundo",
"black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.", "black.meta.description": "Tenha acesso ao Claude, GPT, Gemini e mais com os planos de assinatura OpenCode Black.",
+2
View File
@@ -368,6 +368,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}", "Du har nået din månedlige forbrugsgrænse på ${{amount}}. Administrer dine grænser her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktiveret", "zen.api.error.modelDisabled": "Modellen er deaktiveret",
"zen.api.error.trialEnded":
"Den gratis kampagne for {{model}} er afsluttet. Du kan fortsætte med at bruge modellen ved at abonnere på OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller", "black.meta.title": "OpenCode Black | Få adgang til verdens bedste kodningsmodeller",
"black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.", "black.meta.description": "Få adgang til Claude, GPT, Gemini og mere med OpenCode Black-abonnementer.",
+2
View File
@@ -371,6 +371,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}", "Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modell ist deaktiviert", "zen.api.error.modelDisabled": "Modell ist deaktiviert",
"zen.api.error.trialEnded":
"Die kostenlose Aktion für {{model}} ist beendet. Du kannst das Modell weiterhin nutzen, indem du OpenCode Go abonnierst - {{link}}",
"black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle", "black.meta.title": "OpenCode Black | Zugriff auf die weltweit besten Coding-Modelle",
"black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.", "black.meta.description": "Erhalte Zugriff auf Claude, GPT, Gemini und mehr mit OpenCode Black Abos.",
+2
View File
@@ -364,6 +364,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}", "You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model is disabled", "zen.api.error.modelDisabled": "Model is disabled",
"zen.api.error.trialEnded":
"Free promotion has ended for {{model}}. You can continue using the model by subscribing to OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Access all the world's best coding models", "black.meta.title": "OpenCode Black | Access all the world's best coding models",
"black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.", "black.meta.description": "Get access to Claude, GPT, Gemini and more with OpenCode Black subscription plans.",
+2
View File
@@ -371,6 +371,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}", "Has alcanzado tu límite de gasto mensual de ${{amount}}. Gestiona tus límites aquí: {{membersUrl}}",
"zen.api.error.modelDisabled": "El modelo está deshabilitado", "zen.api.error.modelDisabled": "El modelo está deshabilitado",
"zen.api.error.trialEnded":
"La promoción gratuita de {{model}} ha finalizado. Puedes seguir usando el modelo suscribiéndote a OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Accede a los mejores modelos de codificación del mundo", "black.meta.title": "OpenCode Black | Accede a los mejores modelos de codificación del mundo",
"black.meta.description": "Obtén acceso a Claude, GPT, Gemini y más con los planes de suscripción de OpenCode Black.", "black.meta.description": "Obtén acceso a Claude, GPT, Gemini y más con los planes de suscripción de OpenCode Black.",
+2
View File
@@ -372,6 +372,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}", "Vous avez atteint votre limite de dépense mensuelle de {{amount}} $. Gérez vos limites ici : {{membersUrl}}",
"zen.api.error.modelDisabled": "Le modèle est désactivé", "zen.api.error.modelDisabled": "Le modèle est désactivé",
"zen.api.error.trialEnded":
"La promotion gratuite de {{model}} est terminée. Vous pouvez continuer à utiliser le modèle en vous abonnant à OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Accédez aux meilleurs modèles de code au monde", "black.meta.title": "OpenCode Black | Accédez aux meilleurs modèles de code au monde",
"black.meta.description": "Accédez à Claude, GPT, Gemini et plus avec les forfaits d'abonnement OpenCode Black.", "black.meta.description": "Accédez à Claude, GPT, Gemini et plus avec les forfaits d'abonnement OpenCode Black.",
+2
View File
@@ -367,6 +367,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}", "Hai raggiunto il tuo limite di spesa mensile di ${{amount}}. Gestisci i tuoi limiti qui: {{membersUrl}}",
"zen.api.error.modelDisabled": "Il modello è disabilitato", "zen.api.error.modelDisabled": "Il modello è disabilitato",
"zen.api.error.trialEnded":
"La promozione gratuita di {{model}} è terminata. Puoi continuare a usare il modello abbonandoti a OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Accedi ai migliori modelli di coding al mondo", "black.meta.title": "OpenCode Black | Accedi ai migliori modelli di coding al mondo",
"black.meta.description": "black.meta.description":
+2
View File
@@ -369,6 +369,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}", "月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}",
"zen.api.error.modelDisabled": "モデルが無効です", "zen.api.error.modelDisabled": "モデルが無効です",
"zen.api.error.trialEnded":
"{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}",
"black.meta.title": "OpenCode Black | 世界最高峰のコーディングモデルすべてにアクセス", "black.meta.title": "OpenCode Black | 世界最高峰のコーディングモデルすべてにアクセス",
"black.meta.description": "OpenCode Black サブスクリプションプランで、Claude、GPT、Gemini などにアクセス。", "black.meta.description": "OpenCode Black サブスクリプションプランで、Claude、GPT、Gemini などにアクセス。",
+2
View File
@@ -363,6 +363,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}", "월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}",
"zen.api.error.modelDisabled": "모델이 비활성화되었습니다", "zen.api.error.modelDisabled": "모델이 비활성화되었습니다",
"zen.api.error.trialEnded":
"{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}",
"black.meta.title": "OpenCode Black | 세계 최고의 코딩 모델에 액세스하세요", "black.meta.title": "OpenCode Black | 세계 최고의 코딩 모델에 액세스하세요",
"black.meta.description": "OpenCode Black 구독 플랜으로 Claude, GPT, Gemini 등에 액세스하세요.", "black.meta.description": "OpenCode Black 구독 플랜으로 Claude, GPT, Gemini 등에 액세스하세요.",
+2
View File
@@ -368,6 +368,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}", "Du har nådd din månedlige utgiftsgrense på ${{amount}}. Administrer grensene dine her: {{membersUrl}}",
"zen.api.error.modelDisabled": "Modellen er deaktivert", "zen.api.error.modelDisabled": "Modellen er deaktivert",
"zen.api.error.trialEnded":
"Den gratis kampanjen for {{model}} er avsluttet. Du kan fortsette å bruke modellen ved å abonnere på OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Få tilgang til verdens beste kodemodeller", "black.meta.title": "OpenCode Black | Få tilgang til verdens beste kodemodeller",
"black.meta.description": "Få tilgang til Claude, GPT, Gemini og mer med OpenCode Black-abonnementer.", "black.meta.description": "Få tilgang til Claude, GPT, Gemini og mer med OpenCode Black-abonnementer.",
+2
View File
@@ -369,6 +369,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}", "Osiągnąłeś swój miesięczny limit wydatków w wysokości ${{amount}}. Zarządzaj swoimi limitami tutaj: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model jest wyłączony", "zen.api.error.modelDisabled": "Model jest wyłączony",
"zen.api.error.trialEnded":
"Bezpłatna promocja {{model}} dobiegła końca. Możesz dalej korzystać z modelu, subskrybując OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Dostęp do najlepszych na świecie modeli kodujących", "black.meta.title": "OpenCode Black | Dostęp do najlepszych na świecie modeli kodujących",
"black.meta.description": "Uzyskaj dostęp do Claude, GPT, Gemini i innych dzięki planom subskrypcji OpenCode Black.", "black.meta.description": "Uzyskaj dostęp do Claude, GPT, Gemini i innych dzięki planom subskrypcji OpenCode Black.",
+2
View File
@@ -373,6 +373,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}", "Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}",
"zen.api.error.modelDisabled": "Модель отключена", "zen.api.error.modelDisabled": "Модель отключена",
"zen.api.error.trialEnded":
"Бесплатная акция для {{model}} завершена. Вы можете продолжить использование модели, подписавшись на OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Доступ к лучшим моделям для кодинга в мире", "black.meta.title": "OpenCode Black | Доступ к лучшим моделям для кодинга в мире",
"black.meta.description": "Получите доступ к Claude, GPT, Gemini и другим моделям с подпиской OpenCode Black.", "black.meta.description": "Получите доступ к Claude, GPT, Gemini и другим моделям с подпиской OpenCode Black.",
+2
View File
@@ -365,6 +365,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}", "คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}",
"zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน", "zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน",
"zen.api.error.trialEnded":
"โปรโมชันฟรีสำหรับ {{model}} สิ้นสุดแล้ว คุณสามารถใช้โมเดลต่อได้โดยสมัครสมาชิก OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก", "black.meta.title": "OpenCode Black | เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
"black.meta.description": "เข้าถึง Claude, GPT, Gemini และอื่นๆ ด้วยแผนสมาชิก OpenCode Black", "black.meta.description": "เข้าถึง Claude, GPT, Gemini และอื่นๆ ด้วยแผนสมาชิก OpenCode Black",
+2
View File
@@ -372,6 +372,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached": "zen.api.error.userMonthlyLimitReached":
"Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}", "Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}",
"zen.api.error.modelDisabled": "Model devre dışı", "zen.api.error.modelDisabled": "Model devre dışı",
"zen.api.error.trialEnded":
"{{model}} için ücretsiz promosyon sona erdi. OpenCode Go'ya abone olarak modeli kullanmaya devam edebilirsiniz - {{link}}",
"black.meta.title": "OpenCode Black | Dünyanın en iyi kodlama modellerine erişin", "black.meta.title": "OpenCode Black | Dünyanın en iyi kodlama modellerine erişin",
"black.meta.description": "OpenCode Black abonelik planlarıyla Claude, GPT, Gemini ve daha fazlasına erişin.", "black.meta.description": "OpenCode Black abonelik planlarıyla Claude, GPT, Gemini ve daha fazlasına erişin.",
+1
View File
@@ -349,6 +349,7 @@ export const dict = {
"您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}", "您的工作区已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{billingUrl}}",
"zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}", "zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}",
"zen.api.error.modelDisabled": "模型已禁用", "zen.api.error.modelDisabled": "模型已禁用",
"zen.api.error.trialEnded": "{{model}} 的限免活动已结束。您可以订阅 OpenCode Go 继续使用该模型 - {{link}}",
"black.meta.title": "OpenCode Black | 访问全球顶尖编程模型", "black.meta.title": "OpenCode Black | 访问全球顶尖编程模型",
"black.meta.description": "通过 OpenCode Black 订阅计划使用 Claude, GPT, Gemini 等模型。", "black.meta.description": "通过 OpenCode Black 订阅计划使用 Claude, GPT, Gemini 等模型。",
+1
View File
@@ -349,6 +349,7 @@ export const dict = {
"你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}", "你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}",
"zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}", "zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}",
"zen.api.error.modelDisabled": "模型已停用", "zen.api.error.modelDisabled": "模型已停用",
"zen.api.error.trialEnded": "{{model}} 的限免活动已結束。您可以訂閱 OpenCode Go 繼續使用該模型 - {{link}}",
"black.meta.title": "OpenCode Black | 存取全球最佳編碼模型", "black.meta.title": "OpenCode Black | 存取全球最佳編碼模型",
"black.meta.description": "透過 OpenCode Black 訂閱方案存取 Claude、GPT、Gemini 等模型。", "black.meta.description": "透過 OpenCode Black 訂閱方案存取 Claude、GPT、Gemini 等模型。",
@@ -100,6 +100,7 @@ export async function handler(
session: sessionId, session: sessionId,
request: requestId, request: requestId,
client: ocClient, client: ocClient,
...(model === "mimo-v2-pro-free" && JSON.stringify(body).length < 1000 ? { payload: JSON.stringify(body) } : {}),
}) })
const zenData = ZenData.list(opts.modelList) const zenData = ZenData.list(opts.modelList)
const modelInfo = validateModel(zenData, model) const modelInfo = validateModel(zenData, model)
@@ -403,6 +404,14 @@ export async function handler(
}), }),
) )
if (modelData.trialEnded)
throw new ModelError(
`${t("zen.api.error.trialEnded", {
model: modelData.name,
link: "https://opencode.ai/go",
})}`,
)
logger.metric({ model: modelId }) logger.metric({ model: modelId })
return { id: modelId, ...modelData } return { id: modelId, ...modelData }
+5 -1
View File
@@ -27,6 +27,7 @@ export namespace ZenData {
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(), byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
stickyProvider: z.enum(["strict", "prefer"]).optional(), stickyProvider: z.enum(["strict", "prefer"]).optional(),
trialProviders: z.array(z.string()).optional(), trialProviders: z.array(z.string()).optional(),
trialEnded: z.boolean().optional(),
fallbackProvider: z.string().optional(), fallbackProvider: z.string().optional(),
rateLimit: z.number().optional(), rateLimit: z.number().optional(),
providers: z.array( providers: z.array(
@@ -54,7 +55,10 @@ export namespace ZenData {
const ModelsSchema = z.object({ const ModelsSchema = z.object({
models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])), models: z.record(z.string(), z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))])),
liteModels: z.record(z.string(), ModelSchema), liteModels: z.record(
z.string(),
z.union([ModelSchema, z.array(ModelSchema.extend({ formatFilter: FormatSchema }))]),
),
providers: z.record(z.string(), ProviderSchema), providers: z.record(z.string(), ProviderSchema),
}) })
+3
View File
@@ -53,6 +53,7 @@
"@types/bun": "catalog:", "@types/bun": "catalog:",
"@types/cross-spawn": "6.0.6", "@types/cross-spawn": "6.0.6",
"@types/mime-types": "3.0.1", "@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8", "@types/semver": "^7.5.8",
"@types/turndown": "5.0.5", "@types/turndown": "5.0.5",
"@types/which": "3.0.4", "@types/which": "3.0.4",
@@ -94,6 +95,7 @@
"@hono/standard-validator": "0.1.5", "@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:", "@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1", "@modelcontextprotocol/sdk": "1.27.1",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2", "@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:", "@openauthjs/openauth": "catalog:",
@@ -145,6 +147,7 @@
"tree-sitter-powershell": "0.25.10", "tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0", "turndown": "7.2.0",
"ulid": "catalog:", "ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.1",
"vscode-jsonrpc": "8.2.1", "vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10", "web-tree-sitter": "0.25.10",
"which": "6.0.1", "which": "6.0.1",
+2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import fs from "fs" import fs from "fs"
import path from "path" import path from "path"
import { fileURLToPath } from "url" import { fileURLToPath } from "url"
@@ -48,6 +49,7 @@ await Bun.build({
external: ["jsonc-parser"], external: ["jsonc-parser"],
define: { define: {
OPENCODE_MIGRATIONS: JSON.stringify(migrations), OPENCODE_MIGRATIONS: JSON.stringify(migrations),
OPENCODE_CHANNEL: `'${Script.channel}'`,
}, },
}) })
-15
View File
@@ -2,7 +2,6 @@ const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session" const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e" const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano" const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano"
const requirePaid = process.env.OPENCODE_E2E_REQUIRE_PAID === "true"
const parts = model.split("/") const parts = model.split("/")
const providerID = parts[0] ?? "opencode" const providerID = parts[0] ?? "opencode"
const modelID = parts[1] ?? "gpt-5-nano" const modelID = parts[1] ?? "gpt-5-nano"
@@ -12,7 +11,6 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance") const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap") const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config") const { Config } = await import("../src/config/config")
const { Provider } = await import("../src/provider/provider")
const { Session } = await import("../src/session") const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema") const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project") const { Project } = await import("../src/project/project")
@@ -27,19 +25,6 @@ const seed = async () => {
await Config.waitForDependencies() await Config.waitForDependencies()
await ToolRegistry.ids() await ToolRegistry.ids()
if (requirePaid && providerID === "opencode" && !process.env.OPENCODE_API_KEY) {
throw new Error("OPENCODE_API_KEY is required when OPENCODE_E2E_REQUIRE_PAID=true")
}
const info = await Provider.getModel(ProviderID.make(providerID), ModelID.make(modelID))
if (requirePaid) {
const paid =
info.cost.input > 0 || info.cost.output > 0 || info.cost.cache.read > 0 || info.cost.cache.write > 0
if (!paid) {
throw new Error(`OPENCODE_E2E_MODEL must resolve to a paid model: ${providerID}/${modelID}`)
}
}
const session = await Session.create({ title }) const session = await Session.create({ title })
const messageID = MessageID.ascending() const messageID = MessageID.ascending()
const partID = PartID.ascending() const partID = PartID.ascending()
+22 -3
View File
@@ -10,6 +10,7 @@ Technical reference for the current TUI plugin system.
- Package plugins can be installed from CLI or TUI. - Package plugins can be installed from CLI or TUI.
- v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both. - v1 plugin modules are target-exclusive: a module can export `server` or `tui`, never both.
- Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing. - Server runtime keeps v0 legacy fallback (function exports / enumerated exports) after v1 parsing.
- npm packages can be TUI theme-only via `package.json["oc-themes"]` without a `./tui` entrypoint.
## TUI config ## TUI config
@@ -88,7 +89,8 @@ export default plugin
- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`. - If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
- For npm package specs, TUI does not use `package.json` `main` as a fallback entry. - For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
- `package.json` `main` is only used for server plugin entrypoint resolution. - `package.json` `main` is only used for server plugin entrypoint resolution.
- If a configured plugin has no target-specific entrypoint, it is skipped with a warning (not a load failure). - If a configured TUI package has no `./tui` entrypoint and no valid `oc-themes`, it is skipped with a warning (not a load failure).
- If a configured TUI package has no `./tui` entrypoint but has valid `oc-themes`, runtime creates a no-op module record and still loads it for theme sync and plugin state.
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module. - If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
- File/path plugins must export a non-empty `id`. - File/path plugins must export a non-empty `id`.
- npm plugins may omit `id`; package `name` is used. - npm plugins may omit `id`; package `name` is used.
@@ -101,10 +103,18 @@ export default plugin
## Package manifest and install ## Package manifest and install
Install target detection is inferred from `package.json` entrypoints: Install target detection is inferred from `package.json` entrypoints and theme metadata:
- `server` target when `exports["./server"]` exists or `main` is set. - `server` target when `exports["./server"]` exists or `main` is set.
- `tui` target when `exports["./tui"]` exists. - `tui` target when `exports["./tui"]` exists.
- `tui` target when `oc-themes` exists and resolves to a non-empty set of valid package-relative theme paths.
`oc-themes` rules:
- `oc-themes` is an array of relative paths.
- Absolute paths and `file://` paths are rejected.
- Resolved theme paths must stay inside the package directory.
- Invalid `oc-themes` causes manifest read failure for install.
Example: Example:
@@ -289,9 +299,12 @@ Theme install behavior:
- Relative theme paths are resolved from the plugin root. - Relative theme paths are resolved from the plugin root.
- Theme name is the JSON basename. - Theme name is the JSON basename.
- `api.theme.install(...)` and `oc-themes` auto-sync share the same installer path.
- Theme copy/write runs under cross-process lock key `tui-theme:<dest>`.
- First install writes only when the destination file is missing. - First install writes only when the destination file is missing.
- If the theme name already exists, install is skipped unless plugin metadata state is `updated`. - If the theme name already exists, install is skipped unless plugin metadata state is `updated`.
- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed. - On `updated`, host skips rewrite when tracked `mtime`/`size` is unchanged.
- When a theme already exists and state is not `updated`, host can still persist theme metadata when destination already exists.
- Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source. - Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source.
- Global plugins persist installed themes under the global `themes` dir. - Global plugins persist installed themes under the global `themes` dir.
- Invalid or unreadable theme files are ignored. - Invalid or unreadable theme files are ignored.
@@ -328,6 +341,7 @@ Slot notes:
- `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`. - `api.plugins.add(spec)` treats the input as the runtime plugin spec and loads it without re-reading `tui.json`.
- `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded. - `api.plugins.add(spec)` no-ops when that resolved spec (or resolved plugin id) is already loaded.
- `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state). - `api.plugins.add(spec)` assumes enabled and always attempts initialization (it does not consult config/KV enable state).
- `api.plugins.add(spec)` can load theme-only packages (`oc-themes` with no `./tui`) as runtime entries.
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install. - `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`. - `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install. - `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
@@ -357,7 +371,11 @@ Metadata is persisted by plugin id.
- External TUI plugins load from `tuiConfig.plugin`. - External TUI plugins load from `tuiConfig.plugin`.
- `--pure` / `OPENCODE_PURE` skips external TUI plugins only. - `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
- External plugin resolution and import are parallel. - External plugin resolution and import are parallel.
- Packages with no `./tui` entrypoint and valid `oc-themes` are loaded as synthetic no-op TUI plugin modules.
- Theme-only packages loaded this way appear in `api.plugins.list()` and plugin manager rows like other external plugins.
- Packages with no `./tui` entrypoint and no valid `oc-themes` are skipped with warning.
- External plugin activation is sequential to keep command, route, and side-effect order deterministic. - External plugin activation is sequential to keep command, route, and side-effect order deterministic.
- Theme auto-sync from `oc-themes` runs before plugin `tui(...)` execution and only on metadata state `first` or `updated`.
- File plugins that fail initially are retried once after waiting for config dependency installation. - File plugins that fail initially are retried once after waiting for config dependency installation.
- Runtime add uses the same external loader path, including the file-plugin retry after dependency wait. - Runtime add uses the same external loader path, including the file-plugin retry after dependency wait.
- Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded. - Runtime add skips duplicates by resolved spec and returns `true` when the spec is already loaded.
@@ -400,6 +418,7 @@ The plugin manager is exposed as a command with title `Plugins` and value `plugi
- Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`. - Install is blocked until `api.state.path.directory` is available; current guard message is `Paths are still syncing. Try again in a moment.`.
- Manager install uses `api.plugins.install(spec, { global })`. - Manager install uses `api.plugins.install(spec, { global })`.
- If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load. - If the installed package has no `tui` target (`tui=false`), manager reports that and does not expect a runtime load.
- `tui` target detection includes `exports["./tui"]` and valid `oc-themes`.
- If install reports `tui=true`, manager then calls `api.plugins.add(spec)`. - If install reports `tui=true`, manager then calls `api.plugins.add(spec)`.
- If runtime add fails, TUI shows a warning and restart remains the fallback. - If runtime add fails, TUI shows a warning and restart remains the fallback.
+11 -2
View File
@@ -119,6 +119,11 @@ class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefres
}) {} }) {}
const clientId = "opencode-cli" const clientId = "opencode-cli"
const eagerRefreshThreshold = Duration.minutes(5)
const eagerRefreshThresholdMs = Duration.toMillis(eagerRefreshThreshold)
const isTokenFresh = (tokenExpiry: number | null, now: number) =>
tokenExpiry != null && tokenExpiry > now + eagerRefreshThresholdMs
const mapAccountServiceError = const mapAccountServiceError =
(message = "Account service operation failed") => (message = "Account service operation failed") =>
@@ -218,7 +223,9 @@ export namespace Account {
const account = maybeAccount.value const account = maybeAccount.value
const now = yield* Clock.currentTimeMillis const now = yield* Clock.currentTimeMillis
if (account.token_expiry && account.token_expiry > now) return account.access_token if (isTokenFresh(account.token_expiry, now)) {
return account.access_token
}
return yield* refreshToken(account) return yield* refreshToken(account)
}), }),
@@ -226,7 +233,9 @@ export namespace Account {
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) { const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token if (isTokenFresh(row.token_expiry, now)) {
return row.access_token
}
return yield* Cache.get(refreshTokenCache, row.id) return yield* Cache.get(refreshTokenCache, row.id)
}) })
@@ -12,3 +12,4 @@ Focus on information that would be helpful for continuing the conversation, incl
Your summary should be comprehensive enough to provide context but concise enough to be quickly understood. Your summary should be comprehensive enough to provide context but concise enough to be quickly understood.
Do not respond to any questions in the conversation, only output the summary. Do not respond to any questions in the conversation, only output the summary.
Respond in the same language the user used in the conversation.
-129
View File
@@ -1,129 +0,0 @@
import z from "zod"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { Filesystem } from "../util/filesystem"
import { NamedError } from "@opencode-ai/util/error"
import { Lock } from "../util/lock"
import { PackageRegistry } from "./registry"
import { online, proxied } from "@/util/network"
import { Process } from "../util/process"
export namespace BunProc {
const log = Log.create({ service: "bun" })
export async function run(cmd: string[], options?: Process.RunOptions) {
const full = [which(), ...cmd]
log.info("running", {
cmd: full,
...options,
})
const result = await Process.run(full, {
cwd: options?.cwd,
abort: options?.abort,
kill: options?.kill,
timeout: options?.timeout,
nothrow: options?.nothrow,
env: {
...process.env,
...options?.env,
BUN_BE_BUN: "1",
},
})
log.info("done", {
code: result.code,
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
})
return result
}
export function which() {
return process.execPath
}
export const InstallFailedError = NamedError.create(
"BunInstallFailedError",
z.object({
pkg: z.string(),
version: z.string(),
}),
)
export async function install(pkg: string, version = "latest", opts?: { ignoreScripts?: boolean }) {
// Use lock to ensure only one install at a time
using _ = await Lock.write("bun-install")
const mod = path.join(Global.Path.cache, "node_modules", pkg)
const pkgjsonPath = path.join(Global.Path.cache, "package.json")
const parsed = await Filesystem.readJson<{ dependencies: Record<string, string> }>(pkgjsonPath).catch(async () => {
const result = { dependencies: {} as Record<string, string> }
await Filesystem.writeJson(pkgjsonPath, result)
return result
})
if (!parsed.dependencies) parsed.dependencies = {} as Record<string, string>
const dependencies = parsed.dependencies
const modExists = await Filesystem.exists(mod)
const cachedVersion = dependencies[pkg]
if (!modExists || !cachedVersion) {
// continue to install
} else if (version === "latest") {
if (!online()) return mod
const stale = await PackageRegistry.isOutdated(pkg, cachedVersion, Global.Path.cache)
if (!stale) return mod
log.info("Cached version is outdated, proceeding with install", { pkg, cachedVersion })
} else if (cachedVersion === version) {
return mod
}
// Build command arguments
const args = [
"add",
"--force",
"--exact",
...(opts?.ignoreScripts ? ["--ignore-scripts"] : []),
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
"--cwd",
Global.Path.cache,
pkg + "@" + version,
]
// Let Bun handle registry resolution:
// - If .npmrc files exist, Bun will use them automatically
// - If no .npmrc files exist, Bun will default to https://registry.npmjs.org
// - No need to pass --registry flag
log.info("installing package using Bun's default registry resolution", {
pkg,
version,
})
await BunProc.run(args, {
cwd: Global.Path.cache,
}).catch((e) => {
throw new InstallFailedError(
{ pkg, version },
{
cause: e,
},
)
})
// Resolve actual version from installed package when using "latest"
// This ensures subsequent starts use the cached version until explicitly updated
let resolvedVersion = version
if (version === "latest") {
const installedPkg = await Filesystem.readJson<{ version?: string }>(path.join(mod, "package.json")).catch(
() => null,
)
if (installedPkg?.version) {
resolvedVersion = installedPkg.version
}
}
parsed.dependencies[pkg] = resolvedVersion
await Filesystem.writeJson(pkgjsonPath, parsed)
return mod
}
}
-50
View File
@@ -1,50 +0,0 @@
import semver from "semver"
import { Log } from "../util/log"
import { Process } from "../util/process"
import { online } from "@/util/network"
export namespace PackageRegistry {
const log = Log.create({ service: "bun" })
function which() {
return process.execPath
}
export async function info(pkg: string, field: string, cwd?: string): Promise<string | null> {
if (!online()) {
log.debug("offline, skipping bun info", { pkg, field })
return null
}
const { code, stdout, stderr } = await Process.run([which(), "info", pkg, field], {
cwd,
env: {
...process.env,
BUN_BE_BUN: "1",
},
nothrow: true,
})
if (code !== 0) {
log.warn("bun info failed", { pkg, field, code, stderr: stderr.toString() })
return null
}
const value = stdout.toString().trim()
if (!value) return null
return value
}
export async function isOutdated(pkg: string, cachedVersion: string, cwd?: string): Promise<boolean> {
const latestVersion = await info(pkg, "version", cwd)
if (!latestVersion) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const isRange = /[\s^~*xX<>|=]/.test(cachedVersion)
if (isRange) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
}
+3 -1
View File
@@ -115,7 +115,9 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
if (manifest.code === "manifest_no_targets") { if (manifest.code === "manifest_no_targets") {
inspect.stop("No plugin targets found", 1) inspect.stop("No plugin targets found", 1)
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`) dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.') dep.log.info(
'Expected one of: exports["./tui"], exports["./server"], package.json main for server, or package.json["oc-themes"] for tui themes.',
)
return false return false
} }
+7 -1
View File
@@ -299,7 +299,8 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
useKeyboard((evt) => { useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (!renderer.getSelection()) return const sel = renderer.getSelection()
if (!sel) return
// Windows Terminal-like behavior: // Windows Terminal-like behavior:
// - Ctrl+C copies and dismisses selection // - Ctrl+C copies and dismisses selection
@@ -323,6 +324,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
return return
} }
const focus = renderer.currentFocusedRenderable
if (focus?.hasSelection() && sel.selectedRenderables.includes(focus)) {
return
}
renderer.clearSelection() renderer.clearSelection()
}) })
@@ -4,6 +4,7 @@ import { Clipboard } from "@tui/util/clipboard"
import { createSignal } from "solid-js" import { createSignal } from "solid-js"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { win32FlushInputBuffer } from "../win32" import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll"
export function ErrorComponent(props: { export function ErrorComponent(props: {
error: Error error: Error
@@ -82,7 +83,7 @@ export function ErrorComponent(props: {
<text fg={colors.bg}>Exit</text> <text fg={colors.bg}>Exit</text>
</box> </box>
</box> </box>
<scrollbox height={Math.floor(term().height * 0.7)}> <scrollbox height={Math.floor(term().height * 0.7)} scrollAcceleration={getScrollAcceleration()}>
<text fg={colors.muted}>{props.error.stack}</text> <text fg={colors.muted}>{props.error.stack}</text>
</scrollbox> </scrollbox>
<text fg={colors.text}>{props.error.message}</text> <text fg={colors.text}>{props.error.message}</text>
@@ -6,6 +6,8 @@ import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Sh
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk" import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useTheme, selectedForeground } from "@tui/context/theme" import { useTheme, selectedForeground } from "@tui/context/theme"
import { SplitBorder } from "@tui/component/border" import { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command" import { useCommandDialog } from "@tui/component/dialog-command"
@@ -81,6 +83,7 @@ export function Autocomplete(props: {
const { theme } = useTheme() const { theme } = useTheme()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
const frecency = useFrecency() const frecency = useFrecency()
const tuiConfig = useTuiConfig()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
index: 0, index: 0,
@@ -605,6 +608,7 @@ export function Autocomplete(props: {
}) })
let scroll: ScrollBoxRenderable let scroll: ScrollBoxRenderable
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return ( return (
<box <box
@@ -622,6 +626,7 @@ export function Autocomplete(props: {
backgroundColor={theme.backgroundMenu} backgroundColor={theme.backgroundMenu}
height={height()} height={height()}
scrollbarOptions={{ visible: false }} scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
> >
<Index <Index
each={options()} each={options()}
@@ -18,7 +18,14 @@ import { Log } from "@/util/log"
import { errorData, errorMessage } from "@/util/error" import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { pluginSource, readPluginId, readV1Plugin, resolvePluginId, type PluginSource } from "@/plugin/shared" import {
readPackageThemes,
readPluginId,
readV1Plugin,
resolvePluginId,
type PluginPackage,
type PluginSource,
} from "@/plugin/shared"
import { PluginLoader } from "@/plugin/loader" import { PluginLoader } from "@/plugin/loader"
import { PluginMeta } from "@/plugin/meta" import { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install" import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
@@ -26,6 +33,7 @@ import { hasTheme, upsertTheme } from "../context/theme"
import { Global } from "@/global" import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process" import { Process } from "@/util/process"
import { Flock } from "@/util/flock"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal" import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots" import { setupSlots, Slot as View } from "./slots"
@@ -39,8 +47,9 @@ type PluginLoad = {
source: PluginSource | "internal" source: PluginSource | "internal"
id: string id: string
module: TuiPluginModule module: TuiPluginModule
theme_meta: TuiConfig.PluginMeta origin: Config.PluginOrigin
theme_root: string theme_root: string
theme_files: string[]
} }
type Api = HostPluginApi type Api = HostPluginApi
@@ -67,12 +76,15 @@ type RuntimeState = {
slots: HostSlots slots: HostSlots
plugins: PluginEntry[] plugins: PluginEntry[]
plugins_by_id: Map<string, PluginEntry> plugins_by_id: Map<string, PluginEntry>
pending: Map<string, TuiConfig.PluginRecord> pending: Map<string, Config.PluginOrigin>
} }
const log = Log.create({ service: "tui.plugin" }) const log = Log.create({ service: "tui.plugin" })
const DISPOSE_TIMEOUT_MS = 5000 const DISPOSE_TIMEOUT_MS = 5000
const KV_KEY = "plugin_enabled" const KV_KEY = "plugin_enabled"
const EMPTY_TUI: TuiPluginModule = {
tui: async () => {},
}
function fail(message: string, data: Record<string, unknown>) { function fail(message: string, data: Record<string, unknown>) {
if (!("error" in data)) { if (!("error" in data)) {
@@ -134,7 +146,7 @@ function resolveRoot(root: string) {
} }
function createThemeInstaller( function createThemeInstaller(
meta: TuiConfig.PluginMeta, meta: Config.PluginOrigin,
root: string, root: string,
spec: string, spec: string,
plugin: PluginEntry, plugin: PluginEntry,
@@ -153,20 +165,17 @@ function createThemeInstaller(
const stat = await Filesystem.statAsync(src) const stat = await Filesystem.statAsync(src)
const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined
const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined
const exists = hasTheme(name) const info = {
const prev = plugin.themes[name]
if (exists) {
if (plugin.meta.state !== "updated") return
if (!prev) {
if (await Filesystem.exists(dest)) {
plugin.themes[name] = {
src, src,
dest, dest,
mtime, mtime,
size, size,
} }
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
await Flock.withLock(`tui-theme:${dest}`, async () => {
const save = async () => {
plugin.themes[name] = info
await PluginMeta.setTheme(plugin.id, name, info).catch((error) => {
log.warn("failed to track tui plugin theme", { log.warn("failed to track tui plugin theme", {
path: spec, path: spec,
id: plugin.id, id: plugin.id,
@@ -176,10 +185,17 @@ function createThemeInstaller(
}) })
}) })
} }
const exists = hasTheme(name)
const prev = plugin.themes[name]
if (exists) {
if (plugin.meta.state !== "updated") {
if (!prev && (await Filesystem.exists(dest))) {
await save()
}
return return
} }
if (prev.dest !== dest) return if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return
if (prev.mtime === mtime && prev.size === size) return
} }
const text = await Filesystem.readText(src).catch((error) => { const text = await Filesystem.readText(src).catch((error) => {
@@ -209,103 +225,10 @@ function createThemeInstaller(
} }
upsertTheme(name, data) upsertTheme(name, data)
plugin.themes[name] = { await save()
src, }).catch((error) => {
dest, log.warn("failed to lock tui plugin theme install", { path: spec, theme: src, dest, error })
mtime,
size,
}
await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => {
log.warn("failed to track tui plugin theme", {
path: spec,
id: plugin.id,
theme: src,
dest,
error,
}) })
})
}
}
async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): Promise<PluginLoad | undefined> {
const plan = PluginLoader.plan(cfg.item)
if (plan.deprecated) return
log.info("loading tui plugin", { path: plan.spec, retry })
const resolved = await PluginLoader.resolve(plan, "tui")
if (!resolved.ok) {
if (resolved.stage === "missing") {
warn("tui plugin has no entrypoint", {
path: plan.spec,
retry,
message: resolved.message,
})
return
}
if (resolved.stage === "install") {
fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
return
}
if (resolved.stage === "compatibility") {
fail("tui plugin incompatible", { path: plan.spec, retry, error: resolved.error })
return
}
fail("failed to resolve tui plugin entry", { path: plan.spec, retry, error: resolved.error })
return
}
const loaded = await PluginLoader.load(resolved.value)
if (!loaded.ok) {
fail("failed to load tui plugin", {
path: plan.spec,
target: resolved.value.entry,
retry,
error: loaded.error,
})
return
}
const mod = await Promise.resolve()
.then(() => {
return readV1Plugin(loaded.value.mod as Record<string, unknown>, plan.spec, "tui") as TuiPluginModule
})
.catch((error) => {
fail("failed to load tui plugin", {
path: plan.spec,
target: loaded.value.entry,
retry,
error,
})
return
})
if (!mod) return
const id = await resolvePluginId(
loaded.value.source,
plan.spec,
loaded.value.target,
readPluginId(mod.id, plan.spec),
loaded.value.pkg,
).catch((error) => {
fail("failed to load tui plugin", { path: plan.spec, target: loaded.value.target, retry, error })
return
})
if (!id) return
return {
options: plan.options,
spec: plan.spec,
target: loaded.value.target,
retry,
source: loaded.value.source,
id,
module: mod,
theme_meta: {
scope: cfg.scope,
source: cfg.source,
},
theme_root: loaded.value.pkg?.dir ?? resolveRoot(loaded.value.target),
} }
} }
@@ -350,11 +273,38 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
source: "internal", source: "internal",
id: item.id, id: item.id,
module: item, module: item,
theme_meta: { origin: {
spec,
scope: "global", scope: "global",
source: target, source: target,
}, },
theme_root: process.cwd(), theme_root: process.cwd(),
theme_files: [],
}
}
async function readThemeFiles(spec: string, pkg?: PluginPackage) {
if (!pkg) return [] as string[]
return Promise.resolve()
.then(() => readPackageThemes(spec, pkg))
.catch((error) => {
warn("invalid tui plugin oc-themes", {
path: spec,
pkg: pkg.pkg,
error,
})
return [] as string[]
})
}
async function syncPluginThemes(plugin: PluginEntry) {
if (!plugin.load.theme_files.length) return
if (plugin.meta.state === "same") return
const install = createThemeInstaller(plugin.load.origin, plugin.load.theme_root, plugin.load.spec, plugin)
for (const file of plugin.load.theme_files) {
await install(file).catch((error) => {
warn("failed to sync tui plugin oc-themes", { path: plugin.load.spec, id: plugin.id, theme: file, error })
})
} }
} }
@@ -489,6 +439,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per
const api = pluginApi(state, plugin, scope, plugin.id) const api = pluginApi(state, plugin, scope, plugin.id)
const ok = await Promise.resolve() const ok = await Promise.resolve()
.then(async () => { .then(async () => {
await syncPluginThemes(plugin)
await plugin.plugin(api, plugin.load.options, plugin.meta) await plugin.plugin(api, plugin.load.options, plugin.meta)
return true return true
}) })
@@ -555,7 +506,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
} }
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), { const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin), install: createThemeInstaller(load.origin, load.theme_root, load.spec, plugin),
}) })
const event: TuiPluginApi["event"] = { const event: TuiPluginApi["event"] = {
@@ -637,28 +588,108 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
} }
} }
async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise<void>) { async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item))) return PluginLoader.loadExternal({
const ready: PluginLoad[] = [] items: list,
let deps: Promise<void> | undefined kind: "tui",
wait: async () => {
for (let i = 0; i < list.length; i++) { await wait().catch((error) => {
let entry = loaded[i]
if (!entry) {
const item = list[i]
if (!item) continue
if (pluginSource(Config.pluginSpecifier(item.item)) !== "file") continue
deps ??= wait().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error }) log.warn("failed waiting for tui plugin dependencies", { error })
}) })
await deps },
entry = await loadExternalPlugin(item, true) finish: async (loaded, origin, retry) => {
} const mod = await Promise.resolve()
if (!entry) continue .then(() => readV1Plugin(loaded.mod as Record<string, unknown>, loaded.spec, "tui") as TuiPluginModule)
ready.push(entry) .catch((error) => {
} fail("failed to load tui plugin", {
path: loaded.spec,
target: loaded.entry,
retry,
error,
})
return
})
if (!mod) return
return ready const id = await resolvePluginId(
loaded.source,
loaded.spec,
loaded.target,
readPluginId(mod.id, loaded.spec),
loaded.pkg,
).catch((error) => {
fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
return
})
if (!id) return
const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
return {
options: loaded.options,
spec: loaded.spec,
target: loaded.target,
retry,
source: loaded.source,
id,
module: mod,
origin,
theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
theme_files,
}
},
missing: async (loaded, origin, retry) => {
const theme_files = await readThemeFiles(loaded.spec, loaded.pkg)
if (!theme_files.length) return
const name =
typeof loaded.pkg?.json.name === "string" && loaded.pkg.json.name.trim().length > 0
? loaded.pkg.json.name.trim()
: undefined
const id = await resolvePluginId(loaded.source, loaded.spec, loaded.target, name, loaded.pkg).catch((error) => {
fail("failed to load tui plugin", { path: loaded.spec, target: loaded.target, retry, error })
return
})
if (!id) return
return {
options: loaded.options,
spec: loaded.spec,
target: loaded.target,
retry,
source: loaded.source,
id,
module: EMPTY_TUI,
origin,
theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
theme_files,
}
},
report: {
start(candidate, retry) {
log.info("loading tui plugin", { path: candidate.plan.spec, retry })
},
missing(candidate, retry, message) {
warn("tui plugin has no entrypoint", { path: candidate.plan.spec, retry, message })
},
error(candidate, retry, stage, error, resolved) {
const spec = candidate.plan.spec
if (stage === "install") {
fail("failed to resolve tui plugin", { path: spec, retry, error })
return
}
if (stage === "compatibility") {
fail("tui plugin incompatible", { path: spec, retry, error })
return
}
if (stage === "entry") {
fail("failed to resolve tui plugin entry", { path: spec, retry, error })
return
}
fail("failed to load tui plugin", { path: spec, target: resolved?.entry, retry, error })
},
},
})
} }
async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) { async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]) {
@@ -692,12 +723,12 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
}) })
} }
const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id) const info = createMeta(entry.source, entry.spec, entry.target, hit, entry.id)
const themes = hit?.entry.themes ? { ...hit.entry.themes } : {} const themes = hit?.entry.themes ? { ...hit.entry.themes } : {}
const plugin: PluginEntry = { const plugin: PluginEntry = {
id: entry.id, id: entry.id,
load: entry, load: entry,
meta: row, meta: info,
themes, themes,
plugin: entry.module.tui, plugin: entry.module.tui,
enabled: true, enabled: true,
@@ -712,9 +743,9 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
return { plugins, ok } return { plugins, ok }
} }
function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord { function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
return { return {
item: spec, spec,
scope: "local", scope: "local",
source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"), source: state.api.state.path.config || path.join(state.directory, ".opencode", "tui.json"),
} }
@@ -752,8 +783,8 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
const spec = raw.trim() const spec = raw.trim()
if (!spec) return false if (!spec) return false
const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec) const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
const next = Config.pluginSpecifier(cfg.item) const next = Config.pluginSpecifier(cfg.spec)
if (state.plugins.some((plugin) => plugin.load.spec === next)) { if (state.plugins.some((plugin) => plugin.load.spec === next)) {
state.pending.delete(spec) state.pending.delete(spec)
return true return true
@@ -837,7 +868,7 @@ async function installPluginBySpec(
if (manifest.code === "manifest_no_targets") { if (manifest.code === "manifest_no_targets") {
return { return {
ok: false, ok: false,
message: `"${spec}" does not expose plugin entrypoints in package.json`, message: `"${spec}" does not expose plugin entrypoints or oc-themes in package.json`,
} }
} }
@@ -872,9 +903,9 @@ async function installPluginBySpec(
const tui = manifest.targets.find((item) => item.kind === "tui") const tui = manifest.targets.find((item) => item.kind === "tui")
if (tui) { if (tui) {
const file = patch.items.find((item) => item.kind === "tui")?.file const file = patch.items.find((item) => item.kind === "tui")?.file
const item = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec const next = tui.opts ? ([spec, tui.opts] as Config.PluginSpec) : spec
state.pending.set(spec, { state.pending.set(spec, {
item, spec: next,
scope: global ? "global" : "local", scope: global ? "global" : "local",
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"), source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
}) })
@@ -959,9 +990,9 @@ export namespace TuiPluginRuntime {
directory: cwd, directory: cwd,
fn: async () => { fn: async () => {
const config = await TuiConfig.get() const config = await TuiConfig.get()
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? []) const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_records?.length) { if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length }) log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
} }
for (const item of INTERNAL_TUI_PLUGINS) { for (const item of INTERNAL_TUI_PLUGINS) {
@@ -19,17 +19,17 @@ import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border" import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner" import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme" import { selectedForeground, useTheme } from "@tui/context/theme"
import { import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
BoxRenderable,
ScrollBoxRenderable,
addDefaultParsers,
MacOSScrollAccel,
type ScrollAcceleration,
TextAttributes,
RGBA,
} from "@opentui/core"
import { Prompt, type PromptRef } from "@tui/component/prompt" import { Prompt, type PromptRef } from "@tui/component/prompt"
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2" import type {
AssistantMessage,
Part,
Provider,
ToolPart,
UserMessage,
TextPart,
ReasoningPart,
} from "@opencode-ai/sdk/v2"
import { useLocal } from "@tui/context/local" import { useLocal } from "@tui/context/local"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool" import type { Tool } from "@/tool/tool"
@@ -77,22 +77,14 @@ import { Global } from "@/global"
import { PermissionPrompt } from "./permission" import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question" import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options" import { DialogExportOptions } from "../../ui/dialog-export-options"
import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript" import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts" import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config" import { useTuiConfig } from "../../context/tui-config"
import { getScrollAcceleration } from "../../util/scroll"
addDefaultParsers(parsers.parsers) addDefaultParsers(parsers.parsers)
class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}
tick(_now?: number): number {
return this.speed
}
reset(): void {}
}
const context = createContext<{ const context = createContext<{
width: number width: number
sessionID: string sessionID: string
@@ -102,6 +94,7 @@ const context = createContext<{
showDetails: () => boolean showDetails: () => boolean
showGenericToolOutput: () => boolean showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none" diffWrapMode: () => "word" | "none"
providers: () => ReadonlyMap<string, Provider>
sync: ReturnType<typeof useSync> sync: ReturnType<typeof useSync>
tui: ReturnType<typeof useTuiConfig> tui: ReturnType<typeof useTuiConfig>
}>() }>()
@@ -167,18 +160,9 @@ export function Session() {
}) })
const showTimestamps = createMemo(() => timestamps() === "show") const showTimestamps = createMemo(() => timestamps() === "show")
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4) const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const providers = createMemo(() => Model.index(sync.data.provider))
const scrollAcceleration = createMemo(() => { const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const tui = tuiConfig
if (tui?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
if (tui?.scroll_speed) {
return new CustomSpeedScroll(tui.scroll_speed)
}
return new CustomSpeedScroll(3)
})
createEffect(() => { createEffect(() => {
if (session()?.workspaceID) { if (session()?.workspaceID) {
@@ -376,6 +360,11 @@ export function Session() {
dialog.clear() dialog.clear()
return return
} }
if (!kv.get("share_consent", false)) {
const ok = await DialogConfirm.show(dialog, "Share Session", "Are you sure you want to share it?")
if (ok !== true) return
kv.set("share_consent", true)
}
await sdk.client.session await sdk.client.session
.share({ .share({
sessionID: route.sessionID, sessionID: route.sessionID,
@@ -841,6 +830,7 @@ export function Session() {
thinking: showThinking(), thinking: showThinking(),
toolDetails: showDetails(), toolDetails: showDetails(),
assistantMetadata: showAssistantMetadata(), assistantMetadata: showAssistantMetadata(),
providers: sync.data.provider,
}, },
) )
await Clipboard.copy(transcript) await Clipboard.copy(transcript)
@@ -885,6 +875,7 @@ export function Session() {
thinking: options.thinking, thinking: options.thinking,
toolDetails: options.toolDetails, toolDetails: options.toolDetails,
assistantMetadata: options.assistantMetadata, assistantMetadata: options.assistantMetadata,
providers: sync.data.provider,
}, },
) )
@@ -1030,6 +1021,7 @@ export function Session() {
showDetails, showDetails,
showGenericToolOutput, showGenericToolOutput,
diffWrapMode, diffWrapMode,
providers,
sync, sync,
tui: tuiConfig, tui: tuiConfig,
}} }}
@@ -1314,10 +1306,12 @@ function UserMessage(props: {
} }
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) { function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
const ctx = use()
const local = useLocal() const local = useLocal()
const { theme } = useTheme() const { theme } = useTheme()
const sync = useSync() const sync = useSync()
const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? []) const messages = createMemo(() => sync.data.message[props.message.sessionID] ?? [])
const model = createMemo(() => Model.name(ctx.providers(), props.message.providerID, props.message.modelID))
const final = createMemo(() => { const final = createMemo(() => {
return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish) return props.message.finish && !["tool-calls", "unknown"].includes(props.message.finish)
@@ -1387,7 +1381,7 @@ function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; las
{" "} {" "}
</span>{" "} </span>{" "}
<span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span> <span style={{ fg: theme.text }}>{Locale.titlecase(props.message.mode)}</span>
<span style={{ fg: theme.textMuted }}> · {props.message.modelID}</span> <span style={{ fg: theme.textMuted }}> · {model()}</span>
<Show when={duration()}> <Show when={duration()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span> <span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show> </Show>
@@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { Global } from "@/global" import { Global } from "@/global"
import { useDialog } from "../../ui/dialog" import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config" import { useTuiConfig } from "../../context/tui-config"
type PermissionStage = "permission" | "always" | "reject" type PermissionStage = "permission" | "always" | "reject"
@@ -62,12 +63,14 @@ function EditBody(props: { request: PermissionRequest }) {
}) })
const ft = createMemo(() => filetype(filepath())) const ft = createMemo(() => filetype(filepath()))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
return ( return (
<box flexDirection="column" gap={1}> <box flexDirection="column" gap={1}>
<Show when={diff()}> <Show when={diff()}>
<scrollbox <scrollbox
height="100%" height="100%"
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{ verticalScrollbarOptions={{
trackOptions: { trackOptions: {
backgroundColor: theme.background, backgroundColor: theme.background,
@@ -1,13 +1,18 @@
import { useSync } from "@tui/context/sync" import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js" import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme" import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { TuiPluginRuntime } from "../../plugin" import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) { export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync() const sync = useSync()
const { theme } = useTheme() const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID)) const session = createMemo(() => sync.session.get(props.sessionID))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return ( return (
<Show when={session()}> <Show when={session()}>
@@ -23,6 +28,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
> >
<scrollbox <scrollbox
flexGrow={1} flexGrow={1}
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{ verticalScrollbarOptions={{
trackOptions: { trackOptions: {
backgroundColor: theme.background, backgroundColor: theme.background,
@@ -10,6 +10,8 @@ import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind" import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind" import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../context/tui-config"
export interface DialogSelectProps<T> { export interface DialogSelectProps<T> {
title: string title: string
@@ -50,6 +52,9 @@ export type DialogSelectRef<T> = {
export function DialogSelect<T>(props: DialogSelectProps<T>) { export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog() const dialog = useDialog()
const { theme } = useTheme() const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({ const [store, setStore] = createStore({
selected: 0, selected: 0,
filter: "", filter: "",
@@ -276,6 +281,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
paddingLeft={1} paddingLeft={1}
paddingRight={1} paddingRight={1}
scrollbarOptions={{ visible: false }} scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
ref={(r: ScrollBoxRenderable) => (scroll = r)} ref={(r: ScrollBoxRenderable) => (scroll = r)}
maxHeight={height()} maxHeight={height()}
> >
@@ -0,0 +1,23 @@
import type { Provider } from "@opencode-ai/sdk/v2"
export function index(list: Provider[] | undefined) {
return new Map((list ?? []).map((item) => [item.id, item] as const))
}
export function get(list: Provider[] | ReadonlyMap<string, Provider> | undefined, providerID: string, modelID: string) {
const provider =
list instanceof Map
? list.get(providerID)
: Array.isArray(list)
? list.find((item) => item.id === providerID)
: undefined
return provider?.models[modelID]
}
export function name(
list: Provider[] | ReadonlyMap<string, Provider> | undefined,
providerID: string,
modelID: string,
) {
return get(list, providerID, modelID)?.name ?? modelID
}
@@ -0,0 +1,23 @@
import { MacOSScrollAccel, type ScrollAcceleration } from "@opentui/core"
import type { TuiConfig } from "@/config/tui"
export class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}
tick(_now?: number): number {
return this.speed
}
reset(): void {}
}
export function getScrollAcceleration(tuiConfig?: TuiConfig.Info): ScrollAcceleration {
if (tuiConfig?.scroll_acceleration?.enabled) {
return new MacOSScrollAccel()
}
if (tuiConfig?.scroll_speed !== undefined) {
return new CustomSpeedScroll(tuiConfig.scroll_speed)
}
return new CustomSpeedScroll(3)
}
@@ -1,10 +1,12 @@
import type { AssistantMessage, Part, UserMessage } from "@opencode-ai/sdk/v2" import type { AssistantMessage, Part, Provider, UserMessage } from "@opencode-ai/sdk/v2"
import { Locale } from "@/util/locale" import { Locale } from "@/util/locale"
import * as Model from "./model"
export type TranscriptOptions = { export type TranscriptOptions = {
thinking: boolean thinking: boolean
toolDetails: boolean toolDetails: boolean
assistantMetadata: boolean assistantMetadata: boolean
providers?: Provider[]
} }
export type SessionInfo = { export type SessionInfo = {
@@ -26,6 +28,7 @@ export function formatTranscript(
messages: MessageWithParts[], messages: MessageWithParts[],
options: TranscriptOptions, options: TranscriptOptions,
): string { ): string {
const providers = Model.index(options.providers)
let transcript = `# ${session.title}\n\n` let transcript = `# ${session.title}\n\n`
transcript += `**Session ID:** ${session.id}\n` transcript += `**Session ID:** ${session.id}\n`
transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n` transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n`
@@ -33,20 +36,25 @@ export function formatTranscript(
transcript += `---\n\n` transcript += `---\n\n`
for (const msg of messages) { for (const msg of messages) {
transcript += formatMessage(msg.info, msg.parts, options) transcript += formatMessage(msg.info, msg.parts, options, providers)
transcript += `---\n\n` transcript += `---\n\n`
} }
return transcript return transcript
} }
export function formatMessage(msg: UserMessage | AssistantMessage, parts: Part[], options: TranscriptOptions): string { export function formatMessage(
msg: UserMessage | AssistantMessage,
parts: Part[],
options: TranscriptOptions,
providers?: Provider[] | ReadonlyMap<string, Provider>,
): string {
let result = "" let result = ""
if (msg.role === "user") { if (msg.role === "user") {
result += `## User\n\n` result += `## User\n\n`
} else { } else {
result += formatAssistantHeader(msg, options.assistantMetadata) result += formatAssistantHeader(msg, options.assistantMetadata, providers ?? options.providers)
} }
for (const part of parts) { for (const part of parts) {
@@ -56,7 +64,11 @@ export function formatMessage(msg: UserMessage | AssistantMessage, parts: Part[]
return result return result
} }
export function formatAssistantHeader(msg: AssistantMessage, includeMetadata: boolean): string { export function formatAssistantHeader(
msg: AssistantMessage,
includeMetadata: boolean,
providers?: Provider[] | ReadonlyMap<string, Provider>,
): string {
if (!includeMetadata) { if (!includeMetadata) {
return `## Assistant\n\n` return `## Assistant\n\n`
} }
@@ -64,7 +76,9 @@ export function formatAssistantHeader(msg: AssistantMessage, includeMetadata: bo
const duration = const duration =
msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : "" msg.time.completed && msg.time.created ? ((msg.time.completed - msg.time.created) / 1000).toFixed(1) + "s" : ""
return `## Assistant (${Locale.titlecase(msg.agent)} · ${msg.modelID}${duration ? ` · ${duration}` : ""})\n\n` const modelName = Model.name(providers, msg.providerID, msg.modelID)
return `## Assistant (${Locale.titlecase(msg.agent)} · ${modelName}${duration ? ` · ${duration}` : ""})\n\n`
} }
export function formatPart(part: Part, options: TranscriptOptions): string { export function formatPart(part: Part, options: TranscriptOptions): string {
+17
View File
@@ -4,6 +4,13 @@ import { NamedError } from "@opencode-ai/util/error"
import { logo as glyphs } from "./logo" import { logo as glyphs } from "./logo"
export namespace UI { export namespace UI {
const wordmark = [
``,
`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█`,
`█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀`,
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
]
export const CancelledError = NamedError.create("UICancelledError", z.void()) export const CancelledError = NamedError.create("UICancelledError", z.void())
export const Style = { export const Style = {
@@ -41,6 +48,16 @@ export namespace UI {
} }
export function logo(pad?: string) { export function logo(pad?: string) {
if (!process.stdout.isTTY && !process.stderr.isTTY) {
const result = []
for (const row of wordmark) {
if (pad) result.push(pad)
result.push(row)
result.push(EOL)
}
return result.join("").trimEnd()
}
const result: string[] = [] const result: string[] = []
const reset = "\x1b[0m" const reset = "\x1b[0m"
const left = { const left = {
+87 -149
View File
@@ -20,7 +20,6 @@ import {
} from "jsonc-parser" } from "jsonc-parser"
import { Instance, type InstanceContext } from "../project/instance" import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server" import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown" import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs" import { constants, existsSync } from "fs"
@@ -28,20 +27,18 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global" import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event" import { Event } from "../server/event"
import { Glob } from "../util/glob" import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { online, proxied } from "@/util/network"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Account } from "@/account" import { Account } from "@/account"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state" import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service" import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, ServiceMap } from "effect" import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
import { Flock } from "@/util/flock" import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared" import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm"
export namespace Config { export namespace Config {
const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }) const ModelId = z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" })
@@ -50,6 +47,12 @@ export namespace Config {
export type PluginOptions = z.infer<typeof PluginOptions> export type PluginOptions = z.infer<typeof PluginOptions>
export type PluginSpec = z.infer<typeof PluginSpec> export type PluginSpec = z.infer<typeof PluginSpec>
export type PluginScope = "global" | "local"
export type PluginOrigin = {
spec: PluginSpec
source: string
scope: PluginScope
}
const log = Log.create({ service: "config" }) const log = Log.create({ service: "config" })
@@ -75,9 +78,6 @@ export namespace Config {
// Custom merge function that concatenates array fields instead of replacing them // Custom merge function that concatenates array fields instead of replacing them
function mergeConfigConcatArrays(target: Info, source: Info): Info { function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source) const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
}
if (target.instructions && source.instructions) { if (target.instructions && source.instructions) {
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions])) merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
} }
@@ -90,8 +90,7 @@ export namespace Config {
} }
export async function installDependencies(dir: string, input?: InstallInput) { export async function installDependencies(dir: string, input?: InstallInput) {
if (!(await needsInstall(dir))) return if (!(await isWritable(dir))) return
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, { await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
signal: input?.signal, signal: input?.signal,
onWait: (tick) => onWait: (tick) =>
@@ -102,13 +101,10 @@ export namespace Config {
waited: tick.waited, waited: tick.waited,
}), }),
}) })
input?.signal?.throwIfAborted() input?.signal?.throwIfAborted()
if (!(await needsInstall(dir))) return
const pkg = path.join(dir, "package.json") const pkg = path.join(dir, "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({ const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {}, dependencies: {},
})) }))
@@ -126,49 +122,7 @@ export namespace Config {
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"), ["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
) )
} }
await Npm.install(dir)
// Bun can race cache writes on Windows when installs run in parallel across dirs.
// Serialize installs globally on win32, but keep parallel installs on other platforms.
await using __ =
process.platform === "win32"
? await Flock.acquire("config-install:bun", {
signal: input?.signal,
})
: undefined
await BunProc.run(
[
"install",
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
...(proxied() || process.env.CI ? ["--no-cache"] : []),
],
{
cwd: dir,
abort: input?.signal,
},
).catch((err) => {
if (err instanceof Process.RunFailedError) {
const detail = {
dir,
cmd: err.cmd,
code: err.code,
stdout: err.stdout.toString(),
stderr: err.stderr.toString(),
}
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
log.error("failed to install dependencies", detail)
throw err
}
log.warn("failed to install dependencies", detail)
return
}
if (Flag.OPENCODE_STRICT_CONFIG_DEPS) {
log.error("failed to install dependencies", { dir, error: err })
throw err
}
log.warn("failed to install dependencies", { dir, error: err })
})
} }
async function isWritable(dir: string) { async function isWritable(dir: string) {
@@ -180,42 +134,6 @@ export namespace Config {
} }
} }
export async function needsInstall(dir: string) {
// Some config dirs may be read-only.
// Installing deps there will fail; skip installation in that case.
const writable = await isWritable(dir)
if (!writable) {
log.debug("config dir is not writable, skipping dependency install", { dir })
return false
}
const mod = path.join(dir, "node_modules", "@opencode-ai", "plugin")
if (!existsSync(mod)) return true
const pkg = path.join(dir, "package.json")
const pkgExists = await Filesystem.exists(pkg)
if (!pkgExists) return true
const parsed = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => null)
const dependencies = parsed?.dependencies ?? {}
const depVersion = dependencies["@opencode-ai/plugin"]
if (!depVersion) return true
const targetVersion = Installation.isLocal() ? "latest" : Installation.VERSION
if (targetVersion === "latest") {
if (!online()) return false
const stale = await PackageRegistry.isOutdated("@opencode-ai/plugin", depVersion, dir)
if (!stale) return false
log.info("Cached version is outdated, proceeding with install", {
pkg: "@opencode-ai/plugin",
cachedVersion: depVersion,
})
return true
}
if (depVersion === targetVersion) return false
return true
}
function rel(item: string, patterns: string[]) { function rel(item: string, patterns: string[]) {
const normalizedItem = item.replaceAll("\\", "/") const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) { for (const pattern of patterns) {
@@ -382,31 +300,19 @@ export namespace Config {
return resolved return resolved
} }
/** export function deduplicatePluginOrigins(plugins: PluginOrigin[]): PluginOrigin[] {
* Deduplicates plugins by name, with later entries (higher priority) winning. const seen = new Set<string>()
* Priority order (highest to lowest): const list: PluginOrigin[] = []
* 1. Local plugin/ directory
* 2. Local opencode.json
* 3. Global plugin/ directory
* 4. Global opencode.json
*
* Since plugins are added in low-to-high priority order,
* we reverse, deduplicate (keeping first occurrence), then restore order.
*/
export function deduplicatePlugins(plugins: PluginSpec[]): PluginSpec[] {
const seenNames = new Set<string>()
const uniqueSpecifiers: PluginSpec[] = []
for (const specifier of plugins.toReversed()) { for (const plugin of plugins.toReversed()) {
const spec = pluginSpecifier(specifier) const spec = pluginSpecifier(plugin.spec)
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
if (!seenNames.has(name)) { if (seen.has(name)) continue
seenNames.add(name) seen.add(name)
uniqueSpecifiers.push(specifier) list.push(plugin)
}
} }
return uniqueSpecifiers.toReversed() return list.toReversed()
} }
export const McpLocal = z export const McpLocal = z
@@ -1082,7 +988,9 @@ export namespace Config {
ref: "Config", ref: "Config",
}) })
export type Info = z.output<typeof Info> export type Info = z.output<typeof Info> & {
plugin_origins?: PluginOrigin[]
}
type State = { type State = {
config: Info config: Info
@@ -1129,6 +1037,11 @@ export namespace Config {
}, input) }, input)
} }
function writable(info: Info) {
const { plugin_origins, ...next } = info
return next
}
function parseConfig(text: string, filepath: string): Info { function parseConfig(text: string, filepath: string): Info {
const errors: JsoncParseError[] = [] const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true }) const data = parseJsonc(text, errors, { allowTrailingComma: true })
@@ -1293,6 +1206,30 @@ export namespace Config {
const auth = yield* authSvc.all().pipe(Effect.orDie) const auth = yield* authSvc.all().pipe(Effect.orDie)
let result: Info = {} let result: Info = {}
const scope = (source: string): PluginScope => {
if (source.startsWith("http://") || source.startsWith("https://")) return "global"
if (source === "OPENCODE_CONFIG_CONTENT") return "local"
if (Instance.containsPath(source)) return "local"
return "global"
}
const track = (source: string, list: PluginSpec[] | undefined, kind?: PluginScope) => {
if (!list?.length) return
const hit = kind ?? scope(source)
const plugins = deduplicatePluginOrigins([
...(result.plugin_origins ?? []),
...list.map((spec) => ({ spec, source, scope: hit })),
])
result.plugin = plugins.map((item) => item.spec)
result.plugin_origins = plugins
}
const merge = (source: string, next: Info, kind?: PluginScope) => {
result = mergeConfigConcatArrays(result, next)
track(source, next.plugin, kind)
}
for (const [key, value] of Object.entries(auth)) { for (const [key, value] of Object.entries(auth)) {
if (value.type === "wellknown") { if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "") const url = key.replace(/\/+$/, "")
@@ -1305,21 +1242,21 @@ export namespace Config {
const wellknown = (yield* Effect.promise(() => response.json())) as any const wellknown = (yield* Effect.promise(() => response.json())) as any
const remoteConfig = wellknown.config ?? {} const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json" if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = mergeConfigConcatArrays( const source = `${url}/.well-known/opencode`
result, const next = yield* loadConfig(JSON.stringify(remoteConfig), {
yield* loadConfig(JSON.stringify(remoteConfig), { dir: path.dirname(source),
dir: path.dirname(`${url}/.well-known/opencode`), source,
source: `${url}/.well-known/opencode`, })
}), merge(source, next, "global")
)
log.debug("loaded remote config from well-known", { url }) log.debug("loaded remote config from well-known", { url })
} }
} }
result = mergeConfigConcatArrays(result, yield* getGlobal()) const global = yield* getGlobal()
merge(Global.Path.config, global, "global")
if (Flag.OPENCODE_CONFIG) { if (Flag.OPENCODE_CONFIG) {
result = mergeConfigConcatArrays(result, yield* loadFile(Flag.OPENCODE_CONFIG)) merge(Flag.OPENCODE_CONFIG, yield* loadFile(Flag.OPENCODE_CONFIG))
log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG }) log.debug("loaded custom config", { path: Flag.OPENCODE_CONFIG })
} }
@@ -1327,7 +1264,7 @@ export namespace Config {
for (const file of yield* Effect.promise(() => for (const file of yield* Effect.promise(() =>
ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree), ConfigPaths.projectFiles("opencode", ctx.directory, ctx.worktree),
)) { )) {
result = mergeConfigConcatArrays(result, yield* loadFile(file)) merge(file, yield* loadFile(file), "local")
} }
} }
@@ -1345,9 +1282,10 @@ export namespace Config {
for (const dir of unique(directories)) { for (const dir of unique(directories)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) { if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of ["opencode.json", "opencode.jsonc"]) {
log.debug(`loading config from ${path.join(dir, file)}`) const source = path.join(dir, file)
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file))) log.debug(`loading config from ${source}`)
merge(source, yield* loadFile(source))
result.agent ??= {} result.agent ??= {}
result.mode ??= {} result.mode ??= {}
result.plugin ??= [] result.plugin ??= []
@@ -1355,8 +1293,7 @@ export namespace Config {
} }
const dep = iife(async () => { const dep = iife(async () => {
const stale = await needsInstall(dir) await installDependencies(dir)
if (stale) await installDependencies(dir)
}) })
void dep.catch((err) => { void dep.catch((err) => {
log.warn("background dependency install failed", { dir, error: err }) log.warn("background dependency install failed", { dir, error: err })
@@ -1366,17 +1303,17 @@ export namespace Config {
result.command = mergeDeep(result.command ?? {}, yield* Effect.promise(() => loadCommand(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(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir))) result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir)))) const list = yield* Effect.promise(() => loadPlugin(dir))
track(dir, list)
} }
if (process.env.OPENCODE_CONFIG_CONTENT) { if (process.env.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays( const source = "OPENCODE_CONFIG_CONTENT"
result, const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory, dir: ctx.directory,
source: "OPENCODE_CONFIG_CONTENT", source,
}), })
) merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT") log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
} }
@@ -1395,13 +1332,12 @@ export namespace Config {
const config = Option.getOrUndefined(configOpt) const config = Option.getOrUndefined(configOpt)
if (config) { if (config) {
result = mergeConfigConcatArrays( const source = `${active.url}/api/config`
result, const next = yield* loadConfig(JSON.stringify(config), {
yield* loadConfig(JSON.stringify(config), { dir: path.dirname(source),
dir: path.dirname(`${active.url}/api/config`), source,
source: `${active.url}/api/config`, })
}), merge(source, next, "global")
)
} }
}).pipe( }).pipe(
Effect.catch((err) => { Effect.catch((err) => {
@@ -1414,8 +1350,9 @@ export namespace Config {
} }
if (existsSync(managedDir)) { if (existsSync(managedDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) { for (const file of ["opencode.json", "opencode.jsonc"]) {
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file))) const source = path.join(managedDir, file)
merge(source, yield* loadFile(source), "global")
} }
} }
@@ -1458,8 +1395,6 @@ export namespace Config {
result.compaction = { ...result.compaction, prune: false } result.compaction = { ...result.compaction, prune: false }
} }
result.plugin = deduplicatePlugins(result.plugin ?? [])
return { return {
config: result, config: result,
directories, directories,
@@ -1489,7 +1424,9 @@ export namespace Config {
const dir = yield* InstanceState.directory const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json") const file = path.join(dir, "config.json")
const existing = yield* loadFile(file) const existing = yield* loadFile(file)
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie) yield* fs
.writeFileString(file, JSON.stringify(mergeDeep(writable(existing), writable(config)), null, 2))
.pipe(Effect.orDie)
yield* Effect.promise(() => Instance.dispose()) yield* Effect.promise(() => Instance.dispose())
}) })
@@ -1513,15 +1450,16 @@ export namespace Config {
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) { const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile() const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}" const before = (yield* readConfigFile(file)) ?? "{}"
const input = writable(config)
let next: Info let next: Info
if (!file.endsWith(".jsonc")) { if (!file.endsWith(".jsonc")) {
const existing = parseConfig(before, file) const existing = parseConfig(before, file)
const merged = mergeDeep(existing, config) const merged = mergeDeep(writable(existing), input)
yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie) yield* fs.writeFileString(file, JSON.stringify(merged, null, 2)).pipe(Effect.orDie)
next = merged next = merged
} else { } else {
const updated = patchJsonc(before, config) const updated = patchJsonc(before, input)
next = parseConfig(updated, file) next = parseConfig(updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie) yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
} }
+2 -9
View File
@@ -9,14 +9,7 @@ import { Global } from "@/global"
export namespace ConfigPaths { export namespace ConfigPaths {
export async function projectFiles(name: string, directory: string, worktree: string) { export async function projectFiles(name: string, directory: string, worktree: string) {
const files: string[] = [] return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
for (const file of [`${name}.jsonc`, `${name}.json`]) {
const found = await Filesystem.findUp(file, directory, worktree)
for (const resolved of found.toReversed()) {
files.push(resolved)
}
}
return files
} }
export async function directories(directory: string, worktree: string) { export async function directories(directory: string, worktree: string) {
@@ -43,7 +36,7 @@ export namespace ConfigPaths {
} }
export function fileInDirectory(dir: string, name: string) { export function fileInDirectory(dir: string, name: string) {
return [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)] return [path.join(dir, `${name}.json`), path.join(dir, `${name}.jsonc`)]
} }
export const JsonError = NamedError.create( export const JsonError = NamedError.create(
+10 -61
View File
@@ -3,72 +3,33 @@ import z from "zod"
import { mergeDeep, unique } from "remeda" import { mergeDeep, unique } from "remeda"
import { Config } from "./config" import { Config } from "./config"
import { ConfigPaths } from "./paths" import { ConfigPaths } from "./paths"
import { migrateTuiConfig } from "./migrate-tui-config" import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema" import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance" import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { Log } from "@/util/log" import { Log } from "@/util/log"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { Global } from "@/global" import { Global } from "@/global"
import { parsePluginSpecifier } from "@/plugin/shared"
export namespace TuiConfig { export namespace TuiConfig {
const log = Log.create({ service: "tui.config" }) const log = Log.create({ service: "tui.config" })
export const Info = TuiInfo export const Info = TuiInfo
export type PluginMeta = {
scope: "global" | "local"
source: string
}
export type PluginRecord = {
item: Config.PluginSpec
scope: PluginMeta["scope"]
source: string
}
type PluginEntry = {
item: Config.PluginSpec
meta: PluginMeta
}
type Acc = { type Acc = {
result: Info result: Info
entries: PluginEntry[]
} }
export type Info = z.output<typeof Info> & { export type Info = z.output<typeof Info> & {
// Internal resolved plugin list used by runtime loading. // Internal resolved plugin list used by runtime loading.
plugin_records?: PluginRecord[] plugin_origins?: Config.PluginOrigin[]
} }
function pluginScope(file: string): PluginMeta["scope"] { function pluginScope(file: string): Config.PluginScope {
if (Instance.containsPath(file)) return "local" if (Instance.containsPath(file)) return "local"
return "global" return "global"
} }
function dedupePlugins(list: PluginEntry[]) {
const seen = new Set<string>()
const result: PluginEntry[] = []
for (const item of list.toReversed()) {
const spec = Config.pluginSpecifier(item.item)
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
if (seen.has(name)) continue
seen.add(name)
result.push(item)
}
return result.toReversed()
}
function mergeInfo(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = [...target.plugin, ...source.plugin]
}
return merged
}
function customPath() { function customPath() {
return Flag.OPENCODE_TUI_CONFIG return Flag.OPENCODE_TUI_CONFIG
} }
@@ -95,19 +56,16 @@ export namespace TuiConfig {
async function mergeFile(acc: Acc, file: string) { async function mergeFile(acc: Acc, file: string) {
const data = await loadFile(file) const data = await loadFile(file)
acc.result = mergeInfo(acc.result, data) acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return if (!data.plugin?.length) return
const scope = pluginScope(file) const scope = pluginScope(file)
for (const item of data.plugin) { const plugins = Config.deduplicatePluginOrigins([
acc.entries.push({ ...(acc.result.plugin_origins ?? []),
item, ...data.plugin.map((spec) => ({ spec, scope, source: file })),
meta: { ])
scope, acc.result.plugin = plugins.map((item) => item.spec)
source: file, acc.result.plugin_origins = plugins
},
})
}
} }
const state = Instance.state(async () => { const state = Instance.state(async () => {
@@ -125,7 +83,6 @@ export namespace TuiConfig {
const acc: Acc = { const acc: Acc = {
result: {}, result: {},
entries: [],
} }
for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) { for (const file of ConfigPaths.fileInDirectory(Global.Path.config, "tui")) {
@@ -154,15 +111,7 @@ export namespace TuiConfig {
} }
} }
const merged = dedupePlugins(acc.entries)
acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {}) acc.result.keybinds = Config.Keybinds.parse(acc.result.keybinds ?? {})
const list = merged.map((item) => ({
item: item.item,
scope: item.meta.scope,
source: item.meta.source,
}))
acc.result.plugin = list.map((item) => item.item)
acc.result.plugin_records = list.length ? list : undefined
const deps: Promise<void>[] = [] const deps: Promise<void>[] = []
if (acc.result.plugin?.length) { if (acc.result.plugin?.length) {
@@ -488,3 +488,15 @@ export const layer: Layer.Layer<ChildProcessSpawner, never, FileSystem.FileSyste
) )
export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer)) export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
import { lazy } from "@/util/lazy"
const rt = lazy(async () => {
// Dynamic import to avoid circular dep: cross-spawn-spawner → run-service → Instance → project → cross-spawn-spawner
const { makeRuntime } = await import("@/effect/run-service")
return makeRuntime(ChildProcessSpawner, defaultLayer)
})
type RT = Awaited<ReturnType<typeof rt>>
export const runPromiseExit: RT["runPromiseExit"] = async (...args) => (await rt()).runPromiseExit(...(args as [any]))
export const runPromise: RT["runPromise"] = async (...args) => (await rt()).runPromise(...(args as [any]))
+5
View File
@@ -4,6 +4,7 @@ import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem" import { AppFileSystem } from "@/filesystem"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema" import type { SessionID } from "@/session/schema"
import { Filesystem } from "@/util/filesystem"
import { Log } from "../util/log" import { Log } from "../util/log"
export namespace FileTime { export namespace FileTime {
@@ -62,6 +63,7 @@ export namespace FileTime {
) )
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) { const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
filepath = Filesystem.normalizePath(filepath)
const locks = (yield* InstanceState.get(state)).locks const locks = (yield* InstanceState.get(state)).locks
const lock = locks.get(filepath) const lock = locks.get(filepath)
if (lock) return lock if (lock) return lock
@@ -72,18 +74,21 @@ export namespace FileTime {
}) })
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) { const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
file = Filesystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads const reads = (yield* InstanceState.get(state)).reads
log.info("read", { sessionID, file }) log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file)) session(reads, sessionID).set(file, yield* stamp(file))
}) })
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) { const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
file = Filesystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads const reads = (yield* InstanceState.get(state)).reads
return reads.get(sessionID)?.get(file)?.read return reads.get(sessionID)?.get(file)?.read
}) })
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) { const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return if (disableCheck) return
filepath = Filesystem.normalizePath(filepath)
const reads = (yield* InstanceState.get(state)).reads const reads = (yield* InstanceState.get(state)).reads
const time = reads.get(sessionID)?.get(filepath) const time = reads.get(sessionID)?.get(filepath)
+3 -4
View File
@@ -1,5 +1,4 @@
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance" import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process" import { Process } from "../util/process"
@@ -34,7 +33,7 @@ export const mix: Info = {
export const prettier: Info = { export const prettier: Info = {
name: "prettier", name: "prettier",
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"], command: ["bun", "x", "prettier", "--write", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
@@ -82,7 +81,7 @@ export const prettier: Info = {
export const oxfmt: Info = { export const oxfmt: Info = {
name: "oxfmt", name: "oxfmt",
command: [BunProc.which(), "x", "oxfmt", "$FILE"], command: ["bun", "x", "oxfmt", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
@@ -104,7 +103,7 @@ export const oxfmt: Info = {
export const biome: Info = { export const biome: Info = {
name: "biome", name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"], command: ["bun", "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: { environment: {
BUN_BE_BUN: "1", BUN_BE_BUN: "1",
}, },
+23 -3
View File
@@ -48,7 +48,19 @@ process.on("uncaughtException", (e) => {
}) })
}) })
const cli = yargs(hideBin(process.argv)) const args = hideBin(process.argv)
function show(out: string) {
const text = out.trimStart()
if (!text.startsWith("opencode ")) {
process.stderr.write(UI.logo() + EOL + EOL)
process.stderr.write(text)
return
}
process.stderr.write(out)
}
const cli = yargs(args)
.parserConfiguration({ "populate--": true }) .parserConfiguration({ "populate--": true })
.scriptName("opencode") .scriptName("opencode")
.wrap(100) .wrap(100)
@@ -130,7 +142,7 @@ const cli = yargs(hideBin(process.argv))
process.stderr.write("Database migration complete." + EOL) process.stderr.write("Database migration complete." + EOL)
} }
}) })
.usage("\n" + UI.logo()) .usage("")
.completion("completion", "generate shell completion script") .completion("completion", "generate shell completion script")
.command(AcpCommand) .command(AcpCommand)
.command(McpCommand) .command(McpCommand)
@@ -162,7 +174,7 @@ const cli = yargs(hideBin(process.argv))
msg?.startsWith("Invalid values:") msg?.startsWith("Invalid values:")
) { ) {
if (err) throw err if (err) throw err
cli.showHelp("log") cli.showHelp(show)
} }
if (err) throw err if (err) throw err
process.exit(1) process.exit(1)
@@ -170,7 +182,15 @@ const cli = yargs(hideBin(process.argv))
.strict() .strict()
try { try {
if (args.includes("-h") || args.includes("--help")) {
await cli.parse(args, (err: Error | undefined, _argv: unknown, out: string) => {
if (err) throw err
if (!out) return
show(out)
})
} else {
await cli.parse() await cli.parse()
}
} catch (e) { } catch (e) {
let data: Record<string, any> = {} let data: Record<string, any> = {}
if (e instanceof NamedError) { if (e instanceof NamedError) {
+41 -176
View File
@@ -3,7 +3,6 @@ import path from "path"
import os from "os" import os from "os"
import { Global } from "../global" import { Global } from "../global"
import { Log } from "../util/log" import { Log } from "../util/log"
import { BunProc } from "../bun"
import { text } from "node:stream/consumers" import { text } from "node:stream/consumers"
import fs from "fs/promises" import fs from "fs/promises"
import { Filesystem } from "../util/filesystem" import { Filesystem } from "../util/filesystem"
@@ -14,6 +13,7 @@ import { Process } from "../util/process"
import { which } from "../util/which" import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module" import { Module } from "@opencode-ai/util/module"
import { spawn } from "./launch" import { spawn } from "./launch"
import { Npm } from "@/npm"
export namespace LSPServer { export namespace LSPServer {
const log = Log.create({ service: "lsp.server" }) const log = Log.create({ service: "lsp.server" })
@@ -103,11 +103,12 @@ export namespace LSPServer {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory) const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver }) log.info("typescript server", { tsserver })
if (!tsserver) return if (!tsserver) return
const proc = spawn(BunProc.which(), ["x", "typescript-language-server", "--stdio"], { const bin = await Npm.which("typescript-language-server")
if (!bin) return
const proc = spawn(bin, ["--stdio"], {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
BUN_BE_BUN: "1",
}, },
}) })
return { return {
@@ -129,36 +130,16 @@ export namespace LSPServer {
let binary = which("vue-language-server") let binary = which("vue-language-server")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"@vue",
"language-server",
"bin",
"vue-language-server.js",
)
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], { const resolved = await Npm.which("@vue/language-server")
cwd: Global.Path.bin, if (!resolved) return
env: { binary = resolved
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
BUN_BE_BUN: "1",
}, },
}) })
return { return {
@@ -214,11 +195,10 @@ export namespace LSPServer {
log.info("installed VS Code ESLint server", { serverPath }) log.info("installed VS Code ESLint server", { serverPath })
} }
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], { const proc = spawn("node", [serverPath, "--stdio"], {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
BUN_BE_BUN: "1",
}, },
}) })
@@ -345,15 +325,15 @@ export namespace LSPServer {
if (!bin) { if (!bin) {
const resolved = Module.resolve("biome", root) const resolved = Module.resolve("biome", root)
if (!resolved) return if (!resolved) return
bin = BunProc.which() bin = await Npm.which("biome")
args = ["x", "biome", "lsp-proxy", "--stdio"] if (!bin) return
args = ["lsp-proxy", "--stdio"]
} }
const proc = spawn(bin, args, { const proc = spawn(bin, args, {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
BUN_BE_BUN: "1",
}, },
}) })
@@ -372,9 +352,7 @@ export namespace LSPServer {
}, },
extensions: [".go"], extensions: [".go"],
async spawn(root) { async spawn(root) {
let bin = which("gopls", { let bin = which("gopls")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (!which("go")) return if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -409,9 +387,7 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]), root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"], extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) { async spawn(root) {
let bin = which("rubocop", { let bin = which("rubocop")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
const ruby = which("ruby") const ruby = which("ruby")
const gem = which("gem") const gem = which("gem")
@@ -516,19 +492,10 @@ export namespace LSPServer {
let binary = which("pyright-langserver") let binary = which("pyright-langserver")
const args = [] const args = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "pyright", "dist", "pyright-langserver.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "pyright"], { const resolved = await Npm.which("pyright")
cwd: Global.Path.bin, if (!resolved) return
env: { binary = resolved
...process.env,
BUN_BE_BUN: "1",
},
}).exited
}
binary = BunProc.which()
args.push(...["run", js])
} }
args.push("--stdio") args.push("--stdio")
@@ -552,7 +519,6 @@ export namespace LSPServer {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
BUN_BE_BUN: "1",
}, },
}) })
return { return {
@@ -630,9 +596,7 @@ export namespace LSPServer {
extensions: [".zig", ".zon"], extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]), root: NearestRoot(["build.zig"]),
async spawn(root) { async spawn(root) {
let bin = which("zls", { let bin = which("zls")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
const zig = which("zig") const zig = which("zig")
@@ -742,9 +706,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]), root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"], extensions: [".cs"],
async spawn(root) { async spawn(root) {
let bin = which("csharp-ls", { let bin = which("csharp-ls")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (!which("dotnet")) { if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls") log.error(".NET SDK is required to install csharp-ls")
@@ -781,9 +743,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]), root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"], extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) { async spawn(root) {
let bin = which("fsautocomplete", { let bin = which("fsautocomplete")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (!which("dotnet")) { if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete") log.error(".NET SDK is required to install fsautocomplete")
@@ -1049,29 +1009,16 @@ export namespace LSPServer {
let binary = which("svelteserver") let binary = which("svelteserver")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "svelte-language-server", "bin", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], { const resolved = await Npm.which("svelte-language-server")
cwd: Global.Path.bin, if (!resolved) return
env: { binary = resolved
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
BUN_BE_BUN: "1",
}, },
}) })
return { return {
@@ -1096,29 +1043,16 @@ export namespace LSPServer {
let binary = which("astro-ls") let binary = which("astro-ls")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "@astrojs", "language-server", "bin", "nodeServer.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], { const resolved = await Npm.which("@astrojs/language-server")
cwd: Global.Path.bin, if (!resolved) return
env: { binary = resolved
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
BUN_BE_BUN: "1",
}, },
}) })
return { return {
@@ -1360,38 +1294,16 @@ export namespace LSPServer {
let binary = which("yaml-language-server") let binary = which("yaml-language-server")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(
Global.Path.bin,
"node_modules",
"yaml-language-server",
"out",
"server",
"src",
"server.js",
)
const exists = await Filesystem.exists(js)
if (!exists) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], { const resolved = await Npm.which("yaml-language-server")
cwd: Global.Path.bin, if (!resolved) return
env: { binary = resolved
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
BUN_BE_BUN: "1",
}, },
}) })
return { return {
@@ -1413,9 +1325,7 @@ export namespace LSPServer {
]), ]),
extensions: [".lua"], extensions: [".lua"],
async spawn(root) { async spawn(root) {
let bin = which("lua-language-server", { let bin = which("lua-language-server")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1551,29 +1461,16 @@ export namespace LSPServer {
let binary = which("intelephense") let binary = which("intelephense")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "intelephense", "lib", "intelephense.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "intelephense"], { const resolved = await Npm.which("intelephense")
cwd: Global.Path.bin, if (!resolved) return
env: { binary = resolved
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
BUN_BE_BUN: "1",
}, },
}) })
return { return {
@@ -1648,29 +1545,16 @@ export namespace LSPServer {
let binary = which("bash-language-server") let binary = which("bash-language-server")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "bash-language-server", "out", "cli.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "bash-language-server"], { const resolved = await Npm.which("bash-language-server")
cwd: Global.Path.bin, if (!resolved) return
env: { binary = resolved
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("start") args.push("start")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
BUN_BE_BUN: "1",
}, },
}) })
return { return {
@@ -1684,9 +1568,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"], extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]), root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) { async spawn(root) {
let bin = which("terraform-ls", { let bin = which("terraform-ls")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1767,9 +1649,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"], extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]), root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) { async spawn(root) {
let bin = which("texlab", { let bin = which("texlab")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1860,29 +1740,16 @@ export namespace LSPServer {
let binary = which("docker-langserver") let binary = which("docker-langserver")
const args: string[] = [] const args: string[] = []
if (!binary) { if (!binary) {
const js = path.join(Global.Path.bin, "node_modules", "dockerfile-language-server-nodejs", "lib", "server.js")
if (!(await Filesystem.exists(js))) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], { const resolved = await Npm.which("dockerfile-language-server-nodejs")
cwd: Global.Path.bin, if (!resolved) return
env: { binary = resolved
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
} }
args.push("--stdio") args.push("--stdio")
const proc = spawn(binary, args, { const proc = spawn(binary, args, {
cwd: root, cwd: root,
env: { env: {
...process.env, ...process.env,
BUN_BE_BUN: "1",
}, },
}) })
return { return {
@@ -1966,9 +1833,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"], extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]), root: NearestRoot(["typst.toml"]),
async spawn(root) { async spawn(root) {
let bin = which("tinymist", { let bin = which("tinymist")
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
if (!bin) { if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
+180
View File
@@ -0,0 +1,180 @@
import semver from "semver"
import z from "zod"
import { NamedError } from "@opencode-ai/util/error"
import { Global } from "../global"
import { Log } from "../util/log"
import path from "path"
import { readdir, rm } from "fs/promises"
import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { Arborist } from "@npmcli/arborist"
export namespace Npm {
const log = Log.create({ service: "npm" })
export const InstallFailedError = NamedError.create(
"NpmInstallFailedError",
z.object({
pkg: z.string(),
}),
)
function directory(pkg: string) {
return path.join(Global.Path.cache, "packages", pkg)
}
function resolveEntryPoint(name: string, dir: string) {
let entrypoint: string | undefined
try {
entrypoint = typeof Bun !== "undefined" ? import.meta.resolve(name, dir) : import.meta.resolve(dir)
} catch {}
const result = {
directory: dir,
entrypoint,
}
return result
}
export async function outdated(pkg: string, cachedVersion: string): Promise<boolean> {
const response = await fetch(`https://registry.npmjs.org/${pkg}`)
if (!response.ok) {
log.warn("Failed to resolve latest version, using cached", { pkg, cachedVersion })
return false
}
const data = (await response.json()) as { "dist-tags"?: { latest?: string } }
const latestVersion = data?.["dist-tags"]?.latest
if (!latestVersion) {
log.warn("No latest version found, using cached", { pkg, cachedVersion })
return false
}
const range = /[\s^~*xX<>|=]/.test(cachedVersion)
if (range) return !semver.satisfies(latestVersion, cachedVersion)
return semver.lt(cachedVersion, latestVersion)
}
export async function add(pkg: string) {
const dir = directory(pkg)
await using _ = await Flock.acquire(`npm-install:${Filesystem.resolve(dir)}`)
log.info("installing package", {
pkg,
})
const arborist = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
const tree = await arborist.loadVirtual().catch(() => {})
if (tree) {
const first = tree.edgesOut.values().next().value?.to
if (first) {
return resolveEntryPoint(first.name, first.path)
}
}
const result = await arborist
.reify({
add: [pkg],
save: true,
saveType: "prod",
})
.catch((cause) => {
throw new InstallFailedError(
{ pkg },
{
cause,
},
)
})
const first = result.edgesOut.values().next().value?.to
if (!first) throw new InstallFailedError({ pkg })
return resolveEntryPoint(first.name, first.path)
}
export async function install(dir: string) {
await using _ = await Flock.acquire(`npm-install:${dir}`)
log.info("checking dependencies", { dir })
const reify = async () => {
const arb = new Arborist({
path: dir,
binLinks: true,
progress: false,
savePrefix: "",
})
await arb.reify().catch(() => {})
}
if (!(await Filesystem.exists(path.join(dir, "node_modules")))) {
log.info("node_modules missing, reifying")
await reify()
return
}
const pkg = await Filesystem.readJson(path.join(dir, "package.json")).catch(() => ({}))
const lock = await Filesystem.readJson(path.join(dir, "package-lock.json")).catch(() => ({}))
const declared = new Set([
...Object.keys(pkg.dependencies || {}),
...Object.keys(pkg.devDependencies || {}),
...Object.keys(pkg.peerDependencies || {}),
...Object.keys(pkg.optionalDependencies || {}),
])
const root = lock.packages?.[""] || {}
const locked = new Set([
...Object.keys(root.dependencies || {}),
...Object.keys(root.devDependencies || {}),
...Object.keys(root.peerDependencies || {}),
...Object.keys(root.optionalDependencies || {}),
])
for (const name of declared) {
if (!locked.has(name)) {
log.info("dependency not in lock file, reifying", { name })
await reify()
return
}
}
log.info("dependencies in sync")
}
export async function which(pkg: string) {
const dir = directory(pkg)
const binDir = path.join(dir, "node_modules", ".bin")
const pick = async () => {
const files = await readdir(binDir).catch(() => [])
if (files.length === 0) return undefined
if (files.length === 1) return files[0]
// Multiple binaries — resolve from package.json bin field like npx does
const pkgJson = await Filesystem.readJson<{ bin?: string | Record<string, string> }>(
path.join(dir, "node_modules", pkg, "package.json"),
).catch(() => undefined)
if (pkgJson?.bin) {
const unscoped = pkg.startsWith("@") ? pkg.split("/")[1] : pkg
const bin = pkgJson.bin
if (typeof bin === "string") return unscoped
const keys = Object.keys(bin)
if (keys.length === 1) return keys[0]
return bin[unscoped] ? unscoped : keys[0]
}
return files[0]
}
const bin = await pick()
if (bin) return path.join(binDir, bin)
await rm(path.join(dir, "package-lock.json"), { force: true })
await add(pkg)
const resolved = await pick()
if (!resolved) return
return path.join(binDir, resolved)
}
}
@@ -1,7 +1,12 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin" import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import type { Model } from "@opencode-ai/sdk/v2"
import { Installation } from "@/installation" import { Installation } from "@/installation"
import { iife } from "@/util/iife" import { iife } from "@/util/iife"
import { Log } from "../../util/log"
import { setTimeout as sleep } from "node:timers/promises" import { setTimeout as sleep } from "node:timers/promises"
import { CopilotModels } from "./models"
const log = Log.create({ service: "plugin.copilot" })
const CLIENT_ID = "Ov23li8tweQw6odWQebz" const CLIENT_ID = "Ov23li8tweQw6odWQebz"
// Add a small safety buffer when polling to avoid hitting the server // Add a small safety buffer when polling to avoid hitting the server
@@ -18,45 +23,50 @@ function getUrls(domain: string) {
} }
} }
function base(enterpriseUrl?: string) {
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
}
function fix(model: Model): Model {
return {
...model,
api: {
...model.api,
npm: "@ai-sdk/github-copilot",
},
}
}
export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> { export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
const sdk = input.client const sdk = input.client
return { return {
provider: {
id: "github-copilot",
async models(provider, ctx) {
if (ctx.auth?.type !== "oauth") {
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
}
return CopilotModels.get(
base(ctx.auth.enterpriseUrl),
{
Authorization: `Bearer ${ctx.auth.refresh}`,
"User-Agent": `opencode/${Installation.VERSION}`,
},
provider.models,
).catch((error) => {
log.error("failed to fetch copilot models", { error })
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
})
},
},
auth: { auth: {
provider: "github-copilot", provider: "github-copilot",
async loader(getAuth, provider) { async loader(getAuth) {
const info = await getAuth() const info = await getAuth()
if (!info || info.type !== "oauth") return {} if (!info || info.type !== "oauth") return {}
const enterpriseUrl = info.enterpriseUrl const baseURL = base(info.enterpriseUrl)
const baseURL = enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : undefined
if (provider && provider.models) {
for (const model of Object.values(provider.models)) {
model.cost = {
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
}
// TODO: re-enable once messages api has higher rate limits
// TODO: move some of this hacky-ness to models.dev presets once we have better grasp of things here...
// const base = baseURL ?? model.api.url
// const claude = model.id.includes("claude")
// const url = iife(() => {
// if (!claude) return base
// if (base.endsWith("/v1")) return base
// if (base.endsWith("/")) return `${base}v1`
// return `${base}/v1`
// })
// model.api.url = url
// model.api.npm = claude ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot"
model.api.npm = "@ai-sdk/github-copilot"
}
}
return { return {
baseURL, baseURL,
@@ -0,0 +1,143 @@
import { z } from "zod"
import type { Model } from "@opencode-ai/sdk/v2"
export namespace CopilotModels {
export const schema = z.object({
data: z.array(
z.object({
model_picker_enabled: z.boolean(),
id: z.string(),
name: z.string(),
// every version looks like: `{model.id}-YYYY-MM-DD`
version: z.string(),
supported_endpoints: z.array(z.string()).optional(),
capabilities: z.object({
family: z.string(),
limits: z.object({
max_context_window_tokens: z.number(),
max_output_tokens: z.number(),
max_prompt_tokens: z.number(),
vision: z
.object({
max_prompt_image_size: z.number(),
max_prompt_images: z.number(),
supported_media_types: z.array(z.string()),
})
.optional(),
}),
supports: z.object({
adaptive_thinking: z.boolean().optional(),
max_thinking_budget: z.number().optional(),
min_thinking_budget: z.number().optional(),
reasoning_effort: z.array(z.string()).optional(),
streaming: z.boolean(),
structured_outputs: z.boolean().optional(),
tool_calls: z.boolean(),
vision: z.boolean().optional(),
}),
}),
}),
),
})
type Item = z.infer<typeof schema>["data"][number]
function build(key: string, remote: Item, url: string, prev?: Model): Model {
const reasoning =
!!remote.capabilities.supports.adaptive_thinking ||
!!remote.capabilities.supports.reasoning_effort?.length ||
remote.capabilities.supports.max_thinking_budget !== undefined ||
remote.capabilities.supports.min_thinking_budget !== undefined
const image =
(remote.capabilities.supports.vision ?? false) ||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
return {
id: key,
providerID: "github-copilot",
api: {
id: remote.id,
url,
npm: "@ai-sdk/github-copilot",
},
// API response wins
status: "active",
limit: {
context: remote.capabilities.limits.max_context_window_tokens,
input: remote.capabilities.limits.max_prompt_tokens,
output: remote.capabilities.limits.max_output_tokens,
},
capabilities: {
temperature: prev?.capabilities.temperature ?? true,
reasoning: prev?.capabilities.reasoning ?? reasoning,
attachment: prev?.capabilities.attachment ?? true,
toolcall: remote.capabilities.supports.tool_calls,
input: {
text: true,
audio: false,
image,
video: false,
pdf: false,
},
output: {
text: true,
audio: false,
image: false,
video: false,
pdf: false,
},
interleaved: false,
},
// existing wins
family: prev?.family ?? remote.capabilities.family,
name: prev?.name ?? remote.name,
cost: {
input: 0,
output: 0,
cache: { read: 0, write: 0 },
},
options: prev?.options ?? {},
headers: prev?.headers ?? {},
release_date:
prev?.release_date ??
(remote.version.startsWith(`${remote.id}-`) ? remote.version.slice(remote.id.length + 1) : remote.version),
variants: prev?.variants ?? {},
}
}
export async function get(
baseURL: string,
headers: HeadersInit = {},
existing: Record<string, Model> = {},
): Promise<Record<string, Model>> {
const data = await fetch(`${baseURL}/models`, {
headers,
}).then(async (res) => {
if (!res.ok) {
throw new Error(`Failed to fetch models: ${res.status}`)
}
return schema.parse(await res.json())
})
const result = { ...existing }
const remote = new Map(data.data.filter((m) => m.model_picker_enabled).map((m) => [m.id, m] as const))
// prune existing models whose api.id isn't in the endpoint response
for (const [key, model] of Object.entries(result)) {
const m = remote.get(model.api.id)
if (!m) {
delete result[key]
continue
}
result[key] = build(key, m, baseURL, model)
}
// add new endpoint models not already keyed in result
for (const [id, m] of remote) {
if (id in result) continue
result[id] = build(id, m, baseURL)
}
return result
}
}
+44 -85
View File
@@ -7,7 +7,7 @@ import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex" import { CodexAuthPlugin } from "./codex"
import { Session } from "../session" import { Session } from "../session"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
import { CopilotAuthPlugin } from "./copilot" import { CopilotAuthPlugin } from "./github-copilot/copilot"
import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
import { PoeAuthPlugin } from "opencode-poe-auth" import { PoeAuthPlugin } from "opencode-poe-auth"
import { Effect, Layer, ServiceMap, Stream } from "effect" import { Effect, Layer, ServiceMap, Stream } from "effect"
@@ -24,10 +24,6 @@ export namespace Plugin {
hooks: Hooks[] hooks: Hooks[]
} }
type Loaded = {
row: PluginLoader.Loaded
}
// Hook names that follow the (input, output) => Promise<void> trigger pattern // Hook names that follow the (input, output) => Promise<void> trigger pattern
type TriggerName = { type TriggerName = {
[K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never [K in keyof Hooks]-?: NonNullable<Hooks[K]> extends (input: any, output: any) => Promise<void> ? K : never
@@ -78,22 +74,20 @@ export namespace Plugin {
return result return result
} }
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) { function publishPluginError(message: string) {
const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect") Bus.publish(Session.Event.Error, { error: new NamedError.Unknown({ message }).toObject() })
}
async function applyPlugin(load: PluginLoader.Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = readV1Plugin(load.mod, load.spec, "server", "detect")
if (plugin) { if (plugin) {
await resolvePluginId( await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
load.row.source, hooks.push(await (plugin as PluginModule).server(input, load.options))
load.row.spec,
load.row.target,
readPluginId(plugin.id, load.row.spec),
load.row.pkg,
)
hooks.push(await (plugin as PluginModule).server(input, load.row.options))
return return
} }
for (const server of getLegacyPlugins(load.row.mod)) { for (const server of getLegacyPlugins(load.mod)) {
hooks.push(await server(input, load.row.options)) hooks.push(await server(input, load.options))
} }
} }
@@ -142,87 +136,52 @@ export namespace Plugin {
if (init._tag === "Some") hooks.push(init.value) if (init._tag === "Some") hooks.push(init.value)
} }
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? []) const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && cfg.plugin?.length) { if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length }) log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
} }
if (plugins.length) yield* config.waitForDependencies() if (plugins.length) yield* config.waitForDependencies()
const loaded = yield* Effect.promise(() => const loaded = yield* Effect.promise(() =>
Promise.all( PluginLoader.loadExternal({
plugins.map(async (item) => { items: plugins,
const plan = PluginLoader.plan(item) kind: "server",
if (plan.deprecated) return report: {
log.info("loading plugin", { path: plan.spec }) start(candidate) {
log.info("loading plugin", { path: candidate.plan.spec })
},
missing(candidate, _retry, message) {
log.warn("plugin has no server entrypoint", { path: candidate.plan.spec, message })
},
error(candidate, _retry, stage, error, resolved) {
const spec = candidate.plan.spec
const cause = error instanceof Error ? (error.cause ?? error) : error
const message = stage === "load" ? errorMessage(error) : errorMessage(cause)
const resolved = await PluginLoader.resolve(plan, "server") if (stage === "install") {
if (!resolved.ok) { const parsed = parsePluginSpecifier(spec)
if (resolved.stage === "missing") { log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
log.warn("plugin has no server entrypoint", { publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
path: plan.spec,
message: resolved.message,
})
return return
} }
const cause = if (stage === "compatibility") {
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error log.warn("plugin incompatible", { path: spec, error: message })
const message = errorMessage(cause) publishPluginError(`Plugin ${spec} skipped: ${message}`)
if (resolved.stage === "install") {
const parsed = parsePluginSpecifier(plan.spec)
log.error("failed to install plugin", {
pkg: parsed.pkg,
version: parsed.version,
error: message,
})
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`,
}).toObject(),
})
return return
} }
if (resolved.stage === "compatibility") { if (stage === "entry") {
log.warn("plugin incompatible", { path: plan.spec, error: message }) log.error("failed to resolve plugin server entry", { path: spec, error: message })
Bus.publish(Session.Event.Error, { publishPluginError(`Failed to load plugin ${spec}: ${message}`)
error: new NamedError.Unknown({
message: `Plugin ${plan.spec} skipped: ${message}`,
}).toObject(),
})
return return
} }
log.error("failed to resolve plugin server entry", { log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
path: plan.spec, publishPluginError(`Failed to load plugin ${spec}: ${message}`)
error: message, },
}) },
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plan.spec}: ${message}`,
}).toObject(),
})
return
}
const mod = await PluginLoader.load(resolved.value)
if (!mod.ok) {
const message = errorMessage(mod.error)
log.error("failed to load plugin", { path: plan.spec, target: resolved.value.entry, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${plan.spec}: ${message}`,
}).toObject(),
})
return
}
return {
row: mod.value,
}
}), }),
),
) )
for (const load of loaded) { for (const load of loaded) {
if (!load) continue if (!load) continue
@@ -233,14 +192,14 @@ export namespace Plugin {
try: () => applyPlugin(load, input, hooks), try: () => applyPlugin(load, input, hooks),
catch: (err) => { catch: (err) => {
const message = errorMessage(err) const message = errorMessage(err)
log.error("failed to load plugin", { path: load.row.spec, error: message }) log.error("failed to load plugin", { path: load.spec, error: message })
return message return message
}, },
}).pipe( }).pipe(
Effect.catch((message) => Effect.catch((message) =>
bus.publish(Session.Event.Error, { bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({ error: new NamedError.Unknown({
message: `Failed to load plugin ${load.row.spec}: ${message}`, message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(), }).toObject(),
}), }),
), ),
+30 -8
View File
@@ -13,7 +13,7 @@ import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock" import { Flock } from "@/util/flock"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared" import { parsePluginSpecifier, readPackageThemes, readPluginPackage, resolvePluginTarget } from "./shared"
type Mode = "noop" | "add" | "replace" type Mode = "noop" | "add" | "replace"
type Kind = "server" | "tui" type Kind = "server" | "tui"
@@ -142,19 +142,26 @@ function hasMainTarget(pkg: Record<string, unknown>) {
return Boolean(main.trim()) return Boolean(main.trim())
} }
function packageTargets(pkg: Record<string, unknown>) { function packageTargets(pkg: { json: Record<string, unknown>; dir: string; pkg: string }) {
const spec =
typeof pkg.json.name === "string" && pkg.json.name.trim().length > 0 ? pkg.json.name.trim() : path.basename(pkg.dir)
const targets: Target[] = [] const targets: Target[] = []
const server = exportTarget(pkg, "server") const server = exportTarget(pkg.json, "server")
if (server) { if (server) {
targets.push({ kind: "server", opts: server.opts }) targets.push({ kind: "server", opts: server.opts })
} else if (hasMainTarget(pkg)) { } else if (hasMainTarget(pkg.json)) {
targets.push({ kind: "server" }) targets.push({ kind: "server" })
} }
const tui = exportTarget(pkg, "tui") const tui = exportTarget(pkg.json, "tui")
if (tui) { if (tui) {
targets.push({ kind: "tui", opts: tui.opts }) targets.push({ kind: "tui", opts: tui.opts })
} }
if (!targets.some((item) => item.kind === "tui") && readPackageThemes(spec, pkg).length) {
targets.push({ kind: "tui" })
}
return targets return targets
} }
@@ -293,8 +300,23 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
} }
} }
const targets = packageTargets(pkg.item.json) const targets = await Promise.resolve()
if (!targets.length) { .then(() => packageTargets(pkg.item))
.then(
(item) => ({ ok: true as const, item }),
(error: unknown) => ({ ok: false as const, error }),
)
if (!targets.ok) {
return {
ok: false,
code: "manifest_read_failed",
file: pkg.item.pkg,
error: targets.error,
}
}
if (!targets.item.length) {
return { return {
ok: false, ok: false,
code: "manifest_no_targets", code: "manifest_no_targets",
@@ -304,7 +326,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
return { return {
ok: true, ok: true,
targets, targets: targets.item,
} }
} }
+97 -60
View File
@@ -4,6 +4,7 @@ import {
checkPluginCompatibility, checkPluginCompatibility,
createPluginEntry, createPluginEntry,
isDeprecatedPlugin, isDeprecatedPlugin,
pluginSource,
resolvePluginTarget, resolvePluginTarget,
type PluginKind, type PluginKind,
type PluginPackage, type PluginPackage,
@@ -12,31 +13,42 @@ import {
export namespace PluginLoader { export namespace PluginLoader {
export type Plan = { export type Plan = {
item: Config.PluginSpec
spec: string spec: string
options: Config.PluginOptions | undefined options: Config.PluginOptions | undefined
deprecated: boolean deprecated: boolean
} }
export type Resolved = Plan & { export type Resolved = Plan & {
source: PluginSource source: PluginSource
target: string target: string
entry: string entry: string
pkg?: PluginPackage pkg?: PluginPackage
} }
export type Missing = Plan & {
source: PluginSource
target: string
pkg?: PluginPackage
message: string
}
export type Loaded = Resolved & { export type Loaded = Resolved & {
mod: Record<string, unknown> mod: Record<string, unknown>
} }
export function plan(item: Config.PluginSpec): Plan { type Candidate = { origin: Config.PluginOrigin; plan: Plan }
const spec = Config.pluginSpecifier(item) type Report = {
return { start?: (candidate: Candidate, retry: boolean) => void
item, missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
spec, error?: (
options: Config.pluginOptions(item), candidate: Candidate,
deprecated: isDeprecatedPlugin(spec), retry: boolean,
stage: "install" | "entry" | "compatibility" | "load",
error: unknown,
resolved?: Resolved,
) => void
} }
function plan(item: Config.PluginSpec): Plan {
const spec = Config.pluginSpecifier(item)
return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
} }
export async function resolve( export async function resolve(
@@ -44,68 +56,44 @@ export namespace PluginLoader {
kind: PluginKind, kind: PluginKind,
): Promise< ): Promise<
| { ok: true; value: Resolved } | { ok: true; value: Resolved }
| { ok: false; stage: "missing"; message: string } | { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown } | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> { > {
let target = "" let target = ""
try { try {
target = await resolvePluginTarget(plan.spec) target = await resolvePluginTarget(plan.spec)
} catch (error) { } catch (error) {
return { return { ok: false, stage: "install", error }
ok: false,
stage: "install",
error,
}
}
if (!target) {
return {
ok: false,
stage: "install",
error: new Error(`Plugin ${plan.spec} target is empty`),
}
} }
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
let base let base
try { try {
base = await createPluginEntry(plan.spec, target, kind) base = await createPluginEntry(plan.spec, target, kind)
} catch (error) { } catch (error) {
return { return { ok: false, stage: "entry", error }
ok: false,
stage: "entry",
error,
} }
} if (!base.entry)
if (!base.entry) {
return { return {
ok: false, ok: false,
stage: "missing", stage: "missing",
value: {
...plan,
source: base.source,
target: base.target,
pkg: base.pkg,
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`, message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
} },
} }
if (base.source === "npm") { if (base.source === "npm") {
try { try {
await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg) await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
} catch (error) { } catch (error) {
return { return { ok: false, stage: "compatibility", error }
ok: false,
stage: "compatibility",
error,
} }
} }
} return { ok: true, value: { ...plan, source: base.source, target: base.target, entry: base.entry, pkg: base.pkg } }
return {
ok: true,
value: {
...plan,
source: base.source,
target: base.target,
entry: base.entry,
pkg: base.pkg,
},
}
} }
export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> { export async function load(row: Resolved): Promise<{ ok: true; value: Loaded } | { ok: false; error: unknown }> {
@@ -113,25 +101,74 @@ export namespace PluginLoader {
try { try {
mod = await import(row.entry) mod = await import(row.entry)
} catch (error) { } catch (error) {
return { return { ok: false, error }
ok: false,
error,
} }
if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
return { ok: true, value: { ...row, mod } }
} }
if (!mod) { async function attempt<R>(
return { candidate: Candidate,
ok: false, kind: PluginKind,
error: new Error(`Plugin ${row.spec} module is empty`), retry: boolean,
finish: ((load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
missing: ((value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>) | undefined,
report: Report | undefined,
): Promise<R | undefined> {
const plan = candidate.plan
if (plan.deprecated) return
report?.start?.(candidate, retry)
const resolved = await resolve(plan, kind)
if (!resolved.ok) {
if (resolved.stage === "missing") {
if (missing) {
const value = await missing(resolved.value, candidate.origin, retry)
if (value !== undefined) return value
} }
report?.missing?.(candidate, retry, resolved.value.message, resolved.value)
return
}
report?.error?.(candidate, retry, resolved.stage, resolved.error)
return
}
const loaded = await load(resolved.value)
if (!loaded.ok) {
report?.error?.(candidate, retry, "load", loaded.error, resolved.value)
return
}
if (!finish) return loaded.value as R
return finish(loaded.value, candidate.origin, retry)
} }
return { type Input<R> = {
ok: true, items: Config.PluginOrigin[]
value: { kind: PluginKind
...row, wait?: () => Promise<void>
mod, finish?: (load: Loaded, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
}, missing?: (value: Missing, origin: Config.PluginOrigin, retry: boolean) => Promise<R | undefined>
report?: Report
} }
export async function loadExternal<R = Loaded>(input: Input<R>): Promise<R[]> {
const candidates = input.items.map((origin) => ({ origin, plan: plan(origin.spec) }))
const list: Array<Promise<R | undefined>> = []
for (const candidate of candidates) {
list.push(attempt(candidate, input.kind, false, input.finish, input.missing, input.report))
}
const out = await Promise.all(list)
if (input.wait) {
let deps: Promise<void> | undefined
for (let i = 0; i < candidates.length; i++) {
if (out[i] !== undefined) continue
const candidate = candidates[i]
if (!candidate || pluginSource(candidate.plan.spec) !== "file") continue
deps ??= input.wait()
await deps
out[i] = await attempt(candidate, input.kind, true, input.finish, input.missing, input.report)
}
}
const ready: R[] = []
for (const item of out) if (item !== undefined) ready.push(item)
return ready
} }
} }
+43 -8
View File
@@ -1,7 +1,7 @@
import path from "path" import path from "path"
import { fileURLToPath, pathToFileURL } from "url" import { fileURLToPath, pathToFileURL } from "url"
import semver from "semver" import semver from "semver"
import { BunProc } from "@/bun" import { Npm } from "@/npm"
import { Filesystem } from "@/util/filesystem" import { Filesystem } from "@/util/filesystem"
import { isRecord } from "@/util/record" import { isRecord } from "@/util/record"
@@ -50,6 +50,10 @@ function resolveExportPath(raw: string, dir: string) {
return path.resolve(dir, raw) return path.resolve(dir, raw)
} }
function isAbsolutePath(raw: string) {
return path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)
}
function extractExportValue(value: unknown): string | undefined { function extractExportValue(value: unknown): string | undefined {
if (typeof value === "string") return value if (typeof value === "string") return value
if (!isRecord(value)) return undefined if (!isRecord(value)) return undefined
@@ -68,14 +72,18 @@ function packageMain(pkg: PluginPackage) {
return next return next
} }
function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) { function resolvePackageFile(spec: string, raw: string, kind: string, pkg: PluginPackage) {
const resolved = resolveExportPath(raw, pkg.dir) const resolved = resolveExportPath(raw, pkg.dir)
const root = Filesystem.resolve(pkg.dir) const root = Filesystem.resolve(pkg.dir)
const next = Filesystem.resolve(resolved) const next = Filesystem.resolve(resolved)
if (!Filesystem.contains(root, next)) { if (!Filesystem.contains(root, next)) {
throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`) throw new Error(`Plugin ${spec} resolved ${kind} entry outside plugin directory`)
} }
return pathToFileURL(next).href return next
}
function resolvePackagePath(spec: string, raw: string, kind: PluginKind, pkg: PluginPackage) {
return pathToFileURL(resolvePackageFile(spec, raw, kind, pkg)).href
} }
function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) { function resolvePackageEntrypoint(spec: string, kind: PluginKind, pkg: PluginPackage) {
@@ -106,7 +114,7 @@ async function resolveDirectoryIndex(dir: string) {
async function resolveTargetDirectory(target: string) { async function resolveTargetDirectory(target: string) {
const file = targetPath(target) const file = targetPath(target)
if (!file) return if (!file) return
const stat = await Filesystem.stat(file) const stat = await Filesystem.statAsync(file)
if (!stat?.isDirectory()) return if (!stat?.isDirectory()) return
return file return file
} }
@@ -147,13 +155,13 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
} }
export function isPathPluginSpec(spec: string) { export function isPathPluginSpec(spec: string) {
return spec.startsWith("file://") || spec.startsWith(".") || path.isAbsolute(spec) || /^[A-Za-z]:[\\/]/.test(spec) return spec.startsWith("file://") || spec.startsWith(".") || isAbsolutePath(spec)
} }
export async function resolvePathPluginTarget(spec: string) { export async function resolvePathPluginTarget(spec: string) {
const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw) const file = path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw) ? raw : path.resolve(raw)
const stat = await Filesystem.stat(file) const stat = await Filesystem.statAsync(file)
if (!stat?.isDirectory()) { if (!stat?.isDirectory()) {
if (spec.startsWith("file://")) return spec if (spec.startsWith("file://")) return spec
return pathToFileURL(file).href return pathToFileURL(file).href
@@ -184,12 +192,13 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) { export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec) if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
return BunProc.install(parsed.pkg, parsed.version, { ignoreScripts: true }) const result = await Npm.add(parsed.pkg + "@" + parsed.version)
return result.directory
} }
export async function readPluginPackage(target: string): Promise<PluginPackage> { export async function readPluginPackage(target: string): Promise<PluginPackage> {
const file = target.startsWith("file://") ? fileURLToPath(target) : target const file = target.startsWith("file://") ? fileURLToPath(target) : target
const stat = await Filesystem.stat(file) const stat = await Filesystem.statAsync(file)
const dir = stat?.isDirectory() ? file : path.dirname(file) const dir = stat?.isDirectory() ? file : path.dirname(file)
const pkg = path.join(dir, "package.json") const pkg = path.join(dir, "package.json")
const json = await Filesystem.readJson<Record<string, unknown>>(pkg) const json = await Filesystem.readJson<Record<string, unknown>>(pkg)
@@ -210,6 +219,32 @@ export async function createPluginEntry(spec: string, target: string, kind: Plug
} }
} }
export function readPackageThemes(spec: string, pkg: PluginPackage) {
const field = pkg.json["oc-themes"]
if (field === undefined) return []
if (!Array.isArray(field)) {
throw new TypeError(`Plugin ${spec} has invalid oc-themes field`)
}
const list = field.map((item) => {
if (typeof item !== "string") {
throw new TypeError(`Plugin ${spec} has invalid oc-themes entry`)
}
const raw = item.trim()
if (!raw) {
throw new TypeError(`Plugin ${spec} has empty oc-themes entry`)
}
if (raw.startsWith("file://") || isAbsolutePath(raw)) {
throw new TypeError(`Plugin ${spec} oc-themes entry must be relative: ${item}`)
}
return resolvePackageFile(spec, raw, "oc-themes", pkg)
})
return Array.from(new Set(list))
}
export function readPluginId(id: unknown, spec: string) { export function readPluginId(id: unknown, spec: string) {
if (id === undefined) return if (id === undefined) return
if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`) if (typeof id !== "string") throw new TypeError(`Plugin ${spec} has invalid id type ${typeof id}`)
+55 -18
View File
@@ -5,7 +5,7 @@ import { Config } from "../config/config"
import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda"
import { NoSuchModelError, type Provider as SDK } from "ai" import { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log" import { Log } from "../util/log"
import { BunProc } from "../bun" import { Npm } from "../npm"
import { Hash } from "../util/hash" import { Hash } from "../util/hash"
import { Plugin } from "../plugin" import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/util/error" import { NamedError } from "@opencode-ai/util/error"
@@ -44,6 +44,7 @@ import { createGateway } from "@ai-sdk/gateway"
import { createTogetherAI } from "@ai-sdk/togetherai" import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity" import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel" import { createVercel } from "@ai-sdk/vercel"
import { createVenice } from "venice-ai-sdk-provider"
import { import {
createGitLab, createGitLab,
VERSION as GITLAB_PROVIDER_VERSION, VERSION as GITLAB_PROVIDER_VERSION,
@@ -139,6 +140,7 @@ export namespace Provider {
"@ai-sdk/vercel": createVercel, "@ai-sdk/vercel": createVercel,
"gitlab-ai-provider": createGitLab, "gitlab-ai-provider": createGitLab,
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
"venice-ai-sdk-provider": createVenice,
} }
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any> type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
@@ -681,6 +683,9 @@ export namespace Provider {
autoload: !!apiKey, autoload: !!apiKey,
options: { options: {
apiKey, apiKey,
headers: {
"User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
},
}, },
async getModel(sdk: any, modelID: string) { async getModel(sdk: any, modelID: string) {
return sdk.languageModel(modelID) return sdk.languageModel(modelID)
@@ -732,6 +737,9 @@ export namespace Provider {
cacheKey: input.options?.cacheKey, cacheKey: input.options?.cacheKey,
skipCache: input.options?.skipCache, skipCache: input.options?.skipCache,
collectLog: input.options?.collectLog, collectLog: input.options?.collectLog,
headers: {
"User-Agent": `opencode/${Installation.VERSION} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
},
} }
const aigateway = createAiGateway({ const aigateway = createAiGateway({
@@ -1178,6 +1186,49 @@ export namespace Provider {
mergeProvider(providerID, partial) mergeProvider(providerID, partial)
} }
const gitlab = ProviderID.make("gitlab")
if (discoveryLoaders[gitlab] && providers[gitlab] && isProviderAllowed(gitlab)) {
yield* Effect.promise(async () => {
try {
const discovered = await discoveryLoaders[gitlab]()
for (const [modelID, model] of Object.entries(discovered)) {
if (!providers[gitlab].models[modelID]) {
providers[gitlab].models[modelID] = model
}
}
} catch (e) {
log.warn("state discovery error", { id: "gitlab", error: e })
}
})
}
for (const hook of plugins) {
const p = hook.provider
const models = p?.models
if (!p || !models) continue
const providerID = ProviderID.make(p.id)
if (disabled.has(providerID)) continue
const provider = providers[providerID]
if (!provider) continue
const pluginAuth = yield* auth.get(providerID).pipe(Effect.orDie)
provider.models = yield* Effect.promise(async () => {
const next = await models(provider, { auth: pluginAuth })
return Object.fromEntries(
Object.entries(next).map(([id, model]) => [
id,
{
...model,
id: ModelID.make(id),
providerID,
},
]),
)
})
}
for (const [id, provider] of Object.entries(providers)) { for (const [id, provider] of Object.entries(providers)) {
const providerID = ProviderID.make(id) const providerID = ProviderID.make(id)
if (!isProviderAllowed(providerID)) { if (!isProviderAllowed(providerID)) {
@@ -1222,22 +1273,6 @@ export namespace Provider {
log.info("found", { providerID }) log.info("found", { providerID })
} }
const gitlab = ProviderID.make("gitlab")
if (discoveryLoaders[gitlab] && providers[gitlab]) {
yield* Effect.promise(async () => {
try {
const discovered = await discoveryLoaders[gitlab]()
for (const [modelID, model] of Object.entries(discovered)) {
if (!providers[gitlab].models[modelID]) {
providers[gitlab].models[modelID] = model
}
}
} catch (e) {
log.warn("state discovery error", { id: "gitlab", error: e })
}
})
}
return { return {
models: languages, models: languages,
providers, providers,
@@ -1365,7 +1400,9 @@ export namespace Provider {
let installedPath: string let installedPath: string
if (!model.api.npm.startsWith("file://")) { if (!model.api.npm.startsWith("file://")) {
installedPath = await BunProc.install(model.api.npm, "latest") const item = await Npm.add(model.api.npm)
if (!item.entrypoint) throw new Error(`Package ${model.api.npm} has no import entrypoint`)
installedPath = item.entrypoint
} else { } else {
log.info("loading local provider", { pkg: model.api.npm }) log.info("loading local provider", { pkg: model.api.npm })
installedPath = model.api.npm installedPath = model.api.npm
+2 -1
View File
@@ -190,6 +190,7 @@ export namespace SessionCompaction {
Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next.
The summary that you construct will be used so that another agent can read it and continue the work. The summary that you construct will be used so that another agent can read it and continue the work.
Do not call any tools. Respond only with the summary text. Do not call any tools. Respond only with the summary text.
Respond in the same language as the user's messages in the conversation.
When constructing the summary, try to stick to this template: When constructing the summary, try to stick to this template:
--- ---
@@ -218,7 +219,7 @@ When constructing the summary, try to stick to this template:
const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n") const prompt = compacting.prompt ?? [defaultPrompt, ...compacting.context].join("\n\n")
const msgs = structuredClone(messages) const msgs = structuredClone(messages)
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true })) const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { stripMedia: true })
const ctx = yield* InstanceState.context const ctx = yield* InstanceState.context
const msg: MessageV2.Assistant = { const msg: MessageV2.Assistant = {
id: MessageID.ascending(), id: MessageID.ascending(),
+3 -8
View File
@@ -593,15 +593,10 @@ export namespace Session {
}) })
const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) { const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) {
return yield* Effect.promise(async () => { if (input.limit) {
const result = [] as MessageV2.WithParts[] return MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).items
for await (const msg of MessageV2.stream(input.sessionID)) {
if (input.limit && result.length >= input.limit) break
result.push(msg)
} }
result.reverse() return Array.from(MessageV2.stream(input.sessionID)).reverse()
return result
})
}) })
const removeMessage = Effect.fn("Session.removeMessage")(function* (input: { const removeMessage = Effect.fn("Session.removeMessage")(function* (input: {
+206 -132
View File
@@ -1,13 +1,18 @@
import path from "path"
import os from "os" import os from "os"
import { Global } from "../global" import path from "path"
import { Filesystem } from "../util/filesystem" import { Effect, Layer, ServiceMap } from "effect"
import { Config } from "../config/config" import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Instance } from "../project/instance" import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Flag } from "@/flag/flag" import { Flag } from "@/flag/flag"
import { AppFileSystem } from "@/filesystem"
import { withTransientReadRetry } from "@/util/effect-http-client"
import { Global } from "../global"
import { Instance } from "../project/instance"
import { Log } from "../util/log" import { Log } from "../util/log"
import { Glob } from "../util/glob"
import type { MessageV2 } from "./message-v2" import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"
const log = Log.create({ service: "instruction" }) const log = Log.create({ service: "instruction" })
@@ -29,119 +34,7 @@ function globalFiles() {
return files return files
} }
async function resolveRelative(instruction: string): Promise<string[]> { function extract(messages: MessageV2.WithParts[]) {
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
}
if (!Flag.OPENCODE_CONFIG_DIR) {
log.warn(
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
)
return []
}
return Filesystem.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR).catch(() => [])
}
export namespace InstructionPrompt {
const state = Instance.state(() => {
return {
claims: new Map<string, Set<string>>(),
}
})
function isClaimed(messageID: string, filepath: string) {
const claimed = state().claims.get(messageID)
if (!claimed) return false
return claimed.has(filepath)
}
function claim(messageID: string, filepath: string) {
const current = state()
let claimed = current.claims.get(messageID)
if (!claimed) {
claimed = new Set()
current.claims.set(messageID, claimed)
}
claimed.add(filepath)
}
export function clear(messageID: string) {
state().claims.delete(messageID)
}
export async function systemPaths() {
const config = await Config.get()
const paths = new Set<string>()
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = await Filesystem.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((p) => {
paths.add(path.resolve(p))
})
break
}
}
}
for (const file of globalFiles()) {
if (await Filesystem.exists(file)) {
paths.add(path.resolve(file))
break
}
}
if (config.instructions) {
for (let instruction of config.instructions) {
if (instruction.startsWith("https://") || instruction.startsWith("http://")) continue
if (instruction.startsWith("~/")) {
instruction = path.join(os.homedir(), instruction.slice(2))
}
const matches = path.isAbsolute(instruction)
? await Glob.scan(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
}).catch(() => [])
: await resolveRelative(instruction)
matches.forEach((p) => {
paths.add(path.resolve(p))
})
}
}
return paths
}
export async function system() {
const config = await Config.get()
const paths = await systemPaths()
const files = Array.from(paths).map(async (p) => {
const content = await Filesystem.readText(p).catch(() => "")
return content ? "Instructions from: " + p + "\n" + content : ""
})
const urls: string[] = []
if (config.instructions) {
for (const instruction of config.instructions) {
if (instruction.startsWith("https://") || instruction.startsWith("http://")) {
urls.push(instruction)
}
}
}
const fetches = urls.map((url) =>
fetch(url, { signal: AbortSignal.timeout(5000) })
.then((res) => (res.ok ? res.text() : ""))
.catch(() => "")
.then((x) => (x ? "Instructions from: " + url + "\n" + x : "")),
)
return Promise.all([...files, ...fetches]).then((result) => result.filter(Boolean))
}
export function loaded(messages: MessageV2.WithParts[]) {
const paths = new Set<string>() const paths = new Set<string>()
for (const msg of messages) { for (const msg of messages) {
for (const part of msg.parts) { for (const part of msg.parts) {
@@ -156,37 +49,218 @@ export namespace InstructionPrompt {
} }
} }
return paths return paths
}
export namespace Instruction {
export interface Interface {
readonly clear: (messageID: MessageID) => Effect.Effect<void>
readonly systemPaths: () => Effect.Effect<Set<string>, AppFileSystem.Error>
readonly system: () => Effect.Effect<string[], AppFileSystem.Error>
readonly find: (dir: string) => Effect.Effect<string | undefined, AppFileSystem.Error>
readonly resolve: (
messages: MessageV2.WithParts[],
filepath: string,
messageID: MessageID,
) => Effect.Effect<{ filepath: string; content: string }[], AppFileSystem.Error>
} }
export async function find(dir: string) { export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Instruction") {}
export const layer: Layer.Layer<Service, never, AppFileSystem.Service | Config.Service | HttpClient.HttpClient> =
Layer.effect(
Service,
Effect.gen(function* () {
const cfg = yield* Config.Service
const fs = yield* AppFileSystem.Service
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const state = yield* InstanceState.make(
Effect.fn("Instruction.state")(() =>
Effect.succeed({
// Track which instruction files have already been attached for a given assistant message.
claims: new Map<MessageID, Set<string>>(),
}),
),
)
const relative = Effect.fnUntraced(function* (instruction: string) {
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return yield* fs
.globUp(instruction, Instance.directory, Instance.worktree)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
}
if (!Flag.OPENCODE_CONFIG_DIR) {
log.warn(
`Skipping relative instruction "${instruction}" - no OPENCODE_CONFIG_DIR set while project config is disabled`,
)
return []
}
return yield* fs
.globUp(instruction, Flag.OPENCODE_CONFIG_DIR, Flag.OPENCODE_CONFIG_DIR)
.pipe(Effect.catch(() => Effect.succeed([] as string[])))
})
const read = Effect.fnUntraced(function* (filepath: string) {
return yield* fs.readFileString(filepath).pipe(Effect.catch(() => Effect.succeed("")))
})
const fetch = Effect.fnUntraced(function* (url: string) {
const res = yield* http.execute(HttpClientRequest.get(url)).pipe(
Effect.timeout(5000),
Effect.catch(() => Effect.succeed(null)),
)
if (!res) return ""
const body = yield* res.arrayBuffer.pipe(Effect.catch(() => Effect.succeed(new ArrayBuffer(0))))
return new TextDecoder().decode(body)
})
const clear = Effect.fn("Instruction.clear")(function* (messageID: MessageID) {
const s = yield* InstanceState.get(state)
s.claims.delete(messageID)
})
const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
const config = yield* cfg.get()
const paths = new Set<string>()
// The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
for (const file of FILES) {
const matches = yield* fs.findUp(file, Instance.directory, Instance.worktree)
if (matches.length > 0) {
matches.forEach((item) => paths.add(path.resolve(item)))
break
}
}
}
for (const file of globalFiles()) {
if (yield* fs.existsSafe(file)) {
paths.add(path.resolve(file))
break
}
}
if (config.instructions) {
for (const raw of config.instructions) {
if (raw.startsWith("https://") || raw.startsWith("http://")) continue
const instruction = raw.startsWith("~/") ? path.join(os.homedir(), raw.slice(2)) : raw
const matches = yield* (
path.isAbsolute(instruction)
? fs.glob(path.basename(instruction), {
cwd: path.dirname(instruction),
absolute: true,
include: "file",
})
: relative(instruction)
).pipe(Effect.catch(() => Effect.succeed([] as string[])))
matches.forEach((item) => paths.add(path.resolve(item)))
}
}
return paths
})
const system = Effect.fn("Instruction.system")(function* () {
const config = yield* cfg.get()
const paths = yield* systemPaths()
const urls = (config.instructions ?? []).filter(
(item) => item.startsWith("https://") || item.startsWith("http://"),
)
const files = yield* Effect.forEach(Array.from(paths), read, { concurrency: 8 })
const remote = yield* Effect.forEach(urls, fetch, { concurrency: 4 })
return [
...Array.from(paths).flatMap((item, i) => (files[i] ? [`Instructions from: ${item}\n${files[i]}`] : [])),
...urls.flatMap((item, i) => (remote[i] ? [`Instructions from: ${item}\n${remote[i]}`] : [])),
]
})
const find = Effect.fn("Instruction.find")(function* (dir: string) {
for (const file of FILES) { for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file)) const filepath = path.resolve(path.join(dir, file))
if (await Filesystem.exists(filepath)) return filepath if (yield* fs.existsSafe(filepath)) return filepath
}
} }
})
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) { const resolve = Effect.fn("Instruction.resolve")(function* (
const system = await systemPaths() messages: MessageV2.WithParts[],
const already = loaded(messages) filepath: string,
messageID: MessageID,
) {
const sys = yield* systemPaths()
const already = extract(messages)
const results: { filepath: string; content: string }[] = [] const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
const target = path.resolve(filepath) const target = path.resolve(filepath)
let current = path.dirname(target)
const root = path.resolve(Instance.directory) const root = path.resolve(Instance.directory)
let current = path.dirname(target)
// Walk upward from the file being read and attach nearby instruction files once per message.
while (current.startsWith(root) && current !== root) { while (current.startsWith(root) && current !== root) {
const found = await find(current) const found = yield* find(current)
if (!found || found === target || sys.has(found) || already.has(found)) {
current = path.dirname(current)
continue
}
if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) { let set = s.claims.get(messageID)
claim(messageID, found) if (!set) {
const content = await Filesystem.readText(found).catch(() => undefined) set = new Set()
s.claims.set(messageID, set)
}
if (set.has(found)) {
current = path.dirname(current)
continue
}
set.add(found)
const content = yield* read(found)
if (content) { if (content) {
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content }) results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
}
} }
current = path.dirname(current) current = path.dirname(current)
} }
return results return results
})
return Service.of({ clear, systemPaths, system, find, resolve })
}),
)
export const defaultLayer = layer.pipe(
Layer.provide(Config.defaultLayer),
Layer.provide(AppFileSystem.defaultLayer),
Layer.provide(FetchHttpClient.layer),
)
const { runPromise } = makeRuntime(Service, defaultLayer)
export function clear(messageID: MessageID) {
return runPromise((svc) => svc.clear(messageID))
}
export async function systemPaths() {
return runPromise((svc) => svc.systemPaths())
}
export async function system() {
return runPromise((svc) => svc.system())
}
export function loaded(messages: MessageV2.WithParts[]) {
return extract(messages)
}
export async function find(dir: string) {
return runPromise((svc) => svc.find(dir))
}
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) {
return runPromise((svc) => svc.resolve(messages, filepath, messageID))
} }
} }
+34 -33
View File
@@ -5,7 +5,6 @@ import { NamedError } from "@opencode-ai/util/error"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai" import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { LSP } from "../lsp" import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot" import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
import { SyncEvent } from "../sync" import { SyncEvent } from "../sync"
import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db" import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
import { MessageTable, PartTable, SessionTable } from "./session.sql" import { MessageTable, PartTable, SessionTable } from "./session.sql"
@@ -15,6 +14,7 @@ import { errorMessage } from "@/util/error"
import type { SystemError } from "bun" import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider" import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema" import { ModelID, ProviderID } from "@/provider/schema"
import { Effect } from "effect"
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */ /** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error { interface FetchDecompressionError extends Error {
@@ -547,7 +547,7 @@ export namespace MessageV2 {
and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)), and(eq(MessageTable.time_created, row.time), lt(MessageTable.id, row.id)),
) )
async function hydrate(rows: (typeof MessageTable.$inferSelect)[]) { function hydrate(rows: (typeof MessageTable.$inferSelect)[]) {
const ids = rows.map((row) => row.id) const ids = rows.map((row) => row.id)
const partByMessage = new Map<string, MessageV2.Part[]>() const partByMessage = new Map<string, MessageV2.Part[]>()
if (ids.length > 0) { if (ids.length > 0) {
@@ -573,11 +573,11 @@ export namespace MessageV2 {
})) }))
} }
export async function toModelMessages( export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: WithParts[], input: WithParts[],
model: Provider.Model, model: Provider.Model,
options?: { stripMedia?: boolean }, options?: { stripMedia?: boolean },
): Promise<ModelMessage[]> { ) {
const result: UIMessage[] = [] const result: UIMessage[] = []
const toolNames = new Set<string>() const toolNames = new Set<string>()
// Track media from tool results that need to be injected as user messages // Track media from tool results that need to be injected as user messages
@@ -800,22 +800,26 @@ export namespace MessageV2 {
const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }])) const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }]))
return await convertToModelMessages( return yield* Effect.promise(() =>
convertToModelMessages(
result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")), result.filter((msg) => msg.parts.some((part) => part.type !== "step-start")),
{ {
//@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput) //@ts-expect-error (convertToModelMessages expects a ToolSet but only actually needs tools[name]?.toModelOutput)
tools, tools,
}, },
),
) )
})
export function toModelMessages(
input: WithParts[],
model: Provider.Model,
options?: { stripMedia?: boolean },
): Promise<ModelMessage[]> {
return Effect.runPromise(toModelMessagesEffect(input, model, options))
} }
export const page = fn( export function page(input: { sessionID: SessionID; limit: number; before?: string }) {
z.object({
sessionID: SessionID.zod,
limit: z.number().int().positive(),
before: z.string().optional(),
}),
async (input) => {
const before = input.before ? cursor.decode(input.before) : undefined const before = input.before ? cursor.decode(input.before) : undefined
const where = before const where = before
? and(eq(MessageTable.session_id, input.sessionID), older(before)) ? and(eq(MessageTable.session_id, input.sessionID), older(before))
@@ -841,23 +845,22 @@ export namespace MessageV2 {
} }
const more = rows.length > input.limit const more = rows.length > input.limit
const page = more ? rows.slice(0, input.limit) : rows const slice = more ? rows.slice(0, input.limit) : rows
const items = await hydrate(page) const items = hydrate(slice)
items.reverse() items.reverse()
const tail = page.at(-1) const tail = slice.at(-1)
return { return {
items, items,
more, more,
cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined, cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined,
} }
}, }
)
export const stream = fn(SessionID.zod, async function* (sessionID) { export function* stream(sessionID: SessionID) {
const size = 50 const size = 50
let before: string | undefined let before: string | undefined
while (true) { while (true) {
const next = await page({ sessionID, limit: size, before }) const next = page({ sessionID, limit: size, before })
if (next.items.length === 0) break if (next.items.length === 0) break
for (let i = next.items.length - 1; i >= 0; i--) { for (let i = next.items.length - 1; i >= 0; i--) {
yield next.items[i] yield next.items[i]
@@ -865,9 +868,9 @@ export namespace MessageV2 {
if (!next.more || !next.cursor) break if (!next.more || !next.cursor) break
before = next.cursor before = next.cursor
} }
}) }
export const parts = fn(MessageID.zod, async (message_id) => { export function parts(message_id: MessageID) {
const rows = Database.use((db) => const rows = Database.use((db) =>
db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(), db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(),
) )
@@ -880,14 +883,9 @@ export namespace MessageV2 {
messageID: row.message_id, messageID: row.message_id,
}) as MessageV2.Part, }) as MessageV2.Part,
) )
}) }
export const get = fn( export function get(input: { sessionID: SessionID; messageID: MessageID }): WithParts {
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
async (input): Promise<WithParts> => {
const row = Database.use((db) => const row = Database.use((db) =>
db db
.select() .select()
@@ -898,15 +896,14 @@ export namespace MessageV2 {
if (!row) throw new NotFoundError({ message: `Message not found: ${input.messageID}` }) if (!row) throw new NotFoundError({ message: `Message not found: ${input.messageID}` })
return { return {
info: info(row), info: info(row),
parts: await parts(input.messageID), parts: parts(input.messageID),
}
} }
},
)
export async function filterCompacted(stream: AsyncIterable<MessageV2.WithParts>) { export function filterCompacted(msgs: Iterable<MessageV2.WithParts>) {
const result = [] as MessageV2.WithParts[] const result = [] as MessageV2.WithParts[]
const completed = new Set<string>() const completed = new Set<string>()
for await (const msg of stream) { for (const msg of msgs) {
result.push(msg) result.push(msg)
if ( if (
msg.info.role === "user" && msg.info.role === "user" &&
@@ -921,6 +918,10 @@ export namespace MessageV2 {
return result return result
} }
export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) {
return filterCompacted(stream(sessionID))
})
export function fromError( export function fromError(
e: unknown, e: unknown,
ctx: { providerID: ProviderID; aborted?: boolean }, ctx: { providerID: ProviderID; aborted?: boolean },
+8 -4
View File
@@ -84,13 +84,17 @@ export namespace SessionProcessor {
const status = yield* SessionStatus.Service const status = yield* SessionStatus.Service
const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { const create = Effect.fn("SessionProcessor.create")(function* (input: Input) {
// Pre-capture snapshot before the LLM stream starts. The AI SDK
// may execute tools internally before emitting start-step events,
// so capturing inside the event handler can be too late.
const initialSnapshot = yield* snapshot.track()
const ctx: ProcessorContext = { const ctx: ProcessorContext = {
assistantMessage: input.assistantMessage, assistantMessage: input.assistantMessage,
sessionID: input.sessionID, sessionID: input.sessionID,
model: input.model, model: input.model,
toolcalls: {}, toolcalls: {},
shouldBreak: false, shouldBreak: false,
snapshot: undefined, snapshot: initialSnapshot,
blocked: false, blocked: false,
needsCompaction: false, needsCompaction: false,
currentText: undefined, currentText: undefined,
@@ -180,7 +184,7 @@ export namespace SessionProcessor {
metadata: value.providerMetadata, metadata: value.providerMetadata,
} satisfies MessageV2.ToolPart) } satisfies MessageV2.ToolPart)
const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id)) const parts = MessageV2.parts(ctx.assistantMessage.id)
const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD) const recentParts = parts.slice(-DOOM_LOOP_THRESHOLD)
if ( if (
@@ -250,7 +254,7 @@ export namespace SessionProcessor {
throw value.error throw value.error
case "start-step": case "start-step":
ctx.snapshot = yield* snapshot.track() if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
yield* session.updatePart({ yield* session.updatePart({
id: PartID.ascending(), id: PartID.ascending(),
messageID: ctx.assistantMessage.id, messageID: ctx.assistantMessage.id,
@@ -392,7 +396,7 @@ export namespace SessionProcessor {
} }
ctx.reasoningMap = {} ctx.reasoningMap = {}
const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id)) const parts = MessageV2.parts(ctx.assistantMessage.id)
for (const part of parts) { for (const part of parts) {
if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue
yield* session.updatePart({ yield* session.updatePart({

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