Compare commits

..
Author SHA1 Message Date
LukeParkerDev f2f79a7782 fix(app): keep the Console sign-in from suspending the page behind it
Load the integration through a guarded store instead of createResource: the
dialog runs under the opener's Suspense boundary, so a pending resource read
blanked the whole route while the integration loaded. Also show the API key
switch directly instead of behind an Advanced toggle.
2026-09-22 07:30:24 +10:00
LukeParkerDev ee0a316593 fix(app): drop the Go-inactive toast after Console sign-in
Console sign-in returns Go models inside the opencode provider, so checking
for an opencode-go provider always failed and produced a misleading toast.
2026-09-21 14:14:24 +10:00
LukeParkerDev 5688dd17c5 feat(app): sign in to OpenCode Go and Console through the browser
OpenCode Go and OpenCode (Zen) now start the Console device sign-in as soon as
they are picked, open the browser automatically, and keep the API key path
behind an Advanced toggle. The dialog holds one stable state until the
authorization URL is ready, hidden form fields no longer render as empty
inputs, abandoned attempts are cancelled, and models unlocked by the new
connection are shown in the picker. Adds a /connect command matching the TUI
and the Console setup steps.
2026-09-21 12:30:08 +10:00
Aiden Cline cbdd1f66da fix(codemode): locate failures in the submitted source (#50197) 2026-09-20 20:47:54 -05:00
Luke Parker 788eb0fa29 fix(cli): report fatal startup causes on stderr (#50240) 2026-09-20 22:57:20 +00:00
opencode-agent[bot]andkitlangton 58fcad77a8 feat(tui): copy session ID from tab menu (#50181)
Co-authored-by: kitlangton <7587245+kitlangton@users.noreply.github.com>
2026-09-20 19:15:37 +00:00
Aiden Cline c555559ac1 refactor(plugin): name tool execution context (#50195) 2026-09-20 11:36:19 -05:00
Aiden Cline e3a3fa7108 fix(plugin): forward Promise tool cancellation (#50190) 2026-09-20 11:22:49 -05:00
opencode-agent[bot] 6238af397e chore(core): refresh bundled models.dev snapshot 2026-09-20 16:17:29 +00:00
Aiden Cline 7ffd75faf6 fix(core): restore anthropic budget variants (#50182) 2026-09-20 10:50:21 -05:00
opencode-agent[bot] 1f8ab95695 chore(core): refresh bundled models.dev snapshot 2026-09-20 15:37:06 +00:00
Aiden Cline 62dc1f7696 feat(codemode): add toLocaleString, Error.isError, and Map.getOrInsert (#50098) 2026-09-20 10:23:23 -05:00
opencode-agent[bot] 66f10ab7bf chore(core): refresh bundled models.dev snapshot 2026-09-20 14:37:06 +00:00
opencode-agent[bot] 1ca8f63a79 chore(core): refresh bundled models.dev snapshot 2026-09-20 13:36:51 +00:00
opencode-agent[bot] 03bcdd580d chore(core): refresh bundled models.dev snapshot 2026-09-20 12:43:39 +00:00
opencode-agent[bot] cc502f7e5f chore(core): refresh bundled models.dev snapshot 2026-09-20 11:37:37 +00:00
opencode-agent[bot] 64ce8771c0 chore(core): refresh bundled models.dev snapshot 2026-09-20 10:37:01 +00:00
opencode-agent[bot] 702a73d91a chore(core): refresh bundled models.dev snapshot 2026-09-20 09:37:50 +00:00
opencode 991b727eb8 sync release versions for v2.0.11 2026-09-20 08:57:41 +00:00
opencode-agent[bot] ba342ce227 chore(core): refresh bundled models.dev snapshot 2026-09-20 08:42:13 +00:00
David Hill 1464545665 fix(app): center timeline beside summary (#49620) 2026-09-20 18:06:32 +10:00
David Hill fecacc9e68 fix(app): match vertical nav tab states (#49619) 2026-09-20 18:03:49 +10:00
David HillandLukeParkerDev 1f73b4806b fix(desktop): polish browser panel states and navigation (#49432)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-20 07:54:25 +00:00
opencode-agent[bot] da2ce02596 chore(core): refresh bundled models.dev snapshot 2026-09-20 07:38:48 +00:00
opencode-agent[bot] 25f35dcfb8 chore(core): refresh bundled models.dev snapshot 2026-09-20 06:49:40 +00:00
Luke Parker 717f81ce08 chore(desktop): attribute renderer CPU profiles to source files (#50114) 2026-09-20 16:30:35 +10:00
Luke Parker 5fdfcc7a80 perf(desktop): hand the window its persisted stores before the first render (#50113) 2026-09-20 16:29:02 +10:00
Luke Parker 0530c8e512 perf(desktop): talk to the main process without the Effect runtime in the renderer (#50112) 2026-09-20 16:26:08 +10:00
Luke Parker 1d8cf4564b perf(desktop): move startup work that the first window does not need off its path (#50111) 2026-09-20 16:23:37 +10:00
Luke Parker 7e88f6bb18 perf(desktop): bundle the renderer's initial module graph as one chunk (#50110) 2026-09-20 16:19:28 +10:00
Luke Parker af592fb779 perf(desktop): serve the renderer's first assets before evaluating the main bundle (#50109) 2026-09-20 16:18:50 +10:00
Luke Parker 7fc3f68007 perf(desktop): boot the renderer alongside the main bundle (#50108) 2026-09-20 16:15:58 +10:00
opencode-agent[bot] cdcbb0047e chore(core): refresh bundled models.dev snapshot 2026-09-20 05:38:29 +00:00
opencode-agent[bot] a1956a7522 chore(core): refresh bundled models.dev snapshot 2026-09-20 04:42:05 +00:00
opencode-agent[bot] 55bc7fd403 chore(core): refresh bundled models.dev snapshot 2026-09-20 03:43:32 +00:00
opencode-agent[bot] 3049b1e684 chore(core): refresh bundled models.dev snapshot 2026-09-20 02:45:23 +00:00
opencode-agent[bot] 1f36a7aff8 chore(core): refresh bundled models.dev snapshot 2026-09-20 01:31:11 +00:00
123 changed files with 1949 additions and 626 deletions
+35 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -111,7 +111,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.10",
"version": "2.0.11",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,7 +201,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -214,7 +214,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -250,7 +250,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -277,7 +277,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -294,7 +294,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -318,7 +318,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -338,7 +338,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -406,7 +406,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -455,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -492,7 +492,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +508,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -527,7 +527,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -540,7 +540,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -554,7 +554,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -569,7 +569,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -608,7 +608,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -638,7 +638,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -653,7 +653,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -677,7 +677,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -698,7 +698,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -720,7 +720,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -755,7 +755,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -775,7 +775,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -809,7 +809,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -828,7 +828,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -874,7 +874,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -923,7 +923,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +958,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -991,7 +991,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1032,7 +1032,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.10",
"version": "2.0.11",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+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.10",
"version": "2.0.11",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.11",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -19,6 +19,13 @@ export function mountBrowserPane() {
session: "Alpha",
mounted: true,
visible: true,
url: undefined as string | undefined,
loading: false,
generation: 0,
delayNavigation: false,
pendingURL: undefined as string | undefined,
loadErrors: {} as Record<string, string | undefined>,
error: undefined as string | undefined,
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
})
const tabs = ["Alpha", "Beta"].map((name) => ({
@@ -47,13 +54,33 @@ export function mountBrowserPane() {
opened: () => !!registrations.get(store.session),
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
tabs: () => tabs.filter((tab) => tab.title === store.session),
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
active: () => {
const tab = tabs.find((tab) => tab.title === store.session) ?? tabs[0]
return {
...tab,
url: store.url ?? tab.url,
loading: store.loading,
generation: store.generation,
loadError: store.loadErrors[store.session],
}
},
registration: () => registrations.get(store.session),
error: () => undefined,
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
suspended: () => false,
close: () => undefined,
open: () => undefined,
command: () => undefined,
command: (command) => {
setStore("error", undefined)
if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined)
if (command.type === "navigate") {
if (store.delayNavigation) {
setStore("pendingURL", command.url)
return
}
setStore({ url: command.url, loading: false, generation: store.generation + 1 })
}
if (command.type === "stop") setStore("loading", false)
},
}
return (
<>
@@ -64,6 +91,32 @@ export function mountBrowserPane() {
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
</For>
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
<button
onClick={() =>
setStore({ loading: true, generation: store.generation + 1, loadErrors: { [store.session]: undefined } })
}
>
Load current page
</button>
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
Block navigation
</button>
<button
onClick={() =>
setStore({
url: store.pendingURL,
pendingURL: undefined,
loading: false,
generation: store.generation + 1,
})
}
>
Complete navigation
</button>
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
</nav>
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
@@ -57,3 +57,153 @@ story("hides the native view immediately while the pane stays mounted", async ({
await toggle.click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Loading page", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Stop", exact: true }).click()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("keeps the submitted URL visible until the browser reports navigation", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Blank page", exact: true }).click()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://example.com/")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
await address.fill("https://unsubmitted.example/")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
await expect(address).toHaveValue("https://example.com/")
})
story("restores the current URL when a submitted navigation is blocked", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://blocked.example/")
await address.press("Enter")
await expect(address).toHaveValue("https://blocked.example/")
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
"https://alpha.example/",
)
await root.getByRole("button", { name: "Beta", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Alpha", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Reload", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
})
story("returns a failed tab to the empty state when an empty URL is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Failed page", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
})
story("selects the full URL when the address field gains focus", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
await address.pressSequentially("https://example.com/")
await expect(address).toHaveValue("https://example.com/")
await address.press("Enter")
await expect(address).not.toBeFocused()
await address.focus()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
await address.press("ArrowRight")
await address.click()
await expect(address).toHaveJSProperty("selectionStart", 0)
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
})
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("https://example.com/")
await address.press("Enter")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
await root.getByRole("button", { name: "Load current page", exact: true }).click()
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(address).toHaveValue("https://example.com/")
})
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
const root = page.getByTestId("browser-pane-fixture")
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
await address.fill("")
await address.press("Enter")
await expect(address).not.toBeFocused()
await expect(address).toHaveValue("https://alpha.example/")
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.10",
"version": "2.0.11",
"description": "",
"type": "module",
"exports": {
+15
View File
@@ -5,6 +5,7 @@ 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 { decode64 } from "@/runtime/persistence/base64"
const withCategory = (category: string) => {
return (option: Omit<CommandOption, "category">): CommandOption => ({
@@ -23,6 +24,13 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
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={decode64(local.slug())} />)
}
const chooseModel = async () => {
const owner = sessionOwnership.capture()
@@ -62,6 +70,13 @@ 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"),
+4 -4
View File
@@ -65,14 +65,14 @@
}
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
Move only as far as needed; wider panels already have enough space in the margin. */
@container session-chat (min-width: 1320px) {
When they overlap, center the conversation in the space that remains beside the summary. */
@container session-chat (min-width: 1320px) and (width < 1640px) {
[data-slot="session-chat-panel"][data-summary-open="true"]
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
--session-summary-translate: min(0px, calc(50cqi - 820px));
--session-summary-translate: -160px;
&:dir(rtl) {
--session-summary-translate: max(0px, calc(820px - 50cqi));
--session-summary-translate: 160px;
}
}
}
+120 -20
View File
@@ -1,18 +1,42 @@
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode/client/promise"
import type {
FormAnswer,
IntegrationInfo,
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, createResource, onCleanup } from "solid-js"
import { createEffect, createMemo, on, 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
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()
@@ -23,12 +47,28 @@ export function createProviderConnectionController(options: {
const directory = options.directory()
return directory ? { directory } : undefined
}
const [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }),
(input) =>
serverSDK.api.integration
.get({ integrationID: input.provider, location: location() })
.then((result) => result.data),
// 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 methods = createMemo<ProviderConnectMethod[]>(() => {
const values = integration.latest?.methods.filter(
@@ -43,15 +83,24 @@ export function createProviderConnectionController(options: {
formAnswer: undefined as FormAnswer | 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 }
@@ -109,6 +158,14 @@ 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
@@ -117,6 +174,7 @@ export function createProviderConnectionController(options: {
}
const finish = async () => {
cancelPolling()
polling.attempt = undefined
const ref = location()
data.location.integration.invalidate(ref)
data.location.provider.invalidate(ref)
@@ -140,6 +198,7 @@ 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),
@@ -151,26 +210,35 @@ 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") {
dispatch({ type: "auth.error", error: language.t("common.requestFailed") })
polling.attempt = undefined
dispatch({ type: "auth.error", error: language.t("provider.connect.oauth.expired") })
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 })
if (selected.form?.length && !answer) {
const visible = (selected.form ?? []).some((field) => field.type === "external" || !field.hidden)
if (visible && !answer) {
dispatch({ type: "auth.form" })
return
}
const merged = { ...hiddenDefaults(selected), ...answer }
if (selected.type === "key") {
dispatch({ type: "auth.answer", answer })
dispatch({ type: "auth.answer", answer: Object.keys(merged).length ? merged : undefined })
return
}
if (selected.type !== "oauth") return
@@ -183,11 +251,11 @@ export function createProviderConnectionController(options: {
.connect({
integrationID: options.provider(),
methodID: selected.id,
...(answer ? { answer } : {}),
...(Object.keys(merged).length ? { answer: merged } : {}),
location: location(),
})
.then((response) => {
if (options.provider() === "opencode" && platform.platform === "desktop") {
if (options.provider() === CONSOLE_INTEGRATION && platform.platform === "desktop") {
const url = new URL(response.data.url)
url.searchParams.set("client_id", "opencode-desktop")
response.data.url = url.href
@@ -195,16 +263,39 @@ 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) return
if (!result.ok) {
dispatch({ type: "auth.error", error: String(result.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 (!result.ok) {
dispatch({
type: "auth.error",
error: result.error instanceof Error ? result.error.message : 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) => {
@@ -236,15 +327,16 @@ export function createProviderConnectionController(options: {
return undefined
}
let auto = false
createEffect(() => {
if (auto || integration.loading || methods().length !== 1) return
auto = true
void select(0)
const index = autoIndex()
if (store.auto || index === undefined) return
setStore("auto", true)
void select(index)
})
onCleanup(() => {
polling.disposed = true
cancelPolling()
cancelAttempt()
})
return {
@@ -254,11 +346,19 @@ 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,
},
+170 -100
View File
@@ -8,16 +8,23 @@ 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, Match, onMount, Show, Switch } from "solid-js"
import { type Component, createMemo, createUniqueId, For, type JSX, 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 { createProviderConnectionController, type ProviderConnectMethod } from "./controller"
import {
CONSOLE_PROVIDERS,
consoleIntegration,
createProviderConnectionController,
type ProviderConnectMethod,
} from "./controller"
const CUSTOM_ID = "_custom"
type IntegrationForm = NonNullable<ProviderConnectMethod["form"]>[number]
@@ -258,13 +265,39 @@ 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: () => props.provider,
provider: integrationID,
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",
@@ -276,7 +309,11 @@ function ProviderConnection(props: {
})
const provider = createMemo(() => ({
id: props.provider,
name: providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
name:
integrations.list().find((item) => item.id === props.provider)?.name ??
providers.all().get(props.provider)?.name ??
controller.integration()?.name ??
props.provider,
}))
const methodLabel = (value?: { type?: string; label?: string }) => {
if (!value) return ""
@@ -298,6 +335,13 @@ 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({
@@ -307,7 +351,7 @@ function ProviderConnection(props: {
const fields = createMemo<StringForm[]>(() => {
const value = controller.currentMethod()
return (value?.form ?? []).flatMap((field) => (field.type === "string" ? [field] : []))
return (value?.form ?? []).flatMap((field) => (field.type === "string" && !field.hidden ? [field] : []))
})
const matches = (field: StringForm, value: Record<string, string>) => {
return (field.when ?? []).every((condition) => {
@@ -364,7 +408,7 @@ function ProviderConnection(props: {
})
return (
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4">
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-4 px-3">
<Switch>
<Match when={item()?.field.options === undefined}>
<TextField
@@ -422,7 +466,13 @@ function ProviderConnection(props: {
}
function goBack() {
if (controller.methods().length > 1 && controller.methodIndex() !== undefined) {
// 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) {
controller.auth.reset()
return
}
@@ -463,6 +513,31 @@ 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()
@@ -494,22 +569,17 @@ 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={provider().id === "opencode"}
when={isConsole()}
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
>
<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>
{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>
</Show>
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
@@ -574,13 +644,10 @@ 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.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>
<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>
<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 ?? "" })}
@@ -613,34 +680,48 @@ function ProviderConnection(props: {
}
function OAuthAutoView() {
const code = createMemo(() => {
const instructions = controller.authorization()?.instructions
if (instructions?.includes(":")) {
return instructions.split(":").pop()?.trim()
}
return instructions
})
return (
<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 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(
isConsole() ? "provider.connect.console.description" : "provider.connect.oauth.auto.description",
{ provider: provider().name },
)}
</div>
<TextField
label={language.t("provider.connect.oauth.auto.confirmationCode")}
class="font-mono"
value={code()}
readOnly
copyable
/>
<div class="text-14-regular text-text-base flex items-center gap-4">
<Spinner />
<span>{language.t("provider.connect.status.waiting")}</span>
<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>
)
}
// 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>
)
}
@@ -661,53 +742,42 @@ function ProviderConnection(props: {
</div>
</div>
<div class="flex min-h-0 flex-1 flex-col">
<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>
<Switch>
<Match when={controller.busy()}>
<div class="px-3">
<StatusRow>
{language.t(
isConsole() && controller.methodIndex() !== undefined
? "provider.connect.console.opening"
: "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.currentMethod()?.type !== "key" && keyIndex() !== -1}>
<ConsoleApiKeySwitch />
</Show>
</div>
</div>
)
+21
View File
@@ -87,6 +87,7 @@ 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",
@@ -225,6 +226,21 @@ 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.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.",
@@ -302,6 +318,7 @@ 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…",
@@ -906,6 +923,10 @@ export const dict = {
"session.browser.replaced": "Browser control moved to another desktop window.",
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
"session.browser.address.placeholder": "Enter URL",
"session.browser.empty.title": "Enter URL",
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
"session.browser.failed.title": "URL can't be reached",
"session.browser.failed.description": "Check the URL and your connection, then try again.",
"titlebar.update": "Update",
"titlebar.tabs": "Tabs",
+20 -1
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode/ui/context"
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { Accessor, batch, 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,6 +98,25 @@ 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",
})
}
})
},
}
}
+63 -10
View File
@@ -20,13 +20,18 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const command = useCommand()
const state = props.browser.active
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
const failed = () => !!state()?.loadError
const registration = props.browser.registration
const button = { variant: "ghost", size: "large" } as const
const [store, setStore] = createStore({
address: "",
editing: false,
submitted: false,
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
navigating: false,
visible: typeof document === "undefined" || document.visibilityState === "visible",
})
const empty = () => !address() && !state()?.loading && !store.navigating
let surface: HTMLDivElement | undefined
let addressDisplay: HTMLDivElement | undefined
let frame: number | undefined
@@ -43,7 +48,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
title: language.t("command.browser.reload"),
category: language.t("command.category.view"),
keybind: "f5",
disabled: !props.visible || !state(),
disabled: !props.visible || !address(),
onSelect: () => {
const tab = state()
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
@@ -71,7 +76,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
const top = Math.round(rect.top * zoom)
const right = Math.round(rect.right * zoom)
const bottom = Math.round(rect.bottom * zoom)
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
// The desktop page hides blank and loading documents itself; only hide here
// while the pane shows its own empty or failed state over the surface.
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
// The cutout exposes the app backdrop outside the rounded Review card,
// not the browser surface inside it.
const color = getComputedStyle(
@@ -107,7 +114,25 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
if (frame === undefined) frame = requestAnimationFrame(tick)
}
createEffect(() => !store.editing && setStore("address", address()))
createEffect(on([() => state()?.id, address], () => !store.editing && setStore("address", address())))
// Any reported movement, including a rejected or blocked request, ends the submitted navigation.
createEffect(
on(
[() => state()?.id, () => state()?.generation, () => state()?.loading, () => props.browser.error()],
() => setStore("navigating", false),
{ defer: true },
),
)
// A blocked or rejected submission leaves the page where it was; show that page's URL again.
createEffect(
on(
() => props.browser.error(),
(error) => {
if (error && !store.editing) setStore("address", address())
},
{ defer: true },
),
)
createEffect(
on(registration, (current) => {
// Session routes can change before this pane unmounts. Hide the registration
@@ -123,6 +148,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
() => store.visible,
() => props.visible,
() => state()?.id,
empty,
failed,
registration,
],
() => {
@@ -157,7 +184,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
return (
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
<div class="h-10 shrink-0 flex items-center gap-1 px-3 border-b border-v2-border-border-muted">
<For each={["back", "forward"] as const}>
{(direction) => (
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
@@ -193,7 +220,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
>
<IconButton
{...button}
disabled={!state()}
disabled={!state()?.loading && !address()}
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
onClick={() => {
const tab = state()
@@ -212,8 +239,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
onSubmit={(event) => {
event.preventDefault()
const tab = state()
if (tab && store.address.trim())
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
const url = store.address.trim()
if (!tab) return
if (url || failed()) {
setStore({ submitted: true, address: url, navigating: true })
props.browser.command({ type: "navigate", tabID: tab.id, url: url || "about:blank" })
}
event.currentTarget.querySelector("input")?.blur()
}}
>
<input
@@ -224,8 +256,14 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
disabled={!state()}
placeholder={language.t("session.browser.address.placeholder")}
aria-label={language.t("session.browser.address")}
onFocus={() => setStore("editing", true)}
onBlur={() => setStore({ editing: false, address: address() })}
onFocus={(event) => {
setStore("editing", true)
event.currentTarget.select()
}}
onClick={(event) => event.currentTarget.select()}
onBlur={() =>
setStore({ editing: false, address: store.submitted ? store.address : address(), submitted: false })
}
onInput={(event) => setStore("address", event.currentTarget.value)}
onScroll={(event) => {
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
@@ -243,12 +281,27 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
</div>
</form>
</div>
<Show when={props.browser.error()}>
<Show when={props.browser.error() && !failed()}>
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
{props.browser.error()}
</div>
</Show>
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
<Show when={(empty() || failed()) && !props.browser.suspended()}>
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
<div
dir="auto"
class="flex size-full flex-col items-center justify-center gap-2 p-6 pb-[200px] text-center text-text-weak"
>
<Icon name="globe" size="large" class="mb-2 shrink-0" />
<div class="text-[13px] font-medium leading-[var(--line-height-compact)] text-text-strong">
{language.t(failed() ? "session.browser.failed.title" : "session.browser.empty.title")}
</div>
<div class="text-13-regular leading-[var(--line-height-base)]">
{language.t(failed() ? "session.browser.failed.description" : "session.browser.empty.description")}
</div>
</div>
</Show>
<Show when={props.browser.suspended()}>
<p class="px-6 text-center text-13-regular text-v2-text-text-subtle" role="status">
{language.t("session.browser.suspended")}
+15 -3
View File
@@ -1,6 +1,8 @@
import { children, createMemo, Show } from "solid-js"
import type { JSX } from "solid-js"
import { useSortable } from "@dnd-kit/solid/sortable"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
import { Keybind } from "@opencode/ui/keybind"
import { Tooltip } from "@opencode/ui/tooltip"
import { Tabs } from "@opencode/ui/tabs"
@@ -69,9 +71,19 @@ export function SortableTab(props: {
placement="bottom"
gutter={10}
>
<Tabs.CloseButton
class="h-5 w-5"
onClick={() => props.onTabClose(props.tab)}
<IconButton
size="small"
variant="ghost-muted"
onPointerDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
props.onTabClose(props.tab)
}}
icon={<Icon name="xmark-small" />}
aria-label={language.t("common.closeTab")}
/>
</Tooltip>
+10 -1
View File
@@ -9,7 +9,8 @@
justify-content: center;
}
[data-titlebar-tab] {
[data-titlebar-tab],
[data-titlebar-tab-action] {
--tab-base: var(--v2-background-bg-deep);
--tab-overlay: transparent;
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
@@ -68,6 +69,14 @@
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab-action]:is(:hover, :focus-visible) {
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
}
[data-titlebar-tab-action]:is(:active, [data-state="pressed"]) {
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
}
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
}
+4 -2
View File
@@ -351,9 +351,10 @@ export function Titlebar(props: {
>
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-home"
data-state={layout.route().type === "home" ? "pressed" : undefined}
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base data-[state=pressed]:text-v2-text-text-base"
onClick={toggleHome}
aria-label={language.t("home.title")}
aria-pressed={layout.route().type === "home"}
@@ -650,8 +651,9 @@ export function Titlebar(props: {
{homeButton(true)}
<button
type="button"
data-titlebar-tab-action
data-action="vertical-tabs-new-session"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
onClick={openNewTab}
aria-label={language.t("command.session.new")}
>
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"bin": {
+13 -2
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env bun
import { NodeRuntime, NodeServices } from "@effect/platform-node"
import { Effect } from "effect"
import { Cause, Effect } from "effect"
import { getErrorReported } from "effect/Runtime"
import { Commands } from "./commands/commands"
import { Runtime } from "./framework/runtime"
import { Observability } from "@opencode/util/observability"
@@ -130,5 +131,15 @@ Effect.gen(function* () {
Effect.provide(NodeServices.layer),
Effect.scoped,
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
NodeRuntime.runMain,
// runMain's default reporter logs the fatal cause to stdout. Write it to stderr instead: the
// desktop and `Service.ensure` only capture stderr from `serve --service`, so this is the only
// channel through which a startup failure's reason reaches the user.
Effect.tapCause((cause) =>
Effect.sync(() => {
if (Cause.hasInterruptsOnly(cause)) return
if (!getErrorReported(Cause.squash(cause))) return
process.stderr.write(Cause.pretty(cause) + "\n")
}),
),
NodeRuntime.runMain({ disableErrorReporting: true }),
)
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"repository": {
+40 -23
View File
@@ -24,7 +24,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
and Map, RegExp, and generators serialize as `{}`. A bare `undefined` result is `null`.
Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot
be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own enumerable }`, a
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
@@ -38,8 +38,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
10,000,000 elements (`Array(n)`, `length =`, `Array.from`, `split`, `matchAll`, `concat`, `flat`; below the JS
maximum of 2^32 - 1), and 10,000 pending promises at once. Exceeding one throws a `RangeError`. A single regular
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
rest parameter are accepted unless the program itself begins with `"use strict"`.
- [x] A trailing comma after a rest parameter is a syntax error, with or without `"use strict"`.
- [x] A program that begins with `"use strict"` rejects `yield` as an identifier and duplicate parameter names at
parse time. Without it, `yield` is an ordinary binding.
- [ ] Duplicate parameter names in non-strict code throw when the function is called, instead of binding the last
parameter as JavaScript does.
## Values and literals
@@ -94,7 +97,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `for`, `while`, and `do...while`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, Uint8Arrays, built-in iterators, custom
synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other
non-objects iterate nothing. An un-awaited promise throws rather than iterating.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
- [x] `throw` with arbitrary values.
@@ -108,7 +112,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Functions and callbacks
- [x] Function declarations, function expressions, and arrow functions.
- [x] Synchronous and `async` functions.
- [x] Synchronous and `async` functions. A line break between `function` and the name is allowed, as in JavaScript;
a line break between `async` and `function` is not an async function.
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
- [x] A call depth limit of 10000: deeper nesting throws a catchable `RangeError: Maximum call stack size exceeded`
at the overflowing call instead of running until the timeout. Callbacks invoked by built-ins count below the
@@ -143,7 +148,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`Array.prototype.push.name === "push"`).
- [ ] A named function expression's name is not bound inside its own body.
- [ ] Redeclaring a function in the same scope is rejected; in JavaScript the last declaration wins.
- [ ] A line terminator between `async function` and the function name.
- [ ] Generator and async generator functions evaluate parameter defaults and destructuring at the first `next()`
rather than at the call, so their errors are not thrown synchronously.
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
@@ -180,7 +184,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present. `Error.isError`
is true for every Error value.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -270,8 +275,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `Object.is` for supported data values.
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
and plain-object results.
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString`, `valueOf`,
`hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString` (calls the value's
`toString`, as in JS), `valueOf`, `hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
## Arrays
@@ -285,12 +290,14 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`lastIndexOf`.
- [x] Aggregation: `reduce` and `reduceRight`.
- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`.
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`.
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, `join`, and `toLocaleString` (each element's
`toLocaleString`, holes and nullish elements as empty strings).
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
`[opaque reference]`, serialize to `{}`, and cannot be passed to extensions. Every built-in collection iterator
shares one prototype, which is only observable through `getPrototypeOf`.
shares one prototype. JavaScript gives each collection its own; the difference is not observable here because
`Object.getPrototypeOf` is not exposed.
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
- [x] `Array.prototype.toSpliced`.
@@ -303,12 +310,13 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
form, like `JSON.stringify`.
- [ ] Argument coercion for `indexOf`, `lastIndexOf`, `includes`, `fill`, `flat`, `copyWithin`, and the `join`
separator: JavaScript applies ToIntegerOrInfinity/ToString (including `valueOf`, strings, and `undefined`), the
interpreter requires numbers and strings; `includes()`/`indexOf()` with no argument should search for
`undefined`.
interpreter requires numbers and strings. `indexOf()` and `lastIndexOf()` with no argument already search for
`undefined`; `includes()` still requires a value.
## Strings
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`, and the `toLocaleLowerCase`/`toLocaleUpperCase`
aliases, which ignore their locale argument and apply the default Unicode casing.
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
@@ -332,7 +340,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Coercion functions: `Number`, `parseInt`, and `parseFloat`.
- [x] Number predicates/parsers: `Number.isInteger`, `Number.isFinite`, `Number.isNaN`, `Number.isSafeInteger`,
`Number.parseInt`, and `Number.parseFloat`.
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, and `valueOf`.
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, `valueOf`, and `toLocaleString`, which
always formats as `en-US` (`"1,234.5"`) so output does not depend on the host.
- [x] Number constants: `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `NaN`,
`POSITIVE_INFINITY`, and `NEGATIVE_INFINITY`.
- [x] Math constants: `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT2`, and `SQRT1_2`.
@@ -345,7 +354,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
use their epoch time) and reject opaque runtime references as data errors.
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
example `Math.sum is not a function.` Unknown `Promise` statics keep their descriptive error.
example `Math.sum is not a function.` or `search(...).catch is not a function.` Unknown `Promise` statics keep
their descriptive error.
- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number
elements without coercion.
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
@@ -379,6 +389,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`TimeClip` behavior.
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
- [x] `toDateString` and `toTimeString` in the host's local timezone.
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
- [ ] Date setters and multi-argument construction coerce object arguments through `valueOf`/`toString` and surface
their throws.
@@ -404,7 +416,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity.
- [x] `new Map()` from synchronous iterables of entries.
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, `forEach`, `getOrInsert`, and `getOrInsertComputed`.
- [x] `new Set()` from synchronous iterables.
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
- [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()`
@@ -425,7 +437,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `new URLSearchParams()` from query strings, data objects, synchronous iterables of pairs, and URLSearchParams.
- [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`,
`entries`, `[Symbol.iterator]`, `toString`, and `size`.
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
- [x] URL values are their href in `JSON.stringify` and at the host boundary. URLSearchParams are `{}` in
`JSON.stringify` and their query string at the host boundary.
## Uint8Array
@@ -470,7 +483,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
errors cross as errors with their name, message, `cause`, and own enumerable data, and a `__proto__` key is
dropped. Functions, generators,
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
@@ -495,8 +509,10 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators.
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. `message` is an own
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` while the host still
receives `{ name, message }`. Errors have no `stack`; the diagnostic carries the source location instead.
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` for a plain error. The
result boundary still emits `{ name, message, ...own enumerable }`, so a field such as `code` crosses. `cause` is
non-enumerable: an extension Error carries it, and this JSON form does not. Errors have no `stack`; the diagnostic
carries a 1-based line and column in the submitted source instead.
- [x] `instanceof` against any constructor with a `prototype`, including every built-in and `Function`.
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
@@ -505,8 +521,9 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
short orientation to the supported subset; this matrix is the full reference.
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
This is deliberate: the program should handle a failure the same way regardless of where it originated.
Error-shaped value in `catch`, rejection handlers, and `Promise.allSettled` reasons. It always has `name` and
`message`, plus `cause` and own data when the failure carried them. This is deliberate: the program should
handle a failure the same way regardless of where it originated.
- [x] Failures raised by the interpreter are `TypeError`s unless JavaScript names them otherwise (`RangeError`,
`ReferenceError`, `SyntaxError`, `URIError`), so `e instanceof TypeError` and `e.constructor === TypeError`
hold. Unsupported syntax reached at runtime is a `SyntaxError`; awaited tool failures stay plain `Error`.
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.10",
"version": "2.0.11",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
@@ -1,6 +1,7 @@
import { Effect, Exit } from "effect"
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
import type { Interpreter } from "./interpreter.js"
import { primitivePrototype } from "./intrinsics.js"
import { typeError } from "./model.js"
import { Callable, get, Native, DateObj, Obj } from "./objects.js"
import { typeofValue } from "./references.js"
@@ -43,6 +44,13 @@ export const toPrimitive = <R>(
})
}
/** Invoke(value, name): calls the method the value would find through its prototype. */
export const invoke = <R>(ctx: Interpreter<R>, value: unknown, name: string, label: string) => {
const target = value instanceof Obj ? value : primitivePrototype(ctx.builtins, value)
if (target === undefined) throw typeError(`${label} called on null or undefined.`)
return ctx.call(get(target, name), value, [])
}
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: unknown) =>
Effect.map(toPrimitive(ctx, value, "string"), coerceToString)
@@ -168,6 +168,7 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
methods(builtins, prototype, [
["toString", 0, (thisValue) => errorToString(receiver(Obj, thisValue, "Error.prototype.toString"))],
])
methods(builtins, ctor, [["isError", 1, (_, args) => args[0] instanceof ErrorObj]])
}
return ctor
}
@@ -62,7 +62,7 @@ import {
} from "./model.js"
import { checkStringLength } from "./limits.js"
import { locate, materialize } from "./errors.js"
import type { Builtins } from "./intrinsics.js"
import { type Builtins, primitivePrototype } from "./intrinsics.js"
import { globals } from "./globals.js"
import {
assign,
@@ -110,20 +110,25 @@ const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefin
return undefined
}
const calleeDescription = (callee: Expression | Super | undefined): string => {
if (callee?.type === "Identifier") return callee.name
if (callee?.type === "MemberExpression") {
const object = callee.object
const property = callee.property
const key =
!callee.computed && property.type === "Identifier"
? property.name
: property.type === "Literal" && typeof property.value === "string"
? property.value
: undefined
if (object.type === "Identifier" && key !== undefined) return `${object.name}.${key}`
// Native engines name the callee (`search(...).catch is not a function`), including call chains. Returns
// undefined when a link cannot be named, so a chain is either named completely or not at all.
const calleeDescription = (node: Expression | Super | undefined): string | undefined => {
if (node?.type === "Identifier") return node.name
if (node?.type === "CallExpression") {
const target = calleeDescription(node.callee)
return target === undefined ? undefined : `${target}(...)`
}
return "The called value"
if (node?.type !== "MemberExpression") return undefined
const property = node.property
const key =
!node.computed && property.type === "Identifier"
? property.name
: property.type === "Literal" && typeof property.value === "string"
? property.value
: undefined
if (key === undefined) return undefined
const object = calleeDescription(node.object)
return object === undefined ? undefined : `${object}.${key}`
}
// OrdinaryHasInstance: walk the left operand's chain looking for the constructor's `prototype`.
@@ -1305,7 +1310,7 @@ class Frame<R> {
// `new` itself is supported, so a non-constructible callee is a TypeError like JS rather than
// unsupported syntax. Built-ins like Number are real constructors in JS, so do not claim
// otherwise; say `new` is unsupported for them and point at the plain call.
const name = calleeDescription(node.callee)
const name = calleeDescription(node.callee) ?? "The called value"
const message =
callee instanceof Fn
? `${name} cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.`
@@ -1608,7 +1613,7 @@ class Frame<R> {
if (callable instanceof Native) {
return yield* self.native(() => (callable as Native<R>).call(thisValue, args), node)
}
throw typeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
throw typeError(`${calleeDescription(callee) ?? "The called value"} is not a function.`, callee ?? node)
})
}
@@ -2071,16 +2076,14 @@ class Frame<R> {
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
// Primitives read through their wrapper prototype without being boxed; strings own length and indexes.
const builtins = self.ctx.builtins
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
if (typeof objectValue === "string") {
if (key === "length") return { value: objectValue.length }
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
if (index !== undefined) return { value: objectValue[index] }
return { target: builtins.String, key, receiver: objectValue }
}
if (typeof objectValue === "number") return { target: builtins.Number, key, receiver: objectValue }
if (typeof objectValue === "boolean") return { target: builtins.Boolean, key, receiver: objectValue }
const proto = primitivePrototype(self.ctx.builtins, objectValue)
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
if (objectValue === null || objectValue === undefined) {
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
@@ -52,6 +52,14 @@ export const createErrorValue = (prototype: Obj, message: string | undefined): E
return value
}
/** The prototype a primitive reads its methods from without being boxed; none for null, undefined, and symbols. */
export const primitivePrototype = (builtins: Builtins, value: unknown): Obj | undefined => {
if (typeof value === "string") return builtins.String
if (typeof value === "number") return builtins.Number
if (typeof value === "boolean") return builtins.Boolean
return undefined
}
export const createBuiltins = (): Builtins => {
const object = new Obj(null)
// Function.prototype is itself callable and returns undefined.
+3 -2
View File
@@ -86,9 +86,10 @@ export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
export const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null
// Acorn lines are 1-based and its columns are 0-based. Diagnostics use 1-based columns of the submitted source.
export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({
line: Math.max(1, (node.loc?.start.line ?? 2) - 1),
column: Math.max(1, (node.loc?.start.column ?? 4) - 3),
line: node.loc?.start.line ?? 1,
column: (node.loc?.start.column ?? 0) + 1,
})
export const formatLocation = (node?: AstNode): string => {
+14 -1
View File
@@ -4,7 +4,7 @@ import { checkArrayLength, checkStringLength, MAX_ARRAY_LENGTH } from "../interp
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
import { define, get, hidden, Arr, GeneratorObj, IteratorObj, Obj } from "../interpreter/objects.js"
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { compareText } from "../tool-runtime.js"
import { coerceToNumber, coerceToString } from "./value.js"
@@ -340,6 +340,19 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
return target
},
],
[
"toLocaleString",
0,
(thisValue) =>
Effect.map(
Effect.forEach(self(thisValue, "toLocaleString").items, (item) =>
item === null || item === undefined
? Effect.succeed("")
: Effect.map(invoke(ctx, item, "toLocaleString", "Array.prototype.toLocaleString"), coerceToString),
),
(parts) => parts.join(","),
),
],
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").items.keys())],
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").items.values())],
[
@@ -145,6 +145,29 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
return target
},
],
[
"getOrInsert",
2,
(thisValue, args) => {
const target = self(thisValue, "getOrInsert").map
if (!target.has(args[0])) target.set(args[0], args[1])
return target.get(args[0])
},
],
[
"getOrInsertComputed",
2,
(thisValue, args) => {
const target = self(thisValue, "getOrInsertComputed").map
const apply = applyCollectionCallback(ctx, args[1], "Map.getOrInsertComputed")
if (target.has(args[0])) return target.get(args[0])
// The callback sees the stored key (-0 is +0) and its result wins over anything it inserted itself.
return Effect.map(apply([args[0] === 0 ? 0 : args[0]]), (value) => {
target.set(args[0], value)
return value
})
},
],
["delete", 1, (thisValue, args) => self(thisValue, "delete").map.delete(args[0])],
[
"clear",
+7
View File
@@ -105,6 +105,13 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
["toTimeString", 0, (thisValue) => new Date(self(thisValue, "toTimeString").time).toTimeString()],
["toUTCString", 0, (thisValue) => new Date(self(thisValue, "toUTCString").time).toUTCString()],
["toGMTString", 0, (thisValue) => new Date(self(thisValue, "toGMTString").time).toUTCString()],
...(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"] as const).map(
(name): Method => [
name,
0,
(thisValue) => new Date(self(thisValue, name).time)[name]("en-US", { timeZone: "UTC" }),
],
),
...getters.map((name): Method => [name, 0, (thisValue) => new Date(self(thisValue, name).time)[name]()]),
...setters.map(
([name, length]): Method => [
+1
View File
@@ -50,6 +50,7 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
}
methods(builtins, builtins.Number, [
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum("toFixed", args[0]))],
["toLocaleString", 0, (thisValue) => self(thisValue, "toLocaleString").toLocaleString("en-US")],
[
"toExponential",
1,
+2 -2
View File
@@ -29,7 +29,7 @@ import {
set,
} from "../interpreter/objects.js"
import { containsOpaqueReference, describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { preserveConsumerError } from "../interpreter/callback.js"
import { invoke, preserveConsumerError } from "../interpreter/callback.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { ToolReference } from "../tool-runtime.js"
import { groupBy } from "./collections.js"
@@ -196,7 +196,7 @@ export const objectGlobal = <R>(ctx: Interpreter<R>) => {
true,
],
["toString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
["toLocaleString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
["toLocaleString", 0, (thisValue) => invoke(ctx, thisValue, "toString", "Object.prototype.toLocaleString")],
[
"valueOf",
0,
+2
View File
@@ -150,6 +150,8 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
simple("valueOf", 0, (value) => value),
simple("toLowerCase", 0, (value) => value.toLowerCase()),
simple("toUpperCase", 0, (value) => value.toUpperCase()),
simple("toLocaleLowerCase", 0, (value) => value.toLowerCase()),
simple("toLocaleUpperCase", 0, (value) => value.toUpperCase()),
simple("trim", 0, (value) => value.trim()),
simple("trimStart", 0, (value) => value.trimStart()),
simple("trimLeft", 0, (value) => value.trimStart()),
+35 -7
View File
@@ -22,6 +22,16 @@ describe("source syntax", () => {
})
describe("error identity", () => {
test("Error.isError is true for every Error value and nothing else", async () => {
expect(
await value(`
const caught = (() => { try { null.foo } catch (error) { return error } })()
return [Error.isError(new Error("x")), Error.isError(new RangeError("x")), Error.isError(caught),
Error.isError({ name: "Error", message: "x" }), Error.isError("Error"), Error.isError(null)]
`),
).toEqual([true, true, true, false, false, false])
})
test("awaiting the same rejected promise twice yields the same error object", async () => {
expect(
await value(`
@@ -95,6 +105,24 @@ describe("uncaught program throws", () => {
})
})
describe("source locations", () => {
test("uses the submitted line and 1-based column", async () => {
const failure = await error("const value = 1\nreturn value()")
expect(failure.location).toEqual({ line: 2, column: 8 })
expect(failure.message).toBe("TypeError: value is not a function. (line 2, col 8)")
})
test("names a missing method on a call instead of the previous line", async () => {
const failure = await error(`// Try search with different namespaces
for (const ns of ["github", "tools.github", "tools", ""]) {
const s = await search({query: "star", namespace: ns, limit: 100}).catch(e=>({items:[],error:String(e)}));
return s
}`)
expect(failure.location).toEqual({ line: 3, column: 19 })
expect(failure.message).toBe("TypeError: search(...).catch is not a function. (line 3, col 19)")
})
})
describe("host errors escaping built-ins", () => {
test("become the same-named program error", async () => {
expect(
@@ -107,7 +135,7 @@ describe("host errors escaping built-ins", () => {
test("report the location of the call that raised them", async () => {
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 19)")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
})
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
@@ -115,13 +143,13 @@ describe("host errors escaping built-ins", () => {
})
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 10)")
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
"(line 1, col 10)",
"(line 1, col 14)",
)
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
"(line 1, col 8)",
"(line 1, col 12)",
)
})
@@ -134,7 +162,7 @@ describe("host errors escaping built-ins", () => {
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 4)")
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
})
})
@@ -153,7 +181,7 @@ describe("call depth", () => {
test("uncaught overflow reports the call that overflowed", async () => {
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 14)")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
})
test("the limit is 10000 nested calls", async () => {
+23
View File
@@ -502,6 +502,12 @@ describe("CodeMode-specific array behavior", () => {
expect(err.message).toContain("circular")
})
test("indexOf and lastIndexOf with no argument search for undefined", async () => {
expect(await value(`return [1, undefined, 3].indexOf()`)).toBe(1)
expect(await value(`return [1, undefined, 3].lastIndexOf()`)).toBe(1)
expect(await value(`return [1, 2, 3].indexOf()`)).toBe(-1)
})
test("keys/values/entries return iterators usable with for...of and spread", async () => {
expect(await value(`return [...["x","y","z"].keys()]`)).toEqual([0, 1, 2])
expect(await value(`return [...["x","y"].values()]`)).toEqual(["x", "y"])
@@ -940,6 +946,12 @@ describe("coercion parity: unknown static members read as undefined", () => {
expect(await value(`try { JSON.rawJSON("1") } catch (e) { return e.message }`)).toBe(
"JSON.rawJSON is not a function.",
)
expect(await value(`try { search({ query: "star" }).catch(() => 1) } catch (e) { return e.message }`)).toBe(
"search(...).catch is not a function.",
)
expect(
await value(`const foo = () => ({ bar: () => ({}) }); try { foo().bar().baz() } catch (e) { return e.message }`),
).toBe("foo(...).bar(...).baz is not a function.")
})
test("built-ins are objects on a real prototype chain", async () => {
@@ -961,6 +973,17 @@ describe("coercion parity: unknown static members read as undefined", () => {
})
})
describe("async function line breaks", () => {
test("a line break between function and the name is an async function", async () => {
expect(await value(`async function\nfoo() { return 1 }\nreturn await foo()`)).toBe(1)
})
test("a line break between async and function is not an async function", async () => {
const failure = await error(`async\nfunction foo() { return 1 }\nreturn foo()`)
expect(failure.message).toContain("Unknown identifier 'async'")
})
})
describe("functions are objects", () => {
test("name follows NamedEvaluation and length counts required parameters", async () => {
expect(
+83 -4
View File
@@ -20,9 +20,9 @@ import { CodeMode, Tool } from "../src/index.js"
// Standard-library value types: Date, RegExp, Map, Set. Programs use them as ordinary JS;
// intra-CodeMode checkpoints (Object.* helpers, spread, coercion inputs) preserve the live
// values, while at the host boundary (final result, tool arguments, JSON.stringify) they
// serialize exactly as JSON.stringify would: Date -> ISO string (invalid -> null),
// URL -> href, and RegExp/Map/Set/URLSearchParams -> {}.
// values. JSON.stringify keeps Date -> ISO string (invalid -> null), URL -> href, and
// RegExp/Map/Set/URLSearchParams -> {}. The host boundary matches that except URLSearchParams,
// which cross as their query string, and Set, which crosses as an array.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
@@ -898,6 +898,42 @@ describe("Map", () => {
).toEqual({ a: 3, b: 1, c: 1 })
})
test("getOrInsert and getOrInsertComputed insert only when the key is missing", async () => {
expect(
await value(`
const groups = new Map()
groups.getOrInsert("a", []).push(1)
groups.getOrInsert("a", []).push(2)
let calls = 0
const computed = (key) => { calls++; return key + "!" }
const first = groups.getOrInsertComputed("b", computed)
const second = groups.getOrInsertComputed("b", computed)
const zero = groups.getOrInsertComputed(-0, (key) => 1 / key === Infinity)
return [[...groups], first, second, calls, zero]
`),
).toEqual([
[
["a", [1, 2]],
["b", "b!"],
[0, true],
],
"b!",
"b!",
1,
true,
])
expect(
await value(`
const m = new Map()
const outer = m.getOrInsertComputed("k", () => { m.set("k", "inner"); return "outer" })
let thrown
try { m.getOrInsertComputed("j", () => { throw new Error("boom") }) } catch (error) { thrown = error.message }
return [outer, m.get("k"), thrown, m.has("j")]
`),
).toEqual(["outer", "outer", "boom", false])
expect((await error(`new Map().getOrInsertComputed("k", 5)`)).message).toContain("expects a function callback")
})
test("maps serialize to {} at the boundary, like JSON", async () => {
expect(await value(`return new Map([["a", 1]])`)).toEqual({})
expect(await value(`return JSON.stringify(new Map([["a", 1]]))`)).toBe("{}")
@@ -1061,7 +1097,7 @@ describe("Uint8Array", () => {
console.log(b, new Uint8Array())
return [String(b), b + "", +new Uint8Array([5]), Number.isNaN(Number(b)), b == "1,2", JSON.stringify(b), b.toLocaleString(), typeof b, b instanceof Uint8Array]
`),
).toEqual(["1,2", "1,2", 5, true, true, '{"0":1,"1":2}', "[object Uint8Array]", "object", true])
).toEqual(["1,2", "1,2", 5, true, true, '{"0":1,"1":2}', "1,2", "object", true])
expect((await run(`console.log(new Uint8Array([1, 2]), new Uint8Array())`)).logs).toEqual([
"Uint8Array(2) [1,2] Uint8Array(0) []",
])
@@ -1231,6 +1267,49 @@ describe("built-in iterators", () => {
})
})
describe("toLocaleString", () => {
test("numbers and dates format as en-US in UTC; everything else falls back to toString", async () => {
expect(
await value(`
return [
(1234567.891).toLocaleString(), new Date(0).toLocaleString(), new Date(0).toLocaleDateString(),
new Date(0).toLocaleTimeString(), "a".toLocaleString(), true.toLocaleString(), ({}).toLocaleString(),
({ toString: () => "custom" }).toLocaleString(), new Uint8Array([1, 2]).toLocaleString(),
]
`),
).toEqual([
"1,234,567.891",
"1/1/1970, 12:00:00 AM",
"1/1/1970",
"12:00:00 AM",
"a",
"true",
"[object Object]",
"custom",
"1,2",
])
})
test("arrays join each element's toLocaleString, skipping holes and nullish elements", async () => {
expect(
await value(`
let calls = 0
const item = { toLocaleString() { calls++; return "o" } }
return [[1234.5, "x", null, undefined, item, new Date(0)].toLocaleString(), [, item, , item].toLocaleString(), calls]
`),
).toEqual(["1,234.5,x,,,o,1/1/1970, 12:00:00 AM", ",o,,o", 3])
expect((await error(`const f = ({}).toLocaleString; f()`)).message).toContain(
"Object.prototype.toLocaleString called on null or undefined",
)
})
test("toLocaleLowerCase and toLocaleUpperCase ignore the locale argument", async () => {
expect(
await value(`return ["ABC".toLocaleLowerCase("tr"), "abc".toLocaleUpperCase(), "İ".toLocaleLowerCase()]`),
).toEqual(["abc", "ABC", "i̇"])
})
})
describe("stdlib integration", () => {
test("constructor follows own keys, shadowing, writes, and new", async () => {
expect(
@@ -225,7 +225,6 @@ built-ins/Array/prototype/splice/S15.4.4.12_A4_T1.js # Array.prototype.splice c
built-ins/Array/prototype/splice/S15.4.4.12_A4_T2.js # Array.prototype.splice called on incompatible receiver a data object.
built-ins/Array/prototype/splice/S15.4.4.12_A4_T3.js # Array.prototype.splice called on incompatible receiver a data object.
built-ins/Array/prototype/splice/create-ctor-non-object.js # null value Expected a TypeError to be thrown but no exception was thrown at all
built-ins/Array/prototype/toLocaleString/S15.4.4.3_A1_T1.js # #1: var n = 0; var obj = {toLocaleString: function() {n++}}; var arr = [undefined, obj, null, obj, o
built-ins/Array/prototype/toLocaleString/S15.4.4.3_A3_T1.js # #1: var n = 0; var obj = {toLocaleString: function() {n++}}; Array.prototype[1] = obj; x = [obj]; x.
built-ins/Array/prototype/toReversed/holes-not-preserved.js # Actual [4, undefined, 2, undefined, 0] and expected [4, 3, 2, undefined, 0] should have the same con
built-ins/Array/prototype/toSorted/holes-not-preserved.js # Actual [1, 3, 4, undefined, undefined] and expected [1, 2, 3, 4, undefined] should have the same con
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.10",
"version": "2.0.11",
"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.10",
"version": "2.0.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.10",
"version": "2.0.11",
"$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.10",
"version": "2.0.11",
"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.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.11",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
+2 -4
View File
@@ -45,8 +45,7 @@ export declare namespace Source {
readonly codec: Schema.Codec<A, Schema.Json>
readonly read: Effect.Effect<A | Unavailable | Removed>
readonly render: {
/** Omit when the baseline is already visible to the model and only changes carry information. */
readonly initial?: (current: A) => string
readonly initial: (current: A) => string
readonly changed: (previous: A, current: A) => string
readonly removed?: (previous: A) => string
}
@@ -89,8 +88,7 @@ export const empty: List = []
export function make<A>(source: Source.Definition<A>): List {
const decode = Schema.decodeUnknownOption(source.codec)
const encode = Schema.encodeSync(source.codec)
const initial = (value: A) =>
source.render.initial === undefined ? undefined : requireText(source.key, "initial", source.render.initial(value))
const initial = (value: A) => requireText(source.key, "initial", source.render.initial(value))
const decodeValue = (value: Schema.Json) => Option.getOrUndefined(decode(value))
return [
{
File diff suppressed because one or more lines are too long
-2
View File
@@ -16,7 +16,6 @@ import { McpTool } from "../tool/mcp.js"
import { ReferenceInstructions } from "../reference/instructions.js"
import { SkillInstructions } from "../skill/instructions.js"
import { Tool } from "../tool.js"
import { ToolInstructions } from "../tool/instructions.js"
import { AgentNotFoundError } from "./error.js"
import { SessionHistory } from "./history.js"
import { SessionProviderContext } from "./provider-context.js"
@@ -147,7 +146,6 @@ const layer = Layer.effect(
agent: { ...agent, info: agent.info },
instructions: Instructions.combine([
loaded.builtins,
ToolInstructions.make(loaded.tools.definitions.map((definition) => definition.name)),
CodeModeInstructions.make(loaded.tools.codeModeCatalog),
loaded.discovery,
loaded.skills,
-36
View File
@@ -1,36 +0,0 @@
export * as ToolInstructions from "./instructions.js"
import { Effect, Schema } from "effect"
import { Instructions } from "../instructions/index.js"
const Names = Schema.Array(Schema.String)
type Names = typeof Names.Type
const list = (names: ReadonlyArray<string>) => names.map((name) => `\`${name}\``).join(", ")
export function update(previous: Names, current: Names) {
const added = current.filter((name) => !previous.includes(name))
const removed = previous.filter((name) => !current.includes(name))
return [
"The available tools have changed.",
...(added.length > 0 ? [`New tools are available in addition to those previously provided: ${list(added)}.`] : []),
...(removed.length > 0
? [`The following tools are no longer available and must not be called: ${list(removed)}.`]
: []),
].join("\n\n")
}
const key = Instructions.Key.make("core/tools")
const codec = Schema.toCodecJson(Names)
/**
* Tracks the top-level tool names the model has been shown. The request's own
* tool list is the baseline, so nothing renders until that set changes.
*/
export const make = (names: ReadonlyArray<string>): Instructions.List =>
Instructions.make({
key,
codec,
read: Effect.succeed(Array.from(new Set(names)).sort()),
render: { changed: update },
})
+28 -13
View File
@@ -27,6 +27,7 @@ export function resolve(model: Model.Info, supports: readonly Support[] = [{ typ
const EFFORTS = ["low", "medium", "high"]
const ENCRYPTED_REASONING = ["reasoning.encrypted_content"]
const ADAPTIVE_THINKING = { type: "adaptive", display: "summarized" }
const ANTHROPIC_OUTPUT_TOKEN_MAX = 32_000
const variant = (id: string, overlay: Overlay): Variants[number] => ({ id: Model.VariantID.make(id), ...overlay })
@@ -39,8 +40,9 @@ function budgets(
model: Model.Info,
support: Extract<Support, { type: "budget_tokens" }>,
spell: (tokens: number) => Overlay,
ceiling = model.limit.output,
): Variants {
const maximum = Math.min(support.max ?? model.limit.output - 1, model.limit.output - 1)
const maximum = Math.min(support.max ?? ceiling - 1, model.limit.output - 1, ceiling - 1)
if (maximum <= 0) return []
const high = Math.min(Math.max(support.min ?? 0, Math.floor((maximum + 1) / 2)), maximum)
return [variant("high", spell(high)), variant("max", spell(maximum))]
@@ -273,9 +275,12 @@ const anthropicMessages: Protocol = (model, support) => {
return toggle({ settings: { thinking: { type: "disabled" } } }, thinking)
}
case "budget_tokens":
return budgets(model, support, (tokens) => ({
settings: { thinking: { type: "enabled", budgetTokens: tokens } },
}))
return budgets(
model,
support,
(tokens) => ({ settings: { thinking: { type: "enabled", budgetTokens: tokens } } }),
ANTHROPIC_OUTPUT_TOKEN_MAX,
)
}
}
@@ -381,10 +386,14 @@ const bedrockConverse: Protocol = (model, support) => {
? toggle(fields({ thinking: { type: "disabled" } }), fields({ thinking: ADAPTIVE_THINKING }))
: toggle(fields({ reasoningConfig: { type: "disabled" } }), fields({ reasoningConfig: { type: "enabled" } }))
case "budget_tokens":
return budgets(model, support, (tokens) =>
claude
? fields({ thinking: { type: "enabled", budget_tokens: tokens } })
: fields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }),
return budgets(
model,
support,
(tokens) =>
claude
? fields({ thinking: { type: "enabled", budget_tokens: tokens } })
: fields({ reasoningConfig: { type: "enabled", budgetTokens: tokens } }),
claude ? ANTHROPIC_OUTPUT_TOKEN_MAX : model.limit.output,
)
}
}
@@ -436,9 +445,12 @@ const bedrockAISDK: Protocol = (model, support) => {
{ settings: { additionalModelRequestFields: { reasoningConfig: { type: "enabled" } } } },
)
case "budget_tokens":
return budgets(model, support, (tokens) => ({
settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } },
}))
return budgets(
model,
support,
(tokens) => ({ settings: { reasoningConfig: { type: "enabled", budgetTokens: tokens } } }),
claude ? ANTHROPIC_OUTPUT_TOKEN_MAX : model.limit.output,
)
}
}
@@ -489,8 +501,11 @@ const sapAICore: Protocol = (model, support) => {
return []
case "budget_tokens":
if (id.includes("anthropic"))
return budgets(model, support, (tokens) =>
sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }),
return budgets(
model,
support,
(tokens) => sap({ additionalModelRequestFields: { thinking: { type: "enabled", budget_tokens: tokens } } }),
ANTHROPIC_OUTPUT_TOKEN_MAX,
)
if (id.includes("gemini"))
return budgets(model, support, (tokens) =>
+2 -2
View File
@@ -1018,11 +1018,11 @@ describe("ModelsDevPlugin", () => {
const budgetModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-budget"))
expect(budgetModel?.variants).toContainEqual({
id: Model.VariantID.make("high"),
settings: { thinking: { type: "enabled", budgetTokens: 32000 } },
settings: { thinking: { type: "enabled", budgetTokens: 16000 } },
})
expect(budgetModel?.variants).toContainEqual({
id: Model.VariantID.make("max"),
settings: { thinking: { type: "enabled", budgetTokens: 63999 } },
settings: { thinking: { type: "enabled", budgetTokens: 31999 } },
})
const anthropicEffortModel = yield* modelState.get(Provider.ID.anthropic, Model.ID.make("claude-opus-4.7"))
@@ -0,0 +1,55 @@
import { expect } from "bun:test"
import { Agent } from "@opencode/core/agent"
import { Plugin } from "@opencode/core/plugin"
import { PluginHost } from "@opencode/core/plugin/host"
import { PluginPromise } from "@opencode/core/plugin/promise"
import { Tool } from "@opencode/core/tool"
import { Session } from "@opencode/schema/session"
import { SessionMessage } from "@opencode/schema/session-message"
import { Cause, Deferred, Effect, Exit, Fiber } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
const it = testEffect(PluginTestLayer)
it.live("Promise tool executors receive interruption through their AbortSignal", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const tools = yield* Tool.Service
const started = yield* Deferred.make<AbortSignal>()
yield* PluginPromise.fromPromise({
id: "cancel-tool",
async setup(context) {
await context.tool.transform((editor) =>
editor.add({
name: "wait",
description: "Wait until cancelled",
input: { type: "object", properties: {}, additionalProperties: false },
options: { codemode: false },
execute: (_input, context) =>
new Promise<never>((_resolve, reject) => {
context.signal.addEventListener("abort", () => reject(new Error("cancelled")), { once: true })
Effect.runSync(Deferred.succeed(started, context.signal))
}),
}),
)
},
}).effect(yield* PluginHost.make(plugins))
const snapshot = yield* tools.snapshot()
const fiber = yield* snapshot
.execute({
sessionID: Session.ID.make("ses_promise_tool_cancel"),
agent: Agent.ID.make("build"),
messageID: SessionMessage.ID.make("msg_promise_tool_cancel"),
call: { type: "tool-call", id: "call_promise_tool_cancel", name: "wait", input: {} },
})
.pipe(Effect.forkScoped)
const signal = yield* Deferred.await(started)
expect(signal.aborted).toBe(false)
yield* Fiber.interrupt(fiber)
const exit = yield* Fiber.await(fiber)
expect(signal.aborted).toBe(true)
expect(Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)).toBe(true)
}),
)
@@ -1,58 +0,0 @@
import { describe, expect } from "bun:test"
import { ToolInstructions } from "@opencode/core/tool/instructions"
import { Effect } from "effect"
import { it } from "../lib/effect"
import { readInitial, readUpdate } from "../lib/instructions"
describe("ToolInstructions", () => {
it.effect("renders nothing for the baseline and announces only the delta afterwards", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(ToolInstructions.make(["shell", "read", "edit"]))
expect(initialized.text).toBe("")
expect(initialized.values["core/tools"]).toEqual(["edit", "read", "shell"])
const unchanged = yield* readUpdate(ToolInstructions.make(["edit", "shell", "read", "read"]), initialized)
expect(unchanged.text).toBe("")
const changed = yield* readUpdate(ToolInstructions.make(["edit", "read", "write", "glob"]), initialized)
expect(changed.text).toBe(
[
"The available tools have changed.",
"New tools are available in addition to those previously provided: `glob`, `write`.",
"The following tools are no longer available and must not be called: `shell`.",
].join("\n\n"),
)
const restored = yield* readUpdate(ToolInstructions.make(["edit", "read", "shell", "write", "glob"]), changed)
expect(restored.text).toBe(
[
"The available tools have changed.",
"New tools are available in addition to those previously provided: `shell`.",
].join("\n\n"),
)
}),
)
it.effect("announces transitions to and from an empty tool set", () =>
Effect.gen(function* () {
const initialized = yield* readInitial(ToolInstructions.make([]))
expect(initialized.text).toBe("")
const added = yield* readUpdate(ToolInstructions.make(["read"]), initialized)
expect(added.text).toBe(
[
"The available tools have changed.",
"New tools are available in addition to those previously provided: `read`.",
].join("\n\n"),
)
const emptied = yield* readUpdate(ToolInstructions.make([]), added)
expect(emptied.text).toBe(
[
"The available tools have changed.",
"The following tools are no longer available and must not be called: `read`.",
].join("\n\n"),
)
}),
)
})
+9
View File
@@ -93,6 +93,15 @@ test("recognizes Claude version spellings and future models", () => {
settings: { effort, thinking: { type: "adaptive", display: "summarized" } },
})),
)
expect(
resolve(model("@opencode/ai/providers/anthropic", "claude-haiku-4-5", 64_000), [
{ type: "budget_tokens", min: 1_024, max: 64_000 },
]),
).toEqual([
{ id: "high", settings: { thinking: { type: "enabled", budgetTokens: 16_000 } } },
{ id: "max", settings: { thinking: { type: "enabled", budgetTokens: 31_999 } } },
])
})
test("spells Cloudflare AI Gateway variants for their upstream routes", () => {
+28
View File
@@ -28,6 +28,29 @@ const sentry =
})
: false
// Every module the entry reaches through static imports lands in one chunk. Automatic splitting
// otherwise fragments the initial graph into ~50 files shared with lazy routes, and each file costs
// the renderer a main-thread request round trip through the main process before first paint.
type ChunkingContext = { getModuleInfo(id: string): { isEntry: boolean; importers: readonly string[] } | null }
const initialGraph = new WeakMap<ChunkingContext, Map<string, boolean>>()
function inInitialGraph(id: string, ctx: ChunkingContext) {
const memo = initialGraph.get(ctx) ?? new Map<string, boolean>()
initialGraph.set(ctx, memo)
const visit = (id: string, path: Set<string>): boolean => {
const known = memo.get(id)
if (known !== undefined) return known
if (path.has(id)) return false
const info = ctx.getModuleInfo(id)
if (!info) return false
path.add(id)
const result = info.isEntry || info.importers.some((importer) => visit(importer, path))
path.delete(id)
memo.set(id, result)
return result
}
return visit(id, new Set())
}
export default defineConfig(({ command }) => ({
main: {
resolve: {
@@ -110,6 +133,11 @@ const require = __cjs_mod__.createRequire(import.meta.url);
input: {
main: "src/renderer/index.html",
},
output: {
codeSplitting: {
groups: [{ name: (id, ctx) => (inInitialGraph(id, ctx) ? "app" : null), priority: 10 }],
},
},
},
},
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.10",
"version": "2.0.11",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+17 -4
View File
@@ -191,8 +191,14 @@ const phaseOrder = [
["electron js init → entry", "nodeBootstrapped", "entryStart"],
["entry → chromium ready", "entryStart", "electronReady"],
["ready → window shown", "electronReady", "windowVisible"],
["main bundle load + evaluate", "windowVisible", "bundleEvaluated"],
["layers → first log line", "bundleEvaluated", "appStarting"],
["window → renderer assets served", "windowVisible", "rendererAssetsServed"],
["main bundle load + evaluate", "rendererAssetsServed", "bundleEvaluated"],
["bundle → onboarding decided", "bundleEvaluated", "onboardingDecided"],
["onboarding → logging ready", "onboardingDecided", "loggingReady"],
["logging → first log line", "loggingReady", "appStarting"],
["first log line → storage open", "appStarting", "storageOpen"],
["storage → initialization done", "storageOpen", "initializationDone"],
["initialization → layers ready", "initializationDone", "layersReady"],
["layers → renderer process", "appStarting", "rendererProcess"],
["renderer boot → first paint", "rendererProcess", "firstPaint"],
["first paint → shell", "firstPaint", "shellVisible"],
@@ -397,8 +403,15 @@ async function launch(build: { label: string; exe: string }, run: number): Promi
nodeBootstrapped: boot && Math.round(boot.origin + boot.bootstrapComplete - spawnAt),
entryStart: main.marks.entry && main.marks.entry - spawnAt,
electronReady: main.marks.ready && main.marks.ready - spawnAt,
rendererAssetsServed: main.marks.served && main.marks.served - spawnAt,
bundleEvaluated: main.marks.bundle && main.marks.bundle - spawnAt,
onboardingDecided: main.marks.onboarding && main.marks.onboarding - spawnAt,
loggingReady: main.marks.logging && main.marks.logging - spawnAt,
crashReporterStarted: main.marks.crash && main.marks.crash - spawnAt,
appStarting: main.appStarting && main.appStarting - spawnAt,
storageOpen: main.marks.storage && main.marks.storage - spawnAt,
initializationDone: main.marks.init && main.marks.init - spawnAt,
layersReady: main.marks.layers && main.marks.layers - spawnAt,
cliVersionStart: main.versionStart && main.versionStart - spawnAt,
cliVersionDone: main.versionDone && main.versionDone - spawnAt,
serviceStarting: main.serviceStarting && main.serviceStarting - spawnAt,
@@ -593,8 +606,8 @@ function mainLog() {
// A window shown before the logger existed reports when it was shown; the line itself is later.
const shown = /main window visible/.test(message) ? entry.match(/shownAt: (\d+)/)?.[1] : undefined
if (shown) windowShownAt = Number(shown)
if (/app starting/.test(message))
for (const [, key, value] of entry.matchAll(/\b(entry|ready|window|bundle): (\d{10,})/g)) marks[key] = Number(value)
if (/app starting|layers ready/.test(message))
for (const [, key, value] of entry.matchAll(/\b(\w+): (\d{10,})/g)) marks[key] = Number(value)
timeline.push([new Date(m[1].replace(" ", "T")).getTime(), name.replace(/\.log$/, ""), message])
}
}
@@ -0,0 +1,10 @@
param([Parameter(Mandatory)][string]$Name, [int]$Runs = 5)
$ErrorActionPreference = "Stop"
Set-Location $PSScriptRoot\..
# electron-vite directly: the package's prebuild hook re-downloads the CLI, which the bench keeps fixed.
bunx electron-vite build 2>&1 | Select-String -Pattern "built in|error" | Select-Object -Last 3
if (-not (Test-Path out\main\index.js)) { throw "build failed" }
bunx electron-builder --win --dir --config electron-builder.config.ts 2>&1 | Select-String -Pattern "error|signing with signtool.*OpenCode Dev" | Select-Object -Last 2
if (Test-Path "dist\$Name-unpacked") { Remove-Item "dist\$Name-unpacked" -Recurse -Force }
Rename-Item -Path dist\win-unpacked -NewName "$Name-unpacked"
bun ./scripts/bench-startup.ts --exe "dist\base-unpacked\OpenCode Dev.exe" --compare "dist\$Name-unpacked\OpenCode Dev.exe" --runs $Runs --warmup 1 --window-at=-1700,20 --out "dist\bench-startup\$Name" 2>&1 | Select-String -Pattern "^warm service|^\s{2,}|^phases|^\S+\s+\d+\s+\(|^report|^warm-up|Error|error" | Select-Object -Last 45
@@ -0,0 +1,94 @@
// Attribute a renderer .cpuprofile's self time to original source files through the build's
// source maps. Run with: bun scripts/profile-by-source.ts <profile.cpuprofile> [out/renderer/assets]
import { readFileSync, readdirSync } from "node:fs"
import { join } from "node:path"
import { TraceMap, originalPositionFor } from "C:/Users/Lukem/.local/share/opencode/worktree/6c1049/quiet-wolf-2/node_modules/.bun/@jridgewell+trace-mapping@0.3.31/node_modules/@jridgewell/trace-mapping/dist/trace-mapping.mjs"
const profilePath = process.argv[2]!
const assets = process.argv[3] ?? "out/renderer/assets"
const profile = JSON.parse(readFileSync(profilePath, "utf8"))
const maps = new Map<string, TraceMap>()
for (const name of readdirSync(assets).filter((f) => f.endsWith(".js.map"))) {
maps.set(name.slice(0, -4), new TraceMap(JSON.parse(readFileSync(join(assets, name), "utf8"))))
}
const nodes = new Map<number, any>()
for (const n of profile.nodes) nodes.set(n.id, n)
const self = new Map<string, number>()
const byPkg = new Map<string, number>()
const group = (source: string) => {
const n = source.replace(/\\/g, "/")
const nm = n.match(/node_modules\/(?:\.bun\/[^/]+\/node_modules\/)?((?:@[^/]+\/)?[^/]+)(?:\/dist\/([^/]+))?/)
if (nm) return nm[1] === "effect" ? `effect/${(nm[2] ?? "").replace(/\.js$/, "")}` : nm[1]
const pk = n.match(/packages\/([^/]+)\/src\/(.+)$/)
return pk ? `${pk[1]}/${pk[2]}` : n.slice(-50)
}
const parent = new Map<number, number>()
for (const n of profile.nodes) for (const c of n.children ?? []) parent.set(c, n.id)
const resolve = (frame: any) => {
const name = frame.functionName
if (name === "(program)" || name === "(garbage collector)") return { label: name, fn: name }
const file = frame.url.split("/").pop()
const map = maps.get(file)
if (!map) return { label: `(no map) ${file}`, fn: name }
const pos = originalPositionFor(map, { line: frame.lineNumber + 1, column: frame.columnNumber })
return { label: pos.source ? group(pos.source) : `(unmapped) ${file}`, fn: pos.name ?? name }
}
const labelOf = (frame: any) => resolve(frame).label
// A sample belongs to the render phase once Solid's root is on the stack; everything before that is
// module evaluation, everything after the first render is later work (hydration, effects, timers).
const stackHas = (id: number, test: (label: string, fn: string) => boolean) => {
for (let cur: number | undefined = id; cur !== undefined; cur = parent.get(cur)) {
const resolved = resolve(nodes.get(cur).callFrame)
if (test(resolved.label, resolved.fn)) return true
}
return false
}
const phases = { evaluate: new Map<string, number>(), render: new Map<string, number>(), later: new Map<string, number>() }
let phase: keyof typeof phases = "evaluate"
let t = 0
let total = 0
for (let i = 0; i < profile.samples.length; i++) {
const dt = (profile.timeDeltas[i] ?? 0) / 1000
t += dt
const node = nodes.get(profile.samples[i])
if (node.callFrame.functionName === "(idle)") {
if (phase === "render" && dt > 5) phase = "later"
continue
}
total += dt
if (phase === "evaluate" && stackHas(node.id, (label, fn) => label === "solid-js" && (fn === "render" || fn === "createRoot")))
phase = "render"
const label = labelOf(node.callFrame)
const bucket = phases[phase]
bucket.set(label, (bucket.get(label) ?? 0) + dt)
self.set(label, (self.get(label) ?? 0) + dt)
const pkg = label.split("/").slice(0, label.startsWith("effect/") || label.startsWith("@") ? 2 : 1).join("/")
byPkg.set(pkg, (byPkg.get(pkg) ?? 0) + dt)
}
console.log(`busy ${total.toFixed(0)} ms over ${t.toFixed(0)} ms`)
void phases
// Timeline: 25 ms buckets with the top sources, so module evaluation, render and hydration show as bands.
const buckets = new Map<number, Map<string, number>>()
t = 0
for (let i = 0; i < profile.samples.length; i++) {
const dt = (profile.timeDeltas[i] ?? 0) / 1000
t += dt
const node = nodes.get(profile.samples[i])
if (node.callFrame.functionName === "(idle)") continue
const b = Math.floor(t / 25) * 25
const m = buckets.get(b) ?? new Map()
const label = labelOf(node.callFrame).replace(/^(\.\.\/)+/, "")
m.set(label, (m.get(label) ?? 0) + dt)
buckets.set(b, m)
}
console.log("\n== timeline (25 ms buckets) ==")
for (const [b, m] of [...buckets].sort((a, c) => a[0] - c[0])) {
const busy = [...m.values()].reduce((a, c) => a + c, 0)
if (busy < 1) continue
const top = [...m].sort((a, c) => c[1] - a[1]).slice(0, 4).map(([k, v]) => `${k} ${v.toFixed(0)}`).join(" | ")
console.log(String(b).padStart(5), busy.toFixed(0).padStart(3), top)
}
console.log("\n== by package (all) ==")
for (const [k, v] of [...byPkg].sort((a, b) => b[1] - a[1]).slice(0, 20)) console.log(v.toFixed(1).padStart(7), k)
+44 -6
View File
@@ -103,11 +103,17 @@ export function createBrowserPage(
revision++
})
let closed = false
// Whether the native surface holds a real document worth showing. Chromium keeps the
// previous document painted until the next one renders, so a shown page stays shown
// through later navigations; blank and failed documents hide until a real one is ready.
let content = false
let failure: { url: string; message: string } | undefined
const state = (): Browser.Tab => ({
id: options.id,
url: contents.getURL().slice(0, 16_384),
url: (failure?.url ?? contents.getURL()).slice(0, 16_384),
title: contents.getTitle().slice(0, 2_048),
loading: contents.isLoading(),
...(failure ? { loadError: failure.message } : {}),
canGoBack: contents.navigationHistory.canGoBack(),
canGoForward: contents.navigationHistory.canGoForward(),
generation,
@@ -115,16 +121,40 @@ export function createBrowserPage(
const publish = () => {
if (!closed) options.publish()
}
const reset = (event: Electron.Event<{ isMainFrame: boolean; isSameDocument: boolean }>) => {
const reset = (event: Electron.Event<{ url: string; isMainFrame: boolean; isSameDocument: boolean }>) => {
if (!event.isMainFrame || event.isSameDocument) return
failure = undefined
generation++
documents.clear()
refs.clear()
diagnostics.clear()
publish()
}
const settle = () => {
content = contents.getURL() !== "about:blank" && !failure
updateVisibility()
}
contents.on("did-start-navigation", reset)
contents.on("did-stop-loading", publish)
contents.on("did-navigate", (_event, url, status, statusText) => {
// The server-network proxy answers an unreachable HTTP target with an empty 502. Other
// error statuses are real documents from the user's server and stay visible.
if (status === 502) failure = { url, message: `${status} ${statusText}`.trim().slice(0, 2_048) }
// A blank or failed document paints at commit; a real one waits for dom-ready.
if (url === "about:blank" || failure) settle()
publish()
})
contents.on("did-fail-load", (_event, code, description, url, isMainFrame) => {
// Cancelled navigation and failed subframes do not replace the current page.
if (!isMainFrame || code === -3) return
failure = { url, message: description.slice(0, 2_048) }
settle()
publish()
})
contents.on("dom-ready", settle)
contents.on("did-stop-loading", () => {
settle()
publish()
})
contents.on("did-navigate-in-page", publish)
contents.on("page-title-updated", publish)
contents.on("render-process-gone", () => {
@@ -252,6 +282,14 @@ export function createBrowserPage(
corner.setVisible(false)
win.contentView.addChildView(corner)
})
let visible = false
const updateVisibility = () => {
// The renderer's layout requests may lag behind navigation; the page decides
// whether there is a document worth exposing over the themed background.
const show = visible && content
view.setVisible(show)
corners.forEach((corner) => corner.setVisible(show && !!cornerKey))
}
const ready = Promise.all([
files.ready,
...(options.initialize === false
@@ -302,9 +340,9 @@ export function createBrowserPage(
)
})
},
setVisible(visible: boolean) {
view.setVisible(visible)
corners.forEach((corner) => corner.setVisible(visible && !!cornerKey))
setVisible(value: boolean) {
visible = value
updateVisibility()
},
async execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
await ready
+8
View File
@@ -5,6 +5,7 @@ import { Ipc } from "./ipc"
import { DesktopInitialization } from "./lifecycle/desktop-initialization"
import { installContextMenu } from "./lifecycle/environment"
import { ApplicationLifecycle } from "./lifecycle"
import { DesktopLogging } from "./native/logging"
import { BackgroundService } from "./service/background-service"
import { DesktopCli } from "./service/desktop-cli"
import { UpdaterLive } from "./updater/live"
@@ -15,8 +16,15 @@ marks.bundle = Date.now()
const runIpc = Effect.fn("Desktop.runIpc")(function* () {
const lifecycle = yield* ApplicationLifecycle.Service
marks.layers = Date.now()
yield* Effect.logInfo("layers ready", { marks })
const ipc = yield* Ipc.registerIpcHandlers
if (lifecycle.restoreWindows().length) ipc.installMenu()
// The first window's renderer now has its IPC port and is hydrating its stores over it. The crash
// reporter (spawns a process) and the context menu (a dependency tree) are not worth answering late.
yield* Effect.sleep("500 millis")
const logging = yield* DesktopLogging.Service
yield* logging.startCrashReporter
yield* installContextMenu
yield* Effect.callback<void>((resume) => {
const quit = () => resume(Effect.void)
+10 -1
View File
@@ -2,7 +2,10 @@
import { marks } from "./lifecycle/marks"
import { app } from "electron"
import { acquireApplicationLock, configureApplication } from "./lifecycle/configure"
import { startSidecarProbe } from "./service/sidecar-probe"
import { registerStorageSnapshotHandler } from "./storage/snapshot"
import { createEarlyWindow } from "./windows/early"
import { rendererAssetsServed } from "./windows/protocol"
import { registerRendererScheme } from "./windows/scheme"
// This module stays small on purpose. Electron holds the ready event until the entry module has
@@ -14,10 +17,16 @@ if (acquireApplicationLock()) {
registerRendererScheme()
// Window first, then the bundle: starting the import before ready delays ready itself, because the
// module graph evaluates on the same thread Chromium needs to finish initialising.
void app.whenReady().then(() => {
void app.whenReady().then(async () => {
marks.ready = Date.now()
registerStorageSnapshotHandler()
createEarlyWindow()
marks.window = Date.now()
startSidecarProbe()
// The window's renderer is already loading. Its HTML and preloaded chunks are served from this
// thread, so the bundle waits for that burst to be answered (or a cap) before it evaluates.
if (!process.env.ELECTRON_RENDERER_URL) await rendererAssetsServed({ quietMs: 40, capMs: 400 })
marks.served = Date.now()
return import("./desktop")
})
}
@@ -4,7 +4,7 @@ import { EventRpcs } from "../../shared/ipc-rpc"
import { ipcEventStream } from "../ipc-events"
import { IpcPortHandoff } from "../ipc-transport"
import { Shutdown } from "../lifecycle/shutdown"
import { isRendererUrl } from "../windows/protocol"
import { isRendererUrl } from "../windows/scheme"
import { DesktopStorage } from "../storage"
import { sender } from "./context"
+5 -2
View File
@@ -65,12 +65,15 @@ export const registerIpcHandlers = Effect.gen(function* () {
if (input.type !== "keyDown" || input.key !== "Escape") return
win.webContents.send(DragCancelEvent)
})
win.webContents.on("did-finish-load", () => {
const post = () => {
if (win.isDestroyed() || win.webContents.isDestroyed()) return
const channel = new MessageChannelMain()
handoff.bind(win.webContents, channel.port1)
win.webContents.postMessage(IpcTransportPort, null, [channel.port2])
})
}
win.webContents.on("did-finish-load", post)
// The first window starts loading before the layers exist and may already be done.
if (!win.webContents.isLoading() && win.webContents.getURL()) post()
}
yield* Effect.sync(() => {
app.on("browser-window-created", wire)
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto"
import { mkdirSync, rmSync } from "node:fs"
import { enableCompileCache } from "node:module"
import { homedir, tmpdir } from "node:os"
import path from "node:path"
import { app } from "electron"
@@ -31,6 +32,8 @@ export function configureApplication() {
app.setPath("sessionData", path.join(testRoot, "session"))
if (testOnboarding) app.setPath("documents", path.join(testRoot, "documents"))
}
// V8 bytecode for the main bundle survives between launches, like the renderer's code cache.
enableCompileCache(path.join(app.getPath("userData"), "compile-cache"))
}
export function acquireApplicationLock() {
@@ -4,6 +4,7 @@ import { app } from "electron"
import { Context, Effect, Layer } from "effect"
import { DesktopLogging } from "../native/logging"
import { getStore } from "../storage/store"
import { marks } from "./marks"
import {
loadProxyEnvironment,
preferApplicationEnvironment,
@@ -22,12 +23,15 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const logging = yield* DesktopLogging.Service
yield* prepareApplicationEnvironment
yield* preferApplicationEnvironment
yield* loadProxyEnvironment
// System certificates, the proxy and the net log serve later network work; the first window and
// its IPC port do not wait for them.
yield* Effect.forkScoped(
prepareApplicationEnvironment.pipe(Effect.andThen(loadProxyEnvironment), Effect.andThen(logging.startNetwork)),
)
yield* Effect.promise(() => app.whenReady())
yield* logging.startNetwork
yield* prepareDesktop
marks.init = Date.now()
return Service.of({
version: app.getVersion(),
updaterStore: getStore("opencode.updater"),
@@ -4,7 +4,8 @@ import { app } from "electron"
import { Effect, Path } from "effect"
import { DesktopPaths } from "../paths"
import { getUserShell, loadShellEnv } from "../service/shell-env"
import { registerRendererProtocol, setDockIcon } from "../windows"
import { registerRendererProtocol, setDockIcon, setProtocolReporter } from "../windows"
import { scoped } from "../native/logging"
// electron-context-menu attaches to every existing and future window, so it can load once the first
// window is up instead of holding up startup with its dependency tree.
@@ -37,7 +38,11 @@ export const prepareDesktop = Effect.gen(function* () {
const paths = yield* DesktopPaths.resolve
if (app.isPackaged || process.env.OPENCODE_DESKTOP_DISABLE_PROTOCOL_REGISTRATION !== "1")
app.setAsDefaultProtocolClient("opencode")
yield* registerRendererProtocol()
const runFork = Effect.runForkWith(yield* Effect.context())
setProtocolReporter((level, message, data) =>
runFork(scoped("protocol", level === "error" ? Effect.logError(message, data) : Effect.logWarning(message, data))),
)
registerRendererProtocol(paths.rendererRoot)
setDockIcon(path, paths)
})
@@ -9,6 +9,7 @@ import { DesktopLogging, scoped } from "../native/logging"
import { DesktopStorage } from "../storage"
import { safeWebContentsURL } from "../windows/state"
import { getLastFocusedWindow, makeMainWindows, setAppQuitting, setRelaunchHandler } from "../windows"
import { marks } from "./marks"
import { initializeFirstLaunchOnboarding } from "./onboarding"
import { Shutdown } from "./shutdown"
@@ -157,6 +158,7 @@ export const layer = Layer.unwrap(
// Decide first-launch state before the storage layer creates drafts.sqlite, which would
// otherwise read as evidence of an earlier launch on a fresh install.
yield* initializeFirstLaunchOnboarding(app.getPath("userData"))
marks.onboarding = Date.now()
return runtime.pipe(Layer.provideMerge(platform))
}),
)
+6 -3
View File
@@ -1,4 +1,7 @@
// Startup marks, epoch ms. The entry module records them before any logger exists; the logging
// layer reports them with "app starting" so the startup benchmark can split the time before the
// first log line into Electron's own initialisation, our entry, and the main bundle.
export const marks: { entry: number; ready?: number; window?: number; bundle?: number } = { entry: Date.now() }
// layer reports the early ones with "app starting" and the rest with "layers ready", so the startup
// benchmark can split the time before the renderer gets its IPC port into Electron's own
// initialisation, our entry, the main bundle and each layer.
export const marks: { entry: number } & Partial<
Record<"ready" | "window" | "served" | "bundle" | "onboarding" | "logging" | "crash" | "storage" | "init" | "layers", number>
> = { entry: Date.now() }
@@ -8,6 +8,10 @@ import { getStore } from "../storage/store"
const DEFAULT_PROJECT_DIR = "Default Project"
export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize")(function* (userDataPath: string) {
const store = getStore()
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
if (typeof current === "boolean") return current
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const names = (yield* fs.exists(userDataPath)) ? yield* fs.readDirectory(userDataPath) : []
@@ -17,11 +21,8 @@ export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize"
const info = yield* fs.stat(path.join(userDataPath, name)).pipe(Effect.option)
return { name, directory: Option.isSome(info) && info.value.type === "Directory" }
}),
{ concurrency: "unbounded" },
)
const store = getStore()
const current = store.get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
if (typeof current === "boolean") return current
const complete = hasExistingAppState(entries)
store.set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, complete)
return complete
+10 -2
View File
@@ -19,6 +19,7 @@ let netLogPath: string | undefined
export interface Interface {
readonly startNetwork: Effect.Effect<void>
readonly startCrashReporter: Effect.Effect<void>
readonly exportDebug: Effect.Effect<string>
}
@@ -30,7 +31,9 @@ const serviceLayer = Layer.effect(
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
yield* initLogging(fs, path).pipe(Effect.orDie)
yield* initCrashReporter(fs, path).pipe(Effect.orDie)
// Old run directories go away in the background; listing them is not worth a wait at startup.
yield* Effect.forkScoped(cleanup(fs, path).pipe(Effect.catch(() => Effect.void)))
marks.logging = Date.now()
yield* Effect.logInfo("app starting", {
version: VERSION,
packaged: app.isPackaged,
@@ -42,6 +45,12 @@ const serviceLayer = Layer.effect(
startNetwork: startNetLog(path).pipe(
Effect.catch((error) => Effect.logWarning("failed to start net log", { error })),
),
// Starting crashpad spawns its handler process, ~60 ms on the main thread, so the first window
// and its IPC port come first.
startCrashReporter: initCrashReporter(fs, path).pipe(
Effect.tap(() => Effect.sync(() => (marks.crash = Date.now()))),
Effect.catch((error) => Effect.logWarning("failed to start crash reporter", { error })),
),
exportDebug,
})
}),
@@ -99,7 +108,6 @@ function initLogging(fs: FileSystem.FileSystem, path: Path.Path) {
log.initialize({ preload: false, spyRendererConsole: true })
initConsoleTransport()
})
yield* cleanup(fs, path)
})
}
@@ -3,6 +3,7 @@ import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { BackgroundServiceState } from "./background-service-state"
import { cleanStages, DesktopCli } from "./desktop-cli"
import { SidecarCredentials } from "./sidecar-credentials"
import { sidecarProbe } from "./sidecar-probe"
export * as BackgroundService from "./background-service"
@@ -36,7 +37,7 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
const version = mode === "initial" ? cli.version : undefined
if (isolated) process.env.XDG_STATE_HOME = app.getPath("userData")
const client = yield* Effect.promise(() => import("@opencode/client/service"))
const service = yield* Effect.tryPromise(() =>
const ensure = () =>
client.Service.ensure({
file:
isolated && process.env.OPENCODE_DESKTOP_SERVER_CHANNEL === "local"
@@ -46,13 +47,18 @@ const connect = Effect.fn("BackgroundService.connect")(function* (mode: "initial
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
onStart: (reason, previousVersion) =>
runFork(Effect.logInfo("v2 CLI background service starting", { reason, previousVersion })),
}),
)
})
// A compatible service the entry module already found is adopted at once; ensure() still runs
// afterwards for its side effects (terminal handoff completion), off the renderer's path.
const early = mode === "initial" && !isolated ? yield* Effect.promise(sidecarProbe) : undefined
if (early) yield* Effect.sync(() => void ensure().catch(() => undefined))
const service = early ?? (yield* Effect.tryPromise(ensure))
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
const url = new URL(service.url)
if (url.hostname === "0.0.0.0") url.hostname = "127.0.0.1"
yield* Effect.logInfo("v2 CLI background service ready", {
version,
probed: !!early,
...endpoint(url.origin),
})
if (mode === "initial" && isolated && cli.binary) yield* cleanStages(cli.binary).pipe(Effect.orDie)
@@ -1,6 +1,7 @@
export * as DesktopCli from "./desktop-cli"
import { execFile, spawn } from "node:child_process"
import { existsSync, readFileSync } from "node:fs"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
@@ -93,9 +94,15 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
const shipped = yield* fs
.readFileString(path.join(path.dirname(bundled), "opencode-cli.version"))
.pipe(Effect.map((text) => text.trim()), Effect.orElseSucceed(() => ""))
// Synchronous on purpose: this sits on the path to the first window's IPC port, and a queued
// async read waits behind everything else the main thread is doing at that moment.
const shipped = yield* Effect.sync(() => {
try {
return readFileSync(path.join(path.dirname(bundled), "opencode-cli.version"), "utf8").trim()
} catch {
return ""
}
})
if (shipped) {
yield* Effect.logInfo("v2 CLI version bundled", { version: shipped })
return shipped
@@ -147,7 +154,7 @@ const installCli = Effect.fn("DesktopCli.install")(function* (source: string, ve
const path = yield* Path.Path
const directory = path.join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-"))
const destination = path.join(directory, executableName())
if (yield* fs.exists(destination)) {
if (existsSync(destination)) {
yield* Effect.logInfo("v2 CLI staged executable reused", { path: destination, version })
return destination
}
@@ -0,0 +1,32 @@
import { readFileSync } from "node:fs"
import path from "node:path"
import { app } from "electron"
import type { Endpoint } from "@opencode/client/service"
// The main thread idles between showing the first window and evaluating the main bundle, waiting
// for the renderer's asset requests. That slot is long enough to find out whether a compatible
// background service is already running, so the renderer's first data request is not the first
// moment anyone asks. The probe only looks; a service that has to be started waits for the layers,
// which set the environment the CLI expects.
let probe: Promise<Endpoint | undefined> | undefined
export function startSidecarProbe() {
if (!app.isPackaged) return
const version = bundledVersion()
if (!version) return
probe = import("@opencode/client/service")
.then(({ Service }) => Service.discover({ version }))
.catch(() => undefined)
}
export function sidecarProbe() {
return probe ?? Promise.resolve(undefined)
}
function bundledVersion() {
try {
return readFileSync(path.join(process.resourcesPath, "opencode-cli.version"), "utf8").trim()
} catch {
return ""
}
}
@@ -2,7 +2,9 @@ export * as DesktopStorage from "./index"
import { app, BrowserWindow } from "electron"
import { Context, Effect, Layer, Path } from "effect"
import { marks } from "../lifecycle/marks"
import { openDatabase } from "./database"
import { setStorageSnapshotProvider } from "./snapshot"
import { createDraftStore } from "./drafts"
import { importLegacyStores } from "./legacy"
import { createStateStore } from "./state"
@@ -40,6 +42,8 @@ export const layer = Layer.effect(
storage.close()
}),
)
setStorageSnapshotProvider((names) => Object.fromEntries(names.map((name) => [name, storage.state.items(name)])))
marks.storage = Date.now()
return Service.of(storage)
}),
)
@@ -0,0 +1,56 @@
import { existsSync, readdirSync } from "node:fs"
import path from "node:path"
import { DatabaseSync } from "node:sqlite"
import { app, ipcMain } from "electron"
import { StorageSnapshotChannel, type StorageSnapshot } from "../../shared/ipc-transport"
import { isRendererUrl } from "../windows/scheme"
// A window's preload asks for the namespaces its shell reads before the page runs, so the first
// render is the hydrated one. Until the storage layer is up (the first window asks before it exists)
// the answer comes from the database file; the layer takes over so later windows see queued writes.
type Provider = (names: ReadonlyArray<string>) => StorageSnapshot
let provider: Provider = readFromDisk
export function setStorageSnapshotProvider(next: Provider) {
provider = next
}
export function registerStorageSnapshotHandler() {
ipcMain.handle(StorageSnapshotChannel, (event, names: unknown): StorageSnapshot => {
if (!isRendererUrl(event.senderFrame?.url)) return {}
if (!Array.isArray(names)) return {}
return provider(names.filter((name): name is string => typeof name === "string"))
})
}
// Nothing has been written in this process yet, so every namespace is at revision 0, as the
// storage layer would report before its first update. Legacy electron-store files still waiting
// to be imported would make the database stale for this launch, so the renderer asks the layer then.
function readFromDisk(names: ReadonlyArray<string>): StorageSnapshot {
const userData = app.getPath("userData")
const file = path.join(userData, "drafts.sqlite")
if (!existsSync(file)) return {}
if (readdirSync(userData).some((name) => name === "default.dat" || /^opencode\..+\.dat$/.test(name))) return {}
try {
const db = new DatabaseSync(file)
try {
const rows = db.prepare("SELECT key, value FROM state WHERE name = ?")
return Object.fromEntries(
names.map((name) => [
name,
{
items: Object.fromEntries(
(rows.all(name) as { key: string; value: string }[]).map((row) => [row.key, row.value]),
),
revision: 0,
},
]),
)
} finally {
db.close()
}
} catch {
return {}
}
}
@@ -0,0 +1,20 @@
import { windowBootstrapArgument, type WindowBootstrap } from "../../shared/window-bootstrap"
import { getDefaultServerUrl } from "../service/server-settings"
import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
// The settings store is already in memory when a window is created, so the renderer gets the
// answers its shell gate would otherwise ask for over IPC. A fresh install has no onboarding
// decision yet; the renderer asks once the layers have made one.
export function windowBootstrap(id: string): WindowBootstrap {
const complete = getStore().get(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY)
return {
id,
firstLaunchPending: typeof complete === "boolean" ? !complete : undefined,
defaultServerUrl: getDefaultServerUrl(),
}
}
export function windowArguments(id: string) {
return [windowBootstrapArgument(windowBootstrap(id))]
}
@@ -9,10 +9,6 @@ import { getStore } from "../storage/store"
// full window setup in appearance.ts, so both draw the same frame.
const oc2Theme = oc2ThemeJson as DesktopTheme
const oc2Background = {
light: resolveThemeVariant(oc2Theme.light, false)["background-base"],
dark: resolveThemeVariant(oc2Theme.dark, true)["background-base"],
}
// Match the renderer's 36px titlebar plus its former 8px content inset.
export const titlebarHeight = 44
@@ -21,10 +17,13 @@ export function tone() {
}
// The colour the renderer reported on its last run, or the default theme's for the system tone, so
// a window shown before the renderer paints already has the right background.
// a window shown before the renderer paints already has the right background. Resolving a palette
// costs tens of milliseconds before the first window, so it only happens when nothing is stored.
export function storedBackgroundColor() {
const stored = getStore().get(BACKGROUND_COLOR_KEY)
return typeof stored === "string" ? stored : oc2Background[tone()]
if (typeof stored === "string") return stored
const dark = tone() === "dark"
return resolveThemeVariant(dark ? oc2Theme.dark : oc2Theme.light, dark)["background-base"]
}
export function titlebarOverlay(mode: "light" | "dark" = tone(), zoom = 1) {
+33 -5
View File
@@ -1,14 +1,25 @@
import { randomUUID } from "node:crypto"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { app, BrowserWindow, screen } from "electron"
import { windowIDArgument } from "../../shared/window-bootstrap"
import { app, BrowserWindow, screen, shell } from "electron"
import { resolveExternalURL } from "../files/external-url"
import { windowArguments } from "./bootstrap"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { storedBackgroundColor, titlebarOverlay } from "./defaults"
import { registerRendererProtocol } from "./protocol"
import { loadWindow } from "./scheme"
import { allowRendererPermissions, wireNavigationPolicy, wireRendererHeaders } from "./security"
import { manageWindowState, readWindowState, resolveWindowState, windowStateFile, type WindowState } from "./window-state"
export type EarlyWindow = { id: string; win: BrowserWindow; state: WindowState; shownAt: number }
export type EarlyWindow = {
id: string
win: BrowserWindow
state: WindowState
shownAt: number
// Navigation policy is wired before the layers exist; the adopter swaps in the logged version.
openExternal: (url: string) => void
}
let pending: EarlyWindow | undefined
@@ -46,7 +57,7 @@ export function createEarlyWindow() {
contextIsolation: true,
nodeIntegration: false,
sandbox: true,
additionalArguments: [windowIDArgument(id)],
additionalArguments: windowArguments(id),
},
})
manageWindowState(win, file, state, displays)
@@ -56,7 +67,24 @@ export function createEarlyWindow() {
pending = undefined
app.quit()
})
pending = { id, win, state, shownAt: Date.now() }
const record: EarlyWindow = {
id,
win,
state,
shownAt: Date.now(),
openExternal: (url) => {
const target = resolveExternalURL(url)
if (target) void shell.openExternal(target)
},
}
pending = record
// The renderer boots while the main bundle and layers load, instead of after them. Everything the
// page needs before its first request is wired here; the IPC port arrives once the layers are up.
registerRendererProtocol(path.join(root, "../renderer"))
allowRendererPermissions(win)
wireNavigationPolicy(win, (url) => record.openExternal(url))
wireRendererHeaders(win)
loadWindow(win, "index.html")
}
export function takeEarlyWindow() {
+16 -16
View File
@@ -7,7 +7,8 @@ import { DesktopPaths } from "../paths"
import { DesktopStorage } from "../storage"
import { getStore } from "../storage/store"
import { WINDOW_IDS_KEY } from "../storage/keys"
import { windowIDArgument } from "../../shared/window-bootstrap"
import { windowDataFile } from "../../shared/ipc-transport"
import { windowArguments } from "./bootstrap"
import {
getBackgroundColor,
getPinchZoomEnabled,
@@ -21,7 +22,8 @@ import {
wireFullscreen,
wireZoom,
} from "./appearance"
import { loadWindow, registerRendererProtocol } from "./protocol"
import { registerRendererProtocol, setProtocolReporter } from "./protocol"
import { loadWindow } from "./scheme"
import { createWindowRegistry } from "./registry"
import { makeWindowRecovery } from "./recovery"
import { takeEarlyWindow, type EarlyWindow } from "./early"
@@ -48,6 +50,7 @@ export {
getBackgroundColor,
getPinchZoomEnabled,
registerRendererProtocol,
setProtocolReporter,
setBackgroundColor,
setDockIcon,
setPinchZoomEnabled,
@@ -115,18 +118,23 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
...appearance,
webPreferences: {
...appearance.webPreferences,
additionalArguments: [windowIDArgument(id)],
additionalArguments: windowArguments(id),
},
})
allowRendererPermissions(win)
// The early window was secured and loaded when it was created; only its external-URL policy is
// upgraded to the logged one.
if (early) early.openExternal = (url) => runFork(openExternalURL(url))
if (!early) {
allowRendererPermissions(win)
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
wireRendererHeaders(win)
manageWindowState(win, stateFile, state, displays)
}
wireWindowRecovery(win, id, () => relaunchHandler())
wireNavigationPolicy(win, (url) => runFork(openExternalURL(url)))
wireRendererHeaders(win)
if (!early) manageWindowState(win, stateFile, state, displays)
register(win, id)
wireFullscreen(win)
loadWindow(win, "index.html")
if (!early) loadWindow(win, "index.html")
wireZoom(win)
let contentReady = false
let appliedTheme = false
@@ -182,12 +190,4 @@ export const makeMainWindows = Effect.fn("Window.make")(function* () {
return { create, restore }
})
// Mirrors windowStorage() in packages/app/src/runtime/persistence/storage.ts; it is the state
// namespace the renderer persists this window's tabs under.
function windowDataFile(id: string) {
return `opencode.window.${safeWindowID(id)}.dat`
}
function safeWindowID(id: string) {
return id.replace(/[^a-zA-Z0-9._-]/g, "-")
}
+69 -57
View File
@@ -1,73 +1,85 @@
import { net, protocol } from "electron"
import type { BrowserWindow } from "electron"
import path from "node:path"
import { pathToFileURL } from "node:url"
import { Effect, Path } from "effect"
import { scoped } from "../native/logging"
import { DesktopPaths } from "../paths"
import { documentPolicyHeader, jsCallStacksDocumentPolicy } from "./headers"
import { rendererHost, rendererProtocol } from "./scheme"
export const registerRendererProtocol = Effect.fn("Window.registerRendererProtocol")(function* () {
const path = yield* Path.Path
const paths = yield* DesktopPaths.resolve
const runFork = Effect.runForkWith(yield* Effect.context<never>())
export type ProtocolReport = (level: "warning" | "error", message: string, data: Record<string, unknown>) => void
// The entry module registers the handler the moment the first window exists, before logging is up,
// so problems go to the console until the logging layer installs a reporter.
let report: ProtocolReport = (level, message, data) => console[level === "error" ? "error" : "warn"](message, data)
export function setProtocolReporter(reporter: ProtocolReport) {
report = reporter
}
// Requests in flight and when the last one arrived. The entry module holds the main bundle back
// until the renderer's initial burst of asset requests has been answered, because this handler
// runs on the main thread and a 100 ms module evaluation would otherwise sit between the renderer
// and its HTML.
let inflight = 0
let served = 0
let lastRequest = 0
export function rendererAssetsServed(options: { quietMs: number; capMs: number }) {
const start = Date.now()
return new Promise<void>((resolve) => {
const check = () => {
const now = Date.now()
if (now - start >= options.capMs) return resolve()
if (served > 0 && inflight === 0 && now - lastRequest >= options.quietMs) return resolve()
setTimeout(check, 5)
}
check()
})
}
export function registerRendererProtocol(rendererRoot: string) {
if (protocol.isProtocolHandled(rendererProtocol)) return
protocol.handle(rendererProtocol, async (request) => {
const url = new URL(request.url)
if (url.host !== rendererHost) {
runFork(scoped("protocol", Effect.logWarning("rejected host", { url: request.url })))
return new Response("Not found", { status: 404 })
}
const file = path.resolve(paths.rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = path.relative(paths.rendererRoot, file)
if (rel.startsWith("..") || path.isAbsolute(rel)) {
runFork(scoped("protocol", Effect.logWarning("rejected path", { url: request.url, file })))
return new Response("Not found", { status: 404 })
}
inflight++
lastRequest = Date.now()
try {
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
if (response.status >= 400) {
runFork(
scoped(
"protocol",
Effect.logError("fetch failed", {
url: request.url,
file,
status: response.status,
statusText: response.statusText,
}),
),
)
}
return addDocumentPolicy(response, file)
} catch (error) {
runFork(scoped("protocol", Effect.logError("fetch error", { url: request.url, file, error })))
return new Response("Not found", { status: 404 })
return await serve(request, rendererRoot)
} finally {
inflight--
served++
}
})
})
export function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
void win.loadURL(new URL(html, devUrl).toString())
return
}
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
export function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false
return url.origin === new URL(devUrl).origin
async function serve(request: Request, rendererRoot: string) {
const url = new URL(request.url)
if (url.host !== rendererHost) {
report("warning", "rejected host", { url: request.url })
return new Response("Not found", { status: 404 })
}
const file = path.resolve(rendererRoot, `.${decodeURIComponent(url.pathname)}`)
const rel = path.relative(rendererRoot, file)
if (rel.startsWith("..") || path.isAbsolute(rel)) {
report("warning", "rejected path", { url: request.url, file })
return new Response("Not found", { status: 404 })
}
try {
const range = request.headers.get("range")
const response = await net.fetch(pathToFileURL(file).toString(), { headers: range ? { range } : undefined })
if (response.status >= 400) {
report("error", "fetch failed", {
url: request.url,
file,
status: response.status,
statusText: response.statusText,
})
}
return addDocumentPolicy(response, file)
} catch (error) {
report("error", "fetch error", { url: request.url, file, error })
return new Response("Not found", { status: 404 })
}
}
function addDocumentPolicy(response: Response, file: string) {
@@ -1,8 +1,28 @@
import { protocol } from "electron"
import type { BrowserWindow } from "electron"
export const rendererProtocol = "oc"
export const rendererHost = "renderer"
export function loadWindow(win: BrowserWindow, html: string) {
const devUrl = process.env.ELECTRON_RENDERER_URL
if (devUrl) {
void win.loadURL(new URL(html, devUrl).toString())
return
}
void win.loadURL(`${rendererProtocol}://${rendererHost}/${html}`)
}
export function isRendererUrl(value?: string, html = false) {
if (!value || !URL.canParse(value)) return false
const url = new URL(value)
if (html && !url.pathname.endsWith(".html")) return false
if (url.protocol === `${rendererProtocol}:` && url.host === rendererHost) return true
const devUrl = process.env.ELECTRON_RENDERER_URL
if (!devUrl || !URL.canParse(devUrl)) return false
return url.origin === new URL(devUrl).origin
}
// Scheme privileges can only be granted before the app is ready, so the entry module calls this
// before it loads anything else.
export function registerRendererScheme() {
@@ -1,7 +1,7 @@
import type { BrowserWindow } from "electron"
import { SidecarCredentials } from "../service/sidecar-credentials"
import { addRendererHeaders, hasHeader, upsertHeader } from "./headers"
import { isRendererUrl } from "./protocol"
import { isRendererUrl } from "./scheme"
const rendererPermissions = new Set(["clipboard-sanitized-write", "notifications"])
+17 -3
View File
@@ -1,6 +1,12 @@
import { contextBridge, ipcRenderer, webUtils } from "electron"
import { DragCancelEvent, IpcTransportPort } from "../shared/ipc-transport"
import { windowIDFromArguments } from "../shared/window-bootstrap"
import {
DragCancelEvent,
IpcTransportPort,
StorageSnapshotChannel,
storageSnapshotNames,
type StorageSnapshot,
} from "../shared/ipc-transport"
import { windowBootstrapFromArguments } from "../shared/window-bootstrap"
ipcRenderer.on(IpcTransportPort, (event) => {
const port = event.ports[0]
@@ -9,7 +15,15 @@ ipcRenderer.on(IpcTransportPort, (event) => {
ipcRenderer.on(DragCancelEvent, () => window.dispatchEvent(new Event(DragCancelEvent)))
const bootstrap = windowBootstrapFromArguments(process.argv)
// Asked before the page runs, so the stores the shell reads are hydrated on the first render.
const storageSnapshot: Promise<StorageSnapshot> = ipcRenderer
.invoke(StorageSnapshotChannel, storageSnapshotNames(bootstrap.id))
.catch(() => ({}))
contextBridge.exposeInMainWorld("electron", {
windowID: windowIDFromArguments(process.argv),
windowID: bootstrap.id,
bootstrap,
storageSnapshot,
getPathForFile: (file: File) => webUtils.getPathForFile(file),
})
+5
View File
@@ -1,4 +1,9 @@
import type { StorageSnapshot } from "../shared/ipc-transport"
import type { WindowBootstrap } from "../shared/window-bootstrap"
export type ElectronNative = {
windowID: string
bootstrap: WindowBootstrap
storageSnapshot: Promise<StorageSnapshot>
getPathForFile(file: File): string
}
@@ -5,6 +5,7 @@ import type { UpdaterState } from "@opencode/app/updater"
import type { WslServersPlatform } from "@opencode/app/wsl/types"
import type { SshPlatform } from "@opencode/app/ssh"
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
import type { WindowBootstrap } from "../shared/window-bootstrap"
import type {
ClipboardImage,
DirectoryPickerOptions,
@@ -53,6 +54,7 @@ export type ElectronAPI = {
draftBlobPut(data: ArrayBuffer): Promise<string>
draftBlobGet(id: string): Promise<ArrayBuffer | null>
getWindowID(): string
getWindowBootstrap(): WindowBootstrap
themeReady(): Promise<void>
onMenuCommand(cb: (id: string) => void): () => void
onDeepLink(cb: (urls: string[]) => void): () => void
+13 -1
View File
@@ -22,6 +22,9 @@ const updaterHandler = (state: UpdaterState) => {
updaterCallbacks.forEach((callback) => callback(state))
}
// One renderer-side copy: the bridge clones on every crossing, so consumption is tracked here.
const seeded = window.electron.storageSnapshot.then((snapshot) => new Map(Object.entries(snapshot)))
export const api: ElectronAPI = {
awaitInitialization: () => invoke("AppAwaitInitialization"),
reconnectService: () => invoke("AppReconnectService"),
@@ -99,7 +102,15 @@ export const api: ElectronAPI = {
invoke("AppFinishFirstLaunchOnboarding", { createDefaultProject }),
checkAppExists: (appName) => invoke("AppCheckAppExists", { appName }),
resolveAppPath: (appName) => invoke("AppResolveAppPath", { appName }),
storeItems: (name) => invoke("StorageItems", { name }).then(mutable),
// The first read of a namespace the preload already fetched is served from that snapshot; later
// reads (a window re-opening a namespace) go to the main process as usual.
storeItems: (name) =>
seeded.then((snapshot) => {
const item = snapshot.get(name)
if (!item) return invoke("StorageItems", { name }).then(mutable)
snapshot.delete(name)
return item
}),
storeUpdate: (name, insert, remove) => invoke("StorageUpdate", { name, insert, remove }),
storeClear: (name) => invoke("StorageClear", { name }),
onStoreChanged: (cb) =>
@@ -111,6 +122,7 @@ export const api: ElectronAPI = {
draftBlobGet: (id) => invoke("DraftsGetBlob", { id }).then((data) => (data ? toArrayBuffer(data) : null)),
getWindowID: () => window.electron.windowID,
getWindowBootstrap: () => window.electron.bootstrap,
themeReady: () => invoke("WindowThemeReady"),
onMenuCommand: (cb) => listen("MenuCommandTriggered", (event) => cb(event.id)),
onDeepLink: (cb) => listen("DeepLinksOpened", (event) => cb(mutable(event.urls))),
+13 -5
View File
@@ -48,15 +48,23 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform;
drawingReady: false,
route,
})
// The window was created with the answers the shell gate needs; only a fresh install, which has no
// onboarding decision yet, asks over IPC and waits for the port.
const bootstrap = props.api.getWindowBootstrap()
const [firstLaunch] = createResource(() =>
props.api.isFirstLaunchOnboardingPending().catch((error) => {
console.error("[desktop-onboarding] first launch check failed", error)
return false
}),
bootstrap.firstLaunchPending !== undefined
? Promise.resolve(bootstrap.firstLaunchPending)
: props.api.isFirstLaunchOnboardingPending().catch((error) => {
console.error("[desktop-onboarding] first launch check failed", error)
return false
}),
)
const platform = createDesktopPlatform(props.api, windowState, props.updater)
const [sidecar, { mutate: setSidecar }] = createResource(() => props.api.awaitInitialization())
const [defaultServer] = createResource(() => platform.getDefaultServer?.())
const [defaultServer] = createResource(async () => {
if (bootstrap.defaultServerUrl === undefined) return platform.getDefaultServer?.()
return bootstrap.defaultServerUrl ? ServerConnection.Key.make(bootstrap.defaultServerUrl) : null
})
const [locale] = createResource(() => preloadStoredLocale(platform))
const [initialRoute] = createResource(
() => !firstLaunch.loading && (firstLaunch() && initialUrl === "/" ? "/new-session" : initialUrl),
+73 -69
View File
@@ -1,10 +1,14 @@
import { Context, Effect, Layer, ManagedRuntime, Queue, Schema, Stream } from "effect"
import { RpcClient, RpcMessage } from "effect/unstable/rpc"
import { DesktopRpcs, type DesktopRpcClient } from "../shared/ipc-rpc"
import type { Effect } from "effect"
import type { RpcMessage } from "effect/unstable/rpc"
import type { DesktopRpcClient } from "../shared/ipc-rpc"
import type { DesktopEvent } from "../shared/ipc-rpc/events"
import { IpcTransportPort } from "../shared/ipc-transport"
class DesktopClient extends Context.Service<DesktopClient, DesktopRpcClient>()("opencode/desktop/DesktopClient") {}
// The main process serves Effect's RpcServer over a MessagePort; this side speaks its wire format
// directly. Messages cross by structured clone (no serialization layer, binary stays binary), every
// payload in the contract is JSON-native or a Uint8Array, and the main process is trusted, so the
// renderer needs neither the Effect runtime nor the contract's schemas to talk to it. Keeping them
// out of the renderer's initial module graph is worth about a third of its startup script.
type EventTag = DesktopEvent["_tag"]
type InvokeTag = Exclude<keyof DesktopRpcClient, "DesktopEvents">
@@ -13,55 +17,48 @@ type InvokeResult<Tag extends InvokeTag> =
ReturnType<DesktopRpcClient[Tag]> extends Effect.Effect<infer Value, unknown> ? Value : never
type EventValue<Tag extends EventTag> = Extract<DesktopEvent, { readonly _tag: Tag }>
type Pending = {
readonly resolve: (value: unknown) => void
readonly reject: (error: unknown) => void
readonly chunk?: (values: ReadonlyArray<unknown>) => void
}
const pending = new Map<number, Pending>()
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
const beforeDispose = new Set<() => Promise<unknown> | void>()
let nextId = 0
const port = new Promise<MessagePort>((resolve) => {
const onMessage = (event: MessageEvent) => {
if (event.source !== window || event.data !== IpcTransportPort) return
const value = event.ports[0]
if (!value) return
window.removeEventListener("message", onMessage)
value.addEventListener("message", (message) => receive(value, message.data as RpcMessage.FromServerEncoded))
value.start()
resolve(value)
}
window.addEventListener("message", onMessage)
})
const ClientProtocolLive = Layer.unwrap(Effect.promise(() => port).pipe(Effect.map((value) => clientProtocol(value))))
const ClientLive = Layer.effect(DesktopClient, RpcClient.make(DesktopRpcs)).pipe(Layer.provide(ClientProtocolLive))
const runtime = ManagedRuntime.make(ClientLive)
const listeners = new Map<EventTag, Set<(value: unknown) => void>>()
const beforeDispose = new Set<() => Promise<unknown> | void>()
// Let queued work (storage flushes) hand its messages to the port before the runtime goes away.
// Let queued work (storage flushes) hand its messages to the port before it closes.
window.addEventListener(
"pagehide",
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => runtime.dispose()),
() => void Promise.allSettled([...beforeDispose].map((callback) => callback())).then(() => port.then((p) => p.close())),
{ once: true },
)
void request("DesktopEvents", null, (values) => {
for (const value of values as ReadonlyArray<DesktopEvent>) listeners.get(value._tag)?.forEach((fn) => fn(value))
})
export function onBeforeDispose(callback: () => Promise<unknown> | void) {
beforeDispose.add(callback)
return () => beforeDispose.delete(callback)
}
runtime.runFork(
Effect.gen(function* () {
const client = yield* DesktopClient
yield* client
.DesktopEvents()
.pipe(
Stream.runForEach((event) =>
Effect.sync(() => listeners.get(event._tag)?.forEach((listener) => listener(event))),
),
)
}),
)
export function invoke<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>): Promise<InvokeResult<Tag>> {
return runtime.runPromise(
Effect.gen(function* () {
const client = yield* DesktopClient
const method = client[tag] as unknown as (...args: ReadonlyArray<unknown>) => Effect.Effect<unknown, unknown>
return yield* method(...payload)
}),
) as Promise<InvokeResult<Tag>>
return request(tag, payload[0] ?? null) as Promise<InvokeResult<Tag>>
}
export function send<Tag extends InvokeTag>(tag: Tag, ...payload: InvokeArgs<Tag>) {
@@ -79,42 +76,49 @@ export function listen<Tag extends EventTag>(tag: Tag, listener: (value: EventVa
}
}
// Structured clone over the port, like Effect's worker protocol: no serialization layer, so binary
// payloads stay binary. Buffers are cloned rather than transferred: Electron's MessagePortMain
// drops transferred ArrayBuffers, so a request carrying one would never arrive.
function clientProtocol(value: MessagePort) {
return Layer.effect(
RpcClient.Protocol,
RpcClient.Protocol.make(
Effect.fnUntraced(function* (writeResponse, clientIds) {
const inbound = yield* Queue.unbounded<RpcMessage.FromServerEncoded>()
const onMessage = (event: MessageEvent) => {
Queue.offerUnsafe(inbound, event.data as RpcMessage.FromServerEncoded)
}
value.addEventListener("message", onMessage)
value.start()
yield* Effect.addFinalizer(() =>
Effect.sync(() => {
value.removeEventListener("message", onMessage)
value.close()
}),
)
yield* Stream.fromQueue(inbound).pipe(
Stream.runForEach((message) =>
Effect.forEach(clientIds, (clientId) => writeResponse(clientId, message), { discard: true }),
),
Effect.forkScoped,
)
return {
codecFor: Schema.toCodecJson,
send: (_clientId, request) =>
Effect.sync(() => {
value.postMessage(request)
}),
supportsAck: true,
supportsTransferables: false,
}
}),
),
)
function request(tag: string, payload: unknown, chunk?: Pending["chunk"]) {
const id = nextId++
return new Promise<unknown>((resolve, reject) => {
pending.set(id, { resolve, reject, chunk })
const message: RpcMessage.RequestEncoded = { _tag: "Request", id, tag, payload, headers: [] }
void port.then((p) => p.postMessage(message))
})
}
function receive(p: MessagePort, message: RpcMessage.FromServerEncoded) {
switch (message._tag) {
case "Chunk": {
pending.get(Number(message.requestId))?.chunk?.(message.values)
p.postMessage({ _tag: "Ack", requestId: message.requestId } satisfies RpcMessage.AckEncoded)
return
}
case "Exit": {
const id = Number(message.requestId)
const entry = pending.get(id)
pending.delete(id)
if (!entry) return
if (message.exit._tag === "Success") return entry.resolve(message.exit.value)
return entry.reject(failure(message.exit.cause))
}
case "Defect": {
const error = new Error("Desktop IPC defect", { cause: message.defect })
pending.forEach((entry) => entry.reject(error))
pending.clear()
return
}
case "ClientProtocolError": {
console.error("[desktop-ipc] protocol error", message.error)
return
}
}
}
// The RPC failure a caller sees is the encoded error the handler failed with, as before; defects
// and interrupts surface as errors.
function failure(cause: ReadonlyArray<{ readonly _tag: string; readonly error?: unknown; readonly defect?: unknown }>) {
const failed = cause.find((item) => item._tag === "Fail")
if (failed) return failed.error
const died = cause.find((item) => item._tag === "Die")
if (died) return new Error("Desktop IPC handler failed", { cause: died.defect })
return new Error("Desktop IPC request interrupted")
}
@@ -1,9 +1,22 @@
import { loadLocaleDict, normalizeLocale, type Locale, type Platform } from "@opencode/app/desktop"
import { storedLocaleValue } from "./locale-value"
// The stored language lives in the main process's SQLite store, behind the IPC port. A copy of the
// last answer in localStorage lets the shell mount without waiting for the port; the store is still
// asked every launch and the copy refreshed, and the language provider hydrates from the store
// itself, so a stale copy costs one visible switch, not a wrong language.
const cacheKey = "opencode.desktop.language"
export async function preloadStoredLocale(platform: Platform) {
const raw = await platform.storage?.("opencode.global.dat").getItem("language")
const locale = storedLocale(raw)
const fresh = Promise.resolve(platform.storage?.("opencode.global.dat").getItem("language")).then(
(raw) => {
localStorage.setItem(cacheKey, raw ?? "")
return raw
},
() => undefined,
)
const cached = localStorage.getItem(cacheKey)
const locale = storedLocale(cached ?? (await fresh))
if (!locale) return
if (locale !== "en") await loadLocaleDict(locale)
return locale
@@ -1,2 +1,16 @@
export const IpcTransportPort = "desktop-rpc-port"
export const DragCancelEvent = "opencode:drag-cancel"
export const StorageSnapshotChannel = "desktop-storage-snapshot"
export type StorageSnapshot = Record<string, { items: Record<string, string>; revision: number }>
// The namespaces a window reads while its shell mounts. The preload asks for them before the page
// runs so the first render already has them; mirrors windowStorage() in
// packages/app/src/runtime/persistence/storage.ts.
export function storageSnapshotNames(windowID: string) {
return ["opencode.global.dat", "default.dat", windowDataFile(windowID)]
}
export function windowDataFile(id: string) {
return `opencode.window.${id.replace(/[^a-zA-Z0-9._-]/g, "-")}.dat`
}
@@ -1,13 +1,17 @@
import { describe, expect, test } from "bun:test"
import { windowIDArgument, windowIDFromArguments } from "./window-bootstrap"
import { windowBootstrapArgument, windowBootstrapFromArguments } from "./window-bootstrap"
describe("window bootstrap", () => {
test("round-trips the window ID through renderer arguments", () => {
const id = "window/id with spaces"
expect(windowIDFromArguments(["electron", windowIDArgument(id)])).toBe(id)
test("round-trips through argv", () => {
const bootstrap = { id: "win a/b ü", firstLaunchPending: false, defaultServerUrl: "http://127.0.0.1:1234" }
expect(windowBootstrapFromArguments(["electron", windowBootstrapArgument(bootstrap)])).toEqual(bootstrap)
})
test("requires a window ID argument", () => {
expect(() => windowIDFromArguments(["electron"])).toThrow("Window ID argument not found")
test("keeps unknown values absent", () => {
expect(windowBootstrapFromArguments([windowBootstrapArgument({ id: "x" })])).toEqual({ id: "x" })
})
test("throws when the argument is missing", () => {
expect(() => windowBootstrapFromArguments(["electron"])).toThrow("Window bootstrap argument not found")
})
})
@@ -1,11 +1,19 @@
const windowIDPrefix = "--opencode-window-id="
export function windowIDArgument(id: string) {
return windowIDPrefix + encodeURIComponent(id)
// What the main process already knows when it creates a window, handed to the renderer through the
// preload's argv so the shell can mount before the IPC port exists. Undefined means "ask over IPC".
export type WindowBootstrap = {
id: string
firstLaunchPending?: boolean
defaultServerUrl?: string | null
}
export function windowIDFromArguments(args: readonly string[]) {
const value = args.find((arg) => arg.startsWith(windowIDPrefix))?.slice(windowIDPrefix.length)
if (!value) throw new Error("Window ID argument not found")
return decodeURIComponent(value)
const prefix = "--opencode-window="
export function windowBootstrapArgument(bootstrap: WindowBootstrap) {
return prefix + encodeURIComponent(JSON.stringify(bootstrap))
}
export function windowBootstrapFromArguments(args: readonly string[]): WindowBootstrap {
const value = args.find((arg) => arg.startsWith(prefix))?.slice(prefix.length)
if (!value) throw new Error("Window bootstrap argument not found")
return JSON.parse(decodeURIComponent(value))
}
+31
View File
@@ -31,7 +31,25 @@ async function main() {
app.setPath("userData", path.join(root, "electron-data"))
app.on("window-all-closed", () => {})
await app.whenReady()
let unavailable = true
let finishLoading: (() => void) | undefined
const web = createServer((request, response) => {
if (request.url === "/loading") {
response.writeHead(200, { "content-type": "text/html" })
response.write("<!doctype html><html><body>Loading")
finishLoading = () => response.end("</body></html>")
return
}
if (request.url === "/finish-loading") {
finishLoading?.()
finishLoading = undefined
response.end()
return
}
if (request.url === "/unreachable" && unavailable) {
request.socket.destroy()
return
}
if (request.url === "/cors-allowed" || request.url === "/cors-denied") {
if (request.url === "/cors-allowed") response.setHeader("access-control-allow-origin", "*")
response.setHeader("content-type", "text/plain")
@@ -204,6 +222,19 @@ async function main() {
assert.equal((await call("tabs.list", {})).tabs.length, 2)
const tabID = first.id
assert.equal(first.url, fixture + "/")
assert.equal((await call("navigate", { tabID, url: `${fixture}/unreachable` })).loadError, "502 Bad Gateway")
const failed = await until(async () => {
const tab = (await call("tabs.list", {})).tabs.find((tab) => tab.id === tabID)
return tab?.loadError && !tab.loading ? tab : undefined
})
assert.equal(failed.url, `${fixture}/unreachable`)
assert.equal(failed.loadError, "502 Bad Gateway")
await until(async () => inventories.get("suite")?.tabs.find((tab) => tab.id === tabID)?.loadError)
assert.equal((await call("tabs.list", {})).tabs.find((tab) => tab.id === second.id)?.loadError, undefined)
unavailable = false
assert.equal((await call("reload", { tabID })).loadError, undefined)
assert.equal((await call("navigate", { tabID, url: fixture })).loadError, undefined)
console.log("PASS browser navigation failure state and retry")
const networkProof = await call("evaluate", {
tabID,
script: `(async () => ({
+53
View File
@@ -29,6 +29,58 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
}
try {
await page.ready
const started = new Promise<void>((resolve) => page.contents.once("did-navigate", () => resolve()))
page.setVisible(true)
const navigation = execute({ type: "navigate", tabID, url: url + "/loading" })
await started
assert(page.state().loading)
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
page.setVisible(true)
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
const stopped = new Promise<void>((resolve) => page.contents.once("did-stop-loading", () => resolve()))
await fetch(url + "/finish-loading")
await navigation
await stopped
assert.equal(page.state().loading, false)
assert(page.view.getVisible(), "The requested visibility must return after loading finishes")
console.log("PASS native loading from a blank page keeps the themed background visible until the document is ready")
const again = new Promise<void>((resolve) => page.contents.once("did-navigate", () => resolve()))
const reloading = execute({ type: "navigate", tabID, url: url + "/loading" })
await again
assert(page.state().loading)
assert(page.view.getVisible(), "A shown page must stay visible while the next document loads")
await fetch(url + "/finish-loading")
await reloading
assert(page.view.getVisible())
console.log("PASS native loading from a shown page keeps the previous document visible")
await execute({ type: "navigate", tabID, url: url + "/missing" })
assert.equal(page.state().loadError, undefined, "An HTTP error page from the server is a real document")
page.setVisible(true)
assert(page.view.getVisible(), "An HTTP error page from the server must stay visible")
console.log("PASS native HTTP error pages stay visible")
await assert.rejects(execute({ type: "navigate", tabID, url: url + "/unreachable" }), /ERR_EMPTY_RESPONSE/)
assert.equal(page.state().loadError, "ERR_EMPTY_RESPONSE")
assert.equal(page.state().url, url + "/unreachable")
page.setVisible(true)
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
const clearing = new Promise<void>((resolve) => {
page.contents.once("did-start-navigation", () => {
page.setVisible(true)
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
resolve()
})
})
await execute({ type: "navigate", tabID, url: "about:blank" })
await clearing
assert.equal(page.state().loadError, undefined)
page.setVisible(true)
assert(win.contentView.children.slice(children).every((view) => !view.getVisible()))
const restored = new Promise<void>((resolve) => page.contents.once("did-stop-loading", () => resolve()))
await execute({ type: "navigate", tabID, url })
await restored
page.setVisible(true)
assert(page.view.getVisible())
console.log("PASS native blank navigation stays hidden through stale layout updates")
for (const color of [
[255, 255, 255, 255],
[20, 24, 30, 255],
@@ -91,6 +143,7 @@ export async function verifyTargets(win: BrowserWindow, url: string) {
await execute({ type: "evaluate", tabID, script: "document.querySelector('a[href=\"/download\"]').click()" })
const fileID = await waitForFile()
assert.equal(page.state().loadError, undefined, "A download must not mark the page as unreachable")
const fileAction: Browser.Action = { type: "files.get", tabID, fileID }
const original = await inspect(fileAction)
assert(original.resources.includes(url + "/download"))
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/enterprise",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/function",
"version": "2.0.10",
"version": "2.0.11",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.11",
"name": "@opencode/http-recorder",
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/httpapi-codegen",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/latex",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/merman",
"version": "2.0.10",
"version": "2.0.11",
"private": true,
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/plugin-browser",
"version": "2.0.10",
"version": "2.0.11",
"description": "OpenCode's desktop browser plugin",
"type": "module",
"license": "MIT",
+1
View File
@@ -54,6 +54,7 @@ export const Tab = Schema.Struct({
url: Schema.String.check(Schema.isMaxLength(16_384)),
title: short,
loading: Schema.Boolean,
loadError: optional(short),
canGoBack: Schema.Boolean,
canGoForward: Schema.Boolean,
generation: count,

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