Compare commits

...
Author SHA1 Message Date
usrnk1 d45d7d2ee6 Merge branch 'v2' into adaptive-command-menu 2026-09-21 16:09:06 +02:00
opencode-agent[bot]andnexxeln 2f0c861af0 fix(cli): show actionable upgrade errors (#50346)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-09-21 19:09:56 +05:30
opencode-agent[bot] 4d94777d4d chore(core): refresh bundled models.dev snapshot 2026-09-21 12:19:57 +00:00
opencode-agent[bot] 46ebde65e9 chore: update nix node_modules hashes 2026-09-21 11:58:21 +00:00
Luke Parker 932c12ad1d chore(desktop): upgrade Electron to 44.4.3 (#50327) 2026-09-21 21:33:49 +10:00
opencode 6f655dcbab sync release versions for v2.0.12 2026-09-21 09:57:01 +00:00
Luke Parker ab60f08c69 fix(app): open Review pane for browser focus in background sessions (#50284) 2026-09-21 06:08:36 +00:00
Luke Parker 60ed84ecd1 feat(app): open agent-referenced files as rich artifact tabs (#49882) 2026-09-21 15:20:15 +10:00
Luke Parker 8aebed170a fix(app): show empty review state for projects without VCS (#50265) 2026-09-21 14:54:47 +10:00
14148a0ea4 fix(app): restore server status spacing (#50273)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
2026-09-21 12:37:19 +08:00
Aiden Cline 7f51fbd878 feat(codemode): rank whole-word path matches above substring matches in search (#50275) 2026-09-20 22:59:01 -05: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
usrnk1 3c8301e155 feat(desktop): adapt composer suggestion menu 2026-09-16 15:47:36 +02:00
166 changed files with 3522 additions and 613 deletions
+39 -39
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.10",
"version": "2.0.12",
"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.12",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -175,7 +175,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -201,7 +201,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.10",
"version": "2.0.12",
"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.12",
"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.12",
"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.12",
"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.12",
"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.12",
"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.12",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -406,10 +406,10 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
"electron-context-menu": "5.0.0",
"electron-log": "^5",
"electron-updater": "6.8.9",
"lighthouse": "13.4.1",
@@ -436,7 +436,7 @@
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
"electron": "44.4.3",
"electron-builder": "26.15.7",
"electron-vite": "6.0.0-beta.1",
"puppeteer-core": "25.9.0",
@@ -455,7 +455,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.10",
"version": "2.0.12",
"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.12",
"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.12",
"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.12",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -540,7 +540,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -554,7 +554,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -569,7 +569,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.10",
"version": "2.0.12",
"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.12",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -638,7 +638,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -653,7 +653,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -677,7 +677,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -698,7 +698,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.10",
"version": "2.0.12",
"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.12",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -755,7 +755,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.10",
"version": "2.0.12",
"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.12",
"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.12",
"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.12",
"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.12",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -888,7 +888,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -923,7 +923,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -958,7 +958,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -991,7 +991,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.10",
"version": "2.0.12",
"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.12",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
@@ -3880,13 +3880,13 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@42.10.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-ITc1HPeoDzsxCCaH6MFsN67Nq2nUiJf5N9pBWxzhUpFfKJF0IwiAkKT3emg1lPbvC4OfFE+Cdwe4vhgdOZ1YKg=="],
"electron": ["electron@44.4.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-LTpSFTB40qVCXIX5xMo+cgHI/Jjkbjw7VpB26PccEbroqOn72LBukeaDwPVo1fBYzSzs0c9iPuAucFCO7Tw81Q=="],
"electron-builder": ["electron-builder@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.7", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-DBpaNzxsPs1BvEblzFoNriSbzsBqDCy/gseIngeEhYzQG1IxfB7Hvc2tBBVmpWE2BTQGP9J1RrAvDT+Vc/uAxg=="],
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-B4uvn2NzFSuf084udWqugludFull6CRJiWe2dLzMnZLl6G5hdAGk0fsBMGlBSpKjvQCJn8IPc+S7OnJ+GXqwLA=="],
"electron-context-menu": ["electron-context-menu@4.1.2", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-9xYTUV0oRqKL50N9W71IrXNdVRB0LuBp3R1zkUdUc2wfIa2/QZwYYj5RLuO7Tn7ZSLVIaO3X6u+EIBK+cBvzrQ=="],
"electron-context-menu": ["electron-context-menu@5.0.0", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-rgFpRtwY0/rhsRCoz9rE6VM4WueEsLbIpca7ucOhERVrbGB2dQrxa9xwBNLplU54jRgPuv6nTEbxoplE5bzy2A=="],
"electron-dl": ["electron-dl@4.0.0", "", { "dependencies": { "ext-name": "^5.0.0", "pupa": "^3.1.0", "unused-filename": "^4.0.1" } }, "sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
"x86_64-linux": "sha256-/jah4P2a0aGJNJ0aMdlFEbGHAXx33lqxHbc7UmpLFDg=",
"aarch64-linux": "sha256-L3SoZ24qNXicsE2FK6LATQjOmjPxY679RjugrUyO/1Y=",
"aarch64-darwin": "sha256-pI9NT8KWUPi+JCk6DYMqIAYmBqTbi13uL4VdNV3WS6Y=",
"x86_64-darwin": "sha256-rMAGhTTz46KA5Ya7E5J0af7Bn1QzTDhTaNfNJm8qfsw="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.10",
"version": "2.0.12",
"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.12",
"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()
})
@@ -122,6 +122,35 @@ story("does not mask or pad controls when they fit", async ({ mount }) => {
await expect(controls).toHaveCSS("padding-inline-end", "0px")
})
story("grows suggestions while preserving visible timeline context", async ({ mount }) => {
const component = await mount("opencode-composer-flow--constrained-command-suggestions")
const boundary = component.locator('[data-slot="composer-suggestion-boundary-story"]')
const suggestions = component.locator('[data-component="composer-suggestions"]')
await expect(suggestions).toHaveCSS("max-height", "166px")
await expect(suggestions).toHaveCSS("scroll-padding-bottom", "18px")
await expect.poll(() => suggestions.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
await expect
.poll(async () => {
const menu = await suggestions.boundingBox()
const items = await suggestions.locator("[data-suggestion-id]").evaluateAll((elements) =>
elements.map((element) => {
const rect = element.getBoundingClientRect()
return { top: rect.top, bottom: rect.bottom }
}),
)
if (!menu) return false
const bottom = menu.y + menu.height
return items.some((item) => item.top < bottom && item.bottom > bottom)
})
.toBe(true)
await boundary.evaluate((element) => {
element.style.height = "400px"
})
await expect(suggestions).toHaveCSS("max-height", "306px")
})
// ThemeProvider writes resolved token values into a <style> block, so toggling data-color-scheme by hand
// leaves every --v2-* variable at its previous value. Switch themes through the Storybook global instead.
for (const [theme, background] of [
@@ -17,14 +17,12 @@ const milestones = [
"electronSpawnStarted",
"debugEndpointReady",
"electronStarted",
"layersReady",
"windowVisible",
"serviceEnsureStarted",
"serviceSpawnRequested",
"serviceReady",
"backgroundLoadingReady",
"rendererViteConnected",
"rendererInitializationStarted",
"rendererInitializationReady",
"windowVisible",
"homeReady",
] as const
const phases = [
@@ -128,14 +126,12 @@ const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
{ name: "electronSpawnStarted", text: "starting electron app..." },
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
{ name: "electronStarted", text: "app starting" },
{ name: "layersReady", text: "layers ready" },
{ name: "windowVisible", text: "main window visible" },
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
{ name: "serviceReady", text: "v2 CLI background service ready" },
{ name: "backgroundLoadingReady", text: "loading task finished" },
{ name: "rendererViteConnected", text: "[vite] connected." },
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
{ name: "rendererInitializationReady", text: "server ready" },
{ name: "windowVisible", text: "main window visible" },
]
async function createColdProfile() {
@@ -404,14 +400,12 @@ function requireMilestones(observed: Partial<Record<Milestone, number>>) {
electronSpawnStarted: get("electronSpawnStarted"),
debugEndpointReady: get("debugEndpointReady"),
electronStarted: get("electronStarted"),
layersReady: get("layersReady"),
windowVisible: get("windowVisible"),
serviceEnsureStarted: get("serviceEnsureStarted"),
serviceSpawnRequested: get("serviceSpawnRequested"),
serviceReady: get("serviceReady"),
backgroundLoadingReady: get("backgroundLoadingReady"),
rendererViteConnected: get("rendererViteConnected"),
rendererInitializationStarted: get("rendererInitializationStarted"),
rendererInitializationReady: get("rendererInitializationReady"),
windowVisible: get("windowVisible"),
homeReady: get("homeReady"),
}
}
@@ -20,14 +20,12 @@ describe("desktop startup benchmark", () => {
["electronSpawnStarted", "starting electron app..."],
["debugEndpointReady", "DevTools listening on ws://"],
["electronStarted", "app starting"],
["layersReady", "layers ready"],
["windowVisible", "main window visible"],
["serviceEnsureStarted", "starting v2 background service"],
["serviceSpawnRequested", "v2 CLI background service starting"],
["serviceReady", "v2 CLI background service ready"],
["backgroundLoadingReady", "loading task finished"],
["rendererViteConnected", "[vite] connected."],
["rendererInitializationStarted", "awaiting server ready"],
["rendererInitializationReady", "server ready"],
["windowVisible", "main window visible"],
] as const
cases.forEach(([milestone, line]) => {
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
@@ -58,14 +56,12 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
electronSpawnStarted: 7,
debugEndpointReady: 8,
electronStarted: 9,
serviceEnsureStarted: 10,
serviceSpawnRequested: 11,
serviceReady: 12,
backgroundLoadingReady: 13,
rendererViteConnected: 14,
rendererInitializationStarted: 15,
rendererInitializationReady: 16,
windowVisible: 17,
layersReady: 10,
windowVisible: 11,
serviceEnsureStarted: 12,
serviceSpawnRequested: 13,
serviceReady: 14,
rendererViteConnected: 15,
homeReady: commandToHomeReadyMs,
}
return {
@@ -80,8 +76,8 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
electronStartup: 2,
serviceSpawnWait: 1,
serviceProcessStartup: 1,
rendererStartup: commandToHomeReadyMs - 14,
visibleWindowToHome: commandToHomeReadyMs - 17,
rendererStartup: commandToHomeReadyMs - 15,
visibleWindowToHome: commandToHomeReadyMs - 11,
},
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
}
@@ -71,7 +71,7 @@ for (const direction of ["ltr", "rtl"] as const) {
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
for (const width of [1520, 1280, 1600]) {
for (const width of [1520, 1280, 1800]) {
await page.setViewportSize({ width, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
await page.clock.runFor(100)
@@ -84,6 +84,7 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
await expect(content).not.toHaveCSS("translate", shifted)
await page.setViewportSize({ width: 1440, height: 900 })
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveCSS("translate", shifted)
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
@@ -112,7 +113,8 @@ for (const direction of ["ltr", "rtl"] as const) {
await expect(summary).toBeVisible()
await page.setViewportSize({ width: 1800, height: 900 })
await expect(content).toHaveCSS("translate", "0px")
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
await expect(content).toHaveCSS("translate", "none")
await expect(summary).toBeVisible()
await page.emulateMedia({ reducedMotion: "reduce" })
@@ -116,7 +116,7 @@ test("single-server settings expose scoped pages without a server picker", async
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "10px")
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.10",
"version": "2.0.12",
"description": "",
"type": "module",
"exports": {
+24 -1
View File
@@ -57,6 +57,8 @@ function ComposerStory(props: {
continueOnStop?: boolean
longLabels?: boolean
alternate?: "queue" | "steer"
manySuggestions?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const [draft, setDraft] = createStore<ComposerPersistedState>({
prompt: props.prompt ?? [{ type: "text", content: "", start: 0, end: 0 }],
@@ -93,6 +95,15 @@ function ComposerStory(props: {
const commands: ComposerSuggestion[] = [
{ id: "command.test", kind: "command", label: "/test", trigger: "test", title: "Run tests" },
{ id: "command.review", kind: "command", label: "/review", trigger: "review", title: "Review changes" },
...(props.manySuggestions
? Array.from({ length: 12 }, (_, index) => ({
id: `command.example-${index}`,
kind: "command" as const,
label: `/example-${index}`,
trigger: `example-${index}`,
title: `Run example ${index}`,
}))
: []),
]
const context: ComposerSuggestion[] = [
{
@@ -206,7 +217,7 @@ function ComposerStory(props: {
<output class="text-12-regular text-text-weak" aria-live="polite">
{story.activity}
</output>
<Composer model={model} borderUnderlay />
<Composer model={model} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
</div>
)
}
@@ -273,6 +284,18 @@ export const SlashSuggestions = { render: () => <ComposerStory suggestions="comm
export const ContextSuggestions = { render: () => <ComposerStory suggestions="context" /> }
function ConstrainedCommandSuggestionsStory() {
let boundary: HTMLDivElement | undefined
return (
<div class="mx-auto w-full max-w-200">
<div ref={boundary} data-slot="composer-suggestion-boundary-story" class="h-60" />
<ComposerStory suggestions="command" manySuggestions suggestionBoundary={() => boundary} />
</div>
)
}
export const ConstrainedCommandSuggestions = { render: () => <ConstrainedCommandSuggestionsStory /> }
export const RunningAndStopping = { render: () => <ComposerStory working stopping label="Session is running" /> }
export const SteeringFollowUp = {
+7 -1
View File
@@ -12,7 +12,12 @@ import { formatKeybind, useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import type { ComposerModel } from "./model"
export function Composer(props: { class?: string; model: ComposerModel; borderUnderlay?: boolean }) {
export function Composer(props: {
class?: string
model: ComposerModel
borderUnderlay?: boolean
suggestionBoundary?: () => HTMLElement | undefined
}) {
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
@@ -28,6 +33,7 @@ export function Composer(props: { class?: string; model: ComposerModel; borderUn
attachShortcut={command.keybind("file.attach")}
alternateKeybind={[formatKeybind("mod", language.t), "↵"]}
exitShellKeybind={[formatKeybind("esc", language.t)]}
suggestionBoundary={props.suggestionBoundary}
modelControl={
<ComposerModelControl
loading={props.model.model.loading}
+36 -3
View File
@@ -1,5 +1,6 @@
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { FileIcon } from "@opencode/ui/file-icon"
import { Icon } from "@opencode/ui/icon"
import { IconButton } from "@opencode/ui/icon-button"
@@ -40,6 +41,12 @@ export type {
} from "../types"
export type ComposerMode = "normal" | "shell"
const COMPOSER_SUGGESTION_MAX_HEIGHT = 320
const COMPOSER_SUGGESTION_ROW_HEIGHT = 28
const COMPOSER_SUGGESTION_ROW_PEEK = 18
const COMPOSER_SUGGESTION_TOP_PADDING = 8
const COMPOSER_SUGGESTION_SEARCH_HEIGHT = 28
const COMPOSER_SUGGESTION_CONTEXT_RESERVE = 80
export type ComposerEditorProps = {
controller: ComposerEditorModel
@@ -53,6 +60,7 @@ export type ComposerEditorProps = {
attachShortcut?: string
alternateKeybind?: string[]
exitShellKeybind?: string[]
suggestionBoundary?: () => HTMLElement | undefined
}
export function ComposerEditor(props: ComposerEditorProps) {
@@ -117,6 +125,7 @@ export function ComposerEditor(props: ComposerEditorProps) {
<ComposerEditorPopover
emptyLabel={i18n.t("ui.promptInput.noMatchingItems")}
items={props.controller.suggestions()}
boundary={props.suggestionBoundary}
activeID={state.popover.type === "closed" ? undefined : state.popover.activeID}
search={
state.popover.type === "command-menu"
@@ -761,18 +770,29 @@ export function ComposerEditorPopover(props: {
onValueChange: (value: string) => void
onKeyDown: (event: KeyboardEvent) => void
}
boundary?: () => HTMLElement | undefined
onActiveChange: (item: ComposerSuggestion) => void
onSelect: (item: ComposerSuggestion) => void
}) {
const [store, setStore] = createStore({ maxHeight: COMPOSER_SUGGESTION_MAX_HEIGHT })
const resize = (height: number) =>
setStore("maxHeight", composerSuggestionMaxHeight(height, props.search !== undefined))
createEffect(() => resize(props.boundary?.()?.clientHeight ?? COMPOSER_SUGGESTION_MAX_HEIGHT * 2))
createResizeObserver(
props.boundary ?? (() => undefined),
(rect) => resize(rect.height),
)
return (
<div
data-component="composer-suggestions"
class="absolute inset-x-0 -top-2 z-40 flex max-h-80 -translate-y-full flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
class="absolute inset-x-0 -top-2 z-40 flex -translate-y-full scroll-pb-[18px] flex-col overflow-auto rounded-xl bg-v2-background-bg-base p-2 shadow-[var(--v2-elevation-raised)] no-scrollbar"
style={{ "max-height": `${store.maxHeight}px` }}
onMouseDown={(event) => event.preventDefault()}
>
<Show when={props.search}>
{(search) => (
<div class="px-2 py-1">
<div class="shrink-0 px-2 py-1">
<input
ref={(element) => requestAnimationFrame(() => element.focus())}
value={search().value}
@@ -796,7 +816,7 @@ export function ComposerEditorPopover(props: {
type="button"
data-suggestion-id={item.id}
data-active={props.activeID === item.id ? "" : undefined}
class="flex w-full items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
class="flex h-7 w-full shrink-0 items-center gap-2 rounded-md px-2 py-1 text-start hover:bg-v2-overlay-simple-overlay-hover"
classList={{ "bg-v2-overlay-simple-overlay-hover": props.activeID === item.id }}
onPointerMove={() => props.onActiveChange(item)}
onClick={() => props.onSelect(item)}
@@ -821,6 +841,19 @@ export function ComposerEditorPopover(props: {
)
}
function composerSuggestionMaxHeight(boundaryHeight: number, search: boolean) {
const reserve = Math.min(COMPOSER_SUGGESTION_CONTEXT_RESERVE, boundaryHeight / 4)
const limit = Math.min(COMPOSER_SUGGESTION_MAX_HEIGHT, boundaryHeight - reserve)
const chrome = COMPOSER_SUGGESTION_TOP_PADDING + (search ? COMPOSER_SUGGESTION_SEARCH_HEIGHT : 0)
if (limit < chrome + COMPOSER_SUGGESTION_ROW_HEIGHT + COMPOSER_SUGGESTION_ROW_PEEK) return limit
return (
chrome +
Math.floor((limit - chrome - COMPOSER_SUGGESTION_ROW_PEEK) / COMPOSER_SUGGESTION_ROW_HEIGHT) *
COMPOSER_SUGGESTION_ROW_HEIGHT +
COMPOSER_SUGGESTION_ROW_PEEK
)
}
// "Steer ⌘⏎" / "Queue ⌘⏎" hint next to the submit button: submits with the
// delivery opposite to what plain Enter does. Visible only while the queue
// exposes an alternate (turn running and composer holding a value), so it
+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;
}
}
}
+14
View File
@@ -625,6 +625,16 @@ export const dict = {
"toast.file.loadFailed.title": "Failed to load file",
"file.error.notFound": "File not found: {{name}}",
"toast.file.listFailed.title": "Failed to list files",
"file.view.preview": "Preview",
"file.view.source": "Source",
"file.view.openInBrowser": "Open in browser",
"file.view.binary": "Binary file · {{size}}",
"file.view.table.rows.one": "{{count}} row",
"file.view.table.rows.other": "{{count}} rows",
"file.view.table.columns.one": "{{count}} column",
"file.view.table.columns.other": "{{count}} columns",
"file.view.table.truncated": "Showing the first {{shown}} of {{total}} rows.",
"file.view.fontSample": "Sphinx of black quartz, judge my vow.",
"toast.context.noLineSelection.title": "No line selection",
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
@@ -906,6 +916,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",
@@ -19,6 +19,7 @@ export type BrowserPaneCommand = Browser.Action
export type BrowserPaneState = Browser.State | null
export type BrowserPaneEvent =
| { type: "focus"; tabID: Browser.TabID }
| { type: "preview"; path: string }
| { type: "state"; state: BrowserPaneState; error?: string }
export type BrowserPaneRegistration = {
+2
View File
@@ -33,6 +33,8 @@ export type FileContent = {
}
encoding?: "base64"
mimeType?: string
/** On-disk size when the bytes themselves are not retained. */
size?: number
}
export type Path = {
+25 -11
View File
@@ -1,14 +1,15 @@
import { createEffect, createMemo, getOwner, on, onCleanup, runWithOwner } from "solid-js"
import { batch, createEffect, createMemo, createRoot, getOwner, on, onCleanup, runWithOwner } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { createSimpleContext } from "@opencode/ui/context"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { useLanguage } from "@/runtime/i18n/language"
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
import { usePlatform } from "@/runtime/platform/platform"
import type { useServer } from "@/runtime/server/current"
import type { SessionStateKey } from "@/runtime/server/scope"
import { useSettings } from "@/settings/model"
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
import { useCurrentRoute } from "@/shell/state/layout"
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
import { sessionBrowserTab } from "@/shell/state/session-tabs"
import { createEventListener } from "@solid-primitives/event-listener"
import { createBrowserConnection, type BrowserConnectionState } from "./connection"
@@ -35,13 +36,14 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
const settings = useSettings()
const language = useLanguage()
const shellTabs = useTabs()
const layout = useLayout()
const route = useCurrentRoute()
const owner = getOwner()
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
const live = new Map<string, Live>()
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
const preview = new Map<string, Set<(path: string) => void>>()
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
const enabled = createMemo(
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
@@ -87,11 +89,14 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
enabled,
supported: (server: Server) => !unsupported[server.key],
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
attach(server: Server, sessionID: string) {
attach(server: Server, sessionID: string, sessionKey: SessionStateKey) {
const id = key(server, sessionID)
if (live.has(id)) return
const pane = platform.browserPane
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
// Focus requests write to the owning session's layout even while another shell tab is routed,
// so the Review pane and browser tab are already selected when the user returns to it.
const tabs = createRoot((dispose) => ({ dispose, layout: layout.tabs(sessionKey) }), owner)
const connection = createBrowserConnection({
pane,
// Resolve the current port at every wake, including after sidecar replacement.
@@ -100,7 +105,15 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
sessionID,
endpoint: { ...server.conn.http, url: server.ctx.sdk.url },
}),
focus: (tabID) => focus.get(id)?.forEach((listener) => listener(tabID)),
focus: (tabID) => {
const tab = sessionBrowserTab(tabID)
batch(() => {
shellTabs.setPane(findSessionTab(shellTabs.store, server.key, sessionID), "review", true)
if (!tabs.layout.all().includes(tab)) tabs.layout.setAll([...tabs.layout.all(), tab])
tabs.layout.setActive(tab)
})
},
preview: (path) => preview.get(id)?.forEach((listener) => listener(path)),
change: (state) => {
if (state.error === "browser.pane.unsupported") {
setUnsupported(server.key, true)
@@ -138,17 +151,18 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
entry.dispose = () => {
unsubscribe?.forEach((dispose) => dispose())
connection.dispose()
tabs.dispose()
}
},
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
/** Agent requests to show a file in this session's Review pane. */
onPreview(server: Server, sessionID: string, listener: (path: string) => void) {
const id = key(server, sessionID)
const listeners = focus.get(id) ?? new Set()
const listeners = preview.get(id) ?? new Set()
listeners.add(listener)
focus.set(id, listeners)
preview.set(id, listeners)
return () => {
listeners.delete(listener)
if (!listeners.size) focus.delete(id)
if (!listeners.size) preview.delete(id)
}
},
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
@@ -28,10 +28,12 @@ function fixture() {
commands: Browser.Action[]
}[] = []
const endpoint = { url: "http://localhost:4096" }
const previews: string[] = []
const connection = createBrowserConnection({
target: () => ({ serverKey: "browser-test", sessionID: "ses_browser", endpoint: { ...endpoint } }),
change: (state) => states.push(state),
focus: () => {},
preview: (path) => previews.push(path),
pane: {
register(target, emit) {
const call = { target, emit, closed: false, commands: [] as Browser.Action[] }
@@ -50,9 +52,21 @@ function fixture() {
})
connection.wake()
calls[0].emit({ type: "state", state: browser })
return { connection, calls, states, endpoint }
return { connection, calls, states, endpoint, previews }
}
test("preview requests reach the session without touching connection state", () => {
const app = fixture()
try {
const before = app.states.length
app.calls[0].emit({ type: "preview", path: "docs/report.pdf" })
expect(app.previews).toEqual(["docs/report.pdf"])
expect(app.states).toHaveLength(before)
} finally {
app.connection.dispose()
}
})
test("suspension retains tabs and reconnects once on demand using the current endpoint", async () => {
const app = fixture()
try {
@@ -19,6 +19,7 @@ export function createBrowserConnection(input: {
target: () => BrowserPaneTarget
change: (state: BrowserConnectionState) => void
focus: (tabID: Browser.TabID) => void
preview: (path: string) => void
}) {
const state: BrowserConnectionState = { browser: null, suspended: false }
let disposed = false
@@ -33,6 +34,7 @@ export function createBrowserConnection(input: {
(event) => {
if (disposed || state.registration !== registration) return
if (event.type === "focus") return input.focus(event.tabID)
if (event.type === "preview") return input.preview(event.path)
if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") {
blocked = true
registration.close()
+2 -10
View File
@@ -1,4 +1,4 @@
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
import { batch, createEffect, createMemo, on } from "solid-js"
import type { Browser } from "@opencode/plugin-browser/rpc"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/runtime/i18n/language"
@@ -33,13 +33,6 @@ export function createSessionBrowser(session: SessionModel) {
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
[],
)
const focus = (tabID: Browser.TabID) => {
session.layout.view().reviewPanel.open()
const tabs = session.layout.tabs()
const key = sessionBrowserTab(tabID)
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
tabs.setActive(key)
}
const command = (command: BrowserPaneCommand) => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
@@ -66,8 +59,7 @@ export function createSessionBrowser(session: SessionModel) {
createEffect(() => {
const sessionID = session.identity.sessionID()
if (!sessionID) return
if (attachments.enabled()) attachments.attach(server, sessionID)
onCleanup(attachments.onFocus(server, sessionID, focus))
if (attachments.enabled()) attachments.attach(server, sessionID, session.layout.sessionKey())
})
createEffect(
on(
+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")}
+5 -2
View File
@@ -213,7 +213,10 @@ export function createActiveSessionRegion(input: {
export type ActiveSessionRegionModel = ReturnType<typeof createActiveSessionRegion>
export function ActiveSessionComposerRegion(props: { model: SessionComposerController }) {
export function ActiveSessionComposerRegion(props: {
model: SessionComposerController
suggestionBoundary: () => HTMLElement | undefined
}) {
return (
<SessionComposerRegion
controller={props.model.region}
@@ -221,7 +224,7 @@ export function ActiveSessionComposerRegion(props: { model: SessionComposerContr
<div class="relative">
<SessionQueuePanel queue={props.model.queue} />
<div class="relative z-10">
<Composer model={props.model.composer} borderUnderlay />
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
</div>
</div>
}
@@ -0,0 +1,94 @@
/* The control defaults to a fixed 232px with equal segments; here it hugs its two labels. */
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2"] {
width: auto;
}
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item"] {
flex: 0 0 auto;
padding: 0 14px;
}
/* Shrink-wrapped segments round the label box down a fraction; the ellipsis overflow then clips glyph edges. */
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item-label"] {
overflow: visible;
max-width: none;
}
[data-slot="artifact-stage"] {
--artifact-check: color-mix(in oklch, var(--v2-text-text-base) 5%, transparent);
background-color: var(--v2-background-bg-deep);
}
[data-slot="artifact-stage"][data-checker] {
background-image:
linear-gradient(45deg, var(--artifact-check) 25%, transparent 25%),
linear-gradient(-45deg, var(--artifact-check) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, var(--artifact-check) 75%),
linear-gradient(-45deg, transparent 75%, var(--artifact-check) 75%);
background-size: 16px 16px;
background-position:
0 0,
0 8px,
8px -8px,
-8px 0;
}
[data-slot="artifact-media"] {
display: block;
border-radius: 4px;
box-shadow:
0 0 0 0.5px var(--v2-border-border-base),
0 12px 32px -12px color-mix(in oklch, var(--v2-text-text-base) 25%, transparent);
}
[data-slot="artifact-stage"][data-zoom="fit"] [data-slot="artifact-media"] {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
[data-slot="artifact-stage"][data-zoom="fit"][data-overflow] [data-slot="artifact-media"] {
cursor: zoom-in;
}
[data-slot="artifact-stage"][data-zoom="actual"] [data-slot="artifact-media"] {
cursor: zoom-out;
}
[data-slot="artifact-table"] {
border-collapse: separate;
border-spacing: 0;
font-variant-numeric: tabular-nums;
}
[data-slot="artifact-table"] th,
[data-slot="artifact-table"] td {
padding: 5px 12px;
border-bottom: 1px solid var(--v2-border-border-muted);
border-inline-end: 1px solid var(--v2-border-border-muted);
white-space: pre;
max-width: 40ch;
overflow: hidden;
text-overflow: ellipsis;
text-align: start;
line-height: var(--line-height-compact);
}
[data-slot="artifact-table"] th {
position: sticky;
top: 0;
z-index: 1;
background: var(--v2-background-bg-layer-01);
font-weight: var(--font-weight-medium);
color: var(--v2-text-text-muted);
}
[data-slot="artifact-table"] tbody tr:hover td {
background: color-mix(in oklch, var(--v2-text-text-base) 3%, transparent);
}
[data-slot="artifact-table"] td[data-index] {
color: var(--v2-text-text-faint);
text-align: end;
user-select: none;
}
@@ -0,0 +1,454 @@
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Button } from "@opencode/ui/button"
import { FileIcon } from "@opencode/ui/file-icon"
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
import { ScrollView } from "@opencode/ui/scroll-view"
import { Markdown } from "@opencode/session-ui/markdown"
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
import { getDirectory, getFilename } from "@opencode/util/path"
import type { FileContent } from "@/runtime/server/types"
import { useLanguage } from "@/runtime/i18n/language"
import {
artifactKind,
blobUrlFromContent,
contentBytes,
parseDelimited,
resolveArtifactPath,
type ArtifactKind,
} from "@/workspaces/files/artifact"
import { useArtifactOpener } from "@/session/files/open-artifact"
import "./artifact-view.css"
type ArtifactMode = "preview" | "source"
/** Facts a viewer learns from the decoded media, shown in the toolbar. */
type ArtifactInfo = { width?: number; height?: number; duration?: number; rows?: number; columns?: number }
type MediaProps = {
path: string
content: FileContent
onInfo: (info: ArtifactInfo) => void
/** The browser could not decode the bytes; the host falls back to the binary placeholder. */
onError: () => void
}
/** Kinds that render a preview from their text and can toggle back to highlighted source. */
const previewableKinds = new Set<ArtifactKind>(["svg", "html", "markdown", "mermaid", "table"])
/**
* Renders a loaded non-text file: media, documents, and data get a dedicated viewer with a toolbar;
* previewable text kinds can switch to `source`, which the host supplies (its code view).
*/
export function ArtifactView(props: { path: string; content: FileContent; cacheKey?: string; source: JSX.Element }) {
const language = useLanguage()
const [state, setState] = createStore({
mode: "preview" as ArtifactMode,
info: {} as ArtifactInfo,
// Media the browser could not decode falls back to the binary placeholder.
undecodable: false,
})
createEffect(
on(
() => props.content,
() => setState({ mode: "preview", info: {}, undecodable: false }),
{ defer: true },
),
)
const kind = createMemo<ArtifactKind | "binary">(() => {
if (props.content.type === "binary" && !props.content.mimeType) return "binary"
if (state.undecodable) return "binary"
return artifactKind(props.path)
})
const previewable = createMemo(() => {
const value = kind()
return value !== "binary" && previewableKinds.has(value)
})
const meta = createMemo(() => {
const info = state.info
return [
info.width && info.height ? `${info.width} × ${info.height}` : undefined,
info.duration ? formatDuration(info.duration) : undefined,
info.rows !== undefined ? language.plural("file.view.table.rows", Math.max(0, info.rows - 1)) : undefined,
info.columns !== undefined ? language.plural("file.view.table.columns", info.columns) : undefined,
formatBytes(language.intl(), contentBytes(props.content)),
].filter((item): item is string => !!item)
})
const media = { onInfo: (info: ArtifactInfo) => setState("info", info), onError: () => setState("undecodable", true) }
const rendered = () => (
<ScrollView class="min-h-0 flex-1">
<Show
when={kind() === "markdown"}
fallback={<ArtifactMermaid text={props.content.content} cacheKey={props.cacheKey} />}
>
<ArtifactMarkdown path={props.path} text={props.content.content} cacheKey={props.cacheKey} />
</Show>
</ScrollView>
)
return (
<>
<ArtifactToolbar
mode={state.mode}
onModeChange={previewable() ? (mode) => setState("mode", mode) : undefined}
meta={meta()}
actions={
<Show when={kind() === "html"}>
<OpenInBrowserButton path={props.path} />
</Show>
}
/>
<Show when={!previewable() || state.mode === "preview"} fallback={props.source}>
<Switch>
<Match when={kind() === "image" || kind() === "svg"}>
<ArtifactImage path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "video"}>
<ArtifactVideo path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "audio"}>
<ArtifactAudio path={props.path} content={props.content} {...media} />
</Match>
<Match when={kind() === "pdf" || kind() === "html"}>
<ArtifactFrame path={props.path} content={props.content} kind={kind() === "pdf" ? "pdf" : "html"} />
</Match>
<Match when={kind() === "font"}>
<ArtifactFont path={props.path} content={props.content} />
</Match>
<Match when={kind() === "table"}>
<ArtifactTable path={props.path} text={props.content.content} onInfo={media.onInfo} />
</Match>
<Match when={kind() === "markdown" || kind() === "mermaid"}>{rendered()}</Match>
<Match when={kind() === "binary"}>
<ArtifactBinary path={props.path} size={formatBytes(language.intl(), contentBytes(props.content))} />
</Match>
</Switch>
</Show>
</>
)
}
function formatBytes(locale: string, bytes: number) {
const units = ["byte", "kilobyte", "megabyte", "gigabyte"] as const
const index = Math.min(units.length - 1, bytes > 0 ? Math.floor(Math.log10(bytes) / 3) : 0)
const value = bytes / 1000 ** index
return new Intl.NumberFormat(locale, {
style: "unit",
unit: units[index],
// "short" bytes render as the singular "byte"; the long form pluralizes correctly.
unitDisplay: index === 0 ? "long" : "short",
maximumFractionDigits: value >= 100 || index === 0 ? 0 : 1,
}).format(value)
}
function formatDuration(seconds: number) {
const total = Math.round(seconds)
const minutes = Math.floor(total / 60)
return `${minutes}:${String(total % 60).padStart(2, "0")}`
}
function ArtifactToolbar(props: {
mode?: ArtifactMode
onModeChange?: (mode: ArtifactMode) => void
meta: string[]
actions?: JSX.Element
}) {
const language = useLanguage()
return (
<div data-slot="artifact-toolbar" class="flex h-10 shrink-0 items-center gap-3 px-4">
<Show when={props.onModeChange}>
<SegmentedControl
value={props.mode ?? "preview"}
onChange={(value) => {
if (value === "preview" || value === "source") props.onModeChange?.(value)
}}
>
<SegmentedControlItem value="preview">{language.t("file.view.preview")}</SegmentedControlItem>
<SegmentedControlItem value="source">{language.t("file.view.source")}</SegmentedControlItem>
</SegmentedControl>
</Show>
<div class="ms-auto flex min-w-0 items-center gap-3">
<div class="flex min-w-0 items-center gap-2 text-12-regular text-text-weak">
<For each={props.meta}>
{(item, index) => (
<>
<Show when={index() > 0}>
<span aria-hidden class="text-text-faint">
·
</span>
</Show>
<span class="truncate tabular-nums">{item}</span>
</>
)}
</For>
</div>
{props.actions}
</div>
</div>
)
}
function OpenInBrowserButton(props: { path: string }) {
const language = useLanguage()
const artifacts = useArtifactOpener()
return (
<Show when={artifacts.canOpenInBrowser(props.path)}>
<Button size="small" variant="ghost" icon="globe" onClick={() => artifacts.openInBrowser(props.path)}>
{language.t("file.view.openInBrowser")}
</Button>
</Show>
)
}
function createBlobUrl(content: () => FileContent) {
return createMemo(() => {
const value = blobUrlFromContent(content())
onCleanup(() => URL.revokeObjectURL(value))
return value
})
}
/** Images and SVG previews: fit the pane, click to inspect at 1:1 when the image is larger. */
function ArtifactImage(props: MediaProps) {
const url = createBlobUrl(() => props.content)
const [state, setState] = createStore({ zoom: "fit" as "fit" | "actual", overflow: false, width: 0, height: 0 })
let stage: HTMLDivElement | undefined
const measure = () => {
if (!stage) return
setState("overflow", state.width > stage.clientWidth - 48 || state.height > stage.clientHeight - 48)
}
createResizeObserver(
() => stage,
() => measure(),
)
createEffect(() => {
url()
setState({ zoom: "fit", overflow: false })
})
return (
<div
ref={stage}
data-slot="artifact-stage"
data-checker
data-zoom={state.zoom}
data-overflow={state.overflow || undefined}
class="relative min-h-0 flex-1 overflow-auto"
>
<div
classList={{
"absolute inset-0 flex items-center justify-center p-6": state.zoom === "fit",
"flex min-h-full min-w-full w-max items-center justify-center p-6": state.zoom === "actual",
}}
>
<img
data-slot="artifact-media"
src={url()}
alt={getFilename(props.path)}
draggable={false}
onError={() => props.onError()}
onLoad={(event) => {
const image = event.currentTarget
setState({ width: image.naturalWidth, height: image.naturalHeight })
props.onInfo({ width: image.naturalWidth, height: image.naturalHeight })
measure()
}}
onClick={() => {
if (!state.overflow && state.zoom === "fit") return
setState("zoom", state.zoom === "fit" ? "actual" : "fit")
}}
/>
</div>
</div>
)
}
function ArtifactVideo(props: MediaProps) {
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" data-zoom="fit" class="relative min-h-0 flex-1 overflow-hidden">
<div class="absolute inset-0 flex items-center justify-center p-6">
<video
data-slot="artifact-media"
class="w-full bg-black"
controls
preload="metadata"
playsinline
onError={() => props.onError()}
src={url()}
onLoadedMetadata={(event) => {
const video = event.currentTarget
props.onInfo({ width: video.videoWidth, height: video.videoHeight, duration: video.duration })
}}
/>
</div>
</div>
)
}
function ArtifactAudio(props: MediaProps) {
const url = createBlobUrl(() => props.content)
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1 overflow-auto">
<div class="absolute inset-0 flex items-center justify-center p-6">
<div class="flex w-full max-w-lg flex-col items-center gap-5 rounded-xl border border-v2-border-border-muted bg-v2-background-bg-base px-8 py-8 shadow-[var(--v2-elevation-raised)]">
<div class="flex size-14 items-center justify-center rounded-full bg-v2-background-bg-layer-02">
<FileIcon node={{ path: props.path, type: "file" }} class="size-7" />
</div>
<div class="max-w-full truncate text-14-medium text-text-strong">{getFilename(props.path)}</div>
<audio
class="w-full"
onError={() => props.onError()}
controls
preload="metadata"
src={url()}
onLoadedMetadata={(event) => props.onInfo({ duration: event.currentTarget.duration })}
/>
</div>
</div>
</div>
)
}
function ArtifactFrame(props: { path: string; content: FileContent; kind: "pdf" | "html" }) {
const url = createBlobUrl(() => props.content)
// PDF Open Parameters: start with the thumbnail pane closed and the page fitted to the pane width.
const src = () => (props.kind === "pdf" ? `${url()}#navpanes=0&view=FitH` : url())
return (
<iframe
class="block h-full w-full flex-1 border-0 bg-white"
title={getFilename(props.path)}
src={src()}
// The PDF viewer is Chromium's own and does not run in a sandboxed frame. HTML runs as an
// opaque origin: no app storage, cookies, or credentialed requests reach it.
sandbox={props.kind === "html" ? "allow-scripts allow-popups allow-forms allow-modals" : undefined}
referrerPolicy="no-referrer"
/>
)
}
function ArtifactMarkdown(props: { path: string; text: string; cacheKey?: string }) {
const parent = useMarkdown()
const artifacts = useArtifactOpener()
// getDirectory yields "/" for a root-level file, which would make relative links absolute.
const dir = createMemo(() => (props.path.includes("/") || props.path.includes("\\") ? getDirectory(props.path) : ""))
// Absolute references bypass the file's directory; relative ones resolve against it.
const resolve = (href: string) => (/^([a-z]:)?\//i.test(href) ? href : (resolveArtifactPath(dir(), href) ?? href))
return (
<MarkdownProvider
readImage={(src, signal) => parent?.readImage?.(resolve(src), signal) ?? Promise.resolve(undefined)}
openLocalFile={(href) => artifacts.open(href, dir())}
>
<div class="mx-auto w-full max-w-3xl px-8 py-6">
<Markdown text={props.text} cacheKey={props.cacheKey} class="select-text" />
</div>
</MarkdownProvider>
)
}
/** Mermaid sources render through the same fenced-block pipeline the timeline uses. */
function ArtifactMermaid(props: { text: string; cacheKey?: string }) {
return (
<div class="mx-auto w-full max-w-4xl px-8 py-6">
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} class="select-text" />
</div>
)
}
function ArtifactTable(props: { path: string; text: string; onInfo: (info: ArtifactInfo) => void }) {
const language = useLanguage()
const parsed = createMemo(() => parseDelimited(props.text, props.path.toLowerCase().endsWith(".tsv") ? "\t" : ","))
createEffect(() => props.onInfo({ rows: parsed().total, columns: parsed().columns }))
// Pad the header to the widest row so no data column is dropped.
const header = () => Array.from({ length: parsed().columns }, (_, index) => parsed().rows[0]?.[index] ?? "")
const body = () => parsed().rows.slice(1)
return (
<div class="min-h-0 flex-1 overflow-auto">
<table data-slot="artifact-table" class="min-w-full text-13-regular text-text-base">
<thead>
<tr>
<th data-index />
<For each={header()}>{(cell) => <th>{cell}</th>}</For>
</tr>
</thead>
<tbody>
<For each={body()}>
{(row, index) => (
<tr>
<td data-index>{index() + 1}</td>
<For each={header()}>{(_, column) => <td>{row[column()] ?? ""}</td>}</For>
</tr>
)}
</For>
</tbody>
</table>
<Show when={parsed().total > parsed().rows.length}>
<div class="px-4 py-3 text-12-regular text-text-weak">
{language.t("file.view.table.truncated", { shown: parsed().rows.length - 1, total: parsed().total - 1 })}
</div>
</Show>
</div>
)
}
const specimenSizes = [12, 16, 24, 40, 64]
function ArtifactFont(props: { path: string; content: FileContent }) {
const language = useLanguage()
const url = createBlobUrl(() => props.content)
const family = createMemo(() => `artifact-${Math.random().toString(36).slice(2)}`)
createEffect(() => {
const face = new FontFace(family(), `url(${url()})`)
document.fonts.add(face)
void face.load().catch(() => undefined)
onCleanup(() => document.fonts.delete(face))
})
return (
<div class="min-h-0 flex-1 overflow-auto">
<div class="mx-auto flex w-full max-w-3xl flex-col gap-6 px-8 py-8" style={{ "font-family": `"${family()}"` }}>
<div class="text-text-strong" style={{ "font-size": "56px", "line-height": "1.1" }}>
{getFilename(props.path).replace(/\.[^.]+$/, "")}
</div>
<div class="break-all text-text-base" style={{ "font-size": "22px", "line-height": "1.4" }}>
ABCDEFGHIJKLMNOPQRSTUVWXYZ
<br />
abcdefghijklmnopqrstuvwxyz
<br />
0123456789 !?&@#%(){}[]
</div>
<div class="flex flex-col gap-3 border-t border-v2-border-border-muted pt-6">
<For each={specimenSizes}>
{(size) => (
<div class="flex items-baseline gap-4">
<span
class="w-8 shrink-0 text-12-regular text-text-faint tabular-nums"
style={{ "font-family": "var(--font-family-mono)" }}
>
{size}
</span>
<span class="text-text-base" style={{ "font-size": `${size}px`, "line-height": "1.25" }}>
{language.t("file.view.fontSample")}
</span>
</div>
)}
</For>
</div>
</div>
</div>
)
}
function ArtifactBinary(props: { path: string; size: string }) {
const language = useLanguage()
return (
<div data-slot="artifact-stage" class="relative min-h-0 flex-1">
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
<FileIcon node={{ path: props.path, type: "file" }} class="size-8 text-text-weak" />
<div class="text-14-medium text-text-strong">{getFilename(props.path)}</div>
<div class="text-13-regular text-text-weak">{language.t("file.view.binary", { size: props.size })}</div>
</div>
</div>
)
}
+39 -30
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -11,8 +11,9 @@ import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
import { Menu } from "@opencode/ui/menu"
import { Tabs } from "@opencode/ui/tabs"
import { ScrollView } from "@opencode/ui/scroll-view"
import { showToast } from "@/shell/notifications/toast"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
import { artifactKind } from "@/workspaces/files/artifact"
import { ArtifactView } from "@/session/files/artifact-view"
import { useComments } from "@/composer/comments"
import { useLanguage } from "@/runtime/i18n/language"
import { useComposerState } from "@/composer/persistence"
@@ -205,6 +206,11 @@ export function SessionFileView(props: SessionFileViewProps) {
})
const contents = createMemo(() => state()?.content?.content ?? "")
const cacheKey = createMemo(() => sampledChecksum(contents()))
// Plain text keeps the code view; every other kind is rendered by ArtifactView.
const artifact = createMemo(() => {
const content = state()?.content
return content?.type === "binary" || artifactKind(path() ?? "") !== "text"
})
const selectedLines = createMemo<SelectedLineRange | null>(() => {
const p = path()
if (!p) return null
@@ -433,39 +439,42 @@ export function SessionFileView(props: SessionFileViewProps) {
}}
search={search}
class="select-text"
media={{
mode: "auto",
path: path(),
current: state()?.content,
onLoad: scrollSync.queueRestore,
onError: (args: { kind: "image" | "audio" | "svg" }) => {
if (args.kind !== "svg") return
showToast({
variant: "error",
title: language.t("toast.file.loadFailed.title"),
})
},
}}
// Media and previews have their own viewers below; the code view only ever shows text.
media={{ mode: "off" }}
/>
</div>
)
// The code view scrolls inside ScrollView so line state and scroll position persist per tab.
const codeView = (source: string) => (
<ScrollView class="min-h-0 flex-1" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
{renderFile(source)}
</ScrollView>
)
const content = () => (
<div class="mt-3 relative h-full min-h-0">
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
<Switch>
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}</div>
</Match>
<Match when={state()?.notFound ? state()?.name : undefined}>
{(name) => (
<div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>
)}
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</ScrollView>
<div class="mt-3 relative h-full min-h-0 flex flex-col">
<Switch>
<Match when={state()?.loaded ? state()?.content : undefined}>
{(value) => (
<Show when={artifact()} fallback={codeView(value().content)}>
<ArtifactView
path={path() ?? ""}
content={value()}
cacheKey={cacheKey()}
source={codeView(value().content)}
/>
</Show>
)}
</Match>
<Match when={state()?.loading}>
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}</div>
</Match>
<Match when={state()?.notFound ? state()?.name : undefined}>
{(name) => <div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>}
</Match>
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
</Switch>
</div>
)
@@ -0,0 +1,105 @@
import { createEffect, onCleanup, type ParentProps } from "solid-js"
import { createSimpleContext } from "@opencode/ui/context"
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
import { useBrowserAttachments } from "@/session/browser/attachments"
import type { SessionModel } from "@/session/model"
import { useFile } from "@/workspaces/files/model"
import { artifactKind, resolveArtifactPath } from "@/workspaces/files/artifact"
import { encodeFilePath } from "@/workspaces/files/path"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServer } from "@/runtime/server/current"
import { ServerConnection } from "@/runtime/server/registry"
import { useSessionLayout } from "@/session/session-layout"
import { createOpenSessionFileTab } from "@/session/helpers"
import type { createSessionBrowser } from "@/session/browser/model"
/** Routes local links in timeline markdown to the artifact opener while keeping image loading. */
export function ArtifactMarkdownProvider(props: ParentProps) {
const markdown = useMarkdown()
const artifacts = useArtifactOpener()
return (
<MarkdownProvider readImage={markdown?.readImage} openLocalFile={(path) => artifacts.open(path)}>
{props.children}
</MarkdownProvider>
)
}
/**
* Opens files the agent references as side-panel tabs, inside or outside the workspace, or as
* a browser tab for HTML when the desktop can load the file directly.
*/
export const { use: useArtifactOpener, provider: ArtifactOpenerProvider } = createSimpleContext({
name: "ArtifactOpener",
init: (props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) => {
const file = useFile()
const server = useServer()
const location = useWorkspaceLocation()
const attachments = useBrowserAttachments()
const { tabs, view } = useSessionLayout()
const root = () => location().directory.replaceAll("\\", "/").replace(/\/+$/, "")
/**
* Turn a link into a path `useFile` can load: workspace-relative when it is under the root,
* otherwise absolute. Relative links resolve against `base`; ones that climb past the root
* become absolute too, so a `../../shared/report.pdf` still opens.
*/
const resolve = (href: string, base?: string) => {
// Agents cite locations as path:line or path:line:col; the file is what opens.
const value = href.replaceAll("\\", "/").replace(/:\d+(?::\d+)?$/, "")
if (/^[a-z]:\//i.test(value) || value.startsWith("/")) return file.normalize(value)
const relative = resolveArtifactPath(base ?? "", value)
if (relative !== undefined) return file.normalize(relative)
// Climbing past the workspace root: resolve from the referencing folder's absolute location.
const dir = base ? `${root()}/${base.replace(/\/+$/, "")}` : root()
return file.normalize(resolveArtifactPath(dir, value) ?? value)
}
const showTab = createOpenSessionFileTab({
normalizeTab: (tab) => tab,
openTab: (tab) => tabs().open(tab),
pathFromTab: file.pathFromTab,
loadFile: () => undefined,
openReviewPanel: () => {
if (!view().reviewPanel.opened()) view().reviewPanel.open()
},
setActive: (tab) => tabs().setActive(tab),
})
// Inline paths are guessed from text, so confirm the file exists before a tab appears for it.
const openTab = (path: string) => {
void file.load(path).then(() => {
if (file.get(path)?.loaded) showTab(file.tab(path))
})
}
// The desktop's own sidecar shares this disk, and its browser pane accepts file:// URLs inside the
// session workspace only. Forwarded loopback servers do not qualify, matching the desktop policy.
const canOpenInBrowser = (path?: string) =>
ServerConnection.builtin(server.conn) &&
props.browser.available() &&
props.browser.attached() &&
(path === undefined || !file.absolute(path))
const openInBrowser = (path: string) => {
props.browser.command({ type: "tabs.open", url: `file://${encodeFilePath(`${root()}/${path}`)}` })
}
/** Open `href` as referenced from `base` (a workspace-relative directory, "" for the root). */
const open = (href: string, base?: string) => {
const path = resolve(href, base)
if (!path) return
if (artifactKind(path) === "html" && canOpenInBrowser(path)) return openInBrowser(path)
openTab(path)
}
// The agent's browser.preview tool arrives through the desktop browser pane attachment.
createEffect(() => {
const sessionID = props.session.identity.sessionID()
if (!sessionID) return
onCleanup(attachments.onPreview(server, sessionID, (path) => open(path)))
})
return { canOpenInBrowser, openInBrowser, open }
},
})
@@ -173,7 +173,9 @@ export function SessionFileBrowserTab(props: {
<SessionFilePanelV2Empty>
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
<Icon name="file-tree" size="large" class="mb-2" />
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
<div class="text-[13px] font-medium leading-[13px] text-text-strong">
{language.t("command.file.open")}
</div>
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
</div>
</SessionFilePanelV2Empty>
+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>
+1
View File
@@ -137,6 +137,7 @@ export function useSessionModel() {
tabs: layout.tabs,
view: layout.view,
tabKey: layout.tabKey,
sessionKey: layout.sessionKey,
},
ownership: createSessionOwnership(layout.sessionKey),
tabs: {
+3
View File
@@ -155,6 +155,9 @@ export function createSessionReview(input: {
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
const project = input.session.project()
if (project && !project.vcs) return true
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
}
+2 -2
View File
@@ -249,7 +249,7 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
const noGit = () => props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
@@ -282,7 +282,7 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
const noGit = () => props.review.noGit()
return (
<Switch>
<Match when={loading()}>
+15 -2
View File
@@ -38,6 +38,7 @@ import { SessionReviewToggle } from "./header/session-header-actions"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { createSessionBrowser } from "./browser/model"
import { createTimelineCache } from "./timeline/cache"
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
const SessionMobileFiles = lazy(async () => {
const { SessionMobileFiles } = await import("./files/session-mobile-files")
@@ -50,14 +51,26 @@ const SessionSummaryPanel = lazy(async () => {
})
export function SessionScreen(props: { session: SessionModel }) {
// The timeline cache captures its owner when created, so link handling must be provided above it.
const browser = createSessionBrowser(props.session)
return (
<ArtifactOpenerProvider session={props.session} browser={browser}>
<ArtifactMarkdownProvider>
<SessionScreenContent session={props.session} browser={browser} />
</ArtifactMarkdownProvider>
</ArtifactOpenerProvider>
)
}
function SessionScreenContent(props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) {
const session = props.session
const browser = props.browser
const server = useServer()
const detailsProject = createMemo(() => {
const info = session.data.info()
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
})
const isDesktop = session.isDesktop
const browser = createSessionBrowser(session)
const screen = createSessionScreenLayout(session)
const timeline = createSessionTimelineInteraction(session)
const timelineSearch = createTimelineSearchController({
@@ -325,7 +338,7 @@ export function SessionScreen(props: { session: SessionModel }) {
</div>
<Show when={composer.active()} keyed>
{(model) => <ActiveSessionComposerRegion model={model} />}
{(model) => <ActiveSessionComposerRegion model={model} suggestionBoundary={timeline.scroller} />}
</Show>
</>
)
-4
View File
@@ -1102,10 +1102,6 @@
padding-block: 20px;
}
.settings-server-connection .settings-servers-lead {
gap: 4px;
}
.settings-servers-row:not(:last-child) {
padding-bottom: 16px;
margin-bottom: 16px;
+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")}
>
@@ -0,0 +1,118 @@
import { describe, expect, test } from "bun:test"
import {
artifactKind,
bytesToBase64,
contentBytes,
fileContentFromBytes,
MAX_MEDIA_BYTES,
parseDelimited,
resolveArtifactPath,
} from "./artifact"
describe("artifactKind", () => {
test.each([
["shot.PNG", "image"],
["logo.svg", "svg"],
["song.mp3", "audio"],
["demo.mp4", "video"],
["clip.webm", "video"],
["paper.pdf", "pdf"],
["out/index.html", "html"],
["README.md", "markdown"],
["flow.mmd", "mermaid"],
["data.csv", "table"],
["data.tsv", "table"],
["Inter.woff2", "font"],
["src/app.ts", "text"],
["Makefile", "text"],
[".env", "text"],
["archive.tar.gz", "text"],
] as const)("classifies %s as %s", (path, kind) => {
expect(artifactKind(path)).toBe(kind)
})
})
describe("fileContentFromBytes", () => {
test("keeps media as base64 with a mime type", () => {
const content = fileContentFromBytes("a.png", new Uint8Array([137, 80, 78, 71]))
expect(content).toEqual({ type: "binary", content: "iVBORw==", encoding: "base64", mimeType: "image/png" })
})
test("decodes text and svg with a mime type", () => {
expect(fileContentFromBytes("a.svg", new TextEncoder().encode("<svg/>"))).toEqual({
type: "text",
content: "<svg/>",
mimeType: "image/svg+xml",
})
expect(fileContentFromBytes("a.ts", new TextEncoder().encode("const a = 1"))).toEqual({
type: "text",
content: "const a = 1",
mimeType: undefined,
})
})
test("keeps only the size of media above the cap", () => {
const content = fileContentFromBytes("big.mp4", new Uint8Array(MAX_MEDIA_BYTES + 1))
expect(content).toEqual({ type: "binary", content: "", size: MAX_MEDIA_BYTES + 1 })
})
test("marks unknown binaries without keeping bytes", () => {
expect(fileContentFromBytes("a.bin", new Uint8Array([1, 0, 2]))).toEqual({ type: "binary", content: "", size: 3 })
})
test("encodes large buffers in chunks", () => {
const bytes = new Uint8Array(70_000).fill(65)
expect(bytesToBase64(bytes)).toBe(Buffer.from(bytes).toString("base64"))
})
})
describe("contentBytes", () => {
test("recovers byte counts from base64 and text", () => {
expect(contentBytes({ type: "binary", content: "iVBORw==", encoding: "base64" })).toBe(4)
expect(contentBytes({ type: "binary", content: "iVBORwA=", encoding: "base64" })).toBe(5)
expect(contentBytes({ type: "text", content: "héllo" })).toBe(6)
})
})
describe("parseDelimited", () => {
test("handles quotes, embedded delimiters, newlines, and CRLF", () => {
const parsed = parseDelimited('name,note\r\n"Smith, J","says ""hi""\nand more"\nplain,\n', ",")
expect(parsed.rows).toEqual([
["name", "note"],
["Smith, J", 'says "hi"\nand more'],
["plain", ""],
])
expect(parsed.total).toBe(3)
expect(parsed.columns).toBe(2)
})
test("counts rows past the limit without keeping them", () => {
const parsed = parseDelimited("a\tb\n1\t2\n3\t4\n5\t6", "\t", 2)
expect(parsed.rows).toHaveLength(2)
expect(parsed.total).toBe(4)
})
})
describe("resolveArtifactPath", () => {
test.each([
["docs", "guide.md", "docs/guide.md"],
["docs", "./img/a.png", "docs/img/a.png"],
["docs/api", "../index.md", "docs/index.md"],
["", "src/app.ts", "src/app.ts"],
["docs", "sub\\win.md", "docs/sub/win.md"],
["", "docs/guide.md", "docs/guide.md"],
["/tmp/notes/", "../out/a.pdf", "/tmp/out/a.pdf"],
["C:/tmp/notes/", "img.png", "C:/tmp/notes/img.png"],
["/repo", "../shared/report.pdf", "/shared/report.pdf"],
])("resolves %s + %s", (base, href, expected) => {
expect(resolveArtifactPath(base, href)).toBe(expected)
})
test.each([
["docs", "../../etc/passwd"],
["", "../x"],
["docs", "/abs/path"],
])("rejects %s + %s", (base, href) => {
expect(resolveArtifactPath(base, href)).toBeUndefined()
})
})
@@ -0,0 +1,211 @@
import type { FileContent } from "@/runtime/server/types"
export type ArtifactKind =
| "image"
| "svg"
| "audio"
| "video"
| "pdf"
| "html"
| "markdown"
| "mermaid"
| "table"
| "font"
| "text"
const mimes = new Map([
["png", "image/png"],
["jpg", "image/jpeg"],
["jpeg", "image/jpeg"],
["gif", "image/gif"],
["webp", "image/webp"],
["avif", "image/avif"],
["bmp", "image/bmp"],
["ico", "image/x-icon"],
["tif", "image/tiff"],
["tiff", "image/tiff"],
["heic", "image/heic"],
["svg", "image/svg+xml"],
["mp3", "audio/mpeg"],
["wav", "audio/wav"],
["ogg", "audio/ogg"],
["oga", "audio/ogg"],
["m4a", "audio/mp4"],
["aac", "audio/aac"],
["flac", "audio/flac"],
["opus", "audio/ogg"],
["weba", "audio/webm"],
["mp4", "video/mp4"],
["m4v", "video/mp4"],
["webm", "video/webm"],
["mov", "video/quicktime"],
["ogv", "video/ogg"],
["mkv", "video/x-matroska"],
["pdf", "application/pdf"],
["html", "text/html"],
["htm", "text/html"],
["md", "text/markdown"],
["markdown", "text/markdown"],
["mdx", "text/markdown"],
["mmd", "text/vnd.mermaid"],
["mermaid", "text/vnd.mermaid"],
["csv", "text/csv"],
["tsv", "text/tab-separated-values"],
["ttf", "font/ttf"],
["otf", "font/otf"],
["woff", "font/woff"],
["woff2", "font/woff2"],
])
export function artifactExtension(path: string) {
const name = path.split(/[\\/]/).pop() ?? ""
const index = name.lastIndexOf(".")
if (index <= 0) return ""
return name.slice(index + 1).toLowerCase()
}
export function artifactMime(path: string) {
return mimes.get(artifactExtension(path))
}
export function artifactKind(path: string): ArtifactKind {
const mime = artifactMime(path)
if (!mime) return "text"
if (mime === "image/svg+xml") return "svg"
if (mime === "application/pdf") return "pdf"
if (mime === "text/html") return "html"
if (mime === "text/markdown") return "markdown"
if (mime === "text/vnd.mermaid") return "mermaid"
if (mime === "text/csv" || mime === "text/tab-separated-values") return "table"
if (mime.startsWith("image/")) return "image"
if (mime.startsWith("audio/")) return "audio"
if (mime.startsWith("font/")) return "font"
return "video"
}
/** Kinds whose bytes are kept as base64 so media elements can play them without a text round trip. */
const binaryKinds = new Set<ArtifactKind>(["image", "audio", "video", "pdf", "font"])
/** Text files never contain NUL; a NUL in the first 8 KiB marks an unknown binary. */
function isBinaryBytes(bytes: Uint8Array) {
return bytes.subarray(0, 8192).includes(0)
}
export function bytesToBase64(bytes: Uint8Array) {
const parts: string[] = []
for (let index = 0; index < bytes.length; index += 0x8000) {
parts.push(String.fromCharCode(...bytes.subarray(index, index + 0x8000)))
}
return btoa(parts.join(""))
}
/** Media above this stays a placeholder: base64 encoding on the main thread and the LRU budget both suffer. */
export const MAX_MEDIA_BYTES = 25 * 1024 * 1024
export function fileContentFromBytes(path: string, bytes: Uint8Array): FileContent {
const kind = artifactKind(path)
const mimeType = artifactMime(path)
if (binaryKinds.has(kind)) {
if (bytes.length > MAX_MEDIA_BYTES) return { type: "binary", content: "", size: bytes.length }
return { type: "binary", content: bytesToBase64(bytes), encoding: "base64", mimeType }
}
// Unknown binaries keep no bytes: the viewer only shows a placeholder for them.
if (kind === "text" && isBinaryBytes(bytes)) return { type: "binary", content: "", size: bytes.length }
return { type: "text", content: new TextDecoder().decode(bytes), mimeType }
}
/** Approximate on-disk size of loaded content. */
export function contentBytes(content: FileContent) {
if (content.size !== undefined) return content.size
if (content.encoding === "base64") {
const padding = content.content.endsWith("==") ? 2 : content.content.endsWith("=") ? 1 : 0
return Math.floor((content.content.length * 3) / 4) - padding
}
return new TextEncoder().encode(content.content).length
}
/**
* Parse RFC 4180 style delimited text. Quoted fields may contain the delimiter, newlines, and
* doubled quotes. Rows beyond `limit` are counted but not returned.
*/
export function parseDelimited(text: string, delimiter: string, limit = 1000) {
const rows: string[][] = []
let row: string[] = []
let field = ""
let quoted = false
let total = 0
const endRow = () => {
row.push(field)
field = ""
const blank = row.length === 1 && row[0] === ""
if (!blank) {
total++
if (rows.length < limit) rows.push(row)
}
row = []
}
for (let index = 0; index < text.length; index++) {
const char = text[index]!
if (quoted) {
if (char !== '"') {
field += char
continue
}
if (text[index + 1] === '"') {
field += '"'
index++
continue
}
quoted = false
continue
}
if (char === '"' && field === "") {
quoted = true
continue
}
if (char === delimiter) {
row.push(field)
field = ""
continue
}
if (char === "\r") continue
if (char === "\n") {
endRow()
continue
}
field += char
}
if (field !== "" || row.length > 0) endRow()
const columns = rows.reduce((max, current) => Math.max(max, current.length), 0)
return { rows, total, columns }
}
/** Build a blob URL from loaded content. Callers revoke it when the viewer unmounts. */
export function blobUrlFromContent(content: FileContent) {
const type = content.mimeType ?? "application/octet-stream"
if (content.encoding !== "base64") return URL.createObjectURL(new Blob([content.content], { type }))
const raw = atob(content.content)
const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0))
return URL.createObjectURL(new Blob([bytes], { type }))
}
/**
* Resolve a relative link against a directory. A relative base yields a workspace-relative path and
* an absolute base an absolute one; undefined when the link climbs past the base's root.
*/
export function resolveArtifactPath(base: string, href: string) {
const target = href.replaceAll("\\", "/")
if (target.startsWith("/")) return undefined
const dir = base.replaceAll("\\", "/")
const segments = [...dir.split("/").filter(Boolean)]
for (const segment of target.split("/")) {
if (!segment || segment === ".") continue
if (segment !== "..") {
segments.push(segment)
continue
}
if (segments.length === 0) return undefined
segments.pop()
}
return `${dir.startsWith("/") ? "/" : ""}${segments.join("/")}`
}
+10 -5
View File
@@ -5,11 +5,12 @@ import { createSimpleContext } from "@opencode/ui/context"
import { showToast } from "@/shell/notifications/toast"
import { useParams } from "@solidjs/router"
import { base64Encode } from "@opencode/util/encode"
import { getFilename } from "@opencode/util/path"
import { getDirectory, getFilename } from "@opencode/util/path"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useLanguage } from "@/runtime/i18n/language"
import { useLayout } from "@/shell/state/layout"
import { createPathHelpers } from "./path"
import { fileContentFromBytes } from "./artifact"
import {
approxBytes,
evictContentLru,
@@ -185,14 +186,17 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
setLoading(file)
// Files outside the workspace are read from their own directory, like markdown images.
// The trailing separator from getDirectory keeps "/" and "C:/" valid, like readLocalImage.
const request = path.absolute(file)
? { path: getFilename(file), location: { directory: getDirectory(file) } }
: { path: file, location: { directory } }
const promise = serverSDK.api.file
.read({ path: file, location: { directory } })
.read(request)
.then((data) => {
if (scope() !== directory) return
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
const content = fileContentFromBytes(file, data)
setLoaded(file, content)
if (!content) return
touchFileContent(file, approxBytes(content))
evictContent(new Set([file]))
})
@@ -281,6 +285,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
return {
ready: () => view().ready(),
normalize: path.normalize,
absolute: path.absolute,
tab: path.tab,
pathFromTab: path.pathFromTab,
tree: {
@@ -21,6 +21,24 @@ describe("file path helpers", () => {
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
})
test("keeps files outside the workspace absolute and round-trips them through tabs", () => {
const posix = createPathHelpers(() => "/repo")
expect(posix.normalize("/tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
expect(posix.absolute("/tmp/out/report.pdf")).toBe(true)
expect(posix.absolute("src/app.ts")).toBe(false)
expect(posix.tab("/tmp/out/report.pdf")).toBe("file:///tmp/out/report.pdf")
expect(posix.pathFromTab("file:///tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
expect(posix.normalize("/repository/x.ts")).toBe("/repository/x.ts")
const windows = createPathHelpers(() => "C:\\repo")
expect(windows.normalize("C:\\tmp\\font.ttf")).toBe("C:\\tmp\\font.ttf")
expect(windows.normalize("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
expect(windows.absolute("C:/tmp/font.ttf")).toBe(true)
expect(windows.tab("C:/tmp/font.ttf")).toBe("file:///C:/tmp/font.ttf")
expect(windows.pathFromTab("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
expect(windows.pathFromTab("file:///C:/repo/src/app.ts")).toBe("src/app.ts")
})
test("normalizes Windows directory separators", () => {
const path = createPathHelpers(() => "C:\\repo")
expect(path.normalizeDir("frontend\\")).toBe("frontend")
+12 -6
View File
@@ -105,7 +105,11 @@ export function createPathHelpers(scope: () => string) {
const normalize = (input: string) => {
const root = scope()
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
// file:///C:/dir becomes /C:/dir once the protocol is gone; restore the drive form.
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(
/^[/\\]([A-Za-z]:)/,
"$1",
)
// Separator-agnostic prefix stripping for Cygwin/native Windows compatibility
// Only case-insensitive on Windows (drive letter or UNC paths)
@@ -116,20 +120,21 @@ export function createPathHelpers(scope: () => string) {
canonPath.startsWith(canonRoot) &&
(canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/")
) {
// Slice from original path to preserve native separators
path = path.slice(root.length)
// Slice from original path to preserve native separators, then drop the separator itself.
path = path.slice(root.length).replace(/^[/\\]/, "")
}
if (path.startsWith("./") || path.startsWith(".\\")) {
path = path.slice(2)
}
if (path.startsWith("/") || path.startsWith("\\")) {
path = path.slice(1)
}
// An absolute path that is not under the root stays absolute; it is a file outside the workspace.
return path
}
/** Whether a normalized path points outside the workspace root. */
const absolute = (path: string) => /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("/") || path.startsWith("\\\\")
const tab = (input: string) => {
const path = normalize(input)
return `file://${encodeFilePath(path)}`
@@ -149,6 +154,7 @@ export function createPathHelpers(scope: () => string) {
return {
normalize,
absolute,
tab,
pathFromTab,
normalizeDir,
+21 -1
View File
@@ -1,4 +1,5 @@
import { readFileSync } from "node:fs"
import { createRequire } from "node:module"
import solidPlugin from "vite-plugin-solid"
import tailwindcss from "@tailwindcss/vite"
import { fileURLToPath } from "url"
@@ -17,6 +18,24 @@ if (tailwindGenerate && typeof tailwindHotUpdate === "function") {
}
}
// The markdown worker imports these directly, so they are served unbundled to keep worker startup
// stable. Vite applies `exclude` to every import inside a pre-bundle too, which would leave a bare
// `import "marked"` in mermaid's chunk that the browser cannot resolve from this package.
const workerDeps = ["@shikijs/stream", "marked", "marked-shiki", "remend"]
/** @type {import("rolldown").Plugin} */
const bundleNestedWorkerDeps = {
name: "opencode-desktop:bundle-nested-worker-deps",
resolveId(id, importer) {
if (!importer || !workerDeps.includes(id) || !importer.includes("node_modules")) return
try {
return createRequire(importer).resolve(id)
} catch {
return
}
},
}
export const channel = (() => {
const raw = process.env.OPENCODE_CHANNEL
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
@@ -44,8 +63,9 @@ export default [
format: "es",
},
optimizeDeps: {
exclude: ["@shikijs/stream", "marked", "marked-shiki", "remend"],
exclude: workerDeps,
include: ["@opencode/session-ui > mermaid", "@opencode/session-ui > mermaid > katex"],
rolldownOptions: { plugins: [bundleNestedWorkerDeps] },
},
}
},
+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.12",
"type": "module",
"license": "MIT",
"bin": {
+68 -25
View File
@@ -5,34 +5,77 @@ import { Runtime } from "../../framework/runtime"
import { Updater } from "../../services/updater"
import { handlePromptErrors } from "../../ui/prompt"
import { OPENCODE_VERSION } from "../../version"
import { stripVTControlCharacters } from "node:util"
export default Runtime.handler(
Commands.commands.upgrade,
Effect.fn("cli.upgrade")(function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
Effect.fn("cli.upgrade")(
function* (input) {
intro("Upgrade")
const updater = yield* Updater.Service
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
if (!method)
return yield* Effect.fail(
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
log.info(`Using method: ${method}`)
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
const version = target.trim().replace(/^v/, "")
if (version === OPENCODE_VERSION) {
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
outro("Done")
return
}
log.info(`From ${OPENCODE_VERSION}${version}`)
const progress = spinner()
progress.start("Upgrading...")
yield* updater.upgrade(method, target).pipe(
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
)
outro("Done")
}, handlePromptErrors),
},
(effect) =>
handlePromptErrors(
effect.pipe(
Effect.mapError((error) =>
error instanceof Updater.UpgradeError ? new Error(formatUpgradeError(error), { cause: error }) : error,
),
),
),
),
)
function formatUpgradeError(error: Updater.UpgradeError) {
const clean = (value: string) => stripVTControlCharacters(value).replaceAll("\r", "").trim()
const line = (value: string) => clean(value).replace(/\s+/g, " ")
const detail = wrap(clean(error.detail), Math.max(24, (process.stdout.columns ?? 80) - 7)).join("\n")
return [
line(error.title),
"",
detail,
...(error.command ? ["", field("Command", line(error.command))] : []),
field("Retry", line(error.retry)),
].join("\n")
}
function field(label: string, value: string) {
const prefix = label.padEnd(9)
const width = Math.max(24, Math.min(61, (process.stdout.columns ?? 80) - prefix.length - 10))
const lines = wrap(value, width)
return lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`).join("\n")
}
function wrap(value: string, width: number) {
return value.split("\n").flatMap((source) => {
if (source.length <= width) return [source]
return source.split(/\s+/).reduce<string[]>((result, word) => {
const index = result.length - 1
if (index < 0 || result[index].length + word.length + 1 > width) result.push(word)
else result[index] += ` ${word}`
return result
}, [])
})
}
+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 }),
)
+187 -27
View File
@@ -5,7 +5,9 @@ import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } fro
import { ChildProcess } from "effect/unstable/process"
import { parse, type ParseError } from "jsonc-parser"
import path from "node:path"
import { stripVTControlCharacters } from "node:util"
import { action, parseReleaseVersion, type Policy } from "./updater-action"
import { errorMessage } from "../util/error"
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
@@ -13,10 +15,82 @@ export type Method = (typeof methods)[number]
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
export class UpgradeError extends Error {
readonly title: string
readonly detail: string
readonly command?: string
readonly retry: string
constructor(
input: {
readonly title: string
readonly detail: string
readonly command?: string
readonly retry: string
},
options?: ErrorOptions,
) {
super(input.detail, options)
this.name = "UpgradeError"
this.title = input.title
this.detail = input.detail
this.command = input.command
this.retry = input.retry
}
}
const decodeVpPackages = Schema.decodeUnknownOption(
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
)
const installNames: Record<Method, string> = {
curl: "The OpenCode installer",
npm: "npm",
pnpm: "pnpm",
bun: "Bun",
yarn: "Yarn",
vp: "Vite+",
brew: "Homebrew",
}
function conciseDetail(input: string) {
const lines = stripVTControlCharacters(input)
.trim()
.replaceAll("\r", "\n")
.split("\n")
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0)
const tail = lines.slice(-12).join("\n")
const clipped = tail.length > 2_000
const detail = clipped ? `${tail.slice(-1_999)}` : tail
if (!detail) return
if (lines.length <= 12 && !clipped) return detail
return `${detail}\n\nOutput shortened to the last 12 lines.`
}
function errorDetail(cause: unknown): string {
if (cause instanceof AppProcess.AppProcessError) {
const stderr = conciseDetail(cause.stderr ?? "")
if (stderr) return stderr
if (cause.cause !== undefined) return errorDetail(cause.cause)
return cause.message
}
if (cause instanceof Error) {
const detail = cause.cause === undefined ? undefined : errorDetail(cause.cause)
if (!detail || detail === cause.message) return cause.message
return `${cause.message}: ${detail}`
}
return errorMessage(cause)
}
function resultDetail(result: { code: number; stdout: string; stderr: string }) {
return (
conciseDetail(result.stderr) ??
conciseDetail(result.stdout) ??
`The command exited with code ${result.code} without any error output.`
)
}
export interface Interface {
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
@@ -24,9 +98,9 @@ export interface Interface {
readonly method: () => Effect.Effect<Method | undefined>
readonly latest: () => Effect.Effect<string, Error>
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
readonly removal: (method: Method) =>
| { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> }
| undefined
readonly removal: (
method: Method,
) => { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> } | undefined
}
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
@@ -89,7 +163,6 @@ const make = Effect.gen(function* () {
stdout: result.stdout.toString("utf8"),
stderr: result.stderr.toString("utf8"),
})),
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
)
})
@@ -119,7 +192,11 @@ const make = Effect.gen(function* () {
]
const results = yield* Effect.forEach(
checks,
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
(check) =>
exec(check.command).pipe(
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
Effect.map((result) => ({ check, result })),
),
{ concurrency: "unbounded" },
)
return results.find((result) => {
@@ -144,11 +221,7 @@ const make = Effect.gen(function* () {
return {
command,
run: exec(command, "5 minutes").pipe(
Effect.flatMap((result) =>
result.code === 0
? Effect.void
: Effect.fail(new Error(result.stderr.trim() || `Failed to uninstall with ${method}`)),
),
Effect.flatMap((result) => (result.code === 0 ? Effect.void : Effect.fail(new Error(resultDetail(result))))),
),
}
}
@@ -163,14 +236,44 @@ const make = Effect.gen(function* () {
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
},
),
catch: (cause) => new Error("Failed to check for updates", { cause }),
catch: (cause) =>
new UpgradeError(
{
title: "Could not check for OpenCode updates",
detail: errorDetail(cause),
retry: "Check your network, then run opencode upgrade again.",
},
{ cause },
),
})
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
if (!response.ok)
return yield* Effect.fail(
new UpgradeError({
title: "Could not check for OpenCode updates",
detail: `The update service returned HTTP ${response.status}.`,
retry: "Try again in a few minutes.",
}),
)
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
try: () => response.json(),
catch: (cause) => new Error("Failed to read update information", { cause }),
catch: (cause) =>
new UpgradeError(
{
title: "Could not read the OpenCode update information",
detail: errorDetail(cause),
retry: "Try again in a few minutes.",
},
{ cause },
),
})
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
if (!data.metadata?.package)
return yield* Effect.fail(
new UpgradeError({
title: "Could not read the OpenCode update information",
detail: "The update service returned incomplete release information.",
retry: "Try again in a few minutes.",
}),
)
return { package: data.metadata.package, version: data.version }
})
@@ -185,6 +288,40 @@ const make = Effect.gen(function* () {
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
)
const runUpgrade = (input: {
readonly method: Method
readonly command: string[]
readonly displayCommand?: string[]
readonly title?: string
readonly retry?: string
}) => {
const failure = (detail: string, cause?: unknown) =>
new UpgradeError(
{
title: input.title ?? `${installNames[input.method]} could not install OpenCode`,
detail,
command: (input.displayCommand ?? input.command).join(" "),
retry: input.retry ?? "Fix the issue above, then run opencode upgrade again.",
},
cause === undefined ? undefined : { cause },
)
return exec(input.command, "5 minutes").pipe(
Effect.flatMap((result) =>
result.code === 0 ? Effect.succeed(result) : Effect.fail(failure(resultDetail(result))),
),
Effect.mapError((cause) =>
cause instanceof UpgradeError
? cause
: failure(
cause instanceof AppProcess.AppProcessError && cause.stderr === undefined && cause.cause === undefined
? `Failed to update with ${input.method}`
: errorDetail(cause),
cause,
),
),
)
}
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
const version = input.trim().replace(/^v/, "")
@@ -212,31 +349,54 @@ const make = Effect.gen(function* () {
? ["vp", "install", "-g", "--force", target]
: ["vp", "update", "-g", target],
}
const result = yield* Effect.scoped(
yield* Effect.scoped(
Effect.gen(function* () {
if (method === "bun") {
// Bun does not prune old versions from its shared package cache.
yield* fs.makeDirectory(global.cache, { recursive: true })
const cache = yield* temporaryDirectory("update-")
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
return yield* runUpgrade({
method,
command: ["bun", "install", "--global", "--trust", "--cache-dir", cache, target],
displayCommand: ["bun", "install", "--global", "--trust", target],
})
}
if (method === "curl") {
yield* fs.makeDirectory(global.cache, { recursive: true })
const directory = yield* temporaryDirectory("update-")
const installer = path.join(directory, "install")
const download = yield* exec(
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
"5 minutes",
)
if (download.code !== 0) return download
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
yield* runUpgrade({
method,
command: ["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
displayCommand: ["curl", "-fsSL", "https://opencode.ai/v2/install"],
title: "Could not download the OpenCode installer",
retry: "Check your network, then run opencode upgrade again.",
})
return yield* runUpgrade({
method,
command: ["bash", installer, "--version", version, "--no-modify-path"],
displayCommand: ["opencode", "upgrade", version, "--method", "curl"],
title: "The OpenCode installer failed",
})
}
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
return yield* exec(commands[method], "5 minutes")
if (method === "brew") return yield* runUpgrade({ method, command: ["brew", "upgrade", packageName] })
return yield* runUpgrade({ method, command: commands[method] })
}),
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
if (result.code === 0) return
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
).pipe(
Effect.mapError((cause) =>
cause instanceof UpgradeError
? cause
: new UpgradeError(
{
title: "Could not prepare the OpenCode upgrade",
detail: errorDetail(cause),
retry: "Fix the issue above, then run opencode upgrade again.",
},
{ cause },
),
),
Effect.asVoid,
)
})
const inspect = Effect.fnUntraced(function* () {
+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.12",
"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.12",
"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()),
+4
View File
@@ -192,6 +192,8 @@ export type Prepared<R = never> = {
export type SearchEntry = {
readonly description: ToolDescription
/** The path split into words, so `zones` matches `get_zones` as a word rather than as a substring of `timezones`. */
readonly pathWords: ReadonlyArray<string>
readonly searchText: string
}
@@ -247,6 +249,7 @@ const makeSearchTool = (searchIndex: ReadonlyArray<SearchEntry>): Tool => ({
(total, forms) =>
total +
(forms.some((form) => path === form || path.endsWith(`.${form}`)) ? 20 : 0) +
(forms.some((form) => entry.pathWords.includes(form)) ? 12 : 0) +
(forms.some((form) => path.includes(form)) ? 8 : 0) +
(forms.some((form) => description.includes(form)) ? 4 : 0) +
(forms.some((form) => entry.searchText.includes(form)) ? 2 : 0),
@@ -281,6 +284,7 @@ export const searchSignature = (() => {
const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
description: describeTool(visible),
pathWords: tokenize(visible.path),
searchText: [
visible.path,
visible.tool.description,
+26
View File
@@ -1039,6 +1039,32 @@ describe("CodeMode public contract", () => {
}
})
test("a query term that is a whole word of the path outranks a substring of it", async () => {
const simple = (description: string) =>
Tool.make({
description,
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
})
const runtime = CodeMode.make({
tools: {
// Declared so that alphabetical order would put the substring match first.
cloudflare: { get_timezones: simple("List timezones"), get_zones: simple("List zones") },
},
})
const ranked = await Effect.runPromise(runtime.execute(`return search({ query: "zones" })`))
expect(ranked.ok).toBe(true)
if (ranked.ok) {
const value = ranked.value as { items: Array<{ path: string }> }
expect(value.items.map((item) => item.path)).toStrictEqual([
"tools.cloudflare.get_zones",
"tools.cloudflare.get_timezones",
])
}
})
test("a plural query term matches singular-only tool text", async () => {
const simple = (description: string) =>
Tool.make({
+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.12",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.10",
"version": "2.0.12",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.10",
"version": "2.0.12",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.10",
"version": "2.0.12",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.10",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.10",
"version": "2.0.12",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
File diff suppressed because one or more lines are too long
+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)
}),
)
+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 }],
},
},
},
},
},
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode/desktop",
"private": true,
"version": "2.0.10",
"version": "2.0.12",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
@@ -26,7 +26,7 @@
"main": "./out/main/index.js",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
"electron-context-menu": "5.0.0",
"electron-log": "^5",
"electron-updater": "6.8.9",
"lighthouse": "13.4.1"
@@ -53,7 +53,7 @@
"drizzle-kit": "catalog:",
"drizzle-orm": "catalog:",
"effect": "catalog:",
"electron": "42.10.1",
"electron": "44.4.3",
"electron-builder": "26.15.7",
"electron-vite": "6.0.0-beta.1",
"puppeteer-core": "25.9.0",
+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)
+67 -12
View File
@@ -8,7 +8,14 @@ import { createDiagnostics } from "./browser/diagnostics"
import { createProfiling } from "./browser/profiling"
import { createCornerImages } from "./browser/corners"
import type { BrowserNetwork } from "./browser/network"
import { destinationOrigin, normalizeURL } from "./browser/policy"
import {
allowedDestination,
destinationOrigin,
fileURLWithin,
localFileURL,
normalizeURL,
type Policy,
} from "./browser/policy"
type Element = { backendID: number; frameID: string; sessionID?: string }
let nextRef = 0
@@ -40,8 +47,15 @@ export function createBrowserPage(
initialize?: boolean
restore?: Browser.Tab
popupOptions?: Electron.BrowserWindowConstructorOptions
/** Directories whose files may load as file:// documents; empty when the server is remote. */
fileRoots?: () => ReadonlyArray<string>
},
) {
const policy: Policy = {
get fileRoots() {
return options.fileRoots?.() ?? []
},
}
const view = new electron.WebContentsView({
...options.popupOptions,
webPreferences: {
@@ -103,11 +117,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 +135,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", () => {
@@ -138,9 +182,12 @@ export function createBrowserPage(
contents.session.setDevicePermissionHandler(() => false)
contents.session.setDisplayMediaRequestHandler((_request, callback) => callback({}))
contents.on("content-bounds-updated", (event) => event.preventDefault())
// Sub-frames keep Chromium's own rules so blob:/data: viewers and sandboxed previews still load.
// Sub-frames keep Chromium's own rules so blob:/data: viewers and sandboxed previews still load,
// except file: documents, which must stay inside the allowed roots at every depth.
const guard = (event: Electron.Event<{ url: string; isMainFrame: boolean }>) => {
if (!event.isMainFrame || event.url === "about:blank" || destinationOrigin(event.url)) return
if (event.url === "about:blank") return
if (event.isMainFrame ? allowedDestination(event.url, policy) : !localFileURL(event.url)) return
if (!event.isMainFrame && fileURLWithin(event.url, policy.fileRoots ?? [])) return
event.preventDefault()
options.publish("ERR_BLOCKED_BY_CLIENT")
}
@@ -252,12 +299,20 @@ 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
? []
: [
contents.loadURL(normalizeURL(options.restore?.url || "about:blank")).catch((error: Error) => {
contents.loadURL(normalizeURL(options.restore?.url || "about:blank", policy)).catch((error: Error) => {
if (!options.restore) throw error
// A dev server may have stopped while this page was unloaded. Keep its tab available to retry.
options.publish(error.message)
@@ -302,9 +357,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
@@ -401,7 +456,7 @@ export function createBrowserPage(
}
switch (action.type) {
case "navigate": {
const url = normalizeURL(action.url)
const url = normalizeURL(action.url, policy)
const cancel = () => contents.stop()
signal.addEventListener("abort", cancel, { once: true })
try {
@@ -750,7 +805,7 @@ export function createBrowserPage(
resources: [
...new Set(
action.type === "navigate"
? [new URL(normalizeURL(action.url)).href]
? [new URL(normalizeURL(action.url, policy)).href]
: capture
? sourceURLs()
: urls.length
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { destinationOrigin } from "./browser/policy"
import { allowedDestination, destinationOrigin, fileURLWithin, localFileURL, normalizeURL } from "./browser/policy"
test("allows cross-origin HTTP navigation but rejects unsafe destinations and embedded credentials", () => {
expect(destinationOrigin("https://other.example/path")).toBe("https://other.example")
@@ -13,3 +13,38 @@ test("allows cross-origin HTTP navigation but rejects unsafe destinations and em
expect(destinationOrigin(url)).toBeUndefined()
}
})
test("file documents load only from allowed workspace roots and never from a host", () => {
expect(localFileURL("file:///C:/work/out/report.html")).toBe("file:///C:/work/out/report.html")
expect(localFileURL("file://server/share/report.html")).toBeUndefined()
expect(localFileURL("https://example.com")).toBeUndefined()
const roots = ["/home/me/repo"]
expect(fileURLWithin("file:///home/me/repo/out/index.html", roots)).toBe(true)
expect(fileURLWithin("file:///home/me/repo", roots)).toBe(true)
expect(fileURLWithin("file:///home/me/repo-other/x.html", roots)).toBe(false)
expect(fileURLWithin("file:///home/me/.aws/credentials", roots)).toBe(false)
expect(fileURLWithin("file:///home/me/repo/../.aws/credentials", roots)).toBe(false)
expect(fileURLWithin("file:///home/me/repo/out/%2e%2e/%2e%2e/.aws/credentials", roots)).toBe(false)
expect(fileURLWithin("file:///home/me/repo/x.html", [])).toBe(false)
expect(fileURLWithin("file://server/home/me/repo/x.html", roots)).toBe(false)
expect(allowedDestination("file:///home/me/repo/x.html")).toBe(false)
expect(allowedDestination("file:///home/me/repo/x.html", { fileRoots: roots })).toBe(true)
expect(allowedDestination("file:///etc/passwd", { fileRoots: roots })).toBe(false)
expect(allowedDestination("javascript:alert(1)", { fileRoots: roots })).toBe(false)
expect(normalizeURL("file:///home/me/repo/x.html", { fileRoots: roots })).toBe("file:///home/me/repo/x.html")
expect(() => normalizeURL("file:///home/me/repo/x.html")).toThrow()
expect(() => normalizeURL("file:///etc/passwd", { fileRoots: roots })).toThrow()
expect(normalizeURL("localhost:3000", { fileRoots: roots })).toBe("http://localhost:3000")
})
test("windows workspace roots match drive-letter file URLs", () => {
const roots = ["C:\\Users\\me\\repo"]
const inside = process.platform === "win32"
expect(fileURLWithin("file:///C:/Users/me/repo/out/index.html", roots)).toBe(true)
expect(fileURLWithin("file:///c:/users/me/repo/out/index.html", roots)).toBe(inside)
expect(fileURLWithin("file:///C:/Users/me/repo2/x.html", roots)).toBe(false)
expect(fileURLWithin("file:///D:/Users/me/repo/x.html", roots)).toBe(false)
})
+26 -2
View File
@@ -3,14 +3,14 @@ import { NodeHttpClient } from "@effect/platform-node"
import { Browser } from "@opencode/plugin-browser/rpc"
import { OpenCode } from "@opencode/client/effect"
import { SessionID } from "@opencode/schema/session-id"
import type { BrowserWindow } from "electron"
import electron, { type BrowserWindow } from "electron"
import { Deferred, Effect, ManagedRuntime, Queue, Schedule, Schema, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { BrowserPaneEvent } from "../shared/ipc-rpc/events"
import { createBrowserPage, type BrowserPage } from "./browser-chromium"
import { browserFailure } from "./browser/errors"
import { createBrowserNetwork, type BrowserNetwork } from "./browser/network"
import { destinationOrigin } from "./browser/policy"
import { destinationOrigin, fileURLWithin } from "./browser/policy"
import { emitIpcEvent } from "./ipc-events"
import { SidecarCredentials } from "./service/sidecar-credentials"
import { createBrowserRestoreStore } from "./browser/restore"
@@ -31,6 +31,11 @@ type Entry = {
lastState?: string
network?: BrowserNetwork
storageKey: string
/**
* Workspace directories whose files may load as file:// documents. Set only for the desktop's
* own sidecar: a forwarded or explicit loopback server does not share this machine's disk.
*/
fileRoots: string[]
}
export function createBrowserPane(storage: StateStore) {
@@ -78,7 +83,18 @@ export function createBrowserPane(storage: StateStore) {
focusedTabID: previous.focusedTabID,
partition: `opencode-browser-${crypto.randomUUID()}`,
storageKey,
fileRoots: [],
}
const sidecar = SidecarCredentials.get()
const sameMachine =
!!sidecar && URL.canParse(target.endpoint.url) && new URL(target.endpoint.url).origin === sidecar.url
// Navigation guards cover documents; subresources (img, script, fetch) also must not read
// file: URLs outside the roots. One listener per partition covers every page in this attachment.
electron.session
.fromPartition(entry.partition)
.webRequest.onBeforeRequest({ urls: ["file://*/*"] }, (details, callback) =>
callback({ cancel: !fileURLWithin(details.url, entry.fileRoots) }),
)
// "unsupported" means the server has no browser plugin; the renderer stops retrying.
let reason: "browser.pane.unsupported" | "browser.pane.replaced" | "browser.pane.suspended" | undefined
let attached = false
@@ -112,6 +128,8 @@ export function createBrowserPane(storage: StateStore) {
),
)
const session = yield* client.session.get({ sessionID })
// The agent can already read this workspace, so showing its files adds no access.
if (sameMachine) entry.fileRoots = [session.location.directory]
const options = {
location: { directory: session.location.directory, workspace: session.location.workspaceID },
}
@@ -393,6 +411,7 @@ export function createBrowserPane(storage: StateStore) {
initialize,
restore,
popupOptions,
fileRoots: () => entry.fileRoots,
fail,
publish: (error) => {
if (entry.pages.has(id)) publishState(entry, error)
@@ -420,6 +439,11 @@ export function createBrowserPane(storage: StateStore) {
"Browser request was cancelled. Do not repeat a mutating action until you have inspected its outcome.",
)
if (action.type === "tabs.list") return { value: inventory(entry), files: [] }
if (action.type === "preview") {
// The renderer owns file tabs; it resolves the path against the session's workspace.
report(entry, { type: "preview", path: action.path })
return { value: { path: action.path }, files: [] }
}
if (action.type === "tabs.open") {
const page = create(entry)
if (action.focus !== false) focus(entry, page.state().id)
+43 -3
View File
@@ -6,12 +6,52 @@ export function destinationOrigin(input: string) {
return /^https?:$/.test(url.protocol) && !url.username && !url.password ? url.origin : undefined
}
export function normalizeURL(input: string) {
/** A file URL for this machine: no host, so UNC shares and remote hosts are rejected. */
export function localFileURL(input: string) {
if (!URL.canParse(input)) return
const url = new URL(input)
return url.protocol === "file:" && !url.hostname ? url.href : undefined
}
/** Case-insensitive on Windows, where drive letters and paths compare that way. */
function canonicalPath(input: string) {
const value = decodeURIComponent(input)
.replaceAll("\\", "/")
.replace(/^\/([A-Za-z]:\/)/, "$1")
return process.platform === "win32" ? value.toLowerCase() : value
}
/**
* Whether a file URL points inside one of the allowed directories. The agent already has read
* access to the session's workspace, so files there may be shown; anything else stays behind the
* server's file permissions.
*/
export function fileURLWithin(input: string, roots: ReadonlyArray<string>) {
const href = localFileURL(input)
if (!href || roots.length === 0) return false
const path = canonicalPath(new URL(href).pathname)
return roots.some((root) => {
const prefix = canonicalPath(root).replace(/\/+$/, "")
return path === prefix || path.startsWith(`${prefix}/`)
})
}
export type Policy = { readonly fileRoots?: ReadonlyArray<string> }
export function allowedDestination(input: string, policy?: Policy) {
return !!destinationOrigin(input) || fileURLWithin(input, policy?.fileRoots ?? [])
}
export function normalizeURL(input: string, policy?: Policy) {
const value = input.trim() || "about:blank"
const local = /^(?:localhost|127(?:\.\d{1,3}){3}|\[::1\])(?::\d+)?(?:[/?#]|$)/i.test(value)
const url =
value === "about:blank" || /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `${local ? "http" : "https"}://${value}`
if (url !== "about:blank" && !destinationOrigin(url))
throw new Error("Only HTTP, HTTPS, and about:blank URLs are supported.")
if (url !== "about:blank" && !allowedDestination(url, policy))
throw new Error(
policy?.fileRoots?.length
? "Only HTTP, HTTPS, about:blank, and file URLs inside the workspace are supported."
: "Only HTTP, HTTPS, and about:blank URLs are supported.",
)
return url
}
+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)
+21 -8
View File
@@ -1,7 +1,7 @@
export * as DesktopFiles from "./index"
import { execFile } from "node:child_process"
import { clipboard, dialog, shell } from "electron"
import { clipboard, dialog, nativeImage, shell } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import type { DirectoryPickerOptions, FilePickerOptions, SaveFilePickerOptions } from "../../shared/ipc-contract"
import { scoped } from "../native/logging"
@@ -92,18 +92,31 @@ function make(fs: FileSystem.FileSystem, path: Path.Path) {
shell.showItemInFolder(target)
return true
}),
readClipboardImage() {
const image = clipboard.readImage()
readClipboardImage: Effect.fn("DesktopFiles.readClipboardImage")(function* () {
const items = yield* Effect.promise(() => clipboard.read())
const found = clipboardImageTypes
.flatMap((mime) => items.filter((item) => item.types.includes(mime)).map((item) => ({ item, mime })))
.at(0)
if (!found) return null
// getType() is typed as Blob | ClipboardBookmark; only the bookmark format yields the latter.
const payload = yield* Effect.promise(async () => found.item.getType(found.mime))
if (!(payload instanceof Blob)) return null
const bytes = yield* Effect.promise(() => payload.arrayBuffer())
// Re-encode through nativeImage so the renderer always receives PNG and the image dimensions.
const image = nativeImage.createFromBuffer(Buffer.from(bytes))
if (image.isEmpty()) return null
const size = image.getSize()
return { buffer: new Uint8Array(image.toPNG()).buffer, width: size.width, height: size.height }
},
writeClipboardText(text: string) {
clipboard.writeText(text)
},
return { buffer: new Uint8Array(image.toPNG()), width: size.width, height: size.height }
}),
writeClipboardText: Effect.fn("DesktopFiles.writeClipboardText")(function* (text: string) {
yield* Effect.promise(() => clipboard.writeText(text))
}),
}
}
// Chromium exposes copied bitmaps as image/png; JPEG only appears when an app placed one explicitly.
const clipboardImageTypes = ["image/png", "image/jpeg"]
export const openExternalURL = Effect.fn("DesktopFiles.openExternalURL")(function* (value: string) {
const url = resolveExternalURL(value)
if (!url) {
+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"
@@ -32,12 +32,8 @@ export const fileHandlers = FileRpcs.toLayer(
Effect.orDie,
),
FilesRevealPath: ({ path }) => files.revealPath(path),
FilesReadClipboardImage: () =>
Effect.sync(() => {
const image = files.readClipboardImage()
return image ? { ...image, buffer: new Uint8Array(image.buffer) } : null
}),
FilesWriteClipboardText: ({ text }) => Effect.sync(() => files.writeClipboardText(text)),
FilesReadClipboardImage: () => files.readClipboardImage(),
FilesWriteClipboardText: ({ text }) => files.writeClipboardText(text),
})
}),
)
+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

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