diff --git a/bun.lock b/bun.lock
index 115100e1048..8faaf7a3cc0 100644
--- a/bun.lock
+++ b/bun.lock
@@ -46,7 +46,7 @@
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
- "effect": "catalog:",
+ "effect": "4.0.0-beta.31",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",
@@ -227,7 +227,7 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
- "effect": "catalog:",
+ "effect": "4.0.0-beta.31",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
@@ -324,7 +324,7 @@
"@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
- "@effect/platform-node": "catalog:",
+ "@effect/platform-node": "4.0.0-beta.31",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
@@ -594,7 +594,6 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
- "@effect/platform-node": "4.0.0-beta.35",
"@hono/zod-validator": "0.4.2",
"@kobalte/core": "0.13.11",
"@octokit/rest": "22.0.0",
@@ -618,7 +617,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
- "effect": "4.0.0-beta.35",
+ "effect": "4.0.0-beta.31",
"fuzzysort": "3.1.0",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
@@ -976,9 +975,9 @@
"@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="],
- "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.35", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.35", "mime": "^4.1.0", "undici": "^7.24.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35", "ioredis": "^5.7.0" } }, "sha512-HPc2xZASl9F9y/xJ01bQgFD6Jf9XP4Fcv/BlVTvG0Yr/uN63lwKZYr/VXor5K5krHfBDeCBD8y7/SICPYZoq3A=="],
+ "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.31", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.31", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.31", "ioredis": "^5.7.0" } }, "sha512-KmVZwGsQRBMZZYPJwpL2vj6sxjBzfXhyA8RgsH5/cmckDTsZpVTyqODQ/FFzmCnMWuYjZoJGPghTDrVVDn/6ZA=="],
- "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.35", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.35" } }, "sha512-9bPqNV988itKJ7MQoJuzmR014DB9EZRDOnhJt/+iJlb8qLoR9HnCzNJb9gfBdYhFmVYc8DMsQxG81rdJzpv9tg=="],
+ "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.33", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.33" } }, "sha512-jaJnvYz1IiPZyN//fCJsvwnmujJS5KD8noCVVLhb4ZGCWKhQpt0x2iuax6HFzMlPEQSfl04GLU+PVKh0nkzPyA=="],
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
@@ -2752,7 +2751,7 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
- "effect": ["effect@4.0.0-beta.35", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-64j8dgJmoEMeq6Y3WLYcZIRqPZ5E/lqnULCf6QW5te3hQ/sa13UodWLGwBEviEqBoq72U8lArhVX+T7ntzhJGQ=="],
+ "effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="],
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
@@ -5020,8 +5019,6 @@
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
- "@effect/platform-node/undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="],
-
"@effect/platform-node-shared/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
diff --git a/package.json b/package.json
index b8329dd9483..00e251f500c 100644
--- a/package.json
+++ b/package.json
@@ -25,7 +25,6 @@
"packages/slack"
],
"catalog": {
- "@effect/platform-node": "4.0.0-beta.35",
"@types/bun": "1.3.9",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
@@ -45,7 +44,7 @@
"dompurify": "3.3.1",
"drizzle-kit": "1.0.0-beta.16-ea816b6",
"drizzle-orm": "1.0.0-beta.16-ea816b6",
- "effect": "4.0.0-beta.35",
+ "effect": "4.0.0-beta.31",
"ai": "5.0.124",
"hono": "4.10.7",
"hono-openapi": "1.1.2",
diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts
index 88d71f94cfd..aa047fb287a 100644
--- a/packages/app/e2e/actions.ts
+++ b/packages/app/e2e/actions.ts
@@ -1,4 +1,3 @@
-import { base64Decode, base64Encode } from "@opencode-ai/util/encode"
import { expect, type Locator, type Page } from "@playwright/test"
import fs from "node:fs/promises"
import os from "node:os"
@@ -362,30 +361,6 @@ export async function waitSlug(page: Page, skip: string[] = []) {
return next
}
-export async function resolveSlug(slug: string) {
- const directory = base64Decode(slug)
- if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
- const resolved = await resolveDirectory(directory)
- return { directory: resolved, slug: base64Encode(resolved), raw: slug }
-}
-
-export async function waitDir(page: Page, directory: string) {
- const target = await resolveDirectory(directory)
- await expect
- .poll(
- async () => {
- const slug = slugFromUrl(page.url())
- if (!slug) return ""
- return resolveSlug(slug)
- .then((item) => item.directory)
- .catch(() => "")
- },
- { timeout: 45_000 },
- )
- .toBe(target)
- return { directory: target, slug: base64Encode(target) }
-}
-
export function sessionIDFromUrl(url: string) {
const match = /\/session\/([^/?#]+)/.exec(url)
return match?.[1]
diff --git a/packages/app/e2e/projects/projects-switch.spec.ts b/packages/app/e2e/projects/projects-switch.spec.ts
index e9cbf868dff..6ad64f59278 100644
--- a/packages/app/e2e/projects/projects-switch.spec.ts
+++ b/packages/app/e2e/projects/projects-switch.spec.ts
@@ -1,15 +1,7 @@
import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
-import {
- defocus,
- createTestProject,
- cleanupTestProject,
- openSidebar,
- sessionIDFromUrl,
- waitDir,
- waitSlug,
-} from "../actions"
+import { defocus, createTestProject, cleanupTestProject, openSidebar, sessionIDFromUrl, waitSlug } from "../actions"
import { projectSwitchSelector, promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { dirSlug, resolveDirectory } from "../utils"
@@ -108,8 +100,11 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(btn).toBeVisible()
await btn.click({ force: true })
+ // A new workspace can be discovered via a transient slug before the route and sidebar
+ // settle to the canonical workspace path on Windows, so interact with either and assert
+ // against the resolved workspace slug.
await waitSlug(page)
- await waitDir(page, space)
+ await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
// Create a session by sending a prompt
const prompt = page.locator(promptSelector)
@@ -137,7 +132,6 @@ test("switching back to a project opens the latest workspace session", async ({
await expect(rootButton).toBeVisible()
await rootButton.click()
- await waitDir(page, space)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "").toBe(created)
await expect(page).toHaveURL(new RegExp(`/session/${created}(?:[/?#]|$)`))
},
diff --git a/packages/app/e2e/projects/workspace-new-session.spec.ts b/packages/app/e2e/projects/workspace-new-session.spec.ts
index 0858f26273c..18fa46d3299 100644
--- a/packages/app/e2e/projects/workspace-new-session.spec.ts
+++ b/packages/app/e2e/projects/workspace-new-session.spec.ts
@@ -1,25 +1,18 @@
+import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
-import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitDir, waitSlug } from "../actions"
+import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, slugFromUrl, waitSlug } from "../actions"
import { promptSelector, workspaceItemSelector, workspaceNewSessionSelector } from "../selectors"
import { createSdk } from "../utils"
-function item(space: { slug: string; raw: string }) {
- return `${workspaceItemSelector(space.slug)}, ${workspaceItemSelector(space.raw)}`
-}
-
-function button(space: { slug: string; raw: string }) {
- return `${workspaceNewSessionSelector(space.slug)}, ${workspaceNewSessionSelector(space.raw)}`
-}
-
-async function waitWorkspaceReady(page: Page, space: { slug: string; raw: string }) {
+async function waitWorkspaceReady(page: Page, slug: string) {
await openSidebar(page)
await expect
.poll(
async () => {
- const row = page.locator(item(space)).first()
+ const item = page.locator(workspaceItemSelector(slug)).first()
try {
- await row.hover({ timeout: 500 })
+ await item.hover({ timeout: 500 })
return true
} catch {
return false
@@ -34,30 +27,29 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
- const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
- await waitDir(page, next.directory)
- return next
+ const slug = await waitSlug(page, [root, ...seen])
+ const directory = base64Decode(slug)
+ if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
+ return { slug, directory }
}
-async function openWorkspaceNewSession(page: Page, space: { slug: string; raw: string; directory: string }) {
- await waitWorkspaceReady(page, space)
+async function openWorkspaceNewSession(page: Page, slug: string) {
+ await waitWorkspaceReady(page, slug)
- const row = page.locator(item(space)).first()
- await row.hover()
+ const item = page.locator(workspaceItemSelector(slug)).first()
+ await item.hover()
- const next = page.locator(button(space)).first()
- await expect(next).toBeVisible()
- await next.click({ force: true })
+ const button = page.locator(workspaceNewSessionSelector(slug)).first()
+ await expect(button).toBeVisible()
+ await button.click({ force: true })
- return waitDir(page, space.directory)
+ const next = await waitSlug(page)
+ await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
+ return next
}
-async function createSessionFromWorkspace(
- page: Page,
- space: { slug: string; raw: string; directory: string },
- text: string,
-) {
- const next = await openWorkspaceNewSession(page, space)
+async function createSessionFromWorkspace(page: Page, slug: string, text: string) {
+ const next = await openWorkspaceNewSession(page, slug)
const prompt = page.locator(promptSelector)
await expect(prompt).toBeVisible()
@@ -68,13 +60,13 @@ async function createSessionFromWorkspace(
await expect.poll(async () => ((await prompt.textContent()) ?? "").trim()).toContain(text)
await prompt.press("Enter")
- await waitDir(page, next.directory)
+ await expect.poll(() => slugFromUrl(page.url())).toBe(next)
await expect.poll(() => sessionIDFromUrl(page.url()) ?? "", { timeout: 30_000 }).not.toBe("")
const sessionID = sessionIDFromUrl(page.url())
if (!sessionID) throw new Error(`Failed to parse session id from url: ${page.url()}`)
- await expect(page).toHaveURL(new RegExp(`/session/${sessionID}(?:[/?#]|$)`))
- return { sessionID, slug: next.slug }
+ await expect(page).toHaveURL(new RegExp(`/${next}/session/${sessionID}(?:[/?#]|$)`))
+ return { sessionID, slug: next }
}
async function sessionDirectory(directory: string, sessionID: string) {
@@ -95,11 +87,11 @@ test("new sessions from sidebar workspace actions stay in selected workspace", a
const first = await createWorkspace(page, root, [])
trackDirectory(first.directory)
- await waitWorkspaceReady(page, first)
+ await waitWorkspaceReady(page, first.slug)
const second = await createWorkspace(page, root, [first.slug])
trackDirectory(second.directory)
- await waitWorkspaceReady(page, second)
+ await waitWorkspaceReady(page, second.slug)
const firstSession = await createSessionFromWorkspace(page, first.slug, `workspace one ${Date.now()}`)
trackSession(firstSession.sessionID, first.directory)
diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts
index 297cdb9fc96..aeeccb9bba9 100644
--- a/packages/app/e2e/projects/workspaces.spec.ts
+++ b/packages/app/e2e/projects/workspaces.spec.ts
@@ -1,7 +1,7 @@
+import { base64Decode } from "@opencode-ai/util/encode"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
-import { base64Decode } from "@opencode-ai/util/encode"
import type { Page } from "@playwright/test"
import { test, expect } from "../fixtures"
@@ -13,10 +13,8 @@ import {
confirmDialog,
openSidebar,
openWorkspaceMenu,
- resolveSlug,
setWorkspacesEnabled,
slugFromUrl,
- waitDir,
waitSlug,
} from "../actions"
import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors"
@@ -29,15 +27,15 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
await setWorkspacesEnabled(page, rootSlug, true)
await page.getByRole("button", { name: "New workspace" }).first().click()
- const next = await resolveSlug(await waitSlug(page, [rootSlug]))
- await waitDir(page, next.directory)
+ const slug = await waitSlug(page, [rootSlug])
+ const dir = base64Decode(slug)
await openSidebar(page)
await expect
.poll(
async () => {
- const item = page.locator(workspaceItemSelector(next.slug)).first()
+ const item = page.locator(workspaceItemSelector(slug)).first()
try {
await item.hover({ timeout: 500 })
return true
@@ -49,7 +47,7 @@ async function setupWorkspaceTest(page: Page, project: { slug: string }) {
)
.toBe(true)
- return { rootSlug, slug: next.slug, directory: next.directory }
+ return { rootSlug, slug, directory: dir }
}
test("can enable and disable workspaces from project menu", async ({ page, withProject }) => {
@@ -81,15 +79,15 @@ test("can create a workspace", async ({ page, withProject }) => {
await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible()
await page.getByRole("button", { name: "New workspace" }).first().click()
- const next = await resolveSlug(await waitSlug(page, [slug]))
- await waitDir(page, next.directory)
+ const workspaceSlug = await waitSlug(page, [slug])
+ const workspaceDir = base64Decode(workspaceSlug)
await openSidebar(page)
await expect
.poll(
async () => {
- const item = page.locator(workspaceItemSelector(next.slug)).first()
+ const item = page.locator(workspaceItemSelector(workspaceSlug)).first()
try {
await item.hover({ timeout: 500 })
return true
@@ -101,9 +99,9 @@ test("can create a workspace", async ({ page, withProject }) => {
)
.toBe(true)
- await expect(page.locator(workspaceItemSelector(next.slug)).first()).toBeVisible()
+ await expect(page.locator(workspaceItemSelector(workspaceSlug)).first()).toBeVisible()
- await cleanupTestProject(next.directory)
+ await cleanupTestProject(workspaceDir)
})
})
@@ -121,7 +119,7 @@ test("non-git projects keep workspace mode disabled", async ({ page, withProject
await expect.poll(() => slugFromUrl(page.url()), { timeout: 30_000 }).not.toBe("")
- const activeDir = await resolveSlug(slugFromUrl(page.url())).then((item) => item.directory)
+ const activeDir = base64Decode(slugFromUrl(page.url()))
expect(path.basename(activeDir)).toContain("opencode-e2e-project-nongit-")
await openSidebar(page)
@@ -333,9 +331,9 @@ test("can reorder workspaces by drag and drop", async ({ page, withProject }) =>
for (const _ of [0, 1]) {
const prev = slugFromUrl(page.url())
await page.getByRole("button", { name: "New workspace" }).first().click()
- const next = await resolveSlug(await waitSlug(page, [rootSlug, prev]))
- await waitDir(page, next.directory)
- workspaces.push(next)
+ const slug = await waitSlug(page, [rootSlug, prev])
+ const dir = base64Decode(slug)
+ workspaces.push({ slug, directory: dir })
await openSidebar(page)
}
diff --git a/packages/app/e2e/prompt/prompt-multiline.spec.ts b/packages/app/e2e/prompt/prompt-multiline.spec.ts
index 3584773bb94..216aa3fdaec 100644
--- a/packages/app/e2e/prompt/prompt-multiline.spec.ts
+++ b/packages/app/e2e/prompt/prompt-multiline.spec.ts
@@ -7,18 +7,12 @@ test("shift+enter inserts a newline without submitting", async ({ page, gotoSess
await expect(page).toHaveURL(/\/session\/?$/)
const prompt = page.locator(promptSelector)
- await prompt.focus()
- await expect(prompt).toBeFocused()
-
- await prompt.pressSequentially("line one")
- await expect(prompt).toBeFocused()
-
- await prompt.press("Shift+Enter")
- await expect(page).toHaveURL(/\/session\/?$/)
- await expect(prompt).toBeFocused()
-
- await prompt.pressSequentially("line two")
+ await prompt.click()
+ await page.keyboard.type("line one")
+ await page.keyboard.press("Shift+Enter")
+ await page.keyboard.type("line two")
await expect(page).toHaveURL(/\/session\/?$/)
- await expect.poll(() => prompt.evaluate((el) => el.innerText)).toBe("line one\nline two")
+ await expect(prompt).toContainText("line one")
+ await expect(prompt).toContainText("line two")
})
diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts
index 2c2e4e886da..933d5e6f96d 100644
--- a/packages/app/e2e/session/session-model-persistence.spec.ts
+++ b/packages/app/e2e/session/session-model-persistence.spec.ts
@@ -1,6 +1,7 @@
+import { base64Decode } from "@opencode-ai/util/encode"
import type { Locator, Page } from "@playwright/test"
import { test, expect } from "../fixtures"
-import { openSidebar, resolveSlug, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
+import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions"
import {
promptAgentSelector,
promptModelSelector,
@@ -223,9 +224,10 @@ async function createWorkspace(page: Page, root: string, seen: string[]) {
await openSidebar(page)
await page.getByRole("button", { name: "New workspace" }).first().click()
- const next = await resolveSlug(await waitSlug(page, [root, ...seen]))
- await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
- return next
+ const slug = await waitSlug(page, [root, ...seen])
+ const directory = base64Decode(slug)
+ if (!directory) throw new Error(`Failed to decode workspace slug: ${slug}`)
+ return { slug, directory }
}
async function waitWorkspace(page: Page, slug: string) {
@@ -255,8 +257,8 @@ async function newWorkspaceSession(page: Page, slug: string) {
await expect(button).toBeVisible()
await button.click({ force: true })
- const next = await resolveSlug(await waitSlug(page))
- await expect(page).toHaveURL(new RegExp(`/${next.slug}/session(?:[/?#]|$)`))
+ const next = await waitSlug(page)
+ await expect(page).toHaveURL(new RegExp(`/${next}/session(?:[/?#]|$)`))
await expect(page.locator(promptSelector)).toBeVisible()
return currentDir(page)
}
diff --git a/packages/app/package.json b/packages/app/package.json
index 545d3130980..878cfb8e314 100644
--- a/packages/app/package.json
+++ b/packages/app/package.json
@@ -56,7 +56,7 @@
"@solidjs/router": "catalog:",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "catalog:",
- "effect": "catalog:",
+ "effect": "4.0.0-beta.31",
"fuzzysort": "catalog:",
"ghostty-web": "github:anomalyco/ghostty-web#main",
"luxon": "catalog:",
diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx
index 9a282bbb708..e370862212b 100644
--- a/packages/app/src/app.tsx
+++ b/packages/app/src/app.tsx
@@ -46,13 +46,21 @@ import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
-const HomeRoute = lazy(() => import("@/pages/home"))
+const Home = lazy(() => import("@/pages/home"))
const Session = lazy(() => import("@/pages/session"))
const Loading = () =>
+const HomeRoute = () => (
+ }>
+
+
+)
+
const SessionRoute = () => (
-
+ }>
+
+
)
@@ -116,10 +124,8 @@ function SessionProviders(props: ParentProps) {
function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) {
return (
- }>
- {props.appChildren}
- {props.children}
-
+ {props.appChildren}
+ {props.children}
)
}
@@ -259,15 +265,6 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:
)
}
-function ServerKey(props: ParentProps) {
- const server = useServer()
- return (
-
- {props.children}
-
- )
-}
-
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
@@ -278,22 +275,20 @@ export function AppInterface(props: {
return (
-
-
-
- {routerProps.children} }
- >
-
-
-
-
-
-
-
-
-
+
+
+ {routerProps.children} }
+ >
+
+
+
+
+
+
+
+
)
diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx
index e4fe9e7c4ed..b042205cf4d 100644
--- a/packages/app/src/components/dialog-connect-provider.tsx
+++ b/packages/app/src/components/dialog-connect-provider.tsx
@@ -15,6 +15,7 @@ import { Link } from "@/components/link"
import { useLanguage } from "@/context/language"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
+import { usePlatform } from "@/context/platform"
import { DialogSelectModel } from "./dialog-select-model"
import { DialogSelectProvider } from "./dialog-select-provider"
@@ -22,6 +23,7 @@ export function DialogConnectProvider(props: { provider: string }) {
const dialog = useDialog()
const globalSync = useGlobalSync()
const globalSDK = useGlobalSDK()
+ const platform = usePlatform()
const language = useLanguage()
const alive = { value: true }
@@ -47,14 +49,13 @@ export function DialogConnectProvider(props: { provider: string }) {
const [store, setStore] = createStore({
methodIndex: undefined as undefined | number,
authorization: undefined as undefined | ProviderAuthAuthorization,
- state: "pending" as undefined | "pending" | "complete" | "error" | "prompt",
+ state: "pending" as undefined | "pending" | "complete" | "error",
error: undefined as string | undefined,
})
type Action =
| { type: "method.select"; index: number }
| { type: "method.reset" }
- | { type: "auth.prompt" }
| { type: "auth.pending" }
| { type: "auth.complete"; authorization: ProviderAuthAuthorization }
| { type: "auth.error"; error: string }
@@ -76,11 +77,6 @@ export function DialogConnectProvider(props: { provider: string }) {
draft.error = undefined
return
}
- if (action.type === "auth.prompt") {
- draft.state = "prompt"
- draft.error = undefined
- return
- }
if (action.type === "auth.pending") {
draft.state = "pending"
draft.error = undefined
@@ -124,7 +120,7 @@ export function DialogConnectProvider(props: { provider: string }) {
return fallback
}
- async function selectMethod(index: number, inputs?: Record) {
+ async function selectMethod(index: number) {
if (timer.current !== undefined) {
clearTimeout(timer.current)
timer.current = undefined
@@ -134,10 +130,6 @@ export function DialogConnectProvider(props: { provider: string }) {
dispatch({ type: "method.select", index })
if (method.type === "oauth") {
- if (method.prompts?.length && !inputs) {
- dispatch({ type: "auth.prompt" })
- return
- }
dispatch({ type: "auth.pending" })
const start = Date.now()
await globalSDK.client.provider.oauth
@@ -145,7 +137,6 @@ export function DialogConnectProvider(props: { provider: string }) {
{
providerID: props.provider,
method: index,
- inputs,
},
{ throwOnError: true },
)
@@ -172,122 +163,6 @@ export function DialogConnectProvider(props: { provider: string }) {
}
}
- function OAuthPromptsView() {
- const [formStore, setFormStore] = createStore({
- value: {} as Record,
- index: 0,
- })
-
- const prompts = createMemo(() => method()?.prompts ?? [])
- const matches = (prompt: NonNullable[number]>, value: Record) => {
- if (!prompt.when) return true
- const actual = value[prompt.when.key]
- if (actual === undefined) return false
- return prompt.when.op === "eq" ? actual === prompt.when.value : actual !== prompt.when.value
- }
- const current = createMemo(() => {
- const all = prompts()
- const index = all.findIndex((prompt, index) => index >= formStore.index && matches(prompt, formStore.value))
- if (index === -1) return
- return {
- index,
- prompt: all[index],
- }
- })
- const valid = createMemo(() => {
- const item = current()
- if (!item || item.prompt.type !== "text") return false
- const value = formStore.value[item.prompt.key] ?? ""
- return value.trim().length > 0
- })
-
- async function next(index: number, value: Record) {
- if (store.methodIndex === undefined) return
- const next = prompts().findIndex((prompt, i) => i > index && matches(prompt, value))
- if (next !== -1) {
- setFormStore("index", next)
- return
- }
- await selectMethod(store.methodIndex, value)
- }
-
- async function handleSubmit(e: SubmitEvent) {
- e.preventDefault()
- const item = current()
- if (!item || item.prompt.type !== "text") return
- if (!valid()) return
- await next(item.index, formStore.value)
- }
-
- const item = () => current()
- const text = createMemo(() => {
- const prompt = item()?.prompt
- if (!prompt || prompt.type !== "text") return
- return prompt
- })
- const select = createMemo(() => {
- const prompt = item()?.prompt
- if (!prompt || prompt.type !== "select") return
- return prompt
- })
-
- return (
-
- )
- }
-
let listRef: ListRef | undefined
function handleKey(e: KeyboardEvent) {
if (e.key === "Enter" && e.target instanceof HTMLInputElement) {
@@ -426,7 +301,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error}
/>
- {language.t("common.continue")}
+ {language.t("common.submit")}
@@ -439,6 +314,12 @@ export function DialogConnectProvider(props: { provider: string }) {
error: undefined as string | undefined,
})
+ onMount(() => {
+ if (store.authorization?.method === "code" && store.authorization?.url) {
+ platform.openLink(store.authorization.url)
+ }
+ })
+
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
@@ -487,7 +368,7 @@ export function DialogConnectProvider(props: { provider: string }) {
error={formStore.error}
/>
- {language.t("common.continue")}
+ {language.t("common.submit")}
@@ -505,6 +386,10 @@ export function DialogConnectProvider(props: { provider: string }) {
onMount(() => {
void (async () => {
+ if (store.authorization?.url) {
+ platform.openLink(store.authorization.url)
+ }
+
const result = await globalSDK.client.provider.oauth
.callback({
providerID: props.provider,
@@ -585,9 +470,6 @@ export function DialogConnectProvider(props: { provider: string }) {
-
-
-
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx
index f8d14cbb943..eb039c14d61 100644
--- a/packages/app/src/components/dialog-select-server.tsx
+++ b/packages/app/src/components/dialog-select-server.tsx
@@ -291,8 +291,8 @@ export function DialogSelectServer() {
navigate("/")
return
}
+ server.setActive(ServerConnection.key(conn))
navigate("/")
- queueMicrotask(() => server.setActive(ServerConnection.key(conn)))
}
const handleAddChange = (value: string) => {
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 55cfaa490f3..5c25235c65c 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -1241,20 +1241,6 @@ export const PromptInput: Component
= (props) => {
// Note: Shift+Enter is handled earlier, before IME check
if (event.key === "Enter" && !event.shiftKey) {
- event.preventDefault()
- if (event.repeat) return
- if (
- working() &&
- prompt
- .current()
- .map((part) => ("content" in part ? part.content : ""))
- .join("")
- .trim().length === 0 &&
- imageAttachments().length === 0 &&
- commentCount() === 0
- ) {
- return
- }
handleSubmit(event)
}
}
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx
index 063205f0c30..61facb84eb5 100644
--- a/packages/app/src/components/status-popover.tsx
+++ b/packages/app/src/components/status-popover.tsx
@@ -277,8 +277,8 @@ export function StatusPopover() {
aria-disabled={isBlocked()}
onClick={() => {
if (isBlocked()) return
+ server.setActive(key)
navigate("/")
- queueMicrotask(() => server.setActive(key))
}}
>
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx
index aed46f12623..9297d66266e 100644
--- a/packages/app/src/components/terminal.tsx
+++ b/packages/app/src/components/terminal.tsx
@@ -165,12 +165,6 @@ export const Terminal = (props: TerminalProps) => {
const theme = useTheme()
const language = useLanguage()
const server = useServer()
- const directory = sdk.directory
- const client = sdk.client
- const url = sdk.url
- const auth = server.current?.http
- const username = auth?.username ?? "opencode"
- const password = auth?.password ?? ""
let container!: HTMLDivElement
const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
const id = local.pty.id
@@ -221,7 +215,7 @@ export const Terminal = (props: TerminalProps) => {
}
const pushSize = (cols: number, rows: number) => {
- return client.pty
+ return sdk.client.pty
.update({
ptyID: id,
size: { cols, rows },
@@ -480,7 +474,7 @@ export const Terminal = (props: TerminalProps) => {
}
const gone = () =>
- client.pty
+ sdk.client.pty
.get({ ptyID: id })
.then(() => false)
.catch((err) => {
@@ -512,14 +506,14 @@ export const Terminal = (props: TerminalProps) => {
if (disposed) return
drop?.()
- const next = new URL(url + `/pty/${id}/connect`)
- next.searchParams.set("directory", directory)
- next.searchParams.set("cursor", String(seek))
- next.protocol = next.protocol === "https:" ? "wss:" : "ws:"
- next.username = username
- next.password = password
+ const url = new URL(sdk.url + `/pty/${id}/connect`)
+ url.searchParams.set("directory", sdk.directory)
+ url.searchParams.set("cursor", String(seek))
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
+ url.username = server.current?.http.username ?? "opencode"
+ url.password = server.current?.http.password ?? ""
- const socket = new WebSocket(next)
+ const socket = new WebSocket(url)
socket.binaryType = "arraybuffer"
ws = socket
diff --git a/packages/app/src/context/open-file-path.tsx b/packages/app/src/context/open-file-path.tsx
new file mode 100644
index 00000000000..4ec302d2b4e
--- /dev/null
+++ b/packages/app/src/context/open-file-path.tsx
@@ -0,0 +1,20 @@
+import { createSimpleContext } from "@opencode-ai/ui/context"
+import { usePlatform } from "@/context/platform"
+import { openFilePath, type OpenFileInput } from "@/utils/open-file-path"
+
+export const { use: useOpenFilePath, provider: OpenFilePathProvider } = createSimpleContext({
+ name: "OpenFilePath",
+ init: (props: { directory: string }) => {
+ const platform = usePlatform()
+
+ return {
+ open(input: OpenFileInput) {
+ return openFilePath({
+ directory: props.directory,
+ input,
+ platform,
+ })
+ },
+ }
+ },
+})
diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx
index 17355aab9ab..e65c1678846 100644
--- a/packages/app/src/context/terminal.tsx
+++ b/packages/app/src/context/terminal.tsx
@@ -185,60 +185,6 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str
})
onCleanup(unsub)
- const update = (client: ReturnType["client"], pty: Partial & { id: string }) => {
- const index = store.all.findIndex((x) => x.id === pty.id)
- const previous = index >= 0 ? store.all[index] : undefined
- if (index >= 0) {
- setStore("all", index, (item) => ({ ...item, ...pty }))
- }
- client.pty
- .update({
- ptyID: pty.id,
- title: pty.title,
- size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
- })
- .catch((error: unknown) => {
- if (previous) {
- const currentIndex = store.all.findIndex((item) => item.id === pty.id)
- if (currentIndex >= 0) setStore("all", currentIndex, previous)
- }
- console.error("Failed to update terminal", error)
- })
- }
-
- const clone = async (client: ReturnType["client"], id: string) => {
- const index = store.all.findIndex((x) => x.id === id)
- const pty = store.all[index]
- if (!pty) return
- const next = await client.pty
- .create({
- title: pty.title,
- })
- .catch((error: unknown) => {
- console.error("Failed to clone terminal", error)
- return undefined
- })
- if (!next?.data) return
-
- const active = store.active === pty.id
-
- batch(() => {
- setStore("all", index, {
- id: next.data.id,
- title: next.data.title ?? pty.title,
- titleNumber: pty.titleNumber,
- buffer: undefined,
- cursor: undefined,
- scrollY: undefined,
- rows: undefined,
- cols: undefined,
- })
- if (active) {
- setStore("active", next.data.id)
- }
- })
- }
-
return {
ready,
all: createMemo(() => store.all),
@@ -270,7 +216,24 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str
})
},
update(pty: Partial & { id: string }) {
- update(sdk.client, pty)
+ const index = store.all.findIndex((x) => x.id === pty.id)
+ const previous = index >= 0 ? store.all[index] : undefined
+ if (index >= 0) {
+ setStore("all", index, (item) => ({ ...item, ...pty }))
+ }
+ sdk.client.pty
+ .update({
+ ptyID: pty.id,
+ title: pty.title,
+ size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined,
+ })
+ .catch((error: unknown) => {
+ if (previous) {
+ const currentIndex = store.all.findIndex((item) => item.id === pty.id)
+ if (currentIndex >= 0) setStore("all", currentIndex, previous)
+ }
+ console.error("Failed to update terminal", error)
+ })
},
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -285,23 +248,37 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str
})
},
async clone(id: string) {
- await clone(sdk.client, id)
- },
- bind() {
- const client = sdk.client
- return {
- trim(id: string) {
- const index = store.all.findIndex((x) => x.id === id)
- if (index === -1) return
- setStore("all", index, (pty) => trimTerminal(pty))
- },
- update(pty: Partial & { id: string }) {
- update(client, pty)
- },
- async clone(id: string) {
- await clone(client, id)
- },
- }
+ const index = store.all.findIndex((x) => x.id === id)
+ const pty = store.all[index]
+ if (!pty) return
+ const clone = await sdk.client.pty
+ .create({
+ title: pty.title,
+ })
+ .catch((error: unknown) => {
+ console.error("Failed to clone terminal", error)
+ return undefined
+ })
+ if (!clone?.data) return
+
+ const active = store.active === pty.id
+
+ batch(() => {
+ setStore("all", index, {
+ id: clone.data.id,
+ title: clone.data.title ?? pty.title,
+ titleNumber: pty.titleNumber,
+ // New PTY process, so start clean.
+ buffer: undefined,
+ cursor: undefined,
+ scrollY: undefined,
+ rows: undefined,
+ cols: undefined,
+ })
+ if (active) {
+ setStore("active", clone.data.id)
+ }
+ })
},
open(id: string) {
setStore("active", id)
@@ -426,7 +403,6 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id),
- bind: () => workspace(),
open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to),
diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts
index c8f58c796e6..720045a4d1c 100644
--- a/packages/app/src/i18n/ar.ts
+++ b/packages/app/src/i18n/ar.ts
@@ -204,7 +204,6 @@ export const dict = {
"common.cancel": "إلغاء",
"common.connect": "اتصال",
"common.disconnect": "قطع الاتصال",
- "common.continue": "إرسال",
"common.submit": "إرسال",
"common.save": "حفظ",
"common.saving": "جارٍ الحفظ...",
diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts
index 3112e91bbea..a7d7433b02c 100644
--- a/packages/app/src/i18n/br.ts
+++ b/packages/app/src/i18n/br.ts
@@ -204,7 +204,6 @@ export const dict = {
"common.cancel": "Cancelar",
"common.connect": "Conectar",
"common.disconnect": "Desconectar",
- "common.continue": "Enviar",
"common.submit": "Enviar",
"common.save": "Salvar",
"common.saving": "Salvando...",
diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts
index f2dbd8493c6..ccdf2b6044d 100644
--- a/packages/app/src/i18n/bs.ts
+++ b/packages/app/src/i18n/bs.ts
@@ -221,7 +221,6 @@ export const dict = {
"common.cancel": "Otkaži",
"common.connect": "Poveži",
"common.disconnect": "Prekini vezu",
- "common.continue": "Pošalji",
"common.submit": "Pošalji",
"common.save": "Sačuvaj",
"common.saving": "Čuvanje...",
diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts
index e90e1071ad5..f1701094b56 100644
--- a/packages/app/src/i18n/da.ts
+++ b/packages/app/src/i18n/da.ts
@@ -219,7 +219,6 @@ export const dict = {
"common.cancel": "Annuller",
"common.connect": "Forbind",
"common.disconnect": "Frakobl",
- "common.continue": "Indsend",
"common.submit": "Indsend",
"common.save": "Gem",
"common.saving": "Gemmer...",
diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts
index 69658b29e9a..2dfeed72032 100644
--- a/packages/app/src/i18n/de.ts
+++ b/packages/app/src/i18n/de.ts
@@ -209,7 +209,6 @@ export const dict = {
"common.cancel": "Abbrechen",
"common.connect": "Verbinden",
"common.disconnect": "Trennen",
- "common.continue": "Absenden",
"common.submit": "Absenden",
"common.save": "Speichern",
"common.saving": "Speichert...",
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 72caed40ad9..7f6816de9e3 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -221,7 +221,6 @@ export const dict = {
"common.open": "Open",
"common.connect": "Connect",
"common.disconnect": "Disconnect",
- "common.continue": "Continue",
"common.submit": "Submit",
"common.save": "Save",
"common.saving": "Saving...",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 9e36e4de6db..1cd47dfc796 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -220,7 +220,6 @@ export const dict = {
"common.cancel": "Cancelar",
"common.connect": "Conectar",
"common.disconnect": "Desconectar",
- "common.continue": "Enviar",
"common.submit": "Enviar",
"common.save": "Guardar",
"common.saving": "Guardando...",
diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts
index f53b3882c6d..c7d89c3251b 100644
--- a/packages/app/src/i18n/fr.ts
+++ b/packages/app/src/i18n/fr.ts
@@ -204,7 +204,6 @@ export const dict = {
"common.cancel": "Annuler",
"common.connect": "Connecter",
"common.disconnect": "Déconnecter",
- "common.continue": "Soumettre",
"common.submit": "Soumettre",
"common.save": "Enregistrer",
"common.saving": "Enregistrement...",
diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts
index d66a7341d5a..267411083f4 100644
--- a/packages/app/src/i18n/ja.ts
+++ b/packages/app/src/i18n/ja.ts
@@ -203,7 +203,6 @@ export const dict = {
"common.cancel": "キャンセル",
"common.connect": "接続",
"common.disconnect": "切断",
- "common.continue": "送信",
"common.submit": "送信",
"common.save": "保存",
"common.saving": "保存中...",
diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts
index d534c27e8fb..bb57f99396b 100644
--- a/packages/app/src/i18n/ko.ts
+++ b/packages/app/src/i18n/ko.ts
@@ -207,7 +207,6 @@ export const dict = {
"common.cancel": "취소",
"common.connect": "연결",
"common.disconnect": "연결 해제",
- "common.continue": "제출",
"common.submit": "제출",
"common.save": "저장",
"common.saving": "저장 중...",
diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts
index c23d0a27927..83d6a9903b6 100644
--- a/packages/app/src/i18n/no.ts
+++ b/packages/app/src/i18n/no.ts
@@ -223,7 +223,6 @@ export const dict = {
"common.cancel": "Avbryt",
"common.connect": "Koble til",
"common.disconnect": "Koble fra",
- "common.continue": "Send inn",
"common.submit": "Send inn",
"common.save": "Lagre",
"common.saving": "Lagrer...",
diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts
index dac847b217f..db9ef18003e 100644
--- a/packages/app/src/i18n/pl.ts
+++ b/packages/app/src/i18n/pl.ts
@@ -205,7 +205,6 @@ export const dict = {
"common.cancel": "Anuluj",
"common.connect": "Połącz",
"common.disconnect": "Rozłącz",
- "common.continue": "Prześlij",
"common.submit": "Prześlij",
"common.save": "Zapisz",
"common.saving": "Zapisywanie...",
diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts
index 684d5deecd0..e1abb6e6cf6 100644
--- a/packages/app/src/i18n/ru.ts
+++ b/packages/app/src/i18n/ru.ts
@@ -220,7 +220,6 @@ export const dict = {
"common.cancel": "Отмена",
"common.connect": "Подключить",
"common.disconnect": "Отключить",
- "common.continue": "Отправить",
"common.submit": "Отправить",
"common.save": "Сохранить",
"common.saving": "Сохранение...",
diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts
index 80f0da94ec6..b522e4631b9 100644
--- a/packages/app/src/i18n/th.ts
+++ b/packages/app/src/i18n/th.ts
@@ -220,7 +220,6 @@ export const dict = {
"common.cancel": "ยกเลิก",
"common.connect": "เชื่อมต่อ",
"common.disconnect": "ยกเลิกการเชื่อมต่อ",
- "common.continue": "ส่ง",
"common.submit": "ส่ง",
"common.save": "บันทึก",
"common.saving": "กำลังบันทึก...",
diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts
index 9041e0dd07f..8542dff799b 100644
--- a/packages/app/src/i18n/tr.ts
+++ b/packages/app/src/i18n/tr.ts
@@ -225,7 +225,6 @@ export const dict = {
"common.cancel": "İptal",
"common.connect": "Bağlan",
"common.disconnect": "Bağlantı Kes",
- "common.continue": "Gönder",
"common.submit": "Gönder",
"common.save": "Kaydet",
"common.saving": "Kaydediliyor...",
diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts
index cf64ca9b2c5..e762ba78d9c 100644
--- a/packages/app/src/i18n/zh.ts
+++ b/packages/app/src/i18n/zh.ts
@@ -242,7 +242,6 @@ export const dict = {
"common.cancel": "取消",
"common.connect": "连接",
"common.disconnect": "断开连接",
- "common.continue": "提交",
"common.submit": "提交",
"common.save": "保存",
"common.saving": "保存中...",
diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts
index 02c00d17a22..184c789ce36 100644
--- a/packages/app/src/i18n/zht.ts
+++ b/packages/app/src/i18n/zht.ts
@@ -220,7 +220,6 @@ export const dict = {
"common.cancel": "取消",
"common.connect": "連線",
"common.disconnect": "中斷連線",
- "common.continue": "提交",
"common.submit": "提交",
"common.save": "儲存",
"common.saving": "儲存中...",
diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx
index cd5e079a69a..96768123fed 100644
--- a/packages/app/src/pages/directory-layout.tsx
+++ b/packages/app/src/pages/directory-layout.tsx
@@ -1,19 +1,23 @@
-import { DataProvider } from "@opencode-ai/ui/context"
-import { showToast } from "@opencode-ai/ui/toast"
-import { base64Encode } from "@opencode-ai/util/encode"
+import { batch, createEffect, createMemo, Show, type ParentProps } from "solid-js"
+import { createStore } from "solid-js/store"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
-import { createMemo, createResource, type ParentProps, Show } from "solid-js"
-import { useGlobalSDK } from "@/context/global-sdk"
-import { useLanguage } from "@/context/language"
-import { LocalProvider } from "@/context/local"
import { SDKProvider } from "@/context/sdk"
import { SyncProvider, useSync } from "@/context/sync"
+import { LocalProvider } from "@/context/local"
+import { useGlobalSDK } from "@/context/global-sdk"
+import { OpenFilePathProvider, useOpenFilePath } from "@/context/open-file-path"
+
+import { DataProvider } from "@opencode-ai/ui/context"
+import { base64Encode } from "@opencode-ai/util/encode"
import { decode64 } from "@/utils/base64"
+import { showToast } from "@opencode-ai/ui/toast"
+import { useLanguage } from "@/context/language"
function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
const navigate = useNavigate()
const sync = useSync()
const slug = createMemo(() => base64Encode(props.directory))
+ const open = useOpenFilePath()
return (
) {
directory={props.directory}
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
+ onOpenFilePath={open.open}
>
{props.children}
@@ -29,57 +34,63 @@ function DirectoryDataProvider(props: ParentProps<{ directory: string }>) {
export default function Layout(props: ParentProps) {
const params = useParams()
+ const navigate = useNavigate()
const location = useLocation()
const language = useLanguage()
const globalSDK = useGlobalSDK()
- const navigate = useNavigate()
- let invalid = ""
+ const directory = createMemo(() => decode64(params.dir) ?? "")
+ const [state, setState] = createStore({ invalid: "", resolved: "" })
- const [resolved] = createResource(
- () => {
- if (params.dir) return [location.pathname, params.dir] as const
- },
- async ([pathname, b64Dir]) => {
- const directory = decode64(b64Dir)
+ createEffect(() => {
+ if (!params.dir) return
+ const raw = directory()
+ if (!raw) {
+ if (state.invalid === params.dir) return
+ setState("invalid", params.dir)
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: language.t("directory.error.invalidUrl"),
+ })
+ navigate("/", { replace: true })
+ return
+ }
- if (!directory) {
- if (invalid === params.dir) return
- invalid = b64Dir
- showToast({
- variant: "error",
- title: language.t("common.requestFailed"),
- description: language.t("directory.error.invalidUrl"),
+ const current = params.dir
+ globalSDK
+ .createClient({
+ directory: raw,
+ throwOnError: true,
+ })
+ .path.get()
+ .then((x) => {
+ if (params.dir !== current) return
+ const next = x.data?.directory ?? raw
+ batch(() => {
+ setState("invalid", "")
+ setState("resolved", next)
})
- navigate("/", { replace: true })
- return
- }
-
- return await globalSDK
- .createClient({
- directory,
- throwOnError: true,
- })
- .path.get()
- .then((x) => {
- const next = x.data?.directory ?? directory
- invalid = ""
- if (next === directory) return next
- const path = pathname.slice(b64Dir.length + 1)
- navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
+ if (next === raw) return
+ const path = location.pathname.slice(current.length + 1)
+ navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
+ })
+ .catch(() => {
+ if (params.dir !== current) return
+ batch(() => {
+ setState("invalid", "")
+ setState("resolved", raw)
})
- .catch(() => {
- invalid = ""
- return directory
- })
- },
- )
+ })
+ })
return (
-
+
{(resolved) => (
resolved}>
- {props.children}
+
+ {props.children}
+
)}
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index 52ac7c5f376..c84c7272d67 100644
--- a/packages/app/src/pages/layout.tsx
+++ b/packages/app/src/pages/layout.tsx
@@ -543,14 +543,13 @@ export default function Layout(props: ParentProps) {
const currentProject = createMemo(() => {
const directory = currentDir()
if (!directory) return
- const key = workspaceKey(directory)
const projects = layout.projects.list()
- const sandbox = projects.find((p) => p.sandboxes?.some((item) => workspaceKey(item) === key))
+ const sandbox = projects.find((p) => p.sandboxes?.includes(directory))
if (sandbox) return sandbox
- const direct = projects.find((p) => workspaceKey(p.worktree) === key)
+ const direct = projects.find((p) => p.worktree === directory)
if (direct) return direct
const [child] = globalSync.child(directory, { bootstrap: false })
@@ -567,7 +566,6 @@ export default function Layout(props: ParentProps) {
const [autoselecting] = createResource(async () => {
await ready.promise
await layout.ready.promise
- if (!untrack(() => state.autoselect)) return
const list = layout.projects.list()
const last = server.projects.last()
@@ -631,11 +629,7 @@ export default function Layout(props: ParentProps) {
const projects = layout.projects.list()
for (const [directory, expanded] of Object.entries(store.workspaceExpanded)) {
if (!expanded) continue
- const key = workspaceKey(directory)
- const project = projects.find(
- (item) =>
- workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
- )
+ const project = projects.find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
if (!project) continue
if (project.vcs === "git" && layout.sidebar.workspaces(project.worktree)()) continue
setStore("workspaceExpanded", directory, false)
@@ -1160,17 +1154,13 @@ export default function Layout(props: ParentProps) {
}
function projectRoot(directory: string) {
- const key = workspaceKey(directory)
const project = layout.projects
.list()
- .find(
- (item) =>
- workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
- )
+ .find((item) => item.worktree === directory || item.sandboxes?.includes(directory))
if (project) return project.worktree
const known = Object.entries(store.workspaceOrder).find(
- ([root, dirs]) => workspaceKey(root) === key || dirs.some((item) => workspaceKey(item) === key),
+ ([root, dirs]) => root === directory || dirs.includes(directory),
)
if (known) return known[0]
@@ -1186,6 +1176,13 @@ export default function Layout(props: ParentProps) {
return currentProject()?.worktree ?? projectRoot(directory)
}
+ function touchProjectRoute() {
+ const root = currentProject()?.worktree
+ if (!root) return
+ if (server.projects.last() !== root) server.projects.touch(root)
+ return root
+ }
+
function rememberSessionRoute(directory: string, id: string, root = activeProjectRoot(directory)) {
setStore("lastProjectSession", root, { directory, id, at: Date.now() })
return root
@@ -1349,9 +1346,8 @@ export default function Layout(props: ParentProps) {
function closeProject(directory: string) {
const list = layout.projects.list()
- const key = workspaceKey(directory)
- const index = list.findIndex((x) => workspaceKey(x.worktree) === key)
- const active = workspaceKey(currentProject()?.worktree ?? "") === key
+ const index = list.findIndex((x) => x.worktree === directory)
+ const active = currentProject()?.worktree === directory
if (index === -1) return
const next = list[index + 1]
@@ -1686,55 +1682,38 @@ export default function Layout(props: ParentProps) {
const activeRoute = {
session: "",
sessionProject: "",
- directory: "",
}
createEffect(
on(
- () => {
- const dir = params.dir
- const directory = dir ? decode64(dir) : undefined
- const resolved = directory ? globalSync.child(directory, { bootstrap: false })[0].path.directory : ""
- return [pageReady(), dir, params.id, currentProject()?.worktree, directory, resolved] as const
- },
- ([ready, dir, id, root, directory, resolved]) => {
- if (!ready || !dir || !directory) {
+ () => [pageReady(), params.dir, params.id, currentProject()?.worktree] as const,
+ ([ready, dir, id]) => {
+ if (!ready || !dir) {
activeRoute.session = ""
activeRoute.sessionProject = ""
- activeRoute.directory = ""
return
}
+ const directory = decode64(dir)
+ if (!directory) return
+
+ const root = touchProjectRoute() ?? activeProjectRoot(directory)
+
if (!id) {
activeRoute.session = ""
activeRoute.sessionProject = ""
- activeRoute.directory = ""
return
}
- const next = resolved || directory
const session = `${dir}/${id}`
-
- if (!root) {
- activeRoute.session = session
- activeRoute.directory = next
- activeRoute.sessionProject = ""
- return
- }
-
- if (server.projects.last() !== root) server.projects.touch(root)
-
- const changed = session !== activeRoute.session || next !== activeRoute.directory
- if (changed) {
+ if (session !== activeRoute.session) {
activeRoute.session = session
- activeRoute.directory = next
- activeRoute.sessionProject = syncSessionRoute(next, id, root)
+ activeRoute.sessionProject = syncSessionRoute(directory, id, root)
return
}
if (root === activeRoute.sessionProject) return
- activeRoute.directory = next
- activeRoute.sessionProject = rememberSessionRoute(next, id, root)
+ activeRoute.sessionProject = rememberSessionRoute(directory, id, root)
},
),
)
@@ -1798,13 +1777,8 @@ export default function Layout(props: ParentProps) {
const local = project.worktree
const dirs = [local, ...(project.sandboxes ?? [])]
const active = currentProject()
- const directory = workspaceKey(active?.worktree ?? "") === workspaceKey(project.worktree) ? currentDir() : undefined
- const extra =
- directory &&
- workspaceKey(directory) !== workspaceKey(local) &&
- !dirs.some((item) => workspaceKey(item) === workspaceKey(directory))
- ? directory
- : undefined
+ const directory = active?.worktree === project.worktree ? currentDir() : undefined
+ const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts
index 1fe52d47a0a..9dbc6c72d2f 100644
--- a/packages/app/src/pages/layout/helpers.test.ts
+++ b/packages/app/src/pages/layout/helpers.test.ts
@@ -104,14 +104,14 @@ describe("layout deep links", () => {
describe("layout workspace helpers", () => {
test("normalizes trailing slash in workspace key", () => {
expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo")
- expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo")
+ expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:\\tmp\\demo")
})
test("preserves posix and drive roots in workspace key", () => {
expect(workspaceKey("/")).toBe("/")
expect(workspaceKey("///")).toBe("/")
- expect(workspaceKey("C:\\")).toBe("C:/")
- expect(workspaceKey("C://")).toBe("C:/")
+ expect(workspaceKey("C:\\")).toBe("C:\\")
+ expect(workspaceKey("C:\\\\\\")).toBe("C:\\")
expect(workspaceKey("C:///")).toBe("C:/")
})
diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts
index 209cff8a7ce..be4ce9f5742 100644
--- a/packages/app/src/pages/layout/helpers.ts
+++ b/packages/app/src/pages/layout/helpers.ts
@@ -1,17 +1,11 @@
import { getFilename } from "@opencode-ai/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client"
-type SessionStore = {
- session?: Session[]
- path: { directory: string }
-}
-
export const workspaceKey = (directory: string) => {
- const value = directory.replaceAll("\\", "/")
- const drive = value.match(/^([A-Za-z]:)\/+$/)
- if (drive) return `${drive[1]}/`
- if (/^\/+$/i.test(value)) return "/"
- return value.replace(/\/+$/, "")
+ const drive = directory.match(/^([A-Za-z]:)[\\/]+$/)
+ if (drive) return `${drive[1]}${directory.includes("\\") ? "\\" : "/"}`
+ if (/^[\\/]+$/.test(directory)) return directory.includes("\\") ? "\\" : "/"
+ return directory.replace(/[\\/]+$/, "")
}
function sortSessions(now: number) {
@@ -31,13 +25,13 @@ function sortSessions(now: number) {
const isRootVisibleSession = (session: Session, directory: string) =>
workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived
-const roots = (store: SessionStore) =>
- (store.session ?? []).filter((session) => isRootVisibleSession(session, store.path.directory))
-
-export const sortedRootSessions = (store: SessionStore, now: number) => roots(store).sort(sortSessions(now))
+export const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) =>
+ store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).sort(sortSessions(now))
-export const latestRootSession = (stores: SessionStore[], now: number) =>
- stores.flatMap(roots).sort(sortSessions(now))[0]
+export const latestRootSession = (stores: { session: Session[]; path: { directory: string } }[], now: number) =>
+ stores
+ .flatMap((store) => store.session.filter((session) => isRootVisibleSession(session, store.path.directory)))
+ .sort(sortSessions(now))[0]
export function hasProjectPermissions(
request: Record,
@@ -46,9 +40,9 @@ export function hasProjectPermissions(
return Object.values(request).some((list) => list?.some(include))
}
-export const childMapByParent = (sessions: Session[] | undefined) => {
+export const childMapByParent = (sessions: Session[]) => {
const map = new Map()
- for (const session of sessions ?? []) {
+ for (const session of sessions) {
if (!session.parentID) continue
const existing = map.get(session.parentID)
if (existing) {
diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx
index 127626febef..86ede774e63 100644
--- a/packages/app/src/pages/layout/sidebar-workspace.tsx
+++ b/packages/app/src/pages/layout/sidebar-workspace.tsx
@@ -332,13 +332,12 @@ export const SortableWorkspace = (props: {
const open = createMemo(() => props.ctx.workspaceExpanded(props.directory, local()))
const boot = createMemo(() => open() || active())
const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false)
- const count = createMemo(() => sessions()?.length ?? 0)
- const hasMore = createMemo(() => workspaceStore.sessionTotal > count())
+ const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length)
const busy = createMemo(() => props.ctx.isBusy(props.directory))
const wasBusy = createMemo((prev) => prev || busy(), false)
- const loading = createMemo(() => open() && !booted() && count() === 0 && !wasBusy())
+ const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy())
const touch = createMediaQuery("(hover: none)")
- const showNew = createMemo(() => !loading() && (touch() || count() === 0 || (active() && !params.id)))
+ const showNew = createMemo(() => !loading() && (touch() || sessions().length === 0 || (active() && !params.id)))
const loadMore = async () => {
setWorkspaceStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.directory)
@@ -473,9 +472,8 @@ export const LocalWorkspace = (props: {
const sessions = createMemo(() => sortedRootSessions(workspace().store, props.sortNow()))
const children = createMemo(() => childMapByParent(workspace().store.session))
const booted = createMemo((prev) => prev || workspace().store.status === "complete", false)
- const count = createMemo(() => sessions()?.length ?? 0)
- const loading = createMemo(() => !booted() && count() === 0)
- const hasMore = createMemo(() => workspace().store.sessionTotal > count())
+ const loading = createMemo(() => !booted() && sessions().length === 0)
+ const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length)
const loadMore = async () => {
workspace().setStore("limit", (limit) => (limit ?? 0) + 5)
await globalSync.project.loadSessions(props.project.worktree)
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 6d29170081a..025cfbe84ac 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -50,6 +50,7 @@ import { TerminalPanel } from "@/pages/session/terminal-panel"
import { useSessionCommands } from "@/pages/session/use-session-commands"
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
import { Identifier } from "@/utils/id"
+import { OPEN_FILE_PATH_EVENT } from "@/utils/open-file-path"
import { extractPromptFromParts } from "@/utils/prompt"
import { same } from "@/utils/same"
import { formatServerError } from "@/utils/server-errors"
@@ -910,12 +911,34 @@ export default function Page() {
const openReviewFile = createOpenReviewFile({
showAllFiles,
+ openReviewPanel,
tabForPath: file.tab,
openTab: tabs().open,
setActive: tabs().setActive,
+ getFile: file.get,
+ setSelectedLines: file.setSelectedLines,
+ onLineError: ({ path, line, max }) => {
+ showToast({
+ variant: "default",
+ title: "Line unavailable",
+ description: `${path}:${line} is out of range, opened line ${max} instead.`,
+ })
+ },
loadFile: file.load,
})
+ onMount(() => {
+ const open = (event: Event) => {
+ const detail = (event as CustomEvent<{ path?: string; line?: number }>).detail
+ const path = detail?.path
+ if (!path) return
+ openReviewFile(path, detail?.line)
+ }
+
+ window.addEventListener(OPEN_FILE_PATH_EVENT, open)
+ onCleanup(() => window.removeEventListener(OPEN_FILE_PATH_EVENT, open))
+ })
+
const changesOptions = ["session", "turn"] as const
const changesOptionsList = [...changesOptions]
diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts
index 047946fc1ef..811c999fe9e 100644
--- a/packages/app/src/pages/session/helpers.test.ts
+++ b/packages/app/src/pages/session/helpers.test.ts
@@ -14,18 +14,66 @@ describe("createOpenReviewFile", () => {
const calls: string[] = []
const openReviewFile = createOpenReviewFile({
showAllFiles: () => calls.push("show"),
+ openReviewPanel: () => calls.push("review"),
tabForPath: (path) => {
calls.push(`tab:${path}`)
return `file://${path}`
},
openTab: (tab) => calls.push(`open:${tab}`),
setActive: (tab) => calls.push(`active:${tab}`),
+ getFile: () => ({ content: { content: "one\ntwo" } }),
+ setSelectedLines: (path, range) => calls.push(`select:${path}:${range ? `${range.start}-${range.end}` : "none"}`),
loadFile: (path) => calls.push(`load:${path}`),
})
openReviewFile("src/a.ts")
- expect(calls).toEqual(["show", "load:src/a.ts", "tab:src/a.ts", "open:file://src/a.ts", "active:file://src/a.ts"])
+ expect(calls).toEqual([
+ "tab:src/a.ts",
+ "show",
+ "review",
+ "load:src/a.ts",
+ "open:file://src/a.ts",
+ "active:file://src/a.ts",
+ "select:src/a.ts:none",
+ ])
+ })
+
+ test("selects the requested line when provided", () => {
+ const calls: string[] = []
+ const openReviewFile = createOpenReviewFile({
+ showAllFiles: () => calls.push("show"),
+ openReviewPanel: () => calls.push("review"),
+ tabForPath: (path) => `file://${path}`,
+ openTab: () => calls.push("open"),
+ setActive: () => calls.push("active"),
+ getFile: () => ({ content: { content: "one\n".repeat(20) } }),
+ setSelectedLines: (_path, range) => calls.push(`select:${range?.start}-${range?.end}`),
+ loadFile: () => calls.push("load"),
+ })
+
+ openReviewFile("src/a.ts", 12)
+
+ expect(calls).toContain("select:12-12")
+ })
+
+ test("clamps out of range lines", () => {
+ const calls: string[] = []
+ const openReviewFile = createOpenReviewFile({
+ showAllFiles: () => undefined,
+ openReviewPanel: () => undefined,
+ tabForPath: (path) => `file://${path}`,
+ openTab: () => undefined,
+ setActive: () => undefined,
+ getFile: () => ({ content: { content: "one\ntwo" } }),
+ setSelectedLines: (_path, range) => calls.push(`select:${range?.start}-${range?.end}`),
+ onLineError: ({ line, max }) => calls.push(`warn:${line}->${max}`),
+ loadFile: () => undefined,
+ })
+
+ openReviewFile("src/a.ts", 12)
+
+ expect(calls).toEqual(["warn:12->2", "select:2-2"])
})
})
diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts
index c3571f3ffce..9020d73dff5 100644
--- a/packages/app/src/pages/session/helpers.ts
+++ b/packages/app/src/pages/session/helpers.ts
@@ -98,19 +98,36 @@ export const createOpenReviewFile = (input: {
tabForPath: (path: string) => string
openTab: (tab: string) => void
setActive: (tab: string) => void
+ openReviewPanel: () => void
+ setSelectedLines: (path: string, range: { start: number; end: number } | null) => void
+ getFile: (path: string) => { content?: { content?: unknown } } | undefined
+ onLineError?: (input: { path: string; line: number; max: number }) => void
loadFile: (path: string) => any | Promise
}) => {
- return (path: string) => {
+ const lines = (value: unknown) => {
+ if (typeof value === "string") return Math.max(1, value.split("\n").length - (value.endsWith("\n") ? 1 : 0))
+ if (Array.isArray(value)) return Math.max(1, value.length)
+ if (value == null) return 0
+ const text = String(value)
+ return Math.max(1, text.split("\n").length - (text.endsWith("\n") ? 1 : 0))
+ }
+
+ return (path: string, line?: number) => {
+ const tab = input.tabForPath(path)
batch(() => {
input.showAllFiles()
+ input.openReviewPanel()
const maybePromise = input.loadFile(path)
- const open = () => {
- const tab = input.tabForPath(path)
+ const openTab = () => {
+ const max = lines(input.getFile(path)?.content?.content)
+ const next = typeof line === "number" && max > 0 ? Math.max(1, Math.min(line, max)) : undefined
+ if (typeof line === "number" && max > 0 && next !== line) input.onLineError?.({ path, line, max })
input.openTab(tab)
input.setActive(tab)
+ input.setSelectedLines(path, next ? { start: next, end: next } : null)
}
- if (maybePromise instanceof Promise) maybePromise.then(open)
- else open()
+ if (maybePromise instanceof Promise) maybePromise.then(openTab)
+ else openTab()
})
}
}
diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx
index c073e621472..cd943b54ef6 100644
--- a/packages/app/src/pages/session/review-tab.tsx
+++ b/packages/app/src/pages/session/review-tab.tsx
@@ -7,6 +7,7 @@ import type {
SessionReviewCommentUpdate,
} from "@opencode-ai/ui/session-review"
import type { SelectedLineRange } from "@/context/file"
+import { useOpenFilePath } from "@/context/open-file-path"
import { useSDK } from "@/context/sdk"
import { useLayout } from "@/context/layout"
import type { LineComment } from "@/context/comments"
@@ -45,6 +46,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
const sdk = useSDK()
const layout = useLayout()
+ const open = useOpenFilePath()
const readFile = async (path: string) => {
return sdk.client.file
@@ -156,6 +158,7 @@ export function SessionReviewTab(props: SessionReviewTabProps) {
diffStyle={props.diffStyle}
onDiffStyleChange={props.onDiffStyleChange}
onViewFile={props.onViewFile}
+ onOpenFile={(file) => open.open({ path: file })}
focusedFile={props.focusedFile}
readFile={readFile}
onLineComment={props.onLineComment}
diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx
index c663d7d671e..d62d91c1979 100644
--- a/packages/app/src/pages/session/terminal-panel.tsx
+++ b/packages/app/src/pages/session/terminal-panel.tsx
@@ -280,24 +280,21 @@ export function TerminalPanel() {
- {(id) => {
- const ops = terminal.bind()
- return (
- pty.id === id)}>
- {(pty) => (
-
- ops.trim(id)}
- onCleanup={ops.update}
- onConnectError={() => ops.clone(id)}
- />
-
- )}
-
- )
- }}
+ {(id) => (
+ pty.id === id)}>
+ {(pty) => (
+
+ terminal.trim(id)}
+ onCleanup={terminal.update}
+ onConnectError={() => terminal.clone(id)}
+ />
+
+ )}
+
+ )}
diff --git a/packages/app/src/utils/open-file-path.ts b/packages/app/src/utils/open-file-path.ts
new file mode 100644
index 00000000000..3d991683688
--- /dev/null
+++ b/packages/app/src/utils/open-file-path.ts
@@ -0,0 +1,33 @@
+import type { OpenFilePathFn } from "@opencode-ai/ui/context"
+import { showToast } from "@opencode-ai/ui/toast"
+import type { Platform } from "@/context/platform"
+
+export type OpenFileInput = Parameters
[0]
+
+export const OPEN_FILE_PATH_EVENT = "opencode:open-file-path"
+
+export function dispatchOpenFilePath(input: OpenFileInput) {
+ window.dispatchEvent(new CustomEvent(OPEN_FILE_PATH_EVENT, { detail: input }))
+}
+
+export function resolveOpenFilePath(dir: string, path: string) {
+ const file = path.replace(/^[\\/]+/, "")
+ const separator = dir.includes("\\") ? "\\" : "/"
+ return dir.endsWith(separator) ? dir + file : dir + separator + file
+}
+
+export async function openFilePath(opts: { directory: string; input: OpenFileInput; platform: Platform }) {
+ if (opts.platform.platform !== "desktop" || !opts.platform.openPath) {
+ dispatchOpenFilePath(opts.input)
+ return
+ }
+
+ await opts.platform.openPath(resolveOpenFilePath(opts.directory, opts.input.path)).catch((err) => {
+ showToast({
+ variant: "error",
+ title: "Open failed",
+ description: err instanceof Error ? err.message : String(err),
+ })
+ dispatchOpenFilePath(opts.input)
+ })
+}
diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json
index bcb80d9e692..2af3196e108 100644
--- a/packages/desktop-electron/package.json
+++ b/packages/desktop-electron/package.json
@@ -30,7 +30,7 @@
"@solid-primitives/storage": "catalog:",
"@solidjs/meta": "catalog:",
"@solidjs/router": "0.15.4",
- "effect": "catalog:",
+ "effect": "4.0.0-beta.31",
"electron-log": "^5",
"electron-store": "^10",
"electron-updater": "^6",
diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx
index 30e882e2375..6719c04bee2 100644
--- a/packages/desktop-electron/src/renderer/index.tsx
+++ b/packages/desktop-electron/src/renderer/index.tsx
@@ -152,12 +152,12 @@ const createPlatform = (): Platform => {
storage,
checkUpdate: async () => {
- if (!UPDATER_ENABLED()) return { updateAvailable: false }
+ if (!UPDATER_ENABLED) return { updateAvailable: false }
return window.api.checkUpdate()
},
update: async () => {
- if (!UPDATER_ENABLED()) return
+ if (!UPDATER_ENABLED) return
await window.api.installUpdate()
},
diff --git a/packages/desktop-electron/src/renderer/updater.ts b/packages/desktop-electron/src/renderer/updater.ts
index ba2b458ece7..fe9e601db8e 100644
--- a/packages/desktop-electron/src/renderer/updater.ts
+++ b/packages/desktop-electron/src/renderer/updater.ts
@@ -1,6 +1,6 @@
import { initI18n, t } from "./i18n"
-export const UPDATER_ENABLED = () => window.__OPENCODE__?.updaterEnabled ?? false
+export const UPDATER_ENABLED = window.__OPENCODE__?.updaterEnabled ?? false
export async function runUpdater({ alertOnFail }: { alertOnFail: boolean }) {
await initI18n()
diff --git a/packages/opencode/package.json b/packages/opencode/package.json
index 049573e3e52..4bdc3a963cd 100644
--- a/packages/opencode/package.json
+++ b/packages/opencode/package.json
@@ -6,7 +6,6 @@
"license": "MIT",
"private": true,
"scripts": {
- "prepare": "effect-language-service patch || true",
"typecheck": "tsgo --noEmit",
"test": "bun test --timeout 30000",
"build": "bun run script/build.ts",
@@ -82,6 +81,7 @@
"@ai-sdk/xai": "2.0.51",
"@aws-sdk/credential-providers": "3.993.0",
"@clack/prompts": "1.0.0-alpha.1",
+ "@effect/platform-node": "4.0.0-beta.31",
"@gitlab/gitlab-ai-provider": "3.6.0",
"@gitlab/opencode-gitlab-auth": "1.3.3",
"@hono/standard-validator": "0.1.5",
@@ -97,7 +97,6 @@
"@openrouter/ai-sdk-provider": "1.5.4",
"@opentui/core": "0.1.87",
"@opentui/solid": "0.1.87",
- "@effect/platform-node": "catalog:",
"@parcel/watcher": "2.5.1",
"@pierre/diffs": "catalog:",
"@solid-primitives/event-bus": "1.1.2",
diff --git a/packages/opencode/src/account/effect.ts b/packages/opencode/src/account/effect.ts
index 2f1304d505b..444676046e4 100644
--- a/packages/opencode/src/account/effect.ts
+++ b/packages/opencode/src/account/effect.ts
@@ -148,12 +148,6 @@ export namespace AccountEffect {
mapAccountServiceError("HTTP request failed"),
)
- const executeEffect = (request: Effect.Effect) =>
- request.pipe(
- Effect.flatMap((req) => http.execute(req)),
- mapAccountServiceError("HTTP request failed"),
- )
-
const resolveToken = Effect.fnUntraced(function* (row: AccountRow) {
const now = yield* Clock.currentTimeMillis
if (row.token_expiry && row.token_expiry > now) return row.access_token
@@ -296,7 +290,7 @@ export namespace AccountEffect {
})
const poll = Effect.fn("Account.poll")(function* (input: Login) {
- const response = yield* executeEffect(
+ const response = yield* executeEffectOk(
HttpClientRequest.post(`${input.server}/auth/device/token`).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.schemaBodyJson(DeviceTokenRequest)(
diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts
index e30d05e9350..b2dae0402ca 100644
--- a/packages/opencode/src/agent/agent.ts
+++ b/packages/opencode/src/agent/agent.ts
@@ -260,10 +260,7 @@ export namespace Agent {
return pipe(
await state(),
values(),
- sortBy(
- [(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"],
- [(x) => x.name, "asc"],
- ),
+ sortBy([(x) => (cfg.default_agent ? x.name === cfg.default_agent : x.name === "build"), "desc"]),
)
}
diff --git a/packages/opencode/src/bus/index.ts b/packages/opencode/src/bus/index.ts
index 625f2966227..edb093f1974 100644
--- a/packages/opencode/src/bus/index.ts
+++ b/packages/opencode/src/bus/index.ts
@@ -51,8 +51,8 @@ export namespace Bus {
})
const pending = []
for (const key of [def.type, "*"]) {
- const match = [...(state().subscriptions.get(key) ?? [])]
- for (const sub of match) {
+ const match = state().subscriptions.get(key)
+ for (const sub of match ?? []) {
pending.push(sub(payload))
}
}
diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts
index 3e3672926d0..8395d4628e4 100644
--- a/packages/opencode/src/cli/cmd/models.ts
+++ b/packages/opencode/src/cli/cmd/models.ts
@@ -51,7 +51,7 @@ export const ModelsCommand = cmd({
}
if (args.provider) {
- const provider = providers[ProviderID.make(args.provider)]
+ const provider = providers[args.provider]
if (!provider) {
UI.error(`Provider not found: ${args.provider}`)
return
diff --git a/packages/opencode/src/cli/cmd/providers.ts b/packages/opencode/src/cli/cmd/providers.ts
index 581809e90eb..a2b7c5be15e 100644
--- a/packages/opencode/src/cli/cmd/providers.ts
+++ b/packages/opencode/src/cli/cmd/providers.ts
@@ -349,6 +349,7 @@ export const ProvidersLoginCommand = cmd({
value: x.id,
hint: {
opencode: "recommended",
+ anthropic: "API key",
openai: "ChatGPT Plus/Pro or API key",
}[x.id],
})),
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 249a48d358b..c85426cc247 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -13,7 +13,6 @@ import { MessageID, PartID } from "@/session/schema"
import { createStore, produce } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
-import { assign } from "./part"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
@@ -644,7 +643,10 @@ export function Prompt(props: PromptProps) {
type: "text",
text: inputText,
},
- ...nonTextParts.map(assign),
+ ...nonTextParts.map((x) => ({
+ id: PartID.ascending(),
+ ...x,
+ })),
],
})
.catch(() => {})
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts
deleted file mode 100644
index 8cdcef60676..00000000000
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/part.ts
+++ /dev/null
@@ -1,16 +0,0 @@
-import { PartID } from "@/session/schema"
-import type { PromptInfo } from "./history"
-
-type Item = PromptInfo["parts"][number]
-
-export function strip(part: Item & { id: string; messageID: string; sessionID: string }): Item {
- const { id: _id, messageID: _messageID, sessionID: _sessionID, ...rest } = part
- return rest
-}
-
-export function assign(part: Item): Item & { id: PartID } {
- return {
- ...part,
- id: PartID.ascending(),
- }
-}
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx
index 742d51be228..62154cce563 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-fork-from-timeline.tsx
@@ -7,7 +7,6 @@ import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { useDialog } from "../../ui/dialog"
import type { PromptInfo } from "@tui/component/prompt/history"
-import { strip } from "@tui/component/prompt/part"
export function DialogForkFromTimeline(props: { sessionID: string; onMove: (messageID: string) => void }) {
const sync = useSync()
@@ -43,7 +42,7 @@ export function DialogForkFromTimeline(props: { sessionID: string; onMove: (mess
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
- if (part.type === "file") agg.parts.push(strip(part))
+ if (part.type === "file") agg.parts.push(part)
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx
index a51a6cfe585..ff17b5567eb 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/dialog-message.tsx
@@ -5,7 +5,6 @@ import { useSDK } from "@tui/context/sdk"
import { useRoute } from "@tui/context/route"
import { Clipboard } from "@tui/util/clipboard"
import type { PromptInfo } from "@tui/component/prompt/history"
-import { strip } from "@tui/component/prompt/part"
export function DialogMessage(props: {
messageID: string
@@ -41,7 +40,7 @@ export function DialogMessage(props: {
if (part.type === "text") {
if (!part.synthetic) agg.input += part.text
}
- if (part.type === "file") agg.parts.push(strip(part))
+ if (part.type === "file") agg.parts.push(part)
return agg
},
{ input: "", parts: [] as PromptInfo["parts"] },
diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts
index 16ab3c6d339..ad683303ca5 100644
--- a/packages/opencode/src/file/watcher.ts
+++ b/packages/opencode/src/file/watcher.ts
@@ -51,13 +51,6 @@ export namespace FileWatcher {
if (process.platform === "linux") return "inotify"
}
- function protecteds(dir: string) {
- return Protected.paths().filter((item) => {
- const rel = path.relative(dir, item)
- return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel)
- })
- }
-
export const hasNativeBinding = () => !!watcher()
export class Service extends ServiceMap.Service()("@opencode/FileWatcher") {}
@@ -112,7 +105,7 @@ export namespace FileWatcher {
const cfgIgnores = cfg.watcher?.ignore ?? []
if (yield* Flag.OPENCODE_EXPERIMENTAL_FILEWATCHER) {
- yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...protecteds(instance.directory)])
+ yield* subscribe(instance.directory, [...FileIgnore.PATTERNS, ...cfgIgnores, ...Protected.paths()])
}
if (instance.project.vcs === "git") {
diff --git a/packages/opencode/src/filesystem/index.ts b/packages/opencode/src/filesystem/index.ts
deleted file mode 100644
index d8f7d6053e7..00000000000
--- a/packages/opencode/src/filesystem/index.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import { NodeFileSystem } from "@effect/platform-node"
-import { dirname, join, relative, resolve as pathResolve } from "path"
-import { realpathSync } from "fs"
-import { lookup } from "mime-types"
-import { Effect, FileSystem, Layer, Schema, ServiceMap } from "effect"
-import type { PlatformError } from "effect/PlatformError"
-import { Glob } from "../util/glob"
-
-export namespace AppFileSystem {
- export class FileSystemError extends Schema.TaggedErrorClass()("FileSystemError", {
- method: Schema.String,
- cause: Schema.optional(Schema.Defect),
- }) {}
-
- export type Error = PlatformError | FileSystemError
-
- export interface Interface extends FileSystem.FileSystem {
- readonly isDir: (path: string) => Effect.Effect
- readonly isFile: (path: string) => Effect.Effect
- readonly readJson: (path: string) => Effect.Effect
- readonly writeJson: (path: string, data: unknown, mode?: number) => Effect.Effect
- readonly ensureDir: (path: string) => Effect.Effect
- readonly writeWithDirs: (path: string, content: string | Uint8Array, mode?: number) => Effect.Effect
- readonly findUp: (target: string, start: string, stop?: string) => Effect.Effect
- readonly up: (options: { targets: string[]; start: string; stop?: string }) => Effect.Effect
- readonly globUp: (pattern: string, start: string, stop?: string) => Effect.Effect
- readonly glob: (pattern: string, options?: Glob.Options) => Effect.Effect
- readonly globMatch: (pattern: string, filepath: string) => boolean
- }
-
- export class Service extends ServiceMap.Service()("@opencode/FileSystem") {}
-
- export const layer = Layer.effect(
- Service,
- Effect.gen(function* () {
- const fs = yield* FileSystem.FileSystem
-
- const isDir = Effect.fn("FileSystem.isDir")(function* (path: string) {
- const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
- return info?.type === "Directory"
- })
-
- const isFile = Effect.fn("FileSystem.isFile")(function* (path: string) {
- const info = yield* fs.stat(path).pipe(Effect.catch(() => Effect.void))
- return info?.type === "File"
- })
-
- const readJson = Effect.fn("FileSystem.readJson")(function* (path: string) {
- const text = yield* fs.readFileString(path)
- return JSON.parse(text)
- })
-
- const writeJson = Effect.fn("FileSystem.writeJson")(function* (path: string, data: unknown, mode?: number) {
- const content = JSON.stringify(data, null, 2)
- yield* fs.writeFileString(path, content)
- if (mode) yield* fs.chmod(path, mode)
- })
-
- const ensureDir = Effect.fn("FileSystem.ensureDir")(function* (path: string) {
- yield* fs.makeDirectory(path, { recursive: true })
- })
-
- const writeWithDirs = Effect.fn("FileSystem.writeWithDirs")(function* (
- path: string,
- content: string | Uint8Array,
- mode?: number,
- ) {
- const write = typeof content === "string" ? fs.writeFileString(path, content) : fs.writeFile(path, content)
-
- yield* write.pipe(
- Effect.catchIf(
- (e) => e.reason._tag === "NotFound",
- () =>
- Effect.gen(function* () {
- yield* fs.makeDirectory(dirname(path), { recursive: true })
- yield* write
- }),
- ),
- )
- if (mode) yield* fs.chmod(path, mode)
- })
-
- const glob = Effect.fn("FileSystem.glob")(function* (pattern: string, options?: Glob.Options) {
- return yield* Effect.tryPromise({
- try: () => Glob.scan(pattern, options),
- catch: (cause) => new FileSystemError({ method: "glob", cause }),
- })
- })
-
- const findUp = Effect.fn("FileSystem.findUp")(function* (target: string, start: string, stop?: string) {
- const result: string[] = []
- let current = start
- while (true) {
- const search = join(current, target)
- if (yield* fs.exists(search)) result.push(search)
- if (stop === current) break
- const parent = dirname(current)
- if (parent === current) break
- current = parent
- }
- return result
- })
-
- const up = Effect.fn("FileSystem.up")(function* (options: { targets: string[]; start: string; stop?: string }) {
- const result: string[] = []
- let current = options.start
- while (true) {
- for (const target of options.targets) {
- const search = join(current, target)
- if (yield* fs.exists(search)) result.push(search)
- }
- if (options.stop === current) break
- const parent = dirname(current)
- if (parent === current) break
- current = parent
- }
- return result
- })
-
- const globUp = Effect.fn("FileSystem.globUp")(function* (pattern: string, start: string, stop?: string) {
- const result: string[] = []
- let current = start
- while (true) {
- const matches = yield* glob(pattern, { cwd: current, absolute: true, include: "file", dot: true }).pipe(
- Effect.catch(() => Effect.succeed([] as string[])),
- )
- result.push(...matches)
- if (stop === current) break
- const parent = dirname(current)
- if (parent === current) break
- current = parent
- }
- return result
- })
-
- return Service.of({
- ...fs,
- isDir,
- isFile,
- readJson,
- writeJson,
- ensureDir,
- writeWithDirs,
- findUp,
- up,
- globUp,
- glob,
- globMatch: Glob.match,
- })
- }),
- )
-
- export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer))
-
- // Pure helpers that don't need Effect (path manipulation, sync operations)
- export function mimeType(p: string): string {
- return lookup(p) || "application/octet-stream"
- }
-
- export function normalizePath(p: string): string {
- if (process.platform !== "win32") return p
- try {
- return realpathSync.native(p)
- } catch {
- return p
- }
- }
-
- export function resolve(p: string): string {
- const resolved = pathResolve(windowsPath(p))
- try {
- return normalizePath(realpathSync(resolved))
- } catch (e: any) {
- if (e?.code === "ENOENT") return normalizePath(resolved)
- throw e
- }
- }
-
- export function windowsPath(p: string): string {
- if (process.platform !== "win32") return p
- return p
- .replace(/^\/([a-zA-Z]):(?:[\\/]|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
- .replace(/^\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
- .replace(/^\/cygdrive\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
- .replace(/^\/mnt\/([a-zA-Z])(?:\/|$)/, (_, drive) => `${drive.toUpperCase()}:/`)
- }
-
- export function overlaps(a: string, b: string) {
- const relA = relative(a, b)
- const relB = relative(b, a)
- return !relA || !relA.startsWith("..") || !relB || !relB.startsWith("..")
- }
-
- export function contains(parent: string, child: string) {
- return !relative(parent, child).startsWith("..")
- }
-}
diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts
index 755ce2c2117..8790efac49b 100644
--- a/packages/opencode/src/plugin/index.ts
+++ b/packages/opencode/src/plugin/index.ts
@@ -16,6 +16,8 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "@gitlab/opencode-gitlab-au
export namespace Plugin {
const log = Log.create({ service: "plugin" })
+ const BUILTIN = ["opencode-anthropic-auth@0.0.13"]
+
// Built-in plugins that are directly imported (not installed from npm)
const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin, CopilotAuthPlugin, GitlabAuthPlugin]
@@ -53,6 +55,9 @@ export namespace Plugin {
let plugins = config.plugin ?? []
if (plugins.length) await Config.waitForDependencies()
+ if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) {
+ plugins = [...BUILTIN, ...plugins]
+ }
for (let plugin of plugins) {
// ignore old codex plugin since it is supported first party now
diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts
index f7667fc2cb9..2537f894933 100644
--- a/packages/opencode/src/provider/provider.ts
+++ b/packages/opencode/src/provider/provider.ts
@@ -47,6 +47,8 @@ import { ProviderTransform } from "./transform"
import { Installation } from "../installation"
import { ModelID, ProviderID } from "./schema"
+const DEFAULT_CHUNK_TIMEOUT = 300_000
+
export namespace Provider {
const log = Log.create({ service: "provider" })
@@ -148,7 +150,8 @@ export namespace Provider {
autoload: false,
options: {
headers: {
- "anthropic-beta": "interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
+ "anthropic-beta":
+ "claude-code-20250219,interleaved-thinking-2025-05-14,fine-grained-tool-streaming-2025-05-14",
},
},
}
@@ -836,7 +839,7 @@ export namespace Provider {
return true
}
- const providers: Record = {} as Record
+ const providers: { [providerID: string]: Info } = {}
const languages = new Map()
const modelLoaders: {
[providerID: string]: CustomModelLoader
@@ -1128,7 +1131,7 @@ export namespace Provider {
if (existing) return existing
const customFetch = options["fetch"]
- const chunkTimeout = options["chunkTimeout"]
+ const chunkTimeout = options["chunkTimeout"] || DEFAULT_CHUNK_TIMEOUT
delete options["chunkTimeout"]
options["fetch"] = async (input: any, init?: BunFetchRequestInit) => {
diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts
index 71c8a1029cd..15a919d8eae 100644
--- a/packages/opencode/src/provider/schema.ts
+++ b/packages/opencode/src/provider/schema.ts
@@ -22,7 +22,6 @@ export const ProviderID = providerIdSchema.pipe(
azure: schema.makeUnsafe("azure"),
openrouter: schema.makeUnsafe("openrouter"),
mistral: schema.makeUnsafe("mistral"),
- gitlab: schema.makeUnsafe("gitlab"),
})),
)
diff --git a/packages/opencode/src/server/routes/event.ts b/packages/opencode/src/server/routes/event.ts
deleted file mode 100644
index f34ff056679..00000000000
--- a/packages/opencode/src/server/routes/event.ts
+++ /dev/null
@@ -1,85 +0,0 @@
-import { Hono } from "hono"
-import { describeRoute, resolver } from "hono-openapi"
-import { streamSSE } from "hono/streaming"
-import { Log } from "@/util/log"
-import { BusEvent } from "@/bus/bus-event"
-import { Bus } from "@/bus"
-import { lazy } from "../../util/lazy"
-import { AsyncQueue } from "../../util/queue"
-import { Instance } from "@/project/instance"
-
-const log = Log.create({ service: "server" })
-
-export const EventRoutes = lazy(() =>
- new Hono().get(
- "/event",
- describeRoute({
- summary: "Subscribe to events",
- description: "Get events",
- operationId: "event.subscribe",
- responses: {
- 200: {
- description: "Event stream",
- content: {
- "text/event-stream": {
- schema: resolver(BusEvent.payloads()),
- },
- },
- },
- },
- }),
- async (c) => {
- log.info("event connected")
- c.header("X-Accel-Buffering", "no")
- c.header("X-Content-Type-Options", "nosniff")
- return streamSSE(c, async (stream) => {
- const q = new AsyncQueue()
- let done = false
-
- q.push(
- JSON.stringify({
- type: "server.connected",
- properties: {},
- }),
- )
-
- // Send heartbeat every 10s to prevent stalled proxy streams.
- const heartbeat = setInterval(() => {
- q.push(
- JSON.stringify({
- type: "server.heartbeat",
- properties: {},
- }),
- )
- }, 10_000)
-
- const unsub = Bus.subscribeAll((event) => {
- q.push(JSON.stringify(event))
- if (event.type === Bus.InstanceDisposed.type) {
- stop()
- }
- })
-
- const stop = () => {
- if (done) return
- done = true
- clearInterval(heartbeat)
- unsub()
- q.push(null)
- log.info("event disconnected")
- }
-
- stream.onAbort(stop)
-
- try {
- for await (const data of q) {
- if (data === null) return
- await stream.writeSSE({ data })
- }
- } finally {
- stop()
- }
- })
- },
- ),
-)
diff --git a/packages/opencode/src/server/routes/global.ts b/packages/opencode/src/server/routes/global.ts
index 4a6a3ebc7e2..4d019f6a7ee 100644
--- a/packages/opencode/src/server/routes/global.ts
+++ b/packages/opencode/src/server/routes/global.ts
@@ -4,7 +4,6 @@ import { streamSSE } from "hono/streaming"
import z from "zod"
import { BusEvent } from "@/bus/bus-event"
import { GlobalBus } from "@/bus/global"
-import { AsyncQueue } from "@/util/queue"
import { Instance } from "../../project/instance"
import { Installation } from "@/installation"
import { Log } from "../../util/log"
@@ -70,54 +69,41 @@ export const GlobalRoutes = lazy(() =>
c.header("X-Accel-Buffering", "no")
c.header("X-Content-Type-Options", "nosniff")
return streamSSE(c, async (stream) => {
- const q = new AsyncQueue()
- let done = false
-
- q.push(
- JSON.stringify({
+ stream.writeSSE({
+ data: JSON.stringify({
payload: {
type: "server.connected",
properties: {},
},
}),
- )
+ })
+ async function handler(event: any) {
+ await stream.writeSSE({
+ data: JSON.stringify(event),
+ })
+ }
+ GlobalBus.on("event", handler)
// Send heartbeat every 10s to prevent stalled proxy streams.
const heartbeat = setInterval(() => {
- q.push(
- JSON.stringify({
+ stream.writeSSE({
+ data: JSON.stringify({
payload: {
type: "server.heartbeat",
properties: {},
},
}),
- )
+ })
}, 10_000)
- async function handler(event: any) {
- q.push(JSON.stringify(event))
- }
- GlobalBus.on("event", handler)
-
- const stop = () => {
- if (done) return
- done = true
- clearInterval(heartbeat)
- GlobalBus.off("event", handler)
- q.push(null)
- log.info("event disconnected")
- }
-
- stream.onAbort(stop)
-
- try {
- for await (const data of q) {
- if (data === null) return
- await stream.writeSSE({ data })
- }
- } finally {
- stop()
- }
+ await new Promise((resolve) => {
+ stream.onAbort(() => {
+ clearInterval(heartbeat)
+ GlobalBus.off("event", handler)
+ resolve()
+ log.info("global event disconnected")
+ })
+ })
})
},
)
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index a68becb1fba..c485654fdf8 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -1,7 +1,10 @@
+import { BusEvent } from "@/bus/bus-event"
+import { Bus } from "@/bus"
import { Log } from "../util/log"
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
import { Hono } from "hono"
import { cors } from "hono/cors"
+import { streamSSE } from "hono/streaming"
import { proxy } from "hono/proxy"
import { basicAuth } from "hono/basic-auth"
import z from "zod"
@@ -31,7 +34,6 @@ import { FileRoutes } from "./routes/file"
import { ConfigRoutes } from "./routes/config"
import { ExperimentalRoutes } from "./routes/experimental"
import { ProviderRoutes } from "./routes/provider"
-import { EventRoutes } from "./routes/event"
import { InstanceBootstrap } from "../project/bootstrap"
import { NotFoundError } from "../storage/db"
import type { ContentfulStatusCode } from "hono/utils/http-status"
@@ -249,7 +251,6 @@ export namespace Server {
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
.route("/", FileRoutes())
- .route("/", EventRoutes())
.route("/mcp", McpRoutes())
.route("/tui", TuiRoutes())
.post(
@@ -497,6 +498,64 @@ export namespace Server {
return c.json(await Format.status())
},
)
+ .get(
+ "/event",
+ describeRoute({
+ summary: "Subscribe to events",
+ description: "Get events",
+ operationId: "event.subscribe",
+ responses: {
+ 200: {
+ description: "Event stream",
+ content: {
+ "text/event-stream": {
+ schema: resolver(BusEvent.payloads()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ log.info("event connected")
+ c.header("X-Accel-Buffering", "no")
+ c.header("X-Content-Type-Options", "nosniff")
+ return streamSSE(c, async (stream) => {
+ stream.writeSSE({
+ data: JSON.stringify({
+ type: "server.connected",
+ properties: {},
+ }),
+ })
+ const unsub = Bus.subscribeAll(async (event) => {
+ await stream.writeSSE({
+ data: JSON.stringify(event),
+ })
+ if (event.type === Bus.InstanceDisposed.type) {
+ stream.close()
+ }
+ })
+
+ // Send heartbeat every 10s to prevent stalled proxy streams.
+ const heartbeat = setInterval(() => {
+ stream.writeSSE({
+ data: JSON.stringify({
+ type: "server.heartbeat",
+ properties: {},
+ }),
+ })
+ }, 10_000)
+
+ await new Promise((resolve) => {
+ stream.onAbort(() => {
+ clearInterval(heartbeat)
+ unsub()
+ resolve()
+ log.info("event disconnected")
+ })
+ })
+ })
+ },
+ )
.all("/*", async (c) => {
const path = c.req.path
diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts
index daf70180e52..bcf1b3e6af6 100644
--- a/packages/opencode/src/session/llm.ts
+++ b/packages/opencode/src/session/llm.ts
@@ -207,12 +207,18 @@ export namespace LLM {
maxOutputTokens,
abortSignal: input.abort,
headers: {
- ...(input.model.providerID.startsWith("opencode") && {
- "x-opencode-project": Instance.project.id,
- "x-opencode-session": input.sessionID,
- "x-opencode-request": input.user.id,
- "x-opencode-client": Flag.OPENCODE_CLIENT,
- }),
+ ...(input.model.providerID.startsWith("opencode")
+ ? {
+ "x-opencode-project": Instance.project.id,
+ "x-opencode-session": input.sessionID,
+ "x-opencode-request": input.user.id,
+ "x-opencode-client": Flag.OPENCODE_CLIENT,
+ }
+ : input.model.providerID !== "anthropic"
+ ? {
+ "User-Agent": `opencode/${Installation.VERSION}`,
+ }
+ : undefined),
...input.model.headers,
...headers,
},
diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts
index 41e2d4efc7e..8e4babd6192 100644
--- a/packages/opencode/src/session/message-v2.ts
+++ b/packages/opencode/src/session/message-v2.ts
@@ -956,7 +956,7 @@ export namespace MessageV2 {
{ cause: e },
).toObject()
case e instanceof Error:
- return new NamedError.Unknown({ message: e instanceof Error ? e.message : String(e) }, { cause: e }).toObject()
+ return new NamedError.Unknown({ message: e.toString() }, { cause: e }).toObject()
default:
try {
const parsed = ProviderError.parseStreamError(e)
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 8200dea7564..158b83865dc 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -210,7 +210,7 @@ export namespace SessionProcessor {
state: {
status: "error",
input: value.input ?? match.state.input,
- error: value.error instanceof Error ? value.error.message : String(value.error),
+ error: (value.error as any).toString(),
time: {
start: match.state.time.start,
end: Date.now(),
diff --git a/packages/opencode/src/session/prompt/anthropic-20250930.txt b/packages/opencode/src/session/prompt/anthropic-20250930.txt
new file mode 100644
index 00000000000..e15080e6c8a
--- /dev/null
+++ b/packages/opencode/src/session/prompt/anthropic-20250930.txt
@@ -0,0 +1,166 @@
+You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
+
+IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation.
+IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
+
+If the user asks for help or wants to give feedback inform them of the following:
+- /help: Get help with using Claude Code
+- To give feedback, users should report the issue at https://github.com/anthropics/claude-code/issues
+
+When the user directly asks about Claude Code (eg. "can Claude Code do...", "does Claude Code have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific Claude Code feature (eg. implement a hook, or write a slash command), use the WebFetch tool to gather information to answer the question from Claude Code docs. The list of available docs is available at https://docs.claude.com/en/docs/claude-code/claude_code_docs_map.md.
+
+# Tone and style
+You should be concise, direct, and to the point, while providing complete information and matching the level of detail you provide in your response with the level of complexity of the user's query or the work you have completed.
+A concise response is generally less than 4 lines, not including tool calls or code generated. You should provide more detail when the task is complex or when the user asks you to.
+IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
+IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
+Do not add additional code explanation summary unless requested by the user. After working on a file, briefly confirm that you have completed the task, rather than providing an explanation of what you did.
+Answer the user's question directly, avoiding any elaboration, explanation, introduction, conclusion, or excessive details. Brief answers are best, but be sure to provide complete information. You MUST avoid extra preamble before/after your response, such as "The answer is .", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...".
+
+Here are some examples to demonstrate appropriate verbosity:
+
+user: 2 + 2
+assistant: 4
+
+
+
+user: what is 2+2?
+assistant: 4
+
+
+
+user: is 11 a prime number?
+assistant: Yes
+
+
+
+user: what command should I run to list files in the current directory?
+assistant: ls
+
+
+
+user: what command should I run to watch files in the current directory?
+assistant: [runs ls to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
+npm run dev
+
+
+
+user: How many golf balls fit inside a jetta?
+assistant: 150000
+
+
+
+user: what files are in the directory src/?
+assistant: [runs ls and sees foo.c, bar.c, baz.c]
+user: which file contains the implementation of foo?
+assistant: src/foo.c
+
+When you run a non-trivial bash command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
+Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
+Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
+If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
+Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
+IMPORTANT: Keep your responses short, since they will be displayed on a command line interface.
+
+# Proactiveness
+You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
+- Doing the right thing when asked, including taking actions and follow-up actions
+- Not surprising the user with actions you take without asking
+For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
+
+# Professional objectivity
+Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if Claude honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
+
+# Task Management
+You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
+These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
+
+It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
+
+Examples:
+
+
+user: Run the build and fix any type errors
+assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
+- Run the build
+- Fix any type errors
+
+I'm now going to run the build using Bash.
+
+Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list.
+
+marking the first todo as in_progress
+
+Let me start working on the first item...
+
+The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
+..
+..
+
+In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
+
+
+user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
+
+assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task.
+Adding the following todos to the todo list:
+1. Research existing metrics tracking in the codebase
+2. Design the metrics collection system
+3. Implement core metrics tracking functionality
+4. Create export functionality for different formats
+
+Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
+
+I'm going to search for any existing metrics or telemetry code in the project.
+
+I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
+
+[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
+
+
+
+Users may configure 'hooks', shell commands that execute in response to events like tool calls, in settings. Treat feedback from hooks, including , as coming from the user. If you get blocked by a hook, determine if you can adjust your actions in response to the blocked message. If not, ask the user to check their hooks configuration.
+
+# Doing tasks
+The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
+- Use the TodoWrite tool to plan the task if required
+
+- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
+
+
+# Tool usage policy
+- When doing file search, prefer to use the Task tool in order to reduce context usage.
+- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description.
+
+- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
+- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple bash tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
+- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls.
+- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
+
+
+Here is useful information about the environment you are running in:
+
+Working directory: /home/thdxr/dev/projects/anomalyco/opencode/packages/opencode
+Is directory a git repo: Yes
+Platform: linux
+OS Version: Linux 6.12.4-arch1-1
+Today's date: 2025-09-30
+
+You are powered by the model named Sonnet 4.5. The exact model ID is claude-sonnet-4-5-20250929.
+
+Assistant knowledge cutoff is January 2025.
+
+
+IMPORTANT: Assist with defensive security tasks only. Refuse to create, modify, or improve code that may be used maliciously. Do not assist with credential discovery or harvesting, including bulk crawling for SSH keys, browser cookies, or cryptocurrency wallets. Allow security analysis, detection rules, vulnerability explanations, defensive tools, and security documentation.
+
+
+IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
+
+# Code References
+
+When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
+
+
+user: Where are errors from the client handled?
+assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
+
diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts
index e1039750321..e5279503d90 100644
--- a/packages/opencode/src/skill/discovery.ts
+++ b/packages/opencode/src/skill/discovery.ts
@@ -1,8 +1,7 @@
-import { NodePath } from "@effect/platform-node"
-import { Effect, Layer, Path, Schema, ServiceMap } from "effect"
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import { Effect, FileSystem, Layer, Path, Schema, ServiceMap } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { withTransientReadRetry } from "@/util/effect-http-client"
-import { AppFileSystem } from "@/filesystem"
import { Global } from "../global"
import { Log } from "../util/log"
@@ -25,12 +24,12 @@ export namespace Discovery {
export class Service extends ServiceMap.Service()("@opencode/SkillDiscovery") {}
- export const layer: Layer.Layer =
+ export const layer: Layer.Layer =
Layer.effect(
Service,
Effect.gen(function* () {
const log = Log.create({ service: "skill-discovery" })
- const fs = yield* AppFileSystem.Service
+ const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const http = HttpClient.filterStatusOk(withTransientReadRetry(yield* HttpClient.HttpClient))
const cache = path.join(Global.Path.cache, "skills")
@@ -41,7 +40,11 @@ export namespace Discovery {
return yield* HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((res) => res.arrayBuffer),
- Effect.flatMap((body) => fs.writeWithDirs(dest, new Uint8Array(body))),
+ Effect.flatMap((body) =>
+ fs
+ .makeDirectory(path.dirname(dest), { recursive: true })
+ .pipe(Effect.flatMap(() => fs.writeFile(dest, new Uint8Array(body)))),
+ ),
Effect.as(true),
Effect.catch((err) =>
Effect.sync(() => {
@@ -110,7 +113,7 @@ export namespace Discovery {
export const defaultLayer: Layer.Layer = layer.pipe(
Layer.provide(FetchHttpClient.layer),
- Layer.provide(AppFileSystem.defaultLayer),
+ Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
}
diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts
index 5339691a01b..d7aeb911f32 100644
--- a/packages/opencode/src/skill/skill.ts
+++ b/packages/opencode/src/skill/skill.ts
@@ -204,7 +204,7 @@ export namespace Skill {
const available = Effect.fn("Skill.available")(function* (agent?: Agent.Info) {
yield* Effect.promise(() => state.ensure())
- const list = Object.values(state.skills).toSorted((a, b) => a.name.localeCompare(b.name))
+ const list = Object.values(state.skills)
if (!agent) return list
return list.filter((skill) => PermissionNext.evaluate("skill", skill.name, agent.permission).action !== "deny")
})
diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts
index 887bce33416..9f0eef56bc6 100644
--- a/packages/opencode/src/snapshot/index.ts
+++ b/packages/opencode/src/snapshot/index.ts
@@ -1,11 +1,10 @@
import { NodeChildProcessSpawner, NodeFileSystem, NodePath } from "@effect/platform-node"
-import { Cause, Duration, Effect, Layer, Schedule, ServiceMap, Stream } from "effect"
+import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import path from "path"
import z from "zod"
import { InstanceContext } from "@/effect/instance-context"
import { runPromiseInstance } from "@/effect/runtime"
-import { AppFileSystem } from "@/filesystem"
import { Config } from "../config/config"
import { Global } from "../global"
import { Log } from "../util/log"
@@ -86,12 +85,12 @@ export namespace Snapshot {
export const layer: Layer.Layer<
Service,
never,
- InstanceContext | AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner
+ InstanceContext | FileSystem.FileSystem | ChildProcessSpawner.ChildProcessSpawner
> = Layer.effect(
Service,
Effect.gen(function* () {
const ctx = yield* InstanceContext
- const fs = yield* AppFileSystem.Service
+ const fs = yield* FileSystem.FileSystem
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const directory = ctx.directory
const worktree = ctx.worktree
@@ -125,8 +124,9 @@ export namespace Snapshot {
),
)
- // Snapshot-specific error handling on top of AppFileSystem
const exists = (file: string) => fs.exists(file).pipe(Effect.orDie)
+ const mkdir = (dir: string) => fs.makeDirectory(dir, { recursive: true }).pipe(Effect.orDie)
+ const write = (file: string, text: string) => fs.writeFileString(file, text).pipe(Effect.orDie)
const read = (file: string) => fs.readFileString(file).pipe(Effect.catch(() => Effect.succeed("")))
const remove = (file: string) => fs.remove(file).pipe(Effect.catch(() => Effect.void))
@@ -148,12 +148,12 @@ export namespace Snapshot {
const sync = Effect.fnUntraced(function* () {
const file = yield* excludes()
const target = path.join(gitdir, "info", "exclude")
- yield* fs.ensureDir(path.join(gitdir, "info")).pipe(Effect.orDie)
+ yield* mkdir(path.join(gitdir, "info"))
if (!file) {
- yield* fs.writeFileString(target, "").pipe(Effect.orDie)
+ yield* write(target, "")
return
}
- yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie)
+ yield* write(target, yield* read(file))
})
const add = Effect.fnUntraced(function* () {
@@ -178,7 +178,7 @@ export namespace Snapshot {
const track = Effect.fn("Snapshot.track")(function* () {
if (!(yield* enabled())) return
const existed = yield* exists(gitdir)
- yield* fs.ensureDir(gitdir).pipe(Effect.orDie)
+ yield* mkdir(gitdir)
if (!existed) {
yield* git(["init"], {
env: { GIT_DIR: gitdir, GIT_WORK_TREE: worktree },
@@ -342,8 +342,7 @@ export namespace Snapshot {
export const defaultLayer = layer.pipe(
Layer.provide(NodeChildProcessSpawner.layer),
- Layer.provide(AppFileSystem.defaultLayer),
- Layer.provide(NodeFileSystem.layer), // needed by NodeChildProcessSpawner
+ Layer.provide(NodeFileSystem.layer),
Layer.provide(NodePath.layer),
)
}
diff --git a/packages/opencode/src/tool/task.ts b/packages/opencode/src/tool/task.ts
index 9cabf47eb1d..14ecea10758 100644
--- a/packages/opencode/src/tool/task.ts
+++ b/packages/opencode/src/tool/task.ts
@@ -33,11 +33,10 @@ export const TaskTool = Tool.define("task", async (ctx) => {
const accessibleAgents = caller
? agents.filter((a) => PermissionNext.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
- const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = DESCRIPTION.replace(
"{agents}",
- list
+ accessibleAgents
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)
diff --git a/packages/opencode/src/tool/truncate-effect.ts b/packages/opencode/src/tool/truncate-effect.ts
index 4431c18f839..60b9d0fa843 100644
--- a/packages/opencode/src/tool/truncate-effect.ts
+++ b/packages/opencode/src/tool/truncate-effect.ts
@@ -1,8 +1,7 @@
-import { NodePath } from "@effect/platform-node"
-import { Cause, Duration, Effect, Layer, Schedule, ServiceMap } from "effect"
+import { NodeFileSystem, NodePath } from "@effect/platform-node"
+import { Cause, Duration, Effect, FileSystem, Layer, Schedule, ServiceMap } from "effect"
import path from "path"
import type { Agent } from "../agent/agent"
-import { AppFileSystem } from "@/filesystem"
import { PermissionNext } from "../permission"
import { Identifier } from "../id/id"
import { Log } from "../util/log"
@@ -45,7 +44,7 @@ export namespace TruncateEffect {
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
+ const fs = yield* FileSystem.FileSystem
const cleanup = Effect.fn("Truncate.cleanup")(function* () {
const cutoff = Identifier.timestamp(Identifier.create("tool", false, Date.now() - Duration.toMillis(RETENTION)))
@@ -102,7 +101,7 @@ export namespace TruncateEffect {
const preview = out.join("\n")
const file = path.join(TRUNCATION_DIR, ToolID.ascending())
- yield* fs.ensureDir(TRUNCATION_DIR).pipe(Effect.orDie)
+ yield* fs.makeDirectory(TRUNCATION_DIR, { recursive: true }).pipe(Effect.orDie)
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
const hint = hasTaskTool(agent)
@@ -133,5 +132,5 @@ export namespace TruncateEffect {
}),
)
- export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
+ export const defaultLayer = layer.pipe(Layer.provide(NodeFileSystem.layer), Layer.provide(NodePath.layer))
}
diff --git a/packages/opencode/src/util/eventloop.ts b/packages/opencode/src/util/eventloop.ts
new file mode 100644
index 00000000000..87f6eef41d7
--- /dev/null
+++ b/packages/opencode/src/util/eventloop.ts
@@ -0,0 +1,20 @@
+import { Log } from "./log"
+
+export namespace EventLoop {
+ export async function wait() {
+ return new Promise((resolve) => {
+ const check = () => {
+ const active = [...(process as any)._getActiveHandles(), ...(process as any)._getActiveRequests()]
+ Log.Default.info("eventloop", {
+ active,
+ })
+ if ((process as any)._getActiveHandles().length === 0 && (process as any)._getActiveRequests().length === 0) {
+ resolve()
+ } else {
+ setImmediate(check)
+ }
+ }
+ check()
+ })
+ }
+}
diff --git a/packages/opencode/src/util/process.ts b/packages/opencode/src/util/process.ts
index 7e6be0e20d7..8cf1f5b9f21 100644
--- a/packages/opencode/src/util/process.ts
+++ b/packages/opencode/src/util/process.ts
@@ -98,7 +98,6 @@ export namespace Process {
reject(error)
})
})
- void exited.catch(() => undefined)
if (opts.abort) {
opts.abort.addEventListener("abort", abort, { once: true })
diff --git a/packages/opencode/test/account/service.test.ts b/packages/opencode/test/account/service.test.ts
index 098e00de505..94cd9eb94db 100644
--- a/packages/opencode/test/account/service.test.ts
+++ b/packages/opencode/test/account/service.test.ts
@@ -34,26 +34,6 @@ const encodeOrg = Schema.encodeSync(Org)
const org = (id: string, name: string) => encodeOrg(new Org({ id: OrgID.make(id), name }))
-const login = () =>
- new Login({
- code: DeviceCode.make("device-code"),
- user: UserCode.make("user-code"),
- url: "https://one.example.com/verify",
- server: "https://one.example.com",
- expiry: Duration.seconds(600),
- interval: Duration.seconds(5),
- })
-
-const deviceTokenClient = (body: unknown, status = 400) =>
- HttpClient.make((req) =>
- Effect.succeed(
- req.url === "https://one.example.com/auth/device/token" ? json(req, body, status) : json(req, {}, 404),
- ),
- )
-
-const poll = (body: unknown, status = 400) =>
- AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
-
it.effect("orgsByAccount groups orgs per account", () =>
Effect.gen(function* () {
yield* AccountRepo.use((r) =>
@@ -192,6 +172,15 @@ it.effect("config sends the selected org header", () =>
it.effect("poll stores the account and first org on success", () =>
Effect.gen(function* () {
+ const login = new Login({
+ code: DeviceCode.make("device-code"),
+ user: UserCode.make("user-code"),
+ url: "https://one.example.com/verify",
+ server: "https://one.example.com",
+ expiry: Duration.seconds(600),
+ interval: Duration.seconds(5),
+ })
+
const client = HttpClient.make((req) =>
Effect.succeed(
req.url === "https://one.example.com/auth/device/token"
@@ -209,7 +198,7 @@ it.effect("poll stores the account and first org on success", () =>
),
)
- const res = yield* AccountEffect.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(client)))
+ const res = yield* AccountEffect.Service.use((s) => s.poll(login)).pipe(Effect.provide(live(client)))
expect(res._tag).toBe("PollSuccess")
if (res._tag === "PollSuccess") {
@@ -226,59 +215,3 @@ it.effect("poll stores the account and first org on success", () =>
)
}),
)
-
-for (const [name, body, expectedTag] of [
- [
- "pending",
- {
- error: "authorization_pending",
- error_description: "The authorization request is still pending",
- },
- "PollPending",
- ],
- [
- "slow",
- {
- error: "slow_down",
- error_description: "Polling too frequently, please slow down",
- },
- "PollSlow",
- ],
- [
- "denied",
- {
- error: "access_denied",
- error_description: "The authorization request was denied",
- },
- "PollDenied",
- ],
- [
- "expired",
- {
- error: "expired_token",
- error_description: "The device code has expired",
- },
- "PollExpired",
- ],
-] as const) {
- it.effect(`poll returns ${name} for ${body.error}`, () =>
- Effect.gen(function* () {
- const result = yield* poll(body)
- expect(result._tag).toBe(expectedTag)
- }),
- )
-}
-
-it.effect("poll returns poll error for other OAuth errors", () =>
- Effect.gen(function* () {
- const result = yield* poll({
- error: "server_error",
- error_description: "An unexpected error occurred",
- })
-
- expect(result._tag).toBe("PollError")
- if (result._tag === "PollError") {
- expect(String(result.cause)).toContain("server_error")
- }
- }),
-)
diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts
index 60c8e57c926..d6b6ebb33bc 100644
--- a/packages/opencode/test/agent/agent.test.ts
+++ b/packages/opencode/test/agent/agent.test.ts
@@ -384,32 +384,6 @@ test("multiple custom agents can be defined", async () => {
})
})
-test("Agent.list keeps the default agent first and sorts the rest by name", async () => {
- await using tmp = await tmpdir({
- config: {
- default_agent: "plan",
- agent: {
- zebra: {
- description: "Zebra",
- mode: "subagent",
- },
- alpha: {
- description: "Alpha",
- mode: "subagent",
- },
- },
- },
- })
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const names = (await Agent.list()).map((a) => a.name)
- expect(names[0]).toBe("plan")
- expect(names.slice(1)).toEqual(names.slice(1).toSorted((a, b) => a.localeCompare(b)))
- },
- })
-})
-
test("Agent.get returns undefined for non-existent agent", async () => {
await using tmp = await tmpdir()
await Instance.provide({
diff --git a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts
deleted file mode 100644
index 326d3e624d2..00000000000
--- a/packages/opencode/test/cli/cmd/tui/prompt-part.test.ts
+++ /dev/null
@@ -1,47 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import type { PromptInfo } from "../../../../src/cli/cmd/tui/component/prompt/history"
-import { assign, strip } from "../../../../src/cli/cmd/tui/component/prompt/part"
-
-describe("prompt part", () => {
- test("strip removes persisted ids from reused file parts", () => {
- const part = {
- id: "prt_old",
- sessionID: "ses_old",
- messageID: "msg_old",
- type: "file" as const,
- mime: "image/png",
- filename: "tiny.png",
- url: "data:image/png;base64,abc",
- }
-
- expect(strip(part)).toEqual({
- type: "file",
- mime: "image/png",
- filename: "tiny.png",
- url: "data:image/png;base64,abc",
- })
- })
-
- test("assign overwrites stale runtime ids", () => {
- const part = {
- id: "prt_old",
- sessionID: "ses_old",
- messageID: "msg_old",
- type: "file" as const,
- mime: "image/png",
- filename: "tiny.png",
- url: "data:image/png;base64,abc",
- } as PromptInfo["parts"][number]
-
- const next = assign(part)
-
- expect(next.id).not.toBe("prt_old")
- expect(next.id.startsWith("prt_")).toBe(true)
- expect(next).toMatchObject({
- type: "file",
- mime: "image/png",
- filename: "tiny.png",
- url: "data:image/png;base64,abc",
- })
- })
-})
diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts
index 2cd27643e88..8a3d30d3159 100644
--- a/packages/opencode/test/file/watcher.test.ts
+++ b/packages/opencode/test/file/watcher.test.ts
@@ -2,7 +2,7 @@ import { $ } from "bun"
import { afterEach, describe, expect, test } from "bun:test"
import fs from "fs/promises"
import path from "path"
-import { Deferred, Effect, Option } from "effect"
+import { Deferred, Effect, Fiber, Option } from "effect"
import { tmpdir } from "../fixture/fixture"
import { watcherConfigLayer, withServices } from "../fixture/instance"
import { FileWatcher } from "../../src/file/watcher"
@@ -25,7 +25,6 @@ function withWatcher(directory: string, body: Effect.Effect) {
directory,
FileWatcher.layer,
async (rt) => {
- await rt.runPromise(FileWatcher.Service.use(() => Effect.void))
await Effect.runPromise(ready(directory))
await Effect.runPromise(body)
},
@@ -55,29 +54,24 @@ function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (
}
function wait(directory: string, check: (evt: WatcherEvent) => boolean) {
- return Effect.gen(function* () {
- const deferred = yield* Deferred.make()
- const cleanup = yield* Effect.sync(() => {
- let off = () => {}
- off = listen(directory, check, (evt) => {
- off()
- Deferred.doneUnsafe(deferred, Effect.succeed(evt))
- })
- return off
+ return Effect.callback((resume) => {
+ const cleanup = listen(directory, check, (evt) => {
+ cleanup()
+ resume(Effect.succeed(evt))
})
- return { cleanup, deferred }
- })
+ return Effect.sync(cleanup)
+ }).pipe(Effect.timeout("5 seconds"))
}
function nextUpdate(directory: string, check: (evt: WatcherEvent) => boolean, trigger: Effect.Effect) {
return Effect.acquireUseRelease(
- wait(directory, check),
- ({ deferred }) =>
+ wait(directory, check).pipe(Effect.forkChild({ startImmediately: true })),
+ (fiber) =>
Effect.gen(function* () {
yield* trigger
- return yield* Deferred.await(deferred).pipe(Effect.timeout("5 seconds"))
+ return yield* Fiber.join(fiber)
}),
- ({ cleanup }) => Effect.sync(cleanup),
+ Fiber.interrupt,
)
}
@@ -88,15 +82,23 @@ function noUpdate(
trigger: Effect.Effect,
ms = 500,
) {
- return Effect.acquireUseRelease(
- wait(directory, check),
- ({ deferred }) =>
- Effect.gen(function* () {
- yield* trigger
- expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none())
- }),
- ({ cleanup }) => Effect.sync(cleanup),
- )
+ return Effect.gen(function* () {
+ const deferred = yield* Deferred.make()
+
+ yield* Effect.acquireUseRelease(
+ Effect.sync(() =>
+ listen(directory, check, (evt) => {
+ Effect.runSync(Deferred.succeed(deferred, evt))
+ }),
+ ),
+ () =>
+ Effect.gen(function* () {
+ yield* trigger
+ expect(yield* Deferred.await(deferred).pipe(Effect.timeoutOption(`${ms} millis`))).toEqual(Option.none())
+ }),
+ (cleanup) => Effect.sync(cleanup),
+ )
+ })
}
function ready(directory: string) {
diff --git a/packages/opencode/test/filesystem/filesystem.test.ts b/packages/opencode/test/filesystem/filesystem.test.ts
deleted file mode 100644
index ca73b3336bc..00000000000
--- a/packages/opencode/test/filesystem/filesystem.test.ts
+++ /dev/null
@@ -1,319 +0,0 @@
-import { describe, test, expect } from "bun:test"
-import { Effect, Layer } from "effect"
-import { NodeFileSystem } from "@effect/platform-node"
-import { AppFileSystem } from "../../src/filesystem"
-import { testEffect } from "../lib/effect"
-import path from "path"
-
-const live = AppFileSystem.layer.pipe(Layer.provide(NodeFileSystem.layer))
-const { effect: it } = testEffect(live)
-
-describe("AppFileSystem", () => {
- describe("isDir", () => {
- it(
- "returns true for directories",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- expect(yield* fs.isDir(tmp)).toBe(true)
- }),
- )
-
- it(
- "returns false for files",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- const file = path.join(tmp, "test.txt")
- yield* fs.writeFileString(file, "hello")
- expect(yield* fs.isDir(file)).toBe(false)
- }),
- )
-
- it(
- "returns false for non-existent paths",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
- }),
- )
- })
-
- describe("isFile", () => {
- it(
- "returns true for files",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- const file = path.join(tmp, "test.txt")
- yield* fs.writeFileString(file, "hello")
- expect(yield* fs.isFile(file)).toBe(true)
- }),
- )
-
- it(
- "returns false for directories",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- expect(yield* fs.isFile(tmp)).toBe(false)
- }),
- )
- })
-
- describe("readJson / writeJson", () => {
- it(
- "round-trips JSON data",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- const file = path.join(tmp, "data.json")
- const data = { name: "test", count: 42, nested: { ok: true } }
-
- yield* fs.writeJson(file, data)
- const result = yield* fs.readJson(file)
-
- expect(result).toEqual(data)
- }),
- )
- })
-
- describe("ensureDir", () => {
- it(
- "creates nested directories",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- const nested = path.join(tmp, "a", "b", "c")
-
- yield* fs.ensureDir(nested)
-
- const info = yield* fs.stat(nested)
- expect(info.type).toBe("Directory")
- }),
- )
-
- it(
- "is idempotent",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- const dir = path.join(tmp, "existing")
- yield* fs.makeDirectory(dir)
-
- yield* fs.ensureDir(dir)
-
- const info = yield* fs.stat(dir)
- expect(info.type).toBe("Directory")
- }),
- )
- })
-
- describe("writeWithDirs", () => {
- it(
- "creates parent directories if missing",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- const file = path.join(tmp, "deep", "nested", "file.txt")
-
- yield* fs.writeWithDirs(file, "hello")
-
- expect(yield* fs.readFileString(file)).toBe("hello")
- }),
- )
-
- it(
- "writes directly when parent exists",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- const file = path.join(tmp, "direct.txt")
-
- yield* fs.writeWithDirs(file, "world")
-
- expect(yield* fs.readFileString(file)).toBe("world")
- }),
- )
-
- it(
- "writes Uint8Array content",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- const file = path.join(tmp, "binary.bin")
- const content = new Uint8Array([0x00, 0x01, 0x02, 0x03])
-
- yield* fs.writeWithDirs(file, content)
-
- const result = yield* fs.readFile(file)
- expect(new Uint8Array(result)).toEqual(content)
- }),
- )
- })
-
- describe("findUp", () => {
- it(
- "finds target in start directory",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- yield* fs.writeFileString(path.join(tmp, "target.txt"), "found")
-
- const result = yield* fs.findUp("target.txt", tmp)
- expect(result).toEqual([path.join(tmp, "target.txt")])
- }),
- )
-
- it(
- "finds target in parent directories",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- yield* fs.writeFileString(path.join(tmp, "marker"), "root")
- const child = path.join(tmp, "a", "b")
- yield* fs.makeDirectory(child, { recursive: true })
-
- const result = yield* fs.findUp("marker", child, tmp)
- expect(result).toEqual([path.join(tmp, "marker")])
- }),
- )
-
- it(
- "returns empty array when not found",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- const result = yield* fs.findUp("nonexistent", tmp, tmp)
- expect(result).toEqual([])
- }),
- )
- })
-
- describe("up", () => {
- it(
- "finds multiple targets walking up",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- yield* fs.writeFileString(path.join(tmp, "a.txt"), "a")
- yield* fs.writeFileString(path.join(tmp, "b.txt"), "b")
- const child = path.join(tmp, "sub")
- yield* fs.makeDirectory(child)
- yield* fs.writeFileString(path.join(child, "a.txt"), "a-child")
-
- const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp })
-
- expect(result).toContain(path.join(child, "a.txt"))
- expect(result).toContain(path.join(tmp, "a.txt"))
- expect(result).toContain(path.join(tmp, "b.txt"))
- }),
- )
- })
-
- describe("glob", () => {
- it(
- "finds files matching pattern",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- yield* fs.writeFileString(path.join(tmp, "a.ts"), "a")
- yield* fs.writeFileString(path.join(tmp, "b.ts"), "b")
- yield* fs.writeFileString(path.join(tmp, "c.json"), "c")
-
- const result = yield* fs.glob("*.ts", { cwd: tmp })
- expect(result.sort()).toEqual(["a.ts", "b.ts"])
- }),
- )
-
- it(
- "supports absolute paths",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- yield* fs.writeFileString(path.join(tmp, "file.txt"), "hello")
-
- const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true })
- expect(result).toEqual([path.join(tmp, "file.txt")])
- }),
- )
- })
-
- describe("globMatch", () => {
- it(
- "matches patterns",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
- expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
- expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
- }),
- )
- })
-
- describe("globUp", () => {
- it(
- "finds files walking up directories",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- yield* fs.writeFileString(path.join(tmp, "root.md"), "root")
- const child = path.join(tmp, "a", "b")
- yield* fs.makeDirectory(child, { recursive: true })
- yield* fs.writeFileString(path.join(child, "leaf.md"), "leaf")
-
- const result = yield* fs.globUp("*.md", child, tmp)
- expect(result).toContain(path.join(child, "leaf.md"))
- expect(result).toContain(path.join(tmp, "root.md"))
- }),
- )
- })
-
- describe("built-in passthrough", () => {
- it(
- "exists works",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- const file = path.join(tmp, "exists.txt")
- yield* fs.writeFileString(file, "yes")
-
- expect(yield* fs.exists(file)).toBe(true)
- expect(yield* fs.exists(file + ".nope")).toBe(false)
- }),
- )
-
- it(
- "remove works",
- Effect.gen(function* () {
- const fs = yield* AppFileSystem.Service
- const tmp = yield* fs.makeTempDirectoryScoped()
- const file = path.join(tmp, "delete-me.txt")
- yield* fs.writeFileString(file, "bye")
-
- yield* fs.remove(file)
-
- expect(yield* fs.exists(file)).toBe(false)
- }),
- )
- })
-
- describe("pure helpers", () => {
- test("mimeType returns correct types", () => {
- expect(AppFileSystem.mimeType("file.json")).toBe("application/json")
- expect(AppFileSystem.mimeType("image.png")).toBe("image/png")
- expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream")
- })
-
- test("contains checks path containment", () => {
- expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true)
- expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false)
- })
-
- test("overlaps detects overlapping paths", () => {
- expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true)
- expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true)
- expect(AppFileSystem.overlaps("/a", "/b")).toBe(false)
- })
- })
-})
diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts
index 3358e923000..cb64455b4dd 100644
--- a/packages/opencode/test/provider/amazon-bedrock.test.ts
+++ b/packages/opencode/test/provider/amazon-bedrock.test.ts
@@ -2,7 +2,6 @@ import { test, expect, describe } from "bun:test"
import path from "path"
import { unlink } from "fs/promises"
-import { ProviderID } from "../../src/provider/schema"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
@@ -36,8 +35,8 @@ test("Bedrock: config region takes precedence over AWS_REGION env var", async ()
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.amazonBedrock]).toBeDefined()
- expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
+ expect(providers["amazon-bedrock"]).toBeDefined()
+ expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
},
})
})
@@ -61,8 +60,8 @@ test("Bedrock: falls back to AWS_REGION env var when no config region", async ()
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.amazonBedrock]).toBeDefined()
- expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
+ expect(providers["amazon-bedrock"]).toBeDefined()
+ expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
},
})
})
@@ -117,8 +116,8 @@ test("Bedrock: loads when bearer token from auth.json is present", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.amazonBedrock]).toBeDefined()
- expect(providers[ProviderID.amazonBedrock].options?.region).toBe("eu-west-1")
+ expect(providers["amazon-bedrock"]).toBeDefined()
+ expect(providers["amazon-bedrock"].options?.region).toBe("eu-west-1")
},
})
} finally {
@@ -162,8 +161,8 @@ test("Bedrock: config profile takes precedence over AWS_PROFILE env var", async
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.amazonBedrock]).toBeDefined()
- expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
+ expect(providers["amazon-bedrock"]).toBeDefined()
+ expect(providers["amazon-bedrock"].options?.region).toBe("us-east-1")
},
})
})
@@ -193,8 +192,8 @@ test("Bedrock: includes custom endpoint in options when specified", async () =>
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.amazonBedrock]).toBeDefined()
- expect(providers[ProviderID.amazonBedrock].options?.endpoint).toBe(
+ expect(providers["amazon-bedrock"]).toBeDefined()
+ expect(providers["amazon-bedrock"].options?.endpoint).toBe(
"https://bedrock-runtime.us-east-1.vpce-xxxxx.amazonaws.com",
)
},
@@ -229,8 +228,8 @@ test("Bedrock: autoloads when AWS_WEB_IDENTITY_TOKEN_FILE is present", async ()
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.amazonBedrock]).toBeDefined()
- expect(providers[ProviderID.amazonBedrock].options?.region).toBe("us-east-1")
+ expect(providers["amazon-bedrock"]).toBeDefined()
+ expect(providers["amazon-bedrock"].options?.region).toBe("us-east-1")
},
})
})
@@ -269,9 +268,9 @@ test("Bedrock: model with us. prefix should not be double-prefixed", async () =>
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.amazonBedrock]).toBeDefined()
+ expect(providers["amazon-bedrock"]).toBeDefined()
// The model should exist with the us. prefix
- expect(providers[ProviderID.amazonBedrock].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
+ expect(providers["amazon-bedrock"].models["us.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
@@ -306,8 +305,8 @@ test("Bedrock: model with global. prefix should not be prefixed", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.amazonBedrock]).toBeDefined()
- expect(providers[ProviderID.amazonBedrock].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
+ expect(providers["amazon-bedrock"]).toBeDefined()
+ expect(providers["amazon-bedrock"].models["global.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
@@ -342,8 +341,8 @@ test("Bedrock: model with eu. prefix should not be double-prefixed", async () =>
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.amazonBedrock]).toBeDefined()
- expect(providers[ProviderID.amazonBedrock].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
+ expect(providers["amazon-bedrock"]).toBeDefined()
+ expect(providers["amazon-bedrock"].models["eu.anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
@@ -378,9 +377,9 @@ test("Bedrock: model without prefix in US region should get us. prefix added", a
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.amazonBedrock]).toBeDefined()
+ expect(providers["amazon-bedrock"]).toBeDefined()
// Non-prefixed model should still be registered
- expect(providers[ProviderID.amazonBedrock].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
+ expect(providers["amazon-bedrock"].models["anthropic.claude-opus-4-5-20251101-v1:0"]).toBeDefined()
},
})
})
diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts
index f30e3297de2..86e08a79284 100644
--- a/packages/opencode/test/provider/gitlab-duo.test.ts
+++ b/packages/opencode/test/provider/gitlab-duo.test.ts
@@ -1,7 +1,6 @@
import { test, expect } from "bun:test"
import path from "path"
-import { ProviderID } from "../../src/provider/schema"
import { tmpdir } from "../fixture/fixture"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
@@ -26,8 +25,8 @@ test("GitLab Duo: loads provider with API key from environment", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.gitlab]).toBeDefined()
- expect(providers[ProviderID.gitlab].key).toBe("test-gitlab-token")
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].key).toBe("test-gitlab-token")
},
})
})
@@ -58,8 +57,8 @@ test("GitLab Duo: config instanceUrl option sets baseURL", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.gitlab]).toBeDefined()
- expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.example.com")
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.example.com")
},
})
})
@@ -96,7 +95,7 @@ test("GitLab Duo: loads with OAuth token from auth.json", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.gitlab]).toBeDefined()
+ expect(providers["gitlab"]).toBeDefined()
},
})
})
@@ -131,8 +130,8 @@ test("GitLab Duo: loads with Personal Access Token from auth.json", async () =>
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.gitlab]).toBeDefined()
- expect(providers[ProviderID.gitlab].key).toBe("glpat-test-pat-token")
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].key).toBe("glpat-test-pat-token")
},
})
})
@@ -163,8 +162,8 @@ test("GitLab Duo: supports self-hosted instance configuration", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.gitlab]).toBeDefined()
- expect(providers[ProviderID.gitlab].options?.instanceUrl).toBe("https://gitlab.company.internal")
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.company.internal")
},
})
})
@@ -194,7 +193,7 @@ test("GitLab Duo: config apiKey takes precedence over environment variable", asy
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.gitlab]).toBeDefined()
+ expect(providers["gitlab"]).toBeDefined()
},
})
})
@@ -217,10 +216,8 @@ test("GitLab Duo: includes context-1m beta header in aiGatewayHeaders", async ()
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.gitlab]).toBeDefined()
- expect(providers[ProviderID.gitlab].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain(
- "context-1m-2025-08-07",
- )
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].options?.aiGatewayHeaders?.["anthropic-beta"]).toContain("context-1m-2025-08-07")
},
})
})
@@ -253,9 +250,9 @@ test("GitLab Duo: supports feature flags configuration", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.gitlab]).toBeDefined()
- expect(providers[ProviderID.gitlab].options?.featureFlags).toBeDefined()
- expect(providers[ProviderID.gitlab].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
+ expect(providers["gitlab"]).toBeDefined()
+ expect(providers["gitlab"].options?.featureFlags).toBeDefined()
+ expect(providers["gitlab"].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true)
},
})
})
@@ -278,8 +275,8 @@ test("GitLab Duo: has multiple agentic chat models available", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.gitlab]).toBeDefined()
- const models = Object.keys(providers[ProviderID.gitlab].models)
+ expect(providers["gitlab"]).toBeDefined()
+ const models = Object.keys(providers["gitlab"].models)
expect(models.length).toBeGreaterThan(0)
expect(models).toContain("duo-chat-haiku-4-5")
expect(models).toContain("duo-chat-sonnet-4-5")
diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts
index 72ba9dba5a5..b14d2752240 100644
--- a/packages/opencode/test/provider/provider.test.ts
+++ b/packages/opencode/test/provider/provider.test.ts
@@ -25,11 +25,11 @@ test("provider loaded from env variable", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic]).toBeDefined()
+ expect(providers["anthropic"]).toBeDefined()
// Provider should retain its connection source even if custom loaders
// merge additional options.
- expect(providers[ProviderID.anthropic].source).toBe("env")
- expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
+ expect(providers["anthropic"].source).toBe("env")
+ expect(providers["anthropic"].options.headers["anthropic-beta"]).toBeDefined()
},
})
})
@@ -56,7 +56,7 @@ test("provider loaded from config with apiKey option", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic]).toBeDefined()
+ expect(providers["anthropic"]).toBeDefined()
},
})
})
@@ -80,7 +80,7 @@ test("disabled_providers excludes provider", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic]).toBeUndefined()
+ expect(providers["anthropic"]).toBeUndefined()
},
})
})
@@ -105,8 +105,8 @@ test("enabled_providers restricts to only listed providers", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic]).toBeDefined()
- expect(providers[ProviderID.openai]).toBeUndefined()
+ expect(providers["anthropic"]).toBeDefined()
+ expect(providers["openai"]).toBeUndefined()
},
})
})
@@ -134,8 +134,8 @@ test("model whitelist filters models for provider", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic]).toBeDefined()
- const models = Object.keys(providers[ProviderID.anthropic].models)
+ expect(providers["anthropic"]).toBeDefined()
+ const models = Object.keys(providers["anthropic"].models)
expect(models).toContain("claude-sonnet-4-20250514")
expect(models.length).toBe(1)
},
@@ -165,8 +165,8 @@ test("model blacklist excludes specific models", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic]).toBeDefined()
- const models = Object.keys(providers[ProviderID.anthropic].models)
+ expect(providers["anthropic"]).toBeDefined()
+ const models = Object.keys(providers["anthropic"].models)
expect(models).not.toContain("claude-sonnet-4-20250514")
},
})
@@ -200,9 +200,9 @@ test("custom model alias via config", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic]).toBeDefined()
- expect(providers[ProviderID.anthropic].models["my-alias"]).toBeDefined()
- expect(providers[ProviderID.anthropic].models["my-alias"].name).toBe("My Custom Alias")
+ expect(providers["anthropic"]).toBeDefined()
+ expect(providers["anthropic"].models["my-alias"]).toBeDefined()
+ expect(providers["anthropic"].models["my-alias"].name).toBe("My Custom Alias")
},
})
})
@@ -243,9 +243,9 @@ test("custom provider with npm package", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("custom-provider")]).toBeDefined()
- expect(providers[ProviderID.make("custom-provider")].name).toBe("Custom Provider")
- expect(providers[ProviderID.make("custom-provider")].models["custom-model"]).toBeDefined()
+ expect(providers["custom-provider"]).toBeDefined()
+ expect(providers["custom-provider"].name).toBe("Custom Provider")
+ expect(providers["custom-provider"].models["custom-model"]).toBeDefined()
},
})
})
@@ -276,10 +276,10 @@ test("env variable takes precedence, config merges options", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic]).toBeDefined()
+ expect(providers["anthropic"]).toBeDefined()
// Config options should be merged
- expect(providers[ProviderID.anthropic].options.timeout).toBe(60000)
- expect(providers[ProviderID.anthropic].options.chunkTimeout).toBe(15000)
+ expect(providers["anthropic"].options.timeout).toBe(60000)
+ expect(providers["anthropic"].options.chunkTimeout).toBe(15000)
},
})
})
@@ -446,8 +446,8 @@ test("provider with baseURL from config", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("custom-openai")]).toBeDefined()
- expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1")
+ expect(providers["custom-openai"]).toBeDefined()
+ expect(providers["custom-openai"].options.baseURL).toBe("https://custom.openai.com/v1")
},
})
})
@@ -484,7 +484,7 @@ test("model cost defaults to zero when not specified", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.make("test-provider")].models["test-model"]
+ const model = providers["test-provider"].models["test-model"]
expect(model.cost.input).toBe(0)
expect(model.cost.output).toBe(0)
expect(model.cost.cache.read).toBe(0)
@@ -522,7 +522,7 @@ test("model options are merged from existing model", async () => {
},
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.options.customOption).toBe("custom-value")
},
})
@@ -551,7 +551,7 @@ test("provider removed when all models filtered out", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic]).toBeUndefined()
+ expect(providers["anthropic"]).toBeUndefined()
},
})
})
@@ -629,7 +629,7 @@ test("getModel uses realIdByKey for aliased models", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic].models["my-sonnet"]).toBeDefined()
+ expect(providers["anthropic"].models["my-sonnet"]).toBeDefined()
const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("my-sonnet"))
expect(model).toBeDefined()
@@ -673,7 +673,7 @@ test("provider api field sets model api.url", async () => {
fn: async () => {
const providers = await Provider.list()
// api field is stored on model.api.url, used by getSDK to set baseURL
- expect(providers[ProviderID.make("custom-api")].models["model-1"].api.url).toBe("https://api.example.com/v1")
+ expect(providers["custom-api"].models["model-1"].api.url).toBe("https://api.example.com/v1")
},
})
})
@@ -712,7 +712,7 @@ test("explicit baseURL overrides api field", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("custom-api")].options.baseURL).toBe("https://custom.override.com/v1")
+ expect(providers["custom-api"].options.baseURL).toBe("https://custom.override.com/v1")
},
})
})
@@ -744,7 +744,7 @@ test("model inherits properties from existing database model", async () => {
},
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.name).toBe("Custom Name for Sonnet")
expect(model.capabilities.toolcall).toBe(true)
expect(model.capabilities.attachment).toBe(true)
@@ -772,7 +772,7 @@ test("disabled_providers prevents loading even with env var", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.openai]).toBeUndefined()
+ expect(providers["openai"]).toBeUndefined()
},
})
})
@@ -826,8 +826,8 @@ test("whitelist and blacklist can be combined", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic]).toBeDefined()
- const models = Object.keys(providers[ProviderID.anthropic].models)
+ expect(providers["anthropic"]).toBeDefined()
+ const models = Object.keys(providers["anthropic"].models)
expect(models).toContain("claude-sonnet-4-20250514")
expect(models).not.toContain("claude-opus-4-20250514")
expect(models.length).toBe(1)
@@ -865,7 +865,7 @@ test("model modalities default correctly", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.make("test-provider")].models["test-model"]
+ const model = providers["test-provider"].models["test-model"]
expect(model.capabilities.input.text).toBe(true)
expect(model.capabilities.output.text).toBe(true)
},
@@ -908,7 +908,7 @@ test("model with custom cost values", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.make("test-provider")].models["test-model"]
+ const model = providers["test-provider"].models["test-model"]
expect(model.cost.input).toBe(5)
expect(model.cost.output).toBe(15)
expect(model.cost.cache.read).toBe(2.5)
@@ -1009,10 +1009,10 @@ test("multiple providers can be configured simultaneously", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic]).toBeDefined()
- expect(providers[ProviderID.openai]).toBeDefined()
- expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
- expect(providers[ProviderID.openai].options.timeout).toBe(60000)
+ expect(providers["anthropic"]).toBeDefined()
+ expect(providers["openai"]).toBeDefined()
+ expect(providers["anthropic"].options.timeout).toBe(30000)
+ expect(providers["openai"].options.timeout).toBe(60000)
},
})
})
@@ -1050,9 +1050,9 @@ test("provider with custom npm package", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("local-llm")]).toBeDefined()
- expect(providers[ProviderID.make("local-llm")].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
- expect(providers[ProviderID.make("local-llm")].options.baseURL).toBe("http://localhost:11434/v1")
+ expect(providers["local-llm"]).toBeDefined()
+ expect(providers["local-llm"].models["llama-3"].api.npm).toBe("@ai-sdk/openai-compatible")
+ expect(providers["local-llm"].options.baseURL).toBe("http://localhost:11434/v1")
},
})
})
@@ -1087,7 +1087,7 @@ test("model alias name defaults to alias key when id differs", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.anthropic].models["sonnet"].name).toBe("sonnet")
+ expect(providers["anthropic"].models["sonnet"].name).toBe("sonnet")
},
})
})
@@ -1127,9 +1127,9 @@ test("provider with multiple env var options only includes apiKey when single en
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("multi-env")]).toBeDefined()
+ expect(providers["multi-env"]).toBeDefined()
// When multiple env options exist, key should NOT be auto-set
- expect(providers[ProviderID.make("multi-env")].key).toBeUndefined()
+ expect(providers["multi-env"].key).toBeUndefined()
},
})
})
@@ -1169,9 +1169,9 @@ test("provider with single env var includes apiKey automatically", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("single-env")]).toBeDefined()
+ expect(providers["single-env"]).toBeDefined()
// Single env option should auto-set key
- expect(providers[ProviderID.make("single-env")].key).toBe("my-api-key")
+ expect(providers["single-env"].key).toBe("my-api-key")
},
})
})
@@ -1206,7 +1206,7 @@ test("model cost overrides existing cost values", async () => {
},
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.cost.input).toBe(999)
expect(model.cost.output).toBe(888)
},
@@ -1253,9 +1253,9 @@ test("completely new provider not in database can be configured", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("brand-new-provider")]).toBeDefined()
- expect(providers[ProviderID.make("brand-new-provider")].name).toBe("Brand New")
- const model = providers[ProviderID.make("brand-new-provider")].models["new-model"]
+ expect(providers["brand-new-provider"]).toBeDefined()
+ expect(providers["brand-new-provider"].name).toBe("Brand New")
+ const model = providers["brand-new-provider"].models["new-model"]
expect(model.capabilities.reasoning).toBe(true)
expect(model.capabilities.attachment).toBe(true)
expect(model.capabilities.input.image).toBe(true)
@@ -1288,11 +1288,11 @@ test("disabled_providers and enabled_providers interaction", async () => {
fn: async () => {
const providers = await Provider.list()
// anthropic: in enabled, not in disabled = allowed
- expect(providers[ProviderID.anthropic]).toBeDefined()
+ expect(providers["anthropic"]).toBeDefined()
// openai: in enabled, but also in disabled = NOT allowed
- expect(providers[ProviderID.openai]).toBeUndefined()
+ expect(providers["openai"]).toBeUndefined()
// google: not in enabled = NOT allowed (even though not disabled)
- expect(providers[ProviderID.google]).toBeUndefined()
+ expect(providers["google"]).toBeUndefined()
},
})
})
@@ -1327,7 +1327,7 @@ test("model with tool_call false", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("no-tools")].models["basic-model"].capabilities.toolcall).toBe(false)
+ expect(providers["no-tools"].models["basic-model"].capabilities.toolcall).toBe(false)
},
})
})
@@ -1362,7 +1362,7 @@ test("model defaults tool_call to true when not specified", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("default-tools")].models["model"].capabilities.toolcall).toBe(true)
+ expect(providers["default-tools"].models["model"].capabilities.toolcall).toBe(true)
},
})
})
@@ -1401,7 +1401,7 @@ test("model headers are preserved", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.make("headers-provider")].models["model"]
+ const model = providers["headers-provider"].models["model"]
expect(model.headers).toEqual({
"X-Custom-Header": "custom-value",
Authorization: "Bearer special-token",
@@ -1445,7 +1445,7 @@ test("provider env fallback - second env var used if first missing", async () =>
fn: async () => {
const providers = await Provider.list()
// Provider should load because fallback env var is set
- expect(providers[ProviderID.make("fallback-env")]).toBeDefined()
+ expect(providers["fallback-env"]).toBeDefined()
},
})
})
@@ -1506,7 +1506,7 @@ test("provider name defaults to id when not in database", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("my-custom-id")].name).toBe("my-custom-id")
+ expect(providers["my-custom-id"].name).toBe("my-custom-id")
},
})
})
@@ -1689,7 +1689,7 @@ test("model limit defaults to zero when not specified", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.make("no-limit")].models["model"]
+ const model = providers["no-limit"].models["model"]
expect(model.limit.context).toBe(0)
expect(model.limit.output).toBe(0)
},
@@ -1725,10 +1725,10 @@ test("provider options are deeply merged", async () => {
fn: async () => {
const providers = await Provider.list()
// Custom options should be merged
- expect(providers[ProviderID.anthropic].options.timeout).toBe(30000)
- expect(providers[ProviderID.anthropic].options.headers["X-Custom"]).toBe("custom-value")
+ expect(providers["anthropic"].options.timeout).toBe(30000)
+ expect(providers["anthropic"].options.headers["X-Custom"]).toBe("custom-value")
// anthropic custom loader adds its own headers, they should coexist
- expect(providers[ProviderID.anthropic].options.headers["anthropic-beta"]).toBeDefined()
+ expect(providers["anthropic"].options.headers["anthropic-beta"]).toBeDefined()
},
})
})
@@ -1762,7 +1762,7 @@ test("custom model inherits npm package from models.dev provider config", async
},
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.openai].models["my-custom-model"]
+ const model = providers["openai"].models["my-custom-model"]
expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai")
},
@@ -1797,15 +1797,15 @@ test("custom model inherits api.url from models.dev provider", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.openrouter]).toBeDefined()
+ expect(providers["openrouter"]).toBeDefined()
// New model not in database should inherit api.url from provider
- const intellect = providers[ProviderID.openrouter].models["prime-intellect/intellect-3"]
+ const intellect = providers["openrouter"].models["prime-intellect/intellect-3"]
expect(intellect).toBeDefined()
expect(intellect.api.url).toBe("https://openrouter.ai/api/v1")
// Another new model should also inherit api.url
- const deepseek = providers[ProviderID.openrouter].models["deepseek/deepseek-r1-0528"]
+ const deepseek = providers["openrouter"].models["deepseek/deepseek-r1-0528"]
expect(deepseek).toBeDefined()
expect(deepseek.api.url).toBe("https://openrouter.ai/api/v1")
expect(deepseek.name).toBe("DeepSeek R1")
@@ -1832,7 +1832,7 @@ test("model variants are generated for reasoning models", async () => {
fn: async () => {
const providers = await Provider.list()
// Claude sonnet 4 has reasoning capability
- const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.capabilities.reasoning).toBe(true)
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBeGreaterThan(0)
@@ -1869,7 +1869,7 @@ test("model variants can be disabled via config", async () => {
},
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
// max variant should still exist
@@ -1912,7 +1912,7 @@ test("model variants can be customized via config", async () => {
},
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
expect(model.variants!["high"].thinking.budgetTokens).toBe(20000)
},
@@ -1951,7 +1951,7 @@ test("disabled key is stripped from variant config", async () => {
},
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants!["max"]).toBeDefined()
expect(model.variants!["max"].disabled).toBeUndefined()
expect(model.variants!["max"].customField).toBe("test")
@@ -1989,7 +1989,7 @@ test("all variants can be disabled via config", async () => {
},
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants).toBeDefined()
expect(Object.keys(model.variants!).length).toBe(0)
},
@@ -2027,7 +2027,7 @@ test("variant config merges with generated variants", async () => {
},
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
+ const model = providers["anthropic"].models["claude-sonnet-4-20250514"]
expect(model.variants!["high"]).toBeDefined()
// Should have both the generated thinking config and the custom option
expect(model.variants!["high"].thinking).toBeDefined()
@@ -2065,7 +2065,7 @@ test("variants filtered in second pass for database models", async () => {
},
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.openai].models["gpt-5"]
+ const model = providers["openai"].models["gpt-5"]
expect(model.variants).toBeDefined()
expect(model.variants!["high"]).toBeUndefined()
// Other variants should still exist
@@ -2111,7 +2111,7 @@ test("custom model with variants enabled and disabled", async () => {
directory: tmp.path,
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.make("custom-reasoning")].models["reasoning-model"]
+ const model = providers["custom-reasoning"].models["reasoning-model"]
expect(model.variants).toBeDefined()
// Enabled variants should exist
expect(model.variants!["low"]).toBeDefined()
@@ -2169,8 +2169,8 @@ test("Google Vertex: retains baseURL for custom proxy", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("vertex-proxy")]).toBeDefined()
- expect(providers[ProviderID.make("vertex-proxy")].options.baseURL).toBe("https://my-proxy.com/v1")
+ expect(providers["vertex-proxy"]).toBeDefined()
+ expect(providers["vertex-proxy"].options.baseURL).toBe("https://my-proxy.com/v1")
},
})
})
@@ -2214,7 +2214,7 @@ test("Google Vertex: supports OpenAI compatible models", async () => {
},
fn: async () => {
const providers = await Provider.list()
- const model = providers[ProviderID.make("vertex-openai")].models["gpt-4"]
+ const model = providers["vertex-openai"].models["gpt-4"]
expect(model).toBeDefined()
expect(model.api.npm).toBe("@ai-sdk/openai-compatible")
@@ -2242,7 +2242,7 @@ test("cloudflare-ai-gateway loads with env variables", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
+ expect(providers["cloudflare-ai-gateway"]).toBeDefined()
},
})
})
@@ -2274,8 +2274,8 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => {
},
fn: async () => {
const providers = await Provider.list()
- expect(providers[ProviderID.make("cloudflare-ai-gateway")]).toBeDefined()
- expect(providers[ProviderID.make("cloudflare-ai-gateway")].options.metadata).toEqual({
+ expect(providers["cloudflare-ai-gateway"]).toBeDefined()
+ expect(providers["cloudflare-ai-gateway"].options.metadata).toEqual({
invoked_by: "test",
project: "opencode",
})
diff --git a/packages/opencode/test/session/message-v2.test.ts b/packages/opencode/test/session/message-v2.test.ts
index 0d5b89730a9..86c9254f1da 100644
--- a/packages/opencode/test/session/message-v2.test.ts
+++ b/packages/opencode/test/session/message-v2.test.ts
@@ -4,7 +4,6 @@ import { MessageV2 } from "../../src/session/message-v2"
import type { Provider } from "../../src/provider/provider"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { SessionID, MessageID, PartID } from "../../src/session/schema"
-import { Question } from "../../src/question"
const sessionID = SessionID.make("session")
const providerID = ProviderID.make("test")
@@ -916,15 +915,4 @@ describe("session.message-v2.fromError", () => {
},
})
})
-
- test("serializes tagged errors with their message", () => {
- const result = MessageV2.fromError(new Question.RejectedError(), { providerID })
-
- expect(result).toStrictEqual({
- name: "UnknownError",
- data: {
- message: "The user dismissed this question",
- },
- })
- })
})
diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts
deleted file mode 100644
index 47f5f6fc25d..00000000000
--- a/packages/opencode/test/session/system.test.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import path from "path"
-import { Agent } from "../../src/agent/agent"
-import { Instance } from "../../src/project/instance"
-import { SystemPrompt } from "../../src/session/system"
-import { tmpdir } from "../fixture/fixture"
-
-describe("session.system", () => {
- test("skills output is sorted by name and stable across calls", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- for (const [name, description] of [
- ["zeta-skill", "Zeta skill."],
- ["alpha-skill", "Alpha skill."],
- ["middle-skill", "Middle skill."],
- ]) {
- const skillDir = path.join(dir, ".opencode", "skill", name)
- await Bun.write(
- path.join(skillDir, "SKILL.md"),
- `---
-name: ${name}
-description: ${description}
----
-
-# ${name}
-`,
- )
- }
- },
- })
-
- const home = process.env.OPENCODE_TEST_HOME
- process.env.OPENCODE_TEST_HOME = tmp.path
-
- try {
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const build = await Agent.get("build")
- const first = await SystemPrompt.skills(build!)
- const second = await SystemPrompt.skills(build!)
-
- expect(first).toBe(second)
-
- const alpha = first!.indexOf("alpha-skill ")
- const middle = first!.indexOf("middle-skill ")
- const zeta = first!.indexOf("zeta-skill ")
-
- expect(alpha).toBeGreaterThan(-1)
- expect(middle).toBeGreaterThan(alpha)
- expect(zeta).toBeGreaterThan(middle)
- },
- })
- } finally {
- process.env.OPENCODE_TEST_HOME = home
- }
- })
-})
diff --git a/packages/opencode/test/tool/skill.test.ts b/packages/opencode/test/tool/skill.test.ts
index f622341d333..7cfaee1353a 100644
--- a/packages/opencode/test/tool/skill.test.ts
+++ b/packages/opencode/test/tool/skill.test.ts
@@ -54,56 +54,6 @@ description: Skill for tool tests.
}
})
- test("description sorts skills by name and is stable across calls", async () => {
- await using tmp = await tmpdir({
- git: true,
- init: async (dir) => {
- for (const [name, description] of [
- ["zeta-skill", "Zeta skill."],
- ["alpha-skill", "Alpha skill."],
- ["middle-skill", "Middle skill."],
- ]) {
- const skillDir = path.join(dir, ".opencode", "skill", name)
- await Bun.write(
- path.join(skillDir, "SKILL.md"),
- `---
-name: ${name}
-description: ${description}
----
-
-# ${name}
-`,
- )
- }
- },
- })
-
- const home = process.env.OPENCODE_TEST_HOME
- process.env.OPENCODE_TEST_HOME = tmp.path
-
- try {
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const first = await SkillTool.init()
- const second = await SkillTool.init()
-
- expect(first.description).toBe(second.description)
-
- const alpha = first.description.indexOf("**alpha-skill**: Alpha skill.")
- const middle = first.description.indexOf("**middle-skill**: Middle skill.")
- const zeta = first.description.indexOf("**zeta-skill**: Zeta skill.")
-
- expect(alpha).toBeGreaterThan(-1)
- expect(middle).toBeGreaterThan(alpha)
- expect(zeta).toBeGreaterThan(middle)
- },
- })
- } finally {
- process.env.OPENCODE_TEST_HOME = home
- }
- })
-
test("execute returns skill content block with files", async () => {
await using tmp = await tmpdir({
git: true,
diff --git a/packages/opencode/test/tool/task.test.ts b/packages/opencode/test/tool/task.test.ts
deleted file mode 100644
index df319d8de1e..00000000000
--- a/packages/opencode/test/tool/task.test.ts
+++ /dev/null
@@ -1,45 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { Agent } from "../../src/agent/agent"
-import { Instance } from "../../src/project/instance"
-import { TaskTool } from "../../src/tool/task"
-import { tmpdir } from "../fixture/fixture"
-
-describe("tool.task", () => {
- test("description sorts subagents by name and is stable across calls", async () => {
- await using tmp = await tmpdir({
- config: {
- agent: {
- zebra: {
- description: "Zebra agent",
- mode: "subagent",
- },
- alpha: {
- description: "Alpha agent",
- mode: "subagent",
- },
- },
- },
- })
-
- await Instance.provide({
- directory: tmp.path,
- fn: async () => {
- const build = await Agent.get("build")
- const first = await TaskTool.init({ agent: build })
- const second = await TaskTool.init({ agent: build })
-
- expect(first.description).toBe(second.description)
-
- const alpha = first.description.indexOf("- alpha: Alpha agent")
- const explore = first.description.indexOf("- explore:")
- const general = first.description.indexOf("- general:")
- const zebra = first.description.indexOf("- zebra: Zebra agent")
-
- expect(alpha).toBeGreaterThan(-1)
- expect(explore).toBeGreaterThan(alpha)
- expect(general).toBeGreaterThan(explore)
- expect(zebra).toBeGreaterThan(general)
- },
- })
- })
-})
diff --git a/packages/opencode/test/util/process.test.ts b/packages/opencode/test/util/process.test.ts
index 1d08cba6b71..b9bc50f9b4c 100644
--- a/packages/opencode/test/util/process.test.ts
+++ b/packages/opencode/test/util/process.test.ts
@@ -109,20 +109,4 @@ describe("util.process", () => {
expect(await proc.exited).toBe(0)
})
-
- test("rejects missing commands without leaking unhandled errors", async () => {
- await using tmp = await tmpdir()
- const cmd = path.join(tmp.path, "missing" + (process.platform === "win32" ? ".cmd" : ""))
- const err = await Process.spawn([cmd], {
- stdin: "pipe",
- stdout: "pipe",
- stderr: "pipe",
- }).exited.catch((err) => err)
-
- expect(err).toBeInstanceOf(Error)
- if (!(err instanceof Error)) throw err
- expect(err).toMatchObject({
- code: "ENOENT",
- })
- })
})
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index b6821322e2f..aa759bb1e09 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -2845,38 +2845,6 @@ export class File extends HeyApiClient {
}
}
-export class Event extends HeyApiClient {
- /**
- * Subscribe to events
- *
- * Get events
- */
- public subscribe(
- parameters?: {
- directory?: string
- workspace?: string
- },
- options?: Options,
- ) {
- const params = buildClientParams(
- [parameters],
- [
- {
- args: [
- { in: "query", key: "directory" },
- { in: "query", key: "workspace" },
- ],
- },
- ],
- )
- return (options?.client ?? this.client).sse.get({
- url: "/event",
- ...options,
- ...params,
- })
- }
-}
-
export class Auth2 extends HeyApiClient {
/**
* Remove MCP OAuth
@@ -3898,6 +3866,38 @@ export class Formatter extends HeyApiClient {
}
}
+export class Event extends HeyApiClient {
+ /**
+ * Subscribe to events
+ *
+ * Get events
+ */
+ public subscribe(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).sse.get({
+ url: "/event",
+ ...options,
+ ...params,
+ })
+ }
+}
+
export class OpencodeClient extends HeyApiClient {
public static readonly __registry = new HeyApiRegistry()
@@ -3981,11 +3981,6 @@ export class OpencodeClient extends HeyApiClient {
return (this._file ??= new File({ client: this.client }))
}
- private _event?: Event
- get event(): Event {
- return (this._event ??= new Event({ client: this.client }))
- }
-
private _mcp?: Mcp
get mcp(): Mcp {
return (this._mcp ??= new Mcp({ client: this.client }))
@@ -4030,4 +4025,9 @@ export class OpencodeClient extends HeyApiClient {
get formatter(): Formatter {
return (this._formatter ??= new Formatter({ client: this.client }))
}
+
+ private _event?: Event
+ get event(): Event {
+ return (this._event ??= new Event({ client: this.client }))
+ }
}
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index ec797f2ba81..41aa248171c 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -4229,25 +4229,6 @@ export type FileStatusResponses = {
export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses]
-export type EventSubscribeData = {
- body?: never
- path?: never
- query?: {
- directory?: string
- workspace?: string
- }
- url: "/event"
-}
-
-export type EventSubscribeResponses = {
- /**
- * Event stream
- */
- 200: Event
-}
-
-export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]
-
export type McpStatusData = {
body?: never
path?: never
@@ -4998,3 +4979,22 @@ export type FormatterStatusResponses = {
}
export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]
+
+export type EventSubscribeData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/event"
+}
+
+export type EventSubscribeResponses = {
+ /**
+ * Event stream
+ */
+ 200: Event
+}
+
+export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index fa894045e1c..350395423f0 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -5243,47 +5243,6 @@
]
}
},
- "/event": {
- "get": {
- "operationId": "event.subscribe",
- "parameters": [
- {
- "in": "query",
- "name": "directory",
- "schema": {
- "type": "string"
- }
- },
- {
- "in": "query",
- "name": "workspace",
- "schema": {
- "type": "string"
- }
- }
- ],
- "summary": "Subscribe to events",
- "description": "Get events",
- "responses": {
- "200": {
- "description": "Event stream",
- "content": {
- "text/event-stream": {
- "schema": {
- "$ref": "#/components/schemas/Event"
- }
- }
- }
- }
- },
- "x-codeSamples": [
- {
- "lang": "js",
- "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})"
- }
- ]
- }
- },
"/mcp": {
"get": {
"operationId": "mcp.status",
@@ -6935,6 +6894,47 @@
}
]
}
+ },
+ "/event": {
+ "get": {
+ "operationId": "event.subscribe",
+ "parameters": [
+ {
+ "in": "query",
+ "name": "directory",
+ "schema": {
+ "type": "string"
+ }
+ },
+ {
+ "in": "query",
+ "name": "workspace",
+ "schema": {
+ "type": "string"
+ }
+ }
+ ],
+ "summary": "Subscribe to events",
+ "description": "Get events",
+ "responses": {
+ "200": {
+ "description": "Event stream",
+ "content": {
+ "text/event-stream": {
+ "schema": {
+ "$ref": "#/components/schemas/Event"
+ }
+ }
+ }
+ }
+ },
+ "x-codeSamples": [
+ {
+ "lang": "js",
+ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.event.subscribe({\n ...\n})"
+ }
+ ]
+ }
}
},
"components": {
diff --git a/packages/ui/src/components/file.tsx b/packages/ui/src/components/file.tsx
index 15915dd52d4..261e78bd031 100644
--- a/packages/ui/src/components/file.tsx
+++ b/packages/ui/src/components/file.tsx
@@ -523,6 +523,30 @@ function scrollParent(el: HTMLElement): HTMLElement | undefined {
}
}
+function reveal(viewer: Viewer, range: SelectedLineRange | null | undefined) {
+ if (!range) return
+ const line = Math.min(range.start, range.end)
+ requestAnimationFrame(() => {
+ const root = viewer.getRoot()
+ const wrap = viewer.wrapper
+ if (!root || !wrap) return
+ const path = range.side ? `[data-${range.side}] [data-line="${line}"]` : `[data-line="${line}"]`
+ const node = root.querySelector(path) ?? root.querySelector(`[data-line="${line}"]`)
+ if (!(node instanceof HTMLElement)) return
+ const parent = scrollParent(wrap)
+ if (!parent) {
+ node.scrollIntoView({ block: "center", inline: "nearest" })
+ return
+ }
+ const box = parent.getBoundingClientRect()
+ const item = node.getBoundingClientRect()
+ const top = item.top - box.top + parent.scrollTop
+ const target = top - parent.clientHeight / 2 + item.height / 2
+ const max = Math.max(0, parent.scrollHeight - parent.clientHeight)
+ parent.scrollTop = Math.max(0, Math.min(target, max))
+ })
+}
+
function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
let virtualizer: Virtualizer | undefined
let root: Document | HTMLElement | undefined
@@ -836,6 +860,11 @@ function TextViewer(props: TextFileProps) {
})
}
+ createEffect(() => {
+ viewer.rendered()
+ reveal(viewer, local.selectedLines)
+ })
+
useSearchHandle({
search: () => local.search,
find: viewer.find,
@@ -1027,6 +1056,11 @@ function DiffViewer(props: DiffFileProps) {
})
}
+ createEffect(() => {
+ viewer.rendered()
+ reveal(viewer, local.selectedLines)
+ })
+
useSearchHandle({
search: () => local.search,
find: viewer.find,
diff --git a/packages/ui/src/components/markdown-file-ref.test.ts b/packages/ui/src/components/markdown-file-ref.test.ts
new file mode 100644
index 00000000000..2b066cccd30
--- /dev/null
+++ b/packages/ui/src/components/markdown-file-ref.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, test } from "bun:test"
+import { parseCodeFileRef, splitCodeText } from "./markdown-file-ref"
+
+describe("parseCodeFileRef", () => {
+ test("parses relative path with line and trims punctuation", () => {
+ expect(parseCodeFileRef("src/app.ts:42,", "")).toEqual({
+ path: "src/app.ts",
+ line: 42,
+ })
+ })
+
+ test("parses hash-based line suffix", () => {
+ expect(parseCodeFileRef("src/app.ts#L12", "")).toEqual({
+ path: "src/app.ts",
+ line: 12,
+ })
+ })
+
+ test("parses file urls and strips project root", () => {
+ expect(parseCodeFileRef("file:///Users/test/repo/src/main.ts:9", "/Users/test/repo")).toEqual({
+ path: "src/main.ts",
+ line: 9,
+ })
+ })
+
+ test("normalizes windows paths", () => {
+ expect(parseCodeFileRef("C:\\repo\\src\\main.ts:7", "")).toEqual({
+ path: "C:/repo/src/main.ts",
+ line: 7,
+ })
+ })
+
+ test("parses windows file url paths", () => {
+ expect(parseCodeFileRef("file:///C:/repo/src/main.ts#L11", "")).toEqual({
+ path: "C:/repo/src/main.ts",
+ line: 11,
+ })
+ })
+
+ test("normalizes line breaks inside long paths", () => {
+ expect(parseCodeFileRef("clients/notes/reply-to-\nharry-2026-02-27.md", "")).toEqual({
+ path: "clients/notes/reply-to-harry-2026-02-27.md",
+ })
+ })
+
+ test("ignores non-path text", () => {
+ expect(parseCodeFileRef("hello-world", "")).toBeUndefined()
+ })
+})
+
+describe("splitCodeText", () => {
+ test("splits plain text file refs", () => {
+ expect(splitCodeText("See src/app.ts:42 for details", "")).toEqual([
+ { text: "See " },
+ { text: "src/app.ts:42", file: { path: "src/app.ts", line: 42 } },
+ { text: " for details" },
+ ])
+ })
+
+ test("keeps plain text without refs intact", () => {
+ expect(splitCodeText("hello world", "")).toEqual([{ text: "hello world" }])
+ })
+})
diff --git a/packages/ui/src/components/markdown-file-ref.ts b/packages/ui/src/components/markdown-file-ref.ts
new file mode 100644
index 00000000000..ac794a85663
--- /dev/null
+++ b/packages/ui/src/components/markdown-file-ref.ts
@@ -0,0 +1,76 @@
+export type FileRef = {
+ path: string
+ line?: number
+}
+
+export type FileText = {
+ text: string
+ file?: FileRef
+}
+
+function looksLikePath(path: string) {
+ if (!path) return false
+ if (path.startsWith("./") || path.startsWith("../") || path.startsWith("/")) return true
+ if (/^[a-zA-Z]:[\\/]/.test(path)) return true
+ return path.includes("/") || path.includes("\\")
+}
+
+function normalizeProjectPath(path: string, directory: string) {
+ if (!path) return path
+ const file = path.replace(/\\/g, "/")
+ const root = directory.replace(/\\/g, "/")
+ if (/^\/[a-zA-Z]:\//.test(file)) return file.slice(1)
+ if (file.startsWith(root + "/")) return file.slice(root.length + 1)
+ if (file === root) return ""
+ if (file.startsWith("./")) return file.slice(2)
+ return file
+}
+
+export function parseCodeFileRef(text: string, directory: string): FileRef | undefined {
+ let value = text
+ .trim()
+ .replace(/\s*\n\s*/g, "")
+ .replace(/[),.;!?]+$/, "")
+ let lineFromUrlHash: number | undefined
+ if (!value) return
+
+ if (value.startsWith("file://")) {
+ try {
+ const url = new URL(value)
+ value = decodeURIComponent(url.pathname)
+ const match = url.hash.match(/^#L(\d+)$/)
+ lineFromUrlHash = match ? Number(match[1]) : undefined
+ } catch {
+ return
+ }
+ }
+
+ const hash = value.match(/#L(\d+)$/)
+ const lineFromHash = hash ? Number(hash[1]) : undefined
+ if (hash) value = value.slice(0, -hash[0].length)
+
+ const line = value.match(/:(\d+)(?::\d+)?$/)
+ const lineFromSuffix = line ? Number(line[1]) : undefined
+ if (line) {
+ const maybePath = value.slice(0, -line[0].length)
+ if (looksLikePath(maybePath)) value = maybePath
+ }
+
+ if (!looksLikePath(value)) return
+ const path = normalizeProjectPath(value, directory)
+ if (!path) return
+ return { path, line: lineFromUrlHash ?? lineFromHash ?? lineFromSuffix }
+}
+
+export function splitCodeText(text: string, directory: string): FileText[] {
+ return (text.match(/\s+|[^\s]+/g) ?? []).reduce((list, item) => {
+ const file = parseCodeFileRef(item, directory)
+ if (file) return [...list, { text: item, file }]
+ const last = list.at(-1)
+ if (last && !last.file) {
+ last.text += item
+ return list
+ }
+ return [...list, { text: item }]
+ }, [])
+}
diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css
index f82723807d6..4503915201d 100644
--- a/packages/ui/src/components/markdown.css
+++ b/packages/ui/src/components/markdown.css
@@ -264,3 +264,34 @@
text-decoration: underline;
text-underline-offset: 2px;
}
+
+[data-component="markdown"] button.file-link {
+ appearance: none;
+ border: none;
+ background: transparent;
+ display: inline;
+ padding: 0;
+ margin: 0;
+ color: inherit;
+ font: inherit;
+ text-align: left;
+ white-space: normal;
+ cursor: pointer;
+}
+
+[data-component="markdown"] button.file-link:focus-visible {
+ outline: 1px solid var(--border-interactive-base);
+ outline-offset: 2px;
+ border-radius: 3px;
+}
+
+[data-component="markdown"] button.file-link:hover,
+[data-component="markdown"] button.file-link:focus-visible {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+[data-component="markdown"] button.file-link:hover > code {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx
index 01254f11895..8dfcdec9bb9 100644
--- a/packages/ui/src/components/markdown.tsx
+++ b/packages/ui/src/components/markdown.tsx
@@ -1,5 +1,7 @@
import { useMarked } from "../context/marked"
import { useI18n } from "../context/i18n"
+import { useData } from "../context/data"
+import { parseCodeFileRef, splitCodeText, type FileRef } from "./markdown-file-ref"
import DOMPurify from "dompurify"
import morphdom from "morphdom"
import { checksum } from "@opencode-ai/util/encode"
@@ -104,6 +106,16 @@ function createCopyButton(labels: CopyLabels) {
return button
}
+function createFileButton(file: FileRef, text: string) {
+ const button = document.createElement("button")
+ button.type = "button"
+ button.className = "file-link"
+ button.setAttribute("data-file-path", file.path)
+ if (file.line) button.setAttribute("data-file-line", String(file.line))
+ button.textContent = text
+ return button
+}
+
function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
if (copied) {
button.setAttribute("data-copied", "true")
@@ -143,7 +155,7 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
}
}
-function markCodeLinks(root: HTMLDivElement) {
+function markCodeLinks(root: HTMLDivElement, directory: string, openable: boolean) {
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
for (const code of codeNodes) {
const href = codeUrl(code.textContent ?? "")
@@ -152,35 +164,72 @@ function markCodeLinks(root: HTMLDivElement) {
? code.parentElement
: null
- if (!href) {
- if (parentLink) parentLink.replaceWith(code)
+ if (href) {
+ if (parentLink) {
+ parentLink.href = href
+ } else {
+ const link = document.createElement("a")
+ link.href = href
+ link.className = "external-link"
+ link.target = "_blank"
+ link.rel = "noopener noreferrer"
+ code.parentNode?.replaceChild(link, code)
+ link.appendChild(code)
+ }
continue
}
- if (parentLink) {
- parentLink.href = href
- continue
- }
+ if (parentLink) parentLink.replaceWith(code)
+ if (!openable) continue
+
+ const file = parseCodeFileRef(code.textContent ?? "", directory)
+ if (!file) continue
+
+ const button = createFileButton(file, "")
+ code.parentNode?.replaceChild(button, code)
+ button.appendChild(code)
+ }
+}
+
+function markTextLinks(root: HTMLDivElement, directory: string, openable: boolean) {
+ if (!openable) return
+ const walk = document.createTreeWalker(root, NodeFilter.SHOW_TEXT)
+ const list: Text[] = []
+ let node = walk.nextNode()
+ while (node) {
+ if (node instanceof Text) list.push(node)
+ node = walk.nextNode()
+ }
- const link = document.createElement("a")
- link.href = href
- link.className = "external-link"
- link.target = "_blank"
- link.rel = "noopener noreferrer"
- code.parentNode?.replaceChild(link, code)
- link.appendChild(code)
+ for (const item of list) {
+ const parent = item.parentElement
+ const text = item.textContent ?? ""
+ if (!parent || !text.trim()) continue
+ if (parent.closest("pre, code, a, button")) continue
+ const parts = splitCodeText(text, directory)
+ if (!parts.some((part) => part.file)) continue
+ const frag = document.createDocumentFragment()
+ for (const part of parts) {
+ if (!part.file) {
+ frag.appendChild(document.createTextNode(part.text))
+ continue
+ }
+ frag.appendChild(createFileButton(part.file, part.text))
+ }
+ item.parentNode?.replaceChild(frag, item)
}
}
-function decorate(root: HTMLDivElement, labels: CopyLabels) {
+function decorate(root: HTMLDivElement, labels: CopyLabels, directory: string, openable: boolean) {
const blocks = Array.from(root.querySelectorAll("pre"))
for (const block of blocks) {
ensureCodeWrapper(block, labels)
}
- markCodeLinks(root)
+ markCodeLinks(root, directory, openable)
+ markTextLinks(root, directory, openable)
}
-function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
+function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels, onFileOpen?: (input: FileRef) => void) {
const timeouts = new Map>()
const updateLabel = (button: HTMLButtonElement) => {
@@ -192,6 +241,18 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
const target = event.target
if (!(target instanceof Element)) return
+ const file = target.closest("button.file-link")
+ if (file instanceof HTMLButtonElement) {
+ const path = file.getAttribute("data-file-path")
+ const raw = file.getAttribute("data-file-line")
+ const line = raw ? Number(raw) : undefined
+ if (!path || !onFileOpen) return
+ event.preventDefault()
+ event.stopPropagation()
+ onFileOpen({ path, line })
+ return
+ }
+
const button = target.closest('[data-slot="markdown-copy-button"]')
if (!(button instanceof HTMLButtonElement)) return
const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
@@ -207,8 +268,6 @@ function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
timeouts.set(button, timeout)
}
- decorate(root, labels)
-
const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
for (const button of buttons) {
if (button instanceof HTMLButtonElement) updateLabel(button)
@@ -245,6 +304,7 @@ export function Markdown(
) {
const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
const marked = useMarked()
+ const data = useData()
const i18n = useI18n()
const [root, setRoot] = createSignal()
const [html] = createResource(
@@ -287,10 +347,15 @@ export function Markdown(
const temp = document.createElement("div")
temp.innerHTML = content
- decorate(temp, {
- copy: i18n.t("ui.message.copy"),
- copied: i18n.t("ui.message.copied"),
- })
+ decorate(
+ temp,
+ {
+ copy: i18n.t("ui.message.copy"),
+ copied: i18n.t("ui.message.copied"),
+ },
+ data.directory,
+ !!data.openFilePath,
+ )
morphdom(container, temp, {
childrenOnly: true,
@@ -303,10 +368,14 @@ export function Markdown(
if (copySetupTimer) clearTimeout(copySetupTimer)
copySetupTimer = setTimeout(() => {
if (copyCleanup) copyCleanup()
- copyCleanup = setupCodeCopy(container, {
- copy: i18n.t("ui.message.copy"),
- copied: i18n.t("ui.message.copied"),
- })
+ copyCleanup = setupCodeCopy(
+ container,
+ {
+ copy: i18n.t("ui.message.copy"),
+ copied: i18n.t("ui.message.copied"),
+ },
+ data.openFilePath,
+ )
}, 150)
})
diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css
index 8031bf2631d..e08b72fd103 100644
--- a/packages/ui/src/components/message-part.css
+++ b/packages/ui/src/components/message-part.css
@@ -417,6 +417,29 @@
white-space: pre-wrap;
overflow-wrap: anywhere;
}
+
+ button.file-link {
+ appearance: none;
+ border: none;
+ background: transparent;
+ color: inherit;
+ font: inherit;
+ padding: 0;
+ cursor: pointer;
+ text-align: left;
+ }
+
+ button.file-link:hover,
+ button.file-link:focus-visible {
+ text-decoration: underline;
+ text-underline-offset: 2px;
+ }
+
+ button.file-link:focus-visible {
+ outline: 1px solid var(--border-interactive-base);
+ outline-offset: 2px;
+ border-radius: 3px;
+ }
}
[data-component="edit-trigger"],
@@ -1199,6 +1222,27 @@
flex-shrink: 0;
}
+ button[data-slot="apply-patch-filename"] {
+ appearance: none;
+ border: none;
+ background: transparent;
+ padding: 0;
+ margin: 0;
+ text-align: left;
+ color: inherit;
+ font: inherit;
+ line-height: inherit;
+ cursor: pointer;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+
+ &:focus-visible {
+ outline: 1px solid var(--border-interactive-base);
+ outline-offset: 2px;
+ border-radius: 2px;
+ }
+ }
+
[data-slot="apply-patch-trigger-actions"] {
flex-shrink: 0;
display: flex;
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index e8c9dcf9505..dc3652d15e5 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -52,9 +52,11 @@ import { IconButton } from "./icon-button"
import { TextShimmer } from "./text-shimmer"
import { AnimatedCountList } from "./tool-count-summary"
import { ToolStatusTitle } from "./tool-status-title"
+import { ToolFile } from "./tool-file"
import { animate } from "motion"
import { useLocation } from "@solidjs/router"
import { attached, inline, kind } from "./message-file"
+import { splitCodeText, type FileRef } from "./markdown-file-ref"
function ShellSubmessage(props: { text: string; animate?: boolean }) {
let widthRef: HTMLSpanElement | undefined
@@ -210,6 +212,36 @@ function getDirectory(path: string | undefined) {
return relativizeProjectPath(_getDirectory(path), data.directory)
}
+function openProjectFile(
+ path: string | undefined,
+ directory: string,
+ openFilePath?: (input: FileRef) => void,
+ line?: number,
+) {
+ if (!path) return
+ const file = relativizeProjectPath(path, directory).replace(/^\//, "")
+ if (!file) return
+ openFilePath?.({ path: file, line })
+}
+
+function LinkText(props: { text: string }) {
+ const data = useData()
+ const parts = createMemo(() => splitCodeText(props.text, data.directory))
+
+ return (
+
+ {(part) => {
+ if (!part.file || !data.openFilePath) return part.text
+ return (
+ data.openFilePath?.(part.file!)}>
+ {part.text}
+
+ )
+ }}
+
+ )
+}
+
import type { IconProps } from "./icon"
export type ToolInfo = {
@@ -1170,7 +1202,12 @@ export const ToolRegistry = {
render: getTool,
}
-function ToolFileAccordion(props: { path: string; actions?: JSX.Element; children: JSX.Element }) {
+function ToolFileAccordion(props: {
+ path: string
+ actions?: JSX.Element
+ children: JSX.Element
+ onPathClick?: () => void
+}) {
const value = createMemo(() => props.path || "tool-file")
return (
@@ -1184,15 +1221,11 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre
-
-
-
-
- {`\u202A${getDirectory(props.path)}\u202C`}
-
- {getFilename(props.path)}
-
-
+
{props.actions}
@@ -1748,7 +1781,9 @@ ToolRegistry.register({
@@ -1760,6 +1795,7 @@ ToolRegistry.register({
ToolRegistry.register({
name: "edit",
render(props) {
+ const data = useData()
const i18n = useI18n()
const fileComponent = useFileComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
@@ -1800,6 +1836,7 @@ ToolRegistry.register({
openProjectFile(path(), data.directory, data.openFilePath)}
actions={
@@ -1832,6 +1869,7 @@ ToolRegistry.register({
ToolRegistry.register({
name: "write",
render(props) {
+ const data = useData()
const i18n = useI18n()
const fileComponent = useFileComponent()
const diagnostics = createMemo(() => getDiagnostics(props.metadata.diagnostics, props.input.filePath))
@@ -1866,7 +1904,10 @@ ToolRegistry.register({
}
>
-
+ openProjectFile(path(), data.directory, data.openFilePath)}
+ >
(props.metadata.files ?? []) as ApplyPatchFile[])
@@ -1973,15 +2015,11 @@ ToolRegistry.register({
-
-
-
-
- {`\u202A${getDirectory(file.relativePath)}\u202C`}
-
- {getFilename(file.relativePath)}
-
-
+
openProjectFile(file.relativePath, data.directory, data.openFilePath)}
+ />
@@ -2062,6 +2100,7 @@ ToolRegistry.register({
>
openProjectFile(single()!.relativePath, data.directory, data.openFilePath)}
actions={
diff --git a/packages/ui/src/components/session-review.css b/packages/ui/src/components/session-review.css
index 6b5b9ac8627..c30f4738b5b 100644
--- a/packages/ui/src/components/session-review.css
+++ b/packages/ui/src/components/session-review.css
@@ -131,6 +131,19 @@
white-space: nowrap;
}
+ button[data-slot="session-review-filename"] {
+ border: none;
+ padding: 0;
+ background: transparent;
+ cursor: pointer;
+ text-align: left;
+
+ &:hover,
+ &:focus-visible {
+ text-decoration: underline;
+ }
+ }
+
[data-slot="session-review-view-button"] {
display: flex;
align-items: center;
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx
index 83d2980f61a..33390fd9bf8 100644
--- a/packages/ui/src/components/session-review.tsx
+++ b/packages/ui/src/components/session-review.tsx
@@ -87,6 +87,7 @@ export interface SessionReviewProps {
actions?: JSX.Element
diffs: ReviewDiff[]
onViewFile?: (file: string) => void
+ onOpenFile?: (file: string) => void
readFile?: (path: string) => Promise
}
@@ -166,7 +167,8 @@ export const SessionReview = (props: SessionReviewProps) => {
handleChange(next)
}
- const openFileLabel = () => i18n.t("ui.sessionReview.openFile")
+ const openReviewLabel = () => i18n.t("ui.sessionReview.openInReview")
+ const openExternalLabel = () => i18n.t("ui.sessionReview.openExternally")
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
@@ -407,17 +409,34 @@ export const SessionReview = (props: SessionReviewProps) => {
{`\u202A${getDirectory(file)}\u202C`}
- {getFilename(file)}
-
-
+ {getFilename(file)}}
+ >
+
{
e.stopPropagation()
props.onViewFile?.(file)
}}
+ >
+ {getFilename(file)}
+
+
+
+
+
+ {
+ e.stopPropagation()
+ props.onOpenFile?.(file)
+ }}
>
diff --git a/packages/ui/src/components/tool-file.tsx b/packages/ui/src/components/tool-file.tsx
new file mode 100644
index 00000000000..d97d744fb05
--- /dev/null
+++ b/packages/ui/src/components/tool-file.tsx
@@ -0,0 +1,28 @@
+import { Show } from "solid-js"
+import { FileIcon } from "./file-icon"
+import { getFilename } from "@opencode-ai/util/path"
+
+export function ToolFile(props: { path: string; dir?: string; onClick?: () => void }) {
+ return (
+
+
+
+
+ {`\u202A${props.dir}\u202C`}
+
+ {getFilename(props.path)}}>
+ {
+ event.stopPropagation()
+ props.onClick?.()
+ }}
+ >
+ {getFilename(props.path)}
+
+
+
+
+ )
+}
diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx
index e116199eb23..5fe5dc8aa90 100644
--- a/packages/ui/src/context/data.tsx
+++ b/packages/ui/src/context/data.tsx
@@ -26,6 +26,8 @@ export type NavigateToSessionFn = (sessionID: string) => void
export type SessionHrefFn = (sessionID: string) => string
+export type OpenFilePathFn = (input: { path: string; line?: number }) => void
+
export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: {
@@ -33,6 +35,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
directory: string
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
+ onOpenFilePath?: OpenFilePathFn
}) => {
return {
get store() {
@@ -43,6 +46,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,
+ openFilePath: props.onOpenFilePath,
}
},
})
diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts
index 18823aeaa19..b4a197341dd 100644
--- a/packages/ui/src/i18n/en.ts
+++ b/packages/ui/src/i18n/en.ts
@@ -14,6 +14,8 @@ export const dict: Record = {
"ui.sessionReview.largeDiff.meta": "Limit: {{limit}} changed lines. Current: {{current}} changed lines.",
"ui.sessionReview.largeDiff.renderAnyway": "Render anyway",
"ui.sessionReview.openFile": "Open file",
+ "ui.sessionReview.openInReview": "Open in review",
+ "ui.sessionReview.openExternally": "Open externally",
"ui.sessionReview.selection.line": "line {{line}}",
"ui.sessionReview.selection.lines": "lines {{start}}-{{end}}",
diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx
index 0c0ba30a085..7f993205193 100644
--- a/packages/web/src/content/docs/providers.mdx
+++ b/packages/web/src/content/docs/providers.mdx
@@ -307,6 +307,8 @@ For custom inference profiles, use the model and provider name in the key and se
```txt
┌ Select auth method
│
+ │ Claude Pro/Max
+ │ Create an API Key
│ Manually enter API Key
└
```
@@ -318,19 +320,14 @@ For custom inference profiles, use the model and provider name in the key and se
```
:::info
-There are plugins that allow you to use your Claude Pro/Max models with
-OpenCode. Anthropic explicitly prohibits this.
+Using your Claude Pro/Max subscription in OpenCode is not officially supported by [Anthropic](https://anthropic.com).
+:::
-Previous versions of OpenCode came bundled with these plugins but that is no
-longer the case as of 1.3.0
+##### Using API keys
-Other companies support freedom of choice with developer tooling - you can use
-the following subscriptions in OpenCode with zero setup:
+You can also select **Create an API Key** if you don't have a Pro/Max subscription. It'll also open your browser and ask you to login to Anthropic and give you a code you can paste in your terminal.
-- ChatGPT Plus
-- Github Copilot
-- Gitlab Duo
- :::
+Or if you already have an API key, you can select **Manually enter API Key** and paste it in your terminal.
---
diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx
index 3dd1ef7fb8e..69bd8df5761 100644
--- a/packages/web/src/content/docs/zen.mdx
+++ b/packages/web/src/content/docs/zen.mdx
@@ -94,8 +94,7 @@ You can also access our models through the following API endpoints.
| GLM 5 | glm-5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Kimi K2.5 | kimi-k2.5 | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
-| MiMo V2 Pro Free | mimo-v2-pro-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
-| MiMo V2 Omni Free | mimo-v2-omni-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
+| MiMo V2 Flash Free | mimo-v2-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
| Nemotron 3 Super Free | nemotron-3-super-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` |
The [model id](/docs/config/#models) in your OpenCode config
@@ -121,8 +120,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**.
| Model | Input | Output | Cached Read | Cached Write |
| --------------------------------- | ------ | ------- | ----------- | ------------ |
| Big Pickle | Free | Free | Free | - |
-| MiMo V2 Pro Free | Free | Free | Free | - |
-| MiMo V2 Omni Free | Free | Free | Free | - |
+| MiMo V2 Flash Free | Free | Free | Free | - |
| Nemotron 3 Super Free | Free | Free | Free | - |
| MiniMax M2.5 Free | Free | Free | Free | - |
| MiniMax M2.5 | $0.30 | $1.20 | $0.06 | $0.375 |
@@ -167,8 +165,7 @@ Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don
The free models:
- MiniMax M2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
-- MiMo V2 Pro Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
-- MiMo V2 Omni Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
+- MiMo V2 Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Nemotron 3 Super Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
- Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model.
@@ -215,8 +212,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic
- Big Pickle: During its free period, collected data may be used to improve the model.
- MiniMax M2.5 Free: During its free period, collected data may be used to improve the model.
-- MiMo V2 Pro Free: During its free period, collected data may be used to improve the model.
-- MiMo V2 Omni Free: During its free period, collected data may be used to improve the model.
+- MiniMax M2.5 Free: During its free period, collected data may be used to improve the model.
- Nemotron 3 Super Free: During its free period, collected data may be used to improve the model.
- OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data).
- Anthropic APIs: Requests are retained for 30 days in accordance with [Anthropic's Data Policies](https://docs.anthropic.com/en/docs/claude-code/data-usage).