Compare commits

...
Author SHA1 Message Date
Kit LangtonandGitHub 44e96fd358 Merge branch 'dev' into effect-sync-event 2026-04-02 11:48:33 -04:00
Aiden ClineandGitHub 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 BresslerandGitHub 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 ClineandGitHub 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 AllanandGitHub 69d047ae7d cleanup event listeners with solid-primitives/event-listener (#20619) 2026-04-02 09:40:03 +00:00
Brendan AllanandGitHub 327f62526a use solid-primitives/resize-observer across web code (#20613) 2026-04-02 17:24:10 +08:00
Shoubhit DashandGitHub 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 AllanandGitHub 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
dpuyosaandGitHub 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 LangtonandGitHub 336d28f112 fix(cli): restore colored help logo (#20592) 2026-04-02 03:21:07 +00:00
Kit LangtonandGitHub 916afb5220 refactor(account): share token freshness helper (#20591) 2026-04-02 02:57:45 +00:00
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 LangtonandGitHub 2e8e278441 fix(cli): use simple logo in CLI (#20585) 2026-04-02 02:27:09 +00:00
Kit LangtonandGitHub 0bae38c062 refactor(instruction): migrate to Effect service pattern (#20542) 2026-04-01 22:22:51 -04:00
Kit LangtonandGitHub a09b086729 test(app): block real llm calls in e2e prompts (#20579) 2026-04-01 22:22:43 -04:00
Aiden ClineandGitHub 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 LangtonandGitHub e148b318b7 fix(build): replace require() with dynamic import() in cross-spawn-spawner (#20580) 2026-04-01 21:55:35 -04:00
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 LangtonandGitHub 00d6841f84 fix(account): refresh console tokens before expiry (#20558) 2026-04-02 00:25:24 +00:00
SebastianandGitHub 8a8f7b3e90 flock npm.add (#20557) 2026-04-02 00:21:26 +00:00
Kit LangtonandGitHub c526caae7b fix: show model display name in message footer and transcript (#20539) 2026-04-02 00:17:38 +00:00
Kit LangtonandGitHub b1c07488bd refactor(revert): yield SessionSummary.Service directly (#20541) 2026-04-01 20:10:59 -04:00
Kit LangtonandGitHub 92f8e03160 fix(test): use effect helper in snapshot race test (#20567) 2026-04-01 20:05:47 -04:00
SebastianandGitHub 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 LangtonandGitHub e4ff1ea778 refactor(bash): use Effect ChildProcess for bash tool execution (#20496) 2026-04-01 19:48:47 -04:00
Kit LangtonandGitHub 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 LangtonandGitHub d9d4f895bc fix(test): auto-acknowledge tool-result follow-ups in mock LLM server (#20528) 2026-04-01 23:47:26 +00:00
Kit LangtonandGitHub 48db7cf07a fix(opencode): batch snapshot revert without reordering (#20564) 2026-04-01 23:46:06 +00:00
Luke ParkerandGitHub 802d165572 chore(tui): clean up scroll config follow-up (#20561) 2026-04-02 09:36:49 +10:00
Luke ParkerandGitHub f7f41dc3a0 fix(tui): apply scroll configuration uniformly across all scrollboxes (#14735) 2026-04-02 09:15:19 +10:00
Aiden ClineandGitHub 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 ParkerandGitHub fa96cb9c6e Fix selection expansion by retaining focused input selections during global key events (#20205) 2026-04-02 08:43:40 +10:00
SebastianandGitHub 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
DaxGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>Brendan AllanAiden Cline
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 LangtonandGitHub 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
env:
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
- 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": {
"x86_64-linux": "sha256-bjfe8/aD0hvUQQEfaNdmKV/Y3dzpf8oz1OUJdgf61WI=",
"aarch64-linux": "sha256-iU9v+ekSCB/qTUG+pOOpSMhPh+0hWnWU5jzDNllEkxU=",
"aarch64-darwin": "sha256-SgNydQLeAjbX0J49f2VKcgKg2Y30pK826R2qQJBMWE4=",
"x86_64-darwin": "sha256-/rzwNuI9x55qi0UcU7QvPUTupErmkt62T09g1omXkQk="
"x86_64-linux": "sha256-SQVfq41OQdGCgWuWqyqIN6aggL0r3Hzn2hJ9BwPJN+I=",
"aarch64-linux": "sha256-4w/1HhxsTzPFTHNf4JlnKle6Boz1gVTEedWG64T8E/M=",
"aarch64-darwin": "sha256-uMd+pU1u1yqP4OP/9461Tyy3zwwv/llr+rlllLjM98A=",
"x86_64-darwin": "sha256-BhIW3FPqKkM2vGfCrxXUvj5tarey33Q7dxCuaj5A+yU="
}
}
+22 -1
View File
@@ -1,5 +1,5 @@
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 os from "node:os"
import path from "node:path"
@@ -43,6 +43,27 @@ export async function defocus(page: Page) {
.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) {
const id = await term.getAttribute(terminalAttr)
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})` : ""}`)
}
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
function cap(input: string[]) {
@@ -62,7 +70,6 @@ export async function startBackend(label: string): Promise<Handle> {
const opencodeDir = path.join(repoDir, "packages", "opencode")
const env = {
...process.env,
OPENCODE_DISABLE_SHARE: process.env.OPENCODE_DISABLE_SHARE ?? "true",
OPENCODE_DISABLE_LSP_DOWNLOAD: "true",
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "true",
@@ -117,7 +124,11 @@ export async function startBackend(label: string): Promise<Handle> {
async stop() {
if (proc.exitCode === null) {
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)
},
+19
View File
@@ -15,6 +15,7 @@ import {
waitSlug,
waitSession,
} from "./actions"
import { openaiModel, withMockOpenAI } from "./prompt/mock"
import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
type LLMFixture = {
@@ -47,6 +48,7 @@ type LLMFixture = {
wait: (count: number) => Promise<void>
inputs: () => Promise<Record<string, unknown>[]>
pending: () => Promise<number>
misses: () => Promise<Array<{ url: URL; body: Record<string, unknown> }>>
}
export const settingsKey = "settings.v3"
@@ -83,6 +85,7 @@ type TestFixtures = {
gotoSession: (sessionID?: string) => Promise<void>
withProject: <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 = {
@@ -132,6 +135,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
wait: (count) => rt.runPromise(svc.wait(count)),
inputs: () => rt.runPromise(svc.inputs),
pending: () => rt.runPromise(svc.pending),
misses: () => rt.runPromise(svc.misses),
})
} finally {
await rt.dispose()
@@ -193,6 +197,21 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
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>(
+1 -1
View File
@@ -2,7 +2,7 @@ import { test, expect } from "../fixtures"
import { promptSelector } from "../selectors"
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 page.locator(promptSelector).click()
@@ -10,6 +10,7 @@ import {
waitSession,
waitSessionSaved,
waitSlug,
withNoReplyPrompt,
} from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
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
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill("test")
await page.keyboard.press("Enter")
await withNoReplyPrompt(page, async () => {
await prompt.fill("test")
await page.keyboard.press("Enter")
})
// Wait for the URL to update with the new session ID
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
@@ -9,6 +9,7 @@ import {
waitSession,
waitSessionSaved,
waitSlug,
withNoReplyPrompt,
} from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
@@ -58,8 +59,10 @@ async function createSessionFromWorkspace(
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
await prompt.fill(text)
await page.keyboard.press("Enter")
await withNoReplyPrompt(page, async () => {
await prompt.fill(text)
await page.keyboard.press("Enter")
})
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 15_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
+10
View File
@@ -16,6 +16,16 @@ export function promptMatch(token: string) {
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> }) {
const sdk = createSdk(undefined, input.serverUrl)
const prev = await sdk.global.config.get().then((res) => res.data ?? {})
+51 -93
View File
@@ -3,9 +3,11 @@ import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
import { assistantText, sessionIDFromUrl } from "../actions"
import { promptSelector } from "../selectors"
import { createSdk } from "../utils"
import { openaiModel, promptMatch, titleMatch, withMockOpenAI } from "./mock"
const text = (value: string | null) => (value ?? "").replace(/\u200B/g, "").trim()
type Sdk = ReturnType<typeof createSdk>
const isBash = (part: unknown): part is ToolPart => {
if (!part || typeof part !== "object") return false
@@ -14,47 +16,15 @@ const isBash = (part: unknown): part is ToolPart => {
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) {
await expect.poll(async () => text(await page.locator(promptSelector).textContent())).toBe(value)
}
async function reply(
sdk: { session: { messages: Parameters<typeof assistantText>[0]["session"] } },
sessionID: string,
token: string,
) {
await expect
.poll(() => assistantText(sdk as Parameters<typeof assistantText>[0], sessionID), { timeout: 90_000 })
.toContain(token)
async function reply(sdk: Sdk, sessionID: string, token: string) {
await expect.poll(() => assistantText(sdk, 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
.poll(
async () => {
@@ -142,76 +112,64 @@ 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)
await withMockOpenAI({
serverUrl: backend.url,
llmUrl: llm.url,
fn: async () => {
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
const firstToken = `E2E_SHELL_ONE_${Date.now()}`
const secondToken = `E2E_SHELL_TWO_${Date.now()}`
const normalToken = `E2E_NORMAL_${Date.now()}`
const first = `echo ${firstToken}`
const second = `echo ${secondToken}`
const normal = `Reply with exactly: ${normalToken}`
await llm.textMatch(titleMatch, "E2E Title")
await llm.textMatch(promptMatch(normalToken), normalToken)
await gotoSession()
await withBackendProject(
async (project) => {
const prompt = page.locator(promptSelector)
const prompt = page.locator(promptSelector)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(first)
await page.keyboard.press("Enter")
await wait(page, "")
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
project.trackSession(sessionID)
await shell(project.sdk, sessionID, first, firstToken)
await expect(page).toHaveURL(/\/session\/[^/?#]+/, { timeout: 30_000 })
const sessionID = sessionIDFromUrl(page.url())!
await shell(sdk, sessionID, first, firstToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(project.sdk, sessionID, second, secondToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.type(second)
await page.keyboard.press("Enter")
await wait(page, "")
await shell(sdk, sessionID, second, secondToken)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("Escape")
await wait(page, "")
await page.keyboard.press("ArrowUp")
await wait(page, first)
await prompt.click()
await page.keyboard.type("!")
await page.keyboard.press("ArrowUp")
await wait(page, second)
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("ArrowUp")
await wait(page, first)
await page.keyboard.press("ArrowDown")
await wait(page, "")
await page.keyboard.press("ArrowDown")
await wait(page, second)
await page.keyboard.press("Escape")
await wait(page, "")
await page.keyboard.press("ArrowDown")
await wait(page, "")
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(project.sdk, sessionID, normalToken)
await page.keyboard.press("Escape")
await wait(page, "")
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
},
{
model: openaiModel,
},
)
},
})
await prompt.click()
await page.keyboard.type(normal)
await page.keyboard.press("Enter")
await wait(page, "")
await reply(sdk, sessionID, normalToken)
await prompt.click()
await page.keyboard.press("ArrowUp")
await wait(page, normal)
})
@@ -27,6 +27,7 @@ test("/share and /unshare update session share state", async ({ page, withBacken
await withBackendProject(async (project) => {
await withSession(project.sdk, `e2e slash share ${Date.now()}`, async (session) => {
project.trackSession(session.id)
const prompt = page.locator(promptSelector)
await seed(project.sdk, session.id)
@@ -1,7 +1,9 @@
import { seedSessionTask, withSession } from "../actions"
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)
const errs: string[] = []
@@ -10,17 +12,22 @@ test("task tool child-session link does not trigger stale show errors", async ({
}
page.on("pageerror", onError)
await withBackendProject(async ({ gotoSession, trackSession, sdk }) => {
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
trackSession(session.id)
const child = await seedSessionTask(sdk, {
sessionID: session.id,
description: "Open child session",
prompt: "Search the repository for AssistantParts and then reply with exactly CHILD_OK.",
})
trackSession(child.sessionID)
try {
await withMockProject(async ({ gotoSession, trackSession, sdk }) => {
await withSession(sdk, `e2e child nav ${Date.now()}`, async (session) => {
const taskInput = {
description: "Open child session",
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)
try {
await gotoSession(session.id)
const link = page
@@ -31,11 +38,11 @@ test("task tool child-session link does not trigger stale show errors", async ({
await link.click()
await expect(page).toHaveURL(new RegExp(`/session/${child.sessionID}(?:[/?#]|$)`), { timeout: 30_000 })
await page.waitForTimeout(1000)
expect(errs).toEqual([])
} finally {
page.off("pageerror", onError)
}
await expect(page.locator(promptSelector)).toBeVisible({ timeout: 30_000 })
await expect.poll(() => errs, { timeout: 5_000 }).toEqual([])
})
})
})
} finally {
page.off("pageerror", onError)
}
})
@@ -14,6 +14,7 @@ import {
sessionTodoToggleButtonSelector,
} from "../selectors"
import { modKey } from "../utils"
import { inputMatch } from "../prompt/mock"
type Sdk = Parameters<typeof clearSessionDockSeed>[0]
type PermissionRule = { permission: string; pattern: string; action: "allow" | "deny" | "ask" }
@@ -22,12 +23,13 @@ async function withDockSession<T>(
sdk: Sdk,
title: string,
fn: (session: { id: string; title: string }) => Promise<T>,
opts?: { permission?: PermissionRule[] },
opts?: { permission?: PermissionRule[]; trackSession?: (sessionID: string) => void },
) {
const session = await sdk.session
.create(opts?.permission ? { title, permission: opts.permission } : { title })
.then((r) => r.data)
if (!session?.id) throw new Error("Session create did not return an id")
opts?.trackSession?.(session.id)
try {
return await fn(session)
} 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)
async function withDockSeed<T>(sdk: Sdk, sessionID: string, fn: () => Promise<T>) {
@@ -258,17 +271,22 @@ async function withMockPermission<T>(
test("default dock shows prompt input", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock default", async (session) => {
await project.gotoSession(session.id)
await withDockSession(
project.sdk,
"e2e composer dock default",
async (session) => {
await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await expect(page.locator(promptSelector)).toBeVisible()
await expect(page.locator(questionDockSelector)).toHaveCount(0)
await expect(page.locator(permissionDockSelector)).toHaveCount(0)
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
})
await page.locator(promptSelector).click()
await expect(page.locator(promptSelector)).toBeFocused()
},
{ trackSession: project.trackSession },
)
})
})
@@ -285,225 +303,19 @@ test("auto-accept toggle works before first submit", async ({ page, withBackendP
})
})
test("blocked question flow unblocks after submit", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question", async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
test("blocked question flow unblocks after submit", async ({ page, llm, withMockProject }) => {
await withMockProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock question",
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",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
})
})
})
test("blocked question flow supports keyboard shortcuts", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question 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",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
const second = dock.locator('[data-slot="question-option"]').nth(1)
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(second).toBeFocused()
await page.keyboard.press("Space")
await page.keyboard.press(`${modKey}+Enter`)
await expectQuestionOpen(page)
})
})
})
})
test("blocked question flow supports escape dismiss", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock question escape", 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",
question: "Pick one option",
options: [
{ label: "Continue", description: "Continue now" },
{ label: "Stop", description: "Stop here" },
],
},
],
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("Escape")
await expectQuestionOpen(page)
})
})
})
})
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock permission once", async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_once",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
})
})
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock permission reject", async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_reject",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /deny/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
})
})
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock permission always", async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_always",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow always/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
})
})
})
test("child session question request blocks parent dock and unblocks after submit", async ({
page,
withBackendProject,
}) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock child question parent", async (session) => {
await project.gotoSession(session.id)
const child = await project.sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
try {
await withDockSeed(project.sdk, child.id, async () => {
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, {
sessionID: child.id,
questions: [
{
header: "Child input",
question: "Pick one child option",
options: [
{ label: "Continue", description: "Continue child" },
{ label: "Stop", description: "Stop child" },
],
},
],
sessionID: session.id,
questions: defaultQuestions,
})
const dock = page.locator(questionDockSelector)
@@ -514,10 +326,231 @@ test("child session question request blocks parent dock and unblocks after submi
await expectQuestionOpen(page)
})
} finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
}
})
},
{ trackSession: project.trackSession },
)
})
})
test("blocked question flow supports keyboard shortcuts", async ({ page, llm, withMockProject }) => {
await withMockProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock question keyboard",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: defaultQuestions,
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
const second = dock.locator('[data-slot="question-option"]').nth(1)
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("ArrowDown")
await expect(second).toBeFocused()
await page.keyboard.press("Space")
await page.keyboard.press(`${modKey}+Enter`)
await expectQuestionOpen(page)
})
},
{ trackSession: project.trackSession },
)
})
})
test("blocked question flow supports escape dismiss", async ({ page, llm, withMockProject }) => {
await withMockProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock question escape",
async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await project.gotoSession(session.id)
await llm.toolMatch(inputMatch({ questions: defaultQuestions }), "question", { questions: defaultQuestions })
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: defaultQuestions,
})
const dock = page.locator(questionDockSelector)
const first = dock.locator('[data-slot="question-option"]').first()
await expectQuestionBlocked(page)
await expect(first).toBeFocused()
await page.keyboard.press("Escape")
await expectQuestionOpen(page)
})
},
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports allow once", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock permission once",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_once",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-once"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
},
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports reject", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock permission reject",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_reject",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-reject"],
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /deny/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
},
{ trackSession: project.trackSession },
)
})
})
test("blocked permission flow supports allow always", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(
project.sdk,
"e2e composer dock permission always",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withMockPermission(
page,
{
id: "per_e2e_always",
sessionID: session.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-always"],
metadata: { description: "Need permission for command" },
},
undefined,
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow always/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
},
{ trackSession: project.trackSession },
)
})
})
test("child session question request blocks parent dock and unblocks after submit", async ({
page,
llm,
withMockProject,
}) => {
const questions = [
{
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)
const child = await project.sdk.session
.create({
title: "e2e composer dock child question",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try {
await withDockSeed(project.sdk, child.id, async () => {
await llm.toolMatch(inputMatch({ questions }), "question", { questions })
await seedSessionQuestion(project.sdk, {
sessionID: child.id,
questions,
})
const dock = page.locator(questionDockSelector)
await expectQuestionBlocked(page)
await dock.locator('[data-slot="question-option"]').first().click()
await dock.getByRole("button", { name: /submit/i }).click()
await expectQuestionOpen(page)
})
} finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
}
},
{ trackSession: project.trackSession },
)
})
})
@@ -526,102 +559,120 @@ test("child session permission request blocks parent dock and supports allow onc
withBackendProject,
}) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock child permission parent", async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
await withDockSession(
project.sdk,
"e2e composer dock child permission parent",
async (session) => {
await project.gotoSession(session.id)
await setAutoAccept(page, false)
const child = await project.sdk.session
.create({
title: "e2e composer dock child permission",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
const child = await project.sdk.session
.create({
title: "e2e composer dock child permission",
parentID: session.id,
})
.then((r) => r.data)
if (!child?.id) throw new Error("Child session create did not return an id")
project.trackSession(child.id)
try {
await withMockPermission(
page,
{
id: "per_e2e_child",
sessionID: child.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" },
},
{ child },
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
try {
await withMockPermission(
page,
{
id: "per_e2e_child",
sessionID: child.id,
permission: "bash",
patterns: ["/tmp/opencode-e2e-perm-child"],
metadata: { description: "Need child permission" },
},
{ child },
async (state) => {
await page.goto(page.url())
await expectPermissionBlocked(page)
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await clearPermissionDock(page, /allow once/i)
await state.resolved()
await page.goto(page.url())
await expectPermissionOpen(page)
},
)
} finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
}
})
await expectPermissionOpen(page)
},
)
} finally {
await cleanupSession({ sdk: project.sdk, sessionID: child.id })
}
},
{ trackSession: project.trackSession },
)
})
})
test("todo dock transitions and collapse behavior", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock todo", async (session) => {
const dock = await todoDock(page, session.id)
await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
try {
await dock.open([
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
])
await dock.expectOpen(["pending", "in_progress"])
await dock.collapse()
await dock.expectCollapsed(["pending", "in_progress"])
await dock.expand()
await dock.expectOpen(["pending", "in_progress"])
await dock.finish([
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
])
await dock.expectClosed()
} finally {
await dock.clear()
}
})
})
})
test("keyboard focus stays off prompt while blocked", async ({ page, withBackendProject }) => {
await withBackendProject(async (project) => {
await withDockSession(project.sdk, "e2e composer dock keyboard", async (session) => {
await withDockSeed(project.sdk, session.id, async () => {
await withDockSession(
project.sdk,
"e2e composer dock todo",
async (session) => {
const dock = await todoDock(page, session.id)
await project.gotoSession(session.id)
await expect(page.locator(sessionComposerDockSelector)).toBeVisible()
await seedSessionQuestion(project.sdk, {
sessionID: session.id,
questions: [
{
header: "Need input",
question: "Pick one option",
options: [{ label: "Continue", description: "Continue now" }],
},
],
})
try {
await dock.open([
{ content: "first task", status: "pending", priority: "high" },
{ content: "second task", status: "in_progress", priority: "medium" },
])
await dock.expectOpen(["pending", "in_progress"])
await expectQuestionBlocked(page)
await dock.collapse()
await dock.expectCollapsed(["pending", "in_progress"])
await page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
})
await dock.expand()
await dock.expectOpen(["pending", "in_progress"])
await dock.finish([
{ content: "first task", status: "completed", priority: "high" },
{ content: "second task", status: "cancelled", priority: "medium" },
])
await dock.expectClosed()
} finally {
await dock.clear()
}
},
{ trackSession: project.trackSession },
)
})
})
test("keyboard focus stays off prompt while blocked", async ({ page, llm, withMockProject }) => {
const questions = [
{
header: "Need input",
question: "Pick one option",
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 page.locator("main").click({ position: { x: 5, y: 5 } })
await page.keyboard.type("abc")
await expect(page.locator(promptSelector)).toHaveCount(0)
})
},
{ trackSession: project.trackSession },
)
})
})
@@ -8,11 +8,11 @@ import {
waitSession,
waitSessionIdle,
waitSlug,
withNoReplyPrompt,
} from "../actions"
import {
promptAgentSelector,
promptModelSelector,
promptSelector,
promptVariantSelector,
workspaceItemSelector,
workspaceNewSessionSelector,
@@ -231,11 +231,14 @@ async function goto(page: Page, directory: string, sessionID?: 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 prompt.click()
await prompt.fill(value)
await prompt.press("Enter")
await withNoReplyPrompt(page, async () => {
await prompt.click()
await prompt.fill(value)
await prompt.press("Enter")
})
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const id = sessionIDFromUrl(page.url())
+40 -33
View File
@@ -1,6 +1,6 @@
import { waitSessionIdle, withSession } from "../actions"
import { test, expect } from "../fixtures"
import { createSdk } from "../utils"
import { inputMatch } from "../prompt/mock"
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({
sessionID,
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." }],
})
// 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)
}
@@ -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.skip(true, "Flaky in CI for now.")
test("review applies inline comment clicks without horizontal overflow", async ({ page, llm, withMockProject }) => {
test.setTimeout(180_000)
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 withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await withMockProject(async (project) => {
await withSession(project.sdk, `e2e review comment ${tag}`, async (session) => {
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
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
},
{ 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.skip(true, "Flaky in CI for now.")
test("review file comments submit on click without clipping actions", async ({ page, llm, withMockProject }) => {
test.setTimeout(180_000)
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 withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review file comment ${tag}`, async (session) => {
await patch(sdk, session.id, seed([{ file, mark: tag }]))
await withMockProject(async (project) => {
await withSession(project.sdk, `e2e review file comment ${tag}`, async (session) => {
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed([{ file, mark: tag }]))
await expect
.poll(
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
},
{ 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.skip(Boolean(process.env.CI), "Flaky in CI for now.")
test.fixme("review keeps scroll position after a live diff update", async ({ page, llm, withMockProject }) => {
test.setTimeout(180_000)
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 withProject(async (project) => {
const sdk = createSdk(project.directory)
await withSession(sdk, `e2e review ${tag}`, async (session) => {
await patch(sdk, session.id, seed(list))
await withMockProject(async (project) => {
await withSession(project.sdk, `e2e review ${tag}`, async (session) => {
project.trackSession(session.id)
await patchWithMock(llm, project.sdk, session.id, seed(list))
await expect
.poll(
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
},
{ timeout: 60_000 },
@@ -364,7 +370,7 @@ test("review keeps scroll position after a live diff update", async ({ page, wit
await expect
.poll(
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
},
{ 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()
await expect(view).toBeVisible()
const heads = page.getByRole("heading", { level: 3 }).filter({ hasText: /^review-scroll-/ })
await expect(heads).toHaveCount(list.length, {
timeout: 60_000,
})
await expect(heads).toHaveCount(list.length, { timeout: 60_000 })
await expand(page)
await waitMark(page, hit.file, hit.mark)
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()
await expect(row).toBeVisible()
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)
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
.poll(
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)
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
await withSession(sdk, `e2e undo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
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
await withSession(sdk, `e2e redo ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
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
await withSession(sdk, `e2e undo redo stack ${Date.now()}`, async (session) => {
project.trackSession(session.id)
await project.gotoSession(session.id)
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 withSession(project.sdk, originalTitle, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
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 withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(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 withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(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 withSession(project.sdk, title, async (session) => {
project.trackSession(session.id)
await seedMessage(project.sdk, session.id)
await project.gotoSession(session.id)
+2 -1
View File
@@ -46,9 +46,10 @@
"@solid-primitives/active-element": "2.1.3",
"@solid-primitives/audio": "1.4.2",
"@solid-primitives/event-bus": "1.1.2",
"@solid-primitives/event-listener": "2.4.5",
"@solid-primitives/i18n": "2.2.1",
"@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/storage": "catalog:",
"@solid-primitives/timer": "1.4.4",
+2 -2
View File
@@ -1,6 +1,7 @@
import { useIsRouting, useLocation } from "@solidjs/router"
import { batch, createEffect, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { useLanguage } from "@/context/language"
@@ -349,13 +350,12 @@ export function DebugBar() {
syncHeap()
start()
document.addEventListener("visibilitychange", vis)
makeEventListener(document, "visibilitychange", vis)
onCleanup(() => {
if (one !== 0) cancelAnimationFrame(one)
if (two !== 0) cancelAnimationFrame(two)
stop()
document.removeEventListener("visibilitychange", vis)
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 { usePrompt, type ContentPart, type ImageAttachmentPart } from "@/context/prompt"
import { useLanguage } from "@/context/language"
@@ -181,15 +182,9 @@ export function createPromptAttachments(input: PromptAttachmentsInput) {
}
onMount(() => {
document.addEventListener("dragover", handleGlobalDragOver)
document.addEventListener("dragleave", handleGlobalDragLeave)
document.addEventListener("drop", handleGlobalDrop)
})
onCleanup(() => {
document.removeEventListener("dragover", handleGlobalDragOver)
document.removeEventListener("dragleave", handleGlobalDragLeave)
document.removeEventListener("drop", handleGlobalDrop)
makeEventListener(document, "dragover", handleGlobalDragOver)
makeEventListener(document, "dragleave", handleGlobalDragLeave)
makeEventListener(document, "drop", handleGlobalDrop)
})
return {
@@ -1,11 +1,11 @@
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import {
children,
createEffect,
createMemo,
createSignal,
type JSXElement,
onCleanup,
onMount,
type ParentProps,
Show,
@@ -46,12 +46,9 @@ export function ServerRow(props: ServerRowProps) {
})
onMount(() => {
check()
if (typeof ResizeObserver !== "function") return
const observer = new ResizeObserver(check)
if (nameRef) observer.observe(nameRef)
if (versionRef) observer.observe(versionRef)
onCleanup(() => observer.disconnect())
createResizeObserver([nameRef, versionRef], check)
check()
})
const tooltipValue = () => (
@@ -1,5 +1,6 @@
import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -250,8 +251,7 @@ function useKeyCapture(input: {
input.stop()
}
document.addEventListener("keydown", handle, true)
onCleanup(() => document.removeEventListener("keydown", handle, true))
makeEventListener(document, "keydown", handle, { capture: 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 { type Accessor, createEffect, createMemo, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
import { dict as en } from "@/i18n/en"
@@ -378,11 +379,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
}
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
makeEventListener(document, "keydown", handleKeyDown)
})
function register(cb: () => CommandOption[]): void
+10 -14
View File
@@ -1,7 +1,8 @@
import type { Event } from "@opencode-ai/sdk/v2/client"
import { createSimpleContext } from "@opencode-ai/ui/context"
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 { createSdkForServer } from "@/utils/server"
import { useLanguage } from "./language"
@@ -206,21 +207,16 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
clearHeartbeat()
}
const onVisibility = () => {
if (typeof document === "undefined") return
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
}
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility)
}
onMount(() => {
makeEventListener(document, "visibilitychange", () => {
if (document.visibilityState !== "visible") return
if (!started) return
if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return
attempt?.abort()
})
})
onCleanup(() => {
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility)
}
stop()
abort.abort()
flush()
+3 -4
View File
@@ -1,6 +1,7 @@
import { createStore, produce } from "solid-js/store"
import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useGlobalSync } from "./global-sync"
import { useGlobalSDK } from "./global-sdk"
import { useServer } from "./server"
@@ -366,12 +367,10 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
flush()
}
window.addEventListener("pagehide", flush)
document.addEventListener("visibilitychange", handleVisibility)
makeEventListener(window, "pagehide", flush)
makeEventListener(document, "visibilitychange", handleVisibility)
onCleanup(() => {
window.removeEventListener("pagehide", flush)
document.removeEventListener("visibilitychange", handleVisibility)
scroll.dispose()
})
})
+7 -14
View File
@@ -12,6 +12,7 @@ import {
untrack,
type Accessor,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useNavigate, useParams } from "@solidjs/router"
import { useLayout, LocalProject } from "@/context/layout"
import { useGlobalSync } from "@/context/global-sync"
@@ -215,18 +216,11 @@ export default function Layout(props: ParentProps) {
if (document.visibilityState !== "hidden") return
reset()
}
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
window.addEventListener("blur", blur)
document.addEventListener("visibilitychange", hide)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
window.removeEventListener("blur", blur)
document.removeEventListener("visibilitychange", hide)
})
makeEventListener(window, "pointerup", stop)
makeEventListener(window, "pointercancel", stop)
makeEventListener(window, "blur", stop)
makeEventListener(window, "blur", blur)
makeEventListener(document, "visibilitychange", hide)
})
const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined)
@@ -1394,8 +1388,7 @@ export default function Layout(props: ParentProps) {
}
handleDeepLinks(drainPendingDeepLinks(window))
window.addEventListener(deepLinkEvent, handler as EventListener)
onCleanup(() => window.removeEventListener(deepLinkEvent, handler as EventListener))
makeEventListener(window, deepLinkEvent, handler as EventListener)
})
async function renameProject(project: LocalProject, next: string) {
+4 -5
View File
@@ -14,6 +14,7 @@ import {
onMount,
untrack,
} from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocal } from "@/context/local"
@@ -329,10 +330,9 @@ export default function Page() {
const { params, sessionKey, tabs, view } = useSessionLayout()
createEffect(() => {
if (!untrack(() => prompt.ready())) return
prompt.ready()
if (!prompt.ready()) return
untrack(() => {
if (params.id || !prompt.ready()) return
if (params.id) return
const text = searchParams.prompt
if (!text) return
prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length)
@@ -1688,11 +1688,10 @@ export default function Page() {
)
onMount(() => {
document.addEventListener("keydown", handleKeyDown)
makeEventListener(document, "keydown", handleKeyDown)
})
onCleanup(() => {
document.removeEventListener("keydown", handleKeyDown)
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
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 { SessionTodoDock } from "@/pages/session/composer/session-todo-dock"
import type { FollowupDraft } from "@/components/prompt-input/submit"
import { createResizeObserver } from "@solid-primitives/resize-observer"
export function SessionComposerRegion(props: {
state: SessionComposerState
@@ -115,13 +116,9 @@ export function SessionComposerRegion(props: {
createEffect(() => {
const el = store.body
if (!el) return
const update = () => {
setStore("height", el.getBoundingClientRect().height)
}
const update = () => setStore("height", el.getBoundingClientRect().height)
createResizeObserver(store.body, update)
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
})
return (
@@ -1,5 +1,6 @@
import { createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import { useParams } from "@solidjs/router"
import { showToast } from "@opencode-ai/ui/toast"
@@ -86,8 +87,7 @@ export function createSessionComposerState(options?: { closeMs?: number | (() =>
pull()
}
window.addEventListener(composerEvent, onEvent)
onCleanup(() => window.removeEventListener(composerEvent, onEvent))
makeEventListener(window, composerEvent, onEvent)
})
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 { useLanguage } from "@/context/language"
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[] }>()
@@ -172,17 +174,14 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
update()
window.addEventListener("resize", update)
makeEventListener(window, "resize", update)
const dock = root?.closest('[data-component="session-prompt-dock"]')
const scroller = document.querySelector(".scroll-view__viewport")
const observer = new ResizeObserver(update)
if (dock instanceof HTMLElement) observer.observe(dock)
if (scroller instanceof HTMLElement) observer.observe(scroller)
createResizeObserver([dock, scroller], update)
onCleanup(() => {
window.removeEventListener("resize", update)
observer.disconnect()
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 { TextReveal } from "@opencode-ai/ui/text-reveal"
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 { createStore } from "solid-js/store"
import { composerEnabled, composerProbe } from "@/testing/session-composer"
@@ -91,9 +92,7 @@ export function SessionTodoDock(props: {
setStore("height", el.getBoundingClientRect().height)
}
update()
const observer = new ResizeObserver(update)
observer.observe(el)
onCleanup(() => observer.disconnect())
createResizeObserver(el, update)
})
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 { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileSearchHandle } from "@opencode-ai/ui/file"
import { useFileComponent } from "@opencode-ai/ui/context/file"
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 restoreFrame: number | undefined
let pending: ScrollPos | undefined
let code: HTMLElement[] = []
const [code, setCode] = createSignal<HTMLElement[]>([])
const getCode = () => {
const el = scroll
@@ -106,17 +107,9 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
const sync = () => {
const next = getCode()
if (next.length === code.length && next.every((el, i) => el === code[i])) return
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
code = next
for (const item of code) {
item.addEventListener("scroll", onCodeScroll)
}
const current = code()
if (next.length === current.length && next.every((el, i) => el === current[i])) return
setCode(next)
}
const restore = () => {
@@ -128,14 +121,14 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
sync()
if (code.length > 0) {
for (const item of code) {
if (code().length > 0) {
for (const item of code()) {
if (item.scrollLeft !== pos.x) item.scrollLeft = pos.x
}
}
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
}
@@ -149,24 +142,24 @@ function createScrollSync(input: { tab: () => string; view: ReturnType<typeof us
}
const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => {
if (code.length === 0) sync()
if (code().length === 0) sync()
save({
x: code[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
x: code()[0]?.scrollLeft ?? event.currentTarget.scrollLeft,
y: event.currentTarget.scrollTop,
})
}
createEffect(() => {
for (const item of code()) makeEventListener(item, "scroll", onCodeScroll)
})
const setViewport = (el: HTMLDivElement) => {
scroll = el
restore()
}
onCleanup(() => {
for (const item of code) {
item.removeEventListener("scroll", onCodeScroll)
}
if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame)
if (restoreFrame !== undefined) cancelAnimationFrame(restoreFrame)
})
@@ -358,8 +351,7 @@ export function FileTabContent(props: { tab: string }) {
find?.focus()
}
window.addEventListener("keydown", onKeyDown, { capture: true })
onCleanup(() => window.removeEventListener("keydown", onKeyDown, { capture: true }))
makeEventListener(window, "keydown", onKeyDown, { capture: true })
})
createEffect(
+4 -8
View File
@@ -1,5 +1,6 @@
import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/utils/same"
const emptyTabs: string[] = []
@@ -171,14 +172,9 @@ export const createSizing = () => {
}
onMount(() => {
window.addEventListener("pointerup", stop)
window.addEventListener("pointercancel", stop)
window.addEventListener("blur", stop)
onCleanup(() => {
window.removeEventListener("pointerup", stop)
window.removeEventListener("pointercancel", stop)
window.removeEventListener("blur", stop)
})
makeEventListener(window, "pointerup", stop)
makeEventListener(window, "pointercancel", stop)
makeEventListener(window, "blur", stop)
})
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 { SessionReview } from "@opencode-ai/ui/session-review"
import type {
@@ -123,13 +124,6 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
onCleanup(() => {
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 (
@@ -138,11 +132,11 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
empty={props.empty}
scrollRef={(el) => {
scroll = el
el.addEventListener("wheel", handleInteraction, { passive: true, capture: true })
el.addEventListener("mousewheel", handleInteraction, { passive: true, capture: true })
el.addEventListener("pointerdown", handleInteraction, { passive: true, capture: true })
el.addEventListener("touchstart", handleInteraction, { passive: true, capture: true })
el.addEventListener("keydown", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "wheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "mousewheel", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "pointerdown", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "touchstart", handleInteraction, { passive: true, capture: true })
makeEventListener(el, "keydown", handleInteraction, { capture: true })
props.onScrollRef?.(el)
queueRestore()
}}
@@ -1,5 +1,6 @@
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { IconButton } from "@opencode-ai/ui/icon-button"
@@ -50,12 +51,8 @@ export function TerminalPanel() {
const port = window.visualViewport
sync()
window.addEventListener("resize", sync)
port?.addEventListener("resize", sync)
onCleanup(() => {
window.removeEventListener("resize", sync)
port?.removeEventListener("resize", sync)
})
makeEventListener(window, "resize", sync)
if (port) makeEventListener(port, "resize", sync)
})
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":
"لقد وصلت إلى حد الإنفاق الشهري البالغ ${{amount}}. إدارة حدودك هنا: {{membersUrl}}",
"zen.api.error.modelDisabled": "النموذج معطل",
"zen.api.error.trialEnded":
"انتهى العرض المجاني لـ {{model}}. يمكنك مواصلة استخدام النموذج بالاشتراك في OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | الوصول إلى أفضل نماذج البرمجة في العالم",
"black.meta.description": "احصل على وصول إلى Claude، GPT، Gemini والمزيد مع خطط اشتراك OpenCode Black.",
+2
View File
@@ -371,6 +371,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"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.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.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":
"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.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.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":
"Du hast dein monatliches Ausgabenlimit von ${{amount}} erreicht. Verwalte deine Limits hier: {{membersUrl}}",
"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.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":
"You have reached your monthly spending limit of ${{amount}}. Manage your limits here: {{membersUrl}}",
"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.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":
"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.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.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":
"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.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.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":
"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.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.description":
+2
View File
@@ -369,6 +369,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"月額の利用上限 ${{amount}} に達しました。こちらから上限を管理してください: {{membersUrl}}",
"zen.api.error.modelDisabled": "モデルが無効です",
"zen.api.error.trialEnded":
"{{model}} の無料プロモーションは終了しました。OpenCode Go を購読するとモデルを引き続き使用できます - {{link}}",
"black.meta.title": "OpenCode Black | 世界最高峰のコーディングモデルすべてにアクセス",
"black.meta.description": "OpenCode Black サブスクリプションプランで、Claude、GPT、Gemini などにアクセス。",
+2
View File
@@ -363,6 +363,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"월간 지출 한도인 ${{amount}}에 도달했습니다. 한도 관리를 여기서 하세요: {{membersUrl}}",
"zen.api.error.modelDisabled": "모델이 비활성화되었습니다",
"zen.api.error.trialEnded":
"{{model}}의 무료 프로모션이 종료되었습니다. OpenCode Go를 구독하면 모델을 계속 사용할 수 있습니다 - {{link}}",
"black.meta.title": "OpenCode Black | 세계 최고의 코딩 모델에 액세스하세요",
"black.meta.description": "OpenCode Black 구독 플랜으로 Claude, GPT, Gemini 등에 액세스하세요.",
+2
View File
@@ -368,6 +368,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"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.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.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":
"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.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.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":
"Вы достигли ежемесячного лимита расходов в ${{amount}}. Управляйте лимитами здесь: {{membersUrl}}",
"zen.api.error.modelDisabled": "Модель отключена",
"zen.api.error.trialEnded":
"Бесплатная акция для {{model}} завершена. Вы можете продолжить использование модели, подписавшись на OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | Доступ к лучшим моделям для кодинга в мире",
"black.meta.description": "Получите доступ к Claude, GPT, Gemini и другим моделям с подпиской OpenCode Black.",
+2
View File
@@ -365,6 +365,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"คุณถึงขีดจำกัดการใช้จ่ายรายเดือนที่ ${{amount}} แล้ว จัดการขีดจำกัดของคุณที่นี่: {{membersUrl}}",
"zen.api.error.modelDisabled": "โมเดลถูกปิดใช้งาน",
"zen.api.error.trialEnded":
"โปรโมชันฟรีสำหรับ {{model}} สิ้นสุดแล้ว คุณสามารถใช้โมเดลต่อได้โดยสมัครสมาชิก OpenCode Go - {{link}}",
"black.meta.title": "OpenCode Black | เข้าถึงโมเดลเขียนโค้ดที่ดีที่สุดในโลก",
"black.meta.description": "เข้าถึง Claude, GPT, Gemini และอื่นๆ ด้วยแผนสมาชิก OpenCode Black",
+2
View File
@@ -372,6 +372,8 @@ export const dict = {
"zen.api.error.userMonthlyLimitReached":
"Aylık ${{amount}} harcama limitinize ulaştınız. Limitlerinizi buradan yönetin: {{membersUrl}}",
"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.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}}",
"zen.api.error.userMonthlyLimitReached": "您已达到每月支出限额 ${{amount}}。请在此处管理您的限额:{{membersUrl}}",
"zen.api.error.modelDisabled": "模型已禁用",
"zen.api.error.trialEnded": "{{model}} 的限免活动已结束。您可以订阅 OpenCode Go 继续使用该模型 - {{link}}",
"black.meta.title": "OpenCode Black | 访问全球顶尖编程模型",
"black.meta.description": "通过 OpenCode Black 订阅计划使用 Claude, GPT, Gemini 等模型。",
+1
View File
@@ -349,6 +349,7 @@ export const dict = {
"你的工作區已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{billingUrl}}",
"zen.api.error.userMonthlyLimitReached": "你已達到每月支出限額 ${{amount}}。請在此處管理你的限額:{{membersUrl}}",
"zen.api.error.modelDisabled": "模型已停用",
"zen.api.error.trialEnded": "{{model}} 的限免活动已結束。您可以訂閱 OpenCode Go 繼續使用該模型 - {{link}}",
"black.meta.title": "OpenCode Black | 存取全球最佳編碼模型",
"black.meta.description": "透過 OpenCode Black 訂閱方案存取 Claude、GPT、Gemini 等模型。",
@@ -100,6 +100,7 @@ export async function handler(
session: sessionId,
request: requestId,
client: ocClient,
...(model === "mimo-v2-pro-free" && JSON.stringify(body).length < 1000 ? { payload: JSON.stringify(body) } : {}),
})
const zenData = ZenData.list(opts.modelList)
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 })
return { id: modelId, ...modelData }
+5 -1
View File
@@ -27,6 +27,7 @@ export namespace ZenData {
byokProvider: z.enum(["openai", "anthropic", "google"]).optional(),
stickyProvider: z.enum(["strict", "prefer"]).optional(),
trialProviders: z.array(z.string()).optional(),
trialEnded: z.boolean().optional(),
fallbackProvider: z.string().optional(),
rateLimit: z.number().optional(),
providers: z.array(
@@ -54,7 +55,10 @@ export namespace ZenData {
const ModelsSchema = z.object({
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),
})
+3
View File
@@ -53,6 +53,7 @@
"@types/bun": "catalog:",
"@types/cross-spawn": "6.0.6",
"@types/mime-types": "3.0.1",
"@types/npmcli__arborist": "6.3.3",
"@types/semver": "^7.5.8",
"@types/turndown": "5.0.5",
"@types/which": "3.0.4",
@@ -94,6 +95,7 @@
"@hono/standard-validator": "0.1.5",
"@hono/zod-validator": "catalog:",
"@modelcontextprotocol/sdk": "1.27.1",
"@npmcli/arborist": "9.4.0",
"@octokit/graphql": "9.0.2",
"@octokit/rest": "catalog:",
"@openauthjs/openauth": "catalog:",
@@ -145,6 +147,7 @@
"tree-sitter-powershell": "0.25.10",
"turndown": "7.2.0",
"ulid": "catalog:",
"venice-ai-sdk-provider": "2.0.1",
"vscode-jsonrpc": "8.2.1",
"web-tree-sitter": "0.25.10",
"which": "6.0.1",
+2
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bun
import { Script } from "@opencode-ai/script"
import fs from "fs"
import path from "path"
import { fileURLToPath } from "url"
@@ -48,6 +49,7 @@ await Bun.build({
external: ["jsonc-parser"],
define: {
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 text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
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 providerID = parts[0] ?? "opencode"
const modelID = parts[1] ?? "gpt-5-nano"
@@ -12,7 +11,6 @@ const seed = async () => {
const { Instance } = await import("../src/project/instance")
const { InstanceBootstrap } = await import("../src/project/bootstrap")
const { Config } = await import("../src/config/config")
const { Provider } = await import("../src/provider/provider")
const { Session } = await import("../src/session")
const { MessageID, PartID } = await import("../src/session/schema")
const { Project } = await import("../src/project/project")
@@ -27,19 +25,6 @@ const seed = async () => {
await Config.waitForDependencies()
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 messageID = MessageID.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.
- 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.
- npm packages can be TUI theme-only via `package.json["oc-themes"]` without a `./tui` entrypoint.
## 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["."]`.
- 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.
- 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.
- File/path plugins must export a non-empty `id`.
- npm plugins may omit `id`; package `name` is used.
@@ -101,10 +103,18 @@ export default plugin
## 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.
- `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:
@@ -289,9 +299,12 @@ Theme install behavior:
- Relative theme paths are resolved from the plugin root.
- 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.
- 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.
- Global plugins persist installed themes under the global `themes` dir.
- 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)` 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)` 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(...)` 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.
@@ -357,7 +371,11 @@ Metadata is persisted by plugin id.
- External TUI plugins load from `tuiConfig.plugin`.
- `--pure` / `OPENCODE_PURE` skips external TUI plugins only.
- 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.
- 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.
- 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.
@@ -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.`.
- 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.
- `tui` target detection includes `exports["./tui"]` and valid `oc-themes`.
- 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.
+11 -2
View File
@@ -119,6 +119,11 @@ class TokenRefreshRequest extends Schema.Class<TokenRefreshRequest>("TokenRefres
}) {}
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 =
(message = "Account service operation failed") =>
@@ -218,7 +223,9 @@ export namespace Account {
const account = maybeAccount.value
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)
}),
@@ -226,7 +233,9 @@ export namespace Account {
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
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)
})
@@ -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.
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") {
inspect.stop("No plugin targets found", 1)
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
}
+7 -1
View File
@@ -299,7 +299,8 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
useKeyboard((evt) => {
if (!Flag.OPENCODE_EXPERIMENTAL_DISABLE_COPY_ON_SELECT) return
if (!renderer.getSelection()) return
const sel = renderer.getSelection()
if (!sel) return
// Windows Terminal-like behavior:
// - Ctrl+C copies and dismisses selection
@@ -323,6 +324,11 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
return
}
const focus = renderer.currentFocusedRenderable
if (focus?.hasSelection() && sel.selectedRenderables.includes(focus)) {
return
}
renderer.clearSelection()
})
@@ -4,6 +4,7 @@ import { Clipboard } from "@tui/util/clipboard"
import { createSignal } from "solid-js"
import { Installation } from "@/installation"
import { win32FlushInputBuffer } from "../win32"
import { getScrollAcceleration } from "../util/scroll"
export function ErrorComponent(props: {
error: Error
@@ -82,7 +83,7 @@ export function ErrorComponent(props: {
<text fg={colors.bg}>Exit</text>
</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>
</scrollbox>
<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 { useSDK } from "@tui/context/sdk"
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 { SplitBorder } from "@tui/component/border"
import { useCommandDialog } from "@tui/component/dialog-command"
@@ -81,6 +83,7 @@ export function Autocomplete(props: {
const { theme } = useTheme()
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const tuiConfig = useTuiConfig()
const [store, setStore] = createStore({
index: 0,
@@ -605,6 +608,7 @@ export function Autocomplete(props: {
})
let scroll: ScrollBoxRenderable
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
<box
@@ -622,6 +626,7 @@ export function Autocomplete(props: {
backgroundColor={theme.backgroundMenu}
height={height()}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
>
<Index
each={options()}
@@ -18,7 +18,14 @@ import { Log } from "@/util/log"
import { errorData, errorMessage } from "@/util/error"
import { isRecord } from "@/util/record"
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 { PluginMeta } from "@/plugin/meta"
import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install"
@@ -26,6 +33,7 @@ import { hasTheme, upsertTheme } from "../context/theme"
import { Global } from "@/global"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { Flock } from "@/util/flock"
import { Flag } from "@/flag/flag"
import { INTERNAL_TUI_PLUGINS, type InternalTuiPlugin } from "./internal"
import { setupSlots, Slot as View } from "./slots"
@@ -39,8 +47,9 @@ type PluginLoad = {
source: PluginSource | "internal"
id: string
module: TuiPluginModule
theme_meta: TuiConfig.PluginMeta
origin: Config.PluginOrigin
theme_root: string
theme_files: string[]
}
type Api = HostPluginApi
@@ -67,12 +76,15 @@ type RuntimeState = {
slots: HostSlots
plugins: 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 DISPOSE_TIMEOUT_MS = 5000
const KV_KEY = "plugin_enabled"
const EMPTY_TUI: TuiPluginModule = {
tui: async () => {},
}
function fail(message: string, data: Record<string, unknown>) {
if (!("error" in data)) {
@@ -134,7 +146,7 @@ function resolveRoot(root: string) {
}
function createThemeInstaller(
meta: TuiConfig.PluginMeta,
meta: Config.PluginOrigin,
root: string,
spec: string,
plugin: PluginEntry,
@@ -153,162 +165,73 @@ function createThemeInstaller(
const stat = await Filesystem.statAsync(src)
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 exists = hasTheme(name)
const prev = plugin.themes[name]
if (exists) {
if (plugin.meta.state !== "updated") return
if (!prev) {
if (await Filesystem.exists(dest)) {
plugin.themes[name] = {
src,
dest,
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,
})
})
}
return
}
if (prev.dest !== dest) return
if (prev.mtime === mtime && prev.size === size) return
}
const text = await Filesystem.readText(src).catch((error) => {
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
return
})
if (text === undefined) return
const fail = Symbol()
const data = await Promise.resolve(text)
.then((x) => JSON.parse(x))
.catch((error) => {
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
return fail
})
if (data === fail) return
if (!isTheme(data)) {
log.warn("invalid tui plugin theme", { path: spec, theme: src })
return
}
if (exists || !(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text).catch((error) => {
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
})
}
upsertTheme(name, data)
plugin.themes[name] = {
const info = {
src,
dest,
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,
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", {
path: spec,
id: plugin.id,
theme: src,
dest,
error,
})
})
}
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
}
if (prev?.dest === dest && prev.mtime === mtime && prev.size === size) return
}
const text = await Filesystem.readText(src).catch((error) => {
log.warn("failed to read tui plugin theme", { path: spec, theme: src, error })
return
})
if (text === undefined) return
const fail = Symbol()
const data = await Promise.resolve(text)
.then((x) => JSON.parse(x))
.catch((error) => {
log.warn("failed to parse tui plugin theme", { path: spec, theme: src, error })
return fail
})
if (data === fail) return
if (!isTheme(data)) {
log.warn("invalid tui plugin theme", { path: spec, theme: src })
return
}
if (exists || !(await Filesystem.exists(dest))) {
await Filesystem.write(dest, text).catch((error) => {
log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error })
})
}
upsertTheme(name, data)
await save()
}).catch((error) => {
log.warn("failed to lock tui plugin theme install", { path: spec, 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),
}
}
function createMeta(
source: PluginLoad["source"],
spec: string,
@@ -350,11 +273,38 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
source: "internal",
id: item.id,
module: item,
theme_meta: {
origin: {
spec,
scope: "global",
source: target,
},
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 ok = await Promise.resolve()
.then(async () => {
await syncPluginThemes(plugin)
await plugin.plugin(api, plugin.load.options, plugin.meta)
return true
})
@@ -555,7 +506,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
}
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"] = {
@@ -637,28 +588,108 @@ function applyInitialPluginEnabledState(state: RuntimeState, config: TuiConfig.I
}
}
async function resolveExternalPlugins(list: TuiConfig.PluginRecord[], wait: () => Promise<void>) {
const loaded = await Promise.all(list.map((item) => loadExternalPlugin(item)))
const ready: PluginLoad[] = []
let deps: Promise<void> | undefined
for (let i = 0; i < list.length; i++) {
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) => {
async function resolveExternalPlugins(list: Config.PluginOrigin[], wait: () => Promise<void>) {
return PluginLoader.loadExternal({
items: list,
kind: "tui",
wait: async () => {
await wait().catch((error) => {
log.warn("failed waiting for tui plugin dependencies", { error })
})
await deps
entry = await loadExternalPlugin(item, true)
}
if (!entry) continue
ready.push(entry)
}
},
finish: async (loaded, origin, retry) => {
const mod = await Promise.resolve()
.then(() => readV1Plugin(loaded.mod as Record<string, unknown>, loaded.spec, "tui") as TuiPluginModule)
.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[]) {
@@ -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 plugin: PluginEntry = {
id: entry.id,
load: entry,
meta: row,
meta: info,
themes,
plugin: entry.module.tui,
enabled: true,
@@ -712,9 +743,9 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[]
return { plugins, ok }
}
function defaultPluginRecord(state: RuntimeState, spec: string): TuiConfig.PluginRecord {
function defaultPluginOrigin(state: RuntimeState, spec: string): Config.PluginOrigin {
return {
item: spec,
spec,
scope: "local",
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()
if (!spec) return false
const cfg = state.pending.get(spec) ?? defaultPluginRecord(state, spec)
const next = Config.pluginSpecifier(cfg.item)
const cfg = state.pending.get(spec) ?? defaultPluginOrigin(state, spec)
const next = Config.pluginSpecifier(cfg.spec)
if (state.plugins.some((plugin) => plugin.load.spec === next)) {
state.pending.delete(spec)
return true
@@ -837,7 +868,7 @@ async function installPluginBySpec(
if (manifest.code === "manifest_no_targets") {
return {
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")
if (tui) {
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, {
item,
spec: next,
scope: global ? "global" : "local",
source: (file ?? dir.config) || path.join(patch.dir, "tui.json"),
})
@@ -959,9 +990,9 @@ export namespace TuiPluginRuntime {
directory: cwd,
fn: async () => {
const config = await TuiConfig.get()
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_records ?? [])
if (Flag.OPENCODE_PURE && config.plugin_records?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_records.length })
const records = Flag.OPENCODE_PURE ? [] : (config.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && config.plugin_origins?.length) {
log.info("skipping external tui plugins in pure mode", { count: config.plugin_origins.length })
}
for (const item of INTERNAL_TUI_PLUGINS) {
@@ -19,17 +19,17 @@ import { useSync } from "@tui/context/sync"
import { SplitBorder } from "@tui/component/border"
import { Spinner } from "@tui/component/spinner"
import { selectedForeground, useTheme } from "@tui/context/theme"
import {
BoxRenderable,
ScrollBoxRenderable,
addDefaultParsers,
MacOSScrollAccel,
type ScrollAcceleration,
TextAttributes,
RGBA,
} from "@opentui/core"
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
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 { Locale } from "@/util/locale"
import type { Tool } from "@/tool/tool"
@@ -77,22 +77,14 @@ import { Global } from "@/global"
import { PermissionPrompt } from "./permission"
import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import * as Model from "../../util/model"
import { formatTranscript } from "../../util/transcript"
import { UI } from "@/cli/ui.ts"
import { useTuiConfig } from "../../context/tui-config"
import { getScrollAcceleration } from "../../util/scroll"
addDefaultParsers(parsers.parsers)
class CustomSpeedScroll implements ScrollAcceleration {
constructor(private speed: number) {}
tick(_now?: number): number {
return this.speed
}
reset(): void {}
}
const context = createContext<{
width: number
sessionID: string
@@ -102,6 +94,7 @@ const context = createContext<{
showDetails: () => boolean
showGenericToolOutput: () => boolean
diffWrapMode: () => "word" | "none"
providers: () => ReadonlyMap<string, Provider>
sync: ReturnType<typeof useSync>
tui: ReturnType<typeof useTuiConfig>
}>()
@@ -167,18 +160,9 @@ export function Session() {
})
const showTimestamps = createMemo(() => timestamps() === "show")
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const providers = createMemo(() => Model.index(sync.data.provider))
const scrollAcceleration = createMemo(() => {
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)
})
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
createEffect(() => {
if (session()?.workspaceID) {
@@ -376,6 +360,11 @@ export function Session() {
dialog.clear()
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
.share({
sessionID: route.sessionID,
@@ -841,6 +830,7 @@ export function Session() {
thinking: showThinking(),
toolDetails: showDetails(),
assistantMetadata: showAssistantMetadata(),
providers: sync.data.provider,
},
)
await Clipboard.copy(transcript)
@@ -885,6 +875,7 @@ export function Session() {
thinking: options.thinking,
toolDetails: options.toolDetails,
assistantMetadata: options.assistantMetadata,
providers: sync.data.provider,
},
)
@@ -1030,6 +1021,7 @@ export function Session() {
showDetails,
showGenericToolOutput,
diffWrapMode,
providers,
sync,
tui: tuiConfig,
}}
@@ -1314,10 +1306,12 @@ function UserMessage(props: {
}
function AssistantMessage(props: { message: AssistantMessage; parts: Part[]; last: boolean }) {
const ctx = use()
const local = useLocal()
const { theme } = useTheme()
const sync = useSync()
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(() => {
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 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()}>
<span style={{ fg: theme.textMuted }}> · {Locale.duration(duration())}</span>
</Show>
@@ -15,6 +15,7 @@ import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { Global } from "@/global"
import { useDialog } from "../../ui/dialog"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
type PermissionStage = "permission" | "always" | "reject"
@@ -62,12 +63,14 @@ function EditBody(props: { request: PermissionRequest }) {
})
const ft = createMemo(() => filetype(filepath()))
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
return (
<box flexDirection="column" gap={1}>
<Show when={diff()}>
<scrollbox
height="100%"
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
@@ -1,13 +1,18 @@
import { useSync } from "@tui/context/sync"
import { createMemo, Show } from "solid-js"
import { useTheme } from "../../context/theme"
import { useTuiConfig } from "../../context/tui-config"
import { Installation } from "@/installation"
import { TuiPluginRuntime } from "../../plugin"
import { getScrollAcceleration } from "../../util/scroll"
export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
const sync = useSync()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const session = createMemo(() => sync.session.get(props.sessionID))
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
return (
<Show when={session()}>
@@ -23,6 +28,7 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
>
<scrollbox
flexGrow={1}
scrollAcceleration={scrollAcceleration()}
verticalScrollbarOptions={{
trackOptions: {
backgroundColor: theme.background,
@@ -10,6 +10,8 @@ import { useDialog, type DialogContext } from "@tui/ui/dialog"
import { useKeybind } from "@tui/context/keybind"
import { Keybind } from "@/util/keybind"
import { Locale } from "@/util/locale"
import { getScrollAcceleration } from "../util/scroll"
import { useTuiConfig } from "../context/tui-config"
export interface DialogSelectProps<T> {
title: string
@@ -50,6 +52,9 @@ export type DialogSelectRef<T> = {
export function DialogSelect<T>(props: DialogSelectProps<T>) {
const dialog = useDialog()
const { theme } = useTheme()
const tuiConfig = useTuiConfig()
const scrollAcceleration = createMemo(() => getScrollAcceleration(tuiConfig))
const [store, setStore] = createStore({
selected: 0,
filter: "",
@@ -276,6 +281,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
paddingLeft={1}
paddingRight={1}
scrollbarOptions={{ visible: false }}
scrollAcceleration={scrollAcceleration()}
ref={(r: ScrollBoxRenderable) => (scroll = r)}
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 * as Model from "./model"
export type TranscriptOptions = {
thinking: boolean
toolDetails: boolean
assistantMetadata: boolean
providers?: Provider[]
}
export type SessionInfo = {
@@ -26,6 +28,7 @@ export function formatTranscript(
messages: MessageWithParts[],
options: TranscriptOptions,
): string {
const providers = Model.index(options.providers)
let transcript = `# ${session.title}\n\n`
transcript += `**Session ID:** ${session.id}\n`
transcript += `**Created:** ${new Date(session.time.created).toLocaleString()}\n`
@@ -33,20 +36,25 @@ export function formatTranscript(
transcript += `---\n\n`
for (const msg of messages) {
transcript += formatMessage(msg.info, msg.parts, options)
transcript += formatMessage(msg.info, msg.parts, options, providers)
transcript += `---\n\n`
}
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 = ""
if (msg.role === "user") {
result += `## User\n\n`
} else {
result += formatAssistantHeader(msg, options.assistantMetadata)
result += formatAssistantHeader(msg, options.assistantMetadata, providers ?? options.providers)
}
for (const part of parts) {
@@ -56,7 +64,11 @@ export function formatMessage(msg: UserMessage | AssistantMessage, parts: Part[]
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) {
return `## Assistant\n\n`
}
@@ -64,7 +76,9 @@ export function formatAssistantHeader(msg: AssistantMessage, includeMetadata: bo
const duration =
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 {
+17
View File
@@ -4,6 +4,13 @@ import { NamedError } from "@opencode-ai/util/error"
import { logo as glyphs } from "./logo"
export namespace UI {
const wordmark = [
``,
`█▀▀█ █▀▀█ █▀▀█ █▀▀▄ █▀▀▀ █▀▀█ █▀▀█ █▀▀█`,
`█ █ █ █ █▀▀▀ █ █ █ █ █ █ █ █▀▀▀`,
`▀▀▀▀ █▀▀▀ ▀▀▀▀ ▀ ▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀`,
]
export const CancelledError = NamedError.create("UICancelledError", z.void())
export const Style = {
@@ -41,6 +48,16 @@ export namespace UI {
}
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 reset = "\x1b[0m"
const left = {
+88 -150
View File
@@ -20,7 +20,6 @@ import {
} from "jsonc-parser"
import { Instance, type InstanceContext } from "../project/instance"
import { LSPServer } from "../lsp/server"
import { BunProc } from "@/bun"
import { Installation } from "@/installation"
import { ConfigMarkdown } from "./markdown"
import { constants, existsSync } from "fs"
@@ -28,20 +27,18 @@ import { Bus } from "@/bus"
import { GlobalBus } from "@/bus/global"
import { Event } from "../server/event"
import { Glob } from "../util/glob"
import { PackageRegistry } from "@/bun/registry"
import { online, proxied } from "@/util/network"
import { iife } from "@/util/iife"
import { Account } from "@/account"
import { isRecord } from "@/util/record"
import { ConfigPaths } from "./paths"
import { Filesystem } from "@/util/filesystem"
import { Process } from "@/util/process"
import { AppFileSystem } from "@/filesystem"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { Duration, Effect, Layer, Option, ServiceMap } from "effect"
import { Flock } from "@/util/flock"
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
import { Npm } from "@/npm"
export namespace Config {
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 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" })
@@ -75,9 +78,6 @@ export namespace Config {
// Custom merge function that concatenates array fields instead of replacing them
function mergeConfigConcatArrays(target: Info, source: Info): Info {
const merged = mergeDeep(target, source)
if (target.plugin && source.plugin) {
merged.plugin = Array.from(new Set([...target.plugin, ...source.plugin]))
}
if (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) {
if (!(await needsInstall(dir))) return
if (!(await isWritable(dir))) return
await using _ = await Flock.acquire(`config-install:${Filesystem.resolve(dir)}`, {
signal: input?.signal,
onWait: (tick) =>
@@ -102,13 +101,10 @@ export namespace Config {
waited: tick.waited,
}),
})
input?.signal?.throwIfAborted()
if (!(await needsInstall(dir))) return
const pkg = path.join(dir, "package.json")
const target = Installation.isLocal() ? "*" : Installation.VERSION
const json = await Filesystem.readJson<{ dependencies?: Record<string, string> }>(pkg).catch(() => ({
dependencies: {},
}))
@@ -126,49 +122,7 @@ export namespace Config {
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
)
}
// 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 })
})
await Npm.install(dir)
}
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[]) {
const normalizedItem = item.replaceAll("\\", "/")
for (const pattern of patterns) {
@@ -382,31 +300,19 @@ export namespace Config {
return resolved
}
/**
* Deduplicates plugins by name, with later entries (higher priority) winning.
* Priority order (highest to lowest):
* 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[] = []
export function deduplicatePluginOrigins(plugins: PluginOrigin[]): PluginOrigin[] {
const seen = new Set<string>()
const list: PluginOrigin[] = []
for (const specifier of plugins.toReversed()) {
const spec = pluginSpecifier(specifier)
for (const plugin of plugins.toReversed()) {
const spec = pluginSpecifier(plugin.spec)
const name = spec.startsWith("file://") ? spec : parsePluginSpecifier(spec).pkg
if (!seenNames.has(name)) {
seenNames.add(name)
uniqueSpecifiers.push(specifier)
}
if (seen.has(name)) continue
seen.add(name)
list.push(plugin)
}
return uniqueSpecifiers.toReversed()
return list.toReversed()
}
export const McpLocal = z
@@ -1082,7 +988,9 @@ export namespace Config {
ref: "Config",
})
export type Info = z.output<typeof Info>
export type Info = z.output<typeof Info> & {
plugin_origins?: PluginOrigin[]
}
type State = {
config: Info
@@ -1129,6 +1037,11 @@ export namespace Config {
}, input)
}
function writable(info: Info) {
const { plugin_origins, ...next } = info
return next
}
function parseConfig(text: string, filepath: string): Info {
const errors: JsoncParseError[] = []
const data = parseJsonc(text, errors, { allowTrailingComma: true })
@@ -1293,6 +1206,30 @@ export namespace Config {
const auth = yield* authSvc.all().pipe(Effect.orDie)
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)) {
if (value.type === "wellknown") {
const url = key.replace(/\/+$/, "")
@@ -1305,21 +1242,21 @@ export namespace Config {
const wellknown = (yield* Effect.promise(() => response.json())) as any
const remoteConfig = wellknown.config ?? {}
if (!remoteConfig.$schema) remoteConfig.$schema = "https://opencode.ai/config.json"
result = mergeConfigConcatArrays(
result,
yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(`${url}/.well-known/opencode`),
source: `${url}/.well-known/opencode`,
}),
)
const source = `${url}/.well-known/opencode`
const next = yield* loadConfig(JSON.stringify(remoteConfig), {
dir: path.dirname(source),
source,
})
merge(source, next, "global")
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) {
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 })
}
@@ -1327,7 +1264,7 @@ export namespace Config {
for (const file of yield* Effect.promise(() =>
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)) {
if (dir.endsWith(".opencode") || dir === Flag.OPENCODE_CONFIG_DIR) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
log.debug(`loading config from ${path.join(dir, file)}`)
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(dir, file)))
for (const file of ["opencode.json", "opencode.jsonc"]) {
const source = path.join(dir, file)
log.debug(`loading config from ${source}`)
merge(source, yield* loadFile(source))
result.agent ??= {}
result.mode ??= {}
result.plugin ??= []
@@ -1355,8 +1293,7 @@ export namespace Config {
}
const dep = iife(async () => {
const stale = await needsInstall(dir)
if (stale) await installDependencies(dir)
await installDependencies(dir)
})
void dep.catch((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.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadAgent(dir)))
result.agent = mergeDeep(result.agent, yield* Effect.promise(() => loadMode(dir)))
result.plugin.push(...(yield* Effect.promise(() => loadPlugin(dir))))
const list = yield* Effect.promise(() => loadPlugin(dir))
track(dir, list)
}
if (process.env.OPENCODE_CONFIG_CONTENT) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source: "OPENCODE_CONFIG_CONTENT",
}),
)
const source = "OPENCODE_CONFIG_CONTENT"
const next = yield* loadConfig(process.env.OPENCODE_CONFIG_CONTENT, {
dir: ctx.directory,
source,
})
merge(source, next, "local")
log.debug("loaded custom config from OPENCODE_CONFIG_CONTENT")
}
@@ -1395,13 +1332,12 @@ export namespace Config {
const config = Option.getOrUndefined(configOpt)
if (config) {
result = mergeConfigConcatArrays(
result,
yield* loadConfig(JSON.stringify(config), {
dir: path.dirname(`${active.url}/api/config`),
source: `${active.url}/api/config`,
}),
)
const source = `${active.url}/api/config`
const next = yield* loadConfig(JSON.stringify(config), {
dir: path.dirname(source),
source,
})
merge(source, next, "global")
}
}).pipe(
Effect.catch((err) => {
@@ -1414,8 +1350,9 @@ export namespace Config {
}
if (existsSync(managedDir)) {
for (const file of ["opencode.jsonc", "opencode.json"]) {
result = mergeConfigConcatArrays(result, yield* loadFile(path.join(managedDir, file)))
for (const file of ["opencode.json", "opencode.jsonc"]) {
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.plugin = deduplicatePlugins(result.plugin ?? [])
return {
config: result,
directories,
@@ -1489,7 +1424,9 @@ export namespace Config {
const dir = yield* InstanceState.directory
const file = path.join(dir, "config.json")
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())
})
@@ -1513,15 +1450,16 @@ export namespace Config {
const updateGlobal = Effect.fn("Config.updateGlobal")(function* (config: Info) {
const file = globalConfigFile()
const before = (yield* readConfigFile(file)) ?? "{}"
const input = writable(config)
let next: Info
if (!file.endsWith(".jsonc")) {
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)
next = merged
} else {
const updated = patchJsonc(before, config)
const updated = patchJsonc(before, input)
next = parseConfig(updated, file)
yield* fs.writeFileString(file, updated).pipe(Effect.orDie)
}
+2 -9
View File
@@ -9,14 +9,7 @@ import { Global } from "@/global"
export namespace ConfigPaths {
export async function projectFiles(name: string, directory: string, worktree: string) {
const files: string[] = []
for (const file of [`${name}.jsonc`, `${name}.json`]) {
const found = await Filesystem.findUp(file, directory, worktree)
for (const resolved of found.toReversed()) {
files.push(resolved)
}
}
return files
return Filesystem.findUp([`${name}.json`, `${name}.jsonc`], directory, worktree, { rootFirst: true })
}
export async function directories(directory: string, worktree: string) {
@@ -43,7 +36,7 @@ export namespace ConfigPaths {
}
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(
+10 -61
View File
@@ -3,72 +3,33 @@ import z from "zod"
import { mergeDeep, unique } from "remeda"
import { Config } from "./config"
import { ConfigPaths } from "./paths"
import { migrateTuiConfig } from "./migrate-tui-config"
import { migrateTuiConfig } from "./tui-migrate"
import { TuiInfo } from "./tui-schema"
import { Instance } from "@/project/instance"
import { Flag } from "@/flag/flag"
import { Log } from "@/util/log"
import { isRecord } from "@/util/record"
import { Global } from "@/global"
import { parsePluginSpecifier } from "@/plugin/shared"
export namespace TuiConfig {
const log = Log.create({ service: "tui.config" })
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 = {
result: Info
entries: PluginEntry[]
}
export type Info = z.output<typeof Info> & {
// 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"
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() {
return Flag.OPENCODE_TUI_CONFIG
}
@@ -95,19 +56,16 @@ export namespace TuiConfig {
async function mergeFile(acc: Acc, file: string) {
const data = await loadFile(file)
acc.result = mergeInfo(acc.result, data)
acc.result = mergeDeep(acc.result, data)
if (!data.plugin?.length) return
const scope = pluginScope(file)
for (const item of data.plugin) {
acc.entries.push({
item,
meta: {
scope,
source: file,
},
})
}
const plugins = Config.deduplicatePluginOrigins([
...(acc.result.plugin_origins ?? []),
...data.plugin.map((spec) => ({ spec, scope, source: file })),
])
acc.result.plugin = plugins.map((item) => item.spec)
acc.result.plugin_origins = plugins
}
const state = Instance.state(async () => {
@@ -125,7 +83,6 @@ export namespace TuiConfig {
const acc: Acc = {
result: {},
entries: [],
}
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 ?? {})
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>[] = []
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))
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 { Flag } from "@/flag/flag"
import type { SessionID } from "@/session/schema"
import { Filesystem } from "@/util/filesystem"
import { Log } from "../util/log"
export namespace FileTime {
@@ -62,6 +63,7 @@ export namespace FileTime {
)
const getLock = Effect.fn("FileTime.lock")(function* (filepath: string) {
filepath = Filesystem.normalizePath(filepath)
const locks = (yield* InstanceState.get(state)).locks
const lock = locks.get(filepath)
if (lock) return lock
@@ -72,18 +74,21 @@ export namespace FileTime {
})
const read = Effect.fn("FileTime.read")(function* (sessionID: SessionID, file: string) {
file = Filesystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
log.info("read", { sessionID, file })
session(reads, sessionID).set(file, yield* stamp(file))
})
const get = Effect.fn("FileTime.get")(function* (sessionID: SessionID, file: string) {
file = Filesystem.normalizePath(file)
const reads = (yield* InstanceState.get(state)).reads
return reads.get(sessionID)?.get(file)?.read
})
const assert = Effect.fn("FileTime.assert")(function* (sessionID: SessionID, filepath: string) {
if (disableCheck) return
filepath = Filesystem.normalizePath(filepath)
const reads = (yield* InstanceState.get(state)).reads
const time = reads.get(sessionID)?.get(filepath)
+3 -4
View File
@@ -1,5 +1,4 @@
import { text } from "node:stream/consumers"
import { BunProc } from "../bun"
import { Instance } from "../project/instance"
import { Filesystem } from "../util/filesystem"
import { Process } from "../util/process"
@@ -34,7 +33,7 @@ export const mix: Info = {
export const prettier: Info = {
name: "prettier",
command: [BunProc.which(), "x", "prettier", "--write", "$FILE"],
command: ["bun", "x", "prettier", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -82,7 +81,7 @@ export const prettier: Info = {
export const oxfmt: Info = {
name: "oxfmt",
command: [BunProc.which(), "x", "oxfmt", "$FILE"],
command: ["bun", "x", "oxfmt", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
@@ -104,7 +103,7 @@ export const oxfmt: Info = {
export const biome: Info = {
name: "biome",
command: [BunProc.which(), "x", "@biomejs/biome", "check", "--write", "$FILE"],
command: ["bun", "x", "@biomejs/biome", "check", "--write", "$FILE"],
environment: {
BUN_BE_BUN: "1",
},
+24 -4
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 })
.scriptName("opencode")
.wrap(100)
@@ -130,7 +142,7 @@ const cli = yargs(hideBin(process.argv))
process.stderr.write("Database migration complete." + EOL)
}
})
.usage("\n" + UI.logo())
.usage("")
.completion("completion", "generate shell completion script")
.command(AcpCommand)
.command(McpCommand)
@@ -162,7 +174,7 @@ const cli = yargs(hideBin(process.argv))
msg?.startsWith("Invalid values:")
) {
if (err) throw err
cli.showHelp("log")
cli.showHelp(show)
}
if (err) throw err
process.exit(1)
@@ -170,7 +182,15 @@ const cli = yargs(hideBin(process.argv))
.strict()
try {
await cli.parse()
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()
}
} catch (e) {
let data: Record<string, any> = {}
if (e instanceof NamedError) {
+49 -184
View File
@@ -3,7 +3,6 @@ import path from "path"
import os from "os"
import { Global } from "../global"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { text } from "node:stream/consumers"
import fs from "fs/promises"
import { Filesystem } from "../util/filesystem"
@@ -14,6 +13,7 @@ import { Process } from "../util/process"
import { which } from "../util/which"
import { Module } from "@opencode-ai/util/module"
import { spawn } from "./launch"
import { Npm } from "@/npm"
export namespace LSPServer {
const log = Log.create({ service: "lsp.server" })
@@ -103,11 +103,12 @@ export namespace LSPServer {
const tsserver = Module.resolve("typescript/lib/tsserver.js", Instance.directory)
log.info("typescript server", { tsserver })
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,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -129,36 +130,16 @@ export namespace LSPServer {
let binary = which("vue-language-server")
const args: string[] = []
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
await Process.spawn([BunProc.which(), "install", "@vue/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
const resolved = await Npm.which("@vue/language-server")
if (!resolved) return
binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -214,11 +195,10 @@ export namespace LSPServer {
log.info("installed VS Code ESLint server", { serverPath })
}
const proc = spawn(BunProc.which(), [serverPath, "--stdio"], {
const proc = spawn("node", [serverPath, "--stdio"], {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
@@ -345,15 +325,15 @@ export namespace LSPServer {
if (!bin) {
const resolved = Module.resolve("biome", root)
if (!resolved) return
bin = BunProc.which()
args = ["x", "biome", "lsp-proxy", "--stdio"]
bin = await Npm.which("biome")
if (!bin) return
args = ["lsp-proxy", "--stdio"]
}
const proc = spawn(bin, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
@@ -372,9 +352,7 @@ export namespace LSPServer {
},
extensions: [".go"],
async spawn(root) {
let bin = which("gopls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("gopls")
if (!bin) {
if (!which("go")) return
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -409,9 +387,7 @@ export namespace LSPServer {
root: NearestRoot(["Gemfile"]),
extensions: [".rb", ".rake", ".gemspec", ".ru"],
async spawn(root) {
let bin = which("rubocop", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("rubocop")
if (!bin) {
const ruby = which("ruby")
const gem = which("gem")
@@ -516,19 +492,10 @@ export namespace LSPServer {
let binary = which("pyright-langserver")
const args = []
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
await Process.spawn([BunProc.which(), "install", "pyright"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
}).exited
}
binary = BunProc.which()
args.push(...["run", js])
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
const resolved = await Npm.which("pyright")
if (!resolved) return
binary = resolved
}
args.push("--stdio")
@@ -552,7 +519,6 @@ export namespace LSPServer {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -630,9 +596,7 @@ export namespace LSPServer {
extensions: [".zig", ".zon"],
root: NearestRoot(["build.zig"]),
async spawn(root) {
let bin = which("zls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("zls")
if (!bin) {
const zig = which("zig")
@@ -742,9 +706,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".csproj", "global.json"]),
extensions: [".cs"],
async spawn(root) {
let bin = which("csharp-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("csharp-ls")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install csharp-ls")
@@ -781,9 +743,7 @@ export namespace LSPServer {
root: NearestRoot([".slnx", ".sln", ".fsproj", "global.json"]),
extensions: [".fs", ".fsi", ".fsx", ".fsscript"],
async spawn(root) {
let bin = which("fsautocomplete", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("fsautocomplete")
if (!bin) {
if (!which("dotnet")) {
log.error(".NET SDK is required to install fsautocomplete")
@@ -1049,29 +1009,16 @@ export namespace LSPServer {
let binary = which("svelteserver")
const args: string[] = []
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
await Process.spawn([BunProc.which(), "install", "svelte-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
const resolved = await Npm.which("svelte-language-server")
if (!resolved) return
binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1096,29 +1043,16 @@ export namespace LSPServer {
let binary = which("astro-ls")
const args: string[] = []
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
await Process.spawn([BunProc.which(), "install", "@astrojs/language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
const resolved = await Npm.which("@astrojs/language-server")
if (!resolved) return
binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1360,38 +1294,16 @@ export namespace LSPServer {
let binary = which("yaml-language-server")
const args: string[] = []
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
await Process.spawn([BunProc.which(), "install", "yaml-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
const resolved = await Npm.which("yaml-language-server")
if (!resolved) return
binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1413,9 +1325,7 @@ export namespace LSPServer {
]),
extensions: [".lua"],
async spawn(root) {
let bin = which("lua-language-server", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("lua-language-server")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1551,29 +1461,16 @@ export namespace LSPServer {
let binary = which("intelephense")
const args: string[] = []
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
await Process.spawn([BunProc.which(), "install", "intelephense"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
const resolved = await Npm.which("intelephense")
if (!resolved) return
binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1648,29 +1545,16 @@ export namespace LSPServer {
let binary = which("bash-language-server")
const args: string[] = []
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
await Process.spawn([BunProc.which(), "install", "bash-language-server"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
const resolved = await Npm.which("bash-language-server")
if (!resolved) return
binary = resolved
}
args.push("start")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1684,9 +1568,7 @@ export namespace LSPServer {
extensions: [".tf", ".tfvars"],
root: NearestRoot([".terraform.lock.hcl", "terraform.tfstate", "*.tf"]),
async spawn(root) {
let bin = which("terraform-ls", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("terraform-ls")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1767,9 +1649,7 @@ export namespace LSPServer {
extensions: [".tex", ".bib"],
root: NearestRoot([".latexmkrc", "latexmkrc", ".texlabroot", "texlabroot"]),
async spawn(root) {
let bin = which("texlab", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("texlab")
if (!bin) {
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
@@ -1860,29 +1740,16 @@ export namespace LSPServer {
let binary = which("docker-langserver")
const args: string[] = []
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
await Process.spawn([BunProc.which(), "install", "dockerfile-language-server-nodejs"], {
cwd: Global.Path.bin,
env: {
...process.env,
BUN_BE_BUN: "1",
},
stdout: "pipe",
stderr: "pipe",
stdin: "pipe",
}).exited
}
binary = BunProc.which()
args.push("run", js)
if (Flag.OPENCODE_DISABLE_LSP_DOWNLOAD) return
const resolved = await Npm.which("dockerfile-language-server-nodejs")
if (!resolved) return
binary = resolved
}
args.push("--stdio")
const proc = spawn(binary, args, {
cwd: root,
env: {
...process.env,
BUN_BE_BUN: "1",
},
})
return {
@@ -1966,9 +1833,7 @@ export namespace LSPServer {
extensions: [".typ", ".typc"],
root: NearestRoot(["typst.toml"]),
async spawn(root) {
let bin = which("tinymist", {
PATH: process.env["PATH"] + path.delimiter + Global.Path.bin,
})
let bin = which("tinymist")
if (!bin) {
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 { Model } from "@opencode-ai/sdk/v2"
import { Installation } from "@/installation"
import { iife } from "@/util/iife"
import { Log } from "../../util/log"
import { setTimeout as sleep } from "node:timers/promises"
import { CopilotModels } from "./models"
const log = Log.create({ service: "plugin.copilot" })
const CLIENT_ID = "Ov23li8tweQw6odWQebz"
// 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> {
const sdk = input.client
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: {
provider: "github-copilot",
async loader(getAuth, provider) {
async loader(getAuth) {
const info = await getAuth()
if (!info || info.type !== "oauth") return {}
const enterpriseUrl = 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"
}
}
const baseURL = base(info.enterpriseUrl)
return {
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
}
}
+45 -86
View File
@@ -7,7 +7,7 @@ import { Flag } from "../flag/flag"
import { CodexAuthPlugin } from "./codex"
import { Session } from "../session"
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 { PoeAuthPlugin } from "opencode-poe-auth"
import { Effect, Layer, ServiceMap, Stream } from "effect"
@@ -24,10 +24,6 @@ export namespace Plugin {
hooks: Hooks[]
}
type Loaded = {
row: PluginLoader.Loaded
}
// Hook names that follow the (input, output) => Promise<void> trigger pattern
type TriggerName = {
[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
}
async function applyPlugin(load: Loaded, input: PluginInput, hooks: Hooks[]) {
const plugin = readV1Plugin(load.row.mod, load.row.spec, "server", "detect")
function publishPluginError(message: string) {
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) {
await resolvePluginId(
load.row.source,
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))
await resolvePluginId(load.source, load.spec, load.target, readPluginId(plugin.id, load.spec), load.pkg)
hooks.push(await (plugin as PluginModule).server(input, load.options))
return
}
for (const server of getLegacyPlugins(load.row.mod)) {
hooks.push(await server(input, load.row.options))
for (const server of getLegacyPlugins(load.mod)) {
hooks.push(await server(input, load.options))
}
}
@@ -142,87 +136,52 @@ export namespace Plugin {
if (init._tag === "Some") hooks.push(init.value)
}
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin ?? [])
if (Flag.OPENCODE_PURE && cfg.plugin?.length) {
log.info("skipping external plugins in pure mode", { count: cfg.plugin.length })
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
}
if (plugins.length) yield* config.waitForDependencies()
const loaded = yield* Effect.promise(() =>
Promise.all(
plugins.map(async (item) => {
const plan = PluginLoader.plan(item)
if (plan.deprecated) return
log.info("loading plugin", { path: plan.spec })
PluginLoader.loadExternal({
items: plugins,
kind: "server",
report: {
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 (!resolved.ok) {
if (resolved.stage === "missing") {
log.warn("plugin has no server entrypoint", {
path: plan.spec,
message: resolved.message,
})
if (stage === "install") {
const parsed = parsePluginSpecifier(spec)
log.error("failed to install plugin", { pkg: parsed.pkg, version: parsed.version, error: message })
publishPluginError(`Failed to install plugin ${parsed.pkg}@${parsed.version}: ${message}`)
return
}
const cause =
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
const message = errorMessage(cause)
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(),
})
if (stage === "compatibility") {
log.warn("plugin incompatible", { path: spec, error: message })
publishPluginError(`Plugin ${spec} skipped: ${message}`)
return
}
if (resolved.stage === "compatibility") {
log.warn("plugin incompatible", { path: plan.spec, error: message })
Bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Plugin ${plan.spec} skipped: ${message}`,
}).toObject(),
})
if (stage === "entry") {
log.error("failed to resolve plugin server entry", { path: spec, error: message })
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
return
}
log.error("failed to resolve plugin server entry", {
path: plan.spec,
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,
}
}),
),
log.error("failed to load plugin", { path: spec, target: resolved?.entry, error: message })
publishPluginError(`Failed to load plugin ${spec}: ${message}`)
},
},
}),
)
for (const load of loaded) {
if (!load) continue
@@ -233,14 +192,14 @@ export namespace Plugin {
try: () => applyPlugin(load, input, hooks),
catch: (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
},
}).pipe(
Effect.catch((message) =>
bus.publish(Session.Event.Error, {
error: new NamedError.Unknown({
message: `Failed to load plugin ${load.row.spec}: ${message}`,
message: `Failed to load plugin ${load.spec}: ${message}`,
}).toObject(),
}),
),
+30 -8
View File
@@ -13,7 +13,7 @@ import { Filesystem } from "@/util/filesystem"
import { Flock } from "@/util/flock"
import { isRecord } from "@/util/record"
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
import { parsePluginSpecifier, readPackageThemes, readPluginPackage, resolvePluginTarget } from "./shared"
type Mode = "noop" | "add" | "replace"
type Kind = "server" | "tui"
@@ -142,19 +142,26 @@ function hasMainTarget(pkg: Record<string, unknown>) {
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 server = exportTarget(pkg, "server")
const server = exportTarget(pkg.json, "server")
if (server) {
targets.push({ kind: "server", opts: server.opts })
} else if (hasMainTarget(pkg)) {
} else if (hasMainTarget(pkg.json)) {
targets.push({ kind: "server" })
}
const tui = exportTarget(pkg, "tui")
const tui = exportTarget(pkg.json, "tui")
if (tui) {
targets.push({ kind: "tui", opts: tui.opts })
}
if (!targets.some((item) => item.kind === "tui") && readPackageThemes(spec, pkg).length) {
targets.push({ kind: "tui" })
}
return targets
}
@@ -293,8 +300,23 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
}
}
const targets = packageTargets(pkg.item.json)
if (!targets.length) {
const targets = await Promise.resolve()
.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 {
ok: false,
code: "manifest_no_targets",
@@ -304,7 +326,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
return {
ok: true,
targets,
targets: targets.item,
}
}
+103 -66
View File
@@ -4,6 +4,7 @@ import {
checkPluginCompatibility,
createPluginEntry,
isDeprecatedPlugin,
pluginSource,
resolvePluginTarget,
type PluginKind,
type PluginPackage,
@@ -12,31 +13,42 @@ import {
export namespace PluginLoader {
export type Plan = {
item: Config.PluginSpec
spec: string
options: Config.PluginOptions | undefined
deprecated: boolean
}
export type Resolved = Plan & {
source: PluginSource
target: string
entry: string
pkg?: PluginPackage
}
export type Missing = Plan & {
source: PluginSource
target: string
pkg?: PluginPackage
message: string
}
export type Loaded = Resolved & {
mod: Record<string, unknown>
}
export function plan(item: Config.PluginSpec): Plan {
type Candidate = { origin: Config.PluginOrigin; plan: Plan }
type Report = {
start?: (candidate: Candidate, retry: boolean) => void
missing?: (candidate: Candidate, retry: boolean, message: string, resolved: Missing) => void
error?: (
candidate: Candidate,
retry: boolean,
stage: "install" | "entry" | "compatibility" | "load",
error: unknown,
resolved?: Resolved,
) => void
}
function plan(item: Config.PluginSpec): Plan {
const spec = Config.pluginSpecifier(item)
return {
item,
spec,
options: Config.pluginOptions(item),
deprecated: isDeprecatedPlugin(spec),
}
return { spec, options: Config.pluginOptions(item), deprecated: isDeprecatedPlugin(spec) }
}
export async function resolve(
@@ -44,68 +56,44 @@ export namespace PluginLoader {
kind: PluginKind,
): Promise<
| { ok: true; value: Resolved }
| { ok: false; stage: "missing"; message: string }
| { ok: false; stage: "missing"; value: Missing }
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
> {
let target = ""
try {
target = await resolvePluginTarget(plan.spec)
} catch (error) {
return {
ok: false,
stage: "install",
error,
}
}
if (!target) {
return {
ok: false,
stage: "install",
error: new Error(`Plugin ${plan.spec} target is empty`),
}
return { ok: false, stage: "install", error }
}
if (!target) return { ok: false, stage: "install", error: new Error(`Plugin ${plan.spec} target is empty`) }
let base
try {
base = await createPluginEntry(plan.spec, target, kind)
} catch (error) {
return {
ok: false,
stage: "entry",
error,
}
return { ok: false, stage: "entry", error }
}
if (!base.entry) {
if (!base.entry)
return {
ok: false,
stage: "missing",
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
value: {
...plan,
source: base.source,
target: base.target,
pkg: base.pkg,
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
},
}
}
if (base.source === "npm") {
try {
await checkPluginCompatibility(base.target, Installation.VERSION, base.pkg)
} catch (error) {
return {
ok: false,
stage: "compatibility",
error,
}
return { 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 }> {
@@ -113,25 +101,74 @@ export namespace PluginLoader {
try {
mod = await import(row.entry)
} catch (error) {
return {
ok: false,
error,
return { ok: false, error }
}
if (!mod) return { ok: false, error: new Error(`Plugin ${row.spec} module is empty`) }
return { ok: true, value: { ...row, mod } }
}
async function attempt<R>(
candidate: Candidate,
kind: PluginKind,
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)
}
type Input<R> = {
items: Config.PluginOrigin[]
kind: PluginKind
wait?: () => Promise<void>
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)
}
}
if (!mod) {
return {
ok: false,
error: new Error(`Plugin ${row.spec} module is empty`),
}
}
return {
ok: true,
value: {
...row,
mod,
},
}
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 { fileURLToPath, pathToFileURL } from "url"
import semver from "semver"
import { BunProc } from "@/bun"
import { Npm } from "@/npm"
import { Filesystem } from "@/util/filesystem"
import { isRecord } from "@/util/record"
@@ -50,6 +50,10 @@ function resolveExportPath(raw: string, dir: string) {
return path.resolve(dir, raw)
}
function isAbsolutePath(raw: string) {
return path.isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw)
}
function extractExportValue(value: unknown): string | undefined {
if (typeof value === "string") return value
if (!isRecord(value)) return undefined
@@ -68,14 +72,18 @@ function packageMain(pkg: PluginPackage) {
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 root = Filesystem.resolve(pkg.dir)
const next = Filesystem.resolve(resolved)
if (!Filesystem.contains(root, next)) {
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) {
@@ -106,7 +114,7 @@ async function resolveDirectoryIndex(dir: string) {
async function resolveTargetDirectory(target: string) {
const file = targetPath(target)
if (!file) return
const stat = await Filesystem.stat(file)
const stat = await Filesystem.statAsync(file)
if (!stat?.isDirectory()) return
return file
}
@@ -147,13 +155,13 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
}
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) {
const raw = spec.startsWith("file://") ? fileURLToPath(spec) : spec
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 (spec.startsWith("file://")) return spec
return pathToFileURL(file).href
@@ -184,12 +192,13 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(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> {
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 pkg = path.join(dir, "package.json")
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) {
if (id === undefined) return
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 { NoSuchModelError, type Provider as SDK } from "ai"
import { Log } from "../util/log"
import { BunProc } from "../bun"
import { Npm } from "../npm"
import { Hash } from "../util/hash"
import { Plugin } from "../plugin"
import { NamedError } from "@opencode-ai/util/error"
@@ -44,6 +44,7 @@ import { createGateway } from "@ai-sdk/gateway"
import { createTogetherAI } from "@ai-sdk/togetherai"
import { createPerplexity } from "@ai-sdk/perplexity"
import { createVercel } from "@ai-sdk/vercel"
import { createVenice } from "venice-ai-sdk-provider"
import {
createGitLab,
VERSION as GITLAB_PROVIDER_VERSION,
@@ -139,6 +140,7 @@ export namespace Provider {
"@ai-sdk/vercel": createVercel,
"gitlab-ai-provider": createGitLab,
"@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible,
"venice-ai-sdk-provider": createVenice,
}
type CustomModelLoader = (sdk: any, modelID: string, options?: Record<string, any>) => Promise<any>
@@ -681,6 +683,9 @@ export namespace Provider {
autoload: !!apiKey,
options: {
apiKey,
headers: {
"User-Agent": `opencode/${Installation.VERSION} cloudflare-workers-ai (${os.platform()} ${os.release()}; ${os.arch()})`,
},
},
async getModel(sdk: any, modelID: string) {
return sdk.languageModel(modelID)
@@ -732,6 +737,9 @@ export namespace Provider {
cacheKey: input.options?.cacheKey,
skipCache: input.options?.skipCache,
collectLog: input.options?.collectLog,
headers: {
"User-Agent": `opencode/${Installation.VERSION} cloudflare-ai-gateway (${os.platform()} ${os.release()}; ${os.arch()})`,
},
}
const aigateway = createAiGateway({
@@ -1178,6 +1186,49 @@ export namespace Provider {
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)) {
const providerID = ProviderID.make(id)
if (!isProviderAllowed(providerID)) {
@@ -1222,22 +1273,6 @@ export namespace Provider {
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 {
models: languages,
providers,
@@ -1365,7 +1400,9 @@ export namespace Provider {
let installedPath: string
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 {
log.info("loading local provider", { pkg: 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.
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.
Respond in the same language as the user's messages in the conversation.
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 msgs = structuredClone(messages)
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 msg: MessageV2.Assistant = {
id: MessageID.ascending(),
+4 -9
View File
@@ -593,15 +593,10 @@ export namespace Session {
})
const messages = Effect.fn("Session.messages")(function* (input: { sessionID: SessionID; limit?: number }) {
return yield* Effect.promise(async () => {
const result = [] as MessageV2.WithParts[]
for await (const msg of MessageV2.stream(input.sessionID)) {
if (input.limit && result.length >= input.limit) break
result.push(msg)
}
result.reverse()
return result
})
if (input.limit) {
return MessageV2.page({ sessionID: input.sessionID, limit: input.limit }).items
}
return Array.from(MessageV2.stream(input.sessionID)).reverse()
})
const removeMessage = Effect.fn("Session.removeMessage")(function* (input: {
+219 -145
View File
@@ -1,13 +1,18 @@
import path from "path"
import os from "os"
import { Global } from "../global"
import { Filesystem } from "../util/filesystem"
import { Config } from "../config/config"
import { Instance } from "../project/instance"
import path from "path"
import { Effect, Layer, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { Config } from "@/config/config"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
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 { Glob } from "../util/glob"
import type { MessageV2 } from "./message-v2"
import type { MessageID } from "./schema"
const log = Log.create({ service: "instruction" })
@@ -29,164 +34,233 @@ function globalFiles() {
return files
}
async function resolveRelative(instruction: string): Promise<string[]> {
if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
return Filesystem.globUp(instruction, Instance.directory, Instance.worktree).catch(() => [])
function extract(messages: MessageV2.WithParts[]) {
const paths = new Set<string>()
for (const msg of messages) {
for (const part of msg.parts) {
if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
if (part.state.time.compacted) continue
const loaded = part.state.metadata?.loaded
if (!loaded || !Array.isArray(loaded)) continue
for (const p of loaded) {
if (typeof p === "string") paths.add(p)
}
}
}
}
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(() => [])
return paths
}
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)
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>
}
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 class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Instruction") {}
export function clear(messageID: string) {
state().claims.delete(messageID)
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) {
const filepath = path.resolve(path.join(dir, file))
if (yield* fs.existsSafe(filepath)) return filepath
}
})
const resolve = Effect.fn("Instruction.resolve")(function* (
messages: MessageV2.WithParts[],
filepath: string,
messageID: MessageID,
) {
const sys = yield* systemPaths()
const already = extract(messages)
const results: { filepath: string; content: string }[] = []
const s = yield* InstanceState.get(state)
const target = path.resolve(filepath)
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) {
const found = yield* find(current)
if (!found || found === target || sys.has(found) || already.has(found)) {
current = path.dirname(current)
continue
}
let set = s.claims.get(messageID)
if (!set) {
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) {
results.push({ filepath: found, content: `Instructions from: ${found}\n${content}` })
}
current = path.dirname(current)
}
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() {
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
return runPromise((svc) => svc.systemPaths())
}
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))
return runPromise((svc) => svc.system())
}
export function loaded(messages: MessageV2.WithParts[]) {
const paths = new Set<string>()
for (const msg of messages) {
for (const part of msg.parts) {
if (part.type === "tool" && part.tool === "read" && part.state.status === "completed") {
if (part.state.time.compacted) continue
const loaded = part.state.metadata?.loaded
if (!loaded || !Array.isArray(loaded)) continue
for (const p of loaded) {
if (typeof p === "string") paths.add(p)
}
}
}
}
return paths
return extract(messages)
}
export async function find(dir: string) {
for (const file of FILES) {
const filepath = path.resolve(path.join(dir, file))
if (await Filesystem.exists(filepath)) return filepath
}
return runPromise((svc) => svc.find(dir))
}
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: string) {
const system = await systemPaths()
const already = loaded(messages)
const results: { filepath: string; content: string }[] = []
const target = path.resolve(filepath)
let current = path.dirname(target)
const root = path.resolve(Instance.directory)
while (current.startsWith(root) && current !== root) {
const found = await find(current)
if (found && found !== target && !system.has(found) && !already.has(found) && !isClaimed(messageID, found)) {
claim(messageID, found)
const content = await Filesystem.readText(found).catch(() => undefined)
if (content) {
results.push({ filepath: found, content: "Instructions from: " + found + "\n" + content })
}
}
current = path.dirname(current)
}
return results
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) {
return runPromise((svc) => svc.resolve(messages, filepath, messageID))
}
}
+78 -77
View File
@@ -5,7 +5,6 @@ import { NamedError } from "@opencode-ai/util/error"
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
import { LSP } from "../lsp"
import { Snapshot } from "@/snapshot"
import { fn } from "@/util/fn"
import { SyncEvent } from "../sync"
import { Database, NotFoundError, and, desc, eq, inArray, lt, or } from "@/storage/db"
import { MessageTable, PartTable, SessionTable } from "./session.sql"
@@ -15,6 +14,7 @@ import { errorMessage } from "@/util/error"
import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { ModelID, ProviderID } from "@/provider/schema"
import { Effect } from "effect"
/** Error shape thrown by Bun's fetch() when gzip/br decompression fails mid-stream */
interface FetchDecompressionError extends Error {
@@ -547,7 +547,7 @@ export namespace MessageV2 {
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 partByMessage = new Map<string, MessageV2.Part[]>()
if (ids.length > 0) {
@@ -573,11 +573,11 @@ export namespace MessageV2 {
}))
}
export async function toModelMessages(
export const toModelMessagesEffect = Effect.fnUntraced(function* (
input: WithParts[],
model: Provider.Model,
options?: { stripMedia?: boolean },
): Promise<ModelMessage[]> {
) {
const result: UIMessage[] = []
const toolNames = new Set<string>()
// Track media from tool results that need to be injected as user messages
@@ -800,64 +800,67 @@ export namespace MessageV2 {
const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }]))
return await convertToModelMessages(
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)
tools,
},
return yield* Effect.promise(() =>
convertToModelMessages(
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)
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(
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 where = before
? and(eq(MessageTable.session_id, input.sessionID), older(before))
: eq(MessageTable.session_id, input.sessionID)
const rows = Database.use((db) =>
db
.select()
.from(MessageTable)
.where(where)
.orderBy(desc(MessageTable.time_created), desc(MessageTable.id))
.limit(input.limit + 1)
.all(),
export function page(input: { sessionID: SessionID; limit: number; before?: string }) {
const before = input.before ? cursor.decode(input.before) : undefined
const where = before
? and(eq(MessageTable.session_id, input.sessionID), older(before))
: eq(MessageTable.session_id, input.sessionID)
const rows = Database.use((db) =>
db
.select()
.from(MessageTable)
.where(where)
.orderBy(desc(MessageTable.time_created), desc(MessageTable.id))
.limit(input.limit + 1)
.all(),
)
if (rows.length === 0) {
const row = Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(),
)
if (rows.length === 0) {
const row = Database.use((db) =>
db.select({ id: SessionTable.id }).from(SessionTable).where(eq(SessionTable.id, input.sessionID)).get(),
)
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
return {
items: [] as MessageV2.WithParts[],
more: false,
}
}
const more = rows.length > input.limit
const page = more ? rows.slice(0, input.limit) : rows
const items = await hydrate(page)
items.reverse()
const tail = page.at(-1)
if (!row) throw new NotFoundError({ message: `Session not found: ${input.sessionID}` })
return {
items,
more,
cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined,
items: [] as MessageV2.WithParts[],
more: false,
}
},
)
}
export const stream = fn(SessionID.zod, async function* (sessionID) {
const more = rows.length > input.limit
const slice = more ? rows.slice(0, input.limit) : rows
const items = hydrate(slice)
items.reverse()
const tail = slice.at(-1)
return {
items,
more,
cursor: more && tail ? cursor.encode({ id: tail.id, time: tail.time_created }) : undefined,
}
}
export function* stream(sessionID: SessionID) {
const size = 50
let before: string | undefined
while (true) {
const next = await page({ sessionID, limit: size, before })
const next = page({ sessionID, limit: size, before })
if (next.items.length === 0) break
for (let i = next.items.length - 1; i >= 0; i--) {
yield next.items[i]
@@ -865,9 +868,9 @@ export namespace MessageV2 {
if (!next.more || !next.cursor) break
before = next.cursor
}
})
}
export const parts = fn(MessageID.zod, async (message_id) => {
export function parts(message_id: MessageID) {
const rows = Database.use((db) =>
db.select().from(PartTable).where(eq(PartTable.message_id, message_id)).orderBy(PartTable.id).all(),
)
@@ -880,33 +883,27 @@ export namespace MessageV2 {
messageID: row.message_id,
}) as MessageV2.Part,
)
})
}
export const get = fn(
z.object({
sessionID: SessionID.zod,
messageID: MessageID.zod,
}),
async (input): Promise<WithParts> => {
const row = Database.use((db) =>
db
.select()
.from(MessageTable)
.where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID)))
.get(),
)
if (!row) throw new NotFoundError({ message: `Message not found: ${input.messageID}` })
return {
info: info(row),
parts: await parts(input.messageID),
}
},
)
export function get(input: { sessionID: SessionID; messageID: MessageID }): WithParts {
const row = Database.use((db) =>
db
.select()
.from(MessageTable)
.where(and(eq(MessageTable.id, input.messageID), eq(MessageTable.session_id, input.sessionID)))
.get(),
)
if (!row) throw new NotFoundError({ message: `Message not found: ${input.messageID}` })
return {
info: info(row),
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 completed = new Set<string>()
for await (const msg of stream) {
for (const msg of msgs) {
result.push(msg)
if (
msg.info.role === "user" &&
@@ -921,6 +918,10 @@ export namespace MessageV2 {
return result
}
export const filterCompactedEffect = Effect.fnUntraced(function* (sessionID: SessionID) {
return filterCompacted(stream(sessionID))
})
export function fromError(
e: unknown,
ctx: { providerID: ProviderID; aborted?: boolean },
+8 -4
View File
@@ -84,13 +84,17 @@ export namespace SessionProcessor {
const status = yield* SessionStatus.Service
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 = {
assistantMessage: input.assistantMessage,
sessionID: input.sessionID,
model: input.model,
toolcalls: {},
shouldBreak: false,
snapshot: undefined,
snapshot: initialSnapshot,
blocked: false,
needsCompaction: false,
currentText: undefined,
@@ -180,7 +184,7 @@ export namespace SessionProcessor {
metadata: value.providerMetadata,
} 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)
if (
@@ -250,7 +254,7 @@ export namespace SessionProcessor {
throw value.error
case "start-step":
ctx.snapshot = yield* snapshot.track()
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
yield* session.updatePart({
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
@@ -392,7 +396,7 @@ export namespace SessionProcessor {
}
ctx.reasoningMap = {}
const parts = yield* Effect.promise(() => MessageV2.parts(ctx.assistantMessage.id))
const parts = MessageV2.parts(ctx.assistantMessage.id)
for (const part of parts) {
if (part.type !== "tool" || part.state.status === "completed" || part.state.status === "error") continue
yield* session.updatePart({

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