From 2e43d41af0567ceb724deda0a1264b6b6cf292fa Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 9 Jul 2026 09:07:02 -0500 Subject: [PATCH 01/17] tweak: adjust variant logic for muse (#36079) --- packages/opencode/src/provider/provider.ts | 7 +++++++ packages/opencode/src/provider/transform.ts | 23 ++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index c979fc4465..b1c5c935e9 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -207,6 +207,13 @@ function custom(dep: CustomDep): Record { }, options: { headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT }, }), + meta: () => + Effect.succeed({ + autoload: false, + async getModel(sdk: any, modelID: string, _options?: Record) { + return sdk.responses(modelID) + }, + }), xai: () => Effect.succeed({ autoload: false, diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index c14587ba8a..20c73d0216 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -884,6 +884,18 @@ export function variants(model: Provider.Model): Record [ + effort, + { + reasoningEffort: effort, + reasoningSummary: "auto", + include: INCLUDE_ENCRYPTED_REASONING, + }, + ]), + ) + } // https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai const efforts = openaiReasoningEfforts(model.api.id, model.release_date) return Object.fromEntries( @@ -1128,11 +1140,20 @@ export function options(input: { if ( input.providerOptions?.setCacheKey !== false && - (input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/xai" || input.providerOptions?.setCacheKey) + (input.model.providerID === "openai" || + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/xai" || + input.providerOptions?.setCacheKey) ) { result["promptCacheKey"] = input.sessionID } + if (input.model.providerID === "meta" && input.model.api.npm === "@ai-sdk/openai") { + result["reasoningEffort"] = "high" + result["reasoningSummary"] = "auto" + result["include"] = INCLUDE_ENCRYPTED_REASONING + } + if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") { if (input.model.capabilities.reasoning) { result["thinkingConfig"] = { From 08096b5e61c9227a6b52aacc745a1a51c6385284 Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 9 Jul 2026 15:03:10 +0000 Subject: [PATCH 02/17] sync release versions for v1.17.17 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index 561cb5d519..fd1ea67923 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -95,7 +95,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.16", + "version": "1.17.17", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -143,7 +143,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -157,7 +157,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -193,7 +193,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -220,7 +220,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -242,7 +242,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -266,7 +266,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -286,7 +286,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.16", + "version": "1.17.17", "bin": { "opencode": "./bin/opencode", }, @@ -380,7 +380,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -434,7 +434,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -448,7 +448,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "effect": "catalog:", }, @@ -460,7 +460,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -492,7 +492,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -508,7 +508,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -539,7 +539,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -558,7 +558,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.16", + "version": "1.17.17", "bin": { "opencode": "./bin/opencode", }, @@ -689,7 +689,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -765,7 +765,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "cross-spawn": "catalog:", }, @@ -780,7 +780,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -795,7 +795,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -839,7 +839,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -852,7 +852,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", @@ -885,7 +885,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -904,7 +904,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -945,7 +945,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -972,7 +972,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1023,7 +1023,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index b044ef5631..34d1c0b997 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.16", + "version": "1.17.17", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index d3b9981d1a..777fd87f73 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.17.16", + "version": "1.17.17", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 49bd6edac5..973844777e 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.17.16", + "version": "1.17.17", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index aa55ac720f..1e236ba6dd 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.17.16", + "version": "1.17.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index c009a35a80..fff5e960a5 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.17.16", + "version": "1.17.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 69904bea90..038a9144b7 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.17.16", + "version": "1.17.17", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 3195265394..dde3e42a4e 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.17.16", + "version": "1.17.17", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index ef29aa4064..d74d8ee494 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.17.16", + "version": "1.17.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 9fd15d5ea9..8ec05092d4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.17", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index e12ad568b9..c7117fba31 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.17.16", + "version": "1.17.17", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index e6dde2a1f8..19f25f9248 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.17", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 100571826d..037d1a5c91 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.17", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 80aa77e490..513b189413 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.17.16", + "version": "1.17.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index eee6e3dcd5..d80cec6b50 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.17.16", + "version": "1.17.17", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 13c7e04eb1..b1215f97e4 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.17", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 41cecee3c5..9a0e0c2680 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.17", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 09c9b36b9c..529a47e76d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.16", + "version": "1.17.17", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2a09f49960..b25a278e06 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.17.16", + "version": "1.17.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index f2a5708b2c..9b5cb04208 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.17.16", + "version": "1.17.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 676241d1c3..6ae84578fc 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.17.16", + "version": "1.17.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 1734449d1d..2f50890cfc 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.17.16", + "version": "1.17.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index ebef44433f..ff884343e4 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.17.16", + "version": "1.17.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 811123b5e0..631230fb62 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.17.16", + "version": "1.17.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 47e55ec306..6690cde900 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.17.16", + "version": "1.17.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index c1f0f7045c..6e3b6df582 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.17.16", + "version": "1.17.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index cd853f05b5..9b661d3564 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.17.16", + "version": "1.17.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index f521ec861a..964a995ba0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.17.16", + "version": "1.17.17", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index c65c9c12ea..2fa2057ef9 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.17.16", + "version": "1.17.17", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 28dbe0feb6..739254a2c0 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.17.16", + "version": "1.17.17", "publisher": "sst-dev", "repository": { "type": "git", From b4665a8bf89108c3e72fe48905da5e4c46bf0d28 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:00:39 -0500 Subject: [PATCH 03/17] feat: add meta muse system prompt (#36122) --- packages/opencode/src/session/prompt/meta.txt | 76 +++++++++++++++++++ packages/opencode/src/session/system.ts | 2 + packages/opencode/test/session/system.test.ts | 9 ++- 3 files changed, 86 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/src/session/prompt/meta.txt diff --git a/packages/opencode/src/session/prompt/meta.txt b/packages/opencode/src/session/prompt/meta.txt new file mode 100644 index 0000000000..8f0c926c03 --- /dev/null +++ b/packages/opencode/src/session/prompt/meta.txt @@ -0,0 +1,76 @@ +You are OpenCode, the best coding agent on the planet. +You are based on a large language model trained by Meta MSL named Muse Spark. +When asked who you are, identify yourself as OpenCode powered by Meta Muse Spark by name. +You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. +IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. +If the user asks for help or wants to give feedback inform them of the following: +- ctrl+p to list available actions +- To give feedback, users should report the issue at + https://github.com/anomalyco/opencode +When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs +# Tone and style +- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. +- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. +- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. +- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files. +# Professional objectivity +Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. +# Task Management +You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. +These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. +It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed. +Examples: + +user: Run the build and fix any type errors +assistant: I'm going to use the TodoWrite tool to write the following items to the todo list: +- Run the build +- Fix any type errors +I'm now going to run the build using Bash. +Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list. +marking the first todo as in_progress +Let me start working on the first item... +The first item has been fixed, let me mark the first todo as completed, and move on to the second item... +.. +.. + +In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. + +user: Help me write a new feature that allows users to track their usage metrics and export them to various formats +assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task. +Adding the following todos to the todo list: +1. Research existing metrics tracking in the codebase +2. Design the metrics collection system +3. Implement core metrics tracking functionality +4. Create export functionality for different formats +Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. +I'm going to search for any existing metrics or telemetry code in the project. +I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... +[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] + +# Doing tasks +The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: +- Use the TodoWrite tool to plan the task if required +- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. +# Tool usage policy +- When doing file search, prefer to use the Task tool in order to reduce context usage. +- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description. +- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. +- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. +- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. +- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. +- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly. + +user: Where are errors from the client handled? +assistant: [Uses the Task tool to find the files that handle client errors instead of using Glob or Grep directly] + + +user: What is the codebase structure? +assistant: [Uses the Task tool] + +IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation. +# Code References +When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. + +user: Where are errors from the client handled? +assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712. + diff --git a/packages/opencode/src/session/system.ts b/packages/opencode/src/session/system.ts index 6e5b83ec31..b94e794265 100644 --- a/packages/opencode/src/session/system.ts +++ b/packages/opencode/src/session/system.ts @@ -9,6 +9,7 @@ import PROMPT_BEAST from "./prompt/beast.txt" import PROMPT_GEMINI from "./prompt/gemini.txt" import PROMPT_GPT from "./prompt/gpt.txt" import PROMPT_KIMI from "./prompt/kimi.txt" +import PROMPT_META from "./prompt/meta.txt" import PROMPT_CODEX from "./prompt/codex.txt" import PROMPT_TRINITY from "./prompt/trinity.txt" @@ -24,6 +25,7 @@ import { MCP } from "@/mcp" import { PermissionV1 } from "@opencode-ai/core/v1/permission" export function provider(model: Provider.Model) { + if (model.api.id.includes("muse-spark")) return [PROMPT_META] if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3")) return [PROMPT_BEAST] if (model.api.id.includes("gpt")) { diff --git a/packages/opencode/test/session/system.test.ts b/packages/opencode/test/session/system.test.ts index 1484bfd631..d9d47be3df 100644 --- a/packages/opencode/test/session/system.test.ts +++ b/packages/opencode/test/session/system.test.ts @@ -1,10 +1,11 @@ -import { describe, expect } from "bun:test" +import { describe, expect, test } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect, Layer } from "effect" import type { Agent } from "../../src/agent/agent" import { NamedError } from "@opencode-ai/core/util/error" import { Skill } from "../../src/skill" import { Permission } from "../../src/permission" +import type { Provider } from "../../src/provider/provider" import { SystemPrompt } from "../../src/session/system" import { MCP } from "../../src/mcp" import { testEffect } from "../lib/effect" @@ -83,6 +84,12 @@ const it = testEffect( ) describe("session.system", () => { + test("selects the Meta prompt for Muse Spark model IDs", () => { + expect(SystemPrompt.provider({ api: { id: "meta/muse-spark-preview" } } as Provider.Model)[0]).toContain( + "Meta Muse Spark", + ) + }) + it.effect("skills output is sorted by name and stable across calls", () => Effect.gen(function* () { const prompt = yield* SystemPrompt.Service From b8374b5a7c532e51aeb66b1dee9278de91526ef5 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 9 Jul 2026 13:14:33 -0500 Subject: [PATCH 04/17] fix(copilot): handle zero billing batch size (#36123) --- .../src/plugin/github-copilot/models.ts | 2 +- packages/opencode/src/provider/provider.ts | 16 ++++-- .../test/plugin/github-copilot-models.test.ts | 54 +++++++++++++++++++ .../opencode/test/provider/provider.test.ts | 26 +++++++++ 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/plugin/github-copilot/models.ts b/packages/opencode/src/plugin/github-copilot/models.ts index 4e571103a5..f3f32a0b0b 100644 --- a/packages/opencode/src/plugin/github-copilot/models.ts +++ b/packages/opencode/src/plugin/github-copilot/models.ts @@ -99,7 +99,7 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model): : undefined const prices = remote.billing?.token_prices // Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens. - const usdPerMillion = prices ? 10_000 / prices.batch_size : 0 + const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0 const model: CopilotModel = { id: key, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index b1c5c935e9..c0a222d649 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1071,11 +1071,17 @@ export type ConfigProvidersResult = Types.DeepMutable { - if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined - if (typeof value === "bigint") return value.toString() - return value - }), + JSON.stringify( + { + ...provider, + models: Object.fromEntries(Object.entries(provider.models).filter(([, model]) => Schema.is(Model)(model))), + }, + (_, value) => { + if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined + if (typeof value === "bigint") return value.toString() + return value + }, + ), ) } diff --git a/packages/opencode/test/plugin/github-copilot-models.test.ts b/packages/opencode/test/plugin/github-copilot-models.test.ts index 35bfaa6cb4..e6d89fe01c 100644 --- a/packages/opencode/test/plugin/github-copilot-models.test.ts +++ b/packages/opencode/test/plugin/github-copilot-models.test.ts @@ -187,6 +187,60 @@ test("converts Copilot AIC token prices to USD per million tokens", async () => expect(models["ignored-non-chat-record"]).toBeUndefined() }) +test("uses zero cost when Copilot reports a zero billing batch size", async () => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response( + JSON.stringify({ + data: [ + { + model_picker_enabled: true, + id: "mercury-alpha", + name: "Mercury Alpha", + version: "mercury-alpha-2026-07-09", + billing: { + token_prices: { + batch_size: 0, + default: { + input_price: 0, + output_price: 0, + cache_price: 0, + }, + }, + }, + capabilities: { + family: "mercury", + limits: { + max_context_window_tokens: 128000, + max_output_tokens: 16384, + max_prompt_tokens: 128000, + }, + supports: { + streaming: true, + tool_calls: true, + }, + }, + }, + ], + }), + { status: 200 }, + ), + ), + ) as unknown as typeof fetch + + const model = (await CopilotModels.get("https://api.githubcopilot.com")).models["mercury-alpha"] + + expect(model.cost).toEqual({ + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }) + expect(JSON.stringify(model)).not.toContain("null") +}) + test("records Copilot advertised responses endpoint for non-GPT model IDs", async () => { globalThis.fetch = mock(() => Promise.resolve( diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 18ec8f9fbe..c27877c1f7 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1426,6 +1426,32 @@ test("models.dev normalization fills required response fields", () => { expect(model.release_date).toBe("") }) +test("public provider info omits invalid models", () => { + const provider = Provider.fromModelsDevProvider({ + id: "test", + name: "Test", + env: [], + models: { + valid: { + id: "valid", + name: "Valid", + cost: { input: 1, output: 1 }, + limit: { context: 128_000, output: 16_000 }, + }, + }, + } as unknown as ModelsDev.Provider) + provider.models.invalid = { + ...provider.models.valid, + id: ModelV2.ID.make("invalid"), + cost: { ...provider.models.valid.cost, input: Number.NaN }, + } + + const result = Provider.toPublicInfo(provider) + + expect(result.models.valid).toBeDefined() + expect(result.models.invalid).toBeUndefined() +}) + it.instance("model variants are generated for reasoning models", () => Effect.gen(function* () { yield* set("ANTHROPIC_API_KEY", "test-api-key") From c2e019ba7d61f48a6d952767d619f0c43c9e7100 Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 9 Jul 2026 18:51:42 +0000 Subject: [PATCH 05/17] sync release versions for v1.17.18 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index fd1ea67923..2f31450ed0 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -95,7 +95,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.17.17", + "version": "1.17.18", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -143,7 +143,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -157,7 +157,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -193,7 +193,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -220,7 +220,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -242,7 +242,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -266,7 +266,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -286,7 +286,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.17.17", + "version": "1.17.18", "bin": { "opencode": "./bin/opencode", }, @@ -380,7 +380,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -434,7 +434,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -448,7 +448,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "effect": "catalog:", }, @@ -460,7 +460,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -492,7 +492,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -508,7 +508,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -539,7 +539,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -558,7 +558,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.17.17", + "version": "1.17.18", "bin": { "opencode": "./bin/opencode", }, @@ -689,7 +689,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -765,7 +765,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "cross-spawn": "catalog:", }, @@ -780,7 +780,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -795,7 +795,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/core": "workspace:*", @@ -839,7 +839,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -852,7 +852,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@ibm/plex": "6.4.1", "@opencode-ai/stats-core": "workspace:*", @@ -885,7 +885,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -904,7 +904,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -945,7 +945,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -972,7 +972,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1023,7 +1023,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 34d1c0b997..510e2015db 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.17.17", + "version": "1.17.18", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 777fd87f73..dd660aa2ee 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.17.17", + "version": "1.17.18", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 973844777e..263daa6a53 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.17.17", + "version": "1.17.18", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 1e236ba6dd..35d31f9342 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.17.17", + "version": "1.17.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index fff5e960a5..ffc7a756d2 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.17.17", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 038a9144b7..47ae1efd7a 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.17.17", + "version": "1.17.18", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index dde3e42a4e..d0f16d771f 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.17.17", + "version": "1.17.18", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index d74d8ee494..159a59722b 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.17.17", + "version": "1.17.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 8ec05092d4..faac818690 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.17", + "version": "1.17.18", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index c7117fba31..ece963cbc2 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.17.17", + "version": "1.17.18", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 19f25f9248..6aebc6f41d 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.17", + "version": "1.17.18", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 037d1a5c91..117aab177f 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.17", + "version": "1.17.18", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 513b189413..ea959c4e4f 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.17.17", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index d80cec6b50..282076fa65 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.17.17", + "version": "1.17.18", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index b1215f97e4..534ec4bc0f 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.17", + "version": "1.17.18", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 9a0e0c2680..91103fd4d0 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.17", + "version": "1.17.18", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 529a47e76d..89574054c3 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.17.17", + "version": "1.17.18", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index b25a278e06..d93419770e 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.17.17", + "version": "1.17.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 9b5cb04208..81e184968f 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.17.17", + "version": "1.17.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 6ae84578fc..9872b789c3 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.17.17", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 2f50890cfc..cf3be61f36 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.17.17", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index ff884343e4..11bb354f0d 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.17.17", + "version": "1.17.18", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 631230fb62..f1def44cec 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.17.17", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 6690cde900..6733366530 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.17.17", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 6e3b6df582..a4d44b874d 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.17.17", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 9b661d3564..8523c2fd68 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.17.17", + "version": "1.17.18", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 964a995ba0..26ffadbcf6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.17.17", + "version": "1.17.18", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 2fa2057ef9..2782f6a397 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.17.17", + "version": "1.17.18", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 739254a2c0..52ba1595cc 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.17.17", + "version": "1.17.18", "publisher": "sst-dev", "repository": { "type": "git", From 9c6bb4ff72a2e4ed69761147c29e9164a627659a Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:12:20 +1000 Subject: [PATCH 06/17] fix(app): show tree while opening files (#36018) --- packages/app/e2e/regression/review-open-file.spec.ts | 9 +++++++++ .../src/pages/session/v2/session-file-browser-tab.tsx | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index f69591f608..397c53f248 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -74,6 +74,10 @@ test("opens and searches project files inline", async ({ page }) => { "opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }), + ) localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId: sessionID }]), @@ -86,13 +90,16 @@ test("opens and searches project files inline", async ({ page }) => { await expectSessionTitle(page, title) const panel = page.locator("#review-panel") + const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') const contextButton = page.getByRole("button", { name: "View context usage" }) await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebar).toBeVisible() await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await expect(sidebar).toHaveCount(0) await panel.getByRole("button", { name: "Open file" }).click() const filter = panel.getByRole("combobox", { name: "Filter files" }) await expect(filter).toBeFocused() @@ -102,9 +109,11 @@ test("opens and searches project files inline", async ({ page }) => { await panel.getByRole("button", { name: "README.md" }).click() await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() + await expect(sidebar).toHaveCount(0) await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0) + await expect(sidebar).toBeVisible() await filter.fill("nested") const result = panel.getByRole("option", { name: /nested\.ts/ }) await expect(result).toBeVisible() diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx index 597ebd3f7e..9cbdd40df1 100644 --- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -46,6 +46,7 @@ export function SessionFileBrowserTab(props: { const resultsID = `session-file-browser-results-${createUniqueId()}` const [filter, setFilter] = createSignal("") const [explicitHighlight, setExplicitHighlight] = createSignal() + const sidebarOpened = () => props.placeholder || props.state.sidebarOpened() const query = createMemo(() => filter().trim()) const search = createQuery(() => { const value = query() @@ -98,15 +99,15 @@ export function SessionFileBrowserTab(props: { toolbar toolbarStart={ <> - - + + {title()} } sidebar={ {title()}} filter={filter()} onFilterChange={setFilter} From b94e55c7c0ed5fc48ef2c652bf466e1669af4624 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:12:54 +1000 Subject: [PATCH 07/17] fix(app): align context tokens with usage (#36015) --- .../src/components/session-context-usage.tsx | 5 ++--- .../session/session-context-metrics.test.ts | 19 +++++-------------- .../session/session-context-metrics.ts | 9 +++------ .../session/session-context-tab.tsx | 14 +++++++------- 4 files changed, 17 insertions(+), 30 deletions(-) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index fd2d1dd1a3..c1e0ccde05 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -12,7 +12,7 @@ import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" -import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics" +import { getSessionContext } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { useSettings } from "@/context/settings" @@ -74,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) { ) const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) - const tokens = createMemo(() => info()?.tokens) const cost = createMemo(() => { return usd().format(info()?.cost ?? 0) }) @@ -132,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { ) diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 5b11bf1433..37cc0d5b5d 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Message } from "@opencode-ai/sdk/v2/client" -import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" +import { getSessionContext } from "./session-context-metrics" const assistant = ( id: string, @@ -38,10 +38,10 @@ const user = (id: string) => { } describe("getSessionContext", () => { - test("computes usage from latest assistant with tokens", () => { + test("computes token totals and usage from latest assistant with tokens", () => { const messages = [ user("u1"), - assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), + assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5), assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25), ] const providers = [ @@ -60,6 +60,8 @@ describe("getSessionContext", () => { const ctx = getSessionContext(messages, providers) expect(ctx?.message.id).toBe("a2") + expect(ctx?.total).toBe(500) + expect(ctx?.input).toBe(300) expect(ctx?.usage).toBe(50) expect(ctx?.providerLabel).toBe("OpenAI") expect(ctx?.modelLabel).toBe("GPT-4.1") @@ -94,15 +96,4 @@ describe("getSessionContext", () => { expect(ctx).toBeUndefined() }) - - test("computes stored session token totals", () => { - expect( - getSessionTokenTotal({ - input: 10, - output: 20, - reasoning: 30, - cache: { read: 40, write: 50 }, - }), - ).toBe(150) - }) }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 84cc984abf..30dc9e958b 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client" +import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" type Provider = { id: string @@ -21,6 +21,7 @@ type Context = { modelLabel: string limit: number | undefined input: number + total: number usage: number | null } @@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context | modelLabel: model?.name ?? message.modelID, limit, input: message.tokens.input, + total, usage: limit ? Math.round((total / limit) * 100) : null, } } @@ -61,8 +63,3 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context | export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) { return build(messages, providers) } - -export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) { - if (!tokens) return undefined - return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write -} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 8757c69367..25ad5ab230 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" import { useSessionLayout } from "@/pages/session/session-layout" -import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" +import { getSessionContext } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" @@ -135,7 +135,6 @@ export function SessionContextTab() { ) const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) - const tokens = createMemo(() => info()?.tokens) const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const cost = createMemo(() => { @@ -204,14 +203,15 @@ export function SessionContextTab() { { label: "context.stats.provider", value: providerLabel }, { label: "context.stats.model", value: modelLabel }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, - { label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) }, + { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, - { label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) }, - { label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) }, - { label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) }, + { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, + { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) }, + { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) }, { label: "context.stats.cacheTokens", - value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`, + value: () => + `${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`, }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, From 13b9e55c72620e9c7a528cf3881c929a37e3761c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:13:34 +1000 Subject: [PATCH 08/17] fix(ui): sometimes outlines get clipped (#33846) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- .../session-timeline-shell-outline.spec.ts | 228 ++++++++++++++++++ .../session/timeline/message-timeline.tsx | 9 +- 2 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 packages/app/e2e/regression/session-timeline-shell-outline.spec.ts diff --git a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts new file mode 100644 index 0000000000..54139cc371 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts @@ -0,0 +1,228 @@ +import { expect, test, type Locator, type Page } from "@playwright/test" +import { + assistantMessage, + setupTimeline, + shell, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +for (const deviceScaleFactor of [1.25, 1.5]) { + test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => { + const shellID = "prt_shell_outline" + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])], + settings: { newLayoutDesigns: true, shellToolPartsExpanded: true }, + reducedMotion: true, + deviceScaleFactor, + }) + const part = page.locator(`[data-timeline-part-id="${shellID}"]`) + const output = part.locator('[data-component="bash-output"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(output).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]') + if (!output) throw new Error("Shell output is unavailable") + const rowRect = element.getBoundingClientRect() + const outputRect = output.getBoundingClientRect() + // Match a rounded-down measurement at a fractional device-pixel phase. + element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px` + element.style.transform = "translateY(0.25px)" + output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)") + output.style.setProperty("background", "rgb(0, 0, 0)", "important") + const style = getComputedStyle(output) + return { + outputWidth: outputRect.width, + outputHeight: outputRect.height, + borderColor: style.borderTopColor, + boxShadow: style.boxShadow, + clipMargin: getComputedStyle(element).overflowClipMargin, + } + }) + await timeline.settle() + + const clipped = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]')! + return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom + }) + expect(clipped).toBeCloseTo(0.49, 1) + + expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor) + const edges = await captureCardEdges(page, output) + + expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2) + expect(geometry.borderColor).toBe("rgb(255, 0, 255)") + expect(geometry.boxShadow).toBe("none") + expect(geometry.clipMargin).toBe("0.5px") + expect(edges.magenta.top).toBeGreaterThan(0.75) + expect(edges.magenta.bottom).toBeGreaterThan(0.75) + expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2) + }) +} + +test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => { + const patchID = "prt_patch_outline" + const file = { + filePath: "src/outline.ts", + relativePath: "src/outline.ts", + type: "update", + additions: 1, + deletions: 1, + before: "const outline = false\n", + after: "const outline = true\n", + } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }), + ]), + ], + settings: { editToolPartsExpanded: true, newLayoutDesigns: true }, + reducedMotion: true, + }) + const part = page.locator(`[data-timeline-part-id="${patchID}"]`) + const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(card).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const card = element.querySelector('[data-component="accordion"][data-scope="apply-patch"]') + if (!card) throw new Error("Patch card is unavailable") + const rowRect = element.getBoundingClientRect() + const cardRect = card.getBoundingClientRect() + element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px` + const clipMargin = getComputedStyle(element).overflowClipMargin + const bottom = element.getBoundingClientRect().bottom + return { + overflow: card.getBoundingClientRect().bottom - bottom, + paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin), + clipMargin, + cardWidth: cardRect.width, + cardHeight: cardRect.height, + } + }) + await timeline.settle() + + expect(geometry.overflow).toBeCloseTo(0.49, 1) + expect(geometry.paintOverflow).toBeLessThanOrEqual(0) + const edges = await captureCardEdges(page, card) + expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2) + expect(edges.luminance.top).toBeLessThan(245) + expect(edges.luminance.bottom).toBeLessThan(245) + expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10) + expect(geometry.clipMargin).toBe("0.5px") +}) + +test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => { + const secondUserID = "msg_outline_second_user" + await setupTimeline(page, { + messages: [ + userMessage(undefined, { + summary: { + diffs: [ + { + file: "src/summary.ts", + additions: 1, + deletions: 1, + patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2", + }, + ], + }, + }), + assistantMessage([textPart("prt_outline_text", "Assistant text")]), + userMessage(undefined, { id: secondUserID, created: 1700000010000 }), + assistantMessage([], { + id: "msg_outline_second_assistant", + parentID: secondUserID, + created: 1700000011000, + }), + ], + }) + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() + + const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) => + elements.map((element) => ({ + tag: element.querySelector("[data-timeline-row]")?.dataset.timelineRow, + clipMargin: getComputedStyle(element).overflowClipMargin, + })), + ) + expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true) + expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }]) +}) + +async function captureCardEdges(page: Page, card: Locator) { + const box = await card.boundingBox() + if (!box) throw new Error("Tool card bounds are unavailable") + const viewport = page.viewportSize() + if (!viewport) throw new Error("Viewport bounds are unavailable") + const screenshot = await page.screenshot() + return page.evaluate( + async ({ source, box, viewport }) => { + const image = new Image() + image.src = source + await image.decode() + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext("2d") + if (!context) throw new Error("2D canvas is unavailable") + context.drawImage(image, 0, 0) + const scale = { + x: image.naturalWidth / viewport.width, + y: image.naturalHeight / viewport.height, + } + const rows = (candidates: number[]) => { + const left = Math.floor((box.x + 8) * scale.x) + const width = Math.floor((box.width - 16) * scale.x) + return candidates.map((row) => { + const pixels = context.getImageData(left, row, width, 1).data + const indexes = Array.from({ length: width }, (_, index) => index * 4) + return { + luminance: + indexes + .map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3) + .reduce((sum, value) => sum + value, 0) / width, + magenta: + indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200) + .length / width, + } + }) + } + const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data + const columns = new Uint32Array(image.naturalWidth) + for (let index = 0; index < pixels.length; index += 4) { + if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue + columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1 + } + const top = box.y * scale.y + const bottom = (box.y + box.height) * scale.y + const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)]) + const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1]) + return { + box, + luminance: { + top: Math.min(...topRows.map((row) => row.luminance)), + bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance, + }, + magenta: { + top: Math.max(...topRows.map((row) => row.magenta)), + bottom: Math.max(...bottomRows.map((row) => row.magenta)), + vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length, + }, + } + }, + { + source: `data:image/png;base64,${screenshot.toString("base64")}`, + viewport, + box, + }, + ) +} diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index d6cc7be972..68fc4d3f4f 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1243,12 +1243,13 @@ export function MessageTimeline(props: { const initialRow = timelineRowByKey().get(props.rowKey)! const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem) const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow) - const asyncFile = () => { + const tool = () => { const value = row() - if (value._tag !== "AssistantPart" || value.group.type !== "part") return false + if (value._tag !== "AssistantPart" || value.group.type !== "part") return const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID) - return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool) + if (part?.type === "tool") return part } + const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.tool ?? "") const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile()) let contentMeasureFrame: number | undefined @@ -1278,6 +1279,8 @@ export function MessageTimeline(props: { width: "100%", height: `${item().size}px`, overflow: "clip", + // Rounded virtual measurements can otherwise clip a framed row's outer paint. + "overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px", }} >
Date: Thu, 9 Jul 2026 18:30:03 -0500 Subject: [PATCH 09/17] fix(tui): forward environment to worker (#35925) Co-authored-by: Aiden Cline --- packages/opencode/src/cli/cmd/tui.ts | 6 +++++- packages/opencode/test/cli/tui/thread.test.ts | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 6a9af10040..95ffac7ea5 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -207,7 +207,11 @@ export const TuiThreadCommand = cmd({ } const cwd = Filesystem.resolve(process.cwd()) - const worker = new Worker(file) + const worker = new Worker(file, { + env: Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + }) const client = Rpc.client(worker) const reload = () => { client.call("reload", undefined).catch(() => {}) diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 73f87d904b..2b29698086 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -16,6 +16,12 @@ describe("tui thread", () => { expect(source).not.toContain('import("./app")') }) + test("forwards the CLI environment to the TUI worker", async () => { + const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text() + + expect(source).toMatch(/new Worker\(file, \{\s*env: Object\.fromEntries\(\s*Object\.entries\(process\.env\)/) + }) + async function check(project?: string) { await using tmp = await tmpdir({ git: true }) const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") From 85ef35dd64c9a4e3afd640eaa6c843c3849aa943 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:53:56 -0500 Subject: [PATCH 10/17] fix(data): oom crash in stats sync --- infra/stats.ts | 4 ++- packages/stats/core/src/athena.ts | 41 +++++++++++++++++-------------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/infra/stats.ts b/infra/stats.ts index b5b0e1c600..10d37119f0 100644 --- a/infra/stats.ts +++ b/infra/stats.ts @@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", { cluster: lakeCluster, architecture: "arm64", cpu: "0.25 vCPU", - memory: "0.5 GB", + // 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena + // stats queries (~$5/pass) every ~5 minutes instead of hourly. + memory: "2 GB", image: { context: ".", dockerfile: "packages/stats/server/Dockerfile", diff --git a/packages/stats/core/src/athena.ts b/packages/stats/core/src/athena.ts index 50c356afa5..8879d52a66 100644 --- a/packages/stats/core/src/athena.ts +++ b/packages/stats/core/src/athena.ts @@ -116,28 +116,33 @@ const poll: ( const results: ( client: AwsAthenaClient, queryExecutionId: string, - nextToken?: string, ) => Effect.Effect = Effect.fn("Athena.results")(function* ( client: AwsAthenaClient, queryExecutionId: string, - nextToken?: string, ) { - const result = yield* Effect.tryPromise({ - try: () => - client.send( - new GetQueryResultsCommand({ - QueryExecutionId: queryExecutionId, - NextToken: nextToken, - MaxResults: ATHENA_PAGE_SIZE, - }), - ), - catch: (cause) => new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }), - }) - const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? [] - const rows = (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1).map((row) => rowData(columns, row)) - - if (!result.NextToken) return rows - return [...rows, ...(yield* results(client, queryExecutionId, result.NextToken))] + // Accumulate pages iteratively; recursive spreads copied every previously + // fetched row per page and blew up memory on large result sets. + const rows: AthenaData[] = [] + let nextToken: string | undefined + while (true) { + const result = yield* Effect.tryPromise({ + try: () => + client.send( + new GetQueryResultsCommand({ + QueryExecutionId: queryExecutionId, + NextToken: nextToken, + MaxResults: ATHENA_PAGE_SIZE, + }), + ), + catch: (cause) => + new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }), + }) + const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? [] + // The first page starts with the header row. + for (const row of (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1)) rows.push(rowData(columns, row)) + if (!result.NextToken) return rows + nextToken = result.NextToken + } }) function rowData(columns: string[], row: Row): AthenaData { From d09d5612f3f95dd08997ea095384b70b10c8dcb3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 10 Jul 2026 00:55:59 +0000 Subject: [PATCH 11/17] chore: generate --- packages/stats/core/src/athena.ts | 57 ++++++++++++++----------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/packages/stats/core/src/athena.ts b/packages/stats/core/src/athena.ts index 8879d52a66..96a48d3710 100644 --- a/packages/stats/core/src/athena.ts +++ b/packages/stats/core/src/athena.ts @@ -113,37 +113,32 @@ const poll: ( return yield* poll(client, queryExecutionId, attempt + 1) }) -const results: ( - client: AwsAthenaClient, - queryExecutionId: string, -) => Effect.Effect = Effect.fn("Athena.results")(function* ( - client: AwsAthenaClient, - queryExecutionId: string, -) { - // Accumulate pages iteratively; recursive spreads copied every previously - // fetched row per page and blew up memory on large result sets. - const rows: AthenaData[] = [] - let nextToken: string | undefined - while (true) { - const result = yield* Effect.tryPromise({ - try: () => - client.send( - new GetQueryResultsCommand({ - QueryExecutionId: queryExecutionId, - NextToken: nextToken, - MaxResults: ATHENA_PAGE_SIZE, - }), - ), - catch: (cause) => - new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }), - }) - const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? [] - // The first page starts with the header row. - for (const row of (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1)) rows.push(rowData(columns, row)) - if (!result.NextToken) return rows - nextToken = result.NextToken - } -}) +const results: (client: AwsAthenaClient, queryExecutionId: string) => Effect.Effect = + Effect.fn("Athena.results")(function* (client: AwsAthenaClient, queryExecutionId: string) { + // Accumulate pages iteratively; recursive spreads copied every previously + // fetched row per page and blew up memory on large result sets. + const rows: AthenaData[] = [] + let nextToken: string | undefined + while (true) { + const result = yield* Effect.tryPromise({ + try: () => + client.send( + new GetQueryResultsCommand({ + QueryExecutionId: queryExecutionId, + NextToken: nextToken, + MaxResults: ATHENA_PAGE_SIZE, + }), + ), + catch: (cause) => + new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }), + }) + const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? [] + // The first page starts with the header row. + for (const row of (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1)) rows.push(rowData(columns, row)) + if (!result.NextToken) return rows + nextToken = result.NextToken + } + }) function rowData(columns: string[], row: Row): AthenaData { return Object.fromEntries( From ea982c3832c75cb1330924ba8160da6497a2833a Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:29:30 +0800 Subject: [PATCH 12/17] fix(app): wrap session creation state updates in startTransition (#36182) --- .../app/src/components/prompt-input/submit.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05d978a947..6807b5ab6d 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -3,7 +3,7 @@ import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" -import { batch, type Accessor } from "solid-js" +import { batch, startTransition, type Accessor } from "solid-js" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" @@ -374,13 +374,16 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (created) { seed(sessionDirectory, created) session = created - if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) - local.session.promote(sessionDirectory, session.id) - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - const draftID = search.draftId - if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) - else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) - submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) + await startTransition(() => { + if (!session) return + if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) + local.session.promote(sessionDirectory, session.id) + layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) + const draftID = search.draftId + if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) + else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) + }) } } if (!session) { From 01131c75d8eeeb62be4b0a32251cf1915b0a6963 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:41:54 +0800 Subject: [PATCH 13/17] fix(app): focus input on new session (#36077) Co-authored-by: Victor Navarro --- packages/app/src/pages/new-session.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 7a8e44c5ab..926b0f2e97 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -26,6 +26,7 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands" import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" import { useTitlebarRightMount } from "@/components/titlebar" +import { useCommand } from "@/context/command" import { useProviders } from "@/hooks/use-providers" import { useSettingsDialog } from "@/components/settings-dialog" import { Persist, persisted } from "@/utils/persist" @@ -48,6 +49,7 @@ export default function NewSessionPage() { const comments = useComments() const language = useLanguage() const settings = useSettings() + const command = useCommand() const providers = useProviders(() => sdk().directory) const openProviderSettings = useSettingsDialog("providers") const route = useSessionKey() @@ -68,6 +70,16 @@ export default function NewSessionPage() { onDone: () => inputRef?.focus(), }) + command.register("new-session", () => [ + { + id: "input.focus", + title: language.t("command.input.focus"), + category: language.t("command.category.view"), + keybind: "ctrl+l", + onSelect: () => inputRef?.focus(), + }, + ]) + const [store, setStore] = createStore<{ worktree?: string }>({}) const rightMount = useTitlebarRightMount() From c769380776b1023cb205fe0ddf73fe24ec3e4a2f Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:22:05 +0530 Subject: [PATCH 14/17] feat(app): visual improvements (#36073) --- packages/app/src/components/prompt-input.tsx | 15 +++++----- .../session-sortable-terminal-tab-v2.tsx | 12 +++++++- .../components/settings-v2/settings-v2.css | 6 ++++ packages/app/src/components/terminal.tsx | 21 ++++++++++++-- packages/app/src/pages/session.tsx | 2 +- .../src/pages/session/session-side-panel.tsx | 28 ++++++++++++++++--- .../src/pages/session/terminal-panel-v2.tsx | 4 +-- .../session/timeline/message-timeline.tsx | 2 +- .../src/v2/components/session-review-v2.css | 4 +-- packages/ui/src/components/tabs.css | 19 +++++++++++++ .../ui/src/v2/components/icon-button-v2.css | 12 +++++--- .../ui/src/v2/components/line-comment-v2.css | 1 + packages/ui/src/v2/components/select-v2.css | 1 + packages/ui/src/v2/components/tabs-v2.css | 1 + 14 files changed, 103 insertions(+), 25 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 324a789825..2de29897e7 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -36,6 +36,8 @@ import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" @@ -1704,22 +1706,19 @@ export const PromptInput: Component = (props) => { > } + variant="ghost-muted" + size="large" style={buttons()} disabled={store.mode !== "normal"} tabIndex={store.mode === "normal" ? undefined : -1} aria-label={language.t("prompt.menu.addImagesAndFiles")} /> - + {language.t("prompt.menu.imagesAndFiles")} diff --git a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx index f0cdf41ac1..301783a0df 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx @@ -211,7 +211,17 @@ export function SortableTerminalTabV2(props: { { + // Switch on mousedown to shave the press-release delay off tab switches. + if (e.button !== 0) return + if (store.editing) return + focus() + }} + onClick={(e) => { + // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. + if (e.detail > 0) return + focus() + }} closeButton={ span { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 8998fc06c8..c5faf85bff 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,7 +1,8 @@ import { withAlpha } from "@opencode-ai/ui/theme/color" import { useTheme } from "@opencode-ai/ui/theme/context" import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" -import type { HexColor } from "@opencode-ai/ui/theme/types" +import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve" +import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types" import { showToast } from "@/utils/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" @@ -68,6 +69,19 @@ const debugTerminal = (...values: unknown[]) => { console.debug("[terminal]", ...values) } +const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => { + let current = tokens[key] + for (let i = 0; i < 8 && current; i++) { + const match = /^var\(--([^)]+)\)$/.exec(current.trim()) + if (!match) { + const hex = current.trim() + if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7) + return hex + } + current = tokens[match[1]] + } +} + const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term @@ -238,7 +252,10 @@ export const Terminal = (props: TerminalProps) => { if (!variant?.seeds && !variant?.palette) return fallback const resolved = resolveThemeVariant(variant, mode === "dark") const text = resolved["text-stronger"] ?? fallback.foreground - const background = resolved["background-stronger"] ?? fallback.background + const background = settings.general.newLayoutDesigns() + ? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ?? + fallback.background) + : (resolved["background-stronger"] ?? fallback.background) const alpha = mode === "dark" ? 0.25 : 0.2 const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) const selectionBackground = withAlpha(base, alpha) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index cbf47dc849..678f7b1687 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1304,7 +1304,7 @@ export default function Page() { const reviewPanelV2Rendered = createMemo((prev) => prev || !store.deferRender, false) const reviewPanelV2 = () => ( -
+
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 5e5f89e3e2..e009177682 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -255,8 +255,10 @@ export function SessionSidePanel(props: { aria-label={language.t("session.panel.reviewAndFiles")} aria-hidden={!open()} inert={!open()} - class="relative min-w-0 flex overflow-hidden bg-background-base" + class="relative min-w-0 flex overflow-hidden" classList={{ + "bg-v2-background-bg-base": settings.general.newLayoutDesigns(), + "bg-background-base": !settings.general.newLayoutDesigns(), "h-full shrink-0": !props.stacked, "h-full min-h-0": props.stacked, "pointer-events-none": !open(), @@ -275,8 +277,20 @@ export function SessionSidePanel(props: { }} > -
-
+
+
-
+
-
+
{(title) => (
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 68fc4d3f4f..a7390b6d61 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1386,7 +1386,7 @@ export function MessageTimeline(props: { "w-full": true, "pb-4": true, "pr-3": true, - "pl-2": settings.general.newLayoutDesigns(), + "pl-2.5": settings.general.newLayoutDesigns(), "pl-2 md:pl-4": !settings.general.newLayoutDesigns(), "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(), }} diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index c42fd20d3b..b116ebb43f 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -4,7 +4,7 @@ height: 100%; min-height: 0; overflow: hidden; - background: var(--background-stronger, var(--v2-background-bg-base)); + background: var(--v2-background-bg-base); } [data-component="session-review-v2"] [data-slot="session-review-v2-body"] { @@ -31,7 +31,7 @@ min-height: 0; overflow: hidden; border-right: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); - background: var(--background-stronger, var(--v2-background-bg-base)); + background: var(--v2-background-bg-base); } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"][aria-hidden="true"] { diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index fe450da85a..cd26b0cd50 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -679,3 +679,22 @@ [data-component="tabs-drag-preview"] > * { position: relative; } + +body[data-new-layout] #review-panel [data-component="tabs"], +body[data-new-layout] #terminal-panel [data-component="tabs"], +body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"], +body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] { + background-color: var(--v2-background-bg-base); + + [data-slot="tabs-list"] { + background-color: var(--v2-background-bg-base); + + > .sticky { + background-color: var(--v2-background-bg-base); + + &::before { + background: linear-gradient(90deg, transparent, var(--v2-background-bg-base)); + } + } + } +} diff --git a/packages/ui/src/v2/components/icon-button-v2.css b/packages/ui/src/v2/components/icon-button-v2.css index d8b5339575..a90a101a08 100644 --- a/packages/ui/src/v2/components/icon-button-v2.css +++ b/packages/ui/src/v2/components/icon-button-v2.css @@ -112,8 +112,8 @@ color: var(--v2-text-text-base); } -[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"], [data-expanded]):not( - :disabled +[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not( + [data-expanded] ) { background-color: var(--v2-overlay-simple-overlay-hover); } @@ -122,6 +122,10 @@ background-color: var(--v2-overlay-simple-overlay-pressed); } +[data-component="icon-button-v2"][data-variant="ghost"]:where([data-expanded]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + [data-component="icon-button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) { opacity: 0.5; cursor: not-allowed; @@ -133,8 +137,8 @@ color: var(--v2-icon-icon-muted); } -[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"], [data-expanded]):not( - :disabled +[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not( + [data-expanded] ) { background-color: var(--v2-overlay-simple-overlay-hover); } diff --git a/packages/ui/src/v2/components/line-comment-v2.css b/packages/ui/src/v2/components/line-comment-v2.css index 0428ca19cb..31be02ae28 100644 --- a/packages/ui/src/v2/components/line-comment-v2.css +++ b/packages/ui/src/v2/components/line-comment-v2.css @@ -6,6 +6,7 @@ [data-component="line-comment-v2"] { box-sizing: border-box; + font-family: var(--v2-font-family-sans); font-variant-numeric: tabular-nums; min-width: 0; width: 100%; diff --git a/packages/ui/src/v2/components/select-v2.css b/packages/ui/src/v2/components/select-v2.css index 38562b2ea6..c22a9d901b 100644 --- a/packages/ui/src/v2/components/select-v2.css +++ b/packages/ui/src/v2/components/select-v2.css @@ -65,6 +65,7 @@ box-shadow: var(--v2-elevation-button-neutral); flex: none; align-self: stretch; + user-select: none; transition: background 85ms ease-out, outline-color 85ms ease-out, diff --git a/packages/ui/src/v2/components/tabs-v2.css b/packages/ui/src/v2/components/tabs-v2.css index 8582bbf678..70abf78606 100644 --- a/packages/ui/src/v2/components/tabs-v2.css +++ b/packages/ui/src/v2/components/tabs-v2.css @@ -192,6 +192,7 @@ color: var(--v2-text-text-muted); font-size: 12px; font-weight: 500; + user-select: none; } [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"] { From 772e976d801d96f7319af2ee5efc8d5879cb35fc Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:29:03 +0530 Subject: [PATCH 15/17] fix(app): timeline width jumps around when review panel is open/closed (#36202) --- packages/app/src/pages/session.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 678f7b1687..0217ed668c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -483,7 +483,7 @@ export default function Page() { if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) - const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) + const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen())) const desktopV2PanelLayout = createMemo(() => sessionPanelLayout({ review: desktopV2ReviewOpen(), From d0ba5389248e05546849b9f69b7bc417aa5fd5d7 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jul 2026 01:48:51 -0400 Subject: [PATCH 16/17] zen: track openai cache_write_tokens --- artifacts/glm52-rise-video/sst-env.d.ts | 10 ++++++++++ packages/codemode/sst-env.d.ts | 10 ++++++++++ .../app/src/routes/zen/util/provider/openai.ts | 4 +++- packages/console/app/test/providerUsage.test.ts | 16 ++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 artifacts/glm52-rise-video/sst-env.d.ts create mode 100644 packages/codemode/sst-env.d.ts diff --git a/artifacts/glm52-rise-video/sst-env.d.ts b/artifacts/glm52-rise-video/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/artifacts/glm52-rise-video/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/codemode/sst-env.d.ts b/packages/codemode/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/codemode/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index 0e9992a289..cebc78a127 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -4,6 +4,7 @@ type Usage = { input_tokens?: number input_tokens_details?: { cached_tokens?: number + cache_write_tokens?: number } output_tokens?: number output_tokens_details?: { @@ -48,12 +49,13 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ const outputTokens = usage.output_tokens ?? 0 const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined + const cacheWriteTokens = usage.input_tokens_details?.cache_write_tokens ?? undefined return { inputTokens: inputTokens - (cacheReadTokens ?? 0), outputTokens, reasoningTokens, cacheReadTokens, - cacheWrite5mTokens: undefined, + cacheWrite5mTokens: cacheWriteTokens, cacheWrite1hTokens: undefined, } }, diff --git a/packages/console/app/test/providerUsage.test.ts b/packages/console/app/test/providerUsage.test.ts index d39c9fa861..7be3aacffd 100644 --- a/packages/console/app/test/providerUsage.test.ts +++ b/packages/console/app/test/providerUsage.test.ts @@ -65,4 +65,20 @@ describe("provider usage extraction", () => { output_tokens: 7, }) }) + + test("parses OpenAI stream cache write usage", () => { + const usageParser = providers.openai.createUsageParser() + usageParser.parse( + 'event: response.completed\ndata: {"response":{"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":4,"cache_write_tokens":3},"output_tokens":2}}}', + ) + + expect(providers.openai.normalizeUsage(usageParser.retrieve())).toEqual({ + inputTokens: 6, + outputTokens: 2, + reasoningTokens: undefined, + cacheReadTokens: 4, + cacheWrite5mTokens: 3, + cacheWrite1hTokens: undefined, + }) + }) }) From 8a03fc265b6d73c2e15881fcc702c9cb3027dd0e Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:49:05 +0800 Subject: [PATCH 17/17] feat(app): extract prompt state and add composer model selection (#36227) --- packages/app/src/components/prompt-input.tsx | 1 + .../components/prompt-input/submit.test.ts | 38 +++ .../app/src/components/prompt-input/submit.ts | 14 +- packages/app/src/components/titlebar.tsx | 12 +- packages/app/src/context/local.tsx | 9 +- packages/app/src/context/prompt-state.test.ts | 29 ++ packages/app/src/context/prompt-state.ts | 265 +++++++++++++++ packages/app/src/context/prompt.tsx | 320 +++--------------- packages/app/src/context/tab-memory.ts | 3 + packages/app/src/context/tabs.test.ts | 2 + packages/app/src/context/tabs.tsx | 12 +- packages/app/src/pages/new-session.tsx | 7 +- packages/app/src/pages/session.tsx | 13 +- .../composer/prompt-model-selection.ts | 133 ++++++++ .../composer/session-composer-controls.ts | 5 +- .../session/session-model-helpers.test.ts | 101 +++++- .../pages/session/session-model-helpers.ts | 47 +++ .../pages/session/use-composer-commands.tsx | 9 +- packages/app/src/utils/persist.ts | 2 +- 19 files changed, 729 insertions(+), 293 deletions(-) create mode 100644 packages/app/src/context/prompt-state.test.ts create mode 100644 packages/app/src/context/prompt-state.ts create mode 100644 packages/app/src/pages/session/composer/prompt-model-selection.ts diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2de29897e7..bbb71e8722 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1335,6 +1335,7 @@ export const PromptInput: Component = (props) => { onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, + model: props.controls.model.selection, }) const handleKeyDown = (event: KeyboardEvent) => { diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 9a65fa101d..ad9fd31db8 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -1,5 +1,6 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import type { Prompt } from "@/context/prompt" +import type { ModelSelection } from "@/context/local" let createPromptSubmit: typeof import("./submit").createPromptSubmit @@ -33,6 +34,10 @@ const prompt = { current: () => promptValue, cursor: () => 0, dirty: () => true, + model: { + current: () => undefined, + set: () => undefined, + }, reset: () => undefined, set: () => undefined, context: { @@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => { }) }) + test("uses an injected model selection", async () => { + params = { id: "session-1" } + const model = { + current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }), + variant: { current: () => "draft-variant" }, + } as unknown as ModelSelection + const submit = createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + model, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + + expect(optimistic[0]).toMatchObject({ + message: { + model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" }, + }, + }) + }) + test("seeds new sessions before optimistic prompts are added", async () => { const submit = createPromptSubmit({ prompt, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 6807b5ab6d..c9f560d269 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -8,7 +8,7 @@ import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useLocal } from "@/context/local" +import { useLocal, type ModelSelection } from "@/context/local" import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { useSDK, type DirectorySDK } from "@/context/sdk" @@ -191,6 +191,7 @@ type PromptSubmitInput = { onQueue?: (draft: FollowupDraft) => void onAbort?: () => void onSubmit?: () => void + model?: ModelSelection } export function createPromptSubmit(input: PromptSubmitInput) { @@ -298,9 +299,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } - const currentModel = local.model.current() + const modelSelection = input.model ?? local.model + const currentModel = modelSelection.current() const currentAgent = local.agent.current() - const variant = local.model.variant.current() + const variant = modelSelection.variant.current() if (!currentModel || !currentAgent) { showToast({ title: language.t("prompt.toast.modelAgentRequired.title"), @@ -377,7 +379,11 @@ export function createPromptSubmit(input: PromptSubmitInput) { await startTransition(() => { if (!session) return if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) - local.session.promote(sessionDirectory, session.id) + local.session.promote(sessionDirectory, session.id, { + agent: currentAgent.name, + model: { providerID: currentModel.provider.id, modelID: currentModel.id }, + variant: variant ?? null, + }) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) const draftID = search.draftId if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 2a818d565d..994d69a60f 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp import { useGlobal } from "@/context/global" import { ServerConnection, useServer } from "@/context/server" import { tabKey, useTabs } from "@/context/tabs" +import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" @@ -324,13 +325,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const route = layout.route() const activeSession = session() if (route.type === "session" && activeSession) { - tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "") + const sessionTab = { + type: "session" as const, + server: route.server ?? server.key, + sessionId: activeSession.id, + } + const model = tabs.stateValue(sessionTab, "prompt")?.model.current() + tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model) return } const activeTab = currentTab() if (activeTab?.type === "draft") { - tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "") + const model = tabs.stateValue(activeTab, "prompt")?.model.current() + tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model) return } diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 415da747e0..a12fd832fd 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -67,7 +67,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) - const [saved, setSaved] = persisted( + const [saved, setSaved, , savedReady] = persisted( { ...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]), migrate, @@ -375,11 +375,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model, agent, session: { + ready: savedReady, reset() { setStore({ draft: undefined, promoting: undefined }) }, - promote(dir: string, session: string) { - const next = clone(snapshot()) + promote(dir: string, session: string, state?: State) { + const next = clone(state ?? snapshot()) if (!next) return const key = handoffKey(serverSDK().scope, dir, session) handoff.set(key, next) @@ -409,3 +410,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return result }, }) + +export type ModelSelection = ReturnType["model"] diff --git a/packages/app/src/context/prompt-state.test.ts b/packages/app/src/context/prompt-state.test.ts new file mode 100644 index 0000000000..24afe5424e --- /dev/null +++ b/packages/app/src/context/prompt-state.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createPromptState, DEFAULT_PROMPT } from "./prompt-state" + +describe("prompt state initialization", () => { + test("initializes prompt text, cursor, and model together", () => { + createRoot((dispose) => { + const model = { providerID: "anthropic", modelID: "claude", variant: "high" } + const prompt = createPromptState({ prompt: "hello", model }) + + expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }]) + expect(prompt.cursor()).toBe(5) + expect(prompt.model.current()).toEqual(model) + expect(prompt.model.current()).not.toBe(model) + dispose() + }) + }) + + test("uses the default prompt without initial values", () => { + createRoot((dispose) => { + const prompt = createPromptState() + + expect(prompt.current()).toEqual(DEFAULT_PROMPT) + expect(prompt.cursor()).toBeUndefined() + expect(prompt.model.current()).toBeUndefined() + dispose() + }) + }) +}) diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts new file mode 100644 index 0000000000..b6a68be4e7 --- /dev/null +++ b/packages/app/src/context/prompt-state.ts @@ -0,0 +1,265 @@ +import { checksum } from "@opencode-ai/core/util/encode" +import type { FilePartSource } from "@opencode-ai/sdk/v2/client" +import { batch, createMemo, type Accessor } from "solid-js" +import { createStore, type SetStoreFunction } from "solid-js/store" +import type { FileSelection } from "@/context/file" +import { Persist, persisted } from "@/utils/persist" +import type { ServerScope } from "@/utils/server-scope" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: FileSelection + mime?: string + filename?: string + url?: string + source?: FilePartSource +} + +export interface AgentPart extends PartBase { + type: "agent" + name: string +} + +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + sourcePath?: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart +export type Prompt = ContentPart[] + +export type PromptModel = { + providerID: string + modelID: string + variant?: string | null +} + +export type FileContextItem = { + type: "file" + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export type ContextItem = FileContextItem +export type PromptScope = { draftID: string } | { dir: string; id?: string } + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +type PromptStore = { + prompt: Prompt + cursor?: number + model?: PromptModel + context: { + items: (ContextItem & { key: string })[] + } +} + +type InitialPrompt = { + prompt?: string + model?: PromptModel +} + +function isSelectionEqual(a?: FileSelection, b?: FileSelection) { + if (!a && !b) return true + if (!a || !b) return false + return ( + a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar + ) +} + +function isPartEqual(partA: ContentPart, partB: ContentPart) { + switch (partA.type) { + case "text": + return partB.type === "text" && partA.content === partB.content + case "file": + return ( + partB.type === "file" && + partA.path === partB.path && + partA.mime === partB.mime && + partA.filename === partB.filename && + isSelectionEqual(partA.selection, partB.selection) + ) + case "agent": + return partB.type === "agent" && partA.name === partB.name + case "image": + return partB.type === "image" && partA.id === partB.id + } +} + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + if (!isPartEqual(promptA[i], promptB[i])) return false + } + return true +} + +function cloneSelection(selection?: FileSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +function contextItemKey(item: ContextItem) { + if (item.type !== "file") return item.type + const start = item.selection?.startLine + const end = item.selection?.endLine + const key = `${item.type}:${item.path}:${start}:${end}` + + if (item.commentID) return `${key}:c=${item.commentID}` + const comment = item.comment?.trim() + if (!comment) return key + const digest = checksum(comment) ?? comment + return `${key}:c=${digest.slice(0, 8)}` +} + +function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { + return item.type === "file" && !!item.comment?.trim() +} + +function createPromptActions(setStore: SetStoreFunction) { + return { + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } +} + +function promptTarget(serverScope: ServerScope, scope: PromptScope) { + if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") + const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` + return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) +} + +function promptStore(initial?: InitialPrompt): PromptStore { + const text = initial?.prompt + return { + prompt: + text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }], + cursor: text === undefined ? undefined : text.length, + model: initial?.model ? { ...initial.model } : undefined, + context: { + items: [], + }, + } +} + +function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { + const actions = createPromptActions(setStore) + const value = { + current: () => store.prompt, + cursor: createMemo(() => store.cursor), + dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), + model: { + current: () => store.model, + set: (model: PromptModel | undefined) => setStore("model", model), + }, + context: { + items: createMemo(() => store.context.items), + add(item: ContextItem) { + const key = contextItemKey(item) + if (store.context.items.find((x) => x.key === key)) return + setStore("context", "items", (items) => [...items, { key, ...item }]) + }, + remove(key: string) { + setStore("context", "items", (items) => items.filter((x) => x.key !== key)) + }, + removeComment(path: string, commentID: string) { + setStore("context", "items", (items) => + items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), + ) + }, + updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { + setStore("context", "items", (items) => + items.map((item) => { + if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item + const value = { ...item, ...next } + return { ...value, key: contextItemKey(value) } + }), + ) + }, + replaceComments(items: FileContextItem[]) { + setStore("context", "items", (current) => [ + ...current.filter((item) => !isCommentItem(item)), + ...items.map((item) => ({ ...item, key: contextItemKey(item) })), + ]) + }, + }, + set: actions.set, + reset: actions.reset, + capture: () => value, + } + return value +} + +function createPersistedPrompt(target: ReturnType, initial?: InitialPrompt) { + const [store, setStore, _, ready] = persisted(target, createStore(promptStore(initial))) + return { ready, ...createPromptStateValue(store, setStore) } +} + +export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) { + return createPersistedPrompt(promptTarget(serverScope, scope), initial) +} + +export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) { + return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial) +} + +export type PromptSession = ReturnType + +export function createPromptReady(session: Accessor) { + return Object.defineProperty(() => session().ready(), "promise", { + get: () => session().ready.promise, + }) as (() => boolean) & { readonly promise: Promise | undefined } +} + +export function createPromptState(initial?: InitialPrompt) { + const [store, setStore] = createStore(promptStore(initial)) + const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) + return { + ready, + ...createPromptStateValue(store, setStore), + } +} diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index c361493516..0ad8e70f3c 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,186 +1,49 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" import { createSimpleContext } from "@opencode-ai/ui/context" -import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useParams, useSearchParams } from "@solidjs/router" -import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js" -import { createStore, type SetStoreFunction } from "solid-js/store" -import type { FileSelection } from "@/context/file" -import { Persist, persisted } from "@/utils/persist" +import { createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { requireServerKey } from "@/utils/session-route" +import { ServerConnection } from "./server" import { useServerSDK } from "./server-sdk" -import type { ServerScope } from "@/utils/server-scope" +import { useSettings } from "./settings" import { useSDK } from "./sdk" import { useTabs, type Tab } from "./tabs" -import { ServerConnection } from "./server" -import { requireServerKey } from "@/utils/session-route" -import { useSettings } from "./settings" -import type { FilePartSource } from "@opencode-ai/sdk/v2/client" +import { + createPromptReady, + createPromptSession, + type ContextItem, + type FileContextItem, + type Prompt, + type PromptModel, + type PromptScope, + type PromptSession, +} from "./prompt-state" -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: FileSelection - mime?: string - filename?: string - url?: string - source?: FilePartSource -} - -export interface AgentPart extends PartBase { - type: "agent" - name: string -} - -export interface ImageAttachmentPart { - type: "image" - id: string - filename: string - sourcePath?: string - mime: string - dataUrl: string -} - -export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart -export type Prompt = ContentPart[] - -export type FileContextItem = { - type: "file" - path: string - selection?: FileSelection - comment?: string - commentID?: string - commentOrigin?: "review" | "file" - preview?: string -} - -export type ContextItem = FileContextItem - -export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] - -function isSelectionEqual(a?: FileSelection, b?: FileSelection) { - if (!a && !b) return true - if (!a || !b) return false - return ( - a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar - ) -} - -function isPartEqual(partA: ContentPart, partB: ContentPart) { - switch (partA.type) { - case "text": - return partB.type === "text" && partA.content === partB.content - case "file": - return ( - partB.type === "file" && - partA.path === partB.path && - partA.mime === partB.mime && - partA.filename === partB.filename && - isSelectionEqual(partA.selection, partB.selection) - ) - case "agent": - return partB.type === "agent" && partA.name === partB.name - case "image": - return partB.type === "image" && partA.id === partB.id - } -} - -export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - if (!isPartEqual(promptA[i], promptB[i])) return false - } - return true -} - -function cloneSelection(selection?: FileSelection) { - if (!selection) return undefined - return { ...selection } -} - -function clonePart(part: ContentPart): ContentPart { - if (part.type === "text") return { ...part } - if (part.type === "image") return { ...part } - if (part.type === "agent") return { ...part } - return { - ...part, - selection: cloneSelection(part.selection), - } -} - -function clonePrompt(prompt: Prompt): Prompt { - return prompt.map(clonePart) -} - -function contextItemKey(item: ContextItem) { - if (item.type !== "file") return item.type - const start = item.selection?.startLine - const end = item.selection?.endLine - const key = `${item.type}:${item.path}:${start}:${end}` - - if (item.commentID) { - return `${key}:c=${item.commentID}` - } - - const comment = item.comment?.trim() - if (!comment) return key - const digest = checksum(comment) ?? comment - return `${key}:c=${digest.slice(0, 8)}` -} - -function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { - return item.type === "file" && !!item.comment?.trim() -} - -function createPromptActions( - setStore: SetStoreFunction<{ - prompt: Prompt - cursor?: number - context: { - items: (ContextItem & { key: string })[] - } - }>, -) { - return { - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - reset() { - batch(() => { - setStore("prompt", clonePrompt(DEFAULT_PROMPT)) - setStore("cursor", 0) - }) - }, - } -} +export { + createPromptReady, + createPromptSession, + createPromptState, + DEFAULT_PROMPT, + isPromptEqual, +} from "./prompt-state" +export type { + AgentPart, + ContentPart, + ContextItem, + FileAttachmentPart, + FileContextItem, + ImageAttachmentPart, + Prompt, + PromptModel, + PromptScope, + PromptSession, + TextPart, +} from "./prompt-state" const WORKSPACE_KEY = "__workspace__" const MAX_PROMPT_SESSIONS = 20 -type PromptSession = ReturnType - -type PromptStore = { - prompt: Prompt - cursor?: number - context: { - items: (ContextItem & { key: string })[] - } -} - -type Scope = { draftID: string } | { dir: string; id?: string } - -export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) { +export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) { if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID) if (!scope.id) return return ( @@ -189,7 +52,7 @@ export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnect ) } -function scopeKey(scope: Scope) { +function scopeKey(scope: PromptScope) { if ("draftID" in scope) return `draft:${scope.draftID}` return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}` } @@ -199,91 +62,6 @@ type PromptCacheEntry = { dispose: VoidFunction } -function promptTarget(serverScope: ServerScope, scope: Scope) { - if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") - const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` - return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) -} - -export function createPromptSession(serverScope: ServerScope, scope: Scope) { - const [store, setStore, _, ready] = persisted( - promptTarget(serverScope, scope), - createStore(promptStore()), - ) - - return { ready, ...createPromptStateValue(store, setStore) } -} - -export function createPromptReady(session: Accessor) { - return Object.defineProperty(() => session().ready(), "promise", { - get: () => session().ready.promise, - }) as (() => boolean) & { readonly promise: Promise | undefined } -} - -function promptStore(): PromptStore { - return { - prompt: clonePrompt(DEFAULT_PROMPT), - cursor: undefined, - context: { - items: [], - }, - } -} - -function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { - const actions = createPromptActions(setStore) - - const value = { - current: () => store.prompt, - cursor: createMemo(() => store.cursor), - dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), - context: { - items: createMemo(() => store.context.items), - add(item: ContextItem) { - const key = contextItemKey(item) - if (store.context.items.find((x) => x.key === key)) return - setStore("context", "items", (items) => [...items, { key, ...item }]) - }, - remove(key: string) { - setStore("context", "items", (items) => items.filter((x) => x.key !== key)) - }, - removeComment(path: string, commentID: string) { - setStore("context", "items", (items) => - items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), - ) - }, - updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { - setStore("context", "items", (items) => - items.map((item) => { - if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item - const value = { ...item, ...next } - return { ...value, key: contextItemKey(value) } - }), - ) - }, - replaceComments(items: FileContextItem[]) { - setStore("context", "items", (current) => [ - ...current.filter((item) => !isCommentItem(item)), - ...items.map((item) => ({ ...item, key: contextItemKey(item) })), - ]) - }, - }, - set: actions.set, - reset: actions.reset, - capture: () => value, - } - return value -} - -export function createPromptState() { - const [store, setStore] = createStore(promptStore()) - const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) - return { - ready, - ...createPromptStateValue(store, setStore), - } -} - export const createTabPromptState = ( tabs: ReturnType, tab: Tab, @@ -303,9 +81,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const cache = new Map() const disposeAll = () => { - for (const entry of cache.values()) { - entry.dispose() - } + for (const entry of cache.values()) entry.dispose() cache.clear() } @@ -324,13 +100,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const owner = getOwner() const serverKey = () => params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server) - const scope = () => + const scope = (): PromptScope => search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } - const load = (scope: Scope) => { + const load = (scope: PromptScope) => { const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined - if (current) { - return createTabPromptState(tabs, current, serverSDK().scope, scope) - } + if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope) const key = scopeKey(scope) const existing = cache.get(key) @@ -354,15 +128,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const session = createMemo(() => load(scope())) - const pick = (scope?: Scope) => (scope ? load(scope) : session()) + const pick = (scope?: PromptScope) => (scope ? load(scope) : session()) const ready = createPromptReady(session) return { ready, - capture: (scope?: Scope) => pick(scope).capture(), + capture: (scope?: PromptScope) => pick(scope).capture(), current: () => session().current(), cursor: () => session().cursor(), dirty: () => session().dirty(), + model: { + current: () => session().model.current(), + set: (model: PromptModel | undefined) => session().model.set(model), + }, context: { items: () => session().context.items(), add: (item: ContextItem) => session().context.add(item), @@ -372,8 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( session().context.updateComment(path, commentID, next), replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), }, - set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition), - reset: (scope?: Scope) => pick(scope).reset(), + set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition), + reset: (scope?: PromptScope) => pick(scope).reset(), } }, }) diff --git a/packages/app/src/context/tab-memory.ts b/packages/app/src/context/tab-memory.ts index de8050f379..70bb987c7b 100644 --- a/packages/app/src/context/tab-memory.ts +++ b/packages/app/src/context/tab-memory.ts @@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) { } return { + get(key: string, name: string) { + return entries.get(key)?.get(name)?.value as T | undefined + }, ensure(key: string, name: string, init: () => T) { const state = entries.get(key) ?? new Map() if (!entries.has(key)) entries.set(key, state) diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index b4bff4caff..b56ea2dfcf 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -22,6 +22,8 @@ describe("tab memory", () => { }) expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first) + expect(memory.get("tab", "prompt")).toBe(first) + expect(memory.get("missing", "prompt")).toBeUndefined() expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first) memory.remove("tab") diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 47ed77e781..cac6500350 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" +import { createDraftPromptSession, type PromptModel } from "./prompt-state" export type SessionTab = { type: "session" @@ -207,15 +208,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) return tab }, - newDraft(draft: Omit, prompt?: string) { + newDraft(draft: Omit, prompt?: string, model?: PromptModel) { const draftID = uuid() + const tab = { type: "draft" as const, draftID, ...draft } + memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model })) void startTransition(() => { setStore( produce((tabs) => { - tabs.push({ type: "draft", draftID, ...draft }) + tabs.push(tab) }), ) - navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) + navigate(draftHref(draftID)) }) }, updateDraft(draftID: string, draft: Partial>) { @@ -373,6 +376,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ state(tab: Tab, name: string, init: () => T) { return memory.ensure(tabKey(tab), name, init) }, + stateValue(tab: Tab, name: string) { + return memory.get(tabKey(tab), name) + }, } return { ...actions, store, info, ready, recentReady } diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 926b0f2e97..19c90b9a38 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -31,6 +31,8 @@ import { useProviders } from "@/hooks/use-providers" import { useSettingsDialog } from "@/components/settings-dialog" import { Persist, persisted } from "@/utils/persist" import createPresence from "solid-presence" +import { useLocal } from "@/context/local" +import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 @@ -54,8 +56,10 @@ export default function NewSessionPage() { const openProviderSettings = useSettingsDialog("providers") const route = useSessionKey() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() + const local = useLocal() + const model = createPromptModelSelection({ agent: local.agent.current }) - useComposerCommands() + useComposerCommands({ model }) let inputRef: HTMLDivElement | undefined @@ -63,6 +67,7 @@ export default function NewSessionPage() { sessionKey: route.sessionKey, sessionID: () => route.params.id, queryOptions: serverSync().queryOptions, + model, }) const projectControls = createPromptProjectControls() const projectController = createPromptProjectController({ diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 0217ed668c..66afcfa9bf 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -69,7 +69,7 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline" import { createTimelineModel } from "@/pages/session/timeline/model" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" -import { syncSessionModel } from "@/pages/session/session-model-helpers" +import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers" import { clampSessionPanelWidth, SESSION_PANEL_WIDTH_MIN, @@ -557,6 +557,17 @@ export default function Page() { ), ) + let restoredModelSession: string | undefined + createEffect(() => { + const id = params.id + if (!id || !prompt.ready() || !local.session.ready()) return + if (restoredModelSession !== id) { + restoredModelSession = id + if (restorePromptModel(local, prompt)) return + } + syncPromptModel(local, prompt) + }) + createEffect( on( () => ({ dir: sdk().directory, id: params.id }), diff --git a/packages/app/src/pages/session/composer/prompt-model-selection.ts b/packages/app/src/pages/session/composer/prompt-model-selection.ts new file mode 100644 index 0000000000..3f5bb0f477 --- /dev/null +++ b/packages/app/src/pages/session/composer/prompt-model-selection.ts @@ -0,0 +1,133 @@ +import { batch, createMemo, startTransition } from "solid-js" +import { useModels } from "@/context/models" +import type { ModelKey, ModelSelection } from "@/context/local" +import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant" +import { usePrompt } from "@/context/prompt" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useProviders } from "@/hooks/use-providers" + +export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) { + const sdk = useSDK() + const sync = useSync() + const models = useModels() + const prompt = usePrompt() + const providers = useProviders(() => sdk().directory) + const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) + + const valid = (model: ModelKey) => { + const provider = providers.all().get(model.providerID) + return !!provider?.models[model.modelID] && connected().has(model.providerID) + } + + const configured = () => { + const value = sync().data.config.model + if (!value) return + const [providerID, modelID] = value.split("/") + const model = { providerID, modelID } + if (valid(model)) return model + } + + const recent = () => models.recent.list().find(valid) + const fallback = () => { + const defaults = providers.default() + return providers.connected().flatMap((provider) => { + const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id + return modelID ? [{ providerID: provider.id, modelID }] : [] + })[0] + } + + const current = () => { + const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find( + (item): item is ModelKey => !!item && valid(item), + ) + if (!key) return + return models.find(key) + } + const recentModels = createMemo(() => + models.recent + .list() + .map(models.find) + .filter((item): item is NonNullable => !!item), + ) + + const selection = { + ready: models.ready, + current, + recent: recentModels, + list: models.list, + cycle(direction: 1 | -1) { + const items = recentModels() + const item = current() + if (!item) return + const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id) + if (index === -1) return + const next = items[(index + direction + items.length) % items.length] + if (next) selection.set({ providerID: next.provider.id, modelID: next.id }) + }, + set(item: ModelKey | undefined, options?: { recent?: boolean }) { + startTransition(() => + batch(() => { + prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined) + if (!item) return + models.setVisibility(item, true) + if (options?.recent) models.recent.push(item) + }), + ) + }, + visible: models.visible, + setVisibility: models.setVisibility, + variant: { + configured() { + const item = input.agent() + const model = current() + if (!item || !model) return + return getConfiguredAgentVariant({ + agent: { model: item.model, variant: item.variant }, + model: { providerID: model.provider.id, modelID: model.id, variants: model.variants }, + }) + }, + selected() { + return prompt.model.current()?.variant + }, + current() { + const resolved = resolveModelVariant({ + variants: this.list(), + selected: this.selected(), + configured: this.configured(), + }) + if (resolved) return resolved + const model = current() + if (!model) return + const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id }) + if (saved && this.list().includes(saved)) return saved + }, + list() { + return Object.keys(current()?.variants ?? {}) + }, + set(value: string | undefined) { + startTransition(() => + batch(() => { + const model = current() + if (!model) return + prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null }) + models.variant.set({ providerID: model.provider.id, modelID: model.id }, value) + }), + ) + }, + cycle() { + const variants = this.list() + if (variants.length === 0) return + this.set( + cycleModelVariant({ + variants, + selected: this.selected(), + configured: this.configured(), + }), + ) + }, + }, + } satisfies ModelSelection + + return selection +} diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index c7f2304531..8d5d96719c 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -7,7 +7,7 @@ import type { PromptProjectControls } from "@/components/prompt-project-selector import { useDirectoryPicker } from "@/components/directory-picker" import { useGlobal } from "@/context/global" import { useLayout } from "@/context/layout" -import { useLocal } from "@/context/local" +import { useLocal, type ModelSelection } from "@/context/local" import type { QueryOptionsApi } from "@/context/server-sync" import { useServerSDK } from "@/context/server-sdk" import { serverName, ServerConnection, useServer } from "@/context/server" @@ -22,6 +22,7 @@ export function createPromptInputController(input: { sessionKey: Accessor sessionID: Accessor queryOptions: Pick + model?: ModelSelection }) { const layout = useLayout() const local = useLocal() @@ -44,7 +45,7 @@ export function createPromptInputController(input: { select: local.agent.set, }, model: { - selection: local.model, + selection: input.model ?? local.model, paid: providers.paid().length > 0, loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, }, diff --git a/packages/app/src/pages/session/session-model-helpers.test.ts b/packages/app/src/pages/session/session-model-helpers.test.ts index 2ab293b8fb..201627b369 100644 --- a/packages/app/src/pages/session/session-model-helpers.test.ts +++ b/packages/app/src/pages/session/session-model-helpers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { UserMessage } from "@opencode-ai/sdk/v2" -import { resetSessionModel, syncSessionModel } from "./session-model-helpers" +import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers" const message = (input?: { agent?: string; model?: UserMessage["model"] }) => ({ @@ -50,3 +50,102 @@ describe("resetSessionModel", () => { expect(calls).toEqual(["reset"]) }) }) + +describe("syncPromptModel", () => { + test("stores the effective session model in prompt state", () => { + const calls: unknown[] = [] + + syncPromptModel( + { + model: { + current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }), + set() {}, + variant: { current: () => "high", set() {} }, + }, + }, + { + model: { + current: () => undefined, + set: (model) => calls.push(model), + }, + }, + ) + + expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }]) + }) + + test("does not rewrite an unchanged prompt model", () => { + const calls: unknown[] = [] + const model = { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } + + syncPromptModel( + { + model: { + current: () => ({ id: model.modelID, provider: { id: model.providerID } }), + set() {}, + variant: { current: () => model.variant, set() {} }, + }, + }, + { + model: { + current: () => model, + set: (value) => calls.push(value), + }, + }, + ) + + expect(calls).toEqual([]) + }) +}) + +describe("restorePromptModel", () => { + test("restores the persisted prompt model into session selection", () => { + const calls: unknown[] = [] + const restored = restorePromptModel( + { + model: { + current: () => ({ id: "gpt", provider: { id: "openai" } }), + set: (model) => calls.push(model), + variant: { + current: () => undefined, + set: (variant) => calls.push(variant), + }, + }, + }, + { + model: { + current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }), + set() {}, + }, + }, + ) + + expect(restored).toBe(true) + expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"]) + }) + + test("does nothing without a persisted prompt model", () => { + const calls: unknown[] = [] + const restored = restorePromptModel( + { + model: { + current: () => ({ id: "gpt", provider: { id: "openai" } }), + set: (model) => calls.push(model), + variant: { + current: () => undefined, + set: (variant) => calls.push(variant), + }, + }, + }, + { + model: { + current: () => undefined, + set() {}, + }, + }, + ) + + expect(restored).toBe(false) + expect(calls).toEqual([]) + }) +}) diff --git a/packages/app/src/pages/session/session-model-helpers.ts b/packages/app/src/pages/session/session-model-helpers.ts index c9e2e1dbd2..f2aadff27d 100644 --- a/packages/app/src/pages/session/session-model-helpers.ts +++ b/packages/app/src/pages/session/session-model-helpers.ts @@ -7,6 +7,24 @@ type Local = { } } +type ModelSelection = { + model: { + current(): { id: string; provider: { id: string } } | undefined + set(model: { providerID: string; modelID: string }): void + variant: { + current(): string | undefined + set(variant: string | undefined): void + } + } +} + +type PromptState = { + model: { + current(): { providerID: string; modelID: string; variant?: string | null } | undefined + set(model: { providerID: string; modelID: string; variant?: string | null }): void + } +} + export const resetSessionModel = (local: Local) => { local.session.reset() } @@ -14,3 +32,32 @@ export const resetSessionModel = (local: Local) => { export const syncSessionModel = (local: Local, msg: UserMessage) => { local.session.restore(msg) } + +export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => { + const model = local.model.current() + if (!model) return + const next = { + providerID: model.provider.id, + modelID: model.id, + variant: local.model.variant.current(), + } + const current = prompt.model.current() + if (current?.providerID === next.providerID && current.modelID === next.modelID && current.variant === next.variant) + return + prompt.model.set(next) +} + +export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => { + const model = prompt.model.current() + if (!model) return false + const current = local.model.current() + if ( + current?.provider.id === model.providerID && + current.id === model.modelID && + local.model.variant.current() === (model.variant ?? undefined) + ) + return true + local.model.set({ providerID: model.providerID, modelID: model.modelID }) + local.model.variant.set(model.variant ?? undefined) + return true +} diff --git a/packages/app/src/pages/session/use-composer-commands.tsx b/packages/app/src/pages/session/use-composer-commands.tsx index 98239010f5..9b75c7bf4c 100644 --- a/packages/app/src/pages/session/use-composer-commands.tsx +++ b/packages/app/src/pages/session/use-composer-commands.tsx @@ -1,6 +1,6 @@ import { useCommand, type CommandOption } from "@/context/command" import { useLanguage } from "@/context/language" -import { useLocal } from "@/context/local" +import { useLocal, type ModelSelection } from "@/context/local" import { useSettings } from "@/context/settings" import { useDialog } from "@opencode-ai/ui/context/dialog" import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom" @@ -14,7 +14,7 @@ const withCategory = (category: string) => { }) } -export const useComposerCommands = () => { +export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { const command = useCommand() const dialog = useDialog() const language = useLanguage() @@ -22,6 +22,7 @@ export const useComposerCommands = () => { const settings = useSettings() const { sessionKey } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) + const model = input.model ?? local.model const modelCommand = withCategory(language.t("command.category.model")) const agentCommand = withCategory(language.t("command.category.agent")) @@ -43,7 +44,7 @@ export const useComposerCommands = () => { } const { DialogSelectModel } = await import("@/components/dialog-select-model") owner.run(() => { - void dialog.show(() => , restoreComposer) + void dialog.show(() => , restoreComposer) }) } @@ -61,7 +62,7 @@ export const useComposerCommands = () => { title: language.t("command.model.variant.cycle"), description: language.t("command.model.variant.cycle.description"), keybind: "shift+mod+d", - onSelect: () => local.model.variant.cycle(), + onSelect: () => model.variant.cycle(), }), agentCommand({ id: "agent.cycle", diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 671b798f61..a2daae4866 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -463,7 +463,7 @@ function localStorageDirect(): SyncStorage { } } -const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"] +const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"] export function draftPersistedKeys() { return DRAFT_PERSISTED_KEYS