From 29bf731d47da1cda99de2c9890d525045b1bc8e8 Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 13 Jan 2026 14:41:54 +0000 Subject: [PATCH 001/110] release: v1.1.17 --- bun.lock | 30 +++++++++++++------------- packages/app/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/desktop/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++------ packages/function/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 4 ++-- packages/sdk/js/package.json | 4 ++-- packages/slack/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 18 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index 10001bb61..daeb370fd 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -70,7 +70,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -99,7 +99,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -126,7 +126,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -150,7 +150,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -174,7 +174,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -203,7 +203,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -232,7 +232,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -248,7 +248,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.1.16", + "version": "1.1.17", "bin": { "opencode": "./bin/opencode", }, @@ -351,7 +351,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -371,7 +371,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.1.16", + "version": "1.1.17", "devDependencies": { "@hey-api/openapi-ts": "0.88.1", "@tsconfig/node22": "catalog:", @@ -382,7 +382,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -395,7 +395,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -435,7 +435,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "zod": "catalog:", }, @@ -446,7 +446,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index bef67c82c..305cb7a12 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.1.16", + "version": "1.1.17", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 9557f8310..61982f58a 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index ecfb20007..4385fd87c 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.1.16", + "version": "1.1.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 8f3b1ddee..bc9ce254c 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.1.16", + "version": "1.1.17", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 9572cfde8..b53cd3171 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.1.16", + "version": "1.1.17", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 5cf2b20db..477806f33 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7dcdb574d..259a00b6c 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.1.16", + "version": "1.1.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 6ccac0c10..4c4365b75 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "opencode" name = "OpenCode" description = "The open source coding agent." -version = "1.1.16" +version = "1.1.17" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/anomalyco/opencode" @@ -11,26 +11,26 @@ name = "OpenCode" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.16/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.1.17/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index cc8ae0f18..4d109d370 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.1.16", + "version": "1.1.17", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 07fee7d73..8a3d925c1 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.1.16", + "version": "1.1.17", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index d1848b4a3..fc2db6a51 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "scripts": { @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} +} \ No newline at end of file diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index ca24d02aa..98e46ac3c 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "scripts": { @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} +} \ No newline at end of file diff --git a/packages/slack/package.json b/packages/slack/package.json index 1b2d90166..dfc322fb7 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index b88c747e1..e9159b73b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.1.16", + "version": "1.1.17", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index fcd980b4a..bcbfc0d31 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.1.16", + "version": "1.1.17", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index 5a657fe0d..adafa8540 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.1.16", + "version": "1.1.17", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 2bfb97a33..dab321752 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.1.16", + "version": "1.1.17", "publisher": "sst-dev", "repository": { "type": "git", From c86c2acf4c0e0f9945b3dd83a1a32e1eb9783c86 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 13 Jan 2026 09:57:43 -0500 Subject: [PATCH 002/110] add fullscreen view to permission prompt --- AGENTS.md | 6 +- STYLE_GUIDE.md | 21 +- .../cli/cmd/tui/component/prompt/index.tsx | 40 ++-- .../cli/cmd/tui/routes/session/permission.tsx | 212 +++++++++++------- 4 files changed, 162 insertions(+), 117 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 87d59d4c9..3138f6c5e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,4 +1,4 @@ -- To test opencode in the `packages/opencode` directory you can run `bun dev` -- To regenerate the javascript SDK, run ./packages/sdk/js/script/build.ts +- To test opencode in `packages/opencode`, run `bun dev`. +- To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. -- the default branch in this repo is `dev` +- The default branch in this repo is `dev`. diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index a46ce221f..52d012fcb 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -1,19 +1,16 @@ ## Style Guide -- Try to keep things in one function unless composable or reusable -- AVOID unnecessary destructuring of variables. instead of doing `const { a, b } -= obj` just reference it as obj.a and obj.b. this preserves context -- AVOID `try`/`catch` where possible -- AVOID using `any` type -- PREFER single word variable names where possible -- Use as many bun apis as possible like Bun.file() +- Keep things in one function unless composable or reusable +- Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context +- Avoid `try`/`catch` where possible +- Avoid using the `any` type +- Prefer single word variable names where possible +- Use Bun APIs when possible, like `Bun.file()` # Avoid let statements -we don't like let statements, especially combined with if/else statements. -prefer const - -This is bad: +We don't like `let` statements, especially combined with if/else statements. +Prefer `const`. Good: @@ -32,7 +29,7 @@ else foo = 2 # Avoid else statements -Prefer early returns or even using `iife` to avoid else statements +Prefer early returns or using an `iife` to avoid else statements. Good: diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index d5e0a0aa2..9ad85d08f 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -563,25 +563,27 @@ export function Prompt(props: PromptProps) { })), }) } else { - sdk.client.session.prompt({ - sessionID, - ...selectedModel, - messageID, - agent: local.agent.current().name, - model: selectedModel, - variant, - parts: [ - { - id: Identifier.ascending("part"), - type: "text", - text: inputText, - }, - ...nonTextParts.map((x) => ({ - id: Identifier.ascending("part"), - ...x, - })), - ], - }) + sdk.client.session + .prompt({ + sessionID, + ...selectedModel, + messageID, + agent: local.agent.current().name, + model: selectedModel, + variant, + parts: [ + { + id: Identifier.ascending("part"), + type: "text", + text: inputText, + }, + ...nonTextParts.map((x) => ({ + id: Identifier.ascending("part"), + ...x, + })), + ], + }) + .catch(() => {}) } history.append({ ...store.prompt, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index f5b6badb5..c95b42260 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -1,6 +1,6 @@ import { createStore } from "solid-js/store" import { createMemo, For, Match, Show, Switch } from "solid-js" -import { useKeyboard, useTerminalDimensions, type JSX } from "@opentui/solid" +import { Portal, useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid" import type { TextareaRenderable } from "@opentui/core" import { useKeybind } from "../../context/keybind" import { useTheme, selectedForeground } from "../../context/theme" @@ -11,6 +11,7 @@ import { useSync } from "../../context/sync" import { useTextareaKeybindings } from "../../component/textarea-keybindings" import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" +import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" type PermissionStage = "permission" | "always" | "reject" @@ -32,7 +33,9 @@ function filetype(input?: string) { } function EditBody(props: { request: PermissionRequest }) { - const { theme, syntax } = useTheme() + const themeState = useTheme() + const theme = themeState.theme + const syntax = themeState.syntax const sync = useSync() const dimensions = useTerminalDimensions() @@ -54,7 +57,7 @@ function EditBody(props: { request: PermissionRequest }) { Edit {normalizePath(filepath())} - + - + ) @@ -172,86 +175,95 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { message: message || undefined, }) }} - onCancel={() => setStore("stage", "permission")} + onCancel={() => { + setStore("stage", "permission") + }} /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - } - options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} - escapeKey="reject" - onSelect={(option) => { - if (option === "always") { - setStore("stage", "always") - return - } - if (option === "reject") { - if (session()?.parentID) { - setStore("stage", "reject") - return + {(() => { + const body = ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + } - sdk.client.permission.reply({ - reply: "reject", - requestID: props.request.id, - }) - } - sdk.client.permission.reply({ - reply: "once", - requestID: props.request.id, - }) - }} - /> + options={{ once: "Allow once", always: "Allow always", reject: "Reject" }} + escapeKey="reject" + fullscreen + onSelect={(option) => { + if (option === "always") { + setStore("stage", "always") + return + } + if (option === "reject") { + if (session()?.parentID) { + setStore("stage", "reject") + return + } + sdk.client.permission.reply({ + reply: "reject", + requestID: props.request.id, + }) + } + sdk.client.permission.reply({ + reply: "once", + requestID: props.request.id, + }) + }} + /> + ) + + return body + })()} ) @@ -327,14 +339,18 @@ function Prompt>(props: { body: JSX.Element options: T escapeKey?: keyof T + fullscreen?: boolean onSelect: (option: keyof T) => void }) { const { theme } = useTheme() const keybind = useKeybind() + const dimensions = useTerminalDimensions() const keys = Object.keys(props.options) as (keyof T)[] const [store, setStore] = createStore({ selected: keys[0], + expanded: false, }) + const diffKey = Keybind.parse("ctrl+f")[0] useKeyboard((evt) => { if (evt.name === "left" || evt.name == "h") { @@ -360,17 +376,36 @@ function Prompt>(props: { evt.preventDefault() props.onSelect(props.escapeKey) } + + if (props.fullscreen && diffKey && Keybind.match(diffKey, keybind.parse(evt))) { + evt.preventDefault() + evt.stopPropagation() + setStore("expanded", (v) => !v) + } }) - return ( + const hint = createMemo(() => (store.expanded ? "minimize" : "fullscreen")) + const renderer = useRenderer() + + const content = () => ( - - + + {"△"} {props.title} @@ -403,6 +438,11 @@ function Prompt>(props: { + + + {"ctrl+f"} {hint()} + + {"⇆"} select @@ -413,4 +453,10 @@ function Prompt>(props: { ) + + return ( + {content()}}> + {content()} + + ) } From 7d0b52dc29b7c7bc10bd2defe8452400db76bb9c Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 13 Jan 2026 14:58:51 +0000 Subject: [PATCH 003/110] chore: generate --- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/plugin/package.json b/packages/plugin/package.json index fc2db6a51..7b0507716 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -25,4 +25,4 @@ "typescript": "catalog:", "@typescript/native-preview": "catalog:" } -} \ No newline at end of file +} diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 98e46ac3c..bd89818d1 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -30,4 +30,4 @@ "publishConfig": { "directory": "dist" } -} \ No newline at end of file +} From 2b77a84c4f3c6e651e0cf224b0c814260ddd8a45 Mon Sep 17 00:00:00 2001 From: usvimal Date: Tue, 13 Jan 2026 23:39:43 +0800 Subject: [PATCH 004/110] fix(desktop): correct health check endpoint URL to /global/health (#8231) --- packages/desktop/src-tauri/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 75ddb6566..0d5b585e8 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -198,7 +198,7 @@ fn spawn_sidecar(app: &AppHandle, port: u32, password: &str) -> CommandChild { } async fn check_server_health(url: &str, password: Option<&str>) -> bool { - let health_url = format!("{}/health", url.trim_end_matches('/')); + let health_url = format!("{}/global/health", url.trim_end_matches('/')); let client = reqwest::Client::builder() .timeout(Duration::from_secs(3)) .build(); From 528291532b1d4192302538f9c27054ff717c6982 Mon Sep 17 00:00:00 2001 From: Daniel Polito Date: Tue, 13 Jan 2026 12:41:35 -0300 Subject: [PATCH 005/110] feat(desktop): Adding Provider Icons (#8215) --- .../app/src/components/dialog-select-model.tsx | 11 ++++++++++- packages/app/src/components/prompt-input.tsx | 18 ++++++++++++++++-- packages/ui/src/components/button.css | 3 +-- packages/ui/src/components/list.tsx | 12 +++++++++--- packages/ui/src/components/session-turn.tsx | 10 +++++++++- 5 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index d54f9369a..c614c2d49 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -7,6 +7,8 @@ import { Button } from "@opencode-ai/ui/button" import { Tag } from "@opencode-ai/ui/tag" import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import type { IconName } from "@opencode-ai/ui/icons/provider" import { DialogSelectProvider } from "./dialog-select-provider" import { DialogManageModels } from "./dialog-manage-models" @@ -35,6 +37,12 @@ const ModelList: Component<{ filterKeys={["provider.name", "name", "id"]} sortBy={(a, b) => a.name.localeCompare(b.name)} groupBy={(x) => x.provider.name} + groupHeader={(group) => ( +
+ + {group.category} +
+ )} sortGroupsBy={(a, b) => { if (a.category === "Recent" && b.category !== "Recent") return -1 if (b.category === "Recent" && a.category !== "Recent") return 1 @@ -52,7 +60,8 @@ const ModelList: Component<{ }} > {(i) => ( -
+
+ {i.name} Free diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 13f2b00a3..2be8a21c1 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -33,6 +33,8 @@ import { useSync } from "@/context/sync" import { FileIcon } from "@opencode-ai/ui/file-icon" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import type { IconName } from "@opencode-ai/ui/icons/provider" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" @@ -1560,6 +1562,12 @@ export const PromptInput: Component = (props) => { fallback={ @@ -1569,6 +1577,12 @@ export const PromptInput: Component = (props) => { @@ -1583,10 +1597,10 @@ export const PromptInput: Component = (props) => { > diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 800795e87..c25b89af9 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -123,13 +123,13 @@ &[data-size="normal"] { height: 24px; + line-height: 24px; padding: 0 6px; &[data-icon] { padding: 0 12px 0 4px; } font-size: var(--font-size-small); - line-height: var(--line-height-large); gap: 6px; /* text-12-medium */ @@ -137,7 +137,6 @@ font-size: var(--font-size-small); font-style: normal; font-weight: var(--font-weight-medium); - line-height: var(--line-height-large); /* 166.667% */ letter-spacing: var(--letter-spacing-normal); } diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 1283b3023..8c92728d7 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -10,9 +10,15 @@ export interface ListSearchProps { autofocus?: boolean } +export interface ListGroup { + category: string + items: T[] +} + export interface ListProps extends FilteredListProps { class?: string children: (item: T) => JSX.Element + groupHeader?: (group: ListGroup) => JSX.Element emptyMessage?: string onKeyEvent?: (event: KeyboardEvent, item: T | undefined) => void onMove?: (item: T | undefined) => void @@ -116,7 +122,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) setScrollRef, }) - function GroupHeader(props: { category: string }): JSX.Element { + function GroupHeader(groupProps: { category: string; children?: JSX.Element }): JSX.Element { const [stuck, setStuck] = createSignal(false) const [header, setHeader] = createSignal(undefined) @@ -138,7 +144,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) return (
- {props.category} + {groupProps.children ?? groupProps.category}
) } @@ -185,7 +191,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) {(group) => (
- + {props.groupHeader?.(group)}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 9947578b9..ae1321bac 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -22,6 +22,8 @@ import { Accordion } from "./accordion" import { StickyAccordionHeader } from "./sticky-accordion-header" import { FileIcon } from "./file-icon" import { Icon } from "./icon" +import { ProviderIcon } from "./provider-icon" +import type { IconName } from "./provider-icons/types" import { IconButton } from "./icon-button" import { Tooltip } from "./tooltip" import { Card } from "./card" @@ -498,7 +500,13 @@ export function SessionTurn( {(msg() as UserMessage).agent} - {(msg() as UserMessage).model?.modelID} + + + {(msg() as UserMessage).model?.modelID} + {(msg() as UserMessage).variant || "default"}
From 20b52cad2add67fa49155b49cfe641d7c89715eb Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 13 Jan 2026 15:42:58 +0000 Subject: [PATCH 006/110] chore: generate --- packages/app/src/components/prompt-input.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2be8a21c1..f1ca3ee88 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1563,10 +1563,7 @@ export const PromptInput: Component = (props) => { + ) + }} + + +
+ + + +
+
+ {question()?.question} + {multi() ? " (select all that apply)" : ""} +
+
+ + {(opt, i) => { + const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false + return ( + + ) + }} + + + +
+ setTimeout(() => el.focus(), 0)} + type="text" + data-slot="custom-input" + placeholder="Type your answer..." + value={input()} + onInput={(e) => { + const inputs = [...store.custom] + inputs[store.tab] = e.currentTarget.value + setStore("custom", inputs) + }} + /> + + +
+
+
+
+
+ + +
+
Review your answers
+ + {(q, index) => { + const value = () => store.answers[index()]?.join(", ") ?? "" + const answered = () => Boolean(value()) + return ( +
+ {q.question} + + {answered() ? value() : "(not answered)"} + +
+ ) + }} +
+
+
+ +
+ + + + + + + + + +
+
+ ) +} diff --git a/packages/ui/src/context/data.tsx b/packages/ui/src/context/data.tsx index acab99fe8..dcb9adb39 100644 --- a/packages/ui/src/context/data.tsx +++ b/packages/ui/src/context/data.tsx @@ -1,4 +1,13 @@ -import type { Message, Session, Part, FileDiff, SessionStatus, PermissionRequest } from "@opencode-ai/sdk/v2" +import type { + Message, + Session, + Part, + FileDiff, + SessionStatus, + PermissionRequest, + QuestionRequest, + QuestionAnswer, +} from "@opencode-ai/sdk/v2" import { createSimpleContext } from "./helper" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -16,6 +25,9 @@ type Data = { permission?: { [sessionID: string]: PermissionRequest[] } + question?: { + [sessionID: string]: QuestionRequest[] + } message: { [sessionID: string]: Message[] } @@ -30,6 +42,10 @@ export type PermissionRespondFn = (input: { response: "once" | "always" | "reject" }) => void +export type QuestionReplyFn = (input: { requestID: string; answers: QuestionAnswer[] }) => void + +export type QuestionRejectFn = (input: { requestID: string }) => void + export type NavigateToSessionFn = (sessionID: string) => void export const { use: useData, provider: DataProvider } = createSimpleContext({ @@ -38,6 +54,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ data: Data directory: string onPermissionRespond?: PermissionRespondFn + onQuestionReply?: QuestionReplyFn + onQuestionReject?: QuestionRejectFn onNavigateToSession?: NavigateToSessionFn }) => { return { @@ -48,6 +66,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({ return props.directory }, respondToPermission: props.onPermissionRespond, + replyToQuestion: props.onQuestionReply, + rejectQuestion: props.onQuestionReject, navigateToSession: props.onNavigateToSession, } }, From 80e1173ef7907e978e36314a0d936de418be2903 Mon Sep 17 00:00:00 2001 From: Daniel Sauer <81422812+sauerdaniel@users.noreply.github.com> Date: Tue, 13 Jan 2026 19:38:34 +0100 Subject: [PATCH 014/110] fix(mcp): close existing client before reassignment to prevent leaks (#8253) --- packages/opencode/src/mcp/index.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index aca0c6631..4e0968391 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -266,6 +266,13 @@ export namespace MCP { status: s.status, } } + // Close existing client if present to prevent memory leaks + const existingClient = s.clients[name] + if (existingClient) { + await existingClient.close().catch((error) => { + log.error("Failed to close existing MCP client", { name, error }) + }) + } s.clients[name] = result.mcpClient s.status[name] = result.status @@ -523,6 +530,13 @@ export namespace MCP { const s = await state() s.status[name] = result.status if (result.mcpClient) { + // Close existing client if present to prevent memory leaks + const existingClient = s.clients[name] + if (existingClient) { + await existingClient.close().catch((error) => { + log.error("Failed to close existing MCP client", { name, error }) + }) + } s.clients[name] = result.mcpClient } } From b68a4a883819f841cba623d8dca531bc94268f63 Mon Sep 17 00:00:00 2001 From: Daniel Sauer <81422812+sauerdaniel@users.noreply.github.com> Date: Tue, 13 Jan 2026 19:43:16 +0100 Subject: [PATCH 015/110] fix(state): delete key from recordsByKey on instance disposal (#8252) --- packages/opencode/src/project/state.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/project/state.ts b/packages/opencode/src/project/state.ts index c1ac23c5d..34a5dbb3e 100644 --- a/packages/opencode/src/project/state.ts +++ b/packages/opencode/src/project/state.ts @@ -58,6 +58,7 @@ export namespace State { tasks.push(task) } entries.clear() + recordsByKey.delete(key) await Promise.all(tasks) disposalFinished = true log.info("state disposal completed", { key }) From 5947fe72e412311746c1fd8937035b8a5c5b4b37 Mon Sep 17 00:00:00 2001 From: Zeke Sikelianos Date: Tue, 13 Jan 2026 10:58:09 -0800 Subject: [PATCH 016/110] docs: document ~/.claude/CLAUDE.md compatibility behavior (#8268) --- packages/web/src/content/docs/rules.mdx | 29 ++++++++++++++++++++----- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/web/src/content/docs/rules.mdx b/packages/web/src/content/docs/rules.mdx index 2d02ff47f..3a170019a 100644 --- a/packages/web/src/content/docs/rules.mdx +++ b/packages/web/src/content/docs/rules.mdx @@ -3,7 +3,7 @@ title: Rules description: Set custom instructions for opencode. --- -You can provide custom instructions to opencode by creating an `AGENTS.md` file. This is similar to `CLAUDE.md` or Cursor's rules. It contains instructions that will be included in the LLM's context to customize its behavior for your specific project. +You can provide custom instructions to opencode by creating an `AGENTS.md` file. This is similar to Cursor's rules. It contains instructions that will be included in the LLM's context to customize its behavior for your specific project. --- @@ -58,7 +58,7 @@ opencode also supports reading the `AGENTS.md` file from multiple locations. And ### Project -The ones we have seen above, where the `AGENTS.md` is placed in the project root, are project-specific rules. These only apply when you are working in this directory or its sub-directories. +Place an `AGENTS.md` in your project root for project-specific rules. These only apply when you are working in this directory or its sub-directories. ### Global @@ -66,16 +66,33 @@ You can also have global rules in a `~/.config/opencode/AGENTS.md` file. This ge Since this isn't committed to Git or shared with your team, we recommend using this to specify any personal rules that the LLM should follow. +### Claude Code Compatibility + +For users migrating from Claude Code, OpenCode supports Claude Code's file conventions as fallbacks: + +- **Project rules**: `CLAUDE.md` in your project directory (used if no `AGENTS.md` exists) +- **Global rules**: `~/.claude/CLAUDE.md` (used if no `~/.config/opencode/AGENTS.md` exists) +- **Skills**: `~/.claude/skills/` — see [Agent Skills](/docs/skills/) for details + +To disable Claude Code compatibility, set one of these environment variables: + +```bash +export OPENCODE_DISABLE_CLAUDE_CODE=1 # Disable all .claude support +export OPENCODE_DISABLE_CLAUDE_CODE_PROMPT=1 # Disable only ~/.claude/CLAUDE.md +export OPENCODE_DISABLE_CLAUDE_CODE_SKILLS=1 # Disable only .claude/skills +``` + --- ## Precedence -So when opencode starts, it looks for: +When opencode starts, it looks for rule files in this order: -1. **Local files** by traversing up from the current directory -2. **Global file** by checking `~/.config/opencode/AGENTS.md` +1. **Local files** by traversing up from the current directory (`AGENTS.md`, `CLAUDE.md`, or `CONTEXT.md`) +2. **Global file** at `~/.config/opencode/AGENTS.md` +3. **Claude Code file** at `~/.claude/CLAUDE.md` (unless disabled) -If you have both global and project-specific rules, opencode will combine them together. +The first matching file wins in each category. For example, if you have both `AGENTS.md` and `CLAUDE.md`, only `AGENTS.md` is used. Similarly, `~/.config/opencode/AGENTS.md` takes precedence over `~/.claude/CLAUDE.md`. --- From 05867f9318498e7ec817d365f7300dd135f77c38 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Tue, 13 Jan 2026 20:21:39 +0100 Subject: [PATCH 017/110] feat: Add GitLab Duo Agentic Chat Provider Support (#7333) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Aiden Cline --- bun.lock | 37 +++ packages/opencode/package.json | 1 + packages/opencode/src/plugin/index.ts | 7 +- packages/opencode/src/provider/provider.ts | 41 +++ .../test/provider/amazon-bedrock.test.ts | 7 +- .../opencode/test/provider/gitlab-duo.test.ts | 286 ++++++++++++++++++ packages/web/src/content/docs/providers.mdx | 93 ++++++ 7 files changed, 470 insertions(+), 2 deletions(-) create mode 100644 packages/opencode/test/provider/gitlab-duo.test.ts diff --git a/bun.lock b/bun.lock index 0a2868811..a537fa661 100644 --- a/bun.lock +++ b/bun.lock @@ -276,6 +276,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", + "@gitlab/gitlab-ai-provider": "3.1.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", @@ -586,6 +587,10 @@ "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="], + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.71.2", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ=="], + + "@anycable/core": ["@anycable/core@0.9.2", "", { "dependencies": { "nanoevents": "^7.0.1" } }, "sha512-x5ZXDcW/N4cxWl93CnbHs/u7qq4793jS2kNPWm+duPrXlrva+ml2ZGT7X9tuOBKzyIHf60zWCdIK7TUgMPAwXA=="], + "@astrojs/cloudflare": ["@astrojs/cloudflare@12.6.3", "", { "dependencies": { "@astrojs/internal-helpers": "0.7.1", "@astrojs/underscore-redirects": "1.0.0", "@cloudflare/workers-types": "^4.20250507.0", "tinyglobby": "^0.2.13", "vite": "^6.3.5", "wrangler": "^4.14.1" }, "peerDependencies": { "astro": "^5.0.0" } }, "sha512-xhJptF5tU2k5eo70nIMyL1Udma0CqmUEnGSlGyFflLqSY82CRQI6nWZ/xZt0ZvmXuErUjIx0YYQNfZsz5CNjLQ=="], "@astrojs/compiler": ["@astrojs/compiler@2.13.0", "", {}, "sha512-mqVORhUJViA28fwHYaWmsXSzLO9osbdZ5ImUfxBarqsYdMlPbqAqGJCxsNzvppp1BEzc1mJNjOVvQqeDN8Vspw=="], @@ -906,6 +911,10 @@ "@fontsource/inter": ["@fontsource/inter@5.2.8", "", {}, "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg=="], + "@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.1.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-S0MVXsogrwbOboA/8L0CY5sBXg2HrrO8gdeUeHd9yLZDPsggFD0FzcSuzO5vBO6geUOpruRa8Hqrbb6WWu7Frw=="], + + "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], + "@happy-dom/global-registrator": ["@happy-dom/global-registrator@20.0.11", "", { "dependencies": { "@types/node": "^20.0.0", "happy-dom": "^20.0.11" } }, "sha512-GqNqiShBT/lzkHTMC/slKBrvN0DsD4Di8ssBk4aDaVgEn+2WMzE6DXxq701ndSXj7/0cJ8mNT71pM7Bnrr6JRw=="], "@hey-api/codegen-core": ["@hey-api/codegen-core@0.3.3", "", { "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-vArVDtrvdzFewu1hnjUm4jX1NBITlSCeO81EdWq676MxQbyxsGcDPAgohaSA+Wvr4HjPSvsg2/1s2zYxUtXebg=="], @@ -1600,6 +1609,8 @@ "@smithy/uuid": ["@smithy/uuid@1.1.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw=="], + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + "@solid-primitives/active-element": ["@solid-primitives/active-element@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-9t5K4aR2naVDj950XU8OjnLgOg94a8k5wr6JNOPK+N5ESLsJDq42c1ZP8UKpewi1R+wplMMxiM6OPKRzbxJY7A=="], "@solid-primitives/audio": ["@solid-primitives/audio@1.4.2", "", { "dependencies": { "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-UMD3ORQfI5Ky8yuKPxidDiEazsjv/dsoiKK5yZxLnsgaeNR1Aym3/77h/qT1jBYeXUgj4DX6t7NMpFUSVr14OQ=="], @@ -2318,6 +2329,10 @@ "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "engine.io-client": ["engine.io-client@6.6.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-parser": "~5.2.1", "ws": "~8.18.3", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + "enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="], "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -2540,6 +2555,10 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "graphql": ["graphql@16.12.0", "", {}, "sha512-DKKrynuQRne0PNpEbzuEdHlYOMksHSUI8Zc9Unei5gTsMNA2/vMpoMz/yKba50pejK56qj98qM0SjYxAKi13gQ=="], + + "graphql-request": ["graphql-request@6.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.2.0", "cross-fetch": "^3.1.5" }, "peerDependencies": { "graphql": "14 - 16" } }, "sha512-p+XPfS4q7aIpKVcgmnZKhMNqhltk20hfXtkaIkTfjjmiKMJ5xrt5c743cL03y/K7y1rg3WrIC49xGiEQ4mxdNw=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], @@ -2768,6 +2787,8 @@ "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], + "isomorphic-ws": ["isomorphic-ws@5.0.0", "", { "peerDependencies": { "ws": "*" } }, "sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw=="], + "iterate-iterator": ["iterate-iterator@1.0.2", "", {}, "sha512-t91HubM4ZDQ70M9wqp+pcNpu8OyJ9UAtXntT/Bcsvp5tZMnz9vRa+IunKXeI8AnfZMTv0jNuVEmGeLSMjVvfPw=="], "iterate-value": ["iterate-value@1.0.2", "", { "dependencies": { "es-get-iterator": "^1.0.2", "iterate-iterator": "^1.0.1" } }, "sha512-A6fMAio4D2ot2r/TYzr4yUWrmwNdsN5xL7+HUiyACE4DXm+q8HtPcnFTp+NnW3k4N05tZ7FVYFFb2CR13NxyHQ=="], @@ -2800,6 +2821,8 @@ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], @@ -3076,6 +3099,8 @@ "named-placeholders": ["named-placeholders@1.1.3", "", { "dependencies": { "lru-cache": "^7.14.1" } }, "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w=="], + "nanoevents": ["nanoevents@7.0.1", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="], + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], @@ -3518,6 +3543,10 @@ "smol-toml": ["smol-toml@1.5.2", "", {}, "sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ=="], + "socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="], + + "socket.io-parser": ["socket.io-parser@4.2.5", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1" } }, "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ=="], + "solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="], "solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="], @@ -3682,6 +3711,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], "tsconfck": ["tsconfck@3.1.6", "", { "peerDependencies": { "typescript": "^5.0.0" }, "optionalPeers": ["typescript"], "bin": { "tsconfck": "bin/tsconfck.js" } }, "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w=="], @@ -3874,6 +3905,8 @@ "xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="], + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + "xxhash-wasm": ["xxhash-wasm@1.1.0", "", {}, "sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], @@ -4024,6 +4057,8 @@ "@expressive-code/plugin-shiki/shiki": ["shiki@3.15.0", "", { "dependencies": { "@shikijs/core": "3.15.0", "@shikijs/engine-javascript": "3.15.0", "@shikijs/engine-oniguruma": "3.15.0", "@shikijs/langs": "3.15.0", "@shikijs/themes": "3.15.0", "@shikijs/types": "3.15.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kLdkY6iV3dYbtPwS9KXU7mjfmDm25f5m0IPNFnaXO7TBPcvbUOY72PYXSuSqDzwp+vlH/d7MXpHlKO/x+QoLXw=="], + "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], @@ -4266,6 +4301,8 @@ "editorconfig/minimatch": ["minimatch@9.0.1", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w=="], + "engine.io-client/ws": ["ws@8.18.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg=="], + "es-get-iterator/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "esbuild-plugin-copy/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index f2c95d0b3..c0c4e79b6 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -70,6 +70,7 @@ "@ai-sdk/vercel": "1.0.31", "@ai-sdk/xai": "2.0.51", "@clack/prompts": "1.0.0-alpha.1", + "@gitlab/gitlab-ai-provider": "3.1.0", "@hono/standard-validator": "0.1.5", "@hono/zod-validator": "catalog:", "@modelcontextprotocol/sdk": "1.25.2", diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index b0c9eee2c..8ce6dfd3c 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -14,7 +14,11 @@ import { NamedError } from "@opencode-ai/util/error" export namespace Plugin { const log = Log.create({ service: "plugin" }) - const BUILTIN = ["opencode-copilot-auth@0.0.12", "opencode-anthropic-auth@0.0.8"] + const BUILTIN = [ + "opencode-copilot-auth@0.0.12", + "opencode-anthropic-auth@0.0.8", + "@gitlab/opencode-gitlab-auth@1.3.0", + ] // Built-in plugins that are directly imported (not installed from npm) const INTERNAL_PLUGINS: PluginInstance[] = [CodexAuthPlugin] @@ -46,6 +50,7 @@ export namespace Plugin { if (!Flag.OPENCODE_DISABLE_DEFAULT_PLUGINS) { plugins.push(...BUILTIN) } + for (let plugin of plugins) { // ignore old codex plugin since it is supported first party now if (plugin.includes("opencode-openai-codex-auth")) continue diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 3b76b1e02..9bde1333e 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1,4 +1,6 @@ import z from "zod" +import path from "path" +import os from "os" import fuzzysort from "fuzzysort" import { Config } from "../config/config" import { mapValues, mergeDeep, omit, pickBy, sortBy } from "remeda" @@ -35,6 +37,7 @@ import { createGateway } from "@ai-sdk/gateway" import { createTogetherAI } from "@ai-sdk/togetherai" import { createPerplexity } from "@ai-sdk/perplexity" import { createVercel } from "@ai-sdk/vercel" +import { createGitLab } from "@gitlab/gitlab-ai-provider" import { ProviderTransform } from "./transform" export namespace Provider { @@ -60,6 +63,7 @@ export namespace Provider { "@ai-sdk/togetherai": createTogetherAI, "@ai-sdk/perplexity": createPerplexity, "@ai-sdk/vercel": createVercel, + "@gitlab/gitlab-ai-provider": createGitLab, // @ts-ignore (TODO: kill this code so we dont have to maintain it) "@ai-sdk/github-copilot": createGitHubCopilotOpenAICompatible, } @@ -390,6 +394,43 @@ export namespace Provider { }, } }, + async gitlab(input) { + const instanceUrl = Env.get("GITLAB_INSTANCE_URL") || "https://gitlab.com" + + const auth = await Auth.get(input.id) + const apiKey = await (async () => { + if (auth?.type === "oauth") return auth.access + if (auth?.type === "api") return auth.key + return Env.get("GITLAB_TOKEN") + })() + + const config = await Config.get() + const providerConfig = config.provider?.["gitlab"] + + return { + autoload: !!apiKey, + options: { + instanceUrl, + apiKey, + featureFlags: { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + ...(providerConfig?.options?.featureFlags || {}), + }, + }, + async getModel(sdk: ReturnType, modelID: string, options?: { anthropicModel?: string }) { + const anthropicModel = options?.anthropicModel + return sdk.agenticChat(modelID, { + anthropicModel, + featureFlags: { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + ...(providerConfig?.options?.featureFlags || {}), + }, + }) + }, + } + }, "cloudflare-ai-gateway": async (input) => { const accountId = Env.get("CLOUDFLARE_ACCOUNT_ID") const gateway = Env.get("CLOUDFLARE_GATEWAY_ID") diff --git a/packages/opencode/test/provider/amazon-bedrock.test.ts b/packages/opencode/test/provider/amazon-bedrock.test.ts index d10e85139..05f5bd01f 100644 --- a/packages/opencode/test/provider/amazon-bedrock.test.ts +++ b/packages/opencode/test/provider/amazon-bedrock.test.ts @@ -9,7 +9,11 @@ import path from "path" mock.module("../../src/bun/index", () => ({ BunProc: { - install: async (pkg: string) => pkg, + install: async (pkg: string, _version?: string) => { + // Return package name without version for mocking + const lastAtIndex = pkg.lastIndexOf("@") + return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg + }, run: async () => { throw new Error("BunProc.run should not be called in tests") }, @@ -28,6 +32,7 @@ mock.module("@aws-sdk/credential-providers", () => ({ const mockPlugin = () => ({}) mock.module("opencode-copilot-auth", () => ({ default: mockPlugin })) mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin })) +mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin })) // Import after mocks are set up const { tmpdir } = await import("../fixture/fixture") diff --git a/packages/opencode/test/provider/gitlab-duo.test.ts b/packages/opencode/test/provider/gitlab-duo.test.ts new file mode 100644 index 000000000..4d5aa9c74 --- /dev/null +++ b/packages/opencode/test/provider/gitlab-duo.test.ts @@ -0,0 +1,286 @@ +import { test, expect, mock } from "bun:test" +import path from "path" + +// === Mocks === +// These mocks prevent real package installations during tests + +mock.module("../../src/bun/index", () => ({ + BunProc: { + install: async (pkg: string, _version?: string) => { + // Return package name without version for mocking + const lastAtIndex = pkg.lastIndexOf("@") + return lastAtIndex > 0 ? pkg.substring(0, lastAtIndex) : pkg + }, + run: async () => { + throw new Error("BunProc.run should not be called in tests") + }, + which: () => process.execPath, + InstallFailedError: class extends Error {}, + }, +})) + +const mockPlugin = () => ({}) +mock.module("opencode-copilot-auth", () => ({ default: mockPlugin })) +mock.module("opencode-anthropic-auth", () => ({ default: mockPlugin })) +mock.module("@gitlab/opencode-gitlab-auth", () => ({ default: mockPlugin })) + +// Import after mocks are set up +const { tmpdir } = await import("../fixture/fixture") +const { Instance } = await import("../../src/project/instance") +const { Provider } = await import("../../src/provider/provider") +const { Env } = await import("../../src/env") +const { Global } = await import("../../src/global") + +test("GitLab Duo: loads provider with API key from environment", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-gitlab-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + expect(providers["gitlab"].key).toBe("test-gitlab-token") + }, + }) +}) + +test("GitLab Duo: config instanceUrl option sets baseURL", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + gitlab: { + options: { + instanceUrl: "https://gitlab.example.com", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-token") + Env.set("GITLAB_INSTANCE_URL", "https://gitlab.example.com") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.example.com") + }, + }) +}) + +test("GitLab Duo: loads with OAuth token from auth.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + + const authPath = path.join(Global.Path.data, "auth.json") + await Bun.write( + authPath, + JSON.stringify({ + gitlab: { + type: "oauth", + access: "test-access-token", + refresh: "test-refresh-token", + expires: Date.now() + 3600000, + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + }, + }) +}) + +test("GitLab Duo: loads with Personal Access Token from auth.json", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + + const authPath2 = path.join(Global.Path.data, "auth.json") + await Bun.write( + authPath2, + JSON.stringify({ + gitlab: { + type: "api", + key: "glpat-test-pat-token", + }, + }), + ) + + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + expect(providers["gitlab"].key).toBe("glpat-test-pat-token") + }, + }) +}) + +test("GitLab Duo: supports self-hosted instance configuration", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + gitlab: { + options: { + instanceUrl: "https://gitlab.company.internal", + apiKey: "glpat-internal-token", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_INSTANCE_URL", "https://gitlab.company.internal") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + expect(providers["gitlab"].options?.instanceUrl).toBe("https://gitlab.company.internal") + }, + }) +}) + +test("GitLab Duo: config apiKey takes precedence over environment variable", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + gitlab: { + options: { + apiKey: "config-token", + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "env-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + }, + }) +}) + +test("GitLab Duo: supports feature flags configuration", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + gitlab: { + options: { + featureFlags: { + duo_agent_platform_agentic_chat: true, + duo_agent_platform: true, + }, + }, + }, + }, + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + expect(providers["gitlab"].options?.featureFlags).toBeDefined() + expect(providers["gitlab"].options?.featureFlags?.duo_agent_platform_agentic_chat).toBe(true) + }, + }) +}) + +test("GitLab Duo: has multiple agentic chat models available", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITLAB_TOKEN", "test-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["gitlab"]).toBeDefined() + const models = Object.keys(providers["gitlab"].models) + expect(models.length).toBeGreaterThan(0) + expect(models).toContain("duo-chat-haiku-4-5") + expect(models).toContain("duo-chat-sonnet-4-5") + expect(models).toContain("duo-chat-opus-4-5") + }, + }) +}) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 80c6f89e1..7af4ab85d 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -557,6 +557,99 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, --- +### GitLab Duo + +GitLab Duo provides AI-powered agentic chat with native tool calling capabilities through GitLab's Anthropic proxy. + +1. Run the `/connect` command and select GitLab. + + ```txt + /connect + ``` + +2. Choose your authentication method: + + ```txt + ┌ Select auth method + │ + │ OAuth (Recommended) + │ Personal Access Token + └ + ``` + + #### Using OAuth (Recommended) + + Select **OAuth** and your browser will open for authorization. + + #### Using Personal Access Token + 1. Go to [GitLab User Settings > Access Tokens](https://gitlab.com/-/user_settings/personal_access_tokens) + 2. Click **Add new token** + 3. Name: `OpenCode`, Scopes: `api` + 4. Copy the token (starts with `glpat-`) + 5. Enter it in the terminal + +3. Run the `/models` command to see available models. + + ```txt + /models + ``` + + Three Claude-based models are available: + - **duo-chat-haiku-4-5** (Default) - Fast responses for quick tasks + - **duo-chat-sonnet-4-5** - Balanced performance for most workflows + - **duo-chat-opus-4-5** - Most capable for complex analysis + +##### Self-Hosted GitLab + +For self-hosted GitLab instances: + +```bash +GITLAB_INSTANCE_URL=https://gitlab.company.com GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx opencode +``` + +Or add to your bash profile: + +```bash title="~/.bash_profile" +export GITLAB_INSTANCE_URL=https://gitlab.company.com +export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx +``` + +##### Configuration + +Customize through `opencode.json`: + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "gitlab": { + "options": { + "instanceUrl": "https://gitlab.com", + "featureFlags": { + "duo_agent_platform_agentic_chat": true, + "duo_agent_platform": true + } + } + } + } +} +``` + +##### GitLab API Tools (Optional) + +To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.): + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "plugin": ["@gitlab/opencode-gitlab-plugin"] +} +``` + +This plugin provides comprehensive GitLab repository management capabilities including MR reviews, issue tracking, pipeline monitoring, and more. + +--- + ### GitHub Copilot To use your GitHub Copilot subscription with opencode: From 797a56873dd70c52caec607dbd6d239af5c92d18 Mon Sep 17 00:00:00 2001 From: Dillon Mulroy Date: Tue, 13 Jan 2026 14:22:26 -0500 Subject: [PATCH 018/110] fix(cli): mcp auth duplicate radio button icon (#8273) --- packages/opencode/src/cli/cmd/mcp.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index cfb54081f..cdd741fbc 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -21,7 +21,7 @@ function getAuthStatusIcon(status: MCP.AuthStatus): string { case "expired": return "⚠" case "not_authenticated": - return "○" + return "✗" } } From 1258f7aeea53fa99efdb722407dd1c80bed4dbd8 Mon Sep 17 00:00:00 2001 From: Github Action Date: Tue, 13 Jan 2026 19:22:48 +0000 Subject: [PATCH 019/110] Update Nix flake.lock and x86_64-linux hash --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index 4c953c5a8..0bf4aa627 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { - "x86_64-linux": "sha256-UCPTTk4b7d2bets7KgCeYBHWAUwUAPUyKm+xDYkSexE=", + "x86_64-linux": "sha256-x6A/XT1i3bjakfAj0A1wV4n2s9rpflMDceTeppdP6tE=", "aarch64-darwin": "sha256-Y3o6lovahSWoG9un/l1qxu7hCmIlZXm2LxOLKNiPQfQ=" } } From 3a750b08090a593ae49a90cf262049c9f4d45bfd Mon Sep 17 00:00:00 2001 From: Github Action Date: Tue, 13 Jan 2026 19:29:19 +0000 Subject: [PATCH 020/110] Update aarch64-darwin hash --- nix/hashes.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/hashes.json b/nix/hashes.json index 0bf4aa627..a25b9376e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { "x86_64-linux": "sha256-x6A/XT1i3bjakfAj0A1wV4n2s9rpflMDceTeppdP6tE=", - "aarch64-darwin": "sha256-Y3o6lovahSWoG9un/l1qxu7hCmIlZXm2LxOLKNiPQfQ=" + "aarch64-darwin": "sha256-RkamQYbpjJqpHHf76em9lPgeI9k4/kaCf7T+4xHaizY=" } } From 96ae5925c324767662ee2a76a1ec866ba9bf3bc0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 13 Jan 2026 13:33:58 -0600 Subject: [PATCH 021/110] tweak: ensure external dir and bash tool invocations render workdir details --- .../src/cli/cmd/tui/routes/session/index.tsx | 29 ++++++++++++++++++- .../cli/cmd/tui/routes/session/permission.tsx | 18 +++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 10e340d7f..f87b811ae 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -69,6 +69,7 @@ import { Footer } from "./footer.tsx" import { usePromptRef } from "../../context/prompt" import { useExit } from "../../context/exit" import { Filesystem } from "@/util/filesystem" +import { Global } from "@/global" import { PermissionPrompt } from "./permission" import { QuestionPrompt } from "./question" import { DialogExportOptions } from "../../ui/dialog-export-options" @@ -1525,6 +1526,7 @@ function BlockTool(props: { title: string; children: JSX.Element; onClick?: () = function Bash(props: ToolProps) { const { theme } = useTheme() + const sync = useSync() const output = createMemo(() => stripAnsi(props.metadata.output?.trim() ?? "")) const [expanded, setExpanded] = createSignal(false) const lines = createMemo(() => output().split("\n")) @@ -1534,11 +1536,36 @@ function Bash(props: ToolProps) { return [...lines().slice(0, 10), "…"].join("\n") }) + const workdirDisplay = createMemo(() => { + const workdir = props.input.workdir + if (!workdir || workdir === ".") return undefined + + const base = sync.data.path.directory + if (!base) return undefined + + const absolute = path.resolve(base, workdir) + if (absolute === base) return undefined + + const home = Global.Path.home + if (!home) return absolute + + const match = absolute === home || absolute.startsWith(home + path.sep) + return match ? absolute.replace(home, "~") : absolute + }) + + const title = createMemo(() => { + const desc = props.input.description ?? "Shell" + const wd = workdirDisplay() + if (!wd) return `# ${desc}` + if (desc.includes(wd)) return `# ${desc}` + return `# ${desc} in ${wd}` + }) + return ( setExpanded((prev) => !prev) : undefined} > diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index c95b42260..9cde65d2e 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -226,7 +226,23 @@ export function PermissionPrompt(props: { request: PermissionRequest }) { - + {(() => { + const meta = props.request.metadata ?? {} + const parent = typeof meta["parentDir"] === "string" ? meta["parentDir"] : undefined + const filepath = typeof meta["filepath"] === "string" ? meta["filepath"] : undefined + const pattern = props.request.patterns?.[0] + const derived = + typeof pattern === "string" + ? pattern.includes("*") + ? path.dirname(pattern) + : pattern + : undefined + + const raw = parent ?? filepath ?? derived + const dir = normalizePath(raw) + + return + })()} From 33ba064c40925670b4b4a1286e2afd5ef26df862 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 13 Jan 2026 13:52:09 -0600 Subject: [PATCH 022/110] tweak: external dir permission rendering in tui --- .../cli/cmd/tui/routes/session/permission.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx index 9cde65d2e..eab2adb10 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/permission.tsx @@ -13,15 +13,26 @@ import path from "path" import { LANGUAGE_EXTENSIONS } from "@/lsp/language" import { Keybind } from "@/util/keybind" import { Locale } from "@/util/locale" +import { Global } from "@/global" type PermissionStage = "permission" | "always" | "reject" function normalizePath(input?: string) { if (!input) return "" - if (path.isAbsolute(input)) { - return path.relative(process.cwd(), input) || "." + + const cwd = process.cwd() + const home = Global.Path.home + const absolute = path.isAbsolute(input) ? input : path.resolve(cwd, input) + const relative = path.relative(cwd, absolute) + + if (!relative) return "." + if (!relative.startsWith("..")) return relative + + // outside cwd - use ~ or absolute + if (home && (absolute === home || absolute.startsWith(home + path.sep))) { + return absolute.replace(home, "~") } - return input + return absolute } function filetype(input?: string) { From 1550ae47c0beb02a6d3a7162a00465c0207ac50f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 13 Jan 2026 13:57:34 -0600 Subject: [PATCH 023/110] add family to gpt 5.2 codex in codex plugin --- packages/opencode/src/plugin/codex.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 4e2b28379..91e66197f 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -387,6 +387,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { headers: {}, release_date: "2025-12-18", variants: {} as Record>, + family: "gpt-codex", } model.variants = ProviderTransform.variants(model) provider.models["gpt-5.2-codex"] = model From 66b7a4991ee5903d0239c0d7b98c95b9c5f9e43c Mon Sep 17 00:00:00 2001 From: Joe Harrison <22684038+josephbharrison@users.noreply.github.com> Date: Tue, 13 Jan 2026 15:06:38 -0500 Subject: [PATCH 024/110] fix(prompt-input): handle Shift+Enter before IME check to prevent stuck state (#8275) --- packages/app/src/components/prompt-input.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f1ca3ee88..2f85652a9 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -364,6 +364,12 @@ export const PromptInput: Component = (props) => { if (!isFocused()) setStore("popover", null) }) + // Safety: reset composing state on focus change to prevent stuck state + // This handles edge cases where compositionend event may not fire + createEffect(() => { + if (!isFocused()) setComposing(false) + }) + type AtOption = { type: "agent"; name: string; display: string } | { type: "file"; path: string; display: string } const agentList = createMemo(() => @@ -881,6 +887,14 @@ export const PromptInput: Component = (props) => { } } + // Handle Shift+Enter BEFORE IME check - Shift+Enter is never used for IME input + // and should always insert a newline regardless of composition state + if (event.key === "Enter" && event.shiftKey) { + addPart({ type: "text", content: "\n", start: 0, end: 0 }) + event.preventDefault() + return + } + if (event.key === "Enter" && isImeComposing(event)) { return } @@ -944,11 +958,7 @@ export const PromptInput: Component = (props) => { return } - if (event.key === "Enter" && event.shiftKey) { - addPart({ type: "text", content: "\n", start: 0, end: 0 }) - event.preventDefault() - return - } + // Note: Shift+Enter is handled earlier, before IME check if (event.key === "Enter" && !event.shiftKey) { handleSubmit(event) } From 0a3c72d6787aa3cf39b9517e32f0ad5d8dbb6184 Mon Sep 17 00:00:00 2001 From: Dax Date: Tue, 13 Jan 2026 15:55:48 -0500 Subject: [PATCH 025/110] feat: add plan mode with enter/exit tools (#8281) --- .../plans/1768330644696-gentle-harbor.md | 320 ++++++++++++++++++ packages/opencode/src/agent/agent.ts | 6 +- .../src/cli/cmd/tui/routes/session/index.tsx | 17 + .../cli/cmd/tui/routes/session/question.tsx | 61 ++-- packages/opencode/src/flag/flag.ts | 1 + packages/opencode/src/question/index.ts | 1 + packages/opencode/src/session/index.ts | 9 + packages/opencode/src/session/prompt.ts | 143 +++++++- .../src/session/prompt/build-switch.txt | 2 + packages/opencode/src/tool/plan-enter.txt | 14 + packages/opencode/src/tool/plan-exit.txt | 13 + packages/opencode/src/tool/plan.ts | 130 +++++++ packages/opencode/src/tool/registry.ts | 2 + packages/opencode/test/util/lock.test.ts | 72 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 5 + packages/util/src/slug.ts | 74 ++++ 16 files changed, 824 insertions(+), 46 deletions(-) create mode 100644 .opencode/plans/1768330644696-gentle-harbor.md create mode 100644 packages/opencode/src/tool/plan-enter.txt create mode 100644 packages/opencode/src/tool/plan-exit.txt create mode 100644 packages/opencode/src/tool/plan.ts create mode 100644 packages/opencode/test/util/lock.test.ts create mode 100644 packages/util/src/slug.ts diff --git a/.opencode/plans/1768330644696-gentle-harbor.md b/.opencode/plans/1768330644696-gentle-harbor.md new file mode 100644 index 000000000..9e3e668b4 --- /dev/null +++ b/.opencode/plans/1768330644696-gentle-harbor.md @@ -0,0 +1,320 @@ +# Plan: Implement enter_plan and exit_plan Tools + +## Summary + +The plan mode workflow in `prompt.ts` references `exit_plan` tool that doesn't exist. We need to implement two tools: + +1. **`exit_plan`** - Called when the AI finishes planning; uses the Question module to ask the user if they want to switch to build mode (yes/no). **Only available in plan mode.** If user says yes, creates a synthetic user message with the "build" agent to trigger the mode switch in the loop. +2. **`enter_plan`** - Called to enter plan mode. **Only available in build mode.** If user says yes, creates a synthetic user message with the "plan" agent. + +## Key Insight: How Mode Switching Works + +Looking at `prompt.ts:455-478`, the session loop determines the current agent from the last user message's `agent` field (line 510: `const agent = await Agent.get(lastUser.agent)`). + +To switch modes, we need to: + +1. Ask the user for confirmation +2. If confirmed, create a synthetic user message with the **new agent** specified +3. The loop will pick up this new user message and use the new agent + +## Files to Modify + +| File | Action | +| ------------------------------------------ | --------------------------------------------------------------- | +| `packages/opencode/src/tool/plan.ts` | **CREATE** - New file with both tools | +| `packages/opencode/src/tool/exitplan.txt` | **CREATE** - Description for exit_plan tool | +| `packages/opencode/src/tool/enterplan.txt` | **CREATE** - Description for enter_plan tool | +| `packages/opencode/src/tool/registry.ts` | **MODIFY** - Register the new tools | +| `packages/opencode/src/agent/agent.ts` | **MODIFY** - Add permission rules to restrict tool availability | + +## Implementation Details + +### 1. Create `packages/opencode/src/tool/plan.ts` + +```typescript +import z from "zod" +import { Tool } from "./tool" +import { Question } from "../question" +import { Session } from "../session" +import { MessageV2 } from "../session/message-v2" +import { Identifier } from "../id/id" +import { Provider } from "../provider/provider" +import EXIT_DESCRIPTION from "./exitplan.txt" +import ENTER_DESCRIPTION from "./enterplan.txt" + +export const ExitPlanTool = Tool.define("exit_plan", { + description: EXIT_DESCRIPTION, + parameters: z.object({}), + async execute(_params, ctx) { + const answers = await Question.ask({ + sessionID: ctx.sessionID, + questions: [ + { + question: "Planning is complete. Would you like to switch to build mode and start implementing?", + header: "Build Mode", + options: [ + { label: "Yes", description: "Switch to build mode and start implementing the plan" }, + { label: "No", description: "Stay in plan mode to continue refining the plan" }, + ], + }, + ], + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + + const answer = answers[0]?.[0] + const shouldSwitch = answer === "Yes" + + // If user wants to switch, create a synthetic user message with the new agent + if (shouldSwitch) { + // Get model from the last user message in the session + const model = await getLastModel(ctx.sessionID) + + const userMsg: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID: ctx.sessionID, + role: "user", + time: { + created: Date.now(), + }, + agent: "build", // Switch to build agent + model, + } + await Session.updateMessage(userMsg) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: ctx.sessionID, + type: "text", + text: "User has approved the plan. Switch to build mode and begin implementing the plan.", + synthetic: true, + } satisfies MessageV2.TextPart) + } + + return { + title: shouldSwitch ? "Switching to build mode" : "Staying in plan mode", + output: shouldSwitch + ? "User confirmed to switch to build mode. A new message has been created to switch you to build mode. Begin implementing the plan." + : "User chose to stay in plan mode. Continue refining the plan or address any concerns.", + metadata: { + switchToBuild: shouldSwitch, + answer, + }, + } + }, +}) + +export const EnterPlanTool = Tool.define("enter_plan", { + description: ENTER_DESCRIPTION, + parameters: z.object({}), + async execute(_params, ctx) { + const answers = await Question.ask({ + sessionID: ctx.sessionID, + questions: [ + { + question: + "Would you like to switch to plan mode? In plan mode, the AI will only research and create a plan without making changes.", + header: "Plan Mode", + options: [ + { label: "Yes", description: "Switch to plan mode for research and planning" }, + { label: "No", description: "Stay in build mode to continue making changes" }, + ], + }, + ], + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + + const answer = answers[0]?.[0] + const shouldSwitch = answer === "Yes" + + // If user wants to switch, create a synthetic user message with the new agent + if (shouldSwitch) { + const model = await getLastModel(ctx.sessionID) + + const userMsg: MessageV2.User = { + id: Identifier.ascending("message"), + sessionID: ctx.sessionID, + role: "user", + time: { + created: Date.now(), + }, + agent: "plan", // Switch to plan agent + model, + } + await Session.updateMessage(userMsg) + await Session.updatePart({ + id: Identifier.ascending("part"), + messageID: userMsg.id, + sessionID: ctx.sessionID, + type: "text", + text: "User has requested to enter plan mode. Switch to plan mode and begin planning.", + synthetic: true, + } satisfies MessageV2.TextPart) + } + + return { + title: shouldSwitch ? "Switching to plan mode" : "Staying in build mode", + output: shouldSwitch + ? "User confirmed to switch to plan mode. A new message has been created to switch you to plan mode. Begin planning." + : "User chose to stay in build mode. Continue with the current task.", + metadata: { + switchToPlan: shouldSwitch, + answer, + }, + } + }, +}) + +// Helper to get the model from the last user message +async function getLastModel(sessionID: string) { + for await (const item of MessageV2.stream(sessionID)) { + if (item.info.role === "user" && item.info.model) return item.info.model + } + return Provider.defaultModel() +} +``` + +### 2. Create `packages/opencode/src/tool/exitplan.txt` + +``` +Use this tool when you have completed the planning phase and are ready to exit plan mode. + +This tool will ask the user if they want to switch to build mode to start implementing the plan. + +Call this tool: +- After you have written a complete plan to the plan file +- After you have clarified any questions with the user +- When you are confident the plan is ready for implementation + +Do NOT call this tool: +- Before you have created or finalized the plan +- If you still have unanswered questions about the implementation +- If the user has indicated they want to continue planning +``` + +### 3. Create `packages/opencode/src/tool/enterplan.txt` + +``` +Use this tool to suggest entering plan mode when the user's request would benefit from planning before implementation. + +This tool will ask the user if they want to switch to plan mode. + +Call this tool when: +- The user's request is complex and would benefit from planning first +- You want to research and design before making changes +- The task involves multiple files or significant architectural decisions + +Do NOT call this tool: +- For simple, straightforward tasks +- When the user explicitly wants immediate implementation +- When already in plan mode +``` + +### 4. Modify `packages/opencode/src/tool/registry.ts` + +Add import and register tools: + +```typescript +// Add import at top (around line 27) +import { ExitPlanTool, EnterPlanTool } from "./plan" + +// Add to the all() function return array (around line 110-112) +return [ + // ... existing tools + ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), + ...(config.experimental?.batch_tool === true ? [BatchTool] : []), + ExitPlanTool, + EnterPlanTool, + ...custom, +] +``` + +### 5. Modify `packages/opencode/src/agent/agent.ts` + +Add permission rules to control which agent can use which tool: + +**In the `defaults` ruleset (around line 47-63):** + +```typescript +const defaults = PermissionNext.fromConfig({ + "*": "allow", + doom_loop: "ask", + // Add these new defaults - both denied by default + exit_plan: "deny", + enter_plan: "deny", + external_directory: { + // ... existing + }, + // ... rest of existing defaults +}) +``` + +**In the `build` agent (around line 67-79):** + +```typescript +build: { + name: "build", + options: {}, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + question: "allow", + enter_plan: "allow", // Allow build agent to suggest plan mode + }), + user, + ), + mode: "primary", + native: true, +}, +``` + +**In the `plan` agent (around line 80-96):** + +```typescript +plan: { + name: "plan", + options: {}, + permission: PermissionNext.merge( + defaults, + PermissionNext.fromConfig({ + question: "allow", + exit_plan: "allow", // Allow plan agent to exit plan mode + edit: { + "*": "deny", + ".opencode/plans/*.md": "allow", + }, + }), + user, + ), + mode: "primary", + native: true, +}, +``` + +## Design Decisions + +1. **Synthetic user message for mode switching**: When the user confirms a mode switch, a synthetic user message is created with the new agent specified. The loop picks this up on the next iteration and switches to the new agent. This follows the existing pattern in `prompt.ts:455-478`. + +2. **Permission-based tool availability**: Uses the existing permission system to control which tools are available to which agents. `exit_plan` is only available in plan mode, `enter_plan` only in build mode. + +3. **Question-based confirmation**: Both tools use the Question module for consistent UX. + +4. **Model preservation**: The synthetic user message preserves the model from the previous user message. + +## Verification + +1. Run `bun dev` in `packages/opencode` +2. Start a session in build mode + - Verify `exit_plan` is NOT available (denied by permission) + - Verify `enter_plan` IS available +3. Call `enter_plan` in build mode + - Verify the question prompt appears + - Select "Yes" and verify: + - A synthetic user message is created with `agent: "plan"` + - The next assistant response is from the plan agent + - The plan mode system reminder appears +4. In plan mode, call `exit_plan` + - Verify the question prompt appears + - Select "Yes" and verify: + - A synthetic user message is created with `agent: "build"` + - The next assistant response is from the build agent +5. Test "No" responses - verify no mode switch occurs diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index ea9d3e3ba..6847d29ab 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -53,6 +53,8 @@ export namespace Agent { [Truncate.GLOB]: "allow", }, question: "deny", + plan_enter: "deny", + plan_exit: "deny", // mirrors github.com/github/gitignore Node.gitignore pattern for .env files read: { "*": "allow", @@ -71,6 +73,7 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", + plan_enter: "allow", }), user, ), @@ -84,9 +87,10 @@ export namespace Agent { defaults, PermissionNext.fromConfig({ question: "allow", + plan_exit: "allow", edit: { "*": "deny", - ".opencode/plan/*.md": "allow", + ".opencode/plans/*.md": "allow", }, }), user, diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index f87b811ae..b6916bc5a 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -196,6 +196,23 @@ export function Session() { } }) + let lastSwitch: string | undefined = undefined + sdk.event.on("message.part.updated", (evt) => { + const part = evt.properties.part + if (part.type !== "tool") return + if (part.sessionID !== route.sessionID) return + if (part.state.status !== "completed") return + if (part.id === lastSwitch) return + + if (part.tool === "plan_exit") { + local.agent.set("build") + lastSwitch = part.id + } else if (part.tool === "plan_enter") { + local.agent.set("plan") + lastSwitch = part.id + } + }) + let scroll: ScrollBoxRenderable let prompt: PromptRef const keybind = useKeybind() diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx index ccc0e9b12..5e8ce2380 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx @@ -32,7 +32,8 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { const question = createMemo(() => questions()[store.tab]) const confirm = createMemo(() => !single() && store.tab === questions().length) const options = createMemo(() => question()?.options ?? []) - const other = createMemo(() => store.selected === options().length) + const custom = createMemo(() => question()?.custom !== false) + const other = createMemo(() => custom() && store.selected === options().length) const input = createMemo(() => store.custom[store.tab] ?? "") const multi = createMemo(() => question()?.multiple === true) const customPicked = createMemo(() => { @@ -203,7 +204,7 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { } } else { const opts = options() - const total = opts.length + 1 // options + "Other" + const total = opts.length + (custom() ? 1 : 0) if (evt.name === "up" || evt.name === "k") { evt.preventDefault() @@ -298,35 +299,37 @@ export function QuestionPrompt(props: { request: QuestionRequest }) { ) }} - moveTo(options().length)} onMouseUp={() => selectOption()}> - - - - {options().length + 1}. Type your own answer - + + moveTo(options().length)} onMouseUp={() => selectOption()}> + + + + {options().length + 1}. Type your own answer + + + {customPicked() ? "✓" : ""} - {customPicked() ? "✓" : ""} + + +