Compare commits

..
Author SHA1 Message Date
Aiden Cline 8997d8662f fix(schema): keep legacy blobs on replay, import, and V1 migration
Freeze session.message.content.updated at the stored shape and translate
on replay, run persisted() on CLI session import, write native from the
V1 migration, regenerate the OpenAPI docs, and mirror text.ended state in
the client fold.
2026-09-22 00:37:59 -05:00
Aiden Cline e4dd41b033 test(core): use native for text and reasoning provider blobs 2026-09-21 23:39:41 -05:00
Aiden Cline 347bf1d749 Merge remote-tracking branch 'origin/v2' into message-native 2026-09-21 23:37:54 -05:00
Aiden Cline e7c4bffd38 refactor(schema): drop native migration and fix fixtures 2026-09-21 23:27:12 -05:00
Aiden Cline 85962e49b7 refactor(schema): rename message provider blobs to native 2026-09-20 21:41:03 -05:00
198 changed files with 990 additions and 4116 deletions
+35 -36
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -92,7 +92,6 @@
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3",
"uqr": "0.1.3",
},
"devDependencies": {
"@happy-dom/global-registrator": "20.0.11",
@@ -112,7 +111,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.14",
"version": "2.0.12",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -176,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -202,7 +201,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -215,7 +214,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -251,7 +250,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -278,7 +277,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -295,7 +294,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -319,7 +318,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -339,7 +338,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -407,7 +406,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "5.0.0",
@@ -456,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -493,7 +492,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -509,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -528,7 +527,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -541,7 +540,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -555,7 +554,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -570,7 +569,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -609,7 +608,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -639,7 +638,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -654,7 +653,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -678,7 +677,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -699,7 +698,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -721,7 +720,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -756,7 +755,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -776,7 +775,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -810,7 +809,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -829,7 +828,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -875,7 +874,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -889,7 +888,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -924,7 +923,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -959,7 +958,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -992,7 +991,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1033,7 +1032,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-WBGj4wU5PQs+0zbDbScFhJs0GL8qWnswIsJb/Kee5Qc=",
"aarch64-linux": "sha256-Z8hkyNMdqCpuv24q50QhO2RECINvENjBhLDY5FKcnFc=",
"aarch64-darwin": "sha256-HZHb9HcXMOc7bKR7eADQSyfWxX37GAcoRi/EMSpRiP4=",
"x86_64-darwin": "sha256-CcWCOAcW4t9ADs48fMhumSbDcQWdk91LpkG6fe/gWlk="
"x86_64-linux": "sha256-/jah4P2a0aGJNJ0aMdlFEbGHAXx33lqxHbc7UmpLFDg=",
"aarch64-linux": "sha256-L3SoZ24qNXicsE2FK6LATQjOmjPxY679RjugrUyO/1Y=",
"aarch64-darwin": "sha256-pI9NT8KWUPi+JCk6DYMqIAYmBqTbi13uL4VdNV3WS6Y=",
"x86_64-darwin": "sha256-rMAGhTTz46KA5Ya7E5J0af7Bn1QzTDhTaNfNJm8qfsw="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.14",
"version": "2.0.12",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -41,7 +41,7 @@ import { TypeSafeAI } from "@opencode/ai/providers"
const model = TypeSafeAI.configure().experimental.evaluation("jev-latest")
const program = Evaluation.run({
const program = Evaluation.evaluate({
model,
state: "I was charged twice. Please refund the duplicate payment.",
questions: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.14",
"version": "2.0.12",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
+4 -4
View File
@@ -218,11 +218,11 @@ export function request(input: EvaluationRequest | EvaluationRequestInput) {
})
}
export function run<const Model extends object, const Questions extends EvaluationQuestions>(
export function evaluate<const Model extends object, const Questions extends EvaluationQuestions>(
input: EvaluationRequestInput<Model, Questions>,
): Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service>
export function run(input: EvaluationRequest): Effect.Effect<EvaluationResponse, AIError, Service>
export function run(input: EvaluationRequest | EvaluationRequestInput) {
export function evaluate(input: EvaluationRequest): Effect.Effect<EvaluationResponse, AIError, Service>
export function evaluate(input: EvaluationRequest | EvaluationRequestInput) {
return Effect.try({
try: () => (input instanceof EvaluationRequest ? input : request(input)),
catch: (cause) =>
@@ -241,5 +241,5 @@ export function run(input: EvaluationRequest | EvaluationRequestInput) {
export const Evaluation = {
request,
run,
evaluate,
} as const
+3 -3
View File
@@ -9,7 +9,7 @@ import { dynamicResponse } from "./lib/http.js"
describe("experimental Evaluation", () => {
it.effect("evaluates typed questions through System One", () =>
Effect.gen(function* () {
const response = yield* Evaluation.run({
const response = yield* Evaluation.evaluate({
model: TypeSafeAI.configure({
apiKey: "test",
baseURL: "https://typesafe.test/v1/",
@@ -116,7 +116,7 @@ describe("experimental Evaluation", () => {
)
it.effect("configures the OpenCode Zen System One endpoint", () =>
Evaluation.run({
Evaluation.evaluate({
model: OpenCodeZen.configure({ apiKey: "zen-key", baseURL: "https://zen.test/v1" }).experimental.evaluation(
"jev-1.13",
),
@@ -156,7 +156,7 @@ describe("experimental Evaluation", () => {
it.effect("rejects malformed questions before network I/O", () =>
Effect.gen(function* () {
const error = yield* Evaluation.run({
const error = yield* Evaluation.evaluate({
model: TypeSafeAI.experimental.evaluation("jev-latest"),
state: "hello",
questions: { score: { type: "score", instructions: "How much?", criteria: ["only"] } },
+3 -3
View File
@@ -31,7 +31,7 @@ void (true satisfies Choice)
void (true satisfies ClientRequirements)
Effect.gen(function* () {
const response = yield* Evaluation.run({
const response = yield* Evaluation.evaluate({
model: OpenCodeZen.experimental.evaluation("jev-1.13"),
state: ["hello"],
questions: { greeting: { type: "boolean", instructions: "Greeting?" } },
@@ -45,14 +45,14 @@ Effect.gen(function* () {
declare const route: EvaluationRoute<{ readonly temperature?: number }>
const custom = EvaluationModel.make({ id: "custom", provider: "custom", route })
Evaluation.run({
Evaluation.evaluate({
model: custom,
state: "hello",
questions: { ok: { type: "boolean", instructions: "OK?" } },
options: { temperature: 0.5 },
})
// @ts-expect-error Selected evaluation models retain their request option types.
Evaluation.run({
Evaluation.evaluate({
model: custom,
state: "hello",
questions: { ok: { type: "boolean", instructions: "OK?" } },
+1 -1
View File
@@ -40,7 +40,7 @@ describe("public exports", () => {
expect(TestLLM.layer).toBeFunction()
expect(TestLLM.testLayer).toBeFunction()
expect(TestLLM.Test.of).toBeFunction()
expect(Evaluation.run).toBeFunction()
expect(Evaluation.evaluate).toBeFunction()
expect(EvaluationClient.layer).toBeDefined()
expect(EvaluationClient.fetchLayer).toBeDefined()
})
@@ -63,7 +63,7 @@ const assertEvaluation = (
metadataKey: "typesafe" | "opencode",
) =>
Effect.gen(function* () {
const response = yield* Evaluation.run({ model, state, questions })
const response = yield* Evaluation.evaluate({ model, state, questions })
expect(response.model).toStartWith("jev-")
expect(response.answers.department.type).toBe("choice")
expect(response.answers.department.choice).toBe("billing")
+2 -2
View File
@@ -95,7 +95,7 @@ In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPEN
from storage. Desktop provides the local server it discovers or starts through native initialization.
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
or choose **Scan QR code** to open the camera and read the pairing code from `opencode pair`.
or choose **Scan QR code** to open the camera and read the JSON pairing code from `opencode pair`.
Scanning fills the form and immediately attempts to connect. Failed connections leave the details available
to edit and retry with **Connect**. Credentials are checked before saving the server. Camera access requires
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
@@ -103,7 +103,7 @@ HTTPS (or localhost) and browser permission. Saved offline servers continue to u
When the service is exposed through an HTTPS reverse proxy, advertise its external address at runtime:
```bash
opencode pair --url https://opencode.example.com
opencode pair --url https://your-machine.your-tailnet.ts.net
```
This replaces the addresses printed and encoded in the QR code while retaining the local service password.
@@ -703,7 +703,7 @@ function messageContent(
return {
type: "reasoning",
text: part.text,
state: jsonRecord(part.metadata),
native: jsonRecord(part.metadata),
time: part.time
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
: undefined,
@@ -1,145 +0,0 @@
import { base64Encode } from "@opencode/util/encode"
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectSessionTitle } from "../utils/waits"
const directory = "C:/OpenCode/BtwSidebar"
const projectID = "proj_btw_sidebar"
const sessionID = "ses_btw_sidebar"
const otherSessionID = "ses_btw_sidebar_other"
const title = "Side question session"
const otherTitle = "Other side question session"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const sessionHref = (id: string) => `/server/${base64Encode(server)}/session/${id}`
test.use({ viewport: { width: 1440, height: 900 } })
test("answers /btw in the side panel without admitting a prompt", async ({ page }) => {
const generations: { sessionID: string; prompt: string }[] = []
const prompts: unknown[] = []
const generated = Promise.withResolvers<void>()
await mockOpenCodeServer(page, {
directory,
project: {
id: projectID,
worktree: directory,
vcs: "git",
name: "btw-sidebar",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: {
all: [
{
id: "opencode",
name: "OpenCode",
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
},
],
connected: ["opencode"],
default: { providerID: "opencode", modelID: "test" },
},
sessions: [
{
id: sessionID,
slug: sessionID,
projectID,
directory,
title,
version: "dev",
time: { created: 1700000000000, updated: 1700000000000 },
},
{
id: otherSessionID,
slug: otherSessionID,
projectID,
directory,
title: otherTitle,
version: "dev",
time: { created: 1700000001000, updated: 1700000001000 },
},
],
pageMessages: () => ({ items: [] }),
vcsDiff: [],
onPrompt: (input) => prompts.push(input),
generate: async (input) => {
generations.push(input)
if (input.sessionID === otherSessionID) return { text: "This answer belongs to the **other session**." }
await generated.promise
return {
text: "The retry loop uses **exponential backoff** and stops after three attempts.\n\n```ts\nconst delay = 2 ** attempt\n```",
}
},
})
await page.addInitScript(
({ directory, server, sessionID, otherSessionID }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionID },
{ type: "session", server, sessionId: otherSessionID },
]),
)
},
{ directory, server, sessionID, otherSessionID },
)
await page.goto(sessionHref(sessionID))
await expectSessionTitle(page, title)
const editor = page.locator('[data-component="composer-editor"]')
await expect(editor).toBeEditable()
await editor.fill("/btw")
const suggestion = page.locator('[data-suggestion-id="session.btw"]')
await expect(suggestion).toBeVisible()
await suggestion.click()
await expect(editor).toHaveText("/btw ")
await editor.press("Enter")
const panel = page.locator('[data-slot="session-btw-panel"]')
await expect(panel).toBeHidden()
await expect(page.getByText("Add a question after /btw", { exact: true })).toBeVisible()
expect(generations).toEqual([])
expect(prompts).toEqual([])
await editor.fill("/btw how does the retry loop work?")
await editor.press("Enter")
const tab = page.getByRole("tab", { name: "/btw" })
await expect(panel).toBeVisible()
await expect(panel.getByRole("textbox")).toHaveCount(0)
await expect(panel.getByRole("status")).toContainText("Working")
await expect(tab).toHaveAttribute("data-selected", "")
generated.resolve()
await expect(panel.getByText("how does the retry loop work?", { exact: true })).toBeVisible()
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await expect(panel.getByText("const delay = 2 ** attempt", { exact: true })).toBeVisible()
expect(generations).toHaveLength(1)
expect(generations[0]?.sessionID).toBe(sessionID)
expect(generations[0]?.prompt).toContain("how does the retry loop work?")
expect(prompts).toEqual([])
await expect(editor).toHaveText("")
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(otherSessionID)}"]`).click()
await expectSessionTitle(page, otherTitle)
await editor.fill("/btw what belongs here?")
await editor.press("Enter")
await expect(panel.getByText("other session", { exact: false })).toBeVisible()
await page.locator(`[data-titlebar-tab-link][href="${sessionHref(sessionID)}"]`).click()
await expectSessionTitle(page, title)
await expect(panel.getByText("exponential backoff", { exact: false })).toBeVisible()
await expect(panel.getByText("other session", { exact: false })).toHaveCount(0)
await page.reload()
await expectSessionTitle(page, title)
await expect(page.getByRole("tab", { name: "/btw" })).toHaveCount(0)
await expect(page.locator('[data-slot="session-btw-panel"]')).toHaveCount(0)
})
@@ -1,54 +0,0 @@
import { expect, test } from "@playwright/test"
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
const password = "pairing-secret"
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
const requests: { origin: string; authorization: string | undefined }[] = []
await page.addInitScript((origin) => {
if (localStorage.getItem("opencode.global.dat:server")) return
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
)
}, origin)
await page.route("**/api/**", async (route) => {
requests.push({
origin: new URL(route.request().url()).origin,
authorization: route.request().headers().authorization,
})
// Pairing must succeed even when the API is unavailable.
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
})
await page.goto(`/connect#${Buffer.from(JSON.stringify({ username: "opencode", password })).toString("base64url")}`)
await expect(page).toHaveURL(`${origin}/`)
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
.toEqual([{ type: "http", http: { url: origin, password } }])
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
requests.length = 0
await page.reload()
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
expect(
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
).toBe(true)
})
test("the unpaired page loads without starting server requests", async ({ page }) => {
const requests: string[] = []
await page.route("**/api/**", async (route) => {
requests.push(route.request().url())
await route.abort()
})
await page.goto("/connect")
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
expect(requests).toEqual([])
})
-7
View File
@@ -197,13 +197,6 @@ const Group = HttpApiGroup.make("mock")
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionGenerate", "/api/session/:sessionID/generate", {
params: SessionParams,
payload: Schema.Struct({ prompt: Schema.String }),
success: Json,
}),
)
.add(
HttpApiEndpoint.post("sessionSwitchAgent", "/api/session/:sessionID/agent", {
params: SessionParams,
-7
View File
@@ -42,7 +42,6 @@ export interface MockServerConfig {
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
generate?: (input: { sessionID: string; prompt: string }) => { text: string } | Promise<{ text: string }>
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
}
@@ -457,12 +456,6 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
},
}
}),
sessionGenerate: (ctx) =>
Effect.promise(async () => ({
data: (await config.generate?.({ sessionID: ctx.params.sessionID, prompt: ctx.payload.prompt })) ?? {
text: "Side-question answer",
},
})),
sessionInboxCancel: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.14",
"version": "2.0.12",
"description": "",
"type": "module",
"exports": {
@@ -91,7 +91,6 @@
"remeda": "catalog:",
"solid-js": "catalog:",
"solid-presence": "0.2.0",
"tailwindcss": "4.3.3",
"uqr": "0.1.3"
"tailwindcss": "4.3.3"
}
}
+16 -24
View File
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
import { Font } from "@opencode/ui/font"
import { ThemeProvider } from "@opencode/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
import { type BaseRouterProps, Router } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
import { Dynamic } from "solid-js/web"
import { CommandProvider } from "@/shell/commands/command"
import { DesktopCommands } from "@/shell/commands/desktop"
@@ -107,29 +107,21 @@ export function AppInterface(props: {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const Root = (rootProps: ParentProps) => {
const location = useLocation()
// Pairing saves credentials before mounting any server connections or health checks.
return (
<>
const Root = (rootProps: ParentProps) => (
<TabsProvider>
<GlobalProvider>
<BodyTypography />
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
<TabsProvider>
<GlobalProvider>
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
</Show>
</>
)
}
<CommandProvider>
<DesktopCommands />
<SshRestore />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</GlobalProvider>
</TabsProvider>
)
return (
<ServersProvider
@@ -1,32 +0,0 @@
import { describe, expect, test } from "bun:test"
import { parseClientSlashCommand } from "./client-slash-command"
const options = [
{ id: "session.btw", trigger: "btw", arguments: true, type: "builtin" as const },
{ id: "custom.btw", trigger: "custom", type: "custom" as const },
{ id: "model.choose", trigger: "model", type: "builtin" as const },
]
describe("parseClientSlashCommand", () => {
test("parses inline and multiline arguments", () => {
expect(parseClientSlashCommand(options, "/btw why this approach?")).toEqual({
id: "session.btw",
input: "why this approach?",
})
expect(parseClientSlashCommand(options, "/btw\nwhy this approach?")).toEqual({
id: "session.btw",
input: "why this approach?",
})
})
test("accepts a bare argument command", () => {
expect(parseClientSlashCommand(options, "/btw")).toEqual({ id: "session.btw", input: "" })
})
test("rejects prefixes, custom commands, and ordinary slash commands", () => {
expect(parseClientSlashCommand(options, "/btwx nope")).toBeUndefined()
expect(parseClientSlashCommand(options, "/custom nope")).toBeUndefined()
expect(parseClientSlashCommand(options, "/model opus")).toBeUndefined()
expect(parseClientSlashCommand(options, "ask /btw later")).toBeUndefined()
})
})
@@ -1,24 +0,0 @@
type ClientSlashCommand = {
id: string
trigger: string
arguments?: boolean
type: "builtin" | "custom"
}
export function parseSlashCommand(text: string) {
if (!text.startsWith("/")) return
const separator = text.search(/\s/)
const name = text.slice(1, separator === -1 ? undefined : separator)
return { name, input: separator === -1 ? "" : text.slice(separator).trim() }
}
export function parseClientSlashCommand(options: readonly ClientSlashCommand[], text: string) {
const command = parseSlashCommand(text)
if (!command) return
const option = options.find((item) => item.type === "builtin" && item.arguments && item.trigger === command.name)
if (!option) return
return {
id: option.id,
input: command.input,
}
}
-16
View File
@@ -5,7 +5,6 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "./editor/dom"
import { useSessionLayout } from "@/session/session-layout"
import { createSessionOwnership } from "@/session/session-ownership"
import { useWorkspaceLocation } from "@/workspaces/location"
const withCategory = (category: string) => {
return (option: Omit<CommandOption, "category">): CommandOption => ({
@@ -19,19 +18,11 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
const dialog = useDialog()
const language = useLanguage()
const local = useLocal()
const workspace = useWorkspaceLocation()
const { sessionKey } = useSessionLayout()
const sessionOwnership = createSessionOwnership(sessionKey)
const model = input.model ?? local.model
const modelCommand = withCategory(language.t("command.category.model"))
const agentCommand = withCategory(language.t("command.category.agent"))
const providerCommand = withCategory(language.t("command.category.provider"))
// Mirrors the TUI's `/connect`, which the Console's setup steps tell people to run.
const connectProvider = async () => {
const { DialogConnectProvider } = await import("@/providers/connect/dialog")
void dialog.show(() => <DialogConnectProvider directory={workspace().directory} />)
}
const chooseModel = async () => {
const owner = sessionOwnership.capture()
@@ -71,13 +62,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
keybind: "shift+mod+d",
onSelect: () => model.variant.cycle(),
}),
providerCommand({
id: "provider.connect",
title: language.t("command.provider.connect"),
description: language.t("command.provider.connect.description"),
slash: "connect",
onSelect: connectProvider,
}),
agentCommand({
id: "agent.cycle",
title: language.t("command.agent.cycle"),
+3 -9
View File
@@ -24,7 +24,6 @@ import { createComposerHistory } from "./history/store"
import { composerPlaceholder } from "./placeholder"
import { createComposerSubmit } from "./submit"
import { useAttachmentDestination } from "./attachments/destination"
import { parseClientSlashCommand } from "./client-slash-command"
export type ComposerModel = ComposerEditorModel & {
readonly model: ComposerControls["model"]
@@ -74,7 +73,9 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
return [...result, path]
}, [])
})
const attachments = createMemo(() => prompt.current().filter(isAttachment))
const attachments = createMemo(() =>
prompt.current().filter(isAttachment),
)
const commentCount = createMemo(() => {
if (mode() === "shell") return 0
return prompt.context.items().filter((item) => !!item.comment?.trim()).length
@@ -241,7 +242,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
trigger: item.slash!,
title: item.title,
description: item.description,
arguments: item.slashArguments,
type: "builtin" as const,
})),
])
@@ -299,11 +299,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
clear: comments.clear,
restore: restoreHistoryComments,
},
clientCommand: (text) => {
const selected = parseClientSlashCommand(slashCommands(), text)
if (!selected) return
return () => command.trigger(selected.id, "slash", selected.input)
},
})
const controller = createComposerEditor({
store: prompt.store,
@@ -345,7 +340,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
if (item.kind !== "command") return
const selected = slashCommands().find((entry) => entry.id === item.id)
if (!selected || selected.type === "custom") return
if (selected.arguments) return
return () => command.trigger(selected.id, "slash")
},
attachments: {
+7 -60
View File
@@ -54,17 +54,14 @@ function submitInput(
mode: "normal" | "shell" = "normal",
commands: () => readonly { name: string }[] | undefined = () => [],
history: string[] = [],
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined,
) {
return createComposerSubmit({
adapter,
mode: () => mode,
commands,
clientCommand,
editor: () => undefined,
queueScroll() {},
addToHistory: (prompt) =>
history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
addToHistory: (prompt) => history.push(`add:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
removeFromHistory: (prompt) =>
history.push(`remove:${prompt.map((part) => ("content" in part ? part.content : part.type)).join("")}`),
resetHistory() {},
@@ -121,61 +118,6 @@ function session(input: {
}
describe("Composer submission", () => {
test("runs a client argument command without admitting it to the session", async () => {
const state = createMemoryComposerState().capture()
state.set([
{ type: "text", content: "/btw why this approach?", start: 0, end: 23 },
{
type: "image",
id: "attachment",
filename: "diagram.png",
mime: "image/png",
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
])
state.context.add({ type: "file", path: "src/retry.ts" })
const calls: string[] = []
const target = session({
calls,
prompt: async () => {
throw new Error("client command must not call prompt")
},
})
const adapter: ActiveComposerAdapter = {
kind: "active-session",
state,
ready: () => true,
controls,
working: () => false,
session: () => target,
interrupt: async () => undefined,
submitted() {},
setEditor() {},
}
const history: string[] = []
await submitInput(adapter, undefined, "normal", undefined, history, (text) => {
expect(text).toBe("/btw why this approach?")
return () => {
calls.push("btw")
}
}).submit(new Event("submit"))
expect(calls).toEqual(["btw"])
expect(history).toEqual([])
expect(state.current()).toEqual([
{ type: "text", content: "", start: 0, end: 0 },
{
type: "image",
id: "attachment",
filename: "diagram.png",
mime: "image/png",
blob: { id: "attachment", url: "data:image/png;base64,YQ==" },
},
])
expect(state.context.items()).toHaveLength(1)
})
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
const calls: string[] = []
@@ -683,7 +625,12 @@ describe("Composer submission", () => {
},
}
await submitInput(adapter, undefined, "normal", () => catalog).submit(new Event("submit"))
await submitInput(
adapter,
undefined,
"normal",
() => catalog,
).submit(new Event("submit"))
expect(await sent.promise).toBe("command")
expect(requests).toEqual([
+10 -39
View File
@@ -11,7 +11,6 @@ import { setCursorPosition } from "./editor/dom"
import { blobDataUrl, resolveBlobUrl } from "@/runtime/persistence/drafts"
import { isAttachment } from "./prompt-parts"
import type { ModelSelection } from "@/providers/models/selection"
import { parseSlashCommand } from "./client-slash-command"
const submitting = new WeakSet<object>()
@@ -38,7 +37,6 @@ type ComposerSubmitInput = {
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
delivery?: (alternate: boolean) => ComposerDelivery
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined
notify: {
missingSelection: () => void
failed: (kind: "shell" | "command" | "prompt", error: unknown) => void
@@ -54,31 +52,15 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const submit = async (event: globalThis.Event, options?: { alternate?: boolean }) => {
event.preventDefault()
const prompt = clonePrompt(input.adapter.state.current())
const text = submissionText(prompt)
const clientCommand = input.mode() === "normal" ? input.clientCommand?.(text) : undefined
if (clientCommand) {
if (submitting.has(input.adapter.state)) return
submitting.add(input.adapter.state)
try {
clearClientCommand(input, prompt)
await clientCommand()
} catch (error) {
input.notify.failed("command", error)
} finally {
submitting.delete(input.adapter.state)
}
return
}
const submission = createComposerSubmission({
target: input.adapter.state,
prompt,
prompt: clonePrompt(input.adapter.state.current()),
context: input.adapter.state.context.items().map((item) => ({
...item,
selection: item.selection ? { ...item.selection } : undefined,
})),
})
const read = readSubmission(input, submission.prompt, submission.context, text, options?.alternate ?? false)
const read = readSubmission(input, submission.prompt, submission.context, options?.alternate ?? false)
if (!read) {
if (input.adapter.working() && input.adapter.kind === "active-session") void input.adapter.interrupt()
return
@@ -168,17 +150,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
}
}
function clearClientCommand(input: ComposerSubmitInput, prompt: Prompt) {
input.adapter.state.set([{ type: "text", content: "", start: 0, end: 0 }, ...prompt.filter(isAttachment)], 0)
input.adapter.state.mode.set("normal")
input.setMode("normal")
input.closePopover()
}
function submissionText(prompt: Prompt) {
return prompt.map((part) => ("content" in part ? part.content : "")).join("")
}
function handoffMessage(value: ComposerSubmission): SessionMessageUser {
return {
id: value.id,
@@ -222,9 +193,9 @@ function readSubmission(
input: ComposerSubmitInput,
prompt: Prompt,
context: ComposerSubmission["context"],
text: string,
alternate: boolean,
): ComposerSubmission | undefined {
const text = prompt.map((part) => ("content" in part ? part.content : "")).join("")
const mode = input.mode()
if (mode === "shell" && !text.trim()) return
const images = prompt.filter((part): part is ImageAttachmentPart => part.type === "image")
@@ -327,11 +298,14 @@ async function sendShell(session: ComposerSession, value: ComposerSubmission) {
}
function findCommand(commands: ReturnType<ComposerSubmitInput["commands"]>, text: string) {
const parsed = parseSlashCommand(text)
if (!parsed || !commands?.some((item) => item.name === parsed.name)) return
return { command: parsed.name, arguments: parsed.input }
if (!text.startsWith("/")) return
const [name, ...arguments_] = text.split(" ")
const command = name.slice(1)
if (!commands?.some((item) => item.name === command)) return
return { command, arguments: arguments_.join(" ") }
}
async function sendCommand(
session: ComposerSession,
value: ComposerSubmission,
@@ -416,10 +390,7 @@ async function sendPrompt(
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
const images = await Promise.all(
value.images.map(async (attachment) => ({
...attachment,
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
})),
value.images.map(async (attachment) => ({ ...attachment, dataUrl: await blobDataUrl(attachment.blob, attachment.mime) })),
)
return buildPromptRequest({
prompt: value.prompt,
@@ -47,7 +47,7 @@ export function HomeCommandPalette(props: {
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
void item.option?.onSelect?.("palette")
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") props.onSelectSession(item)
+21 -125
View File
@@ -1,44 +1,18 @@
import type {
FormAnswer,
IntegrationInfo,
IntegrationMethod,
IntegrationOauthConnectOutput,
} from "@opencode/client/promise"
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode/client/promise"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
import { createStore, produce } from "solid-js/store"
export type ProviderConnectMethod = Extract<IntegrationMethod, { type: "key" | "oauth" }>
type Authorization = IntegrationOauthConnectOutput["data"]
// OpenCode Go and OpenCode Zen both bill through the OpenCode Console, so the
// Console sign-in is the connection method for both providers.
export const CONSOLE_INTEGRATION = "opencode"
export const CONSOLE_PROVIDERS = new Set(["opencode", "opencode-go"])
export function consoleIntegration(provider: string) {
return CONSOLE_PROVIDERS.has(provider) ? CONSOLE_INTEGRATION : provider
}
function hiddenDefaults(method: ProviderConnectMethod | undefined) {
return Object.fromEntries(
(method?.form ?? []).flatMap((field) =>
field.type !== "external" && field.hidden && field.default !== undefined ? [[field.key, field.default]] : [],
),
) as FormAnswer
}
export function createProviderConnectionController(options: {
provider: () => string
/** Integration that stores API keys when it differs from the one that lists methods and runs OAuth. */
keyProvider?: () => string
directory: () => string | undefined
onComplete: () => void
/** Picks the method to start without asking when the integration exposes several. */
autoSelect?: (methods: ProviderConnectMethod[]) => number | undefined
pollInterval?: number
}) {
const language = useLanguage()
@@ -49,28 +23,12 @@ export function createProviderConnectionController(options: {
const directory = options.directory()
return directory ? { directory } : undefined
}
// Not createResource: the dialog is owned by whichever page opened it, so reading a pending
// resource here would suspend that page's <Suspense> and blank the screen behind the dialog.
const [integration, setIntegration] = createStore({
loading: true,
latest: undefined as IntegrationInfo | undefined,
})
createEffect(
on(
() => ({ provider: options.provider(), directory: options.directory() }),
(input) => {
setIntegration({ loading: true, latest: undefined })
serverSDK.api.integration
.get({ integrationID: input.provider, location: location() })
.then((result) => result.data)
.catch(() => undefined)
.then((latest) => {
if (polling.disposed) return
if (input.provider !== options.provider() || input.directory !== options.directory()) return
setIntegration({ loading: false, latest })
})
},
),
const [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }),
(input) =>
serverSDK.api.integration
.get({ integrationID: input.provider, location: location() })
.then((result) => result.data),
)
const methods = createMemo<ProviderConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
@@ -83,28 +41,17 @@ export function createProviderConnectionController(options: {
methodIndex: undefined as number | undefined,
authorization: undefined as Authorization | undefined,
formAnswer: undefined as FormAnswer | undefined,
// Nothing is in flight until a method is selected; `busy()` reads this, so a truthy initial
// value would keep multi-method providers on the spinner instead of the method list.
state: undefined as "pending" | "complete" | "error" | "form" | undefined,
state: "pending" as "pending" | "complete" | "error" | "form" | undefined,
error: undefined as string | undefined,
auto: false,
})
const polling = {
generation: 0,
timer: undefined as ReturnType<typeof setTimeout> | undefined,
disposed: false,
// An attempt the server still considers open; cancelled when the dialog goes away.
attempt: undefined as Authorization | undefined,
}
const currentMethod = createMemo(() =>
store.methodIndex === undefined ? undefined : methods().at(store.methodIndex),
)
const autoIndex = createMemo(() => {
if (integration.loading) return undefined
const values = methods()
if (values.length === 1) return 0
return options.autoSelect?.(values)
})
type Action =
| { type: "method.select"; index: number }
@@ -162,14 +109,6 @@ export function createProviderConnectionController(options: {
)
}
const cancelAttempt = () => {
const attempt = polling.attempt
polling.attempt = undefined
if (!attempt) return
void serverSDK.api.integration.oauth
.cancel({ integrationID: options.provider(), attemptID: attempt.attemptID, location: location() })
.catch(() => undefined)
}
const cancelPolling = () => {
polling.generation++
if (polling.timer === undefined) return
@@ -178,7 +117,6 @@ export function createProviderConnectionController(options: {
}
const finish = async () => {
cancelPolling()
polling.attempt = undefined
const ref = location()
data.location.integration.invalidate(ref)
data.location.provider.invalidate(ref)
@@ -202,7 +140,6 @@ export function createProviderConnectionController(options: {
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
polling.attempt = undefined
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
@@ -214,35 +151,26 @@ export function createProviderConnectionController(options: {
return
}
if (result.status.status === "failed") {
polling.attempt = undefined
dispatch({ type: "auth.error", error: result.status.message })
return
}
if (result.status.status === "expired") {
polling.attempt = undefined
dispatch({ type: "auth.error", error: language.t("provider.connect.oauth.expired") })
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
return
}
polling.timer = setTimeout(() => void poll(authorization, generation), options.pollInterval ?? 1_000)
}
const open = () => {
const url = store.authorization?.url
if (url) platform.openExternal(url)
}
const select = async (index: number, answer?: FormAnswer) => {
cancelPolling()
cancelAttempt()
const generation = polling.generation
const selected = methods()[index]
dispatch({ type: "method.select", index })
const visible = (selected.form ?? []).some((field) => field.type === "external" || !field.hidden)
if (visible && !answer) {
if (selected.form?.length && !answer) {
dispatch({ type: "auth.form" })
return
}
const merged = { ...hiddenDefaults(selected), ...answer }
if (selected.type === "key") {
dispatch({ type: "auth.answer", answer: Object.keys(merged).length ? merged : undefined })
dispatch({ type: "auth.answer", answer })
return
}
if (selected.type !== "oauth") return
@@ -255,11 +183,11 @@ export function createProviderConnectionController(options: {
.connect({
integrationID: options.provider(),
methodID: selected.id,
...(Object.keys(merged).length ? { answer: merged } : {}),
...(answer ? { answer } : {}),
location: location(),
})
.then((response) => {
if (options.provider() === CONSOLE_INTEGRATION && platform.platform === "desktop") {
if (options.provider() === "opencode" && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
response.data.url = url.href
@@ -267,44 +195,21 @@ export function createProviderConnectionController(options: {
return { ok: true as const, authorization: response.data }
})
.catch((error) => ({ ok: false as const, error }))
if (polling.disposed || generation !== polling.generation) {
if (result.ok)
void serverSDK.api.integration.oauth
.cancel({
integrationID: options.provider(),
attemptID: result.authorization.attemptID,
location: location(),
})
.catch(() => undefined)
return
}
if (polling.disposed || generation !== polling.generation) return
if (!result.ok) {
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : String(result.error),
})
dispatch({ type: "auth.error", error: String(result.error) })
return
}
polling.attempt = result.authorization
dispatch({ type: "auth.complete", authorization: result.authorization })
// Same as `opencode auth login`: hand the user straight to the browser instead of
// asking them to click a link and retype a code.
platform.openExternal(result.authorization.url)
if (result.authorization.mode === "auto") void poll(result.authorization, generation)
}
const retry = () => {
const index = store.methodIndex
if (index === undefined) return
void select(index, store.formAnswer)
}
const reset = () => {
cancelPolling()
cancelAttempt()
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
await serverSDK.api.integration.connect.key({
integrationID: options.keyProvider?.() ?? options.provider(),
integrationID: options.provider(),
location: location(),
key,
...(store.formAnswer ? { answer: store.formAnswer } : {}),
@@ -331,16 +236,15 @@ export function createProviderConnectionController(options: {
return undefined
}
let auto = false
createEffect(() => {
const index = autoIndex()
if (store.auto || index === undefined) return
setStore("auto", true)
void select(index)
if (auto || integration.loading || methods().length !== 1) return
auto = true
void select(0)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
cancelAttempt()
})
return {
@@ -350,19 +254,11 @@ export function createProviderConnectionController(options: {
currentMethod,
methodIndex: () => store.methodIndex,
authorization: () => store.authorization,
// True while nothing useful can be shown yet: the integration is loading, a method is
// about to be picked automatically, or the authorization request is in flight.
busy: () =>
integration.loading ||
(store.methodIndex === undefined && !store.auto && autoIndex() !== undefined) ||
store.state === "pending",
auth: {
state: () => store.state,
error: () => store.error,
select,
reset,
retry,
open,
connectKey,
completeCode,
},
+95 -197
View File
@@ -8,23 +8,16 @@ import { TextField } from "@opencode/ui/text-field"
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import { type Component, createMemo, createUniqueId, For, type JSX, Match, onMount, Show, Switch } from "solid-js"
import { type Component, createMemo, createUniqueId, For, Match, onMount, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { useParams } from "@solidjs/router"
import { ExternalLink } from "@/runtime/platform/external-link"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { useProviders } from "@/providers/catalog/providers"
import { useIntegrations } from "@/providers/catalog/integrations"
import { CustomProviderForm } from "@/providers/credentials/dialog"
import { decode64 } from "@/runtime/persistence/base64"
import {
CONSOLE_PROVIDERS,
consoleIntegration,
createProviderConnectionController,
type ProviderConnectMethod,
} from "./controller"
import { createProviderConnectionController, type ProviderConnectMethod } from "./controller"
const CUSTOM_ID = "_custom"
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
@@ -265,42 +258,13 @@ function ProviderConnection(props: {
const dialog = useDialog()
const params = useParams()
const language = useLanguage()
const data = useData()
const global = useGlobal()
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const directory = () => props.directory ?? decode64(params.dir)
const location = () => {
const value = directory()
return value ? { directory: value } : undefined
}
const integrationID = () => consoleIntegration(props.provider)
const isConsole = () => CONSOLE_PROVIDERS.has(props.provider)
const controller = createProviderConnectionController({
provider: integrationID,
// A Go service-account key still belongs to the `opencode-go` integration (zen/go/v1),
// exactly as before; only the sign-in is shared with the Console.
keyProvider: () => props.provider,
provider: () => props.provider,
directory,
autoSelect: (methods) => {
if (!isConsole()) return undefined
const index = methods.findIndex((method) => method.type === "oauth")
return index === -1 ? undefined : index
},
onComplete: () => {
// The picker only lists the newest model per family by default, which hides most of
// what a new connection just unlocked. Show everything the connected integration offers.
// Console sign-in returns Go models inside the `opencode` provider, so this covers Go too.
const linked = (data.location.provider.list(location()) ?? []).filter(
(item) => item.id === props.provider || item.integrationID === integrationID(),
)
const ids = new Set(linked.map((item) => item.id))
global.models.show(
(data.location.model.list(location()) ?? [])
.filter((model) => ids.has(model.providerID) && model.status !== "deprecated")
.map((model) => ({ providerID: model.providerID, modelID: model.id })),
)
dialog.close()
showToast({
variant: "success",
@@ -312,11 +276,7 @@ function ProviderConnection(props: {
})
const provider = createMemo(() => ({
id: props.provider,
name:
integrations.list().find((item) => item.id === props.provider)?.name ??
providers.all().get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
}))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
@@ -338,13 +298,6 @@ function ProviderConnection(props: {
: undefined,
}
}
const code = createMemo(() => {
const instructions = controller.authorization()?.instructions
if (instructions?.includes(":")) return instructions.split(":").pop()?.trim()
return instructions
})
const keyIndex = () => controller.methods().findIndex((method) => method.type === "key")
const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth")
function AuthFormView() {
const [formStore, setFormStore] = createStore({
@@ -354,7 +307,7 @@ function ProviderConnection(props: {
const fields = createMemo<StringForm[]>(() => {
const value = controller.currentMethod()
return (value?.form ?? []).flatMap((field) => (field.type === "string" && !field.hidden ? [field] : []))
return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
})
const matches = (field: StringForm, value: Record<string, string>) => {
return (field.when ?? []).every((condition) => {
@@ -411,7 +364,7 @@ function ProviderConnection(props: {
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<Switch>
<Match when={item()?.field.options === undefined}>
<TextField
@@ -469,13 +422,7 @@ function ProviderConnection(props: {
}
function goBack() {
// The API key path for the Console is an escape hatch below the sign-in flow, so
// "back" returns to the sign-in rather than leaving the provider.
if (isConsole() && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) {
void controller.auth.select(oauthIndex())
return
}
if (!isConsole() && controller.methods().length > 1 && controller.methodIndex() !== undefined) {
if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
controller.auth.reset()
return
}
@@ -516,31 +463,6 @@ function ProviderConnection(props: {
)
}
function StatusRow(input: { children: JSX.Element }) {
return (
<div class="flex items-center gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Spinner class="size-4 shrink-0 text-v2-icon-icon-muted" />
<span>{input.children}</span>
</div>
)
}
function ErrorRow() {
return (
<div class="flex flex-col items-start gap-3">
<div class="flex items-start gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
<Icon name="circle-ban-sign" size="small" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
<span role="alert">
{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
</span>
</div>
<Button variant="neutral" onClick={() => controller.auth.retry()}>
{language.t("common.retry")}
</Button>
</div>
)
}
function ApiAuthView() {
let apiKey: HTMLInputElement | undefined
const errorID = createUniqueId()
@@ -572,17 +494,22 @@ function ProviderConnection(props: {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<Show
when={isConsole()}
when={provider().id === "opencode"}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<div>
{language.t("provider.connect.console.apiKey.description")}{" "}
<ExternalLink
href="https://opencode.ai/console"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.console.apiKey.link")}
</ExternalLink>
<div class="flex flex-col gap-5">
<div>{language.t("provider.connect.opencodeZen.line1")}</div>
<div>{language.t("provider.connect.opencodeZen.line2")}</div>
<div>
{language.t("provider.connect.opencodeZen.visit.prefix")}
<ExternalLink
href="https://opencode.ai/zen"
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
>
{language.t("provider.connect.opencodeZen.visit.link")}
</ExternalLink>
{language.t("provider.connect.opencodeZen.visit.suffix")}
</div>
</div>
</Show>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@@ -647,10 +574,13 @@ function ProviderConnection(props: {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>{language.t("provider.connect.oauth.code.description", { provider: provider().name })}</div>
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
<div>
{language.t("provider.connect.oauth.code.visit.prefix")}
<ExternalLink href={controller.authorization()!.url} class="text-v2-text-text-base">
{language.t("provider.connect.oauth.code.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
</div>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
<label class="flex w-full flex-col gap-1 font-[530] leading-4 text-v2-text-text-base">
{language.t("provider.connect.oauth.code.label", { method: controller.currentMethod()?.label ?? "" })}
@@ -683,71 +613,34 @@ function ProviderConnection(props: {
}
function OAuthAutoView() {
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>{language.t("provider.connect.oauth.auto.description", { provider: provider().name })}</div>
<StatusRow>{language.t("provider.connect.status.waiting")}</StatusRow>
<div class="flex flex-wrap items-center gap-2">
<Button variant="neutral" icon="arrow-up-right" onClick={() => controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
</div>
<Show when={code()}>
{(value) => (
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
description={language.t("provider.connect.oauth.auto.confirmationCode.description")}
class="font-mono"
value={value()}
readOnly
copyable
/>
)}
</Show>
</div>
)
}
const code = createMemo(() => {
const instructions = controller.authorization()?.instructions
if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim()
}
return instructions
})
// Rendered from the first frame of the Console sign-in, before the attempt exists. Only the
// status line changes once the browser opens, so nothing around it moves.
function ConsoleSignInView() {
const ready = () => controller.authorization()?.url !== undefined
return (
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
<div>{language.t("provider.connect.console.description", { provider: provider().name })}</div>
<StatusRow>
{language.t(ready() ? "provider.connect.status.waiting" : "provider.connect.console.opening")}
</StatusRow>
<div class="flex flex-wrap items-center gap-2">
<Button variant="neutral" icon="arrow-up-right" disabled={!ready()} onClick={() => controller.auth.open()}>
{language.t("provider.connect.oauth.openBrowser")}
</Button>
<div class="flex flex-col gap-6">
<div class="text-14-regular text-text-base">
{language.t("provider.connect.oauth.auto.visit.prefix")}
<ExternalLink href={controller.authorization()!.url}>
{language.t("provider.connect.oauth.auto.visit.link")}
</ExternalLink>
{language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
description={language.t("provider.connect.oauth.auto.confirmationCode.description")}
class="font-mono"
value={code() ?? ""}
placeholder={language.t("provider.connect.console.code.placeholder")}
value={code()}
readOnly
copyable={code() !== undefined}
copyable
/>
</div>
)
}
// Deliberately quiet: most people should never need a key, so this stays small and at the bottom.
function ConsoleApiKeySwitch() {
return (
<div class="mt-auto flex justify-end px-3 pt-4 text-[11px] leading-4 text-v2-text-text-muted">
<button
type="button"
data-action="provider-connect-api-key"
class="rounded-xs px-1 underline decoration-v2-border-border-base underline-offset-2 hover:text-v2-text-text-base focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
onClick={() => void controller.auth.select(keyIndex())}
>
{language.t("provider.connect.console.apiKey.switch")}
</button>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
</div>
</div>
)
}
@@ -768,48 +661,53 @@ function ProviderConnection(props: {
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<Switch>
<Match
when={
isConsole() &&
controller.currentMethod()?.type !== "key" &&
controller.auth.state() !== "error" &&
(controller.busy() || controller.authorization()?.mode === "auto")
}
>
<ConsoleSignInView />
</Match>
<Match when={controller.busy()}>
<div class="px-3">
<StatusRow>{language.t("provider.connect.status.inProgress")}</StatusRow>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="px-3">
<ErrorRow />
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
<Show
when={isConsole() && !controller.loading() && controller.currentMethod()?.type !== "key" && keyIndex() !== -1}
>
<ConsoleApiKeySwitch />
</Show>
<div>
<Switch>
<Match when={controller.loading()}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.methodIndex() === undefined}>
<MethodSelection />
</Match>
<Match when={controller.auth.state() === "pending"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Spinner />
<span>{language.t("provider.connect.status.inProgress")}</span>
</div>
</div>
</Match>
<Match when={controller.auth.state() === "form"}>
<AuthFormView />
</Match>
<Match when={controller.auth.state() === "error"}>
<div class="text-14-regular text-text-base">
<div class="flex items-center gap-x-2">
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
<span>{language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}</span>
</div>
</div>
</Match>
<Match when={controller.currentMethod()?.type === "key"}>
<ApiAuthView />
</Match>
<Match when={controller.currentMethod()?.type === "oauth"}>
<Switch>
<Match when={controller.authorization()?.mode === "code"}>
<OAuthCodeView />
</Match>
<Match when={controller.authorization()?.mode === "auto"}>
<OAuthAutoView />
</Match>
</Switch>
</Match>
</Switch>
</div>
</div>
</div>
)
-39
View File
@@ -87,7 +87,6 @@ export const dict = {
"command.project.index": "Switch to project {{index}}",
"command.project.copyID": "Copy Project ID",
"command.provider.connect": "Connect provider",
"command.provider.connect.description": "Sign in to OpenCode Go, OpenCode Console, or another model provider",
"command.server.switch": "Switch server",
"command.settings.open": "Open settings",
"command.session.previous": "Previous session",
@@ -152,8 +151,6 @@ export const dict = {
"command.session.compact.description": "Summarize the session to reduce context size",
"command.session.fork": "Fork from message",
"command.session.fork.description": "Create a new session from a previous message",
"command.session.btw": "Ask a side question",
"command.session.btw.description": "Get a one-shot answer without adding to the conversation",
"command.session.export": "Export session",
"command.session.export.description": "Export the full session transcript as JSON",
"command.session.import": "Import session",
@@ -228,22 +225,6 @@ export const dict = {
"provider.connect.oauth.auto.visit.suffix":
" and enter the code below to connect your account and use {{provider}} models in OpenCode.",
"provider.connect.oauth.auto.confirmationCode": "Confirmation code",
"provider.connect.oauth.auto.confirmationCode.description":
"Check that your browser shows the same code before you authorize.",
"provider.connect.oauth.auto.description":
"Your browser opens so you can sign in to {{provider}}. Come back here when you are done.",
"provider.connect.oauth.code.description":
"Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.",
"provider.connect.oauth.openBrowser": "Open browser",
"provider.connect.oauth.expired": "Authorization expired",
"provider.connect.console.description":
"Sign in with your OpenCode Console account. Your browser opens to the Console, where you pick a workspace and select Authorize.",
"provider.connect.console.opening": "Opening your browser…",
"provider.connect.console.code.placeholder": "Requesting a code…",
"provider.connect.console.apiKey.switch": "Connect with an API key or service account instead",
"provider.connect.console.apiKey.description":
"Paste an API key for a service account. You create service accounts in the OpenCode Console under Keys.",
"provider.connect.console.apiKey.link": "Open the Console",
"provider.connect.toast.connected.title": "{{provider}} connected",
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
@@ -321,7 +302,6 @@ export const dict = {
"common.connect": "Connect",
"common.disconnect": "Disconnect",
"common.continue": "Continue",
"common.retry": "Try again",
"common.submit": "Submit",
"common.save": "Save",
"common.saving": "Saving…",
@@ -460,19 +440,6 @@ export const dict = {
"No camera is available to this browser. Enter your connection details manually.",
"server.connect.camera.error":
"Could not open the camera. Allow camera access or enter your connection details manually.",
"command.server.pair": "Pair device",
"settings.pairing.title": "Pairing",
"settings.pairing.connection": "Local Network",
"pair.local.description": "View connection details and a QR code to connect a device on the same network.",
"pair.local.open": "Show details",
"pair.screenActive.title": "Keep screen active",
"pair.screenActive.description": "Prevent this computers display from sleeping while OpenCode is running.",
"pair.screenActive.error": "Could not update the screen activity setting. Try again.",
"pair.description": "Connect another device to this machine's OpenCode server.",
"pair.qr": "Pairing QR code",
"pair.copy": "Copy details",
"pair.copy.error": "Could not copy pairing details. Try again.",
"pair.error": "Could not update pairing details. Try again.",
"dialog.server.edit.title": "Edit server",
"dialog.server.default.title": "Default server",
"dialog.server.default.description":
@@ -789,7 +756,6 @@ export const dict = {
"session.tab.browser": "Browser",
"session.tab.add": "Add tab",
"session.tab.context": "Context",
"session.tab.btw": "/btw",
"session.tab.unknown": "Unknown Session",
"session.panel.reviewAndFiles": "Review and files",
"session.error.notFound": "This session cannot be found",
@@ -967,10 +933,6 @@ export const dict = {
"common.dismiss": "Dismiss",
"common.moreCountSuffix": " (+{{count}} more)",
"common.requestFailed": "Request failed",
"session.btw.questionRequired": "Add a question after /btw",
"session.btw.error": "Couldnt answer that question",
"session.btw.retry": "Retry",
"session.btw.copy": "Copy answer",
"common.moreOptions": "More options",
"common.learnMore": "Learn more",
"common.rename": "Rename",
@@ -1387,7 +1349,6 @@ export const dict = {
"settings.providers.tag.environment": "Environment",
"settings.providers.tag.config": "Config",
"settings.providers.tag.custom": "Custom",
"settings.providers.tag.account": "Account",
"settings.providers.tag.other": "Other",
"settings.models.title": "Models",
"settings.models.description": "Choose which models appear in model picker",
@@ -22,12 +22,6 @@ type SaveFilePickerOptions = { title?: string; defaultPath?: string }
type PlatformName = "web" | "desktop"
type DesktopOS = "macos" | "windows" | "linux"
export type PairingInfo = {
readonly urls: readonly string[]
readonly username: "opencode"
readonly password: string
}
export type FatalRendererErrorLog = {
error: string
url: string
@@ -107,10 +101,6 @@ type PlatformBase = {
/** Allow native pinch/Ctrl-scroll zoom gestures (desktop only) */
setPinchZoomEnabled?(enabled: boolean): Promise<void> | void
/** Prevent the local display from sleeping while the desktop app is running. */
getKeepScreenActive?(): Promise<boolean>
setKeepScreenActive?(enabled: boolean): Promise<void>
/** Run a desktop-only menu action from the app chrome */
runDesktopMenuAction?(action: DesktopMenuAction): Promise<void> | void
@@ -134,11 +124,6 @@ type PlatformBase = {
/** Native browser pane hosted by the platform (desktop only). */
browserPane?: BrowserPanePlatform
/** Pair another device with the local desktop server. */
pair?: {
info(): Promise<PairingInfo>
}
}
export type Platform = PlatformBase &
@@ -51,7 +51,6 @@ export function normalizeProviderList(
for (const provider of providers) {
all.set(provider.id, {
id: provider.id,
integrationID: provider.integrationID,
name: provider.name,
source: "custom",
env: [],
+1 -20
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
import { useServerHealth } from "@/runtime/server/health"
@@ -98,25 +98,6 @@ function createGlobalModels() {
set: setStore,
ready,
recent: () => recent()!,
// Marks models visible in the picker regardless of the "latest per family" default.
show(models: ReadonlyArray<{ providerID: string; modelID: string }>) {
const seen = new Map(store.user.map((item, index) => [`${item.providerID}:${item.modelID}`, index]))
batch(() => {
for (const model of models) {
const index = seen.get(`${model.providerID}:${model.modelID}`)
if (index !== undefined) {
setStore("user", index, "visibility", "show")
continue
}
seen.set(`${model.providerID}:${model.modelID}`, store.user.length)
setStore("user", store.user.length, {
providerID: model.providerID,
modelID: model.modelID,
visibility: "show",
})
}
})
},
}
}
-2
View File
@@ -129,8 +129,6 @@ export type Model = {
export type Provider = {
id: string
/** Integration that connects this provider; differs from `id` for Console-managed providers. */
integrationID?: string
name: string
source: "env" | "config" | "custom" | "api"
env: string[]
@@ -1,95 +0,0 @@
import { describe, expect, test } from "bun:test"
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
describe("pairing URL", () => {
test("pairs with the current origin using credentials without server URLs", () => {
const info = { username: "opencode" as const, password: "a+b & café" }
const origin = "https://opencode.example.com:49709"
const url = new URL(pairingUrl(info, origin))
expect(url.origin).toBe(origin)
expect(url.pathname).toBe("/connect")
expect(url.search).toBe("")
expect(url.hash).not.toBe("")
expect(decodePairingUrl(url.hash, origin)).toEqual({ urls: [origin], password: info.password })
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
})
test("keeps accepting query pairing data", () => {
const info = { urls: ["http://192.168.1.2:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`?data=${encodeURIComponent(JSON.stringify(info))}`)).toEqual({
urls: info.urls,
password: info.password,
})
})
test("accepts CLI base64url fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects invalid query data", () => {
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
expect(decodePairingUrl("?other=value")).toBeUndefined()
})
test("accepts legacy JSON fragments", () => {
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
urls: value.urls,
password: value.password,
})
})
test("rejects an invalid fragment", () => {
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
})
})
describe("pairing scan", () => {
const info = {
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
username: "opencode" as const,
password: "a+b & café",
}
test("decodes the raw JSON code", () => {
expect(decodePairingScan(JSON.stringify(info))).toEqual({ urls: info.urls, password: info.password })
})
test("decodes a direct /connect URL", () => {
expect(
decodePairingScan(pairingUrl({ username: info.username, password: info.password }, "http://192.168.1.2:49374")),
).toEqual({
urls: ["http://192.168.1.2:49374"],
password: info.password,
})
})
test("keeps accepting /connect URLs with query data", () => {
expect(
decodePairingScan(`http://192.168.1.2:49374/connect?data=${encodeURIComponent(JSON.stringify(info))}`),
).toEqual({
urls: info.urls,
password: info.password,
})
})
test("falls back to the URL origin when the payload omits server URLs", () => {
const origin = "https://opencode.example.com:49709"
expect(decodePairingScan(pairingUrl({ username: "opencode", password: "secret" }, origin))).toEqual({
urls: [origin],
password: "secret",
})
})
test("rejects URLs without pairing data and non-http schemes", () => {
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
expect(decodePairingScan("https://example.com/?data=invalid")).toBeUndefined()
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
expect(decodePairingScan("not a code")).toBeUndefined()
})
})
+3 -36
View File
@@ -1,10 +1,9 @@
import { Option, Schema } from "effect"
import { base64Encode } from "@opencode/util/encode"
import { normalizeServerUrl } from "@/runtime/server/registry"
const pairing = Schema.fromJsonString(
Schema.Struct({
urls: Schema.optional(Schema.Array(Schema.String)),
urls: Schema.Array(Schema.String),
username: Schema.Literal("opencode"),
password: Schema.String,
}),
@@ -20,42 +19,10 @@ export function serverAddress(value: string) {
return normalized
}
export function decodePairingCode(value: string, origin?: string) {
export function decodePairingCode(value: string) {
const result = Schema.decodeUnknownOption(pairing)(value)
if (Option.isNone(result)) return
const urls = [
...new Set((result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined)),
]
const urls = [...new Set(result.value.urls.map(serverAddress).filter((url) => url !== undefined))]
if (!urls.length) return
return { urls, password: result.value.password }
}
export function pairingUrl(value: { username: "opencode"; password: string }, host: string) {
return `${new URL("/connect", host)}#${base64Encode(JSON.stringify(value))}`
}
// Scanned codes are either the raw pairing JSON or a full /connect URL from `opencode pair` or desktop.
export function decodePairingScan(value: string) {
const url = URL.parse(value.trim())
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash, url.origin)
}
export function decodePairingUrl(value: string, origin?: string) {
if (value.startsWith("?")) {
const data = new URLSearchParams(value).get("data")
return data === null ? undefined : decodePairingCode(data, origin)
}
const encoded = value.startsWith("#") ? value.slice(1) : value
if (!encoded) return
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
if (legacy.startsWith("{")) return decodePairingCode(legacy)
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
const binary = atob(
encoded
.replaceAll("-", "+")
.replaceAll("_", "/")
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
)
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))), origin)
}
+3 -3
View File
@@ -3,11 +3,11 @@ import { onCleanup, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Button } from "@opencode/ui/button"
import { useLanguage } from "@/runtime/i18n/language"
import { decodePairingScan } from "./pairing"
import { decodePairingCode } from "./pairing"
import "./scanner.css"
export function PairingScanner(props: {
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
onScan: (value: NonNullable<ReturnType<typeof decodePairingCode>>) => void
onCancel: () => void
}) {
const language = useLanguage()
@@ -22,7 +22,7 @@ export function PairingScanner(props: {
const scanner = new QrScanner(
video,
(result) => {
const pairing = decodePairingScan(result.data)
const pairing = decodePairingCode(result.data)
if (!pairing) {
setState("error", language.t("server.connect.scan.invalid"))
return
+2 -12
View File
@@ -9,28 +9,19 @@ import { usePlatform } from "@/runtime/platform/platform"
import { useCheckServerHealth } from "@/runtime/server/health"
import { useServers } from "@/runtime/server/registry"
import { serverAddress } from "./pairing"
import type { decodePairingCode } from "./pairing"
import { isMixedContent } from "./browser"
import { createCameraAvailability } from "./camera"
import "./screen.css"
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
export function ConnectServerScreen(
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
) {
export function ConnectServerScreen() {
const language = useLanguage()
const platform = usePlatform()
const servers = useServers()
const check = useCheckServerHealth()
const camera = createCameraAvailability()
const [state, setState] = createStore({
url: props.pairing?.urls[0] ?? "",
password: props.pairing?.password ?? "",
urls: props.pairing?.urls ?? ([] as string[]),
error: "",
scanning: false,
})
const [state, setState] = createStore({ url: "", password: "", urls: [] as string[], error: "", scanning: false })
const connectionError = () =>
language.t(
platform.platform === "web" && isMixedContent(location.href, state.url)
@@ -51,7 +42,6 @@ export function ConnectServerScreen(
return
}
servers.add({ type: "http", http })
props.onConnect?.()
},
onError: () => setState("error", connectionError()),
}))
-114
View File
@@ -1,114 +0,0 @@
import { createEffect, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { useServerSDK } from "@/runtime/server/client"
import { useCommand } from "@/shell/commands/command"
import { showToast } from "@/shell/notifications/toast"
import { SESSION_BTW_TAB } from "@/session/helpers"
import type { SessionModel } from "../model"
const instructions = [
"The user is asking a quick side question about the conversation so far.",
"Answer directly and concisely in markdown from what you already know.",
"Do not call any tools and do not take any actions.",
].join(" ")
const empty = {
question: "",
answer: "",
error: false,
pending: false,
}
export function createSessionBtw(session: SessionModel) {
const command = useCommand()
const language = useLanguage()
const server = useServerSDK()
const [states, setStates] = createStore<Record<string, typeof empty>>({})
const requests = new Map<string, number>()
const controllers = new Map<string, AbortController>()
const state = () => states[session.identity.sessionKey()] ?? empty
createEffect(() => {
const key = session.identity.sessionKey()
onCleanup(() => {
const controller = controllers.get(key)
if (!controller) return
controller.abort()
controllers.delete(key)
if (states[key]?.pending) setStates(key, { pending: false, error: true })
})
})
const open = () => {
session.layout.view().reviewPanel.open()
const tabs = session.layout.tabs()
if (tabs.active() !== SESSION_BTW_TAB) tabs.open(SESSION_BTW_TAB)
}
const ask = (value?: string) => {
const question = value?.trim()
if (!question) {
showToast({ title: language.t("session.btw.questionRequired") })
return
}
open()
const sessionID = session.identity.sessionID()
if (!sessionID) return
const key = session.identity.sessionKey()
const request = (requests.get(key) ?? 0) + 1
requests.set(key, request)
controllers.get(key)?.abort()
const controller = new AbortController()
controllers.set(key, controller)
const owner = session.ownership.capture()
setStates(key, { question, answer: "", error: false, pending: true })
return server.api.session
.generate(
{
sessionID,
prompt: [instructions, question].join("\n\n"),
},
{ signal: controller.signal },
)
.then((result) => {
owner.run(() => {
if (requests.get(key) !== request) return
setStates(key, { answer: result.text.trim(), pending: false })
})
})
.catch(() => {
owner.run(() => {
if (controller.signal.aborted || requests.get(key) !== request) return
setStates(key, { error: true, pending: false })
})
})
.finally(() => {
if (controllers.get(key) === controller) controllers.delete(key)
})
}
command.register("session.btw", () => [
{
id: "session.btw",
title: language.t("command.session.btw"),
description: language.t("command.session.btw.description"),
category: language.t("command.category.session"),
slash: "btw",
slashArguments: true,
hidden: true,
disabled: !session.isDesktop(),
onSelect: (_source, input) => ask(input),
},
])
return {
answer: () => state().answer,
error: () => state().error,
pending: () => state().pending,
question: () => state().question,
retry: () => ask(state().question),
}
}
export type SessionBtwModel = ReturnType<typeof createSessionBtw>
-80
View File
@@ -1,80 +0,0 @@
import { createEffect, createSignal, Match, Show, Switch } from "solid-js"
import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { ScrollView } from "@opencode/ui/scroll-view"
import { TextShimmer } from "@opencode/ui/text-shimmer"
import { Tooltip } from "@opencode/ui/tooltip"
import { Markdown } from "@opencode/session-ui/markdown"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { showToast } from "@/shell/notifications/toast"
import type { SessionBtwModel } from "./model"
export function SessionBtwPanel(props: { btw: SessionBtwModel }) {
const language = useLanguage()
const platform = usePlatform()
const [copied, setCopied] = createSignal(false)
createEffect(() => {
props.btw.answer()
setCopied(false)
})
const copy = () => {
const answer = props.btw.answer()
if (!answer) return
void (platform.writeClipboardText?.(answer) ?? navigator.clipboard.writeText(answer)).then(
() => setCopied(true),
() => showToast({ title: language.t("common.requestFailed") }),
)
}
return (
<div class="flex h-full min-h-0 flex-col bg-v2-background-bg-base" data-slot="session-btw-panel">
<div class="flex shrink-0 items-start justify-between gap-3 border-b border-v2-border-border-base px-5 py-4">
<div class="min-w-0 text-13-regular text-text-weak">{props.btw.question()}</div>
<Show when={props.btw.answer()}>
<Tooltip value={copied() ? language.t("common.copied") : language.t("session.btw.copy")}>
<IconButton
size="small"
variant="ghost-muted"
icon={<Icon name={copied() ? "check" : "outline-copy"} />}
aria-label={copied() ? language.t("common.copied") : language.t("session.btw.copy")}
onClick={copy}
/>
</Tooltip>
</Show>
</div>
<div class="relative min-h-0 flex-1">
<Switch>
<Match when={props.btw.pending()}>
<div
data-component="session-working"
role="status"
class="flex h-9 items-center px-5 pt-3 text-[13px] font-[530] leading-text-compact"
>
<TextShimmer text={language.t("session.timeline.working")} active />
</div>
</Match>
<Match when={props.btw.error()}>
<div class="flex h-full flex-col items-center justify-center gap-3 px-8 pb-24 text-center">
<div class="text-13-regular text-text-weak">{language.t("session.btw.error")}</div>
<Button size="small" variant="outline" onClick={props.btw.retry}>
{language.t("session.btw.retry")}
</Button>
</div>
</Match>
<Match when={props.btw.answer()}>
<ScrollView class="absolute inset-0">
<div class="px-5 py-4 pb-8">
<Markdown text={props.btw.answer()} class="text-14-regular" />
</div>
</ScrollView>
</Match>
</Switch>
</div>
</div>
)
}
@@ -37,7 +37,6 @@ import { useSettings } from "@/settings/model"
import { createFileTabListSync } from "@/session/files/file-tab-scroll"
import {
SESSION_OPEN_FILE_TAB,
SESSION_BTW_TAB,
isSessionBrowserTab,
sessionBrowserTab,
createOpenSessionFileTab,
@@ -75,7 +74,6 @@ export function SessionSidePanel(props: {
size: Sizing
stacked?: boolean
browser: ReturnType<typeof createSessionBrowser>
btwPanel: () => JSX.Element
}) {
const layout = useLayout()
const settings = useSettings()
@@ -229,7 +227,7 @@ export function SessionSidePanel(props: {
})
const fileBrowserVisible = createMemo(() => {
const active = activeTab()
return active === SESSION_OPEN_FILE_TAB || active === activeFileTab()
return active !== "review" && active !== "context" && active !== "empty" && !isSessionBrowserTab(active)
})
const openFileKeybind = createMemo(() => command.keybindParts("file.open"))
const openBrowserKeybind = createMemo(() => command.keybindParts("browser.open"))
@@ -387,14 +385,6 @@ export function SessionSidePanel(props: {
/>
}
>
<Match when={tab === SESSION_BTW_TAB}>
<SortableTab tab={tab} index={tabs().all().indexOf(tab)} onTabClose={tabs().close}>
<div class="flex items-center gap-1.5">
<Icon name="bubble-5" size="small" />
<span>{language.t("session.tab.btw")}</span>
</div>
</SortableTab>
</Match>
<Match when={isSessionBrowserTab(tab)}>
<Show when={props.browser.tabs().find((item) => sessionBrowserTab(item.id) === tab)}>
{(item) => (
@@ -593,12 +583,6 @@ export function SessionSidePanel(props: {
</Tabs.Content>
</Show>
<Show when={activeTab() === SESSION_BTW_TAB}>
<Tabs.Content value={SESSION_BTW_TAB} class="flex h-full min-h-0 flex-col overflow-hidden">
{props.btwPanel()}
</Tabs.Content>
</Show>
<Show when={props.browser.opened()}>
<div
id={browserTabPanelID}
-19
View File
@@ -2,7 +2,6 @@ import { describe, expect, test } from "bun:test"
import { createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import {
SESSION_BTW_TAB,
SESSION_BROWSER_TAB,
sessionBrowserTab,
SESSION_OPEN_FILE_TAB,
@@ -237,24 +236,6 @@ describe("createSessionTabs", () => {
})
})
test("exposes the BTW tab without treating it as a file tab", () => {
createRoot((dispose) => {
const tabs = createMemo(() => ({ active: () => SESSION_BTW_TAB, all: () => [SESSION_BTW_TAB] }))
const result = createSessionTabs({
tabs,
pathFromTab: () => undefined,
normalizeTab: (tab) => tab,
})
expect(result.panelTabs()).toEqual([SESSION_BTW_TAB])
expect(result.openedTabs()).toEqual([])
expect(result.activeTab()).toBe(SESSION_BTW_TAB)
expect(result.activeFileTab()).toBeUndefined()
expect(result.closableTab()).toBe(SESSION_BTW_TAB)
dispose()
})
})
test("exposes one browser tab without treating it as a file tab", () => {
createRoot((dispose) => {
const tabs = createMemo(() => ({ active: () => SESSION_BROWSER_TAB, all: () => [SESSION_BROWSER_TAB] }))
+2 -8
View File
@@ -2,11 +2,10 @@ import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { makeEventListener } from "@solid-primitives/event-listener"
import { same } from "@/runtime/persistence/equality"
import { isSessionBrowserTab, SESSION_BTW_TAB, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
import { isSessionBrowserTab, SESSION_OPEN_FILE_TAB } from "@/shell/state/session-tabs"
export {
SESSION_BROWSER_TAB,
SESSION_BTW_TAB,
SESSION_OPEN_FILE_TAB,
sessionBrowserTab,
isSessionBrowserTab,
@@ -64,17 +63,13 @@ export const createSessionTabs = (input: TabsInput) => {
{ equals: same },
)
const openedTabs = createMemo(
() =>
panelTabs().filter(
(tab) => tab !== SESSION_OPEN_FILE_TAB && tab !== SESSION_BTW_TAB && !isSessionBrowserTab(tab),
),
() => panelTabs().filter((tab) => tab !== SESSION_OPEN_FILE_TAB && !isSessionBrowserTab(tab)),
emptyTabs,
{ equals: same },
)
const activeTab = createMemo(() => {
const active = input.tabs().active()
if (active === "context") return active
if (active === SESSION_BTW_TAB) return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active && isSessionBrowserTab(active) && browser()) return active
if (active === "review" && review()) return active
@@ -94,7 +89,6 @@ export const createSessionTabs = (input: TabsInput) => {
const closableTab = createMemo<string | undefined>(() => {
const active = activeTab()
if (active === "context") return active
if (active === SESSION_BTW_TAB) return active
if (active === SESSION_OPEN_FILE_TAB && openFileOpen()) return active
if (active && isSessionBrowserTab(active) && browser()) return active
if (!openedTabs().includes(active)) return
-4
View File
@@ -14,8 +14,6 @@ import { ReviewPanel } from "./panel"
import { SessionReviewTab } from "./review-tab"
import type { ChangeMode, SessionReviewModel } from "./model"
import type { createSessionBrowser } from "../browser/model"
import type { SessionBtwModel } from "../btw/model"
import { SessionBtwPanel } from "../btw/panel"
const MobilePanelDrawer = lazy(async () => {
const { MobilePanelDrawer } = await import("@/shell/mobile-panel-drawer")
@@ -129,7 +127,6 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) {
export function SessionDesktopReview(props: {
review: SessionReviewModel
browser: ReturnType<typeof createSessionBrowser>
btw: SessionBtwModel
present?: boolean
}) {
return (
@@ -156,7 +153,6 @@ export function SessionDesktopReview(props: {
size={props.review.screen.size}
stacked={props.review.screen.side.layout().stacked}
browser={props.browser}
btwPanel={() => <SessionBtwPanel btw={props.btw} />}
/>
</Suspense>
)
+1 -8
View File
@@ -39,7 +39,6 @@ import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
import { createSessionBtw } from "./btw/model"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -72,7 +71,6 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const btw = createSessionBtw(session)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
@@ -453,12 +451,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
setStore("sideReviewPresent", false)
}}
>
<SessionDesktopReview
review={review}
browser={browser}
btw={btw}
present={store.sideReviewPresent}
/>
<SessionDesktopReview review={review} browser={browser} present={store.sideReviewPresent} />
</div>
</Show>
</div>
-2
View File
@@ -7,7 +7,6 @@ export const pageIcons = {
appearance: "appearance",
notifications: "notifications",
shortcuts: "keyboard",
pairing: "server",
projects: "folder",
workspaces: "outline-worktree",
providers: "providers",
@@ -23,7 +22,6 @@ export const pageLabels = {
appearance: "settings.general.section.appearance",
notifications: "settings.tab.notifications",
shortcuts: "settings.shortcuts.title",
pairing: "settings.pairing.title",
projects: "settings.tab.projects",
workspaces: "settings.tab.workspaces",
providers: "settings.providers.title",
@@ -1,193 +0,0 @@
import { Button } from "@opencode/ui/button"
import { useDialog } from "@opencode/ui/context/dialog"
import { Dialog, DialogBody, DialogHeader, DialogTitleGroup } from "@opencode/ui/dialog"
import { Icon } from "@opencode/ui/icon"
import { Switch } from "@opencode/ui/switch"
import { Tooltip } from "@opencode/ui/tooltip"
import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
import { renderSVG } from "uqr"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
import { pairingUrl } from "@/servers/connect/pairing"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
export function SettingsPairing() {
const language = useLanguage()
const dialog = useDialog()
const platform = usePlatform()
const queryClient = useQueryClient()
const pair = platform.pair
if (!pair) return null
const local = useQuery(() => ({
queryKey: ["pairing", "local"],
queryFn: pair.info,
}))
// Reading pending query data would suspend the entire settings surface.
const localInfo = () => (local.isSuccess ? local.data : undefined)
const localHost = createMemo(() =>
localInfo()?.urls.find((value) => {
const host = new URL(value).hostname
return (
host !== "localhost" &&
!host.endsWith(".localhost") &&
!host.startsWith("127.") &&
host !== "[::1]" &&
host !== "0.0.0.0" &&
host !== "[::]"
)
}),
)
const screenActive = useQuery(() => ({
queryKey: ["pairing", "screen-active"],
queryFn: () => platform.getKeepScreenActive!(),
enabled: !!platform.getKeepScreenActive,
}))
const screenActivity = useMutation(() => ({
mutationFn: async (enabled: boolean) => platform.setKeepScreenActive?.(enabled),
onSuccess: (_, enabled) => queryClient.setQueryData(["pairing", "screen-active"], enabled),
}))
return (
<>
<div class="settings-tab-header">
<div class="settings-tab-header-row">
<div class="flex flex-col gap-1">
<h2 class="settings-tab-title">{language.t("settings.pairing.title")}</h2>
<span class="text-11-regular text-v2-text-text-muted">{language.t("pair.description")}</span>
</div>
</div>
</div>
<div class="settings-tab-body settings-tab-body--sectioned">
<section class="settings-section" aria-label={language.t("settings.pairing.connection")}>
<SettingsList>
<SettingsRow
title={language.t("settings.pairing.connection")}
description={language.t("pair.local.description")}
>
<Button
variant="neutral"
disabled={!localHost()}
onClick={() =>
dialog.push(() => (
<DialogPairing
title={language.t("settings.pairing.connection")}
info={localInfo()}
host={localHost()!}
/>
))
}
>
{language.t("pair.local.open")}
</Button>
</SettingsRow>
<Show when={platform.getKeepScreenActive && platform.setKeepScreenActive}>
<div data-action="settings-keep-screen-active">
<SettingsRow
title={language.t("pair.screenActive.title")}
description={language.t("pair.screenActive.description")}
>
<Switch
hideLabel
checked={screenActive.isSuccess && screenActive.data}
disabled={screenActive.isPending || !!screenActive.error || screenActivity.isPending}
onChange={(enabled) => screenActivity.mutate(enabled)}
>
{language.t("pair.screenActive.title")}
</Switch>
</SettingsRow>
</div>
</Show>
</SettingsList>
<Show when={screenActive.error || screenActivity.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.screenActive.error")}
</p>
</Show>
<Show when={local.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.error")}
</p>
</Show>
</section>
</div>
</>
)
}
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host: string }) {
const language = useLanguage()
const platform = usePlatform()
const url = createMemo(() => {
if (!props.info) return
return pairingUrl({ username: props.info.username, password: props.info.password }, props.host)
})
const origin = createMemo(() => {
const value = url()
if (!value) return
return new URL(value).origin
})
const copy = useMutation(() => ({
mutationFn: async () => {
const value = url()
if (!value) return
await (platform.writeClipboardText?.(value) ?? navigator.clipboard.writeText(value))
},
}))
createEffect(() => {
if (!copy.isSuccess) return
const timeout = setTimeout(() => copy.reset(), 2000)
onCleanup(() => clearTimeout(timeout))
})
const qr = createMemo(() => {
const value = url()
if (!value) return
return renderSVG(value, { border: 4, blackColor: "currentColor", whiteColor: "transparent" })
})
return (
<Dialog fit containerClass="max-w-[min(400px,calc(100vw-32px),calc(100dvh-180px))]">
<DialogHeader>
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
</DialogHeader>
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
<Show when={props.info}>
<div
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
role="img"
aria-label={language.t("pair.qr")}
innerHTML={qr()}
/>
<div class="flex min-w-0 justify-center pb-2">
<Tooltip
class="min-w-0 max-w-full"
value={language.t(copy.isSuccess ? "common.copied" : "pair.copy")}
placement="top"
forceOpen={copy.isSuccess ? true : undefined}
>
<button
type="button"
class="inline-flex min-h-8 max-w-full select-none items-center justify-center gap-2 rounded-[6px] px-2 py-1 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted transition-colors hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base focus-visible:bg-v2-background-bg-layer-02 focus-visible:outline-none disabled:opacity-50"
disabled={copy.isPending}
aria-label={language.t("pair.copy")}
onClick={() => copy.mutate()}
>
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
<bdi dir="ltr" class="min-w-0 break-all text-start">
{origin()}
</bdi>
</button>
</Tooltip>
</div>
</Show>
<Show when={copy.error}>
<p class="text-text-danger-base" role="alert">
{language.t("pair.copy.error")}
</p>
</Show>
</DialogBody>
</Dialog>
)
}
@@ -12,7 +12,7 @@ import { DialogConnectProvider, useProviderConnectController } from "@/providers
import { SettingsList } from "@/settings/list"
import "@/settings/settings.css"
type ProviderSource = "env" | "api" | "account" | "config" | "custom"
type ProviderSource = "env" | "api" | "config" | "custom"
type ProviderItem = ReturnType<ReturnType<typeof useProviders>["connected"]>[number]
const PROVIDER_NOTES = [
@@ -38,12 +38,7 @@ export const SettingsProviders: Component<{
const providers = useProviders(() => props.directory)
const integrations = useIntegrations(() => props.directory)
const providerConnect = useProviderConnectController({ onBack: props.onBack })
// Console-managed providers (`opencode-go`, `console-*`) connect through the `opencode`
// integration, so the lookup must follow `integrationID` rather than the provider id.
const integration = (item: ProviderItem) => {
const id = item.integrationID ?? item.id
return integrations.list().find((entry) => entry.id === id)
}
const integration = (providerID: string) => integrations.list().find((item) => item.id === providerID)
const connect = (provider?: string) => {
providerConnect.select(provider)
@@ -74,9 +69,8 @@ export const SettingsProviders: Component<{
// connections mean an API key or OAuth grant, env connections mean detected
// environment variables, and a connectionless integration is config-provided.
const source = (item: ProviderItem): ProviderSource | undefined => {
const current = integration(item)
const credential = current?.connections.find((connection) => connection.type === "credential")
if (credential) return credential.method === "oauth" ? "account" : "api"
const current = integration(item.id)
if (current?.connections.some((connection) => connection.type === "credential")) return "api"
if (current?.connections.some((connection) => connection.type === "env")) return "env"
if (current) return "config"
if (!("source" in item)) return
@@ -89,14 +83,13 @@ export const SettingsProviders: Component<{
const current = source(item)
if (current === "env") return language.t("settings.providers.tag.environment")
if (current === "api") return language.t("provider.connect.method.apiKey")
if (current === "account") return language.t("settings.providers.tag.account")
if (current === "config") return language.t("settings.providers.tag.config")
if (current === "custom") return language.t("settings.providers.tag.custom")
return language.t("settings.providers.tag.other")
}
const canDisconnect = (item: ProviderItem) => {
const current = integration(item)
const current = integration(item.id)
if (current) return current.connections.some((connection) => connection.type === "credential")
const currentSource = source(item)
return currentSource !== "env" && currentSource !== "config"
@@ -104,11 +97,10 @@ export const SettingsProviders: Component<{
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const disconnect = async (item: ProviderItem) => {
const name = item.name
const disconnect = async (providerID: string, name: string) => {
const location = props.directory ? { directory: props.directory } : undefined
await serverSdk.api.integration
.get({ integrationID: item.integrationID ?? item.id, location })
.get({ integrationID: providerID, location })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
@@ -170,7 +162,7 @@ export const SettingsProviders: Component<{
</span>
}
>
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item)}>
<Button size="normal" variant="ghost-muted" onClick={() => void disconnect(item.id, item.name)}>
{language.t("common.disconnect")}
</Button>
</Show>
@@ -20,20 +20,6 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
{ tab: "appearance", label: "settings.general.section.appearance" },
{ tab: "notifications", label: "settings.tab.notifications" },
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
{
tab: "pairing",
label: "settings.pairing.title",
keywords: "pair device qr local",
available: "desktop",
},
{
tab: "pairing",
label: "pair.screenActive.title",
description: "pair.screenActive.description",
target: "settings-keep-screen-active",
keywords: "display sleep awake local",
available: "desktop",
},
{ tab: "experimental", label: "settings.tab.experimental" },
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
+1 -12
View File
@@ -3,7 +3,6 @@ import { useDialog } from "@opencode/ui/context/dialog"
import { createEffect, createMemo, on, onCleanup, onMount, Show, Switch, Match, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
import { usePlatform } from "@/runtime/platform/platform"
import { useLayout } from "@/shell/state/layout"
import { useTabs } from "@/shell/tabs/tabs"
import { displayName } from "@/shell/layout/helpers"
@@ -19,7 +18,6 @@ import { SettingsAppearance } from "./appearance/appearance"
import { SettingsExperimental } from "./experimental/experimental"
import { SettingsKeybinds } from "./keybinds/keybinds"
import { SettingsNotifications } from "./notifications/notifications"
import { SettingsPairing } from "./pairing/pairing"
import { SettingsProviders } from "./providers/providers"
import { SettingsModels } from "./models/models"
import { SettingsServerGeneral } from "./servers/servers"
@@ -43,7 +41,6 @@ const rootClientTabs = [
{ value: "appearance", icon: pageIcons.appearance, label: "settings.general.section.appearance" },
{ value: "notifications", icon: pageIcons.notifications, label: "settings.tab.notifications" },
{ value: "shortcuts", icon: pageIcons.shortcuts, label: "settings.tab.shortcuts" },
{ value: "pairing", icon: pageIcons.pairing, label: "settings.pairing.title" },
] as const
const serverTabs = [
@@ -190,7 +187,6 @@ function RootSettings() {
const tabs = useTabs()
const servers = useServerCollectionController()
const inventory = useSettingsServers()
const platform = usePlatform()
const [state, setState] = createStore({ worktreeFilterReset: 0 })
const list = servers.collection.items
const singleEntry = createMemo(() => (inventory().length === 1 ? inventory()[0] : undefined))
@@ -220,11 +216,7 @@ function RootSettings() {
<DialogServer mode="add" onSave={(server) => surface.openServer(ServerConnection.key(server))} />
))
const groups = createMemo<SettingsNavGroup[]>(() => [
{
items: rootClientTabs
.filter((item) => item.value !== "pairing" || !!platform.pair)
.map((item) => ({ ...item, label: language.t(item.label) })),
},
{ items: rootClientTabs.map((item) => ({ ...item, label: language.t(item.label) })) },
...(multiple()
? [
{
@@ -290,9 +282,6 @@ function RootSettings() {
<Tabs.Content value="shortcuts" class="settings-panel">
<SettingsKeybinds active={surface.view().tab === "shortcuts"} autofocus={!surface.search.state.selected} />
</Tabs.Content>
<Tabs.Content value="pairing" class="settings-panel">
<SettingsPairing />
</Tabs.Content>
<Tabs.Content value="experimental" class="settings-panel">
<SettingsExperimental />
</Tabs.Content>
-2
View File
@@ -11,7 +11,6 @@ export type SettingsRootTab =
| "appearance"
| "notifications"
| "shortcuts"
| "pairing"
| "projects"
| "workspaces"
| "providers"
@@ -45,7 +44,6 @@ const rootTabs: Record<SettingsRootTab, true> = {
appearance: true,
notifications: true,
shortcuts: true,
pairing: true,
projects: true,
workspaces: true,
providers: true,
+6 -7
View File
@@ -88,12 +88,11 @@ export interface CommandOption {
category?: string
keybind?: KeybindConfig
slash?: string
slashArguments?: boolean
suggested?: boolean
disabled?: boolean
hidden?: boolean
when?: (event: KeyboardEvent) => boolean
onSelect?: (source?: "palette" | "keybind" | "slash", input?: string) => void | Promise<void>
onSelect?: (source?: "palette" | "keybind" | "slash") => void
onHighlight?: () => (() => void) | void
}
@@ -390,9 +389,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return map
})
const run = (id: string, source?: CommandSource, input?: string) => {
const run = (id: string, source?: CommandSource) => {
const option = optionMap().get(id)
return option?.onSelect?.(source, input)
option?.onSelect?.(source)
}
const showPalette = () => {
@@ -421,7 +420,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
if (!option) return
event.preventDefault()
event.stopPropagation()
void option.onSelect?.("keybind")
option.onSelect?.("keybind")
}
onMount(() => {
@@ -455,8 +454,8 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
return {
register,
trigger(id: string, source?: CommandSource, input?: string) {
return run(id, source, input)
trigger(id: string, source?: CommandSource) {
run(id, source)
},
keybind(id: string) {
const config = keybindConfig(id)
@@ -4,7 +4,6 @@ import { useCommand, type CommandOption } from "./command"
import { useDialog } from "@opencode/ui/context/dialog"
import { DialogSsh } from "@/servers/ssh/dialog"
import { useUpdaterAction } from "@/shell/updates/action"
import { useSettingsSurface } from "@/settings/surface"
export function DesktopCommands() {
const command = useCommand()
@@ -41,25 +40,3 @@ export function DesktopCommands() {
return null
}
export function DesktopPairingCommand() {
const command = useCommand()
const language = useLanguage()
const platform = usePlatform()
const settings = useSettingsSurface()
command.register("desktop-pairing", () =>
platform.platform === "desktop" && platform.pair
? [
{
id: "server.pair",
title: language.t("command.server.pair"),
category: language.t("command.category.server"),
onSelect: () => settings.open("pairing"),
},
]
: [],
)
return null
}
+1 -1
View File
@@ -166,7 +166,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
state.cleanup = undefined
dialog.close()
if (item.type === "command") {
void item.option?.onSelect?.("palette")
item.option?.onSelect?.("palette")
return
}
if (item.type === "session") {
+25 -48
View File
@@ -1,5 +1,5 @@
import { Route, useNavigate, useParams } from "@solidjs/router"
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
@@ -10,8 +10,6 @@ import { LayoutProvider } from "@/shell/state/layout"
import { SettingsSurfaceProvider } from "@/settings/surface"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
import { decodePairingUrl } from "@/servers/connect/pairing"
import { DesktopPairingCommand } from "@/shell/commands/desktop"
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
const loadSessionRoute = () => Promise.all([import("@/session/route"), File.preload()]).then(([module]) => module)
@@ -28,7 +26,6 @@ export function preloadRoute(url: string) {
const pathname = url.split(/[?#]/, 1)[0]
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
return TargetSessionRouteContent.preload().then(() => undefined)
return Promise.resolve()
@@ -36,48 +33,29 @@ export function preloadRoute(url: string) {
export function AppRoutes() {
return (
<>
<Route path="/connect" component={ConnectRoute} />
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
</>
)
}
function ConnectRoute() {
const navigate = useNavigate()
const servers = useServers()
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash, location.origin)
onMount(() => {
if (!pairing) return
servers.add({ type: "http", http: { url: pairing.urls[0], password: pairing.password } })
navigate("/", { replace: true })
})
return (
<Show when={!pairing}>
<ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
</Show>
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route path="/settings" component={SettingsScreen} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
)
}
@@ -101,7 +79,6 @@ function AppLayout(props: ParentProps) {
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
<LayoutProvider>
<SettingsSurfaceProvider>
<DesktopPairingCommand />
<BrowserAttachmentsProvider>
<Shell>{props.children}</Shell>
</BrowserAttachmentsProvider>
+1 -5
View File
@@ -10,10 +10,6 @@ test("settings has its own layout route", () => {
expect(currentRoute("/settings", "")).toEqual({ type: "settings" })
})
test("connect has its own layout route", () => {
expect(currentRoute("/connect", "")).toEqual({ type: "connect" })
})
describe("layout persistence", () => {
const schema = Persistence.withInitial(layoutPersistence, initialLayout(ServerConnection.Key.make("local")))
const decode = Schema.decodeUnknownSync(schema)
@@ -100,7 +96,7 @@ describe("layout persistence", () => {
test("keeps scoped state and salvages valid tab entries", () => {
const key = "local\u0000L3Byb2plY3Q/session"
const value = decode({
sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b", "btw"], active: "btw" } },
sessionTabs: { old: { all: ["old"] }, [key]: { all: ["a", null, "a", "b"], active: 12 } },
sessionView: { old: { scroll: {} }, [key]: { scroll: {}, reviewOpen: ["a", null, "b"] } },
})
expect(value.sessionTabs).toEqual({ [key]: { all: ["a", "b"], active: undefined } })
+3 -6
View File
@@ -18,7 +18,7 @@ import type { ProjectAvatarVariant } from "@opencode/ui/project-avatar"
import { SessionStateKey } from "@/runtime/server/scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./helpers"
import { requireServerKey } from "@/shell/routes/session"
import { closeSessionTab, openSessionTab, previewSessionTab, SESSION_BTW_TAB, type SessionTabs } from "./session-tabs"
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./session-tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -67,7 +67,6 @@ export type TabPanes = {
export type LayoutRoute =
| { type: "home" }
| { type: "settings" }
| { type: "connect" }
| { type: "draft"; draftID: string }
| { type: "session"; sessionId: string; server: ServerConnection.Key }
@@ -98,9 +97,8 @@ const normalizeSessionTabList = (path: ReturnType<typeof createPathHelpers> | un
const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => {
const path = sessionPath(key)
return {
all: normalizeSessionTabList(path, tabs.all).filter((tab) => tab !== SESSION_BTW_TAB),
active:
tabs.active === SESSION_BTW_TAB ? undefined : tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
all: normalizeSessionTabList(path, tabs.all),
active: tabs.active ? normalizeSessionTab(path, tabs.active) : tabs.active,
}
}
@@ -108,7 +106,6 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const parts = pathname.split("/").filter(Boolean)
if (parts.length === 0) return { type: "home" }
if (parts[0] === "settings") return { type: "settings" }
if (parts[0] === "connect") return { type: "connect" }
if (parts[0] === "new-session") {
const draftID = new URLSearchParams(search).get("draftId")
@@ -1,6 +1,5 @@
export const SESSION_OPEN_FILE_TAB = "open-file"
export const SESSION_BROWSER_TAB = "browser"
export const SESSION_BTW_TAB = "btw"
export const sessionBrowserTab = (tabID: string) => `${SESSION_BROWSER_TAB}:${tabID}`
export const isSessionBrowserTab = (tab: string | undefined) =>
!!tab && (tab === SESSION_BROWSER_TAB || tab.startsWith(`${SESSION_BROWSER_TAB}:`))
@@ -303,7 +303,6 @@ export function Titlebar(props: {
return
}
case "settings":
case "connect":
case "home": {
const selection = layout.home.selection()
const conn =
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.14",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"bin": {
+7 -17
View File
@@ -2,7 +2,6 @@ import { EOL } from "os"
import { Effect, Option } from "effect"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { base64Encode } from "@opencode/util/encode"
import { renderUnicodeCompact } from "uqr"
import { Commands } from "../commands"
import { Runtime } from "../../framework/runtime"
@@ -19,9 +18,6 @@ export default Runtime.handler(
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
)).urls
const info = { urls, username: "opencode", password }
const link = info.urls[0]
? `${new URL("/connect", info.urls[0])}#${base64Encode(JSON.stringify({ username: info.username, password }))}`
: undefined
process.stdout.write(
[
"",
@@ -29,19 +25,13 @@ export default Runtime.handler(
...info.urls.slice(1).map((url) => ` ${url}`),
` Username ${info.username}`,
` Password ${info.password}`,
...(link
? [
"",
" Scan to pair",
"",
renderUnicodeCompact(link, { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
"",
` Link ${link}`,
]
: []),
"",
" Scan to pair",
"",
renderUnicodeCompact(JSON.stringify(info), { border: 2 })
.split(EOL)
.map((line) => " " + line)
.join(EOL),
"",
].join(EOL) + EOL,
)
@@ -1,8 +1,9 @@
import { OpenCode } from "@opencode/client"
import { Service } from "@opencode/client/effect/service"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { SessionTransfer } from "@opencode/schema/session-transfer"
import { Effect, Option, Schema } from "effect"
import { Effect, Option, Predicate, Schema } from "effect"
import { EOL } from "node:os"
import path from "node:path"
import { Commands } from "../../commands"
@@ -23,7 +24,13 @@ export default Runtime.handler(
catch: (cause) =>
new Error(`Failed to read session data: ${cause instanceof Error ? cause.message : String(cause)}`),
})
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(SessionTransfer.Data))(text)
const raw = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown))(text)
// Exports written before provider blobs were renamed to `native` still carry the old keys.
const data = yield* Schema.decodeUnknownEffect(SessionTransfer.Data)(
Predicate.isObject(raw) && Array.isArray(raw.messages)
? { ...raw, messages: raw.messages.map(SessionMessage.persisted) }
: raw,
)
const encoded = Schema.encodeSync(SessionTransfer.Data)(data)
const server = yield* ServerConnection.resolve({
server: Option.getOrUndefined(input.server),
+1 -1
View File
@@ -564,7 +564,7 @@ export async function runNonInteractivePrompt(input: Input) {
messageID: message.id,
type: "reasoning",
text,
metadata: item.state,
metadata: item.native,
time: { start: message.time.created, end: timestamp },
}
renderedReasoning.set(key, item.text)
+12 -10
View File
@@ -10,16 +10,18 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
? Effect.succeed(options.assets)
: yield* Effect.cached(load().pipe(Effect.provideService(FileSystem.FileSystem, fileSystem)))
return <E, R>(api: Effect.Effect<HttpServerResponse.HttpServerResponse, E, R>) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
const url = new URL(request.url, "http://localhost")
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
return yield* api.pipe(
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
)
return yield* assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
})
api.pipe(
Effect.catchIf(isRouteNotFound, () =>
HttpServerRequest.HttpServerRequest.pipe(
Effect.flatMap((request) => {
const url = new URL(request.url, "http://localhost")
if (url.pathname === "/api" || url.pathname.startsWith("/api/"))
return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
return assets.pipe(Effect.flatMap((files) => serveUI(request, url, files)))
}),
),
),
)
})
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
+3 -3
View File
@@ -49,7 +49,7 @@ describe("auth command", () => {
name: "Anthropic",
methods: [],
connections: [
{ type: "credential", method: "key", id: "cred_test", label: "default" },
{ type: "credential", id: "cred_test", label: "default" },
{ type: "env", name: "ANTHROPIC_API_KEY" },
],
},
@@ -67,7 +67,7 @@ describe("auth command", () => {
id: "anthropic",
name: "Anthropic",
connections: [
{ type: "credential", method: "key", id: "cred_test", label: "default" },
{ type: "credential", id: "cred_test", label: "default" },
{ type: "env", name: "ANTHROPIC_API_KEY" },
],
},
@@ -267,7 +267,7 @@ describe("auth command", () => {
id: "anthropic",
name: "Anthropic",
methods: [{ type: "key" }],
connections: [{ type: "credential", method: "key", id: "cred_test", label: "default" }],
connections: [{ type: "credential", id: "cred_test", label: "default" }],
},
]),
)
-59
View File
@@ -1,5 +1,4 @@
import { NodeFileSystem, NodeHttpServer } from "@effect/platform-node"
import { ServerProcess } from "@opencode/server/process"
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { HttpServer, HttpServerError, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -8,69 +7,11 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import path from "node:path"
import { WebUi } from "../src/services/web-ui"
import { it } from "../../core/test/lib/effect"
const root = await mkdtemp(path.join(tmpdir(), "opencode-web-ui-"))
afterAll(() => rm(root, { recursive: true, force: true }))
describe("web UI", () => {
it.live("serves the web shell and assets before server authentication", () =>
Effect.gen(function* () {
const transform = yield* WebUi.handler({
assets: {
"index.html": "<html><body>connect</body></html>",
"_assets/app.js": "console.log('connect')",
"_assets/app.css": "body { color: black; }",
"icons/icon.svg": "<svg></svg>",
"font.woff2": new Uint8Array([0, 1, 2, 255]),
"sw.js": "service worker",
},
})
const server = yield* ServerProcess.start<never, never>(
{ hostname: "127.0.0.1", port: 0, password: "secret", database: { path: ":memory:" } },
undefined,
transform,
)
const origin = HttpServer.formatAddress(server.address)
yield* Effect.forEach(
[
"/",
"/connect?data=%7B%7D",
"/workspace/example",
"/_assets/app.js",
"/_assets/app.css",
"/icons/icon.svg",
"/font.woff2",
"/sw.js",
],
(pathname) =>
Effect.gen(function* () {
yield* Effect.forEach(["GET", "HEAD"], (method) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin), { method }))
expect(response.status).toBe(200)
expect(response.headers.get("www-authenticate")).toBeNull()
yield* Effect.promise(() => response.arrayBuffer())
}),
)
}),
)
yield* Effect.forEach(["/api", "/api/info", "/api/event", "/api/missing", "/openapi.json"], (pathname) =>
Effect.gen(function* () {
const response = yield* Effect.promise(() => fetch(new URL(pathname, origin)))
expect(response.status).toBe(401)
expect(response.headers.get("www-authenticate")).toBe('Basic realm="Secure Area"')
yield* Effect.promise(() => response.arrayBuffer())
}),
)
const response = yield* Effect.promise(() =>
fetch(new URL("/api/info", origin), { headers: { authorization: `Basic ${btoa("opencode:secret")}` } }),
)
expect(response.status).toBe(200)
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
}).pipe(Effect.provide(NodeFileSystem.layer)),
)
test("falls back from API routes to assets and the SPA index", async () => {
const index = path.join(root, "index.html")
const asset = path.join(root, "app.js")
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.14",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"repository": {
@@ -292,10 +292,7 @@ export function make(options: ClientOptions) {
const fetch = options.fetch ?? globalThis.fetch
const prepare = (descriptor: RequestDescriptor, requestOptions?: RequestOptions) => {
// A leading slash would replace any path prefix on baseUrl, so join relative to it.
const baseUrl = new URL(options.baseUrl)
if (!baseUrl.pathname.endsWith("/")) baseUrl.pathname += "/"
const url = new URL(descriptor.path.slice(1), baseUrl)
const url = new URL(descriptor.path, options.baseUrl)
for (const [key, value] of Object.entries(descriptor.query ?? {})) appendQuery(url.searchParams, key, value)
const headers = new Headers(options.headers)
for (const [key, value] of Object.entries(descriptor.headers ?? {})) {
+24 -28
View File
@@ -230,7 +230,7 @@ export type IntegrationCommandMethod = { id: string; type: "command"; label: str
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string; method: "key" | "oauth" }
export type ConnectionCredentialInfo = { type: "credential"; id: string; label: string }
export type ConnectionEnvInfo = { type: "env"; name: string }
@@ -509,12 +509,12 @@ export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text?: string; mention?: PromptMention }
export type SessionMessageAssistantText = { type: "text"; text: string; state?: SessionMessageProviderState }
export type SessionMessageAssistantText = { type: "text"; text: string; native?: SessionMessageProviderState }
export type SessionMessageAssistantReasoning = {
type: "reasoning"
text: string
state?: SessionMessageProviderState
native?: SessionMessageProviderState
time?: { created: number; completed?: number }
}
@@ -1354,15 +1354,6 @@ export type SessionToolCalled = {
}
}
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
export type SessionMessageAssistantReasoning1 = {
type: "reasoning"
text: string
state?: SessionMessageProviderState1
time?: { created: number; completed?: number }
}
export type ToolContent1 = ToolTextContent | ToolFileContent1
export type FormNumberField = {
@@ -1762,7 +1753,7 @@ export type SessionMessageCompactionCompleted = {
status: "completed"
reason: "auto" | "manual"
model?: ModelRef
providerState?: SessionMessageProviderState
native?: SessionMessageProviderState
summary: string
recent: string
providerContext?: SessionProviderContext
@@ -2228,7 +2219,7 @@ export type SessionMessageAssistant = {
snapshot?: { start?: string; end?: string; files?: Array<string> }
finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
rawFinish?: string
providerState?: SessionMessageProviderState
native?: SessionMessageProviderState
cost?: MoneyUSD
tokens?: TokenUsageInfo
error?: SessionStructuredError
@@ -2236,8 +2227,13 @@ export type SessionMessageAssistant = {
}
export type SessionMessageAssistantContentEncoded =
| SessionMessageAssistantText1
| SessionMessageAssistantReasoning1
| { type: "text"; text: string; state?: SessionMessageProviderState1 }
| {
type: "reasoning"
text: string
time?: { created: number; completed?: number }
state?: SessionMessageProviderState1
}
| SessionMessageAssistantTool1
export type FormInfo = { id: string; sessionID: string; title: string; metadata?: FormMetadata; fields: FormFields }
@@ -3102,11 +3098,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3180,7 +3176,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3214,7 +3210,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
@@ -3419,11 +3415,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3497,7 +3493,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3531,7 +3527,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
@@ -3736,11 +3732,11 @@ export type SessionImportInput = {
readonly agent: string
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly content: ReadonlyArray<
| { readonly type: "text"; readonly text: string; readonly state?: { readonly [x: string]: JsonValue } }
| { readonly type: "text"; readonly text: string; readonly native?: { readonly [x: string]: JsonValue } }
| {
readonly type: "reasoning"
readonly text: string
readonly state?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly time?: { readonly created: number; readonly completed?: number }
}
| {
@@ -3814,7 +3810,7 @@ export type SessionImportInput = {
readonly snapshot?: { readonly start?: string; readonly end?: string; readonly files?: ReadonlyArray<string> }
readonly finish?: "stop" | "length" | "tool-calls" | "content-filter" | "error" | "unknown"
readonly rawFinish?: string
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly cost?: number
readonly tokens?: {
readonly input: number
@@ -3848,7 +3844,7 @@ export type SessionImportInput = {
readonly status: "completed"
readonly reason: "auto" | "manual"
readonly model?: { readonly id: string; readonly providerID: string; readonly variant?: string }
readonly providerState?: { readonly [x: string]: JsonValue }
readonly native?: { readonly [x: string]: JsonValue }
readonly summary: string
readonly recent: string
readonly providerContext?: {
+8 -7
View File
@@ -846,7 +846,7 @@ export function createData(config: CreateDataInput) {
existing.error = undefined
existing.finish = undefined
existing.rawFinish = undefined
existing.providerState = undefined
existing.native = undefined
existing.time.created = event.data.started
existing.time.streamed = undefined
existing.time.completed = undefined
@@ -880,7 +880,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.native = event.data.providerState
assistant.cost = event.data.cost
assistant.tokens = event.data.tokens
if (event.data.snapshot) assistant.snapshot = { ...assistant.snapshot, end: event.data.snapshot }
@@ -892,7 +892,7 @@ export function createData(config: CreateDataInput) {
assistant.time.completed = event.created
assistant.finish = event.data.finish ?? "error"
assistant.rawFinish = event.data.rawFinish
assistant.providerState = event.data.providerState
assistant.native = event.data.providerState
assistant.error = event.data.error
assistant.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
@@ -914,6 +914,7 @@ export function createData(config: CreateDataInput) {
case "session.text.ended":
message.editText(event.data.sessionID, event.data.assistantMessageID, (text) => {
text.text = event.data.text
text.native = event.data.state
})
return
case "session.tool.input.started":
@@ -984,7 +985,7 @@ export function createData(config: CreateDataInput) {
assistant.content.push({
type: "reasoning",
text: "",
state: event.data.state,
native: event.data.state,
time: { created: event.created },
})
})
@@ -998,7 +999,7 @@ export function createData(config: CreateDataInput) {
message.editReasoning(event.data.sessionID, event.data.assistantMessageID, (reasoning) => {
reasoning.text = event.data.text
reasoning.time = { created: reasoning.time?.created ?? event.created, completed: event.created }
if (event.data.state !== undefined) reasoning.state = event.data.state
if (event.data.state !== undefined) reasoning.native = event.data.state
})
return
case "session.retry.scheduled":
@@ -1105,7 +1106,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
@@ -1120,7 +1121,7 @@ export function createData(config: CreateDataInput) {
status: "completed",
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
providerContext: event.data.providerContext,
summary: event.data.text,
recent: event.data.recent,
+2 -6
View File
@@ -26,9 +26,7 @@ export function createPtyClient(api: OpenCodeClient, options: PtyClientOptions)
location: input.location,
"x-opencode-ticket": "1",
})
const baseUrl = new URL(options.url)
if (!baseUrl.pathname.endsWith("/")) baseUrl.pathname += "/"
const url = new URL(`api/pty/${encodeURIComponent(input.ptyID)}/connect`, baseUrl)
const url = new URL(`/api/pty/${encodeURIComponent(input.ptyID)}/connect`, options.url)
if (input.location?.directory) url.searchParams.set("location[directory]", input.location.directory)
if (input.cursor !== undefined) url.searchParams.set("cursor", String(input.cursor))
url.searchParams.set("ticket", result.data.ticket)
@@ -48,9 +46,7 @@ export function createPersistentPtyClient(api: OpenCodeClient, options: PtyClien
ptyID: input.ptyID,
"x-opencode-ticket": "1",
})
const baseUrl = new URL(options.url)
if (!baseUrl.pathname.endsWith("/")) baseUrl.pathname += "/"
const url = new URL(`api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`, baseUrl)
const url = new URL(`/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/connect`, options.url)
url.searchParams.set("ticket", token.ticket)
url.searchParams.set("cursor", String(input.cursor))
url.searchParams.set("attachment_id", input.attachmentID)
-28
View File
@@ -83,34 +83,6 @@ test("config.get returns ordered config entries for a location", async () => {
expect(request?.url).toBe("http://localhost:3000/api/config?location%5Bdirectory%5D=%2Ftmp%2Fproject")
})
test("requests keep a path prefix on baseUrl", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:8888/ws/abc",
fetch: async (input) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp" } })
},
})
await client.server.info()
expect(request?.url).toBe("http://localhost:8888/ws/abc/api/info")
})
test("requests join against the base URL pathname", async () => {
let request: Request | undefined
const client = OpenCode.make({
baseUrl: "http://localhost:8888/ws/abc?tenant=one#fragment",
fetch: async (input) => {
request = input instanceof Request ? input : new Request(input)
return Response.json({ version: "2.0.0", pid: 1, urls: [], paths: { tmp: "/tmp" } })
},
})
await client.server.info()
expect(request?.url).toBe("http://localhost:8888/ws/abc/api/info")
})
test("vcs.base and committed diffs preserve location and explicit base on the wire", async () => {
const requests: Request[] = []
const location = { directory: "/repo", project: { id: "global", directory: "/repo", canonical: "/repo" } }
@@ -135,7 +135,7 @@ test.each(["started", "cancelled", "failed"])(
status: "completed",
summary: "Summary",
model,
providerState,
native: providerState,
providerContext,
cost: 0.01,
tokens,
+4 -49
View File
@@ -8,7 +8,7 @@ describe("createPtyClient", () => {
let socketURL: URL | undefined
const socket = { binaryType: "blob" } as unknown as WebSocket
const api = OpenCode.make({
baseUrl: "https://server.example",
baseUrl: "https://server.example/base",
headers: { Authorization: "Basic credential" },
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
@@ -22,7 +22,7 @@ describe("createPtyClient", () => {
},
})
const pty = createPtyClient(api, {
url: "https://server.example",
url: "https://server.example/base",
openSocket(url) {
socketURL = url
return socket
@@ -65,31 +65,6 @@ describe("createPtyClient", () => {
await expect(pty.connect({ ptyID: "pty_1", location: { directory: "/repo" } })).rejects.toThrow()
expect(opened).toBe(false)
})
test("keeps the server path prefix when opening the terminal socket", async () => {
let socketURL: URL | undefined
const api = OpenCode.make({
baseUrl: "https://server.example/base",
fetch: async () =>
Response.json({
location: {
directory: "/repo",
project: { id: "project_1", directory: "/repo", canonical: "/repo" },
},
data: { ticket: "ticket-1", expires_in: 60 },
}),
})
const pty = createPtyClient(api, {
url: "https://server.example/base",
openSocket(url) {
socketURL = url
return { binaryType: "blob" } as unknown as WebSocket
},
})
await pty.connect({ ptyID: "pty_1" })
expect(socketURL?.toString()).toBe("wss://server.example/base/api/pty/pty_1/connect?ticket=ticket-1")
})
})
describe("createPersistentPtyClient", () => {
@@ -98,7 +73,7 @@ describe("createPersistentPtyClient", () => {
let socketURL: URL | undefined
const socket = { binaryType: "blob" } as unknown as WebSocket
const api = OpenCode.make({
baseUrl: "https://server.example",
baseUrl: "https://server.example/base",
headers: { Authorization: "Basic credential" },
fetch: async (input, init) => {
request = input instanceof Request ? input : new Request(input, init)
@@ -106,7 +81,7 @@ describe("createPersistentPtyClient", () => {
},
})
const pty = createPersistentPtyClient(api, {
url: "https://server.example",
url: "https://server.example/base",
openSocket(url) {
socketURL = url
return socket
@@ -123,24 +98,4 @@ describe("createPersistentPtyClient", () => {
)
expect(socket.binaryType).toBe("arraybuffer")
})
test("keeps the server path prefix when opening the persistent terminal socket", async () => {
let socketURL: URL | undefined
const api = OpenCode.make({
baseUrl: "https://server.example/base",
fetch: async () => Response.json({ data: { ticket: "ticket-1", expires_in: 60 } }),
})
const pty = createPersistentPtyClient(api, {
url: "https://server.example/base",
openSocket(url) {
socketURL = url
return { binaryType: "blob" } as unknown as WebSocket
},
})
await pty.connect({ ptyID: "pty_1", cursor: 0, attachmentID: "attachment_1" })
expect(socketURL?.toString()).toBe(
"wss://server.example/base/api/experimental/persistent-pty/pty_1/connect?ticket=ticket-1&cursor=0&attachment_id=attachment_1&takeover=false&input_protocol=1",
)
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.14",
"version": "2.0.12",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.14",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.14",
"version": "2.0.12",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.14",
"version": "2.0.12",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.14",
"version": "2.0.12",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.14",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.14",
"version": "2.0.12",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
@@ -379,13 +379,13 @@ export function transformSession(input: TransformInput): TransformResult {
return []
const content = owned.flatMap((part): Array<Record<string, unknown>> => {
if (part.type === "text")
return [{ type: "text", text: part.text, ...(part.metadata ? { state: part.metadata } : {}) }]
return [{ type: "text", text: part.text, ...(part.metadata ? { native: part.metadata } : {}) }]
if (part.type === "reasoning")
return [
{
type: "reasoning",
text: part.text,
...(part.metadata ? { state: part.metadata } : {}),
...(part.metadata ? { native: part.metadata } : {}),
time: { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) },
},
]
+4 -3
View File
@@ -361,7 +361,6 @@ const layer = Layer.effect(
type: "credential" as const,
id: credential.id,
label: credential.label,
method: credential.value.type,
}))
.toReversed()
const env = (entry?.methods ?? [])
@@ -732,7 +731,8 @@ const layer = Layer.effect(
connect: connectOAuth,
status: Effect.fn("Integration.oauth.status")(function* (input) {
const attempt = (yield* SynchronizedRef.get(attempts)).get(input.attemptID)
if (!attempt || attempt.integrationID !== input.integrationID) return yield* new AttemptNotFoundError(input)
if (!attempt || attempt.integrationID !== input.integrationID)
return yield* new AttemptNotFoundError(input)
if (attempt.status === "failed") {
return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time }
}
@@ -777,7 +777,8 @@ const layer = Layer.effect(
connect: connectCommand,
status: Effect.fn("Integration.command.status")(function* (input) {
const attempt = (yield* SynchronizedRef.get(commandAttempts)).get(input.attemptID)
if (!attempt || attempt.integrationID !== input.integrationID) return yield* new AttemptNotFoundError(input)
if (!attempt || attempt.integrationID !== input.integrationID)
return yield* new AttemptNotFoundError(input)
if (attempt.status === "pending") {
return {
status: attempt.status,
File diff suppressed because one or more lines are too long
@@ -165,7 +165,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
yield* ctx.integration.transform((editor) => {
editor.update("opencode", (integration) => {
integration.name = "OpenCode Console"
integration.name = "OpenCode"
})
editor.method.update(oauth(http))
editor.method.update({ integrationID: "opencode", method: { type: "key", label: "API key (service account)" } })
+1 -1
View File
@@ -60,7 +60,7 @@ export const latestCompaction = Effect.fnUntraced(function* (
})
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
decode({ ...row.data, id: row.id, type: row.type }).pipe(
decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
Effect.tap((message) =>
SessionProviderContext.isCheckpoint(message)
? SessionProviderContext.validate(message.providerContext)
+1 -1
View File
@@ -125,7 +125,7 @@ const promotedFromMessage = Effect.fn("SessionInbox.promotedFromMessage")(functi
if (row === undefined) return undefined
if (row.session_id !== sessionID || (row.type !== "user" && row.type !== "synthetic"))
return yield* new LifecycleConflict({ id })
const message = decodeMessage({ ...row.data, id: row.id, type: row.type })
const message = decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
const base = { id, sessionID, time: { created: message.time.created }, delivery }
if (message.type === "user")
return User.make({
+11 -9
View File
@@ -83,7 +83,9 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
"session.message.content.updated": (event) =>
updateOwnedAssistant(event.data.messageID, (draft) => {
draft.content = castDraft(
Schema.decodeUnknownSync(Schema.Array(SessionMessage.AssistantContent))(event.data.content),
Schema.decodeUnknownSync(Schema.Array(SessionMessage.AssistantContent))(
SessionMessage.persistedContent(event.data.content),
),
)
}),
"session.usage.recorded": () => Effect.void,
@@ -222,7 +224,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.error = undefined
draft.finish = undefined
draft.rawFinish = undefined
draft.providerState = undefined
draft.native = undefined
draft.time.created = DateTime.makeUnsafe(event.data.started)
draft.time.streamed = undefined
draft.time.completed = undefined
@@ -263,7 +265,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.native = castDraft(event.data.providerState)
draft.cost = event.data.cost
draft.tokens = event.data.tokens
projectTerminalSnapshot(draft, event)
@@ -274,7 +276,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
draft.time.completed = created
draft.finish = event.data.finish ?? "error"
draft.rawFinish = event.data.rawFinish
draft.providerState = castDraft(event.data.providerState)
draft.native = castDraft(event.data.providerState)
draft.error = castDraft(event.data.error)
draft.retry = undefined
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
@@ -294,7 +296,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
const match = latestText(draft)
if (match) {
match.text = event.data.text
match.state = castDraft(event.data.state)
match.native = castDraft(event.data.state)
}
})
},
@@ -382,7 +384,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
SessionMessage.AssistantReasoning.make({
type: "reasoning",
text: "",
state: event.data.state,
native: event.data.state,
time: { created },
}),
),
@@ -395,7 +397,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
if (match) {
match.text = event.data.text
match.time = { created: match.time?.created ?? created, completed: created }
if (event.data.state !== undefined) match.state = event.data.state
if (event.data.state !== undefined) match.native = event.data.state
}
})
},
@@ -431,7 +433,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata ? { ...current.metadata, ...event.metadata } : current.metadata,
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
@@ -448,7 +450,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
metadata: event.metadata,
reason: event.data.reason,
model: event.data.model,
providerState: event.data.providerState,
native: event.data.providerState,
summary: event.data.text,
providerContext: event.data.providerContext,
recent: event.data.recent,
+1 -1
View File
@@ -228,7 +228,7 @@ const projectFork = Effect.fn("SessionProjector.projectFork")(function* (
function run(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
const decodeRow = (row: typeof SessionMessageTable.$inferSelect) =>
decodeMessage({ ...row.data, id: row.id, type: row.type })
decodeMessage(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type }))
const updateMessage = (message: SessionMessage.Info) => {
const encoded = encodeMessage(message)
const { id, type, ...data } = encoded
+3 -1
View File
@@ -107,7 +107,9 @@ const plan = Effect.fn("SessionRevert.plan")(function* (db: Database.Interface["
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
const files = new Map<RelativePath, Snapshot.ID>()
for (const row of rows) {
const message = yield* decode({ ...row.data, id: row.id, type: row.type }).pipe(Effect.orDie)
const message = yield* decode(SessionMessage.persisted({ ...row.data, id: row.id, type: row.type })).pipe(
Effect.orDie,
)
if (message.type !== "assistant" || !message.snapshot?.start) continue
for (const file of message.snapshot.files ?? [])
if (!files.has(file)) files.set(file, Snapshot.ID.make(message.snapshot.start))
@@ -162,7 +162,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
text: item.text,
// Text can carry provider-bound state (e.g. Gemini thought signatures),
// which is only replayable against the model that produced it.
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.state) : undefined,
providerMetadata: reuseProviderMetadata ? providerMetadata(providerMetadataKey, item.native) : undefined,
},
]
// Let the destination adapter handle readable reasoning after a model/provider switch.
@@ -172,7 +172,7 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
{
type: "reasoning",
text: item.text,
providerMetadata: providerMetadata(providerMetadataKey, item.state),
providerMetadata: providerMetadata(providerMetadataKey, item.native),
},
]
: item.text.length > 0
+3 -3
View File
@@ -269,13 +269,13 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
return {
...content,
text: redact("text", message.id, content.text),
state: content.state ? { redacted: `text-state:${message.id}` } : undefined,
native: content.native ? { redacted: `text-native:${message.id}` } : undefined,
}
if (content.type === "reasoning")
return {
...content,
text: redact("reasoning", message.id, content.text),
state: content.state ? { redacted: `reasoning-state:${message.id}` } : undefined,
native: content.native ? { redacted: `reasoning-native:${message.id}` } : undefined,
}
return {
...content,
@@ -299,7 +299,7 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
summary: redact("compaction-summary", message.id, message.summary),
recent: redact("compaction-recent", message.id, message.recent),
...(message.status === "completed"
? { providerState: metadata("compaction-provider-state", message.id, message.providerState) }
? { native: metadata("compaction-native", message.id, message.native) }
: {}),
}
}
+2 -4
View File
@@ -239,10 +239,8 @@ const layer = () =>
if (page.output.endsWith("\n")) lines.pop()
const truncated = latest.size > maxBytes || lines.length > maxLines
const text = lines.length > maxLines ? lines.slice(-maxLines).join("\n") : page.output
const notice = truncated
? `${text ? "\n\n" : ""}[full output saved to ${info.file}]`
: ""
return { output: `${text}${notice}`, truncated }
const notice = truncated ? `\n\n[output truncated; full output saved to: ${info.file}]` : ""
return { output: `${text || "(no output)"}${notice}`, truncated }
}).pipe(Effect.catchTag("Shell.NotFoundError", () => Effect.succeed(undefined)))
return { info, capture }
})
+4 -8
View File
@@ -27,8 +27,8 @@ export function output(result: Result): Output {
}
export function notice(output: Pick<Output, "exit" | "timeout">) {
if (output.timeout) return "Timed out before completion"
if (output.exit !== undefined && output.exit !== 0) return `Exited with code ${output.exit}`
if (output.timeout) return "Command timed out before completion."
if (output.exit !== undefined) return `Command exited with code ${output.exit}.`
}
export function metadata(output: Output) {
@@ -62,16 +62,12 @@ export function notification(input: {
export function userNotification(result: Result) {
const captured = output(result)
const status =
result.info.status === "killed"
? "Cancelled"
: captured.exit === 0
? undefined
: (notice(captured) ?? "Exited with code unknown")
result.info.status === "killed" ? "Command cancelled." : (notice(captured) ?? "Command exited with code unknown.")
const message = notification({
shellID: result.info.id,
command: result.info.command,
state: result.info.status === "killed" ? "cancelled" : "completed",
text: [captured.output, status].filter((item) => item !== undefined && item !== "").join("\n\n"),
text: `${captured.output}\n\n${status}`,
output: captured,
})
return { ...message, text: `The following shell command was executed by the user:\n${message.text}` }
+2 -2
View File
@@ -76,7 +76,7 @@ type Output = typeof Output.Type
const resultMessages = (output: Output) => {
const notice = output.status === "running" ? BACKGROUND_INSTRUCTION : ShellResult.notice(output)
return [...(output.output ? [output.output] : []), ...(notice ? [notice] : [])]
return [output.output, ...(notice ? [notice] : [])]
}
const toolResult = (output: Output) => {
@@ -165,7 +165,7 @@ export const Plugin = {
? resultMessages(output).join("\n\n")
: info.status === "error"
? (info.error ?? "Command failed")
: "Cancelled"
: "Command cancelled"
yield* sessions.synthetic({
...(info.notificationID ? { id: info.notificationID } : {}),
sessionID,
-6
View File
@@ -599,19 +599,16 @@ describe("Integration", () => {
expect((yield* integrations.get(integrationID))?.connections).toEqual([
{
type: "credential",
method: "key",
id: personal.id,
label: "Personal",
},
{
type: "credential",
method: "key",
id: work.id,
label: "Work",
},
{
type: "credential",
method: "key",
id: archived.id,
label: "Archived",
},
@@ -619,7 +616,6 @@ describe("Integration", () => {
])
expect(yield* integrations.connection.active(integrationID)).toEqual({
type: "credential",
method: "key",
id: personal.id,
label: "Personal",
})
@@ -631,7 +627,6 @@ describe("Integration", () => {
expect(yield* integrations.connection.active(integrationID)).toEqual({
type: "credential",
method: "key",
id: work.id,
label: "Work",
})
@@ -654,7 +649,6 @@ describe("Integration", () => {
yield* integrations.connection.remove(work.id)
expect(yield* integrations.connection.active(integrationID)).toEqual({
type: "credential",
method: "key",
id: personal.id,
label: "Personal",
})
-9
View File
@@ -422,15 +422,6 @@ describe("MCP OAuth", () => {
expect(tokenRequests[0]?.get("grant_type")).toBe("refresh_token")
})
test("requests offline_access without forcing a consent prompt", async () => {
const { server } = authorizationServer({ scopes_supported: ["read", "offline_access"] })
const { url } = await Effect.runPromise(
Effect.scoped(start(server, { client_id: "client", scope: "read" })),
).finally(() => server.stop(true))
expect(url.searchParams.get("scope")).toBe("read offline_access")
expect(url.searchParams.has("prompt")).toBe(false)
})
test("forwards iss from the redirect so issuer-advertising servers can complete", async () => {
const { server } = authorizationServer({ authorization_response_iss_parameter_supported: true })
const result = await Effect.runPromise(
+3 -8
View File
@@ -568,14 +568,9 @@ it.effect("refreshes expired OAuth credentials through the context during activa
expect(yield* plugins.list()).toMatchObject([{ id: "oauth-refresh", state: { status: "active" } }])
expect(resolved).toEqual([refreshed])
expect((yield* credentials.get(stored.id))?.value).toEqual(refreshed)
expect(
yield* integrations.connection.resolve({
type: "credential",
method: "oauth",
id: stored.id,
label: stored.label,
}),
).toEqual(refreshed)
expect(yield* integrations.connection.resolve({ type: "credential", id: stored.id, label: stored.label })).toEqual(
refreshed,
)
expect(refreshes).toEqual([expired])
}),
)
@@ -320,12 +320,7 @@ describe("OpencodePlugin", () => {
expect(config).toEqual([{ authorization: "Bearer access", orgID: scenario.orgID ?? "org-a" }])
const integrations = yield* Integration.Service
expect(
yield* integrations.connection.resolve({
type: "credential",
method: "oauth",
id: initial.id,
label: initial.label,
}),
yield* integrations.connection.resolve({ type: "credential", id: initial.id, label: initial.label }),
).toEqual(stored.value)
expect(requests).toEqual(["/auth/device/token", "/api/v2/config"])
}),
@@ -319,7 +319,7 @@ it.effect("keeps near-expiry keys usable and requires a new login after expiry",
expires: (yield* Clock.currentTimeMillis) + 120_000,
}),
})
const connection = { type: "credential" as const, method: "oauth" as const, id: saved.id, label: saved.label }
const connection = { type: "credential" as const, id: saved.id, label: saved.label }
expect(yield* test.integrations.connection.resolve(connection)).toEqual(saved.value)
yield* TestClock.adjust("2 minutes")
const error = yield* test.integrations.connection.resolve(connection).pipe(Effect.flip)
+4 -4
View File
@@ -1272,7 +1272,7 @@ describe("SessionTransfer", () => {
const runningCompactionID = SessionMessage.ID.create()
const completedCompactionID = SessionMessage.ID.create()
const model = Model.Ref.make({ id: Model.ID.make("model"), providerID: Provider.ID.make("provider") })
const providerState = { responseId: "summary-response" }
const native = { responseId: "summary-response" }
yield* transfer.import({
data: {
@@ -1328,7 +1328,7 @@ describe("SessionTransfer", () => {
status: "completed",
reason: "manual",
model,
providerState,
native,
summary: "summary",
recent: "recent",
time: { created: DateTime.makeUnsafe(9) },
@@ -1345,10 +1345,10 @@ describe("SessionTransfer", () => {
completedCompactionID,
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(4)
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, providerState })
expect((yield* transfer.export({ sessionID })).messages.at(-1)).toMatchObject({ model, native })
expect((yield* transfer.export({ sessionID, sanitize: true })).messages.at(-1)).toMatchObject({
model,
providerState: { redacted: `compaction-provider-state:${completedCompactionID}` },
native: { redacted: `compaction-native:${completedCompactionID}` },
})
}),
)
+2 -2
View File
@@ -507,7 +507,7 @@ describe("SessionRestart background recovery", () => {
run: Deferred.await(complete),
})
yield* jobs.background("call-completed-shell")
yield* Deferred.succeed(complete, "Exited with code 7")
yield* Deferred.succeed(complete, "(no output)\n\nCommand exited with code 7.")
yield* jobs.wait({ id: "call-completed-shell" })
const scope = yield* Scope.make()
@@ -529,7 +529,7 @@ describe("SessionRestart background recovery", () => {
{
type: "synthetic",
payload: {
text: '<shell id="call-completed-shell" state="completed" command="exit 7">\nExited with code 7\n</shell>',
text: '<shell id="call-completed-shell" state="completed" command="exit 7">\n(no output)\n\nCommand exited with code 7.\n</shell>',
},
},
])

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