From 86976449c40d48f2731553a024f74552593d1764 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 19:13:14 +1000 Subject: [PATCH 001/138] release: v2.2.0 (#59558) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- CHANGELOG.md | 281 ++++++++++++++++++++++++++ bun.lock | 66 +++--- package.json | 2 +- packages/ai/package.json | 2 +- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/client/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/function/package.json | 2 +- packages/gui-plugins/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/httpapi-codegen/package.json | 2 +- packages/latex/package.json | 2 +- packages/merman/package.json | 2 +- packages/patchmark/package.json | 2 +- packages/plugin-browser/package.json | 2 +- packages/plugin/package.json | 2 +- packages/plugins/figma/package.json | 2 +- packages/protocol/package.json | 2 +- packages/schema/package.json | 2 +- packages/sdk/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/simulation/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/theme/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- services/update/package.json | 2 +- 37 files changed, 349 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a47787011ed7..22a498102ecd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,287 @@ Release notes for OpenCode V2, newest first. +## v2.2.0 — 2026-10-27 + +### Highlights + +- Create, install and share [Desktop plugins](https://opencode.ai/v2/docs/build/plugins/gui) (experimental) ([#55246](https://github.com/anomalyco/opencode/pull/55246), [#59469](https://github.com/anomalyco/opencode/pull/59469), [#59489](https://github.com/anomalyco/opencode/pull/59489)) +- Refreshed the desktop app experience ([#56898](https://github.com/anomalyco/opencode/pull/56898), [#58865](https://github.com/anomalyco/opencode/pull/58865), [#55838](https://github.com/anomalyco/opencode/pull/55838), [#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Add experimental image and decision generation APIs to plugins and sdk, allowing image generation or decision model (think typesafe, clef, etc) workflows. ([#59190](https://github.com/anomalyco/opencode/pull/59190), [#59380](https://github.com/anomalyco/opencode/pull/59380), [#58758](https://github.com/anomalyco/opencode/pull/58758)) +- Plugins can now attach and update keyed session instructions with ctx.session.instructions.entry.put/list/remove. Instead of editing the system prompt and busting the prompt cache, each change is added as a small update at the next step. The entries also survive compaction and reverts, and they don't start an extra model turn. ([#56550](https://github.com/anomalyco/opencode/pull/56550)) +- Restored the Todo tool in desktop via opt-in ([#59092](https://github.com/anomalyco/opencode/pull/59092)) +- Add configurable prompt cache TTLs per provider/model ([#59157](https://github.com/anomalyco/opencode/pull/59157)) + +### Breaking changes + +#### `@opencode/ai` root is now the Promise API + +`@opencode/ai` now works like `@opencode/sdk` and `@opencode/client`: the package root exports the Promise API, and the Effect API moved to `@opencode/ai/effect`. `@opencode/ai/promise` is removed, and the wildcard subpath export is replaced with an explicit list of provider and protocol entry points. ([#58847](https://github.com/anomalyco/opencode/pull/58847), [#59183](https://github.com/anomalyco/opencode/pull/59183)) + +```diff +-import { AI } from "@opencode/ai/promise" +-import { LLM } from "@opencode/ai" ++import { AI } from "@opencode/ai" ++import { LLM } from "@opencode/ai/effect" +``` + +#### The OpenCode GitHub Action is removed + +The OpenCode GitHub Action is no longer published. Remove it from your workflows. ([#59032](https://github.com/anomalyco/opencode/pull/59032)) + +#### TUI plugins require OpenTUI 0.7 + +`@opencode/plugin` now requires `@opentui/core` and `@opentui/solid` 0.7.0, up from 0.6.8 or later. Plugins that depend on OpenTUI 0.6 fail to install until they upgrade. In OpenTUI 0.7, custom renderables read their hooks from their class at construction: assign `renderBefore`, `renderAfter`, and `onLifecyclePass` in the constructor instead of as class fields, call `refreshHooks()` after adding a hook by reflection, and replace `defineNativeIntegration()` with a `static nativeIntegration` declaration. See the [OpenTUI 0.7.0 release notes](https://opentui.com/docs/releases/0.7.0). ([#59295](https://github.com/anomalyco/opencode/pull/59295)) + +### Core + +#### Improvements + +- Add experimental image and decision generation API endpoints. ([#59190](https://github.com/anomalyco/opencode/pull/59190)) +- Add image and decision entrypoints for catalog providers. ([#59380](https://github.com/anomalyco/opencode/pull/59380)) +- Improve integration rebuild performance. ([#59402](https://github.com/anomalyco/opencode/pull/59402)) +- Let custom model configuration set a model type. ([#59388](https://github.com/anomalyco/opencode/pull/59388)) +- Let model-list API requests filter by type. ([#59355](https://github.com/anomalyco/opencode/pull/59355)) +- Make external-directory permission checks use the project's filesystem. ([#59089](https://github.com/anomalyco/opencode/pull/59089)) +- Reduce memory use when code search finds very large lines. ([#59205](https://github.com/anomalyco/opencode/pull/59205)) +- Let plugins filter event subscriptions by type. ([#58864](https://github.com/anomalyco/opencode/pull/58864)) +- Add configurable prompt-cache TTLs. ([#59157](https://github.com/anomalyco/opencode/pull/59157)) +- Move image resizing off the main process and release idle image memory. ([#58820](https://github.com/anomalyco/opencode/pull/58820)) +- Reduce repeated session-history loading during a step. ([#59091](https://github.com/anomalyco/opencode/pull/59091)) +- Add an experimental API for session media and opt-in media URI support. ([#59103](https://github.com/anomalyco/opencode/pull/59103)) +- Add the machine hostname to `/api/info`. ([#59090](https://github.com/anomalyco/opencode/pull/59090)) +- Add experimental content-addressed blob storage for session media behind `experimental.media_blob`. ([#58656](https://github.com/anomalyco/opencode/pull/58656), [#58741](https://github.com/anomalyco/opencode/pull/58741)) +- Start session history from the latest completed compaction. ([#59020](https://github.com/anomalyco/opencode/pull/59020)) +- Reduce work encoding durable event payloads that are not persisted. ([#58923](https://github.com/anomalyco/opencode/pull/58923)) +- Expose MCP server titles from `serverInfo`. ([#58950](https://github.com/anomalyco/opencode/pull/58950)) +- Apply OpenCode Console workspace configuration as a configuration layer. ([#57703](https://github.com/anomalyco/opencode/pull/57703)) +- Replace experimental evaluations with Decision APIs in the AI pkg, including OpenAI Decisions, System One, and Cloudflare Run models. ([#58758](https://github.com/anomalyco/opencode/pull/58758), [#58382](https://github.com/anomalyco/opencode/pull/58382), [#58379](https://github.com/anomalyco/opencode/pull/58379), [#58381](https://github.com/anomalyco/opencode/pull/58381)) +- Convert tool schemas once to speed up tool execution. ([#58798](https://github.com/anomalyco/opencode/pull/58798), [#58848](https://github.com/anomalyco/opencode/pull/58848)) +- Make Git project discovery faster while recognizing repository changes. ([#58616](https://github.com/anomalyco/opencode/pull/58616)) +- Show OpenCode Console budget limits and Go usage where they block you. ([#58694](https://github.com/anomalyco/opencode/pull/58694), [#58714](https://github.com/anomalyco/opencode/pull/58714)) +- Record provider-reported session costs. ([#58562](https://github.com/anomalyco/opencode/pull/58562)) +- Find Git more reliably on macOS. ([#58602](https://github.com/anomalyco/opencode/pull/58602)) +- Resolve model details once per prompt step. ([#58601](https://github.com/anomalyco/opencode/pull/58601)) +- Let plugins manage session instruction entries for subsequent model requests. ([#56550](https://github.com/anomalyco/opencode/pull/56550)) +- Add GPT-6.1 Terra and GPT-6.1 Terra Fast to Sign in with ChatGPT. ([#59359](https://github.com/anomalyco/opencode/pull/59359)) + +#### Bug fixes + +- Wait for plugins to finish reloading before each session step and tool call. Thanks [@kernel-oops](https://github.com/kernel-oops) and [@Tarquinen](https://github.com/Tarquinen). ([#51133](https://github.com/anomalyco/opencode/pull/51133)) +- Size session compaction from the provider's reported token count to preserve usable context. ([#59441](https://github.com/anomalyco/opencode/pull/59441)) +- Skip unavailable models.dev API entrypoints instead of failing model catalog updates. ([#59475](https://github.com/anomalyco/opencode/pull/59475)) +- Return a server error with a reference ID when the server cannot encode a response. ([#59459](https://github.com/anomalyco/opencode/pull/59459)) +- Avoid warming a session while its model request is running. ([#59325](https://github.com/anomalyco/opencode/pull/59325)) +- Preserve legacy MCP `codemode` settings during migration. Thanks [@tneemo](https://github.com/tneemo). ([#59126](https://github.com/anomalyco/opencode/pull/59126)) +- Use the correct Vertex AI endpoints for the `eu` and `us` multi-regions. ([#59335](https://github.com/anomalyco/opencode/pull/59335)) +- Accept Databricks hosts that include `http://` or `https://`. ([#59334](https://github.com/anomalyco/opencode/pull/59334)) +- Keep files intact when patch edits move a file onto itself. ([#59197](https://github.com/anomalyco/opencode/pull/59197)) +- Preserve literal `$0` and `$ARGUMENTS` text in custom command input. ([#59198](https://github.com/anomalyco/opencode/pull/59198)) +- Close project locations concurrently during shutdown. ([#59326](https://github.com/anomalyco/opencode/pull/59326)) +- Treat equivalent directory paths, including trailing slashes, as one location. ([#59327](https://github.com/anomalyco/opencode/pull/59327)) +- Send valid tool schemas to Kimi for tuple, default, and keyword-like arguments. ([#59207](https://github.com/anomalyco/opencode/pull/59207)) +- Expose Code Mode MCP tools directly when the `execute` permission is denied. ([#59315](https://github.com/anomalyco/opencode/pull/59315)) +- Coordinate OAuth credential refreshes across locations and processes. ([#54023](https://github.com/anomalyco/opencode/pull/54023)) +- List and search remote Git branches correctly without bare remote names. ([#58597](https://github.com/anomalyco/opencode/pull/58597)) +- Detect AVIF, HEIC/HEIF, QuickTime, and M4A attachments correctly. ([#58480](https://github.com/anomalyco/opencode/pull/58480), [#58886](https://github.com/anomalyco/opencode/pull/58886), [#58887](https://github.com/anomalyco/opencode/pull/58887)) +- Reduce memory retained by long-running tool calls and unchanged hooked model requests. ([#58448](https://github.com/anomalyco/opencode/pull/58448), [#58449](https://github.com/anomalyco/opencode/pull/58449)) +- Load session diffs without reading every message, improving performance for large sessions. ([#57686](https://github.com/anomalyco/opencode/pull/57686)) +- Keep active locations alive when session events use an equivalent directory path. ([#56259](https://github.com/anomalyco/opencode/pull/56259)) +- Reload compatibility skills only when their own sources change. ([#59086](https://github.com/anomalyco/opencode/pull/59086)) +- Avoid publishing redundant catalog updates when configuration is unchanged. ([#56267](https://github.com/anomalyco/opencode/pull/56267)) +- Avoid booting idle or deleted project locations while polling session forms. ([#59083](https://github.com/anomalyco/opencode/pull/59083)) +- Wait for plugins before returning agent and provider lists, preventing incomplete catalogs. ([#59082](https://github.com/anomalyco/opencode/pull/59082)) +- Manage sessions and instruction entries after their project directory is deleted. ([#58997](https://github.com/anomalyco/opencode/pull/58997)) +- Start fuzzy file search only on first use, reducing project startup work. ([#58959](https://github.com/anomalyco/opencode/pull/58959)) +- Exit after 10 seconds when shutdown hangs and report pending work. ([#58817](https://github.com/anomalyco/opencode/pull/58817)) +- Keep the models catalog fresh without redownloading it when unchanged. ([#58813](https://github.com/anomalyco/opencode/pull/58813)) +- Send xAI Responses `top_k` settings and stop sending unsupported penalties. ([#58868](https://github.com/anomalyco/opencode/pull/58868)) +- Honor shared parallel tool-call and service-tier settings for Groq. ([#58871](https://github.com/anomalyco/opencode/pull/58871)) +- Retry throttled requests instead of misreporting them as context overflows. ([#58866](https://github.com/anomalyco/opencode/pull/58866)) +- Preserve streamed text and reasoning that arrive after an OpenAI Chat finish reason. ([#58851](https://github.com/anomalyco/opencode/pull/58851)) +- Classify AWS REST JSON errors consistently regardless of where the error type appears. ([#58770](https://github.com/anomalyco/opencode/pull/58770)) +- Validate large base64 attachments without slowdowns or hangs. ([#58811](https://github.com/anomalyco/opencode/pull/58811)) +- Retry Bedrock throttling errors as rate limits instead of context overflows. Thanks [@Irish-Joseph](https://github.com/Irish-Joseph). ([#58759](https://github.com/anomalyco/opencode/pull/58759)) +- Keep working MCP tools available when a server returns malformed ones. ([#58350](https://github.com/anomalyco/opencode/pull/58350)) +- Match absolute instruction-file globs across directories. Thanks [@heimoshuiyu](https://github.com/heimoshuiyu). ([#58749](https://github.com/anomalyco/opencode/pull/58749)) +- Use configured provider regions in Bedrock endpoint URLs. ([#58538](https://github.com/anomalyco/opencode/pull/58538)) +- Handle unknown OpenAI Chat finish reasons without failing the response. ([#58608](https://github.com/anomalyco/opencode/pull/58608)) +- Avoid unnecessary Azure model updates when endpoint transforms make no change. ([#58600](https://github.com/anomalyco/opencode/pull/58600)) +- Prefer JPEG for opaque browser screenshots, reducing attachment size. ([#56990](https://github.com/anomalyco/opencode/pull/56990)) +- Detect generated image types from their bytes when compatible hosts omit the format. ([#58475](https://github.com/anomalyco/opencode/pull/58475)) +- Keep project services alive while sessions run long tools or wait for input, with a 24-hour inactivity cap. ([#57274](https://github.com/anomalyco/opencode/pull/57274)) +- Prevent unrelated configuration changes and compatibility-skill update bursts from repeatedly reloading skills. ([#56260](https://github.com/anomalyco/opencode/pull/56260)) + +### TUI + +#### Improvements + +- Reduce CPU use while animated session-tab pulses are visible. ([#59235](https://github.com/anomalyco/opencode/pull/59235)) +- List OpenCode Console before OpenCode Go in the integration picker. ([#59107](https://github.com/anomalyco/opencode/pull/59107)) +- Add `/usage`, model-picker, prompt, sidebar, warning, and error details for OpenCode Console usage limits. ([#58731](https://github.com/anomalyco/opencode/pull/58731)) +- Make CLI service registration more reliable when concurrent processes update its files. ([#59105](https://github.com/anomalyco/opencode/pull/59105)) + +#### Bug fixes + +- Return `opencode run --format json` server connection errors as JSON. Thanks [@andreivince](https://github.com/andreivince). ([#59366](https://github.com/anomalyco/opencode/pull/59366)) +- Preserve pasted prompt text when copying it back out. ([#59209](https://github.com/anomalyco/opencode/pull/59209)) +- Reliably update the background service on Windows when its files are briefly locked. ([#59105](https://github.com/anomalyco/opencode/pull/59105)) +- Keep the background service state directory out of tool shells and identify processes blocking its port. ([#58815](https://github.com/anomalyco/opencode/pull/58815)) +- Refresh Git branch details after switching branches. ([#58982](https://github.com/anomalyco/opencode/pull/58982)) +- Keep the TUI running when a plugin slot or route renders bare text. Thanks [@anmol0b](https://github.com/anmol0b) and [@Tarquinen](https://github.com/Tarquinen). ([#58144](https://github.com/anomalyco/opencode/pull/58144)) +- Accept `--server` and `--standalone` anywhere in supported CLI commands. ([#58505](https://github.com/anomalyco/opencode/pull/58505)) +- Release closed and background session transcripts to prevent memory growth. ([#58434](https://github.com/anomalyco/opencode/pull/58434), [#57025](https://github.com/anomalyco/opencode/pull/57025)) +- Connect remote access tunnels through HTTP proxies. ([#58464](https://github.com/anomalyco/opencode/pull/58464)) +- Keep development builds on a separate server, database, and port from released builds. ([#58331](https://github.com/anomalyco/opencode/pull/58331)) + +### Desktop + +#### Improvements + +- Manage desktop plugins from Settings › Extensions › Plugins. ([#59489](https://github.com/anomalyco/opencode/pull/59489)) +- Add a persistent desktop zoom setting. ([#59277](https://github.com/anomalyco/opencode/pull/59277)) +- Capture heap snapshots in desktop debug builds on `SIGUSR2` when explicitly enabled. ([#58317](https://github.com/anomalyco/opencode/pull/58317)) +- Show the open session title in the browser tab. ([#59131](https://github.com/anomalyco/opencode/pull/59131)) +- Show todowrite tasks above the composer. ([#59092](https://github.com/anomalyco/opencode/pull/59092)) +- Load session history once when returning to a session tab. ([#59166](https://github.com/anomalyco/opencode/pull/59166)) +- Answer questions with number keys and show multi-select choices as checkboxes. ([#58595](https://github.com/anomalyco/opencode/pull/58595)) +- Add an always-available Add project action to the location picker. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Let you rewind or fork from any message. ([#58865](https://github.com/anomalyco/opencode/pull/58865)) +- Let you edit line comments and copy shell output. ([#58970](https://github.com/anomalyco/opencode/pull/58970), [#58974](https://github.com/anomalyco/opencode/pull/58974)) +- Render Mermaid diagrams in messages much faster, sized to fit the column. ([#57483](https://github.com/anomalyco/opencode/pull/57483), [#57004](https://github.com/anomalyco/opencode/pull/57004)) +- Add opt-in memory logging and remote debugging for desktop debug builds. ([#58315](https://github.com/anomalyco/opencode/pull/58315)) +- Show web sources for a turn behind the experimental Sources setting. ([#58307](https://github.com/anomalyco/opencode/pull/58307)) +- Jump from a completed shell notification to its shell output. ([#58663](https://github.com/anomalyco/opencode/pull/58663)) +- Keep the composer usable while the voice-call panel floats. ([#58587](https://github.com/anomalyco/opencode/pull/58587)) +- Use the Soft Radar activity spinner by default and provide experimental variations. ([#58010](https://github.com/anomalyco/opencode/pull/58010), [#57903](https://github.com/anomalyco/opencode/pull/57903)) +- Add an experimental new model picker. ([#58413](https://github.com/anomalyco/opencode/pull/58413)) +- Add experimental Console voice calls in Desktop beta. ([#58249](https://github.com/anomalyco/opencode/pull/58249)) +- Add a timeline width setting. ([#58308](https://github.com/anomalyco/opencode/pull/58308)) +- Improve command, session, and model search lists with sticky sections. ([#58222](https://github.com/anomalyco/opencode/pull/58222)) +- Add search to recently closed sessions. ([#55721](https://github.com/anomalyco/opencode/pull/55721)) +- Open extension configuration and skills in side tabs. ([#58023](https://github.com/anomalyco/opencode/pull/58023)) +- Open session extension lists on hover and follow keyboard navigation. ([#58021](https://github.com/anomalyco/opencode/pull/58021)) +- Add video previews with zoom controls. ([#57924](https://github.com/anomalyco/opencode/pull/57924)) +- Add generated project avatars and an experimental avatar-style switcher. ([#57871](https://github.com/anomalyco/opencode/pull/57871), [#57930](https://github.com/anomalyco/opencode/pull/57930)) +- Add recently closed side tabs to the new-tab view. ([#57863](https://github.com/anomalyco/opencode/pull/57863)) +- Add a searchable prompt history picker. ([#57716](https://github.com/anomalyco/opencode/pull/57716)) +- Find settings from the command palette. ([#57651](https://github.com/anomalyco/opencode/pull/57651)) +- Group queued and steered messages, and group consecutive user messages under one metadata line. ([#57561](https://github.com/anomalyco/opencode/pull/57561), [#57552](https://github.com/anomalyco/opencode/pull/57552)) +- Improve file-tree and folder-picker performance. ([#57573](https://github.com/anomalyco/opencode/pull/57573), [#57557](https://github.com/anomalyco/opencode/pull/57557)) +- Keep branch search results visible while new results load. ([#57531](https://github.com/anomalyco/opencode/pull/57531)) +- Add a session-details menu to the session header. ([#57528](https://github.com/anomalyco/opencode/pull/57528)) +- Show a subagent's model, status, and elapsed time in its composer area. ([#57473](https://github.com/anomalyco/opencode/pull/57473)) +- Let you delete sessions from tab menus and rename the current session with Ctrl+R. ([#57453](https://github.com/anomalyco/opencode/pull/57453), [#57423](https://github.com/anomalyco/opencode/pull/57423)) +- Restore session summary and project menu actions. ([#57781](https://github.com/anomalyco/opencode/pull/57781)) +- Let you place the background-activity capsule at the left, center, right, or hide it. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Show project and worktree details in the session header and location menu. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Add text-rendering controls to the debug bar. ([#57326](https://github.com/anomalyco/opencode/pull/57326)) +- Show elapsed time for background work. ([#57218](https://github.com/anomalyco/opencode/pull/57218)) +- Improve streamed Markdown rendering reliability and performance. ([#57016](https://github.com/anomalyco/opencode/pull/57016)) +- Split the side pane and add a draggable bottom pane with tabs. ([#56960](https://github.com/anomalyco/opencode/pull/56960)) +- Add consistent MCP sign-in and error-status links across connection, session, and Settings views. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Open multiple file tabs at once and open side-panel content in tabs. ([#57182](https://github.com/anomalyco/opencode/pull/57182), [#57094](https://github.com/anomalyco/opencode/pull/57094)) +- Move running work to the background when you steer a session. ([#57142](https://github.com/anomalyco/opencode/pull/57142)) +- Let the timeline scroll beneath the composer while keeping its content visible. ([#57141](https://github.com/anomalyco/opencode/pull/57141)) +- Simplify Settings into a flat list with a per-server switcher. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Quote selected timeline or terminal text into the composer. ([#55664](https://github.com/anomalyco/opencode/pull/55664), [#58420](https://github.com/anomalyco/opencode/pull/58420)) +- Search sessions across online servers from the title bar. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Add a Reduce motion appearance setting. ([#57103](https://github.com/anomalyco/opencode/pull/57103)) +- Show projects from all servers in the new-session location picker. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Pick models from a searchable dialog with favorites and recent models. ([#55838](https://github.com/anomalyco/opencode/pull/55838)) +- Attach the new-session location picker below the composer. ([#56952](https://github.com/anomalyco/opencode/pull/56952)) +- Open image previews directly from message and composer thumbnails. ([#56896](https://github.com/anomalyco/opencode/pull/56896)) +- Start new worktrees from the current branch in the simplified location picker. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Manage running subagents and shells from the composer activity capsule. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Sync projects from each server and prioritize projects you use on Home. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Choose between Focus and Pro interface layouts in Settings. ([#56898](https://github.com/anomalyco/opencode/pull/56898)) +- Open terminals as side-panel tabs. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Choose a project and worktree from one new-session location dialog. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Show subagents as separate timeline rows by default. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Add a sidebar toggle for vertical tabs. ([#55646](https://github.com/anomalyco/opencode/pull/55646)) +- Add ⌘1–9 and ⇧⌘[ / ⇧⌘] shortcuts for side-panel tabs. ([#57301](https://github.com/anomalyco/opencode/pull/57301)) +- Let you choose a new directory or worktree when a session location is unavailable. ([#56087](https://github.com/anomalyco/opencode/pull/56087)) +- Refresh the Manage models, MCP, and fork dialogs with compact searchable lists. ([#56029](https://github.com/anomalyco/opencode/pull/56029), [#56028](https://github.com/anomalyco/opencode/pull/56028)) +- Install future Windows desktop updates in about a second. ([#55933](https://github.com/anomalyco/opencode/pull/55933)) +- Show individual Code Mode tool calls in Execute rows. ([#55649](https://github.com/anomalyco/opencode/pull/55649)) +- Add a Commentary timeline category and display setting. ([#55248](https://github.com/anomalyco/opencode/pull/55248)) +- Support `@` links to other sessions in messages. ([#57480](https://github.com/anomalyco/opencode/pull/57480)) +- Polish spacing, alignment, borders, and typography across the session header, titlebar, composer, file tree, browser pane, timeline, and Settings. ([#58195](https://github.com/anomalyco/opencode/pull/58195), [#57776](https://github.com/anomalyco/opencode/pull/57776), [#57570](https://github.com/anomalyco/opencode/pull/57570), [#57782](https://github.com/anomalyco/opencode/pull/57782), [#57336](https://github.com/anomalyco/opencode/pull/57336), [#57363](https://github.com/anomalyco/opencode/pull/57363), [#58100](https://github.com/anomalyco/opencode/pull/58100), [#58979](https://github.com/anomalyco/opencode/pull/58979)) + +#### Bug fixes + +- Prevent duplicate tab shortcuts and remove uninstalled plugin commands from Shortcuts. ([#59499](https://github.com/anomalyco/opencode/pull/59499)) +- Keep todo items from shrinking in the todo panel. ([#59430](https://github.com/anomalyco/opencode/pull/59430)) +- Keep question-dock answers and controls positioned correctly when changing questions. ([#59383](https://github.com/anomalyco/opencode/pull/59383)) +- Keep the @ menu open while it searches. ([#59425](https://github.com/anomalyco/opencode/pull/59425)) +- Finish quitting after Cmd+Q on macOS. Thanks [@anmol0b](https://github.com/anmol0b). ([#59144](https://github.com/anomalyco/opencode/pull/59144)) +- Toggle MCP servers for the current session location from Settings. ([#59132](https://github.com/anomalyco/opencode/pull/59132)) +- Deliver steering messages when an earlier move was missed. ([#58984](https://github.com/anomalyco/opencode/pull/58984)) +- Keep session and command-palette search results stable while searching. ([#58908](https://github.com/anomalyco/opencode/pull/58908), [#57479](https://github.com/anomalyco/opencode/pull/57479)) +- Restrict Search projects to the project list. ([#58978](https://github.com/anomalyco/opencode/pull/58978)) +- Let custom Close tab shortcuts close terminal and browser tabs. ([#58945](https://github.com/anomalyco/opencode/pull/58945)) +- Prevent live status rows from jumping or leaving gaps above the composer. ([#58691](https://github.com/anomalyco/opencode/pull/58691), [#58697](https://github.com/anomalyco/opencode/pull/58697)) +- Keep browser tabs still behind open dialogs. ([#58958](https://github.com/anomalyco/opencode/pull/58958)) +- Keep side-panel content and browser pages visible while the sidebar or panel moves. ([#58202](https://github.com/anomalyco/opencode/pull/58202), [#58185](https://github.com/anomalyco/opencode/pull/58185)) +- Render session diffs reliably. +- Keep keyboard focus in terminals, side panels, dialogs, and the composer when appropriate. ([#57980](https://github.com/anomalyco/opencode/pull/57980), [#57297](https://github.com/anomalyco/opencode/pull/57297), [#56273](https://github.com/anomalyco/opencode/pull/56273)) +- Preserve empty browser and side-panel tabs instead of closing them automatically. ([#58440](https://github.com/anomalyco/opencode/pull/58440)) +- Reduce desktop memory use by releasing closed transcripts, discarded tool-result images, retained UI batches, and aborted timeline work. ([#57025](https://github.com/anomalyco/opencode/pull/57025), [#58434](https://github.com/anomalyco/opencode/pull/58434), [#58427](https://github.com/anomalyco/opencode/pull/58427), [#58560](https://github.com/anomalyco/opencode/pull/58560), [#58447](https://github.com/anomalyco/opencode/pull/58447), [#58439](https://github.com/anomalyco/opencode/pull/58439), [#58432](https://github.com/anomalyco/opencode/pull/58432)) +- Restore horizontal scrolling and sticky change badges in the file tree. ([#58117](https://github.com/anomalyco/opencode/pull/58117)) +- Show live Thinking for reasoning-only turns. ([#58299](https://github.com/anomalyco/opencode/pull/58299)) +- Stop loading older history after completing the initially visible turn and prioritize the first transcript page. ([#57700](https://github.com/anomalyco/opencode/pull/57700), [#57741](https://github.com/anomalyco/opencode/pull/57741)) +- Keep Home project and session lists reliable during refreshes. +- Keep deleted sessions from reappearing on Home. ([#55349](https://github.com/anomalyco/opencode/pull/55349)) +- Find sessions immediately in @ mentions, including multi-word titles. ([#57683](https://github.com/anomalyco/opencode/pull/57683)) +- Keep queued messages editable while changes sync with the server. ([#57569](https://github.com/anomalyco/opencode/pull/57569)) +- Open image and video URLs in the browser pane. ([#57562](https://github.com/anomalyco/opencode/pull/57562)) +- Mark submitted generated forms as complete and send each response only once. ([#57537](https://github.com/anomalyco/opencode/pull/57537)) +- Improve file-view performance with reused views, virtualized medium files, and cached diffs. ([#57551](https://github.com/anomalyco/opencode/pull/57551)) +- Group untracked folders into one expandable review row. ([#57527](https://github.com/anomalyco/opencode/pull/57527)) +- Let you paste into the composer from the session view. ([#57545](https://github.com/anomalyco/opencode/pull/57545)) +- Recover failed beta background servers and preserve their required CLI versions. ([#57321](https://github.com/anomalyco/opencode/pull/57321)) +- Restore branch selection when starting a new worktree session. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Keep the running server available while beta updates install. ([#57309](https://github.com/anomalyco/opencode/pull/57309)) +- Make links in user messages, shell output, and Code Mode output clickable. ([#57211](https://github.com/anomalyco/opencode/pull/57211), [#57303](https://github.com/anomalyco/opencode/pull/57303)) +- Make Check for Updates always report its result. ([#57296](https://github.com/anomalyco/opencode/pull/57296)) +- Reopen the last closed side-panel tab with ⇧⌘T and open a browser tab with ⌘T. ([#57269](https://github.com/anomalyco/opencode/pull/57269), [#57156](https://github.com/anomalyco/opencode/pull/57156)) +- Keep OpenCode keyboard shortcuts working in the browser pane. ([#56697](https://github.com/anomalyco/opencode/pull/56697), [#55240](https://github.com/anomalyco/opencode/pull/55240)) +- Preview image attachments supplied by file path. ([#57249](https://github.com/anomalyco/opencode/pull/57249)) +- Apply configured code and UI fonts consistently. ([#57242](https://github.com/anomalyco/opencode/pull/57242)) +- Keep activity and busy indicators steady while switching sessions. ([#57196](https://github.com/anomalyco/opencode/pull/57196)) +- Reserve ⇧⌘W for closing the window so ⌘W does not. ([#57150](https://github.com/anomalyco/opencode/pull/57150)) +- Make image preview transitions resize smoothly without clipping or blocking clicks. ([#56997](https://github.com/anomalyco/opencode/pull/56997), [#57324](https://github.com/anomalyco/opencode/pull/57324)) +- Keep in-session search reliable alongside titlebar session search. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Return focus to the prompt after keyboard model selection. +- Open searchable pickers at the top with search focused. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Keep tables visible beneath sticky headers. ([#57050](https://github.com/anomalyco/opencode/pull/57050)) +- Fit provider connection steps without shifting a dialog's top edge. ([#57006](https://github.com/anomalyco/opencode/pull/57006)) +- Give unavailable-server panels clear error styling. ([#57003](https://github.com/anomalyco/opencode/pull/57003)) +- Make mouse-wheel and keyboard scrolling respond immediately on Linux. ([#57962](https://github.com/anomalyco/opencode/pull/57962)) +- Keep long Execute tool code contained and show successful Code Mode output. ([#56915](https://github.com/anomalyco/opencode/pull/56915)) +- Focus the most useful control when dialogs open, including Cancel in delete confirmations. ([#57453](https://github.com/anomalyco/opencode/pull/57453)) +- Install Windows beta, stable, and nightly builds into separate directories. ([#56904](https://github.com/anomalyco/opencode/pull/56904)) +- Keep session tabs recoverable when their server becomes unreachable. ([#56248](https://github.com/anomalyco/opencode/pull/56248)) +- Keep the composer model's provider and session-model resolution consistent with the TUI. ([#56225](https://github.com/anomalyco/opencode/pull/56225)) +- Reread open files without resetting their scroll position. ([#55899](https://github.com/anomalyco/opencode/pull/55899)) +- Hide agent names when agents are disabled. ([#57525](https://github.com/anomalyco/opencode/pull/57525)) +- Polish sidebar, panel, composer, activity, question-dock, loading, and motion behavior across light and dark themes. ([#57449](https://github.com/anomalyco/opencode/pull/57449), [#58013](https://github.com/anomalyco/opencode/pull/58013), [#58577](https://github.com/anomalyco/opencode/pull/58577), [#57944](https://github.com/anomalyco/opencode/pull/57944), [#58052](https://github.com/anomalyco/opencode/pull/58052), [#58494](https://github.com/anomalyco/opencode/pull/58494), [#58073](https://github.com/anomalyco/opencode/pull/58073), [#58114](https://github.com/anomalyco/opencode/pull/58114)) +- Polish hover states, cursors, tooltips, focus rings, scrollbars, and transitions across desktop controls. ([#57653](https://github.com/anomalyco/opencode/pull/57653), [#57787](https://github.com/anomalyco/opencode/pull/57787), [#57304](https://github.com/anomalyco/opencode/pull/57304), [#58971](https://github.com/anomalyco/opencode/pull/58971), [#57221](https://github.com/anomalyco/opencode/pull/57221), [#57192](https://github.com/anomalyco/opencode/pull/57192), [#58575](https://github.com/anomalyco/opencode/pull/58575), [#58580](https://github.com/anomalyco/opencode/pull/58580)) + +### SDK + +#### Improvements + +- Add GUI plugin APIs for toasts, dialogs, notifications, tabs, composer controls, and custom UI. ([#59469](https://github.com/anomalyco/opencode/pull/59469)) + +#### Bug fixes + +- Fix generated Promise client input types for TypeScript consumers. ([#58875](https://github.com/anomalyco/opencode/pull/58875)) +- Let SDK clients discard inline tool-result file payloads to reduce memory use. ([#59101](https://github.com/anomalyco/opencode/pull/59101)) +- Avoid unnecessary catalog refreshes for locations an SDK client has not loaded. ([#55237](https://github.com/anomalyco/opencode/pull/55237), [#59099](https://github.com/anomalyco/opencode/pull/59099)) +- Restore running sessions created while an SDK client was disconnected. ([#53012](https://github.com/anomalyco/opencode/pull/53012)) +- Release omitted tool-image payloads from memory instead of retaining their base64 data. ([#58560](https://github.com/anomalyco/opencode/pull/58560)) + ## v2.1.4 — 2026-10-23 ### Highlights diff --git a/bun.lock b/bun.lock index b39b552ae564..3379e52387fa 100644 --- a/bun.lock +++ b/bun.lock @@ -34,7 +34,7 @@ }, "packages/ai": { "name": "@opencode/ai", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@aws-sdk/credential-providers": "3.1057.0", "@opencode/schema": "workspace:*", @@ -56,7 +56,7 @@ }, "packages/app": { "name": "@opencode/app", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -111,7 +111,7 @@ }, "packages/cli": { "name": "@opencode/cli", - "version": "2.1.4", + "version": "2.2.0", "bin": { "opencode": "./bin/opencode.cjs", "opencode2": "./bin/opencode2.cjs", @@ -178,7 +178,7 @@ }, "packages/client": { "name": "@opencode/client", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@opencode/protocol": "workspace:*", "@opencode/schema": "workspace:*", @@ -204,7 +204,7 @@ }, "packages/codemode": { "name": "@opencode/codemode", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -217,7 +217,7 @@ }, "packages/console/app": { "name": "@opencode/console-app", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -257,7 +257,7 @@ }, "packages/core": { "name": "@opencode/core", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@ai-sdk/cohere": "3.0.27", "@ai-sdk/perplexity": "3.0.26", @@ -323,7 +323,7 @@ }, "packages/desktop": { "name": "@opencode/desktop", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@zip.js/zip.js": "2.7.62", "electron-context-menu": "5.0.0", @@ -370,7 +370,7 @@ }, "packages/function": { "name": "@opencode/function", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -386,7 +386,7 @@ }, "packages/gui-plugins": { "name": "@opencode/gui-plugins", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@betteroffice/docx": "0.4.3", "@betteroffice/fonts": "0.4.0", @@ -435,7 +435,7 @@ }, "packages/http-recorder": { "name": "@opencode/http-recorder", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@effect/platform-node-shared": "catalog:", }, @@ -454,7 +454,7 @@ }, "packages/httpapi-codegen": { "name": "@opencode/httpapi-codegen", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "effect": "catalog:", "prettier": "3.6.2", @@ -467,7 +467,7 @@ }, "packages/latex": { "name": "@opencode/latex", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@opencode/plugin": "workspace:*", "@opentui/core": "catalog:", @@ -481,7 +481,7 @@ }, "packages/merman": { "name": "@opencode/merman", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@opencode/plugin": "workspace:*", "@opentui/core": "catalog:", @@ -496,7 +496,7 @@ }, "packages/patchmark": { "name": "@opencode/patchmark", - "version": "2.0.32", + "version": "2.2.0", "devDependencies": { "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", @@ -509,7 +509,7 @@ }, "packages/plugin": { "name": "@opencode/plugin", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode/ai": "workspace:*", @@ -555,7 +555,7 @@ }, "packages/plugin-browser": { "name": "@opencode/plugin-browser", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@opencode/plugin": "workspace:*", "@opencode/schema": "workspace:*", @@ -570,7 +570,7 @@ }, "packages/plugins/figma": { "name": "@opencode/plugin-figma", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "gray-matter": "4.0.3", }, @@ -600,7 +600,7 @@ }, "packages/protocol": { "name": "@opencode/protocol", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@opencode/schema": "workspace:*", "effect": "catalog:", @@ -615,7 +615,7 @@ }, "packages/schema": { "name": "@opencode/schema", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@standard-schema/spec": "catalog:", "effect": "catalog:", @@ -639,7 +639,7 @@ }, "packages/sdk": { "name": "@opencode/sdk", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@opencode/client": "workspace:*", "@opencode/core": "workspace:*", @@ -660,7 +660,7 @@ }, "packages/server": { "name": "@opencode/server", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", @@ -683,7 +683,7 @@ }, "packages/session-ui": { "name": "@opencode/session-ui", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@kitlangton/solid-motion": "0.2.2", "@kobalte/core": "catalog:", @@ -716,7 +716,7 @@ }, "packages/simulation": { "name": "@opencode/simulation", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@opencode/ai": "workspace:*", "@opencode/core": "workspace:*", @@ -736,7 +736,7 @@ }, "packages/stats/app": { "name": "@opencode/stats-app", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -763,7 +763,7 @@ }, "packages/stats/core": { "name": "@opencode/stats-core", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@planetscale/database": "1.19.0", "drizzle-orm": "catalog:", @@ -781,7 +781,7 @@ }, "packages/stats/server": { "name": "@opencode/stats-server", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@effect/platform-node": "catalog:", "@opencode/stats-core": "workspace:*", @@ -816,7 +816,7 @@ }, "packages/theme": { "name": "@opencode/theme", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@opentui/core": "catalog:", "effect": "catalog:", @@ -830,7 +830,7 @@ }, "packages/tui": { "name": "@opencode/tui", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@opencode/client": "workspace:*", "@opencode/core": "workspace:*", @@ -862,7 +862,7 @@ }, "packages/ui": { "name": "@opencode/ui", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@kitlangton/solid-motion": "0.2.2", "@kobalte/core": "catalog:", @@ -897,7 +897,7 @@ }, "packages/util": { "name": "@opencode/util", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@effect/opentelemetry": "catalog:", "@effect/platform-node": "catalog:", @@ -936,7 +936,7 @@ }, "packages/web": { "name": "@opencode/web", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -990,7 +990,7 @@ }, "services/update": { "name": "@opencode/update", - "version": "2.1.4", + "version": "2.2.0", "dependencies": { "jose": "6.0.11", "semver": "catalog:", diff --git a/package.json b/package.json index b3db95bdf23a..23caf8179fca 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/package.json", "name": "opencode", "description": "AI-powered development tool", - "version": "2.1.4", + "version": "2.2.0", "private": true, "type": "module", "packageManager": "bun@1.4.4", diff --git a/packages/ai/package.json b/packages/ai/package.json index 8964c6af50ca..8ad4e84399de 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.1.4", + "version": "2.2.0", "name": "@opencode/ai", "type": "module", "license": "MIT", diff --git a/packages/app/package.json b/packages/app/package.json index 543f5b220167..f71b0c7b1c8d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/app", - "version": "2.1.4", + "version": "2.2.0", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index c58a04ca0821..04816573da07 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/cli", - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "bin": { diff --git a/packages/client/package.json b/packages/client/package.json index 5573dc862af3..a4005037cc8e 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/client", - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "repository": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index d7de2c42977c..f249afd84e7a 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/codemode", - "version": "2.1.4", + "version": "2.2.0", "description": "Effect-native confined code execution over schema-described tools", "type": "module", "license": "MIT", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index b52c6f31666a..1d4e5688c2cb 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/console-app", - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index bbae41e3804f..08e5c5c734f2 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.1.4", + "version": "2.2.0", "name": "@opencode/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 30186bf29d84..8d2f76a4a494 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode/desktop", "private": true, - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/function/package.json b/packages/function/package.json index 286aaf342650..9ad221862510 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/function", - "version": "2.1.4", + "version": "2.2.0", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/gui-plugins/package.json b/packages/gui-plugins/package.json index d7e5f42286bb..13b7f333aca2 100644 --- a/packages/gui-plugins/package.json +++ b/packages/gui-plugins/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/gui-plugins", - "version": "2.1.4", + "version": "2.2.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 2c0e1e1a32dd..22629b31a645 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": "2.1.4", + "version": "2.2.0", "name": "@opencode/http-recorder", "description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes", "type": "module", diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json index a9bb9c3d4e94..570eff2d98b0 100644 --- a/packages/httpapi-codegen/package.json +++ b/packages/httpapi-codegen/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/httpapi-codegen", - "version": "2.1.4", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/latex/package.json b/packages/latex/package.json index 5026a64f0c77..80f0fbb4a5b6 100644 --- a/packages/latex/package.json +++ b/packages/latex/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/latex", - "version": "2.1.4", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/merman/package.json b/packages/merman/package.json index 6b558c3f141d..93d2d6f7557e 100644 --- a/packages/merman/package.json +++ b/packages/merman/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/merman", - "version": "2.1.4", + "version": "2.2.0", "private": true, "type": "module", "exports": { diff --git a/packages/patchmark/package.json b/packages/patchmark/package.json index 99d8a9a86509..cf98a7d79174 100644 --- a/packages/patchmark/package.json +++ b/packages/patchmark/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/patchmark", - "version": "2.0.32", + "version": "2.2.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/plugin-browser/package.json b/packages/plugin-browser/package.json index a8e584ec5e08..de905d69e7cb 100644 --- a/packages/plugin-browser/package.json +++ b/packages/plugin-browser/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/plugin-browser", - "version": "2.1.4", + "version": "2.2.0", "description": "OpenCode's desktop browser plugin", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index f57be17f21a8..b2eb2b1d916d 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/plugin", - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/plugins/figma/package.json b/packages/plugins/figma/package.json index e75faf541e39..e934232e4a5c 100644 --- a/packages/plugins/figma/package.json +++ b/packages/plugins/figma/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/plugin-figma", - "version": "2.1.4", + "version": "2.2.0", "description": "Figma MCP integration and official Figma skills for OpenCode", "type": "module", "license": "MIT", diff --git a/packages/protocol/package.json b/packages/protocol/package.json index efffa7c22629..2af9834ff822 100644 --- a/packages/protocol/package.json +++ b/packages/protocol/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/protocol", - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "repository": { diff --git a/packages/schema/package.json b/packages/schema/package.json index d26016890a6b..26637c29a2b1 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/schema", - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "repository": { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index b927d12e5e4b..fad4ea26b4dd 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "2.1.4", + "version": "2.2.0", "name": "@opencode/sdk", "type": "module", "license": "MIT", diff --git a/packages/server/package.json b/packages/server/package.json index e3c1925026bf..226099160755 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/server", - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "repository": { diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 34626b4352b0..c1325f756238 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/session-ui", - "version": "2.1.4", + "version": "2.2.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/simulation/package.json b/packages/simulation/package.json index 348770a70701..6d8970bb6eb4 100644 --- a/packages/simulation/package.json +++ b/packages/simulation/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/simulation", - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "repository": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 112e3fda405b..b73bce9a7393 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/stats-app", - "version": "2.1.4", + "version": "2.2.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index d3852a75a05c..54481a0b5585 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/stats-core", - "version": "2.1.4", + "version": "2.2.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 9e363fe479ed..fedc8e5b5361 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/stats-server", - "version": "2.1.4", + "version": "2.2.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/theme/package.json b/packages/theme/package.json index 7f33a4e6878b..d7e7a13b7ad3 100644 --- a/packages/theme/package.json +++ b/packages/theme/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/theme", - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "repository": { diff --git a/packages/tui/package.json b/packages/tui/package.json index 4934e96a5000..ae263fc399cc 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/tui", - "version": "2.1.4", + "version": "2.2.0", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 311fb7ee81f2..98459dc4a260 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode/ui", - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "repository": { diff --git a/packages/util/package.json b/packages/util/package.json index 7b211f3c1f5f..7329730504dd 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/util", - "version": "2.1.4", + "version": "2.2.0", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index b120eac2313f..48051488f0a3 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode/web", "type": "module", "license": "MIT", - "version": "2.1.4", + "version": "2.2.0", "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 2b4ca9a95bf9..92d283d53dd3 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": "2.1.4", + "version": "2.2.0", "publisher": "sst-dev", "repository": { "type": "git", diff --git a/services/update/package.json b/services/update/package.json index 13a86b0a21e2..67adafaf9992 100644 --- a/services/update/package.json +++ b/services/update/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode/update", - "version": "2.1.4", + "version": "2.2.0", "private": true, "type": "module", "scripts": { From 0b1f1251be0a35795adf723abd0041ea464863bc Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Tue, 27 Oct 2026 13:56:20 +0100 Subject: [PATCH 002/138] docs(console): every harness reaches every model (#59611) --- services/www/src/docs/content/console/go.mdx | 4 +- .../content/console/harnesses/claude-code.mdx | 31 ++++------------ .../docs/content/console/harnesses/codex.mdx | 23 ++++-------- .../docs/content/console/harnesses/index.mdx | 37 ++++++------------- .../www/src/docs/content/console/models.mdx | 3 ++ 5 files changed, 31 insertions(+), 67 deletions(-) diff --git a/services/www/src/docs/content/console/go.mdx b/services/www/src/docs/content/console/go.mdx index 9564b88556f5..2441b2807c47 100644 --- a/services/www/src/docs/content/console/go.mdx +++ b/services/www/src/docs/content/console/go.mdx @@ -371,7 +371,9 @@ For these models, you still get a little more than if you paid the model provide ## Endpoints -You can also access Go models through the following API endpoints. +You can also access Go models through the following API endpoints. Each model is listed with its native API, but every +text model also works through the other APIs, which Console translates. See +[Base URLs](/console/harnesses#base-urls) for the full set.
diff --git a/services/www/src/docs/content/console/harnesses/claude-code.mdx b/services/www/src/docs/content/console/harnesses/claude-code.mdx index 1ffa4b0efd68..3ba028577c7d 100644 --- a/services/www/src/docs/content/console/harnesses/claude-code.mdx +++ b/services/www/src/docs/content/console/harnesses/claude-code.mdx @@ -3,8 +3,9 @@ title: "Claude Code" description: "Use Console and Go models from Claude Code." --- -Claude Code speaks the Anthropic Messages API. Point it at Console with two environment variables and use any model -that accepts Anthropic Messages. +Claude Code speaks the Anthropic Messages API. Point it at Console with two environment variables and use any paid or Go +model, including GPT, Gemini, and open models. Console translates the request for models that don't accept Anthropic +Messages. ```bash export ANTHROPIC_BASE_URL="https://opencode.ai/inference/anthropic" @@ -56,29 +57,11 @@ Use the Go base URL and a key from a workspace with a [Go](/console/go) subscrip ## Models -Claude Code can only use models that accept Anthropic Messages. +Use any model ID from the [Console](/console/models#endpoints) or [Go](/console/go#endpoints) table. Switch models in a +session with `/model`, or pick one for a single run. -| Plan | Models | -| ------------- | ------------------------------------------------------------------- | -| Pay-as-you-go | Claude models and most Qwen models | -| Go | Claude Haiku 5.5, Kimi K3, DeepSeek V4, MiniMax M3, and Qwen models | - -GPT, Gemini, and most open models are not available in Claude Code yet. Use [Pi](/console/harnesses/pi) or -[Hermes](/console/harnesses/hermes) for them in the meantime. - -Support for every model in every harness is coming soon. - -### Non-Claude models - -Claude Code sends Claude-specific request options to every model. If a non-Claude model rejects a request with a -`400` error, turn off experimental betas. - -```json title="~/.claude/settings.json" -{ - "env": { - "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS": "1" - } -} +```bash +claude --model gpt-6.1-sol ``` ## Console MCP diff --git a/services/www/src/docs/content/console/harnesses/codex.mdx b/services/www/src/docs/content/console/harnesses/codex.mdx index 5fc1466047b5..2e04811395ee 100644 --- a/services/www/src/docs/content/console/harnesses/codex.mdx +++ b/services/www/src/docs/content/console/harnesses/codex.mdx @@ -3,8 +3,8 @@ title: "Codex" description: "Use Console and Go models from Codex." --- -Codex speaks the OpenAI Responses API, so it can use the models served through Responses, such as GPT, Grok, and Muse -Spark. +Codex speaks the OpenAI Responses API. Point it at Console and use any paid or Go model, including Claude, Gemini, and +open models. Console translates the request for models that don't accept Responses. ## Configure @@ -38,7 +38,7 @@ codex Use the Go base URL and a key from a workspace with a [Go](/console/go) subscription. ```toml title="~/.codex/config.toml" -model = "gpt-6-luna" +model = "kimi-k3" model_provider = "opencode-go" [model_providers.opencode-go] @@ -50,22 +50,13 @@ wire_api = "responses" ## Models -Set `model` to a model that accepts the Responses API. Override it for one run with `--model`. - -| Plan | Models | -| ------------- | -------------------------------------- | -| Pay-as-you-go | GPT, Grok, and Muse Spark models | -| Go | GPT, Grok, Muse Spark, and DeepSeek V4 | +Set `model` to any model ID from the [Console](/console/models#endpoints) or [Go](/console/go#endpoints) table. Override +it for one run with `--model`. ```bash -codex --model grok-4.7 +codex --model claude-sonnet-5-5 ``` -Claude, Gemini, and most open models are not available in Codex yet. Use [Pi](/console/harnesses/pi) or -[Hermes](/console/harnesses/hermes) for them in the meantime. - -Support for every model in every harness is coming soon. - Codex doesn't know the context window or reasoning settings of models outside its own catalog. It uses generic defaults for them and prints a warning, which you can ignore. @@ -75,7 +66,7 @@ Keep pay-as-you-go and Go side by side with a profile. Define both providers in profile file that switches to Go. ```toml title="~/.codex/go.config.toml" -model = "gpt-6-luna" +model = "kimi-k3" model_provider = "opencode-go" ``` diff --git a/services/www/src/docs/content/console/harnesses/index.mdx b/services/www/src/docs/content/console/harnesses/index.mdx index c4fb10920530..43767430d899 100644 --- a/services/www/src/docs/content/console/harnesses/index.mdx +++ b/services/www/src/docs/content/console/harnesses/index.mdx @@ -3,16 +3,16 @@ title: "Harnesses" description: "Use Console and Go models from other coding agents and AI apps." --- -Console works with coding agents other than OpenCode. Point a harness at the Console gateway with an API key, and usage -is billed to your workspace like any other request. +Console works with coding agents other than OpenCode. Point a harness at the Console gateway with an API key to use any +paid or Go model, and usage is billed to your workspace like any other request. -| Harness | Pay-as-you-go | Go | Models | MCP | -| --------------------------------------------- | ------------- | --------- | ------------------------- | --- | -| [Claude Code](/console/harnesses/claude-code) | Yes | Yes | Anthropic Messages models | Yes | -| [Codex](/console/harnesses/codex) | Yes | Yes | OpenAI Responses models | Yes | -| [Hermes](/console/harnesses/hermes) | Built in | Built in | Paid and Go models | Yes | -| [Pi](/console/harnesses/pi) | Built in | Built in | Paid and Go models | Yes | -| [Raycast](/console/harnesses/raycast) | Extension | Extension | Paid and Go models | Yes | +| Harness | Pay-as-you-go | Go | MCP | +| --------------------------------------------- | ------------- | --------- | --- | +| [Claude Code](/console/harnesses/claude-code) | Yes | Yes | Yes | +| [Codex](/console/harnesses/codex) | Yes | Yes | Yes | +| [Hermes](/console/harnesses/hermes) | Built in | Built in | Yes | +| [Pi](/console/harnesses/pi) | Built in | Built in | Yes | +| [Raycast](/console/harnesses/raycast) | Extension | Extension | Yes | Free-tier chat models are only available from within OpenCode. Other harnesses receive HTTP `403` for those models, even with a Console API key. Use paid models or a Go subscription. @@ -46,7 +46,8 @@ Pay-as-you-go and Go are served from different base URLs. Use the one that match | Pay-as-you-go | `https://opencode.ai/inference` | | Go | `https://opencode.ai/inference/go` | -Append the path for the API your harness speaks. +Append the path for the API your harness speaks. Every API serves every paid and Go text model: Console sends a request +to the model's own endpoint when it speaks that API, and translates it otherwise. | API | Path | | ----------------------- | ----------------------------------------------- | @@ -59,22 +60,6 @@ Append the path for the API your harness speaks. https://opencode.ai/inference/go/openai/v1/chat/completions ``` -## Choose an API - -A harness can only use the models that accept the API it speaks. Most models accept one API, and some accept several. -The endpoint column in the [Console](/console/models#endpoints) and [Go](/console/go#endpoints) tables lists one API per -model, so check the harness page for the full list. - -Hermes, Pi, and the Raycast extension pick the right API for each paid or Go model. Claude Code and -Codex speak a single API and only reach part of the catalog. - -| Harness | API | Models | -| ----------- | ------------------ | ---------------------------------------------------------------- | -| Claude Code | Anthropic Messages | Claude and Qwen, plus Kimi K3, DeepSeek V4, and MiniMax M3 on Go | -| Codex | OpenAI Responses | GPT, Grok, and Muse Spark, plus DeepSeek V4 on Go | - -Support for every model in every harness is coming soon. - ## Sessions Send a stable session ID with every request so Console can route a conversation consistently and reuse its prompt diff --git a/services/www/src/docs/content/console/models.mdx b/services/www/src/docs/content/console/models.mdx index c9693738f1c5..9c4674a67234 100644 --- a/services/www/src/docs/content/console/models.mdx +++ b/services/www/src/docs/content/console/models.mdx @@ -31,6 +31,9 @@ You are charged per request and can add credits to your account. You can also access the models directly through the following API endpoints. Free-tier chat models require OpenCode; use paid models or [Go](/console/go) from other clients. +Each model is listed with its native API, but every text model also works through the other APIs, which Console +translates. See [Base URLs](/console/harnesses#base-urls) for the full set. +
| Model | Model ID | Endpoint | AI SDK Package | From 5f8487f4d8c8369f8dfe48be9ac68b8e0f646863 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Tue, 27 Oct 2026 13:56:42 +0100 Subject: [PATCH 003/138] fix(core): offer Console web search only when the workspace enables it (#59592) --- packages/core/src/plugin/provider/opencode.ts | 15 +++++++++++---- .../core/test/plugin/provider-opencode.test.ts | 6 ++++-- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index bb4a2cb1c83c..4f3c3cd2386f 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -355,11 +355,18 @@ export const OpencodePlugin = define< }) } }) - // Like any search plugin, the Console's provider is available whenever it is connected; the Console's config - // selects it as the default through config, where the user's own selection wins. + // The Console's config selects its provider only when the workspace enabled hosted search and the member's policy + // allows it; otherwise its searches fail, so the provider is offered only then. The selection still merges as + // config, where the user's own choice wins. yield* ctx.websearch.transform((editor) => { - if (snapshot.remote?.type === "config" && snapshot.connection) - editor.add(search(WebSearch.ID.make("opencode"), snapshot.connection)) + const provider = WebSearch.ID.make("opencode") + if ( + snapshot.remote?.type === "config" && + snapshot.remote.info.websearch && + snapshot.remote.info.websearch.provider === provider && + snapshot.connection + ) + editor.add(search(provider, snapshot.connection)) }) const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) { diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index 2fc3840c3035..ce92764ae7ac 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -2318,7 +2318,7 @@ describe("OpencodePlugin with a Console config", () => { ), ) - it.effect("registers Console web search without forcing it, so config selects it and user config wins", () => + it.effect("offers Console web search only when the Console selects it, and user config still wins", () => Effect.acquireUseRelease( Effect.sync(() => { const state = { selected: false } @@ -2354,13 +2354,15 @@ describe("OpencodePlugin with a Console config", () => { const host = yield* PluginHost.make(yield* Plugin.Service) yield* OpencodePlugin.effect(host).pipe(inProcess(server)) yield* ConfigWebSearchPlugin.Plugin.effect(host) - expect(yield* websearch.providers()).toContainEqual(opencode) + // A workspace without hosted search, or a member its policy denies, would get only failed searches. + expect(yield* websearch.providers()).not.toContainEqual(opencode) expect(yield* websearch.default()).toBeUndefined() state.selected = true yield* TestClock.adjust("1 minute") yield* drain expect(yield* announced(opencode)).toEqual(opencode) + expect(yield* websearch.providers()).toContainEqual(opencode) expect((yield* websearch.query({ query: "console" })).providerID).toBe(opencode.id) yield* config.setEntries([ From 5e313444555b05d55acf43ac5330a0f158210ec4 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Tue, 27 Oct 2026 14:05:35 +0100 Subject: [PATCH 004/138] refactor(core): drop the legacy Console config path (#59595) --- packages/core/src/config/plugin/policy.ts | 4 +- .../src/plugin/provider/opencode-legacy.ts | 191 -------- packages/core/src/plugin/provider/opencode.ts | 83 +--- .../test/plugin/provider-opencode.test.ts | 440 +++--------------- 4 files changed, 93 insertions(+), 625 deletions(-) delete mode 100644 packages/core/src/plugin/provider/opencode-legacy.ts diff --git a/packages/core/src/config/plugin/policy.ts b/packages/core/src/config/plugin/policy.ts index 3af1f70a2746..f5de7e739589 100644 --- a/packages/core/src/config/plugin/policy.ts +++ b/packages/core/src/config/plugin/policy.ts @@ -22,8 +22,8 @@ export const Plugin = define({ Effect.forkScoped({ startImmediately: true }), ) // Documents reverse so earlier-loaded policy wins: managed policy, then a Console config document, then user - // and project files. Statements from a Console serving the legacy format follow every document and have the - // final say. + // and project files. ManagedPolicy statements follow every document and have the final say; the Console no longer + // sets any, since it serves its policy as a config document. const policies = () => ManagedPolicy.statements(loaded.entries, managed.current()) const runSync = Effect.runSyncWith(yield* Effect.context()) yield* ctx.provider.transform((providers) => { diff --git a/packages/core/src/plugin/provider/opencode-legacy.ts b/packages/core/src/plugin/provider/opencode-legacy.ts deleted file mode 100644 index a282981ca510..000000000000 --- a/packages/core/src/plugin/provider/opencode-legacy.ts +++ /dev/null @@ -1,191 +0,0 @@ -export * as OpencodeLegacy from "./opencode-legacy.js" - -// The OpenCode Console's original `/api/v2/config` response, which it serves without `$schema`. Unlike a regular -// OpenCode config, the plugin applies it itself: providers, organization policy, web search, and MCP servers. -// Delete this module and its uses in opencode.ts once every Console serves a regular OpenCode config. - -import { Effect, Schema } from "effect" -import type { Context } from "@opencode/plugin/effect/plugin" -import type { WebSearchDefinition } from "@opencode/plugin/effect/websearch" -import { ConfigProvider } from "@opencode/schema/config/provider" -import { Mcp } from "@opencode/schema/mcp" -import { Money } from "@opencode/schema/money" -import { ConfigIntegration } from "../../config/integration.js" -import { Integration } from "../../integration.js" -import { Model } from "../../model.js" -import { Provider } from "../../provider.js" -import { Variant } from "../../variant.js" -import { WebSearch } from "../../websearch.js" - -export const Response = Schema.Struct({ - providers: Schema.Record(Schema.String, ConfigProvider.Info), - websearch: Schema.Struct({ - providerID: WebSearch.ID, - }).pipe(Schema.optional), - // MCP servers by name, in the same shape as a remote server in local config. Only remote servers are - // accepted so the Console can never make the client run a command. `auth: "console"` asks the client - // to attach its own Console credential to that server's requests. - mcp: Schema.Struct({ - servers: Schema.Record( - Schema.String, - Schema.Struct({ ...Mcp.RemoteConfig.fields, auth: Schema.Literal("console").pipe(Schema.optional) }), - ), - }).pipe(Schema.optional), - // Organization policy compiled for the authenticated caller; omitted when there is none. - // `experimental.policies` remains accepted alongside top-level `policies`. - policies: ConfigIntegration.Policies.pipe(Schema.optional), - experimental: Schema.Struct({ policies: ConfigIntegration.Policies.pipe(Schema.optional) }).pipe(Schema.optional), -}) -export type Response = typeof Response.Type - -export interface Snapshot { - readonly type: "legacy" - readonly response: Response - // Console MCP servers carry the credential in their headers, so a rotated token changes the snapshot. - readonly mcp: { servers: NonNullable["servers"]; headers: Record } | undefined -} - -/** The plugin's Console response in either format; this module only acts on the legacy one. */ -type Remote = Snapshot | { readonly type: "config" } | undefined - -type Connection = NonNullable>> - -export function make(response: Response, headers: Record): Snapshot { - return { type: "legacy", response, mcp: response.mcp && { servers: response.mcp.servers, headers } } -} - -/** Organization policy statements, which the plugin hands to `ManagedPolicy` instead of config. */ -export function statements(snapshot: Snapshot) { - return [...(snapshot.response.experimental?.policies ?? []), ...(snapshot.response.policies ?? [])] -} - -/** Providers the legacy response routes through the Console, so the plugin can explain their rejected inference. */ -export function providerIDs(remote: Remote) { - return remote?.type === "legacy" ? Object.keys(remote.response.providers) : [] -} - -/** - * Registers the transforms that apply a legacy snapshot: the Console's providers bound to its connection, its web - * search provider as the default, and its MCP servers with the Console credential where they ask for it. - */ -export const register = Effect.fn("OpencodeLegacy.register")(function* ( - ctx: Context, - current: () => { readonly remote: Remote; readonly connection: Connection | undefined }, - search: (providerID: WebSearch.ID, connection: Connection) => WebSearchDefinition, -) { - yield* ctx.provider.transform((providers) => { - const snapshot = current() - const remote = snapshot.remote?.type === "legacy" ? snapshot.remote : undefined - for (const [providerID, item] of Object.entries(remote?.response.providers ?? {})) { - const source = providers.get(item.canonical ?? providerID) - providers.update(providerID, (provider) => { - if (source && source.provider !== provider) - Object.assign(provider, structuredClone(source.provider), { id: provider.id }) - provider.integrationID = Integration.ID.make("opencode") - if (item.canonical !== undefined) provider.canonical = item.canonical - if (item.name !== undefined) provider.name = item.name - provider.package = item.package ?? provider.package - provider.settings = Provider.mergeOverlay( - withoutCredentials(provider.settings), - withoutCredentials(item.settings), - ) - provider.headers = Provider.mergeHeaders(provider.headers, item.headers) - provider.body = Provider.mergeOverlay(provider.body, item.body) - }) - - for (const [modelID, config] of Object.entries(item.models ?? {})) { - const base = source?.models.get(config.modelID ?? modelID) ?? source?.models.get(modelID) - providers.models.update(providerID, modelID, (model) => { - Object.assign(model, structuredClone(base ?? model)) - if (config.type !== undefined) model.type = config.type - if (config.family !== undefined) model.family = config.family - if (config.name !== undefined) model.name = config.name - if (config.modelID !== undefined) model.modelID = config.modelID - if (config.releaseDate !== undefined) model.time = { released: Date.parse(config.releaseDate) } - if (config.compatibility !== undefined) - model.compatibility = { ...model.compatibility, ...config.compatibility } - model.package = config.package ?? (item.package !== undefined ? undefined : model.package) - if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL - if (config.capabilities !== undefined) - model.capabilities = Model.mergeCapabilities(config.capabilities, model.capabilities) - model.settings = Provider.mergeOverlay( - withoutCredentials(model.settings), - withoutCredentials(config.settings), - ) - model.headers = Provider.mergeHeaders(model.headers, config.headers) - model.body = Provider.mergeOverlay(model.body, config.body) - if (config.limit !== undefined) model.limit = { ...model.limit, ...config.limit } - }) - // Variants are spelled for the final package, which the update above normalizes from `aisdk:*`. - providers.models.update(providerID, modelID, (model) => { - // The Console's reasoning replaces the base variants; its variants still overlay them below. - if (config.capabilities?.reasoning !== undefined) - model.variants = [ - ...Variant.resolve( - { ...model, package: model.package ?? providers.get(providerID)?.provider.package }, - Variant.supports(config.capabilities.reasoning), - ), - ] - for (const variant of config.variants ?? []) { - let existing = model.variants.find((item) => item.id === variant.id) - if (!existing) { - existing = { id: variant.id } - model.variants.push(existing) - } - if (variant.settings !== undefined) - existing.settings = Provider.mergeOverlay(existing.settings, withoutCredentials(variant.settings)) - if (variant.headers !== undefined) - existing.headers = Provider.mergeHeaders(existing.headers, variant.headers) - if (variant.body !== undefined) existing.body = Provider.mergeOverlay(existing.body, variant.body) - } - if (config.cost !== undefined) - model.cost = (Array.isArray(config.cost) ? config.cost : [config.cost]).map((cost) => ({ - tier: cost.tier && { ...cost.tier }, - input: cost.input, - output: cost.output, - cache: { - read: cost.cache?.read ?? Money.USDPerMillionTokens.zero, - write: cost.cache?.write ?? Money.USDPerMillionTokens.zero, - }, - })) - model.enabled = !config.disabled - }) - } - const configured = providers.get(providerID) - if (configured) - providers.add({ - info: configured.provider, - models: Array.from(configured.models.values()), - sourceConnection: snapshot.connection, - }) - } - }) - - yield* ctx.websearch.transform((editor) => { - const snapshot = current() - const descriptor = snapshot.remote?.type === "legacy" ? snapshot.remote.response.websearch : undefined - const connection = snapshot.connection - if (!descriptor || !connection) return - editor.add(search(descriptor.providerID, connection)) - editor.default.set(descriptor.providerID) - }) - - yield* ctx.mcp.transform((editor) => { - const remote = current().remote - const mcp = remote?.type === "legacy" ? remote.mcp : undefined - if (!mcp) return - for (const [name, server] of Object.entries(mcp.servers)) { - // A server the user configured under the same name wins. - if (editor.get(name)) continue - const { auth, ...config } = server - editor.set(name, auth === "console" ? { ...config, headers: { ...config.headers, ...mcp.headers } } : config) - } - }) -}) - -function withoutCredentials(body: Readonly> | undefined) { - return ( - body && - Object.fromEntries(Object.entries(body).filter(([key]) => !["apiKey", "authToken", "accessToken"].includes(key))) - ) -} diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 4f3c3cd2386f..9fc1e7a924b4 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -1,16 +1,4 @@ -import { - Clock, - Duration, - Effect, - Equal, - FiberHandle, - Option, - Predicate, - Schema, - Scope, - Semaphore, - Stream, -} from "effect" +import { Clock, Duration, Effect, Equal, FiberHandle, Option, Schema, Scope, Semaphore, Stream } from "effect" import type { IntegrationOAuthMethodRegistration } from "@opencode/plugin/effect/integration" import { define } from "@opencode/plugin/effect/plugin" import type { SessionHttpResponse } from "@opencode/plugin/effect/session" @@ -27,7 +15,6 @@ import { IntegrationConnection } from "../../integration/connection.js" import { ManagedPolicy } from "../../managed-policy.js" import { Provider } from "../../provider.js" import { WebSearch } from "../../websearch.js" -import { OpencodeLegacy } from "./opencode-legacy.js" const clientID = "opencode-cli" const methodID = Integration.MethodID.make("device") @@ -173,9 +160,8 @@ export const OpencodePlugin = define< const loading = Semaphore.makeUnsafe(1) type ActiveConnection = Effect.Success> let snapshot: { - // The Console response in one of its two formats: a regular OpenCode config, merged as an integration config - // layer, or the legacy response, which OpencodeLegacy applies directly. - remote: { type: "config"; info: ConfigIntegration.Remote } | OpencodeLegacy.Snapshot | undefined + // The Console's regular OpenCode config, merged as an integration config layer. + remote: ConfigIntegration.Remote | undefined connection: ActiveConnection organization: string | undefined } = { remote: undefined, connection: undefined, organization: undefined } @@ -202,10 +188,7 @@ export const OpencodePlugin = define< return Effect.succeed({ remote: undefined, connection, organization: undefined }) return fetchConfig(http, credential).pipe( Effect.map((remote) => ({ - remote: - remote?.type === "legacy" - ? OpencodeLegacy.make(remote.response, ConsoleAccess.headers(credential)) - : remote, + remote, connection, organization: organizationName(credential), })), @@ -228,7 +211,7 @@ export const OpencodePlugin = define< ) }) // The Console document as config layered it. Its providers apply at this plugin's position in the provider chain, - // where the legacy overlay applies, while the rest of the document merges through config. + // while the rest of the document merges through config. let integrated: ConfigIntegration.Document | undefined const integrate = Effect.fnUntraced(function* (document: ConfigIntegration.Document | undefined) { yield* config.integration?.(Integration.ID.make("opencode"), document) ?? Effect.void @@ -239,30 +222,21 @@ export const OpencodePlugin = define< }) // Policies and providers ride on the snapshot, so a credential switch, disconnect, or 404 replaces them too. const publish = Effect.fnUntraced(function* (next: typeof snapshot) { - switch (next.remote?.type) { - // A regular config merges as one integration document, which config loads right after managed policies, - // so its policies reach ManagedPolicy through config rather than as Console statements. - case "config": - yield* managed.set({ statements: [], organization: next.organization }) - return yield* integrate( - next.connection && - ConfigIntegration.make({ - integrationID: Integration.ID.make("opencode"), - connection: - next.connection.type === "credential" - ? { ...next.connection, id: Credential.ID.make(next.connection.id) } - : next.connection, - organization: next.organization, - info: next.remote.info, - }), - ) - case "legacy": - yield* managed.set({ statements: OpencodeLegacy.statements(next.remote), organization: next.organization }) - return yield* integrate(undefined) - default: - yield* managed.set({ statements: [], organization: next.organization }) - return yield* integrate(undefined) - } + yield* managed.set({ statements: [], organization: next.organization }) + // The config merges as one integration document, which config loads right after managed policies. + yield* integrate( + next.remote && + next.connection && + ConfigIntegration.make({ + integrationID: Integration.ID.make("opencode"), + connection: + next.connection.type === "credential" + ? { ...next.connection, id: Credential.ID.make(next.connection.id) } + : next.connection, + organization: next.organization, + info: next.remote, + }), + ) }) const search = (providerID: WebSearch.ID, connection: NonNullable) => ({ id: providerID, @@ -324,7 +298,6 @@ export const OpencodePlugin = define< snapshot = yield* load() yield* publish(snapshot) - yield* OpencodeLegacy.register(ctx, () => snapshot, search) yield* ctx.provider.transform((providers) => { if (integrated) ConfigProviderPlugin.integrate(providers, integrated) }) @@ -360,12 +333,7 @@ export const OpencodePlugin = define< // config, where the user's own choice wins. yield* ctx.websearch.transform((editor) => { const provider = WebSearch.ID.make("opencode") - if ( - snapshot.remote?.type === "config" && - snapshot.remote.info.websearch && - snapshot.remote.info.websearch.provider === provider && - snapshot.connection - ) + if (snapshot.remote?.websearch && snapshot.remote.websearch.provider === provider && snapshot.connection) editor.add(search(provider, snapshot.connection)) }) @@ -418,9 +386,7 @@ export const OpencodePlugin = define< next.connection ? new Set([ Provider.ID.opencode, - ...(next.remote?.type === "config" - ? Object.keys(next.remote.info.providers ?? {}) - : OpencodeLegacy.providerIDs(next.remote)), + ...Object.keys(next.remote?.providers ?? {}), ]) : [], (providerID) => ctx.session.hook("http.response", explain, { providerID }).pipe(Scope.provide(scope)), @@ -473,10 +439,7 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Key | Creden }) } const body = yield* HttpClientResponse.filterStatusOk(response).pipe(Effect.flatMap((ok) => ok.json)) - // A Console that serves a regular OpenCode config marks it with `$schema`; any other body is the legacy response. - if (Predicate.hasProperty(body, "$schema")) - return { type: "config" as const, info: yield* Schema.decodeUnknownEffect(ConfigIntegration.Remote)(body) } - return { type: "legacy" as const, response: yield* Schema.decodeUnknownEffect(OpencodeLegacy.Response)(body) } + return yield* Schema.decodeUnknownEffect(ConfigIntegration.Remote)(body) }), ), Effect.scoped, diff --git a/packages/core/test/plugin/provider-opencode.test.ts b/packages/core/test/plugin/provider-opencode.test.ts index ce92764ae7ac..dbe9dce194b9 100644 --- a/packages/core/test/plugin/provider-opencode.test.ts +++ b/packages/core/test/plugin/provider-opencode.test.ts @@ -17,8 +17,6 @@ import { ConfigProviderPlugin } from "@opencode/core/config/plugin/provider" import { ConfigWebSearchPlugin } from "@opencode/core/config/plugin/websearch" import { Credential } from "@opencode/core/credential" import { Integration } from "@opencode/core/integration" -import { ManagedPolicy } from "@opencode/core/managed-policy" -import { Mcp } from "@opencode/core/mcp/index" import { Model } from "@opencode/core/model" import { ModelResolver } from "@opencode/core/model-resolver" import { Plugin } from "@opencode/core/plugin" @@ -30,7 +28,6 @@ import { Provider } from "@opencode/core/provider" import { Session } from "@opencode/core/session" import { WebSearch } from "@opencode/core/websearch" import { withEnv } from "../fixture/env" -import { emptyMcp } from "../fixture/mcp" import { drain } from "../lib/clock" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -43,6 +40,19 @@ const addPlugin = Effect.fn(function* () { yield* OpencodePlugin.effect(host) }) +// The Console's config merges as an integration document; its policies are what organization policy the member gets. +const consoleDocument = (config: Config.Interface) => + config + .entries() + .pipe( + Effect.map((entries) => + entries.find((entry): entry is ConfigIntegration.Document => entry instanceof ConfigIntegration.Document), + ), + ) +const consoleSearch = { id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" } +const consolePolicies = (config: Config.Interface) => + consoleDocument(config).pipe(Effect.map((document) => document?.info.policies ?? [])) + const noRemoteConfig = HttpClient.make((request) => Effect.succeed(HttpClientResponse.fromWeb(request, new Response(null, { status: 404 }))), ) @@ -697,7 +707,7 @@ describe("OpencodePlugin", () => { if (state.failing) return new Response("Unavailable", { status: 502 }) return Response.json({ providers: {}, - ...(state.advertised ? { websearch: { providerID: "opencode" } } : {}), + ...(state.advertised ? { websearch: { provider: "opencode" } } : {}), }) }, }) @@ -723,33 +733,33 @@ describe("OpencodePlugin", () => { yield* drain const initial = { ...rebuilds } expect(state.requests).toBe(1) - expect(yield* websearch.default()).toBeUndefined() + expect(yield* websearch.providers()).toEqual([]) state.advertised = true yield* TestClock.adjust("50 seconds") yield* drain expect(state.requests).toBe(1) expect(rebuilds).toEqual(initial) - expect(yield* websearch.default()).toBeUndefined() + expect(yield* websearch.providers()).toEqual([]) yield* TestClock.adjust("10 seconds") yield* drain expect(state.requests).toBe(2) expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 }) - expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" }) + expect(yield* websearch.providers()).toContainEqual(consoleSearch) yield* TestClock.adjust("1 minute") yield* drain expect(state.requests).toBe(3) expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 }) - expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" }) + expect(yield* websearch.providers()).toContainEqual(consoleSearch) state.failing = true yield* TestClock.adjust("1 minute") yield* drain expect(state.requests).toBe(4) expect(rebuilds).toEqual({ provider: initial.provider + 1, websearch: initial.websearch + 1 }) - expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" }) + expect(yield* websearch.providers()).toContainEqual(consoleSearch) state.failing = false state.advertised = false @@ -758,197 +768,11 @@ describe("OpencodePlugin", () => { expect(state.requests).toBe(5) expect(rebuilds).toEqual({ provider: initial.provider + 2, websearch: initial.websearch + 2 }) expect(yield* websearch.providers()).toEqual([]) - expect(yield* websearch.default()).toBeUndefined() - }), - ({ server }) => Effect.promise(() => server.stop(true)), - ), - ) - - it.effect("registers the Console's MCP servers as sent, attaching the credential only where asked", () => - Effect.acquireUseRelease( - Effect.sync(() => { - const state = { advertised: true } - const server = Bun.serve({ - port: 0, - fetch: (request) => - Response.json({ - providers: {}, - ...(state.advertised - ? { - mcp: { - servers: { - opencode_console: { - type: "remote", - url: `${new URL(request.url).origin}/console/mcp`, - headers: { "x-client": "opencode", authorization: "Bearer forged" }, - oauth: false, - auth: "console", - }, - opencode_console_oauth: { - type: "remote", - url: `${new URL(request.url).origin}/console/oauth/mcp`, - oauth: { scope: "workspace" }, - timeout: { startup: 5000 }, - }, - }, - }, - } - : {}), - }), - }) - return { server, state } - }), - ({ server, state }) => - Effect.gen(function* () { - const credentials = yield* Credential.Service - const transforms: Array<(editor: Mcp.Editor) => void> = [] - const reloads = { count: 0 } - const servers = () => { - const configured = new Map() - transforms.forEach((transform) => - transform({ - list: () => [], - get: (name) => (configured.has(name) ? { type: "remote", url: "user" } : undefined), - set: (name, config) => configured.set(name, config), - update: () => {}, - remove: (name) => configured.delete(name), - }), - ) - return Object.fromEntries(configured) - } - yield* credentials.create({ - integrationID: Integration.ID.make("opencode"), - value: Credential.Key.make({ - type: "key", - key: "secret", - metadata: { server: server.url.origin, orgID: "org-a" }, - }), - }) - yield* addPlugin().pipe( - Effect.provideService( - Mcp.Service, - Mcp.Service.of({ - ...emptyMcp, - transform: (transform) => - Effect.sync(() => { - transforms.push(transform) - return { dispose: Effect.void } - }), - reload: () => - Effect.sync(() => { - reloads.count++ - }), - }), - ), - inProcess(server), - ) - yield* drain - - expect(servers()).toEqual({ - opencode_console: { - type: "remote", - url: `${server.url.origin}/console/mcp`, - headers: { "x-client": "opencode", authorization: "Bearer secret", "x-org-id": "org-a" }, - oauth: false, - }, - opencode_console_oauth: { - type: "remote", - url: `${server.url.origin}/console/oauth/mcp`, - oauth: { scope: "workspace" }, - timeout: { startup: 5000 }, - }, - }) - - state.advertised = false - yield* TestClock.adjust("1 minute") - yield* drain - expect(servers()).toEqual({}) - expect(reloads.count).toBe(1) }), ({ server }) => Effect.promise(() => server.stop(true)), ), ) - it.effect("ignores unsupported managed actions, effects, and keys while enforcing supported policies", () => - Effect.acquireUseRelease( - Effect.sync(() => - Bun.serve({ - port: 0, - fetch: () => - Response.json({ - providers: { opencode: {} }, - experimental: { - policies: [ - { action: "future.use", resource: { names: ["anything"] }, effect: "future-effect", future: true }, - { action: "provider.use", resource: "*", effect: "deny" }, - { action: "provider.use", resource: "opencode", effect: "allow" }, - { action: "tool.use", resource: "shell:*", effect: "deny" }, - { action: "tool.use", resource: "shell:git *", effect: "allow" }, - { action: "tool.use", resource: "shell:git status", effect: "future-effect" }, - { action: "permission", resource: "*", effect: "deny" }, - { action: "tool.use", resource: "shell:sudo *", effect: "deny", audience: "ignored" }, - { action: "integration.use", resource: "mcp:restricted", effect: "deny" }, - ], - unknown: { future: true }, - }, - future: { unknown: true }, - }), - }), - ), - (server) => - Effect.gen(function* () { - const credentials = yield* Credential.Service - const catalog = yield* Provider.Service - const managed = yield* ManagedPolicy.Service - const plugins = yield* Plugin.Service - yield* catalog.transform((catalog) => catalog.update(Provider.ID.anthropic, () => {})) - yield* credentials.create({ - integrationID: Integration.ID.make("opencode"), - value: Credential.Key.make({ - type: "key", - key: "secret", - metadata: { server: server.url.origin, orgID: "org_test", orgName: "Acme" }, - }), - }) - const host = yield* PluginHost.make(plugins) - yield* OpencodePlugin.effect(host) - yield* ConfigPolicyPlugin.Plugin.effect(host).pipe(Effect.provide(Config.testLayer([]))) - - expect(managed.current()).toEqual({ - statements: [ - { action: "provider.use", resource: "*", effect: "deny" }, - { action: "provider.use", resource: "opencode", effect: "allow" }, - { action: "tool.use", resource: "shell:*", effect: "deny" }, - { action: "tool.use", resource: "shell:git *", effect: "allow" }, - { action: "tool.use", resource: "shell:sudo *", effect: "deny" }, - { action: "integration.use", resource: "mcp:restricted", effect: "deny" }, - ], - organization: "Acme", - }) - const hooks = yield* PluginHooks.Service - const decision = yield* hooks.trigger("permission", "evaluate", { - sessionID: Session.ID.make("ses_tool_policy"), - action: "shell", - resources: ["sudo ls"], - effect: "allow", - }) - expect(decision.effect).toBe("deny") - expect(decision.message).toBe("Blocked by Acme's policy") - expect( - (yield* hooks.trigger("permission", "evaluate", { - sessionID: Session.ID.make("ses_tool_policy"), - action: "shell", - resources: ["git status"], - effect: "ask", - })).effect, - ).toBe("ask") - expect(yield* catalog.get(Provider.ID.anthropic)).toBeUndefined() - expect(yield* catalog.get(Provider.ID.opencode)).toBeDefined() - }), - (server) => Effect.promise(() => server.stop(true)), - ), - ) - it.effect("refreshes past future-only policies while retaining config on malformed statements", () => Effect.acquireUseRelease( Effect.sync(() => { @@ -957,14 +781,14 @@ describe("OpencodePlugin", () => { } const server = Bun.serve({ port: 0, - fetch: () => Response.json({ providers: { opencode: {} }, experimental: { policies: state.policies } }), + fetch: () => Response.json({ providers: { opencode: {} }, policies: state.policies }), }) return { server, state } }), ({ server, state }) => Effect.gen(function* () { const credentials = yield* Credential.Service - const managed = yield* ManagedPolicy.Service + const config = yield* Config.Service const providers = yield* Provider.Service const sudo: ConfigPolicy.Info = { action: "tool.use", resource: "shell:sudo *", effect: "deny" } const env: ConfigPolicy.Info = { action: "tool.use", resource: "edit:*.env", effect: "deny" } @@ -978,7 +802,7 @@ describe("OpencodePlugin", () => { }) yield* addPlugin().pipe(inProcess(server)) yield* drain - expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" }) + expect(yield* consolePolicies(config)).toEqual([sudo]) for (const malformed of [ { action: "tool.use", resource: 42, effect: "deny" }, @@ -989,124 +813,25 @@ describe("OpencodePlugin", () => { state.policies = [env, malformed] yield* TestClock.adjust("1 minute") yield* drain - expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" }) + expect(yield* consolePolicies(config)).toEqual([sudo]) expect(yield* providers.get(Provider.ID.opencode)).toBeDefined() } state.policies = [env, { action: "tool.use", resource: "shell:*", effect: "future-effect" }] yield* TestClock.adjust("1 minute") yield* drain - expect(managed.current()).toEqual({ statements: [env], organization: "Acme" }) + expect(yield* consolePolicies(config)).toEqual([env]) state.policies = [{ action: "future.use", future: { values: [true] } }] yield* TestClock.adjust("1 minute") yield* drain - expect(managed.current()).toEqual({ statements: [], organization: "Acme" }) + expect(yield* consolePolicies(config)).toEqual([]) expect(yield* providers.get(Provider.ID.opencode)).toBeDefined() state.policies = [{ action: "future.use", effect: { next: true } }, env] yield* TestClock.adjust("1 minute") yield* drain - expect(managed.current()).toEqual({ statements: [env], organization: "Acme" }) - }), - ({ server }) => Effect.promise(() => server.stop(true)), - ), - ) - - it.effect("keeps policy statements bound to the connected Console account", () => - Effect.acquireUseRelease( - Effect.sync(() => { - const state: { status: number; policies: Record; requests: number } = { - status: 200, - policies: {}, - requests: 0, - } - const server = Bun.serve({ - port: 0, - fetch: (request) => { - state.requests++ - if (state.status !== 200) return new Response("Unavailable", { status: state.status }) - const policies = state.policies[request.headers.get("x-org-id") ?? ""] - return Response.json({ providers: {}, ...(policies ? { experimental: { policies } } : {}) }) - }, - }) - return { server, state } - }), - ({ server, state }) => - Effect.gen(function* () { - const credentials = yield* Credential.Service - const providers = yield* Provider.Service - const managed = yield* ManagedPolicy.Service - const rebuilds = { count: 0 } - const account = (orgID: string, orgName: string) => - credentials.create({ - integrationID: Integration.ID.make("opencode"), - value: Credential.Key.make({ - type: "key", - key: orgID, - metadata: { server: server.url.origin, orgID, orgName }, - }), - }) - const sudo: ConfigPolicy.Info = { action: "tool.use", resource: "shell:sudo *", effect: "deny" } - const env: ConfigPolicy.Info = { action: "tool.use", resource: "edit:*.env", effect: "deny" } - yield* providers.transform(() => { - rebuilds.count++ - }) - const alpha = yield* account("org_alpha", "Alpha") - yield* addPlugin().pipe(inProcess(server)) - yield* drain - const initial = rebuilds.count - expect(state.requests).toBe(1) - expect(managed.current()).toEqual({ statements: [], organization: "Alpha" }) - - state.policies.org_alpha = [sudo] - yield* TestClock.adjust("1 minute") - yield* drain - expect(state.requests).toBe(2) - expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" }) - expect(rebuilds.count).toBe(initial + 1) - - yield* TestClock.adjust("1 minute") - yield* drain - expect(state.requests).toBe(3) - expect(rebuilds.count).toBe(initial + 1) - - // An outage for the same connection keeps the last statements instead of lifting them. - state.status = 503 - yield* TestClock.adjust("1 minute") - yield* drain - expect(state.requests).toBe(4) - expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" }) - expect(rebuilds.count).toBe(initial + 1) - - state.status = 200 - state.policies.org_beta = [env] - const beta = yield* account("org_beta", "Beta") - yield* eventually( - Effect.sync(() => managed.current()), - (current) => current.organization === "Beta", - ) - expect(managed.current()).toEqual({ statements: [env], organization: "Beta" }) - - state.status = 404 - yield* TestClock.adjust("1 minute") - yield* drain - expect(managed.current()).toEqual({ statements: [], organization: "Beta" }) - - state.status = 200 - yield* credentials.remove(beta.id) - yield* eventually( - Effect.sync(() => managed.current()), - (current) => current.organization === "Alpha", - ) - expect(managed.current()).toEqual({ statements: [sudo], organization: "Alpha" }) - - yield* credentials.remove(alpha.id) - yield* eventually( - Effect.sync(() => managed.current()), - (current) => current.organization === undefined, - ) - expect(managed.current()).toEqual({ statements: [], organization: undefined }) + expect(yield* consolePolicies(config)).toEqual([env]) }), ({ server }) => Effect.promise(() => server.stop(true)), ), @@ -1126,7 +851,7 @@ describe("OpencodePlugin", () => { ) return Response.json({ providers: {}, - experimental: { policies: [{ action: "tool.use", resource: "shell:sudo *", effect: "deny" }] }, + policies: [{ action: "tool.use", resource: "shell:sudo *", effect: "deny" }], }) }, }) @@ -1136,7 +861,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const credentials = yield* Credential.Service const integrations = yield* Integration.Service - const managed = yield* ManagedPolicy.Service + const config = yield* Config.Service const status = () => integrations .get(Integration.ID.make("opencode")) @@ -1159,13 +884,13 @@ describe("OpencodePlugin", () => { yield* drain const url = `${server.url.origin}/console/auth/sso/ssoconn%2F1/start?redirectTo=%2Fconsole%2F` expect(yield* status()).toEqual({ status: "needs_auth", message: "Sign in with SSO again to use Acme", url }) - expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" }) + expect(yield* consolePolicies(config)).toEqual([sudo]) state.sso = false yield* TestClock.adjust("1 minute") yield* drain expect(yield* status()).toBeUndefined() - expect(managed.current()).toEqual({ statements: [sudo], organization: "Acme" }) + expect(yield* consolePolicies(config)).toEqual([sudo]) }), ({ server }) => Effect.promise(() => server.stop(true)), ), @@ -1262,7 +987,7 @@ describe("OpencodePlugin", () => { state.body ?? Response.json({ providers: {}, - experimental: { policies: [{ action: "tool.use", resource: "shell:sudo *", effect: "deny" }] }, + policies: [{ action: "tool.use", resource: "shell:sudo *", effect: "deny" }], }), }) return { server, state } @@ -1271,7 +996,7 @@ describe("OpencodePlugin", () => { Effect.gen(function* () { const credentials = yield* Credential.Service const integrations = yield* Integration.Service - const managed = yield* ManagedPolicy.Service + const config = yield* Config.Service const sudo: ConfigPolicy.Info = { action: "tool.use", resource: "shell:sudo *", effect: "deny" } yield* credentials.create({ integrationID: Integration.ID.make("opencode"), @@ -1288,7 +1013,7 @@ describe("OpencodePlugin", () => { yield* TestClock.adjust("1 minute") yield* drain expect((yield* integrations.get(Integration.ID.make("opencode")))?.connections[0]?.status).toBeUndefined() - expect(managed.current().statements).toEqual([sudo]) + expect(yield* consolePolicies(config)).toEqual([sudo]) } }), ({ server }) => Effect.promise(() => server.stop(true)), @@ -1459,9 +1184,7 @@ describe("OpencodePlugin", () => { providers: {}, ...(state.advertised ? { - websearch: { - providerID: "opencode", - }, + websearch: { provider: "opencode" }, } : {}), }) @@ -1507,8 +1230,8 @@ describe("OpencodePlugin", () => { id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search", }) - expect(yield* websearch.default()).toEqual({ id: WebSearch.ID.make("opencode"), name: "OpenCode Web Search" }) - expect(yield* websearch.query({ query: "effect web search" })).toEqual( + expect(yield* websearch.providers()).toContainEqual(consoleSearch) + expect(yield* websearch.query({ query: "effect web search", providerID: consoleSearch.id })).toEqual( new WebSearch.Response({ providerID: WebSearch.ID.make("opencode"), results: [ @@ -1540,7 +1263,7 @@ describe("OpencodePlugin", () => { yield* credentials.update(initial.id, { value: account("replacement"), }) - yield* websearch.query({ query: "fresh credential" }) + yield* websearch.query({ query: "fresh credential", providerID: consoleSearch.id }) expect(requests.at(-1)).toMatchObject({ method: "POST", authorization: "Bearer replacement", @@ -1550,7 +1273,7 @@ describe("OpencodePlugin", () => { yield* credentials.update(initial.id, { value: account("moved", `${server.url.origin}/other///?ignored=true#ignored`), }) - yield* websearch.query({ query: "updated server" }) + yield* websearch.query({ query: "updated server", providerID: consoleSearch.id }) expect(requests.at(-1)).toMatchObject({ method: "POST", path: "/other/api/websearch", @@ -1563,7 +1286,9 @@ describe("OpencodePlugin", () => { }) state.providerID = "unexpected" - expect((yield* websearch.query({ query: "wrong provider" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request") + expect( + (yield* websearch.query({ query: "wrong provider", providerID: consoleSearch.id }).pipe(Effect.flip))._tag, + ).toBe("WebSearch.Request") state.advertised = false state.waitForConfig = true @@ -1576,13 +1301,14 @@ describe("OpencodePlugin", () => { Effect.sync(() => requests), (requests) => requests.some((request) => request.authorization === "Bearer switched"), ) - expect((yield* websearch.query({ query: "switch race" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request") + expect( + (yield* websearch.query({ query: "switch race", providerID: consoleSearch.id }).pipe(Effect.flip))._tag, + ).toBe("WebSearch.Request") expect(requests.filter((request) => request.path === "/api/websearch")).toHaveLength(searchCount) gate.resolve() yield* eventually(websearch.providers(), (providers) => providers.every((provider) => provider.id !== WebSearch.ID.make("opencode")), ) - expect(yield* websearch.default()).toBeUndefined() expect(requests.at(-1)).toMatchObject({ method: "GET", path: "/api/v2/config", @@ -1595,7 +1321,7 @@ describe("OpencodePlugin", () => { ), ) - it.live("derives hosted search identity and the default Console endpoint locally", () => + it.live("sends hosted search to the default Console when the credential names none", () => Effect.acquireUseRelease( Effect.sync(() => Bun.serve({ @@ -1604,14 +1330,10 @@ describe("OpencodePlugin", () => { if (new URL(request.url).pathname === "/console/api/v2/config") { return Response.json({ providers: {}, - websearch: { - providerID: "managed-search", - name: "Remote name", - url: "https://example.invalid/search", - }, + websearch: { provider: "opencode" }, }) } - return Response.json({ providerID: "managed-search", results: [] }) + return Response.json({ providerID: "opencode", results: [] }) }, }), ), @@ -1637,12 +1359,8 @@ describe("OpencodePlugin", () => { ), ) - expect(yield* websearch.default()).toEqual({ - id: WebSearch.ID.make("managed-search"), - name: "OpenCode Web Search", - }) - expect(yield* websearch.query({ query: "default Console" })).toEqual( - new WebSearch.Response({ providerID: WebSearch.ID.make("managed-search"), results: [] }), + expect(yield* websearch.query({ query: "default Console", providerID: consoleSearch.id })).toEqual( + new WebSearch.Response({ providerID: WebSearch.ID.make("opencode"), results: [] }), ) expect(requests).toEqual([ "https://opencode.ai/console/api/v2/config", @@ -1666,9 +1384,7 @@ describe("OpencodePlugin", () => { if (url.pathname === "/console/api/v2/config") { return Response.json({ providers: {}, - websearch: { - providerID: "opencode", - }, + websearch: { provider: "opencode" }, }) } if (url.pathname === "/console/api/websearch") { @@ -1694,11 +1410,15 @@ describe("OpencodePlugin", () => { }) yield* addPlugin() - expect((yield* websearch.query({ query: "private search" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request") + expect( + (yield* websearch.query({ query: "private search", providerID: consoleSearch.id }).pipe(Effect.flip))._tag, + ).toBe("WebSearch.Request") expect(requests).toEqual(["/console/api/v2/config", "/console/api/websearch"]) state.crossOrigin = true - expect((yield* websearch.query({ query: "private search" }).pipe(Effect.flip))._tag).toBe("WebSearch.Request") + expect( + (yield* websearch.query({ query: "private search", providerID: consoleSearch.id }).pipe(Effect.flip))._tag, + ).toBe("WebSearch.Request") expect(requests).toEqual(["/console/api/v2/config", "/console/api/websearch", "/console/api/websearch"]) }), ({ server }) => Effect.promise(() => server.stop(true)), @@ -1716,9 +1436,7 @@ describe("OpencodePlugin", () => { if (url.pathname === "/api/v2/config") { return Response.json({ providers: {}, - websearch: { - providerID: "opencode", - }, + websearch: { provider: "opencode" }, }) } return new Response( @@ -1750,7 +1468,9 @@ describe("OpencodePlugin", () => { }) yield* addPlugin() - const error = yield* websearch.query({ query: "rejected search" }).pipe(Effect.flip) + const error = yield* websearch + .query({ query: "rejected search", providerID: consoleSearch.id }) + .pipe(Effect.flip) expect(error._tag).toBe("WebSearch.Request") yield* eventually( Effect.sync(() => state.cancelled), @@ -2034,39 +1754,22 @@ describe("OpencodePlugin", () => { ) }) -// A Console that marks its response with `$schema` serves a regular OpenCode config, merged as an integration document. describe("OpencodePlugin with a Console config", () => { const $schema = "https://opencode.ai/config.json" - const consoleDocument = (config: Config.Interface) => - config - .entries() - .pipe( - Effect.map((entries) => - entries.find((entry): entry is ConfigIntegration.Document => entry instanceof ConfigIntegration.Document), - ), - ) const deny: ConfigPolicy.Info = { action: "tool.use", resource: "shell:*", effect: "deny" } - it.effect("merges a response with $schema as config and applies one without it as before", () => + it.effect("merges the Console response as config, with its policy named after the organization", () => Effect.acquireUseRelease( - Effect.sync(() => { - const state = { schema: true } - const server = Bun.serve({ + Effect.sync(() => + Bun.serve({ port: 0, - fetch: () => - Response.json({ - ...(state.schema ? { $schema } : {}), - providers: { remote: { name: "Remote" } }, - policies: [deny], - }), - }) - return { server, state } - }), - ({ server, state }) => + fetch: () => Response.json({ $schema, providers: { remote: { name: "Remote" } }, policies: [deny] }), + }), + ), + (server) => Effect.gen(function* () { const credentials = yield* Credential.Service const config = yield* Config.Service - const managed = yield* ManagedPolicy.Service const hooks = yield* PluginHooks.Service yield* credentials.create({ integrationID: Integration.ID.make("opencode"), @@ -2083,7 +1786,6 @@ describe("OpencodePlugin with a Console config", () => { const document = required(yield* consoleDocument(config)) expect(document.info.providers?.remote?.name).toBe("Remote") expect(document.organization).toBe("Acme") - expect(managed.current()).toEqual({ statements: [], organization: "Acme" }) expect( yield* hooks.trigger("permission", "evaluate", { sessionID: Session.ID.make("ses_console_policy"), @@ -2092,14 +1794,8 @@ describe("OpencodePlugin with a Console config", () => { effect: "allow", }), ).toMatchObject({ effect: "deny", message: "Blocked by Acme's policy" }) - - state.schema = false - yield* TestClock.adjust("1 minute") - yield* drain - expect(yield* consoleDocument(config)).toBeUndefined() - expect(managed.current()).toEqual({ statements: [deny], organization: "Acme" }) }), - ({ server }) => Effect.promise(() => server.stop(true)), + (server) => Effect.promise(() => server.stop(true)), ), ) From d78b817631258f47926d594593a369ee03a61871 Mon Sep 17 00:00:00 2001 From: Victor Navarro Date: Tue, 27 Oct 2026 14:11:06 +0100 Subject: [PATCH 005/138] refactor(core): drop the ManagedPolicy service (#59609) --- packages/core/src/config/plugin/policy.ts | 12 +-- packages/core/src/managed-policy.ts | 73 ++++------------- packages/core/src/plugin/internal.ts | 3 - packages/core/src/plugin/module.ts | 3 +- packages/core/src/plugin/provider/opencode.ts | 7 +- packages/core/src/plugin/supervisor.ts | 3 - packages/core/test/config/config.test.ts | 4 +- packages/core/test/config/policy.test.ts | 78 +++++++++---------- packages/core/test/fixture/policy.ts | 24 +++++- packages/core/test/mcp.test.ts | 22 ++---- packages/core/test/plugin/fixture.ts | 2 - packages/core/test/plugin/module.test.ts | 20 ++--- .../test/plugin/supervisor-reload.test.ts | 19 +++-- packages/core/test/session-skill.test.ts | 76 +++++++++--------- packages/core/test/skill.test.ts | 33 ++++---- packages/core/test/tool-skill.test.ts | 8 +- 16 files changed, 165 insertions(+), 222 deletions(-) diff --git a/packages/core/src/config/plugin/policy.ts b/packages/core/src/config/plugin/policy.ts index f5de7e739589..54bce2e1cebc 100644 --- a/packages/core/src/config/plugin/policy.ts +++ b/packages/core/src/config/plugin/policy.ts @@ -1,7 +1,7 @@ export * as ConfigPolicyPlugin from "./policy.js" import { define } from "@opencode/plugin/effect/plugin" -import { Effect, Stream } from "effect" +import { Effect } from "effect" import { Config } from "../../config.js" import { ManagedPolicy } from "../../managed-policy.js" import { State } from "../../state.js" @@ -12,19 +12,13 @@ export const Plugin = define({ id: "opencode.config.policy", effect: Effect.fn(function* (ctx) { const config = yield* Config.Service - const managed = yield* ManagedPolicy.Service const reload = State.batch( Effect.all([ctx.provider.reload(), ctx.mcp.reload(), ctx.skill.reload()], { discard: true }), ) const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, reload) - yield* managed.changes().pipe( - Stream.runForEach(() => reload), - Effect.forkScoped({ startImmediately: true }), - ) // Documents reverse so earlier-loaded policy wins: managed policy, then a Console config document, then user - // and project files. ManagedPolicy statements follow every document and have the final say; the Console no longer - // sets any, since it serves its policy as a config document. - const policies = () => ManagedPolicy.statements(loaded.entries, managed.current()) + // and project files. + const policies = () => ManagedPolicy.statements(loaded.entries) const runSync = Effect.runSyncWith(yield* Effect.context()) yield* ctx.provider.transform((providers) => { const current = policies() diff --git a/packages/core/src/managed-policy.ts b/packages/core/src/managed-policy.ts index c91f2c82bc3a..e7a3b018f9a0 100644 --- a/packages/core/src/managed-policy.ts +++ b/packages/core/src/managed-policy.ts @@ -1,70 +1,23 @@ export * as ManagedPolicy from "./managed-policy.js" import { Document, type Entry } from "@opencode/schema/config" -import { isDeepStrictEqual } from "node:util" import { ConfigIntegration } from "./config/integration.js" import { Wildcard } from "./util/wildcard.js" import { ConfigPolicy } from "@opencode/schema/config/policy" -import { Context, Effect, Layer, PubSub, Stream } from "effect" -import { makeGlobalNode } from "@opencode/util/effect/app-node" -/** Policy statements the connected OpenCode Console compiled for whoever it authenticated. */ -export interface State { - readonly statements: ReadonlyArray - /** Organization name for denial messages, when the connection knows it. */ - readonly organization?: string -} - -export interface Interface { - readonly changes: () => Stream.Stream - /** Synchronous so catalog transforms can consult the statements while they run. */ - readonly current: () => State - /** Replaces the whole state; statements never merge across connections. */ - readonly set: (state: State) => Effect.Effect -} - -export class Service extends Context.Service()("@opencode/ManagedPolicy") {} - -export const layer = Layer.effect( - Service, - Effect.gen(function* () { - const changes = yield* PubSub.unbounded() - const state: { current: State } = { current: { statements: [] } } - return Service.of({ - changes: () => Stream.fromPubSub(changes), - current: () => state.current, - set: (next) => - Effect.gen(function* () { - const changed = - state.current.organization !== next.organization || - !isDeepStrictEqual(state.current.statements, next.statements) - state.current = next - if (!changed) return - yield* PubSub.publish(changes, undefined) - }), - }) - }), -) - -export const node = makeGlobalNode({ service: Service, layer, deps: [] }) - -export function statements(entries: readonly Entry[], organization: State) { - return [ - ...entries - .filter((entry): entry is Document => entry.type === "document") - .toReversed() - .flatMap((entry) => - (entry.info.policies ?? []).map((policy) => ({ - ...policy, - // An integration document's policies are those of the organization behind its connection. - message: - entry instanceof ConfigIntegration.Document - ? blocked(entry.organization) - : "Blocked by configuration policy", - })), - ), - ...organization.statements.map((policy) => ({ ...policy, message: blocked(organization.organization) })), - ] +/** Every policy statement in config, earliest-loaded first so it wins, each with the message a denial shows. */ +export function statements(entries: readonly Entry[]) { + return entries + .filter((entry): entry is Document => entry.type === "document") + .toReversed() + .flatMap((entry) => + (entry.info.policies ?? []).map((policy) => ({ + ...policy, + // An integration document's policies are those of the organization behind its connection. + message: + entry instanceof ConfigIntegration.Document ? blocked(entry.organization) : "Blocked by configuration policy", + })), + ) } function blocked(organization: string | undefined) { diff --git a/packages/core/src/plugin/internal.ts b/packages/core/src/plugin/internal.ts index e0421b9c5802..c9fa35cf0ba2 100644 --- a/packages/core/src/plugin/internal.ts +++ b/packages/core/src/plugin/internal.ts @@ -50,7 +50,6 @@ import { Integration } from "../integration.js" import { Job } from "../job.js" import { KV } from "../kv.js" import { Location } from "../location.js" -import { ManagedPolicy } from "../managed-policy.js" import { ModelsDev } from "../models-dev.js" import { Mcp } from "../mcp/index.js" import { Npm } from "@opencode/util/npm" @@ -131,7 +130,6 @@ const services = [ KV.Service, LLMClient.Service, Location.Service, - ManagedPolicy.Service, ModelsDev.Service, Mcp.Service, Npm.Service, @@ -185,7 +183,6 @@ export const requirements = LayerNode.group([ KV.node, llmClient, Location.node, - ManagedPolicy.node, ModelsDev.node, Mcp.node, Npm.node, diff --git a/packages/core/src/plugin/module.ts b/packages/core/src/plugin/module.ts index a6caaa82d639..22aae51767fb 100644 --- a/packages/core/src/plugin/module.ts +++ b/packages/core/src/plugin/module.ts @@ -19,7 +19,6 @@ import { Watcher } from "../filesystem/watcher.js" export const make = Effect.fn("PluginModule.make")(function* () { const config = yield* Config.Service - const managed = yield* ManagedPolicy.Service const watcher = yield* Watcher.Service const scope = yield* Effect.scope const runPromise = yield* FiberSet.makeRuntimePromise() @@ -53,7 +52,7 @@ export const make = Effect.fn("PluginModule.make")(function* () { path.isAbsolute(operation.target) || version <= 0 ? operation.target : operation.target.slice(0, version) if ( ManagedPolicy.decision( - ManagedPolicy.statements(yield* config.entries(), managed.current()), + ManagedPolicy.statements(yield* config.entries()), "integration.use", `plugin:${target}`, ) === "deny" diff --git a/packages/core/src/plugin/provider/opencode.ts b/packages/core/src/plugin/provider/opencode.ts index 9fc1e7a924b4..19317d3676d3 100644 --- a/packages/core/src/plugin/provider/opencode.ts +++ b/packages/core/src/plugin/provider/opencode.ts @@ -12,7 +12,6 @@ import { ConsoleAccess } from "../../console-access.js" import { Credential } from "../../credential.js" import { Integration } from "../../integration.js" import { IntegrationConnection } from "../../integration/connection.js" -import { ManagedPolicy } from "../../managed-policy.js" import { Provider } from "../../provider.js" import { WebSearch } from "../../websearch.js" @@ -143,9 +142,7 @@ function oauth(http: HttpClient.HttpClient) { } satisfies IntegrationOAuthMethodRegistration } -export const OpencodePlugin = define< - HttpClient.HttpClient | Bus.Service | Config.Service | ManagedPolicy.Service | Scope.Scope ->({ +export const OpencodePlugin = define({ id: "opencode.provider.opencode", effect: Effect.fn(function* (ctx) { const disableFree = ["1", "true"].includes(process.env.OPENCODE_DISABLE_FREE_MODELS?.toLowerCase() ?? "") @@ -154,7 +151,6 @@ export const OpencodePlugin = define< // Every request here goes to the Console, which reads the User-Agent to tell which OpenCode a member runs // and whether it evaluates the policies it is being sent. const http = HttpClient.mapRequest(client, HttpClientRequest.setHeader("User-Agent", App.useragent(ctx.app))) - const managed = yield* ManagedPolicy.Service const config = yield* Config.Service const scope = yield* Scope.Scope const loading = Semaphore.makeUnsafe(1) @@ -222,7 +218,6 @@ export const OpencodePlugin = define< }) // Policies and providers ride on the snapshot, so a credential switch, disconnect, or 404 replaces them too. const publish = Effect.fnUntraced(function* (next: typeof snapshot) { - yield* managed.set({ statements: [], organization: next.organization }) // The config merges as one integration document, which config loads right after managed policies. yield* integrate( next.remote && diff --git a/packages/core/src/plugin/supervisor.ts b/packages/core/src/plugin/supervisor.ts index 175d8b15f6b9..bba12a834143 100644 --- a/packages/core/src/plugin/supervisor.ts +++ b/packages/core/src/plugin/supervisor.ts @@ -5,7 +5,6 @@ import { Cause, Effect, Layer, Queue, Stream } from "effect" import path from "path" import { ConfigPluginSource } from "../config/plugin/source.js" import { makeLocationNode } from "@opencode/util/effect/app-node" -import { ManagedPolicy } from "../managed-policy.js" import { Bus } from "../bus.js" import { Npm } from "@opencode/util/npm" import { Plugin } from "../plugin.js" @@ -123,7 +122,6 @@ const resolve = Effect.fn("PluginSupervisor.resolve")(function* ( export const layer = Layer.effectDiscard( Effect.gen(function* () { - const managed = yield* ManagedPolicy.Service const registry = yield* Plugin.Service const sdk = yield* SdkPlugins.Service const instance = yield* InstancePlugins.Service @@ -218,7 +216,6 @@ export const layer = Layer.effectDiscard( ) yield* watch(sources.changes()) yield* watch(modules.changes()) - yield* watch(managed.changes()) yield* watch(Stream.fromEffectRepeat(Effect.sleep("24 hours"))) yield* watch(bus.subscribe([Event.Updated, SdkPlugins.Updated])) yield* watch( diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index f85e01aaf970..e28cce00d3a5 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -324,7 +324,7 @@ describe("Config", () => { expect(Config.latest(entries, "shell")).toBe("system") expect(Config.latest(entries, "username")).toBe("system-user") expect( - ManagedPolicy.decision(ManagedPolicy.statements(entries, { statements: [] }), "tool.use", "shell:ls"), + ManagedPolicy.decision(ManagedPolicy.statements(entries), "tool.use", "shell:ls"), ).toBe("deny") }).pipe( Effect.provide( @@ -543,7 +543,7 @@ describe("Config", () => { info: decodeInfo({ policies: [shell("shell:rm*", "allow"), shell("shell:*", "deny")] }), }), ) - const statements = ManagedPolicy.statements(yield* config.entries(), { statements: [] }) + const statements = ManagedPolicy.statements(yield* config.entries()) const deciding = (resource: string) => statements.findLast( (policy) => policy.action === "tool.use" && Wildcard.match(resource, policy.resource), diff --git a/packages/core/test/config/policy.test.ts b/packages/core/test/config/policy.test.ts index 9e312ae5277b..1340f8ed51d5 100644 --- a/packages/core/test/config/policy.test.ts +++ b/packages/core/test/config/policy.test.ts @@ -3,9 +3,9 @@ import { Document, Event, Info, type Entry } from "@opencode/schema/config" import { ConfigPolicy } from "@opencode/schema/config/policy" import { Permission } from "@opencode/schema/permission" import { Config } from "@opencode/core/config" +import { Integration } from "@opencode/core/integration" import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy" import { Bus } from "@opencode/core/bus" -import { ManagedPolicy } from "@opencode/core/managed-policy" import { Plugin } from "@opencode/core/plugin" import { PluginHooks } from "@opencode/core/plugin/hooks" import { PluginHost } from "@opencode/core/plugin/host" @@ -13,6 +13,7 @@ import { Provider } from "@opencode/core/provider" import { Session } from "@opencode/core/session" import { Effect, Schema } from "effect" import { testEffect } from "../lib/effect" +import { organizationPolicy } from "../fixture/policy" import { PluginTestLayer } from "../plugin/fixture" const it = testEffect(PluginTestLayer) @@ -62,12 +63,8 @@ describe("ConfigPolicyPlugin.Plugin", () => { it.effect("organization tool.use policy overrides authored policy", () => Effect.gen(function* () { - const managed = yield* ManagedPolicy.Service - yield* managed.set({ statements: [tool("deny", "shell:*")], organization: "Acme" }) - yield* addPlugin([document(tool("allow", "shell:*"))]) + yield* addPlugin([organizationPolicy([tool("deny", "shell:*")], "Acme"), document(tool("allow", "shell:*"))]) expect(yield* evaluate("shell", ["ls"])).toEqual({ effect: "deny", message: "Blocked by Acme's policy" }) - yield* managed.set({ statements: [tool("allow", "shell:*")] }) - expect((yield* evaluate("shell", ["ls"], "ask")).effect).toBe("ask") }), ) @@ -163,17 +160,18 @@ describe("ConfigPolicyPlugin.Plugin", () => { }), ) - it.effect("evaluates organization provider statements after every authored document", () => + it.effect("lets organization provider statements override authored documents", () => Effect.gen(function* () { const catalog = yield* Provider.Service - const managed = yield* ManagedPolicy.Service yield* catalog.transform((catalog) => { catalog.update(Provider.ID.openai, () => {}) catalog.update(Provider.ID.anthropic, () => {}) catalog.update(Provider.ID.opencode, () => {}) }) - yield* managed.set({ statements: [provider("deny", "*"), provider("allow", "opencode")] }) - yield* addPlugin([document(provider("allow", "anthropic"))]) + yield* addPlugin([ + organizationPolicy([provider("deny", "*"), provider("allow", "opencode")]), + document(provider("allow", "anthropic")), + ]) expect(yield* catalog.get(Provider.ID.anthropic)).toBeUndefined() expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined() @@ -184,48 +182,48 @@ describe("ConfigPolicyPlugin.Plugin", () => { it.effect("lets an organization allow restore a provider denied by the user", () => Effect.gen(function* () { const catalog = yield* Provider.Service - const managed = yield* ManagedPolicy.Service yield* catalog.transform((catalog) => catalog.update(Provider.ID.openai, () => {})) - yield* managed.set({ statements: [provider("allow", "openai")] }) - yield* addPlugin([document(provider("deny", "openai"))]) + yield* addPlugin([organizationPolicy([provider("allow", "openai")]), document(provider("deny", "openai"))]) expect(yield* catalog.get(Provider.ID.openai)).toBeDefined() }), ) - it.effect("names the organization when its permission statement decides", () => - Effect.gen(function* () { - const managed = yield* ManagedPolicy.Service - yield* managed.set({ statements: [tool("deny", "shell:sudo *")], organization: "Acme" }) - yield* addPlugin([document(tool("allow", "shell:*"))]) - - expect(yield* evaluate("shell", ["sudo ls"])).toEqual({ effect: "deny", message: "Blocked by Acme's policy" }) - expect((yield* evaluate("shell", ["ls"])).effect).toBe("allow") - - yield* managed.set({ statements: [tool("deny", "shell:sudo *")] }) - expect(yield* evaluate("shell", ["sudo ls"])).toEqual({ - effect: "deny", - message: "Blocked by your organization's policy", - }) - }), - ) - - it.effect("rebuilds the catalog from replaced organization statements", () => + for (const input of [ + { organization: "Acme", message: "Blocked by Acme's policy" }, + { organization: undefined, message: "Blocked by your organization's policy" }, + ]) + it.effect(`names the organization when its permission statement decides: ${input.organization}`, () => + Effect.gen(function* () { + yield* addPlugin([ + organizationPolicy([tool("deny", "shell:sudo *")], input.organization), + document(tool("allow", "shell:*")), + ]) + + expect(yield* evaluate("shell", ["sudo ls"])).toEqual({ effect: "deny", message: input.message }) + expect((yield* evaluate("shell", ["ls"])).effect).toBe("allow") + }), + ) + + it.live("rebuilds the catalog when the organization's policy changes", () => Effect.gen(function* () { const catalog = yield* Provider.Service - const managed = yield* ManagedPolicy.Service + const bus = yield* Bus.Service + const test = yield* Config.Test + const host = yield* PluginHost.make(yield* Plugin.Service) + const opencode = Integration.ID.make("opencode") yield* catalog.transform((catalog) => catalog.update(Provider.ID.openai, () => {})) - yield* addPlugin([]) + yield* ConfigPolicyPlugin.Plugin.effect(host) expect(yield* catalog.get(Provider.ID.openai)).toBeDefined() - yield* managed.set({ statements: [provider("deny", "openai")] }) - yield* catalog.reload() - expect(yield* catalog.get(Provider.ID.openai)).toBeUndefined() + yield* test.integration?.(opencode, organizationPolicy([provider("deny", "openai")])) ?? Effect.void + yield* bus.publish(Event.Updated, {}) + yield* waitUntil(catalog.get(Provider.ID.openai).pipe(Effect.map((provider) => provider === undefined))) - yield* managed.set({ statements: [] }) - yield* catalog.reload() - expect(yield* catalog.get(Provider.ID.openai)).toBeDefined() - }), + yield* test.integration?.(opencode, undefined) ?? Effect.void + yield* bus.publish(Event.Updated, {}) + yield* waitUntil(catalog.get(Provider.ID.openai).pipe(Effect.map((provider) => provider !== undefined))) + }).pipe(Effect.provide(Config.testLayer())), ) }) diff --git a/packages/core/test/fixture/policy.ts b/packages/core/test/fixture/policy.ts index 6ac6c259e2fb..e4737b3d3f3f 100644 --- a/packages/core/test/fixture/policy.ts +++ b/packages/core/test/fixture/policy.ts @@ -1,10 +1,32 @@ +import { Config } from "@opencode/core/config" +import { ConfigIntegration } from "@opencode/core/config/integration" import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy" +import { Integration } from "@opencode/core/integration" import { Mcp } from "@opencode/core/mcp/index" import { Skill } from "@opencode/core/skill" +import { Info } from "@opencode/schema/config" +import { ConfigPolicy } from "@opencode/schema/config/policy" import { ID } from "@opencode/schema/event" -import { Effect, Stream } from "effect" +import { Effect, Schema, Stream } from "effect" import { host } from "../plugin/host" +/** A connected organization's policy as the OpenCode Console delivers it: an integration config document. */ +export function organizationPolicy(policies: ConfigPolicy.Info[], organization?: string) { + return new ConfigIntegration.Document({ + type: "document", + integrationID: Integration.ID.make("opencode"), + connection: { type: "env", name: "OPENCODE_API_KEY" }, + organization, + info: Schema.decodeUnknownSync(Info)({ policies }), + }) +} + +/** Replaces the connected organization's policy, or removes it; publish `Event.Updated` to make plugins reload. */ +export const setOrganizationPolicy = Effect.fn(function* (policies: ConfigPolicy.Info[] | undefined) { + const config = yield* Config.Service + yield* config.integration?.(Integration.ID.make("opencode"), policies && organizationPolicy(policies)) ?? Effect.void +}) + // Exercise the real policy plugin against supplied catalogs without unrelated provider/permission setup. export const registerIntegrationPolicy = Effect.fn(function* (input: { mcp?: Mcp.Interface diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index 60ed092782d5..128606c1f170 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -14,7 +14,6 @@ import { Document, Event, Info } from "@opencode/schema/config" import { ConfigMCP } from "@opencode/schema/config/mcp" import { McpEvent } from "@opencode/schema/mcp-event" import { ConfigPolicy } from "@opencode/schema/config/policy" -import { ManagedPolicy } from "@opencode/core/managed-policy" import { Config } from "@opencode/core/config" import { ConfigMcpPlugin } from "@opencode/core/config/plugin/mcp" import { Credential } from "@opencode/core/credential" @@ -61,7 +60,7 @@ import { Image } from "@opencode/core/image" import { Global } from "@opencode/util/global" import { advance, drain } from "./lib/clock" import { testEffect } from "./lib/effect" -import { registerIntegrationPolicy } from "./fixture/policy" +import { registerIntegrationPolicy, setOrganizationPolicy } from "./fixture/policy" import { imagePassthrough } from "./lib/image" import { location } from "./fixture/location" import { tmpdirScoped } from "./fixture/tmpdir" @@ -297,7 +296,6 @@ function resourceMcpLayer( onFormCreated?: (form: Form.Info) => Effect.Effect, options?: Mcp.Options, overrides?: { - managed?: ManagedPolicy.Interface policies?: readonly ConfigPolicy.Info[] entries?: Config.Interface["entries"] subscribe?: Bus.Interface["subscribe"] @@ -323,7 +321,6 @@ function resourceMcpLayer( Layer.provideMerge(Form.layer), Layer.provide( Layer.mergeAll( - overrides?.managed ? Layer.succeed(ManagedPolicy.Service, overrides.managed) : ManagedPolicy.layer, overrides?.entries ? Layer.succeed( Config.Service, @@ -2061,7 +2058,6 @@ const shutdownIt = testEffect( AppNodeBuilder.build( LayerNode.group([ Config.node, - ManagedPolicy.node, Bus.node, Integration.node, Credential.node, @@ -2751,12 +2747,11 @@ for (const modern of [false, true]) { } for (const source of ["config", "organization"] as const) { - testEffect(Layer.mergeAll(Config.testLayer(), ManagedPolicy.layer)).live( + testEffect(Config.testLayer()).live( `revokes and restores a connected MCP through ${source} policy`, () => Effect.gen(function* () { const server = yield* resourceServer() - const managed = yield* ManagedPolicy.Service const config = yield* Config.Service const test = yield* Config.Test const updates = yield* PubSub.unbounded<{ readonly type: string }>() @@ -2777,8 +2772,8 @@ for (const source of ["config", "organization"] as const) { expect((yield* service.tools()).length).toBeGreaterThan(0) if (source === "config") yield* test.setEntries([document("deny")]) if (source === "organization") - yield* managed.set({ statements: [{ action: "integration.use", resource: "mcp:*", effect: "deny" }] }) - if (source === "config") yield* PubSub.publish(updates, { type: Event.Updated.type }) + yield* setOrganizationPolicy([{ action: "integration.use", resource: "mcp:*", effect: "deny" }]) + yield* PubSub.publish(updates, { type: Event.Updated.type }) yield* service.servers().pipe( Effect.filterOrFail( (servers) => servers.length === 0, @@ -2807,11 +2802,9 @@ for (const source of ["config", "organization"] as const) { expect(server.state.toolCalls).toEqual([]) expect(server.state.resourceReads).toEqual([]) yield* Effect.promise(server.restart) - if (source === "config") { - yield* test.setEntries([document("allow")]) - yield* PubSub.publish(updates, { type: Event.Updated.type }) - } - if (source === "organization") yield* managed.set({ statements: [] }) + if (source === "config") yield* test.setEntries([document("allow")]) + if (source === "organization") yield* setOrganizationPolicy(undefined) + yield* PubSub.publish(updates, { type: Event.Updated.type }) yield* service.servers().pipe( Effect.filterOrFail( (servers) => servers.some((server) => server.status.status === "connected"), @@ -2825,7 +2818,6 @@ for (const source of ["config", "organization"] as const) { Effect.provide( resourceMcpLayer(server.url, undefined, undefined, { entries: config.entries, - managed, subscribe: (() => Stream.fromPubSub(updates)) as Bus.Interface["subscribe"], }), ), diff --git a/packages/core/test/plugin/fixture.ts b/packages/core/test/plugin/fixture.ts index 80784347b6fc..1357876e4860 100644 --- a/packages/core/test/plugin/fixture.ts +++ b/packages/core/test/plugin/fixture.ts @@ -16,7 +16,6 @@ import { Generate } from "@opencode/core/generate" import { Integration } from "@opencode/core/integration" import { KV } from "@opencode/core/kv" import { Location } from "@opencode/core/location" -import { ManagedPolicy } from "@opencode/core/managed-policy" import { Mcp } from "@opencode/core/mcp/index" import { Model } from "@opencode/core/model" import { Npm } from "@opencode/util/npm" @@ -102,7 +101,6 @@ export const makePluginTestLayer = (permission = permissionLayer) => Command.node, Integration.node, KV.node, - ManagedPolicy.node, Mcp.node, Session.node, InstructionEntry.node, diff --git a/packages/core/test/plugin/module.test.ts b/packages/core/test/plugin/module.test.ts index a8cae30fcbb2..2c6c58547cb1 100644 --- a/packages/core/test/plugin/module.test.ts +++ b/packages/core/test/plugin/module.test.ts @@ -8,7 +8,6 @@ import { SessionMessage } from "@opencode/schema/session-message" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { Watcher } from "@opencode/core/filesystem/watcher" import { Plugin } from "@opencode/core/plugin" -import { ManagedPolicy } from "@opencode/core/managed-policy" import { PluginModule } from "@opencode/core/plugin/module" import { Rpc } from "@opencode/core/rpc" import { Tool } from "@opencode/core/tool" @@ -18,6 +17,7 @@ import { Npm } from "@opencode/util/npm" import { createForeignPackageFilter, ensurePluginRuntime } from "@opencode/plugin/runtime" import { createLoader, discoverPluginRuntimeSpecifiers, pluginRuntimeLoaderCode } from "@opencode/plugin/runtime-modules" import { tempGlobalLayer } from "../fixture/global" +import { setOrganizationPolicy } from "../fixture/policy" import { tmpdirScoped } from "../fixture/tmpdir" import { testEffect } from "../lib/effect" import { PluginTestLayer } from "./fixture" @@ -415,15 +415,12 @@ it.live("blocks a local plugin before module initialization and honors replaceme `, ), ) - const managed = yield* ManagedPolicy.Service const modules = yield* PluginModule.make() const operation = { type: "add" as const, target: directory.path, options: {} } - yield* managed.set({ - statements: [{ action: "integration.use", resource: `plugin:${directory.path}`, effect: "deny" }], - }) + yield* setOrganizationPolicy([{ action: "integration.use", resource: `plugin:${directory.path}`, effect: "deny" }]) expect(yield* modules.load(operation)).toEqual({ blocked: true }) expect(yield* Effect.promise(() => Bun.file(entered).exists())).toBe(false) - yield* managed.set({ statements: [] }) + yield* setOrganizationPolicy(undefined) expect(yield* modules.load(operation)).toMatchObject({ id: "policy-fixture" }) expect(yield* Effect.promise(() => Bun.file(entered).exists())).toBe(true) }), @@ -476,13 +473,10 @@ it.live("loads a GUI-only package under its package name without running it, and for (const target of ["policy-fixture@1.2.3", "@scope/policy-fixture@1.2.3"]) { it.live(`blocks versioned package ${target} before npm resolution or installation`, () => Effect.gen(function* () { - const managed = yield* ManagedPolicy.Service - const modules = yield* PluginModule.make() - yield* managed.set({ - statements: [ - { action: "integration.use", resource: `plugin:${target.slice(0, target.lastIndexOf("@"))}`, effect: "deny" }, - ], - }) + const modules = yield* PluginModule.make() + yield* setOrganizationPolicy([ + { action: "integration.use", resource: `plugin:${target.slice(0, target.lastIndexOf("@"))}`, effect: "deny" }, + ]) const operation = { type: "add" as const, target, options: {} } expect(yield* modules.load(operation, { install: false })).toEqual({ blocked: true }) expect(yield* modules.load(operation, { install: true })).toEqual({ blocked: true }) diff --git a/packages/core/test/plugin/supervisor-reload.test.ts b/packages/core/test/plugin/supervisor-reload.test.ts index e2ed79e23f95..36b6a0544bd5 100644 --- a/packages/core/test/plugin/supervisor-reload.test.ts +++ b/packages/core/test/plugin/supervisor-reload.test.ts @@ -7,13 +7,14 @@ import { define } from "@opencode/plugin/effect/plugin" import { Event } from "@opencode/schema/config" import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { LayerNode } from "@opencode/util/effect/layer-node" +import { Config } from "@opencode/core/config" +import { Integration } from "@opencode/core/integration" import { Global } from "@opencode/util/global" import { Npm } from "@opencode/util/npm" import { Bus } from "@opencode/core/bus" import { Command } from "@opencode/core/command" import { Database } from "@opencode/core/database/database" import { Watcher } from "@opencode/core/filesystem/watcher" -import { ManagedPolicy } from "@opencode/core/managed-policy" import { Instance } from "@opencode/core/instance" import { LocationServiceMap } from "@opencode/core/location-services" import { Location } from "@opencode/core/location" @@ -22,6 +23,7 @@ import { Rpc } from "@opencode/core/rpc" import { SdkPlugins } from "@opencode/core/plugin/sdk" import { AbsolutePath } from "@opencode/core/schema" import { tempGlobalLayer } from "../fixture/global" +import { organizationPolicy } from "../fixture/policy" import { offlineModels } from "../fixture/models" import { tmpdirScoped } from "../fixture/tmpdir" import { advance } from "../lib/clock" @@ -60,13 +62,11 @@ const instances = Layer.effect( LocationServiceMap.Service, Effect.gen(function* () { const watcher = yield* Watcher.Test - const managed = yield* ManagedPolicy.Service const map = yield* LayerMap.make((ref: Location.Ref) => Instance.layer(ref, { replacements: bindings }), { idleTimeToLive: Duration.infinity, }) const bindings: LayerNode.Replacements = [ Global.node.replace(tempGlobalLayer), - ManagedPolicy.node.replace(Layer.succeed(ManagedPolicy.Service, managed)), offlineModels, Npm.node.replace(npmLayer), Watcher.node.replace(Layer.succeed(Watcher.Service, watcher)), @@ -79,11 +79,11 @@ const instances = Layer.effect( ] return map }), -).pipe(Layer.provide(Watcher.testLayer), Layer.provide(ManagedPolicy.layer)) +).pipe(Layer.provide(Watcher.testLayer)) const it = testEffect( AppNodeBuilder.build( - LayerNode.group([ManagedPolicy.node, Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), + LayerNode.group([Database.node, Bus.node, SdkPlugins.node, LocationServiceMap.node]), [Global.node.replace(tempGlobalLayer), offlineModels, LocationServiceMap.node.replace(instances)], ).pipe(Layer.provideMerge(Watcher.testLayer)), ) @@ -363,7 +363,7 @@ it.live("applies organization integration changes to running external plugins", yield* Effect.gen(function* () { const plugins = yield* Plugin.Service const commands = yield* Command.Service - const managed = yield* ManagedPolicy.Service + const config = yield* Config.Service const bus = yield* Bus.Service yield* plugins.awaitActivation expect(yield* commands.get("policy-greet")).toBeUndefined() @@ -375,14 +375,17 @@ it.live("applies organization integration changes to running external plugins", Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }), ) expect(yield* Effect.promise(() => Bun.file(entered).exists())).toBe(true) - yield* managed.set({ statements: [{ action: "integration.use", resource: `plugin:${root}`, effect: "deny" }] }) + yield* config.integration?.( + Integration.ID.make("opencode"), + organizationPolicy([{ action: "integration.use", resource: `plugin:${root}`, effect: "deny" }]), + ) ?? Effect.void yield* commands.get("policy-greet").pipe( Effect.flatMap((command) => (command ? Effect.fail("pending") : Effect.void)), Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }), ) yield* plugins.awaitActivation expect((yield* plugins.list()).some((plugin) => plugin.id === "greeter")).toBe(false) - yield* managed.set({ statements: [] }) + yield* config.integration?.(Integration.ID.make("opencode"), undefined) ?? Effect.void yield* commands.get("policy-greet").pipe( Effect.flatMap((command) => (command ? Effect.void : Effect.fail("pending"))), Effect.retry({ times: 200, schedule: Schedule.spaced("10 millis") }), diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 2d519a48d909..30e763d1f215 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -8,7 +8,6 @@ import { LayerNode } from "@opencode/util/effect/layer-node" import { makeGlobalNode } from "@opencode/util/effect/app-node" import { Bus } from "@opencode/core/bus" import { Config } from "@opencode/core/config" -import { ManagedPolicy } from "@opencode/core/managed-policy" import { Image } from "@opencode/core/image" import { Location } from "@opencode/core/location" import { LocationServiceMap } from "@opencode/core/location-service-map" @@ -28,9 +27,11 @@ import { Skill } from "@opencode/core/skill" import { Event } from "@opencode/schema/event" import { testEffect } from "./lib/effect" import { globalProjectNode } from "./lib/project" -import { registerIntegrationPolicy } from "./fixture/policy" +import { organizationPolicy, registerIntegrationPolicy } from "./fixture/policy" const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) +// The organization behind this location's Console connection denies the skill. +const blocked = Location.Ref.make({ directory: AbsolutePath.make("/blocked") }) const info = Skill.Info.make({ id: Skill.ID.make("effect"), name: Skill.Name.make("Effect"), @@ -42,39 +43,45 @@ const locations = makeGlobalNode({ service: LocationServiceMap.Service, layer: Layer.effect( LocationServiceMap.Service, - Effect.gen(function* () { - const managed = yield* ManagedPolicy.Service - return yield* LayerMap.make( - (_ref: Location.Ref) => - // These tests need skill activation and prompt preparation from the same location services. - // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion - Layer.mergeAll( - Layer.effectDiscard( - Effect.gen(function* () { - const skills = yield* Skill.Service - yield* skills.transform((editor) => editor.add(info)) - yield* registerIntegrationPolicy({ skill: skills }) - }), - ).pipe( - Layer.provideMerge( - LayerNode.compile( - LayerNode.group([PluginHooks.node, Image.node, Skill.node, Config.node, ManagedPolicy.node]), - { - replacements: [ - Config.node.replace(Config.testLayer()), - ManagedPolicy.node.replace(Layer.succeed(ManagedPolicy.Service, managed)), - ], - }, - ), + LayerMap.make( + (ref: Location.Ref) => + // These tests need skill activation and prompt preparation from the same location services. + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion + Layer.mergeAll( + Layer.effectDiscard( + Effect.gen(function* () { + const skills = yield* Skill.Service + yield* skills.transform((editor) => editor.add(info)) + yield* registerIntegrationPolicy({ skill: skills }) + }), + ).pipe( + Layer.provideMerge( + LayerNode.compile( + LayerNode.group([PluginHooks.node, Image.node, Skill.node, Config.node]), + { + replacements: [ + Config.node.replace( + Config.testLayer( + ref.directory === blocked.directory + ? [ + organizationPolicy([ + { action: "integration.use", resource: "skill:effect", effect: "deny" }, + ]), + ] + : [], + ), + ), + ], + }, ), ), - Layer.mock(Plugin.Service, { awaitActivation: Effect.void }), - ) as unknown as Layer.Layer, - { idleTimeToLive: Duration.infinity }, - ) - }), + ), + Layer.mock(Plugin.Service, { awaitActivation: Effect.void }), + ) as unknown as Layer.Layer, + { idleTimeToLive: Duration.infinity }, + ), ), - deps: [ManagedPolicy.node], + deps: [], }) const it = testEffect( AppNodeBuilder.build( @@ -84,7 +91,6 @@ const it = testEffect( SessionProjector.node, SessionStore.node, Session.node, - ManagedPolicy.node, LocationServiceMap.node, ]), [ @@ -99,9 +105,7 @@ describe("Session.skill", () => { it.effect("refuses blocked skill mentions and standalone activation without writing their content", () => Effect.gen(function* () { const sessions = yield* Session.Service - const managed = yield* ManagedPolicy.Service - const session = yield* sessions.create({ location }) - yield* managed.set({ statements: [{ action: "integration.use", resource: "skill:effect", effect: "deny" }] }) + const session = yield* sessions.create({ location: blocked }) expect( yield* sessions.skill({ sessionID: session.id, skill: info.id, resume: false }).pipe(Effect.flip), ).toMatchObject({ _tag: "Session.SkillNotFoundError", skill: info.id }) diff --git a/packages/core/test/skill.test.ts b/packages/core/test/skill.test.ts index 175bf9fc5c60..2ea250f3badd 100644 --- a/packages/core/test/skill.test.ts +++ b/packages/core/test/skill.test.ts @@ -5,19 +5,18 @@ import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder" import { LayerNode } from "@opencode/util/effect/layer-node" import { Bus } from "@opencode/core/bus" import { Config } from "@opencode/core/config" -import { ManagedPolicy } from "@opencode/core/managed-policy" import { Document, Event } from "@opencode/schema/config" import { AbsolutePath } from "@opencode/core/schema" import { Skill } from "@opencode/core/skill" import { SkillInstructions } from "@opencode/core/skill/instructions" import { testEffect } from "./lib/effect" import { readInitial } from "./lib/instructions" -import { registerIntegrationPolicy } from "./fixture/policy" +import { registerIntegrationPolicy, setOrganizationPolicy } from "./fixture/policy" const configLayer = Config.testLayer() const it = testEffect( AppNodeBuilder.build( - LayerNode.group([Skill.node, SkillInstructions.node, Agent.node, Bus.node, ManagedPolicy.node]), + LayerNode.group([Skill.node, SkillInstructions.node, Agent.node, Bus.node]), [Config.node.replace(configLayer)], ).pipe(Layer.provideMerge(configLayer)), ) @@ -35,8 +34,8 @@ describe("Skill", () => { it.live("hides and refuses denied skills while organization policy overrides local allows", () => Effect.gen(function* () { const skills = yield* Skill.Service - const managed = yield* ManagedPolicy.Service const config = yield* Config.Test + const bus = yield* Bus.Service yield* skills.transform((editor) => { editor.add(info("team:review", "Review")) editor.add(info("deploy", "Deploy")) @@ -49,13 +48,11 @@ describe("Skill", () => { }, }), ]) - yield* managed.set({ - statements: [ - { action: "integration.use", resource: "*", effect: "deny" }, - { action: "integration.use", resource: "skill:team:review", effect: "allow" }, - ], - }) - yield* registerIntegrationPolicy({ skill: skills }) + yield* setOrganizationPolicy([ + { action: "integration.use", resource: "*", effect: "deny" }, + { action: "integration.use", resource: "skill:team:review", effect: "allow" }, + ]) + yield* registerIntegrationPolicy({ skill: skills, events: bus.subscribe() }) expect(yield* skills.list()).toEqual([info("team:review", "Review")]) expect(yield* skills.get(Skill.ID.make("deploy"))).toBeUndefined() expect(yield* skills.get(Skill.ID.make("team:review"))).toEqual(info("team:review", "Review")) @@ -64,11 +61,13 @@ describe("Skill", () => { expect(guidance.text).toContain("team:review") expect(guidance.text).not.toContain("deploy") - yield* managed.set({ statements: [{ action: "integration.use", resource: "skill:team:review", effect: "deny" }] }) + yield* setOrganizationPolicy([{ action: "integration.use", resource: "skill:team:review", effect: "deny" }]) + yield* bus.publish(Event.Updated, {}) yield* waitUntil(skills.get(Skill.ID.make("team:review")).pipe(Effect.map((skill) => skill === undefined))) expect(yield* skills.get(Skill.ID.make("team:review"))).toBeUndefined() expect(yield* skills.list()).toEqual([info("deploy", "Deploy")]) - yield* managed.set({ statements: [] }) + yield* setOrganizationPolicy(undefined) + yield* bus.publish(Event.Updated, {}) yield* waitUntil(skills.list().pipe(Effect.map((skills) => skills.length === 2))) expect(yield* skills.list()).toHaveLength(2) }), @@ -102,19 +101,19 @@ describe("Skill", () => { }), ) - it.live("publishes catalog updates when managed policy changes", () => + it.live("publishes catalog updates when organization policy changes", () => Effect.gen(function* () { const skills = yield* Skill.Service - const managed = yield* ManagedPolicy.Service const bus = yield* Bus.Service yield* skills.transform((editor) => editor.add(info("review", "Review"))) - yield* registerIntegrationPolicy({ skill: skills }) + yield* registerIntegrationPolicy({ skill: skills, events: bus.subscribe() }) const updated = yield* Deferred.make() yield* bus.subscribe(Skill.Event.Updated).pipe( Stream.runForEach(() => skills.list().pipe(Effect.flatMap((values) => Deferred.succeed(updated, values)))), Effect.forkScoped({ startImmediately: true }), ) - yield* managed.set({ statements: [{ action: "integration.use", resource: "skill:review", effect: "deny" }] }) + yield* setOrganizationPolicy([{ action: "integration.use", resource: "skill:review", effect: "deny" }]) + yield* bus.publish(Event.Updated, {}) expect(yield* Deferred.await(updated).pipe(Effect.timeout("1 second"))).toEqual([]) }), ) diff --git a/packages/core/test/tool-skill.test.ts b/packages/core/test/tool-skill.test.ts index 048042b5110a..003cdb38855d 100644 --- a/packages/core/test/tool-skill.test.ts +++ b/packages/core/test/tool-skill.test.ts @@ -9,7 +9,6 @@ import { AbsolutePath } from "@opencode/core/schema" import { Session } from "@opencode/core/session" import { Skill } from "@opencode/core/skill" import { Config } from "@opencode/core/config" -import { ManagedPolicy } from "@opencode/core/managed-policy" import { SkillTool } from "@opencode/core/tool/plugin/skill" import { Tool } from "@opencode/core/tool" import { tmpdir } from "./fixture/tmpdir" @@ -20,7 +19,7 @@ import { permissionLayer } from "./lib/permission" import { makeLocationNode } from "@opencode/util/effect/app-node" import { FSUtil } from "@opencode/util/fs-util" import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool" -import { registerIntegrationPolicy } from "./fixture/policy" +import { registerIntegrationPolicy, setOrganizationPolicy } from "./fixture/policy" const skillToolNode = makeLocationNode({ name: "test/skill-tool-plugin", @@ -34,7 +33,6 @@ describe("SkillTool", () => { it.effect("refuses a policy-blocked skill before authorization or content preparation", () => Effect.gen(function* () { const skills = yield* Skill.Service - const managed = yield* ManagedPolicy.Service const tools = yield* Tool.Service // This path does not exist; an attempted content scan would fail the test. yield* skills.transform((editor) => @@ -47,7 +45,7 @@ describe("SkillTool", () => { }), ), ) - yield* managed.set({ statements: [{ action: "integration.use", resource: "skill:private", effect: "deny" }] }) + yield* setOrganizationPolicy([{ action: "integration.use", resource: "skill:private", effect: "deny" }]) yield* registerIntegrationPolicy({ skill: skills }) expect( yield* executeTool(tools, { @@ -58,7 +56,7 @@ describe("SkillTool", () => { ).toEqual({ status: "error", error: { type: "tool.execution", message: "Unable to load skill private" } }) }).pipe( Effect.provide( - AppNodeBuilder.build(LayerNode.group([Tool.node, skillToolNode, Skill.node, Config.node, ManagedPolicy.node]), [ + AppNodeBuilder.build(LayerNode.group([Tool.node, skillToolNode, Skill.node, Config.node]), [ Config.node.replace(Config.testLayer()), Permission.node.replace(permissionLayer({ assert: () => Effect.die("Blocked skill reached authorization") })), Image.node.replace(imagePassthrough), From 112427aeb9f97b6d7587cd98cc0cec97f1398b83 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:04:01 +0100 Subject: [PATCH 006/138] fix(app): make dropdowns open instantly (#59268) --- .../app/e2e/regression/new-session.spec.ts | 118 +++++++++++++++- .../src/new-session/workspace/controller.ts | 6 +- .../src/new-session/workspace/selector.tsx | 133 +++++++++++++----- packages/ui/src/components/popover.css | 32 ----- packages/ui/src/forms/typeahead/typeahead.tsx | 68 +++++---- packages/ui/src/navigation/menu/menu.css | 30 ---- 6 files changed, 257 insertions(+), 130 deletions(-) diff --git a/packages/app/e2e/regression/new-session.spec.ts b/packages/app/e2e/regression/new-session.spec.ts index 24a93a7d7760..03bb8f613eb2 100644 --- a/packages/app/e2e/regression/new-session.spec.ts +++ b/packages/app/e2e/regression/new-session.spec.ts @@ -76,13 +76,14 @@ test("the combined location picker shows checkout branches and chooses the base const screen = page.locator('[data-component="new-session"]') const base = screen.getByRole("button", { name: "from timeline-quotes", exact: true }) await expect(base).toBeVisible() - expect(searches).toEqual([]) + await expect.poll(() => searches).toEqual([{ directory: view.directory, search: "", limit: 50 }]) await base.click() const search = page.getByRole("combobox", { name: "Search branches", exact: true }) - await expect(search).toBeFocused() - await expect(search).toHaveAttribute("aria-busy", "true") + await expect(search).toHaveCount(0) await expect(newSessionLocation(page)).toBeVisible() listed.resolve(["timeline-quotes", "release/base", "origin/dev"]) + await expect(search).toBeFocused() + await expect(search).toHaveAttribute("aria-busy", "false") await expect(page.getByRole("option", { name: "release/base", exact: true })).toBeVisible() await search.fill("release") await expect(search).toHaveAttribute("aria-busy", "false") @@ -96,6 +97,15 @@ test("the combined location picker shows checkout branches and chooses the base { directory: view.directory, search: "release", limit: 50 }, ]) + await selected.click() + await expect(search).toBeFocused() + await expect(search).toHaveValue("") + await expect(page.getByRole("option")).toHaveCount(3) + // Reopening revalidates the branch list. + expect(searches).toHaveLength(3) + await page.getByRole("option", { name: "release/base", exact: true }).click() + await expect(view.editor).toBeFocused() + await page.setViewportSize({ width: 390, height: 844 }) await expect .poll(async () => { @@ -136,6 +146,55 @@ test("the combined location picker shows checkout branches and chooses the base .toEqual([expect.objectContaining({ body: expect.objectContaining({ text: "Use the selected base" }) })]) }) +test("a pending branch picker can be dismissed before its results arrive", async ({ page }) => { + const listed = Promise.withResolvers() + + const view = await openWorktreeDraft(page, { + name: "BranchDismiss", + vcs: () => ({ current: "dev", default: "dev" }), + branches: () => listed.promise, + }) + + const base = page.getByRole("button", { name: "from dev", exact: true }) + const search = page.getByRole("combobox", { name: "Search branches", exact: true }) + await base.click() + await expect(base).toHaveAttribute("aria-expanded", "true") + await expect(search).toHaveCount(0) + await view.editor.click() + await expect(base).toHaveAttribute("aria-expanded", "false") + await base.click() + await base.press("Escape") + await expect(base).toHaveAttribute("aria-expanded", "false") + await base.click() + await base.click() + await expect(base).toHaveAttribute("aria-expanded", "false") + listed.resolve(["dev", "origin/dev"]) + await expect(base).toHaveAttribute("aria-busy", "false") + await expect(search).toHaveCount(0) + await base.click() + await expect(search).toBeFocused() + await expect(page.getByRole("option")).toHaveText(["dev", "origin/dev"]) +}) + +test("reopening the branch picker shows branches created since it last opened", async ({ page }) => { + let created = false + + await openWorktreeDraft(page, { + name: "BranchRefresh", + vcs: () => ({ current: "dev", default: "dev" }), + branches: () => (created ? ["dev", "feature/new"] : ["dev"]), + }) + + const base = page.getByRole("button", { name: "from dev", exact: true }) + await base.click() + await expect(page.getByRole("option")).toHaveText(["dev"]) + created = true + await base.click() + await expect(page.getByRole("combobox", { name: "Search branches", exact: true })).toHaveCount(0) + await base.click() + await expect(page.getByRole("option")).toHaveText(["dev", "feature/new"]) +}) + test("branch search keeps results on screen while a query is pending", async ({ page }) => { const held = Promise.withResolvers() @@ -174,11 +233,64 @@ test("branch search keeps results on screen while a query is pending", async ({ await search.pressSequentially("-missing") await expect(options).toHaveText(["timeline-quotes"]) await expect(page.getByRole("listbox")).not.toHaveAttribute("data-emptied") + held.resolve([]) await expect(page.getByText("No results found", { exact: true })).toBeVisible() await expect(search).toHaveAttribute("aria-busy", "false") }) +test("the empty branch search state is as tall as a single result", async ({ page }) => { + await openWorktreeDraft(page, { + name: "BranchEmptyHeight", + vcs: () => ({ current: "dev", default: "dev" }), + branches: () => ["dev"], + }) + + await page.getByRole("button", { name: "from dev", exact: true }).click() + await expect(page.getByRole("option")).toHaveCount(1) + const panel = page.getByRole("dialog") + // Measured on open, before any search animates the panel. + const single = await panel.evaluate((element) => element.getBoundingClientRect().height) + + await page.getByRole("combobox", { name: "Search branches", exact: true }).fill("missing") + await expect(page.getByText("No results found", { exact: true })).toBeVisible() + await expect.poll(() => panel.evaluate((element) => element.getBoundingClientRect().height)).toBe(single) +}) + +test("backspacing keeps confirmed branch results until the broader search answers", async ({ page }) => { + const broader = Promise.withResolvers() + const closed = ["closed-search-beta", "closed-session-search", "reopen-closed-tab", "recently-closed-projects"] + + await openWorktreeDraft(page, { + name: "BranchBackspace", + vcs: () => ({ current: "dev", default: "dev" }), + branches: (input) => { + if (input.search === "clo") return broader.promise + + if (input.search) return closed + + return [...closed, "origin/cloudflare-run-images", "origin/cloudflare-run-functions"] + }, + }) + + await page.getByRole("button", { name: "from dev", exact: true }).click() + const search = page.getByRole("combobox", { name: "Search branches", exact: true }) + const options = page.getByRole("option") + await expect(options).toHaveCount(6) + await search.fill("closed") + await expect(search).toHaveAttribute("aria-busy", "false") + await expect(options).toHaveText(closed) + await search.press("Backspace") + await search.press("Backspace") + await search.press("Backspace") + await expect(search).toHaveValue("clo") + await expect(search).toHaveAttribute("aria-busy", "true") + await expect(options).toHaveText(closed) + broader.resolve([...closed, "clover-fix"]) + await expect(search).toHaveAttribute("aria-busy", "false") + await expect(options).toHaveText([...closed, "clover-fix"]) +}) + test("the dark new session panel exposes no lighter background at its rounded corners", async ({ page }) => { await page.setViewportSize({ width: 935, height: 522 }) await openDraft(page, { name: "PanelCorner", seed: { theme: { id: "oc-2", scheme: "dark" } } }) diff --git a/packages/app/src/new-session/workspace/controller.ts b/packages/app/src/new-session/workspace/controller.ts index 7a572289cf33..aec078977232 100644 --- a/packages/app/src/new-session/workspace/controller.ts +++ b/packages/app/src/new-session/workspace/controller.ts @@ -60,9 +60,7 @@ export function createNewSessionWorkspaceController(input: { const settings = useSettings() const tabs = useTabs() - const [state, setState] = createStore({ - existing: undefined as { projectID: string; directory: string } | undefined, - }) + const [state, setState] = createStore<{ existing?: { projectID: string; directory: string } }>({}) const currentProject = createMemo(() => { const projectID = data.location.info({ directory: sdk().directory })?.project.id @@ -267,7 +265,7 @@ export function createNewSessionWorkspaceController(input: { visible, branch, }, - branches: (search: string, signal: AbortSignal) => + branches: (search: string, signal?: AbortSignal) => serverSDK.api.vcs.branch .list({ location: { directory: projectRoot() }, search, limit: 50 }, { signal }) .then((response) => response.data), diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx index babce57d280d..4027fc73550b 100644 --- a/packages/app/src/new-session/workspace/selector.tsx +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -1,6 +1,7 @@ -import { Show } from "solid-js" +import { createEffect, createResource, Show } from "solid-js" import { createStore } from "solid-js/store" import { Popover } from "@kobalte/core/popover" +import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Icon } from "@opencode/ui/icon" import { Tooltip } from "@opencode/ui/tooltip" @@ -14,8 +15,50 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll const [state, setState] = createStore({ open: false, truncated: false }) let outside = false + let trigger: HTMLButtonElement | undefined const creating = () => props.workspace.selection.value() === "create" + // Fetch before opening and retain the result across closes, rather than showing a smaller loading panel. + const [branches, branchActions] = createResource( + () => (creating() ? props.workspace.project.root() : undefined), + async (directory) => ({ + directory, + ...(await props.workspace.branches("").then( + (items) => ({ items, failed: false }), + () => ({ items: [], failed: true }), + )), + }), + ) + + const initial = () => { + if (branches.state !== "ready" && branches.state !== "refreshing") return + const result = branches.latest + + return result?.directory === props.workspace.project.root() ? result : undefined + } + + // Until content mounts, Kobalte has no dismissable layer to cancel a pending open. + createEffect(() => { + if (!state.open || initial()) return + + const close = (event: Event) => { + if (event.target instanceof Node && trigger?.contains(event.target)) return + setState("open", false) + } + + makeEventListener(document, "pointerdown", close, { capture: true }) + makeEventListener(document, "focusin", close) + makeEventListener(window, "blur", close) + makeEventListener( + window, + "keydown", + (event) => { + if (event.key === "Escape") setState("open", false) + }, + { capture: true }, + ) + }) + const label = () => creating() ? language.t("session.new.workspace.fromBranch", { branch: props.workspace.bar.branch() ?? "" }) @@ -46,11 +89,20 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll > setState("open", open)} + onOpenChange={(open) => { + setState("open", open) + + // Revalidate on every open, as before the preload; the loaded list stays on screen meanwhile. + if (open && initial()) branchActions.refetch() + }} placement="bottom-start" gutter={4} > - + {label()} @@ -58,37 +110,50 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll - (outside = true)} - onCloseAutoFocus={(event) => { - // An outside click keeps focus where the user put it. - if (event.defaultPrevented || outside) return (outside = false) - event.preventDefault() - props.onDone() - }} - > - branch.toLowerCase().includes(query.toLowerCase())} - delay={100} - key={(branch) => branch} - selected={props.workspace.bar.branch()} - onSelect={(branch) => { - props.workspace.selection.branch(branch) - setState("open", false) - }} - > - {(branch) => ( - - {branch} - - )} - - + + {(initial) => ( + (outside = true)} + onCloseAutoFocus={(event) => { + // An outside click keeps focus where the user put it. + if (event.defaultPrevented || outside) return (outside = false) + event.preventDefault() + props.onDone() + }} + > + { + const directory = props.workspace.project.root() + const items = await props.workspace.branches(query, signal) + + if (!query && directory === props.workspace.project.root()) + branchActions.mutate({ directory, items, failed: false }) + + return items + }} + match={(branch, query) => branch.toLowerCase().includes(query.toLowerCase())} + delay={100} + key={(branch) => branch} + selected={props.workspace.bar.branch()} + onSelect={(branch) => { + props.workspace.selection.branch(branch) + setState("open", false) + }} + > + {(branch) => ( + + {branch} + + )} + + + )} + diff --git a/packages/ui/src/components/popover.css b/packages/ui/src/components/popover.css index 4d50c4cb620b..8eb42c1a4a10 100644 --- a/packages/ui/src/components/popover.css +++ b/packages/ui/src/components/popover.css @@ -13,20 +13,10 @@ background-clip: padding-box; box-shadow: var(--shadow-md); - transform-origin: var(--kb-popover-content-transform-origin); - &:focus-within { outline: none; } - &[data-closed] { - animation: popover-close 0.15s ease-out; - } - - &[data-expanded] { - animation: popover-open 0.15s ease-out; - } - [data-slot="popover-header"] { display: flex; padding: 12px; @@ -74,25 +64,3 @@ fill: var(--surface-raised-stronger-non-alpha); } } - -@keyframes popover-open { - from { - opacity: 0; - transform: scale(0.96); - } - to { - opacity: 1; - transform: scale(1); - } -} - -@keyframes popover-close { - from { - opacity: 1; - transform: scale(1); - } - to { - opacity: 0; - transform: scale(0.96); - } -} diff --git a/packages/ui/src/forms/typeahead/typeahead.tsx b/packages/ui/src/forms/typeahead/typeahead.tsx index a45756db94df..c4351085c1d5 100644 --- a/packages/ui/src/forms/typeahead/typeahead.tsx +++ b/packages/ui/src/forms/typeahead/typeahead.tsx @@ -1,5 +1,5 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" -import { createEffect, createMemo, For, type JSX, onCleanup, Show } from "solid-js" +import { createEffect, createMemo, For, type JSX, on, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Icon } from "@opencode/ui/icon" import { abort } from "@opencode/util/abort" @@ -12,6 +12,8 @@ export { createTypeahead } from "./controller" /** A search field over results from `source` that keeps rows on screen while a query is pending. */ export function Typeahead(props: { source: (query: string, signal: AbortSignal) => Promise + /** Preloaded opening results, including a failed request that the user can retry. */ + initial?: { items: T[]; failed?: boolean } /** Narrows earlier results while the request for the current query is pending. */ match: (item: T, query: string) => boolean key: (item: T) => string @@ -27,17 +29,23 @@ export function Typeahead(props: { }) { const i18n = useI18n() - const [store, setStore] = createStore<{ query: string; answered?: string; failed?: boolean; loading?: boolean }>({ - query: "", - }) + const [store, setStore] = createStore<{ + query: string + searched: boolean + answered?: string + failed?: boolean + loading?: boolean + }>({ query: "", searched: false, answered: props.initial ? "" : undefined }) const cache = new Map() - let seen: T[] = [] let request: AbortController | undefined let input: HTMLInputElement | undefined onCleanup(() => abort(request)) const query = () => store.query.trim() + // The parent owns the opening list, so a refresh reaches an open panel without remounting the input. + const opening = () => (query() ? undefined : props.initial) + const failed = () => (opening() ? opening()?.failed : store.failed) const pending = () => store.loading || store.answered !== query() const search = (query: string) => { @@ -49,9 +57,7 @@ export function Typeahead(props: { props.source(query, current.signal).then( (items) => { if (current.signal.aborted) return - const keys = new Set(items.map(props.key)) cache.set(query, items) - seen = [...items, ...seen.filter((item) => !keys.has(props.key(item)))].slice(0, 500) setStore({ answered: query, failed: false, loading: false }) }, () => !current.signal.aborted && setStore({ answered: query, failed: true, loading: false }), @@ -59,27 +65,36 @@ export function Typeahead(props: { } // A cached or empty query answers at once; a typed one waits, and the request it supersedes is aborted. - createEffect(() => { - const next = query() - - if (!next || cache.has(next)) return search(next) - abort(request) - const timer = setTimeout(() => search(next), props.delay) - onCleanup(() => clearTimeout(timer)) - }) + createEffect( + on( + query, + (next) => { + if (!next || cache.has(next)) return search(next) + abort(request) + const timer = setTimeout(() => search(next), props.delay) + onCleanup(() => clearTimeout(timer)) + }, + { defer: !!props.initial }, + ), + ) - // While a query is pending: its cached answer, else earlier rows that match it, else the rows on screen, marked stale. + // Narrow visible rows while pending; broadening waits for a cached or server answer, avoiding speculative rows flashing. const results = createMemo<{ items: T[]; stale: boolean }>((previous) => { const next = query() + const preloaded = opening() + + if (preloaded) return { items: preloaded.failed ? [] : preloaded.items, stale: false } if (store.answered === next && store.failed) return { items: [], stale: false } - const cached = cache.get(next) ?? seen.filter((item) => props.match(item, next)) + const cached = cache.get(next) ?? previous?.items.filter((item) => props.match(item, next)) ?? [] if (cache.has(next) || cached.length > 0) return { items: cached, stale: false } return { items: previous?.items ?? [], stale: !!previous?.items.length } }) + const animateHeight = () => store.searched && results().items.length > 0 + const typeahead = createTypeahead({ items: () => results().items, key: (item) => props.key(item), @@ -119,7 +134,7 @@ export function Typeahead(props: { autocomplete="off" spellcheck={false} class="h-8 min-w-0 flex-1 outline-none placeholder:text-v2-text-text-faint" - onInput={(event) => setStore("query", event.currentTarget.value)} + onInput={(event) => setStore({ query: event.currentTarget.value, searched: true })} onKeyDown={typeahead.onKeyDown} /> @@ -137,8 +152,11 @@ export function Typeahead(props: {
- {/* Height follows the results on a critically damped spring (0.3 s, bounce 0); the first measurement snaps. */} -
+ {/* Initial results and the empty state snap into place; other search resizing animates. */} +
createResizeObserver(element, (rect) => @@ -146,11 +164,7 @@ export function Typeahead(props: { ) } > -
+
{(item) => (
(props: { {/* Stays mounted while a retry or a newer query is pending, so the list never collapses to nothing. */}
- + - ) - return ( - + + ) @@ -529,27 +598,11 @@ function DrillButton(props: { label: string; onDrill: () => void }) { props.onDrill() }} > - + ) } -// The negative start margin cancels the chip's padding, so its icon sits where the search icon would and lines up with -// the row icons, leaving the chip the same 6px inset from the field on every side. -function BackChip(props: { label: string; onBack: () => void; children: JSX.Element }) { - return ( - - ) -} - /** The server a project lives on; projects on the current server have none. */ function ServerTag(props: { server?: PromptServer }) { return ( @@ -576,11 +629,7 @@ function WorkspaceRow(props: { title: string; value: string; description: string return ( <>
- +
{props.title} diff --git a/packages/app/src/new-session/screen.tsx b/packages/app/src/new-session/screen.tsx index 2ddd5a342b20..1e5a170886bd 100644 --- a/packages/app/src/new-session/screen.tsx +++ b/packages/app/src/new-session/screen.tsx @@ -53,7 +53,7 @@ export default function NewSessionPage(props: { draftId: string }) { restoreFocus: model.restoreFocus, project: { empty: project.empty, - open: () => location({ projectsOnly: true }), + open: () => location(), }, workspace: { enabled: workspace.bar.visible, @@ -67,7 +67,7 @@ export default function NewSessionPage(props: { draftId: string }) { if (!ready) return // Search projects opened this draft; the picker returns focus to the composer when it closes. - if (layout.draft.projectSearch.take()) location({ projectsOnly: true }) + if (layout.draft.projectSearch.take()) location() else model.restoreFocus() }), ) diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index 31df80ccd03e..c2ad7b2f8bfc 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -2,6 +2,8 @@ import { useDialog } from "@opencode/ui/context/dialog" import { Tooltip } from "@opencode/ui/tooltip" import { Icon } from "@opencode/ui/icon" import { Slash } from "@opencode/ui/slash" +import { Keybind } from "@opencode/ui/keybind" +import { useCommand } from "@/shell/commands/command" import { Show, createMemo, createSignal, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" @@ -221,9 +223,7 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"]; class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none" onClick={open} > - - {language.t("home.providerTip")} - + {language.t("home.providerTip")} @@ -249,6 +249,29 @@ function NewSessionTips(props: { selection: ComposerModel["model"]["selection"]; ) } +// Keeps as many leading segments as fit, then "…/", then the last two segments. The cut always lands on a slash. +function fitPath(value: string, width: number, font: string) { + const separator = value.includes("/") ? "/" : "\\" + const parts = value.split(separator) + const context = document.createElement("canvas").getContext("2d") + + if (!context) return { text: value, minimum: 0 } + context.font = font + const fits = (text: string) => context.measureText(text).width <= width + const first = parts[0] === "" ? 2 : 1 + + const candidates = Array.from({ length: Math.max(0, parts.length - 2 - first) }, (_, index) => + [...parts.slice(0, parts.length - 3 - index), "…", ...parts.slice(-2)].join(separator), + ) + + const minimum = candidates.at(-1) ?? value + + return { + text: !width || fits(value) ? value : (candidates.find(fits) ?? minimum), + minimum: context.measureText(minimum).width, + } +} + /** The composer tray: combined location picker, Git context, and the session's directory. */ function NewSessionLocation(props: { project: PromptProject @@ -260,7 +283,8 @@ function NewSessionLocation(props: { onClick: () => void }) { const language = useLanguage() - const [truncated, setTruncated] = createSignal(false) + const command = useCommand() + const [layout, setLayout] = createStore({ width: 0, font: "" }) const worktree = () => { if (props.workspace === "main") return @@ -276,6 +300,8 @@ function NewSessionLocation(props: { return props.workspace === "main" ? props.root : props.workspace } + const fitted = createMemo(() => fitPath(path() ?? "", layout.width, layout.font)) + return (
@@ -337,22 +363,56 @@ function NewSessionLocation(props: {
{(directory) => ( -
+
- createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)) + ref={(row) => + createResizeObserver(row, () => { + const icon = row.querySelector("[data-slot=new-session-location-icon]") + const style = getComputedStyle(row) + + setLayout({ + width: row.clientWidth - (icon?.clientWidth ?? 0) - parseFloat(style.columnGap), + font: style.font, + }) + }) } - data-slot="new-session-location-path" - class="block min-w-0 truncate text-v2-text-text-faint [direction:rtl] [text-align:end]" + class="flex min-w-0 flex-1 items-center justify-end gap-1.5" + dir="ltr" > - {directory()} + + {language.t("session.location.toggle")} + + + } + > + + + + + + {fitted().text} +
diff --git a/packages/app/src/new-session/workspace/selector.tsx b/packages/app/src/new-session/workspace/selector.tsx index 4027fc73550b..c1c26f4f7e1e 100644 --- a/packages/app/src/new-session/workspace/selector.tsx +++ b/packages/app/src/new-session/workspace/selector.tsx @@ -80,7 +80,7 @@ export function NewSessionBranch(props: { workspace: NewSessionWorkspaceControll when={creating()} fallback={
- + {label()} diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 5153c30c52d6..138ad8dbe15e 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -60,6 +60,7 @@ export const dict = { "command.usage.view": "View usage", "command.usage.view.description": "See your OpenCode Go allowance and Console budgets", "command.session.location.cycle": "Cycle session location", + "session.location.toggle": "Toggle session location", "command.palette": "Search everything", "command.commands.search": "Search commands", @@ -1128,6 +1129,7 @@ export const dict = { "session.new.workspace.search.placeholder": "Search worktrees", "session.new.workspace.search.empty": "No matching worktrees", "session.new.location.select": "Select", + "session.new.location.otherProjects": "Other projects", "session.new.location.worktrees": "Worktrees", "session.new.location.back": "Back to projects", "session.new.location.servers": "Servers", From 4ba064f653063365eca21895f1de7af5e880f660 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 27 Oct 2026 15:44:13 +0100 Subject: [PATCH 010/138] feat(app): add session location menu actions (#59607) --- .../app/e2e/regression/session-header.spec.ts | 97 ++++++++++++++++++- .../app/e2e/utils/windows-menu.fixture.tsx | 18 +++- packages/app/e2e/utils/windows-menu.html | 7 +- packages/app/src/runtime/i18n/en.ts | 1 + .../src/session/session-identity-header.tsx | 83 ++++++++++++---- packages/gui-plugins/README.md | 12 ++- packages/gui-plugins/src/file/open-in-app.tsx | 21 ++++ packages/gui-plugins/src/file/renderer.tsx | 23 ++++- packages/gui-plugins/src/terminal/i18n/en.ts | 1 + .../gui-plugins/src/terminal/renderer.tsx | 16 ++- packages/plugin/src/gui/builtin.ts | 4 + 11 files changed, 256 insertions(+), 27 deletions(-) diff --git a/packages/app/e2e/regression/session-header.spec.ts b/packages/app/e2e/regression/session-header.spec.ts index d9ddcd0db4db..73d2adcc6a07 100644 --- a/packages/app/e2e/regression/session-header.spec.ts +++ b/packages/app/e2e/regression/session-header.spec.ts @@ -2,6 +2,7 @@ import type { SessionMessageInfo } from "@opencode/client/promise" import { expect, test, type Page } from "@playwright/test" import { REMOTE_SERVER, seed, sessionHref } from "../utils/app" import { fixture, mockStressTimeline } from "../utils/session-fixture" +import { reviewTabs } from "../utils/side-panel" import { expectSessionTitle } from "../utils/waits" import { mockRemoteServer, mockWorkspace, openSession } from "../utils/workspace" @@ -333,7 +334,7 @@ test.describe("location", () => { test("names the project and worktree, and the card copies the full path", async ({ page, context }) => { await context.grantPermissions(["clipboard-read", "clipboard-write"]) - await openSession(page, workspace) + await openSession(page, { ...workspace, vcs: { current: "", default: "main" } }) await expect(trigger(page).locator('[data-slot="session-location-project"]')).toHaveText("Location") await expect(trigger(page).locator('[data-slot="session-location-worktree"]')).toHaveText("quiet-falcon") await expect(trigger(page)).not.toContainText("/") @@ -341,6 +342,8 @@ test.describe("location", () => { await trigger(page).click() const card = page.locator('[data-slot="session-location-card"]') const path = card.getByRole("menuitem", { name: worktree, exact: true }) + await expect(card.getByText("No branch", { exact: true })).toBeVisible() + await expect(card.getByRole("menuitem", { name: "No branch", exact: true })).toHaveCount(0) await expect(path).toHaveText(worktree) await expect(card.getByRole("menuitem", { name: "Copy path", exact: true })).toHaveCount(0) // Select actual text on the first line, not the empty half of a wrapped path's bounding box. @@ -379,6 +382,98 @@ test.describe("location", () => { await expect(page.locator('[data-slot="session-location-card"]').getByText("apollo", { exact: true })).toBeVisible() }) + test("copies the branch name and opens its worktree in the right-panel terminal", async ({ page, context }) => { + await context.grantPermissions(["clipboard-read", "clipboard-write"]) + await page.setViewportSize({ width: 1440, height: 900 }) + + const { pty } = await openSession(page, { + ...workspace, + vcs: (directory) => ({ current: directory === worktree ? "feature/branch-menu" : "main", default: "main" }), + pty: {}, + }) + + await expect(page.getByRole("button", { name: "More options", exact: true })).toBeVisible() + await trigger(page).click() + const card = page.locator('[data-slot="session-location-card"]') + const branch = card.getByRole("menuitem", { name: "feature/branch-menu", exact: true }) + await page.screenshot({ path: test.info().outputPath("branch-card.png"), animations: "disabled" }) + await branch.press("Enter") + const actions = page.getByRole("menu", { name: "feature/branch-menu", exact: true }) + await expect(actions.getByRole("menuitem")).toHaveText(["Copy branch name", "Open in terminal"]) + await page.screenshot({ path: test.info().outputPath("branch-actions.png"), animations: "disabled" }) + await actions.getByRole("menuitem", { name: "Copy branch name", exact: true }).click() + await expect(card).toBeHidden() + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe("feature/branch-menu") + + await trigger(page).click() + await branch.click() + await actions.getByRole("menuitem", { name: "Open in terminal", exact: true }).click() + await expect(card).toBeHidden() + await expect.poll(() => pty.created.map((item) => item.cwd)).toEqual([worktree]) + await expect.poll(() => pty.sockets.length).toBe(1) + pty.send("$ pwd\r\n" + worktree + "\r\n") + const terminal = page.locator('[data-component="terminal"]') + await expect(terminal).toHaveCSS("opacity", "1") + await expect(terminal.locator("textarea")).toBeFocused() + await expect + .poll(async () => { + const box = await terminal.boundingBox() + const header = await page.locator("[data-session-title]").boundingBox() + + return !!box && !!header && box.x >= header.x + header.width + }) + .toBe(true) + await page.screenshot({ path: test.info().outputPath("branch-terminal.png"), animations: "disabled" }) + }) + + for (const app of [ + { id: "vscode", label: "VS Code" }, + { id: "finder", label: "Finder" }, + ]) { + test(`uses the selected ${app.label} app in the path submenu`, async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 900 }) + + const mock = await mockWorkspace(page, { + ...workspace, + seed: { + storage: { + ...reviewTabs(worktree, "ses_location"), + "opencode.global.dat:plugin.file.app": { app: app.id }, + }, + }, + }) + + await page.goto( + `/e2e/utils/windows-menu.html?${new URLSearchParams({ + server: mock.server, + path: sessionHref("ses_location", "sidecar"), + os: "macos", + openApps: "1", + })}`, + ) + await expectSessionTitle(page, "Location session") + await page.getByRole("button", { name: "Toggle review", exact: true }).click() + // Opening review hands focus to the panel after its motion; let that finish before opening another menu. + await expect(page.getByRole("complementary", { name: "Review and files", exact: true })).toBeFocused() + await expect(page.getByRole("button", { name: `Open in ${app.label}`, exact: true })).toBeVisible() + await trigger(page).click() + await page.getByRole("menuitem", { name: worktree, exact: true }).click() + const actions = page.getByRole("menu", { name: worktree, exact: true }) + + if (app.id === "finder") { + await expect(actions.getByRole("menuitem")).toHaveText(["Copy path", "Reveal in Finder"]) + + return + } + + await page.screenshot({ path: test.info().outputPath("path-open-app.png"), animations: "disabled" }) + await actions.getByRole("menuitem", { name: "Open in VS Code", exact: true }).click() + await expect(page.getByLabel("Opened paths")).toHaveText( + JSON.stringify([{ path: worktree, app: "Visual Studio Code" }]), + ) + }) + } + test.describe("touch", () => { test.use({ hasTouch: true }) diff --git a/packages/app/e2e/utils/windows-menu.fixture.tsx b/packages/app/e2e/utils/windows-menu.fixture.tsx index 0a1ca921fbaf..b5909a5fcb87 100644 --- a/packages/app/e2e/utils/windows-menu.fixture.tsx +++ b/packages/app/e2e/utils/windows-menu.fixture.tsx @@ -7,7 +7,7 @@ import { PlatformProvider, type Platform } from "../../src/runtime/platform/plat // The app on a Windows desktop platform, so the titlebar shows the app menu. Menu actions are listed in an output. // `path` opens a route other than Home. -export function mount(input: { server: string; path?: string }) { +export function mount(input: { server: string; path?: string; os?: "macos"; openApps?: boolean }) { const root = document.getElementById("root") if (!root) throw new Error("Missing fixture root") @@ -15,7 +15,10 @@ export function mount(input: { server: string; path?: string }) { if (input.path) history.set({ value: input.path, replace: true }) render(() => { - const [store, setStore] = createStore<{ actions: string[] }>({ actions: [] }) + const [store, setStore] = createStore<{ actions: string[]; opened: { path: string; app?: string }[] }>({ + actions: [], + opened: [], + }) const unused = async () => { throw new Error("Unexpected fixture action") @@ -42,7 +45,7 @@ export function mount(input: { server: string; path?: string }) { const platform: Platform = { platform: "desktop", - os: "windows", + os: input.os ?? "windows", windowID: "windows-menu-test", openExternal: () => undefined, openDirectoryPickerDialog: async () => null, @@ -52,10 +55,19 @@ export function mount(input: { server: string; path?: string }) { runDesktopMenuAction: (action) => setStore("actions", (actions) => [...actions, action]), } + if (input.openApps) { + platform.checkAppExists = async (app) => app === "Visual Studio Code" || app === "code" + platform.openPath = async (path, app) => void setStore("opened", (opened) => [...opened, { path, app }]) + platform.revealPath = async () => true + } + return ( {store.actions.join(",")} + + {JSON.stringify(store.opened)} + } diff --git a/packages/app/e2e/utils/windows-menu.html b/packages/app/e2e/utils/windows-menu.html index 766f5adc1ea9..5b2cc1d045d9 100644 --- a/packages/app/e2e/utils/windows-menu.html +++ b/packages/app/e2e/utils/windows-menu.html @@ -8,7 +8,12 @@ diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 138ad8dbe15e..9bfe7a81dc47 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -670,6 +670,7 @@ export const dict = { "session.header.reveal.fileExplorer": "Reveal in File Explorer", "session.header.reveal.containingFolder": "Open containing folder", "session.location.copyPath": "Copy path", + "session.location.copyBranch": "Copy branch name", "session.location.noBranch": "No branch", "settings.tab.servers": "Servers", diff --git a/packages/app/src/session/session-identity-header.tsx b/packages/app/src/session/session-identity-header.tsx index 2883d7ae4053..13395c0fadea 100644 --- a/packages/app/src/session/session-identity-header.tsx +++ b/packages/app/src/session/session-identity-header.tsx @@ -147,18 +147,14 @@ export function SessionLocation(props: { void server.ctx.data.location.vcs.sync({ directory: props.project.worktree }).catch(() => undefined) } - const copyPath = async () => { - const directory = props.directory - - if (!directory) return - + const copyText = async (text: string) => { try { - await (platform.writeClipboardText?.(directory) ?? navigator.clipboard.writeText(directory)) + await (platform.writeClipboardText?.(text) ?? navigator.clipboard.writeText(text)) showToast({ variant: "success", icon: "circle-check", title: language.t("common.copied"), - description: directory, + description: text, }) } catch (cause) { showToast({ @@ -291,10 +287,10 @@ export function SessionLocation(props: { - - {language.t("session.location.noBranch")} @@ -308,11 +304,46 @@ export function SessionLocation(props: { )} - } - > - {branch()} - - + + } + > + {(name) => ( + + + + + {name()} + + + + + void copyText(name())}> + + {language.t("session.location.copyBranch")} + + + {(context) => ( + + )} + + + + + )} + {(context) => ( @@ -353,7 +384,7 @@ export function SessionLocation(props: { - void copyPath()}> + void copyText(directory())}> {language.t("session.location.copyPath")} @@ -363,6 +394,24 @@ export function SessionLocation(props: { {language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)} + + {(context) => ( + + )} + diff --git a/packages/gui-plugins/README.md b/packages/gui-plugins/README.md index 0566758d792c..bc4f086679c7 100644 --- a/packages/gui-plugins/README.md +++ b/packages/gui-plugins/README.md @@ -224,7 +224,7 @@ SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the | [`LinkHandler`](../plugin/src/gui/registries.ts) | window | Opens local links, such as file paths in messages, and says whether their target exists | | [`TitlebarItem`](../plugin/src/gui/registries.ts) | window | A pill in the titlebar or the tabs footer | | [`ComposerPill`](../plugin/src/gui/registries.ts) | window | A pill in the row above a session's composer, optionally opening a menu of rows | -| [`Slot`](../plugin/src/gui/registries.ts) | window | Content for `window.bottom`, `session.header`, `session.location.menu`, `session.panel.end`; built-ins also fill `session.location.workspace` through `BuiltinSlot` | +| [`Slot`](../plugin/src/gui/registries.ts) | window | Content for `window.bottom`, `session.header`, `session.location.menu`, `session.panel.end`; built-ins also fill `session.location.workspace`, `session.location.branch.menu` and `session.location.path.menu` through `BuiltinSlot` | | [`Style`](../plugin/src/gui/registries.ts) | window | CSS imported with `?inline` | | [`MenubarItem`](../plugin/src/gui/main.ts) | main | An item of the native app menu | @@ -232,6 +232,16 @@ SDK icon fields take an `IconName` from `@opencode/plugin/gui`: the names of the the live `session`, and `shown`; keep data subscriptions active only while `shown` is true. Loading headers without an owning screen show the host's location details without this slot. +`session.location.branch.menu` (built-ins only, through `BuiltinSlot`) contributes items after Copy branch name in +the branch submenu, with the same `screen`, live `session`, and project-menu `shown` context. It renders only when a +branch name and owning screen are available. Use it for actions on the branch's working directory, such as opening +an integrated terminal. + +`session.location.path.menu` (built-ins only, through `BuiltinSlot`) contributes items after the host's copy and +reveal actions in the session directory's path submenu. It receives the same session context and renders only with +an owning screen. Use it for external-app actions on `session.directory` without duplicating the host's file-manager +action. + `session.location.workspace` (built-ins only, through `BuiltinSlot`) replaces the current location row with the same session context plus its `label` and `icon`; one contribution owns it. Without a contribution, the host keeps its static location row. Use it to attach location actions directly diff --git a/packages/gui-plugins/src/file/open-in-app.tsx b/packages/gui-plugins/src/file/open-in-app.tsx index 283495daf31b..d10eb0d878db 100644 --- a/packages/gui-plugins/src/file/open-in-app.tsx +++ b/packages/gui-plugins/src/file/open-in-app.tsx @@ -193,6 +193,27 @@ export function useOpenInApp(input: { session: MountedSession; path: () => strin type OpenInAppState = ReturnType +export function OpenInAppMenuItem(props: { session: MountedSession }) { + const ctx = usePlugin() + + const state = useOpenInApp({ + get session() { + return props.session + }, + path: () => props.session.directory, + request: useShared().request, + }) + + return ( + + state.openPath(state.current().id)}> + + {ctx.t("open.ariaLabel", { app: state.current().label })} + + + ) +} + export default function OpenInAppButton(props: { session: MountedSession; screen: SessionScreen }) { const ctx = usePlugin() const directory = () => props.screen.file.root diff --git a/packages/gui-plugins/src/file/renderer.tsx b/packages/gui-plugins/src/file/renderer.tsx index f94c10a6bb13..3a3da6869c6c 100644 --- a/packages/gui-plugins/src/file/renderer.tsx +++ b/packages/gui-plugins/src/file/renderer.tsx @@ -46,7 +46,7 @@ import type File from "./index" import { FileVisual } from "./label" import { fileTabId, fileTabPath, isFileTab, workspaceFileUrl } from "./path" import tabStyles from "./tabs.css?inline" -import { onIdle, BuiltinPanel, type BuiltinPanelTab } from "@opencode/plugin/gui/builtin" +import { onIdle, BuiltinPanel, BuiltinSlot, type BuiltinPanelTab } from "@opencode/plugin/gui/builtin" import { Browser } from "@opencode/plugin/gui/browser" import { Changes } from "@opencode/plugin/gui/review" @@ -414,6 +414,10 @@ const setup: Plugin.Setup = (ctx) => { const OpenInAppButton = lazy(() => import("./open-in-app")) + const OpenInAppMenuItem = lazy(() => + import("./open-in-app").then((module) => ({ default: module.OpenInAppMenuItem })), + ) + // How many "Open in" buttons each screen's panel headers show; the tab strip shows its own only while none do. const headers = new WeakMap>() @@ -430,7 +434,22 @@ const setup: Plugin.Setup = (ctx) => { } if (desktop) { - onCleanup(onIdle(() => void OpenInAppButton.preload())) + onCleanup( + onIdle(() => { + void OpenInAppButton.preload() + void OpenInAppMenuItem.preload() + }), + ) + ctx.add(BuiltinSlot, { + at: "session.location.path.menu", + render: (input) => ( + + + + + + ), + }) ctx.add(Slot, { at: "session.panel.end", render: (input) => ( diff --git a/packages/gui-plugins/src/terminal/i18n/en.ts b/packages/gui-plugins/src/terminal/i18n/en.ts index 19c4e1563dbc..133ee4525dd3 100644 --- a/packages/gui-plugins/src/terminal/i18n/en.ts +++ b/packages/gui-plugins/src/terminal/i18n/en.ts @@ -3,6 +3,7 @@ export default { "command.toggle": "Toggle terminal", "command.new": "New terminal", "command.new.description": "Create a new terminal tab", + "location.open": "Open in terminal", loading: "Loading terminal…", "title.numbered": "Terminal {{number}}", close: "Close terminal", diff --git a/packages/gui-plugins/src/terminal/renderer.tsx b/packages/gui-plugins/src/terminal/renderer.tsx index 805e2e94640a..d9458dcebef9 100644 --- a/packages/gui-plugins/src/terminal/renderer.tsx +++ b/packages/gui-plugins/src/terminal/renderer.tsx @@ -1,4 +1,6 @@ import { createMemo, lazy, on, onCleanup, Suspense } from "solid-js" +import { Icon } from "@opencode/ui/icon" +import { Menu } from "@opencode/ui/menu" import { Command, createKeyed, @@ -16,7 +18,7 @@ import { terminalTab, type LocalPTY } from "./state" import { terminalTabLabel } from "./title" import { TerminalTabLabel } from "./label" import quoteStyles from "./quote.css?inline" -import { onIdle, BuiltinCommand } from "@opencode/plugin/gui/builtin" +import { onIdle, BuiltinCommand, BuiltinSlot } from "@opencode/plugin/gui/builtin" const setup: Plugin.Setup = (ctx) => { const sessions = ctx.sessions @@ -66,7 +68,7 @@ const setup: Plugin.Setup = (ctx) => { void terminal.new().then((id) => { const pty = id ? terminal.all().find((item) => item.id === id) : undefined - if (pty) show(session, terminal, pty) + if (pty && !ctx.signal.aborted) show(session, terminal, pty) }) // Closes the tab, which closes its terminal; the narrow-screen view lists terminals without storing their tabs. @@ -78,6 +80,16 @@ const setup: Plugin.Setup = (ctx) => { const active = (terminal: TerminalWorkspace) => terminal.all().find((pty) => pty.id === terminal.active()) + ctx.add(BuiltinSlot, { + at: "session.location.branch.menu", + render: (input) => ( + create(input.session, model.load(input.session))}> + + {ctx.t("location.open")} + + ), + }) + ctx.add( Command, (): Command => ({ diff --git a/packages/plugin/src/gui/builtin.ts b/packages/plugin/src/gui/builtin.ts index ea7c9c83b2e6..9536e589939e 100644 --- a/packages/plugin/src/gui/builtin.ts +++ b/packages/plugin/src/gui/builtin.ts @@ -98,6 +98,10 @@ export const BuiltinPanel = Registry.define("panel") /** The slots of `SlotMap`, and those only built-ins fill. */ export interface BuiltinSlotMap extends SlotMap { + /** Items after Copy branch name in the branch submenu. Only rendered for a named branch with an owning screen. */ + readonly "session.location.branch.menu": SlotMap["session.location.menu"] + /** Items after the host's copy and reveal actions in the session directory's path submenu. Requires an owning screen. */ + readonly "session.location.path.menu": SlotMap["session.location.menu"] /** * Replaces the project menu's current location row; one contribution owns it. Without one the host shows a static * row. From d012e07e6309db4e53eb77714d955fd4daabf6ec Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:04:46 +0100 Subject: [PATCH 011/138] feat(app): focus prompt history search (#59626) --- packages/app/component-tests/composer.spec.ts | 42 +++++++++---------- packages/app/src/composer/editor/editor.tsx | 36 +++------------- .../app/src/composer/editor/interaction.ts | 24 ++--------- packages/app/src/composer/model.ts | 9 ---- .../app/src/composer/suggestions/machine.ts | 35 +++------------- packages/ui/src/i18n/en.ts | 1 - 6 files changed, 32 insertions(+), 115 deletions(-) diff --git a/packages/app/component-tests/composer.spec.ts b/packages/app/component-tests/composer.spec.ts index c37bd337d814..cb2a0c7db6d2 100644 --- a/packages/app/component-tests/composer.spec.ts +++ b/packages/app/component-tests/composer.spec.ts @@ -96,20 +96,22 @@ story( const height = await composer.evaluate((element) => element.getBoundingClientRect().height) await input.press("Shift+ArrowUp") - await expect(input).toBeFocused() - await expect(search).toHaveCount(0) + await expect(search).toBeFocused() await expect(input).toHaveText("Explain the latest changes") await expect(composer).toHaveCSS("height", `${height}px`) const menu = await component.locator('[data-component="composer-suggestions"]').boundingBox() if (!menu) throw new Error("Missing history menu") await expect(options).toHaveText([ - "Run the focused tests", - "Review the composer layout Keep keyboard navigation accessible", "Explain the latest changes", + "Review the composer layout Keep keyboard navigation accessible", + "Run the focused tests", ]) await expect(component.getByRole("option", { selected: true })).toHaveText("Explain the latest changes") - await input.press("ArrowUp") + await expect( + component.getByRole("option", { name: "Explain the latest changes", exact: true }).locator("bdi"), + ).toHaveCSS("font-weight", "400") + await search.press("ArrowDown") await expect(component.getByRole("option", { selected: true })).toHaveText( "Review the composer layout Keep keyboard navigation accessible", ) @@ -140,20 +142,16 @@ story( .toBe(menu.y) await expect(component.getByRole("img", { name: "layout.png", exact: true })).toBeVisible() await expect(component.getByText("Keep the search separate from the draft", { exact: true })).toBeVisible() - await input.press("ArrowUp") + await search.press("ArrowDown") await expect(input).toHaveText("Run the focused tests") await expect(composer).toHaveCSS("height", `${height}px`) - await input.press("ArrowUp") + await search.press("ArrowDown") await expect(input).toHaveText("Run the focused tests") - await input.press("ArrowDown") + await search.press("ArrowUp") await expect(input).toHaveText("Review the composer layout\nKeep keyboard navigation accessible") - await input.press("ControlOrMeta+f") await expect(search).toBeFocused() await search.fill("ACCESSIBLE") await expect(composer).toHaveCSS("height", `${height}px`) - await search.press("Escape") - await expect(input).toBeFocused() - await expect(composer).toHaveCSS("height", `${height}px`) await expect .poll(() => component @@ -161,7 +159,7 @@ story( .evaluate((element) => element.scrollHeight > element.clientHeight), ) .toBe(true) - await input.press("Enter") + await search.press("Enter") await expect(options).toHaveCount(0) await expect(content).toHaveCSS("mask-image", "none") await expect(viewport).not.toHaveCSS("mask-image", "none") @@ -186,7 +184,7 @@ story( await expect(input).toHaveText("Explain the latest changes") await expect(component.getByRole("img", { name: "layout.png", exact: true })).toHaveCount(0) await expect(composer).toHaveCSS("height", `${restoredHeight}px`) - await input.press("Escape") + await search.press("Escape") await expect(options).toHaveCount(0) await expect(input).toBeFocused() await expect(input).toBeEmpty() @@ -196,13 +194,12 @@ story( }, ) -story("searches history on demand, returns to browsing, and keeps shell history separate", async ({ mount, page }) => { +story("searches history, cancels with Escape, and keeps shell history separate", async ({ mount, page }) => { const component = await mount("opencode-composer-flow--prompt-history") const input = component.getByRole("textbox", { name: "Prompt", exact: true }) const search = component.getByRole("combobox", { name: "Prompt history", exact: true }) await input.press("Shift+ArrowUp") - await input.press("ControlOrMeta+f") await expect(search).toBeFocused() await search.fill("ACCESSIBLE") await expect(component.getByRole("option")).toHaveCount(1) @@ -211,13 +208,10 @@ story("searches history on demand, returns to browsing, and keeps shell history await search.press("Escape") await expect(search).toHaveCount(0) await expect(input).toBeFocused() - await expect(component.getByRole("option")).toHaveCount(3) - await input.press("Escape") await expect(component.getByRole("option")).toHaveCount(0) await expect(input).toBeEmpty() await input.press("Shift+ArrowUp") - await component.getByRole("button", { name: /^Search/ }).click() await expect(search).toBeFocused() await search.fill("no matching prompt") await expect(component.getByRole("option")).toHaveCount(0) @@ -246,6 +240,8 @@ story("searches history on demand, returns to browsing, and keeps shell history await input.press("Shift+ArrowUp") await expect(input).toHaveText("Explain the latest changes") await expect(component.getByRole("option")).toHaveCount(3) + await expect(search).toBeFocused() + await search.press("Enter") await expect(input).toBeFocused() await input.press("Shift+Digit1") await expect(component.getByRole("option")).toHaveCount(0) @@ -256,11 +252,11 @@ story("searches history on demand, returns to browsing, and keeps shell history await input.press("Shift+ArrowUp") await expect(input).toHaveText("bun typecheck") await expect(component.getByRole("option")).toHaveText(["bun typecheck"]) - await input.press("Escape") + await search.press("Escape") await expect(input).toBeFocused() await expect(input).toBeEmpty() await input.press("Shift+ArrowUp") - await input.press("Enter") + await search.press("Enter") await expect(input).toHaveText("bun typecheck") await expect(component.getByRole("status")).toHaveText("Ready") }) @@ -270,7 +266,7 @@ story("pins history search above the scrolling prompts", async ({ mount }) => { const input = component.getByRole("textbox", { name: "Prompt", exact: true }) await input.press("Shift+ArrowUp") const menu = component.locator('[data-component="composer-suggestions"]') - const search = menu.getByRole("button", { name: /^Search/ }) + const search = menu.getByRole("combobox", { name: "Prompt history", exact: true }) const viewport = menu.locator("[data-scrollable]") await expect.poll(() => viewport.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true) @@ -287,7 +283,7 @@ story("pins history search above the scrolling prompts", async ({ mount }) => { await expect.poll(clippedTop).toBe(false) - for (let index = 0; index < 12; index++) await input.press("ArrowUp") + for (let index = 0; index < 12; index++) await search.press("ArrowDown") await expect(menu.getByRole("option", { selected: true })).toBeInViewport({ ratio: 1 }) await expect.poll(clippedTop).toBe(false) await expect.poll(async () => (await search.boundingBox())!.y - (await viewport.boundingBox())!.y).toBeLessThan(0) diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index 77e806092f3a..7404e1cf4972 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -45,7 +45,6 @@ import type { import type { ComposerEditorModel, ComposerSelectControl } from "./interaction" import { isAttachment } from "../prompt-parts" import { QuoteCommentEditor } from "./quote-comment" -import { formatKeybindParts } from "@/shell/commands/command" import "../attachments/attachments.css" import "./editor.css" import "@/shell/palette-menu.css" @@ -185,8 +184,6 @@ export function ComposerEditor(props: ComposerEditorProps) { const search = () => { if (state.popover.type !== "history" && state.popover.type !== "command-menu") return - if (state.popover.type === "history" && !state.popover.searching) return - return { value: state.popover.query, label: i18n.t(state.popover.type === "history" ? "ui.promptInput.history" : "ui.promptInput.commands"), @@ -219,9 +216,6 @@ export function ComposerEditor(props: ComposerEditorProps) { activeID={state.popover.type === "closed" ? undefined : state.popover.activeID} search={search()} history={state.popover.type === "history" ? state.mode : undefined} - historyBrowsing={state.popover.type === "history" && !state.popover.searching} - editor={() => editor} - onSearch={() => props.controller.dispatch({ type: "history.search" })} onClose={() => props.controller.dispatch({ type: "popover.close" })} onActiveChange={(item) => props.controller.dispatch({ type: "popover.active", id: item.id })} onSelect={(item) => props.controller.dispatch({ type: "popover.select", item })} @@ -297,11 +291,6 @@ export function ComposerEditor(props: ComposerEditorProps) { aria-multiline="true" aria-label={i18n.t("ui.promptInput.label")} aria-controls={state.popover.type === "history" ? suggestionsID : undefined} - aria-activedescendant={ - state.popover.type === "history" && !state.popover.searching && state.popover.activeID - ? `${suggestionsID}-${state.popover.activeID}` - : undefined - } dir={state.mode === "normal" ? "auto" : "ltr"} contenteditable={!props.disabled && !props.readOnly} autocapitalize={state.mode === "normal" ? "sentences" : "off"} @@ -1163,9 +1152,6 @@ function ComposerEditorPopover(props: { items: ComposerSuggestion[] activeID?: string history?: ComposerMode - historyBrowsing?: boolean - editor?: () => HTMLElement | undefined - onSearch?: () => void onClose?: () => void search?: { value: string @@ -1205,7 +1191,6 @@ function ComposerEditorPopover(props: { const dismiss = (event: Event) => { if (!(event.target instanceof Node) || container?.contains(event.target)) return - if (event.type === "focusin" && props.historyBrowsing && props.editor?.()?.contains(event.target)) return props.onClose?.() } @@ -1239,7 +1224,7 @@ function ComposerEditorPopover(props: { {(search) => ( -
+
requestAnimationFrame(() => element.isConnected && element.focus())} value={search().value} @@ -1260,16 +1245,6 @@ function ComposerEditorPopover(props: {
)} - - -
{item.label || item.description || i18n.t("ui.promptInput.history.contextOnly")} diff --git a/packages/app/src/composer/editor/interaction.ts b/packages/app/src/composer/editor/interaction.ts index 386d9d7c2a71..d59b058751b2 100644 --- a/packages/app/src/composer/editor/interaction.ts +++ b/packages/app/src/composer/editor/interaction.ts @@ -264,17 +264,14 @@ export function createComposerEditor(input: { if (state.popover.type !== "history") return list().flat() const query = state.popover.query.trim().toLowerCase() - return history() - .filter((item) => `${item.label} ${item.description}`.toLowerCase().includes(query)) - .toReversed() + return history().filter((item) => `${item.label} ${item.description}`.toLowerCase().includes(query)) } const execute = (command: ComposerInteractionCommand) => { if (command.type === "history.preview") { const item = history().find((entry) => entry.id === command.id) - if (item?.history) - applyHistory(item.history, { focus: state.popover.type === "history" && !state.popover.searching }) + if (item?.history) applyHistory(item.history, { focus: false }) return } @@ -324,7 +321,7 @@ export function createComposerEditor(input: { } function dispatch(event: ComposerInteractionEvent) { - if (event.type === "history.search" || event.type === "popover.close") cancelFocus() + if (event.type === "popover.close") cancelFocus() if ( event.type === "history.open" || @@ -380,21 +377,6 @@ export function createComposerEditor(input: { const onKeyDown = (event: KeyboardEvent) => { flushFocus() - if ( - state.popover.type === "history" && - (event.metaKey || event.ctrlKey) && - !event.altKey && - !event.shiftKey && - !event.isComposing && - event.key.toLowerCase() === "f" - ) { - event.preventDefault() - event.stopPropagation() - dispatch({ type: "history.search" }) - - return true - } - if ( state.mode === "normal" && (event.metaKey || event.ctrlKey) && diff --git a/packages/app/src/composer/model.ts b/packages/app/src/composer/model.ts index c72816d07e40..d348fd5e1758 100644 --- a/packages/app/src/composer/model.ts +++ b/packages/app/src/composer/model.ts @@ -562,15 +562,6 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue? Object.defineProperty(controller, "model", { get: () => adapter.controls().model }) command.register("composer-editor", () => [ - { - id: "prompt.history.search", - title: language.t("ui.promptInput.history.search"), - keybind: "mod+f", - hidden: true, - editable: true, - when: () => controller.state.popover.type === "history", - onSelect: () => void controller.dispatch({ type: "history.search" }), - }, { id: "file.attach", title: language.t("prompt.action.attachFile"), diff --git a/packages/app/src/composer/suggestions/machine.ts b/packages/app/src/composer/suggestions/machine.ts index 6b882c039cfb..33c7a48b93b9 100644 --- a/packages/app/src/composer/suggestions/machine.ts +++ b/packages/app/src/composer/suggestions/machine.ts @@ -8,7 +8,7 @@ export type ComposerInteractionState = { | { type: "context"; query: string; activeID?: string } | { type: "command-inline"; query: string; activeID?: string } | { type: "command-menu"; query: string; activeID?: string } - | { type: "history"; query: string; searching: boolean; activeID?: string } + | { type: "history"; query: string; activeID?: string } drag: "idle" | "active" focus: "editor" | "command-search" | "history-search" | "external" activeContextID?: string @@ -19,8 +19,6 @@ export type ComposerInteractionEvent = | { type: "commands.open" } | { type: "context.open" } | { type: "history.open"; id: string } - | { type: "history.search" } - | { type: "history.browse" } | { type: "popover.query"; value: string } | { type: "popover.results"; ids: string[] } | { type: "popover.active"; id: string } @@ -73,15 +71,9 @@ export function transitionComposer( if (event.type === "context.open") return openContext(state) if (event.type === "history.open") - return changed( - { ...state, popover: { type: "history", query: "", searching: false, activeID: event.id }, focus: "editor" }, - [{ type: "history.preview", id: event.id }], - ) - - if (event.type === "history.search" && state.popover.type === "history") - return changed({ ...state, popover: { ...state.popover, searching: true }, focus: "history-search" }) - - if (event.type === "history.browse") return browseHistory(state) + return changed({ ...state, popover: { type: "history", query: "", activeID: event.id }, focus: "history-search" }, [ + { type: "history.preview", id: event.id }, + ]) if (event.type === "popover.query") return queryChanged(state, event.value) @@ -193,25 +185,10 @@ function queryChanged(state: ComposerInteractionState, query: string): ComposerE ]) } -function browseHistory(state: ComposerInteractionState): ComposerEditorTransition { - if (state.popover.type !== "history") return unchanged(state) - - return changed( - { ...state, popover: { ...state.popover, searching: false, query: "" }, focus: "editor" }, - [{ type: "focus.editor" }], - true, - ) -} - function resultsChanged(state: ComposerInteractionState, ids: string[]): ComposerEditorTransition { if (state.popover.type === "closed") return unchanged(state) - const activeID = - state.popover.activeID && ids.includes(state.popover.activeID) - ? state.popover.activeID - : state.popover.type === "history" - ? ids.at(-1) - : ids[0] + const activeID = state.popover.activeID && ids.includes(state.popover.activeID) ? state.popover.activeID : ids[0] if (activeID === state.popover.activeID) return unchanged(state) @@ -269,8 +246,6 @@ function keyDown( event: Extract, ): ComposerEditorTransition { if (state.popover.type === "history" && (event.key === "Escape" || (event.ctrl && event.key.toLowerCase() === "g"))) { - if (state.popover.searching && event.key === "Escape") return browseHistory(state) - return changed({ ...state, popover: { type: "closed" }, focus: "editor" }, [{ type: "history.restore" }], true) } diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 7ed8e6d2d4e2..a96904931276 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -164,7 +164,6 @@ const source = { "ui.promptInput.commands": "Commands", "ui.promptInput.history": "Prompt history", "ui.promptInput.history.search": "Search prompt history…", - "ui.promptInput.history.searchAction": "Search", "ui.promptInput.history.contextOnly": "Prompt with context", "ui.promptInput.dropFiles": "Drop files to add", "ui.promptInput.dropFiles.image": "Drop images or files to add", From 7b1e54a74f6201e725a50cba5d7fb383aa64e407 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 27 Oct 2026 16:21:02 +0100 Subject: [PATCH 012/138] feat(app): align search and command palettes (#59604) --- .../e2e/regression/command-palette.spec.ts | 65 +++++++-- packages/app/src/new-session/view.tsx | 2 +- packages/app/src/shell/commands/dialog.css | 125 ++++++++++++++++++ packages/app/src/shell/commands/dialog.tsx | 19 ++- 4 files changed, 192 insertions(+), 19 deletions(-) diff --git a/packages/app/e2e/regression/command-palette.spec.ts b/packages/app/e2e/regression/command-palette.spec.ts index 68533559af9e..bfadd05f5711 100644 --- a/packages/app/e2e/regression/command-palette.spec.ts +++ b/packages/app/e2e/regression/command-palette.spec.ts @@ -619,18 +619,61 @@ test("keeps matching session and file results steady across keystrokes while sea }) }) -test("keeps the same height as the number of results changes", async ({ page }) => { - const { dialog, input } = await openCommandPalette(page) +test("titlebar session search shrinks with results while its field stays anchored through scrolling", async ({ + page, +}) => { + await openPaletteWorkspace(page, true, [ + { ...paletteSession, time: { ...paletteSession.time } }, + ...Array.from({ length: 23 }, (_, index) => ({ + ...paletteSession, + id: `ses_compact_palette_${index}`, + title: index === 0 ? "Compact keeper needle" : `Compact keeper result ${index}`, + time: { created: 1700000000000 - index - 1, updated: 1700000000000 - index - 1 }, + })), + ]) + await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click() + const dialog = page.getByRole("dialog") + const input = dialog.getByRole("combobox") + const options = dialog.getByRole("option") const container = page.locator('[data-component="dialog-v2"] [data-slot="dialog-container"]') - const height = async () => (await container.boundingBox())?.height - const initial = await height() - expect(initial).toBe(480) - await input.fill("copy session") - await expect(dialog.getByRole("option")).toHaveCount(1) - expect(await height()).toBe(initial) + const geometry = () => + container.evaluate((element) => ({ + height: element.getBoundingClientRect().height, + inputTop: element.querySelector('[role="combobox"]')!.getBoundingClientRect().top, + })) - await input.fill("zzzz no palette match") - await expect(dialog.getByText("No results found")).toBeVisible() - expect(await height()).toBe(initial) + await expect(input).toBeFocused() + await expect(options).toHaveCount(10) + // Hover waits for the field's opening animation to settle before taking the reference position. + await input.hover() + const initial = await geometry() + expect(initial.height).toBeGreaterThan(200) + + await input.fill("Compact keeper needle") + await expect(options).toHaveText([/Compact keeper needle/]) + await expect.poll(async () => (await geometry()).height).toBeLessThan(initial.height - 100) + await expect.poll(async () => (await geometry()).inputTop).toBeCloseTo(initial.inputTop, 0) + + await input.clear() + await expect(options).toHaveCount(10) + await expect.poll(geometry).toEqual({ + height: expect.closeTo(initial.height, 0), + inputTop: expect.closeTo(initial.inputTop, 0), + }) + + // A broader query exposes more than the recent-session limit and must scroll below the field. + await input.fill("Compact keeper") + await expect(options).toHaveCount(23) + const scroll = dialog.locator('[data-slot="search-list-scroll"]') + await expect.poll(() => scroll.evaluate((element) => element.scrollHeight - element.clientHeight)).toBeGreaterThan(0) + await expect.poll(async () => (await geometry()).height).toBeLessThanOrEqual(480) + const last = dialog.getByRole("option", { name: /Compact keeper result 22/ }) + await expect(last).not.toBeInViewport() + await scroll.hover() + await page.mouse.wheel(0, 2000) + await expect(last).toBeInViewport() + await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0) + await expect.poll(async () => (await geometry()).inputTop).toBeCloseTo(initial.inputTop, 0) + await expect(input).toBeFocused() }) diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index c2ad7b2f8bfc..bfdd3b040004 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -365,7 +365,7 @@ function NewSessionLocation(props: { {(directory) => (
.command-palette-title { + max-width: 60%; + flex-shrink: 0; + } + + .command-palette-description { + flex: 1; + color: var(--v2-text-text-faint); + } +} + +[data-color-scheme="dark"] .command-palette-compact [data-slot="search-list-row"]:is([data-active], :focus-visible) { + background: var(--v2-overlay-simple-overlay-hover); +} + +@media (max-width: 640px) { + .command-palette-compact { + [data-slot="search-list-row"]:has(.command-palette-description) { + min-height: 40px; + } + + .command-palette-row-text:has(.command-palette-description) > .command-palette-title { + max-width: 100%; + } + + .command-palette-description { + flex: none; + } + } +} + +@media (pointer: coarse) { + .command-palette-compact [data-slot="search-list-row"], + .command-palette-compact [data-slot="search-list-row"]:has(.command-palette-description), + .command-palette-compact [data-slot="search-list-search"] [data-component="text-input-v2"] { + min-height: 44px; + } +} diff --git a/packages/app/src/shell/commands/dialog.tsx b/packages/app/src/shell/commands/dialog.tsx index 344324199b36..fed41df6dc03 100644 --- a/packages/app/src/shell/commands/dialog.tsx +++ b/packages/app/src/shell/commands/dialog.tsx @@ -82,7 +82,7 @@ export function CommandPaletteView(props: { }) return ( - + +