From 196a03caff570d98a116c5c29e9fddda03b7c824 Mon Sep 17 00:00:00 2001 From: Knut Zuidema Date: Mon, 30 Mar 2026 05:48:17 +0200 Subject: [PATCH 001/161] fix: discourage _noop tool call during LiteLLM compaction (#18539) --- packages/opencode/src/session/compaction.ts | 1 + packages/opencode/src/session/llm.ts | 14 +++++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index 223e71639c..69759c0d96 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -176,6 +176,7 @@ export namespace SessionCompaction { const defaultPrompt = `Provide a detailed prompt for continuing our conversation above. Focus on information that would be helpful for continuing the conversation, including what we did, what we're doing, which files we're working on, and what we're going to do next. The summary that you construct will be used so that another agent can read it and continue the work. +Do not call any tools. Respond only with the summary text. When constructing the summary, try to stick to this template: --- diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 02b72f70a4..c63fb180e0 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -199,11 +199,19 @@ export namespace LLM { input.model.providerID.toLowerCase().includes("litellm") || input.model.api.id.toLowerCase().includes("litellm") + // LiteLLM/Bedrock rejects requests where the message history contains tool + // calls but no tools param is present. When there are no active tools (e.g. + // during compaction), inject a stub tool to satisfy the validation requirement. + // The stub description explicitly tells the model not to call it. if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) { tools["_noop"] = tool({ - description: - "Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed", - inputSchema: jsonSchema({ type: "object", properties: {} }), + description: "Do not call this tool. It exists only for API compatibility and must never be invoked.", + inputSchema: jsonSchema({ + type: "object", + properties: { + reason: { type: "string", description: "Unused" }, + }, + }), execute: async () => ({ output: "", title: "", metadata: {} }), }) } From 0465579d6bb6bf5a55febacd6b4b130f5238ac27 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 30 Mar 2026 03:53:11 +0000 Subject: [PATCH 002/161] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index c84d8bd20c..29defea804 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-ppK5TVMsmy/7uP1kc6hw3gHMxokD/hBZYt5IGHR3/ok=", - "aarch64-linux": "sha256-lrZjanBS8iHJa5TJJHQ9Gaz+lUqNaTgAUuDd6QHu8No=", - "aarch64-darwin": "sha256-EojkRZQF5NqKPF3Bd/8UIiNngpkBk7uAM8m875bfOUo=", - "x86_64-darwin": "sha256-fEO9Hx8yikkvdGj8nC06fy4u/hTGWO6kjENsU/B2OyY=" + "x86_64-linux": "sha256-0JgEA54d1ZZ0IWUmxCJP2fnQ2cpmLO25G+hafUbHFLw=", + "aarch64-linux": "sha256-oB4Ptc+MH76MEw0DZodmCCz87qOmbzi26751ZM4DYyE=", + "aarch64-darwin": "sha256-712rb7B5gTRz1uTx4cJQSrmq9DoBUe+UxbvawYV4XTE=", + "x86_64-darwin": "sha256-GRCiEBDDEyVx1et04xqdIEQr3ykRMOBJoQy/xddSsCA=" } } From ee018d5c82a593907bae9011bc074766e670d593 Mon Sep 17 00:00:00 2001 From: Chris Yang <415386365@qq.com> Date: Mon, 30 Mar 2026 12:01:57 +0800 Subject: [PATCH 003/161] docs: rename patch tool to apply_patch and clarify apply_patch behavior (#19979) --- packages/web/src/content/docs/tools.mdx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/web/src/content/docs/tools.mdx b/packages/web/src/content/docs/tools.mdx index 4c48d194b0..abd486aeb6 100644 --- a/packages/web/src/content/docs/tools.mdx +++ b/packages/web/src/content/docs/tools.mdx @@ -95,7 +95,7 @@ Create new files or overwrite existing ones. Use this to allow the LLM to create new files. It will overwrite existing files if they already exist. :::note -The `write` tool is controlled by the `edit` permission, which covers all file modifications (`edit`, `write`, `patch`, `multiedit`). +The `write` tool is controlled by the `edit` permission, which covers all file modifications (`edit`, `write`, `apply_patch`, `multiedit`). ::: --- @@ -191,7 +191,7 @@ To configure which LSP servers are available for your project, see [LSP Servers] --- -### patch +### apply_patch Apply patches to files. @@ -206,8 +206,12 @@ Apply patches to files. This tool applies patch files to your codebase. Useful for applying diffs and patches from various sources. +When handling `tool.execute.before` or `tool.execute.after` hooks, check `input.tool === "apply_patch"` (not `"patch"`). + +`apply_patch` uses `output.args.patchText` instead of `output.args.filePath`. Paths are embedded in marker lines within `patchText` and are relative to the project root (for example: `*** Add File: src/new-file.ts`, `*** Update File: src/existing.ts`, `*** Move to: src/renamed.ts`, `*** Delete File: src/obsolete.ts`). + :::note -The `patch` tool is controlled by the `edit` permission, which covers all file modifications (`edit`, `write`, `patch`, `multiedit`). +The `apply_patch` tool is controlled by the `edit` permission, which covers all file modifications (`edit`, `write`, `apply_patch`, `multiedit`). ::: --- From 6926fe1c7455f4cd075d374b188850c14cda0fb2 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 14:37:02 +1000 Subject: [PATCH 004/161] fix: stabilize release changelog generation (#19987) --- .gitignore | 1 + .opencode/command/changelog.md | 45 +++- script/changelog.ts | 403 +++++++++++++++------------------ script/version.ts | 3 +- 4 files changed, 215 insertions(+), 237 deletions(-) diff --git a/.gitignore b/.gitignore index c287d91ac1..52a5a04596 100644 --- a/.gitignore +++ b/.gitignore @@ -25,6 +25,7 @@ target # Local dev files opencode-dev +UPCOMING_CHANGELOG.md logs/ *.bun-build tsconfig.tsbuildinfo diff --git a/.opencode/command/changelog.md b/.opencode/command/changelog.md index 271e7eba18..f0ff1e422d 100644 --- a/.opencode/command/changelog.md +++ b/.opencode/command/changelog.md @@ -2,22 +2,43 @@ model: opencode/kimi-k2.5 --- -create UPCOMING_CHANGELOG.md +Create `UPCOMING_CHANGELOG.md` from the structured changelog input below. +If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely. +Do not preserve, merge, or reuse text from the existing file. -it should have sections +Any command arguments are passed directly to `bun script/changelog.ts`. +Use `--from` / `-f` and `--to` / `-t` to preview a specific release range. -``` -## TUI +The input already contains the exact commit range since the last non-draft release. +The commits are already filtered to the release-relevant packages and grouped into +the release sections. Do not fetch GitHub releases, PRs, or build your own commit list. +The input may also include a `## Community Contributors Input` section. -## Desktop +Before writing any entry you keep, inspect the real diff with +`git show --stat --format='' ` or `git show --format='' ` so the +summary reflects the actual user-facing change and not just the commit message. +Do not use `git log` or author metadata when deciding attribution. -## Core +Rules: -## Misc -``` +- Write the final file with sections in this order: + `## Core`, `## TUI`, `## Desktop`, `## SDK`, `## Extensions` +- Only include sections that have at least one notable entry +- Keep one bullet per commit you keep +- Skip commits that are entirely internal, CI, tests, refactors, or otherwise not user-facing +- Start each bullet with a capital letter +- Prefer what changed for users over what code changed internally +- Do not copy raw commit prefixes like `fix:` or `feat:` or trailing PR numbers like `(#123)` +- Community attribution is deterministic: only preserve an existing `(@username)` suffix from the changelog input +- If an input bullet has no `(@username)` suffix, do not add one +- Never add a new `(@username)` suffix from `git show`, commit authors, names, or email addresses +- If no notable entries remain and there is no contributor block, write exactly `No notable changes.` +- If no notable entries remain but there is a contributor block, omit all release sections and return only the contributor block +- If the input contains `## Community Contributors Input`, append the block below that heading to the end of the final file verbatim +- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block +- Do not derive the thank-you section from the main summary bullets +- Do not include the heading `## Community Contributors Input` in the final file -fetch the latest github release for this repository to determine the last release version. +## Changelog Input -find each PR that was merged since the last release - -for each PR spawn a subagent to summarize what the PR was about. focus on user facing changes. if it was entirely internal or code related you can ignore it. also skip docs updates. each subagent should append its summary to UPCOMING_CHANGELOG.md into the appropriate section. +!`bun script/changelog.ts $ARGUMENTS` diff --git a/script/changelog.ts b/script/changelog.ts index 5fc30a228b..3c3a659e71 100755 --- a/script/changelog.ts +++ b/script/changelog.ts @@ -1,33 +1,11 @@ #!/usr/bin/env bun import { $ } from "bun" -import { createOpencode } from "@opencode-ai/sdk/v2" import { parseArgs } from "util" -import { Script } from "@opencode-ai/script" type Release = { tag_name: string draft: boolean - prerelease: boolean -} - -export async function getLatestRelease(skip?: string) { - const data = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=100").then((res) => { - if (!res.ok) throw new Error(res.statusText) - return res.json() - }) - - const releases = data as Release[] - const target = skip?.replace(/^v/, "") - - for (const release of releases) { - if (release.draft) continue - const tag = release.tag_name.replace(/^v/, "") - if (target && tag === target) continue - return tag - } - - throw new Error("No releases found") } type Commit = { @@ -37,84 +15,23 @@ type Commit = { areas: Set } -export async function getCommits(from: string, to: string): Promise { - const fromRef = from.startsWith("v") ? from : `v${from}` - const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}` - - // Get commit data with GitHub usernames from the API - const compare = - await $`gh api "/repos/anomalyco/opencode/compare/${fromRef}...${toRef}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text() - - const commitData = new Map() - for (const line of compare.split("\n").filter(Boolean)) { - const data = JSON.parse(line) as { sha: string; login: string | null; message: string } - commitData.set(data.sha, { login: data.login, message: data.message.split("\n")[0] ?? "" }) - } - - // Get commits that touch the relevant packages - const log = - await $`git log ${fromRef}..${toRef} --oneline --format="%H" -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text() - const hashes = log.split("\n").filter(Boolean) - - const commits: Commit[] = [] - for (const hash of hashes) { - const data = commitData.get(hash) - if (!data) continue - - const message = data.message - if (message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue - - const files = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text() - const areas = new Set() - - for (const file of files.split("\n").filter(Boolean)) { - if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui") - else if (file.startsWith("packages/opencode/")) areas.add("core") - else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri") - else if (file.startsWith("packages/desktop/")) areas.add("app") - else if (file.startsWith("packages/app/")) areas.add("app") - else if (file.startsWith("packages/sdk/")) areas.add("sdk") - else if (file.startsWith("packages/plugin/")) areas.add("plugin") - else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed") - else if (file.startsWith("sdks/vscode/")) areas.add("extensions/vscode") - else if (file.startsWith("github/")) areas.add("github") - } - - if (areas.size === 0) continue - - commits.push({ - hash: hash.slice(0, 7), - author: data.login, - message, - areas, - }) - } - - return filterRevertedCommits(commits) -} - -function filterRevertedCommits(commits: Commit[]): Commit[] { - const revertPattern = /^Revert "(.+)"$/ - const seen = new Map() - - for (const commit of commits) { - const match = commit.message.match(revertPattern) - if (match) { - // It's a revert - remove the original if we've seen it - const original = match[1]! - if (seen.has(original)) seen.delete(original) - else seen.set(commit.message, commit) // Keep revert if original not in range - } else { - // Regular commit - remove if its revert exists, otherwise add - const revertMsg = `Revert "${commit.message}"` - if (seen.has(revertMsg)) seen.delete(revertMsg) - else seen.set(commit.message, commit) - } - } - - return [...seen.values()] +type User = Map> +type Diff = { + sha: string + login: string | null + message: string } +const repo = process.env.GH_REPO ?? "anomalyco/opencode" +const bot = ["actions-user", "opencode", "opencode-agent[bot]"] +const team = [ + ...(await Bun.file(new URL("../.github/TEAM_MEMBERS", import.meta.url)) + .text() + .then((x) => x.split(/\r?\n/).map((x) => x.trim())) + .then((x) => x.filter((x) => x && !x.startsWith("#")))), + ...bot, +] +const order = ["Core", "TUI", "Desktop", "SDK", "Extensions"] as const const sections = { core: "Core", tui: "TUI", @@ -127,8 +44,37 @@ const sections = { github: "Extensions", } as const -function getSection(areas: Set): string { - // Priority order for multi-area commits +function ref(input: string) { + if (input === "HEAD") return input + if (input.startsWith("v")) return input + if (input.match(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)) return `v${input}` + return input +} + +async function latest() { + const data = await $`gh api "/repos/${repo}/releases?per_page=100"`.json() + const release = (data as Release[]).find((item) => !item.draft) + if (!release) throw new Error("No releases found") + return release.tag_name.replace(/^v/, "") +} + +async function diff(base: string, head: string) { + const list: Diff[] = [] + for (let page = 1; ; page++) { + const text = + await $`gh api "/repos/${repo}/compare/${base}...${head}?per_page=100&page=${page}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text() + const batch = text + .split("\n") + .filter(Boolean) + .map((line) => JSON.parse(line) as Diff) + if (batch.length === 0) break + list.push(...batch) + if (batch.length < 100) break + } + return list +} + +function section(areas: Set) { const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/zed", "extensions/vscode", "github"] for (const area of priority) { if (areas.has(area)) return sections[area as keyof typeof sections] @@ -136,138 +82,151 @@ function getSection(areas: Set): string { return "Core" } -async function summarizeCommit(opencode: Awaited>, message: string): Promise { - console.log("summarizing commit:", message) - const session = await opencode.client.session.create() - const result = await opencode.client.session - .prompt( - { - sessionID: session.data!.id, - model: { providerID: "opencode", modelID: "claude-sonnet-4-5" }, - tools: { - "*": false, - }, - parts: [ - { - type: "text", - text: `Summarize this commit message for a changelog entry. Return ONLY a single line summary starting with a capital letter. Be concise but specific. If the commit message is already well-written, just clean it up (capitalize, fix typos, proper grammar). Do not include any prefixes like "fix:" or "feat:". +function reverted(commits: Commit[]) { + const seen = new Map() -Commit: ${message}`, - }, - ], - }, - { - signal: AbortSignal.timeout(120_000), - }, - ) - .then((x) => x.data?.parts?.find((y) => y.type === "text")?.text ?? message) - return result.trim() + for (const commit of commits) { + const match = commit.message.match(/^Revert "(.+)"$/) + if (match) { + const msg = match[1]! + if (seen.has(msg)) seen.delete(msg) + else seen.set(commit.message, commit) + continue + } + + const revert = `Revert "${commit.message}"` + if (seen.has(revert)) { + seen.delete(revert) + continue + } + + seen.set(commit.message, commit) + } + + return [...seen.values()] } -export async function generateChangelog(commits: Commit[], opencode: Awaited>) { - // Summarize commits in parallel with max 10 concurrent requests - const BATCH_SIZE = 10 - const summaries: string[] = [] - for (let i = 0; i < commits.length; i += BATCH_SIZE) { - const batch = commits.slice(i, i + BATCH_SIZE) - const results = await Promise.all(batch.map((c) => summarizeCommit(opencode, c.message))) - summaries.push(...results) +async function commits(from: string, to: string) { + const base = ref(from) + const head = ref(to) + + const data = new Map() + for (const item of await diff(base, head)) { + data.set(item.sha, { login: item.login, message: item.message.split("\n")[0] ?? "" }) } - const grouped = new Map() - for (let i = 0; i < commits.length; i++) { - const commit = commits[i]! - const section = getSection(commit.areas) - const attribution = commit.author && !Script.team.includes(commit.author) ? ` (@${commit.author})` : "" - const entry = `- ${summaries[i]}${attribution}` + const log = + await $`git log ${base}..${head} --format=%H -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text() - if (!grouped.has(section)) grouped.set(section, []) - grouped.get(section)!.push(entry) + const list: Commit[] = [] + for (const hash of log.split("\n").filter(Boolean)) { + const item = data.get(hash) + if (!item) continue + if (item.message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue + + const diff = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text() + const areas = new Set() + + for (const file of diff.split("\n").filter(Boolean)) { + if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui") + else if (file.startsWith("packages/opencode/")) areas.add("core") + else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri") + else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app") + else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk") + else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed") + else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode") + } + + if (areas.size === 0) continue + + list.push({ + hash: hash.slice(0, 7), + author: item.login, + message: item.message, + areas, + }) } - const sectionOrder = ["Core", "TUI", "Desktop", "SDK", "Extensions"] - const lines: string[] = [] - for (const section of sectionOrder) { - const entries = grouped.get(section) - if (!entries || entries.length === 0) continue - lines.push(`## ${section}`) - lines.push(...entries) + return reverted(list) +} + +async function contributors(from: string, to: string) { + const base = ref(from) + const head = ref(to) + + const users: User = new Map() + for (const item of await diff(base, head)) { + const title = item.message.split("\n")[0] ?? "" + if (!item.login || team.includes(item.login)) continue + if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue + if (!users.has(item.login)) users.set(item.login, new Set()) + users.get(item.login)!.add(title) } + return users +} + +async function published(to: string) { + if (to === "HEAD") return + const body = await $`gh release view ${ref(to)} --repo ${repo} --json body --jq .body`.text().catch(() => "") + if (!body) return + + const lines = body.split(/\r?\n/) + const start = lines.findIndex((line) => line.startsWith("**Thank you to ")) + if (start < 0) return + return lines.slice(start).join("\n").trim() +} + +async function thanks(from: string, to: string, reuse: boolean) { + const release = reuse ? await published(to) : undefined + if (release) return release.split(/\r?\n/) + + const users = await contributors(from, to) + if (users.size === 0) return [] + + const lines = [`**Thank you to ${users.size} community contributor${users.size > 1 ? "s" : ""}:**`] + for (const [name, commits] of users) { + lines.push(`- @${name}:`) + for (const commit of commits) lines.push(` - ${commit}`) + } return lines } -export async function getContributors(from: string, to: string) { - const fromRef = from.startsWith("v") ? from : `v${from}` - const toRef = to === "HEAD" ? to : to.startsWith("v") ? to : `v${to}` - const compare = - await $`gh api "/repos/anomalyco/opencode/compare/${fromRef}...${toRef}" --jq '.commits[] | {login: .author.login, message: .commit.message}'`.text() - const contributors = new Map>() +function format(from: string, to: string, list: Commit[], thanks: string[]) { + const grouped = new Map() + for (const title of order) grouped.set(title, []) - for (const line of compare.split("\n").filter(Boolean)) { - const { login, message } = JSON.parse(line) as { login: string | null; message: string } - const title = message.split("\n")[0] ?? "" - if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue - - if (login && !Script.team.includes(login)) { - if (!contributors.has(login)) contributors.set(login, new Set()) - contributors.get(login)!.add(title) - } + for (const commit of list) { + const title = section(commit.areas) + const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : "" + grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`) } - return contributors + const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""] + + if (list.length === 0) { + lines.push("No notable changes.") + } + + for (const title of order) { + const entries = grouped.get(title) + if (!entries || entries.length === 0) continue + lines.push(`## ${title}`) + lines.push(...entries) + lines.push("") + } + + if (thanks.length > 0) { + if (lines.at(-1) !== "") lines.push("") + lines.push("## Community Contributors Input") + lines.push("") + lines.push(...thanks) + } + + if (lines.at(-1) === "") lines.pop() + return lines.join("\n") } -export async function buildNotes(from: string, to: string) { - const commits = await getCommits(from, to) - - if (commits.length === 0) { - return [] - } - - console.log("generating changelog since " + from) - - const opencode = await createOpencode({ port: 0 }) - const notes: string[] = [] - - try { - const lines = await generateChangelog(commits, opencode) - notes.push(...lines) - console.log("---- Generated Changelog ----") - console.log(notes.join("\n")) - console.log("-----------------------------") - } catch (error) { - if (error instanceof Error && error.name === "TimeoutError") { - console.log("Changelog generation timed out, using raw commits") - for (const commit of commits) { - const attribution = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : "" - notes.push(`- ${commit.message}${attribution}`) - } - } else { - throw error - } - } finally { - await opencode.server.close() - } - console.log("changelog generation complete") - - const contributors = await getContributors(from, to) - - if (contributors.size > 0) { - notes.push("") - notes.push(`**Thank you to ${contributors.size} community contributor${contributors.size > 1 ? "s" : ""}:**`) - for (const [username, userCommits] of contributors) { - notes.push(`- @${username}:`) - for (const c of userCommits) { - notes.push(` - ${c}`) - } - } - } - - return notes -} - -// CLI entrypoint if (import.meta.main) { const { values } = parseArgs({ args: Bun.argv.slice(2), @@ -283,24 +242,20 @@ if (import.meta.main) { Usage: bun script/changelog.ts [options] Options: - -f, --from Starting version (default: latest GitHub release) + -f, --from Starting version (default: latest non-draft GitHub release) -t, --to Ending ref (default: HEAD) -h, --help Show this help message Examples: - bun script/changelog.ts # Latest release to HEAD - bun script/changelog.ts --from 1.0.200 # v1.0.200 to HEAD + bun script/changelog.ts + bun script/changelog.ts --from 1.0.200 bun script/changelog.ts -f 1.0.200 -t 1.0.205 `) process.exit(0) } const to = values.to! - const from = values.from ?? (await getLatestRelease()) - - console.log(`Generating changelog: v${from} -> ${to}\n`) - - const notes = await buildNotes(from, to) - console.log("\n=== Final Notes ===") - console.log(notes.join("\n")) + const from = values.from ?? (await latest()) + const list = await commits(from, to) + console.log(format(from, to, list, await thanks(from, to, !values.from))) } diff --git a/script/version.ts b/script/version.ts index 7bed6d3a9a..2fa59fe9f8 100755 --- a/script/version.ts +++ b/script/version.ts @@ -6,7 +6,8 @@ import { $ } from "bun" const output = [`version=${Script.version}`] if (!Script.preview) { - await $`opencode run --command changelog`.cwd(process.cwd()) + const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim() + await $`opencode run --command changelog -- --to ${sha}`.cwd(process.cwd()) const file = `${process.cwd()}/UPCOMING_CHANGELOG.md` const body = await Bun.file(file) .text() From 186af2723d7dab64453dfd43355fff55336e48b7 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Mon, 30 Mar 2026 15:42:38 +1000 Subject: [PATCH 005/161] make variant modal less annoying (#19998) --- packages/opencode/src/cli/cmd/tui/app.tsx | 14 +++++++++- .../cli/cmd/tui/component/dialog-model.tsx | 8 +++++- .../cli/cmd/tui/component/dialog-variant.tsx | 26 +++++++++++++------ .../src/cli/cmd/tui/context/local.tsx | 10 +++++-- 4 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 3cb383be48..3e4d0b4270 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -581,10 +581,22 @@ function App(props: { onSnapshot?: () => Promise }) { }, }, { - title: "Switch model variant", + title: "Variant cycle", value: "variant.cycle", keybind: "variant_cycle", category: "Agent", + onSelect: () => { + local.model.variant.cycle() + }, + }, + { + title: "Switch model variant", + value: "variant.list", + category: "Agent", + hidden: local.model.variant.list().length === 0, + slash: { + name: "variants", + }, onSelect: () => { dialog.replace(() => ) }, diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx index ee9fa225ed..549165f51a 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-model.tsx @@ -136,7 +136,13 @@ export function DialogModel(props: { providerID?: string }) { function onSelect(providerID: string, modelID: string) { local.model.set({ providerID, modelID }, { recent: true }) - if (local.model.variant.list().length > 0) { + const list = local.model.variant.list() + const cur = local.model.variant.selected() + if (cur === "default" || (cur && list.includes(cur))) { + dialog.clear() + return + } + if (list.length > 0) { dialog.replace(() => ) return } diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx index 872092d23e..28ee1b2825 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-variant.tsx @@ -8,21 +8,31 @@ export function DialogVariant() { const dialog = useDialog() const options = createMemo(() => { - return local.model.variant.list().map((variant) => ({ - value: variant, - title: variant, - onSelect: () => { - dialog.clear() - local.model.variant.set(variant) + return [ + { + value: "default", + title: "Default", + onSelect: () => { + dialog.clear() + local.model.variant.set(undefined) + }, }, - })) + ...local.model.variant.list().map((variant) => ({ + value: variant, + title: variant, + onSelect: () => { + dialog.clear() + local.model.variant.set(variant) + }, + })), + ] }) return ( options={options()} title={"Select variant"} - current={local.model.variant.current()} + current={local.model.variant.selected()} flat={true} /> ) diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index d93079f12a..e131df358f 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -321,12 +321,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) }, variant: { - current() { + selected() { const m = currentModel() if (!m) return undefined const key = `${m.providerID}/${m.modelID}` return modelStore.variant[key] }, + current() { + const v = this.selected() + if (!v) return undefined + if (!this.list().includes(v)) return undefined + return v + }, list() { const m = currentModel() if (!m) return [] @@ -339,7 +345,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const m = currentModel() if (!m) return const key = `${m.providerID}/${m.modelID}` - setModelStore("variant", key, value) + setModelStore("variant", key, value ?? "default") save() }, cycle() { From 47d2ab120a4fbc92e72aca4d5b40d722d0e4d2be Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 30 Mar 2026 06:06:12 +0000 Subject: [PATCH 006/161] release: v1.3.7 --- bun.lock | 32 +++++++++++++------------- 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-electron/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 | 2 +- packages/sdk/js/package.json | 2 +- 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 +- 19 files changed, 39 insertions(+), 39 deletions(-) diff --git a/bun.lock b/bun.lock index b88c23e29d..12e53065df 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -79,7 +79,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -113,7 +113,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -140,7 +140,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -164,7 +164,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -188,7 +188,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -221,7 +221,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -252,7 +252,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -281,7 +281,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -297,7 +297,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.3.6", + "version": "1.3.7", "bin": { "opencode": "./bin/opencode", }, @@ -423,7 +423,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -457,7 +457,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.3.6", + "version": "1.3.7", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -468,7 +468,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -503,7 +503,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -550,7 +550,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "zod": "catalog:", }, @@ -561,7 +561,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.3.6", + "version": "1.3.7", "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 d1ba1d0880..4f3cd5e4d8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.3.6", + "version": "1.3.7", "description": "", "type": "module", "exports": { diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 273d635958..e11f8c0e1e 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.3.6", + "version": "1.3.7", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index b68af6a11c..bfe7fe115a 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.3.6", + "version": "1.3.7", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 3b15d52fe1..343987c79b 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.3.6", + "version": "1.3.7", "$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 889136b4e4..573e7b3f85 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.3.6", + "version": "1.3.7", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 98c81c9649..38251a7638 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "1.3.6", + "version": "1.3.7", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ac7f497ab0..cdf4b85249 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.3.6", + "version": "1.3.7", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 980304ca40..01511daf01 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.3.6", + "version": "1.3.7", "private": true, "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index d3db2b7303..6c2ea2dea5 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.3.6" +version = "1.3.7" 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.3.6/opencode-darwin-arm64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.7/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.6/opencode-darwin-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.7/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.6/opencode-linux-arm64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.7/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.3.6/opencode-linux-x64.tar.gz" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.7/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.3.6/opencode-windows-x64.zip" +archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.7/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/function/package.json b/packages/function/package.json index c02b2e1fb0..7cca34a52b 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.3.6", + "version": "1.3.7", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d17fe02348..47948acb22 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.3.6", + "version": "1.3.7", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 8a6b776c14..5651b32fd7 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.3.6", + "version": "1.3.7", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 60c4aabe04..d2340e3172 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.3.6", + "version": "1.3.7", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/slack/package.json b/packages/slack/package.json index a0b8904eb5..ed5d5bd184 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.3.6", + "version": "1.3.7", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/ui/package.json b/packages/ui/package.json index bdc0192e64..5bf009ed3b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.3.6", + "version": "1.3.7", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index bc6e1b18ac..581cbb2b34 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.3.6", + "version": "1.3.7", "private": true, "type": "module", "license": "MIT", diff --git a/packages/web/package.json b/packages/web/package.json index b054317fb4..80db7ec735 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.3.6", + "version": "1.3.7", "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 16b83eb013..427e103317 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.3.6", + "version": "1.3.7", "publisher": "sst-dev", "repository": { "type": "git", From 3c32013eb122d794089e011d2ec7077395d6f1c4 Mon Sep 17 00:00:00 2001 From: Jack Date: Mon, 30 Mar 2026 17:11:34 +0800 Subject: [PATCH 007/161] fix: preserve image attachments when selecting slash commands (#19771) --- packages/app/src/components/prompt-input.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 1cc7c578d3..c8f72b8d2f 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -624,17 +624,18 @@ export const PromptInput: Component = (props) => { if (!cmd) return promptProbe.select(cmd.id) closePopover() + const images = imageAttachments() if (cmd.type === "custom") { const text = `/${cmd.trigger} ` setEditorText(text) - prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + prompt.set([{ type: "text", content: text, start: 0, end: text.length }, ...images], text.length) focusEditorEnd() return } clearEditor() - prompt.set([{ type: "text", content: "", start: 0, end: 0 }], 0) + prompt.set([...DEFAULT_PROMPT, ...images], 0) command.trigger(cmd.id, "slash") } From 8e4bab51812fccf3b69713904159a4394b3a29ab Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 30 Mar 2026 13:51:07 +0200 Subject: [PATCH 008/161] update plugin themes when plugin was updated (#20052) --- packages/opencode/specs/tui-plugins.md | 4 +- .../src/cli/cmd/tui/context/theme.tsx | 12 ++ .../src/cli/cmd/tui/plugin/runtime.ts | 110 +++++++++++++----- packages/opencode/src/plugin/meta.ts | 37 ++++-- packages/opencode/src/util/filesystem.ts | 9 +- .../test/cli/tui/plugin-loader.test.ts | 103 ++++++++++++++++ 6 files changed, 237 insertions(+), 38 deletions(-) diff --git a/packages/opencode/specs/tui-plugins.md b/packages/opencode/specs/tui-plugins.md index 31edcf114a..5a7caa75b9 100644 --- a/packages/opencode/specs/tui-plugins.md +++ b/packages/opencode/specs/tui-plugins.md @@ -269,7 +269,9 @@ Theme install behavior: - Relative theme paths are resolved from the plugin root. - Theme name is the JSON basename. -- Install is skipped if that theme name already exists. +- First install writes only when the destination file is missing. +- If the theme name already exists, install is skipped unless plugin metadata state is `updated`. +- On `updated`, host only rewrites themes previously tracked for that plugin and only when source `mtime`/`size` changed. - Local plugins persist installed themes under the local `.opencode/themes` area near the plugin config source. - Global plugins persist installed themes under the global `themes` dir. - Invalid or unreadable theme files are ignored. diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index dcef2cb466..4857f7a4d2 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -183,6 +183,18 @@ export function addTheme(name: string, theme: unknown) { return true } +export function upsertTheme(name: string, theme: unknown) { + if (!name) return false + if (!isTheme(theme)) return false + if (customThemes[name] !== undefined) { + customThemes[name] = theme + } else { + pluginThemes[name] = theme + } + syncThemes() + return true +} + export function resolveTheme(theme: ThemeJson, mode: "dark" | "light") { const defs = theme.defs ?? {} function resolveColor(c: ColorValue, chain: string[] = []): RGBA { diff --git a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts index 0e1674bdac..e992577a6e 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/runtime.ts @@ -31,7 +31,7 @@ import { } from "@/plugin/shared" import { PluginMeta } from "@/plugin/meta" import { installPlugin as installModulePlugin, patchPluginConfig, readPluginManifest } from "@/plugin/install" -import { addTheme, hasTheme } from "../context/theme" +import { hasTheme, upsertTheme } from "../context/theme" import { Global } from "@/global" import { Filesystem } from "@/util/filesystem" import { Process } from "@/util/process" @@ -49,7 +49,8 @@ type PluginLoad = { source: PluginSource | "internal" id: string module: TuiPluginModule - install_theme: TuiTheme["install"] + theme_meta: TuiConfig.PluginMeta + theme_root: string } type Api = HostPluginApi @@ -64,6 +65,7 @@ type PluginEntry = { id: string load: PluginLoad meta: TuiPluginMeta + themes: Record plugin: TuiPlugin options: Config.PluginOptions | undefined enabled: boolean @@ -143,12 +145,54 @@ function resolveRoot(root: string) { return path.resolve(process.cwd(), root) } -function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: string): TuiTheme["install"] { +function createThemeInstaller( + meta: TuiConfig.PluginMeta, + root: string, + spec: string, + plugin: PluginEntry, +): TuiTheme["install"] { return async (file) => { const raw = file.startsWith("file://") ? fileURLToPath(file) : file const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw) - const theme = path.basename(src, path.extname(src)) - if (hasTheme(theme)) return + const name = path.basename(src, path.extname(src)) + const source_dir = path.dirname(meta.source) + const local_dir = + path.basename(source_dir) === ".opencode" + ? path.join(source_dir, "themes") + : path.join(source_dir, ".opencode", "themes") + const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes") + const dest = path.join(dest_dir, `${name}.json`) + const stat = await Filesystem.statAsync(src) + const mtime = stat ? Math.floor(typeof stat.mtimeMs === "bigint" ? Number(stat.mtimeMs) : stat.mtimeMs) : undefined + const size = stat ? (typeof stat.size === "bigint" ? Number(stat.size) : stat.size) : undefined + const exists = hasTheme(name) + const prev = plugin.themes[name] + + if (exists) { + if (plugin.meta.state !== "updated") return + if (!prev) { + if (await Filesystem.exists(dest)) { + plugin.themes[name] = { + src, + dest, + mtime, + size, + } + await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => { + log.warn("failed to track tui plugin theme", { + path: spec, + id: plugin.id, + theme: src, + dest, + error, + }) + }) + } + return + } + if (prev.dest !== dest) return + if (prev.mtime === mtime && prev.size === size) return + } const text = await Filesystem.readText(src).catch((error) => { log.warn("failed to read tui plugin theme", { path: spec, theme: src, error }) @@ -170,20 +214,28 @@ function createThemeInstaller(meta: TuiConfig.PluginMeta, root: string, spec: st return } - const source_dir = path.dirname(meta.source) - const local_dir = - path.basename(source_dir) === ".opencode" - ? path.join(source_dir, "themes") - : path.join(source_dir, ".opencode", "themes") - const dest_dir = meta.scope === "local" ? local_dir : path.join(Global.Path.config, "themes") - const dest = path.join(dest_dir, `${theme}.json`) - if (!(await Filesystem.exists(dest))) { + if (exists || !(await Filesystem.exists(dest))) { await Filesystem.write(dest, text).catch((error) => { log.warn("failed to persist tui plugin theme", { path: spec, theme: src, dest, error }) }) } - addTheme(theme, data) + upsertTheme(name, data) + plugin.themes[name] = { + src, + dest, + mtime, + size, + } + await PluginMeta.setTheme(plugin.id, name, plugin.themes[name]!).catch((error) => { + log.warn("failed to track tui plugin theme", { + path: spec, + id: plugin.id, + theme: src, + dest, + error, + }) + }) } } @@ -222,7 +274,6 @@ async function loadExternalPlugin( } const root = resolveRoot(source === "file" ? spec : target) - const install_theme = createThemeInstaller(meta, root, spec) const entry = await resolvePluginEntrypoint(spec, target, "tui").catch((error) => { fail("failed to resolve tui plugin entry", { path: spec, target, retry, error }) return @@ -253,7 +304,8 @@ async function loadExternalPlugin( source, id, module: mod, - install_theme, + theme_meta: meta, + theme_root: root, } } @@ -297,14 +349,11 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad { source: "internal", id: item.id, module: item, - install_theme: createThemeInstaller( - { - scope: "global", - source: target, - }, - process.cwd(), - spec, - ), + theme_meta: { + scope: "global", + source: target, + }, + theme_root: process.cwd(), } } @@ -436,7 +485,7 @@ async function activatePluginEntry(state: RuntimeState, plugin: PluginEntry, per if (plugin.scope) return true const scope = createPluginScope(plugin.load, plugin.id) - const api = pluginApi(state, plugin.load, scope, plugin.id) + const api = pluginApi(state, plugin, scope, plugin.id) const ok = await Promise.resolve() .then(async () => { await plugin.plugin(api, plugin.options, plugin.meta) @@ -479,9 +528,10 @@ async function deactivatePluginById(state: RuntimeState | undefined, id: string, return deactivatePluginEntry(state, plugin, persist) } -function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, base: string): TuiPluginApi { +function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScope, base: string): TuiPluginApi { const api = runtime.api const host = runtime.slots + const load = plugin.load const command: TuiPluginApi["command"] = { register(cb) { return scope.track(api.command.register(cb)) @@ -504,7 +554,7 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, } const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), { - install: load.install_theme, + install: createThemeInstaller(load.theme_meta, load.theme_root, load.spec, plugin), }) const event: TuiPluginApi["event"] = { @@ -563,13 +613,14 @@ function pluginApi(runtime: RuntimeState, load: PluginLoad, scope: PluginScope, } } -function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta) { +function collectPluginEntries(load: PluginLoad, meta: TuiPluginMeta, themes: Record = {}) { const options = load.item ? Config.pluginOptions(load.item) : undefined return [ { id: load.id, load, meta, + themes, plugin: load.module.tui, options, enabled: true, @@ -661,7 +712,8 @@ async function addExternalPluginEntries(state: RuntimeState, ready: PluginLoad[] } const row = createMeta(entry.source, entry.spec, entry.target, hit, entry.id) - for (const plugin of collectPluginEntries(entry, row)) { + const themes = hit?.entry.themes ? { ...hit.entry.themes } : {} + for (const plugin of collectPluginEntries(entry, row, themes)) { if (!addPluginEntry(state, plugin)) { ok = false continue diff --git a/packages/opencode/src/plugin/meta.ts b/packages/opencode/src/plugin/meta.ts index bf93870cb0..cbfaf6ae15 100644 --- a/packages/opencode/src/plugin/meta.ts +++ b/packages/opencode/src/plugin/meta.ts @@ -11,6 +11,13 @@ import { parsePluginSpecifier, pluginSource } from "./shared" export namespace PluginMeta { type Source = "file" | "npm" + export type Theme = { + src: string + dest: string + mtime?: number + size?: number + } + export type Entry = { id: string source: Source @@ -24,6 +31,7 @@ export namespace PluginMeta { time_changed: number load_count: number fingerprint: string + themes?: Record } export type State = "first" | "updated" | "same" @@ -35,7 +43,7 @@ export namespace PluginMeta { } type Store = Record - type Core = Omit + type Core = Omit type Row = Touch & { core: Core } function storePath() { @@ -52,11 +60,11 @@ export namespace PluginMeta { return } - function modifiedAt(file: string) { - const stat = Filesystem.stat(file) + async function modifiedAt(file: string) { + const stat = await Filesystem.statAsync(file) if (!stat) return - const value = stat.mtimeMs - return Math.floor(typeof value === "bigint" ? Number(value) : value) + const mtime = stat.mtimeMs + return Math.floor(typeof mtime === "bigint" ? Number(mtime) : mtime) } function resolvedTarget(target: string) { @@ -66,7 +74,7 @@ export namespace PluginMeta { async function npmVersion(target: string) { const resolved = resolvedTarget(target) - const stat = Filesystem.stat(resolved) + const stat = await Filesystem.statAsync(resolved) const dir = stat?.isDirectory() ? resolved : path.dirname(resolved) return Filesystem.readJson<{ version?: string }>(path.join(dir, "package.json")) .then((item) => item.version) @@ -84,7 +92,7 @@ export namespace PluginMeta { source, spec, target, - modified: file ? modifiedAt(file) : undefined, + modified: file ? await modifiedAt(file) : undefined, } } @@ -122,6 +130,7 @@ export namespace PluginMeta { time_changed: prev?.time_changed ?? now, load_count: (prev?.load_count ?? 0) + 1, fingerprint: fingerprint(core), + themes: prev?.themes, } const state: State = !prev ? "first" : prev.fingerprint === entry.fingerprint ? "same" : "updated" if (state === "updated") entry.time_changed = now @@ -158,6 +167,20 @@ export namespace PluginMeta { }) } + export async function setTheme(id: string, name: string, theme: Theme): Promise { + const file = storePath() + await Flock.withLock(lock(file), async () => { + const store = await read(file) + const entry = store[id] + if (!entry) return + entry.themes = { + ...(entry.themes ?? {}), + [name]: theme, + } + await Filesystem.writeJson(file, store) + }) + } + export async function list(): Promise { const file = storePath() return Flock.withLock(lock(file), async () => read(file)) diff --git a/packages/opencode/src/util/filesystem.ts b/packages/opencode/src/util/filesystem.ts index b4ae46df13..29f79e9587 100644 --- a/packages/opencode/src/util/filesystem.ts +++ b/packages/opencode/src/util/filesystem.ts @@ -1,4 +1,4 @@ -import { chmod, mkdir, readFile, writeFile } from "fs/promises" +import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises" import { createWriteStream, existsSync, statSync } from "fs" import { lookup } from "mime-types" import { realpathSync } from "fs" @@ -25,6 +25,13 @@ export namespace Filesystem { return statSync(p, { throwIfNoEntry: false }) ?? undefined } + export async function statAsync(p: string): Promise | undefined> { + return statFile(p).catch((e) => { + if (isEnoent(e)) return undefined + throw e + }) + } + export async function size(p: string): Promise { const s = stat(p)?.size ?? 0 return typeof s === "bigint" ? Number(s) : s diff --git a/packages/opencode/test/cli/tui/plugin-loader.test.ts b/packages/opencode/test/cli/tui/plugin-loader.test.ts index 9e72754975..143c060e9c 100644 --- a/packages/opencode/test/cli/tui/plugin-loader.test.ts +++ b/packages/opencode/test/cli/tui/plugin-loader.test.ts @@ -561,3 +561,106 @@ describe("tui.plugin.loader", () => { expect(data.leaked_global_to_local).toBe(false) }) }) + +test("updates installed theme when plugin metadata changes", async () => { + await using tmp = await tmpdir<{ + spec: string + pluginPath: string + themePath: string + dest: string + themeName: string + }>({ + init: async (dir) => { + const pluginPath = path.join(dir, "theme-update-plugin.ts") + const spec = pathToFileURL(pluginPath).href + const themeFile = "theme-update.json" + const themePath = path.join(dir, themeFile) + const dest = path.join(dir, ".opencode", "themes", themeFile) + const themeName = themeFile.replace(/\.json$/, "") + const configPath = path.join(dir, "tui.json") + + await Bun.write(themePath, JSON.stringify({ theme: { primary: "#111111" } }, null, 2)) + await Bun.write( + pluginPath, + `export default { + id: "demo.theme-update", + tui: async (api, options) => { + if (!options?.theme_path) return + await api.theme.install(options.theme_path) + }, +} +`, + ) + await Bun.write( + configPath, + JSON.stringify( + { + plugin: [[spec, { theme_path: `./${themeFile}` }]], + }, + null, + 2, + ), + ) + + return { + spec, + pluginPath, + themePath, + dest, + themeName, + } + }, + }) + + process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json") + const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path) + const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue() + const install = spyOn(Config, "installDependencies").mockResolvedValue() + + const api = () => + createTuiPluginApi({ + theme: { + has(name) { + return allThemes()[name] !== undefined + }, + }, + }) + + try { + await TuiPluginRuntime.init(api()) + await TuiPluginRuntime.dispose() + await expect(fs.readFile(tmp.extra.dest, "utf8")).resolves.toContain("#111111") + + await Bun.write(tmp.extra.themePath, JSON.stringify({ theme: { primary: "#222222" } }, null, 2)) + await Bun.write( + tmp.extra.pluginPath, + `export default { + id: "demo.theme-update", + tui: async (api, options) => { + if (!options?.theme_path) return + await api.theme.install(options.theme_path) + }, +} +// v2 +`, + ) + const stamp = new Date(Date.now() + 10_000) + await fs.utimes(tmp.extra.pluginPath, stamp, stamp) + await fs.utimes(tmp.extra.themePath, stamp, stamp) + + await TuiPluginRuntime.init(api()) + const text = await fs.readFile(tmp.extra.dest, "utf8") + expect(text).toContain("#222222") + expect(text).not.toContain("#111111") + const list = await Filesystem.readJson }>>( + process.env.OPENCODE_PLUGIN_META_FILE!, + ) + expect(list["demo.theme-update"]?.themes?.[tmp.extra.themeName]?.dest).toBe(tmp.extra.dest) + } finally { + await TuiPluginRuntime.dispose() + cwd.mockRestore() + wait.mockRestore() + install.mockRestore() + delete process.env.OPENCODE_PLUGIN_META_FILE + } +}) From 14f9e21d5c3f4e853dee8ca133693dd3b915b634 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Mon, 30 Mar 2026 14:33:01 +0200 Subject: [PATCH 009/161] pluggable home footer (#20057) --- .../cmd/tui/feature-plugins/home/footer.tsx | 93 +++++++++++++++++++ .../src/cli/cmd/tui/plugin/internal.ts | 2 + .../opencode/src/cli/cmd/tui/routes/home.tsx | 61 +----------- packages/plugin/src/tui.ts | 1 + 4 files changed, 99 insertions(+), 58 deletions(-) create mode 100644 packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx diff --git a/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx new file mode 100644 index 0000000000..8047c26458 --- /dev/null +++ b/packages/opencode/src/cli/cmd/tui/feature-plugins/home/footer.tsx @@ -0,0 +1,93 @@ +import type { TuiPlugin, TuiPluginApi, TuiPluginModule } from "@opencode-ai/plugin/tui" +import { createMemo, Match, Show, Switch } from "solid-js" +import { Global } from "@/global" + +const id = "internal:home-footer" + +function Directory(props: { api: TuiPluginApi }) { + const theme = () => props.api.theme.current + const dir = createMemo(() => { + const dir = props.api.state.path.directory || process.cwd() + const out = dir.replace(Global.Path.home, "~") + const branch = props.api.state.vcs?.branch + if (branch) return out + ":" + branch + return out + }) + + return {dir()} +} + +function Mcp(props: { api: TuiPluginApi }) { + const theme = () => props.api.theme.current + const list = createMemo(() => props.api.state.mcp()) + const has = createMemo(() => list().length > 0) + const err = createMemo(() => list().some((item) => item.status === "failed")) + const count = createMemo(() => list().filter((item) => item.status === "connected").length) + + return ( + + + + + + + + + 0 ? theme().success : theme().textMuted }}>⊙ + + + {count()} MCP + + /status + + + ) +} + +function Version(props: { api: TuiPluginApi }) { + const theme = () => props.api.theme.current + + return ( + + {props.api.app.version} + + ) +} + +function View(props: { api: TuiPluginApi }) { + return ( + + + + + + + ) +} + +const tui: TuiPlugin = async (api) => { + api.slots.register({ + order: 100, + slots: { + home_footer() { + return + }, + }, + }) +} + +const plugin: TuiPluginModule & { id: string } = { + id, + tui, +} + +export default plugin diff --git a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts index 9e28bbd2e3..856ee0ebb1 100644 --- a/packages/opencode/src/cli/cmd/tui/plugin/internal.ts +++ b/packages/opencode/src/cli/cmd/tui/plugin/internal.ts @@ -1,3 +1,4 @@ +import HomeFooter from "../feature-plugins/home/footer" import HomeTips from "../feature-plugins/home/tips" import SidebarContext from "../feature-plugins/sidebar/context" import SidebarMcp from "../feature-plugins/sidebar/mcp" @@ -14,6 +15,7 @@ export type InternalTuiPlugin = TuiPluginModule & { } export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [ + HomeFooter, HomeTips, SidebarContext, SidebarMcp, diff --git a/packages/opencode/src/cli/cmd/tui/routes/home.tsx b/packages/opencode/src/cli/cmd/tui/routes/home.tsx index b63bf2d2df..8826df314b 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/home.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/home.tsx @@ -1,15 +1,11 @@ import { Prompt, type PromptRef } from "@tui/component/prompt" -import { createEffect, createMemo, Match, on, onMount, Show, Switch } from "solid-js" -import { useTheme } from "@tui/context/theme" +import { createEffect, on, onMount } from "solid-js" import { Logo } from "../component/logo" -import { Locale } from "@/util/locale" import { useSync } from "../context/sync" import { Toast } from "../ui/toast" import { useArgs } from "../context/args" -import { useDirectory } from "../context/directory" import { useRouteData } from "@tui/context/route" import { usePromptRef } from "../context/prompt" -import { Installation } from "@/installation" import { useLocal } from "../context/local" import { TuiPluginRuntime } from "../plugin" @@ -22,37 +18,8 @@ const placeholder = { export function Home() { const sync = useSync() - const { theme } = useTheme() const route = useRouteData("home") const promptRef = usePromptRef() - const mcp = createMemo(() => Object.keys(sync.data.mcp).length > 0) - const mcpError = createMemo(() => { - return Object.values(sync.data.mcp).some((x) => x.status === "failed") - }) - - const connectedMcpCount = createMemo(() => { - return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length - }) - - const Hint = ( - - 0}> - - - - mcp errors{" "} - ctrl+x s - - - {" "} - {Locale.pluralize(connectedMcpCount(), "{} mcp server", "{} mcp servers")} - - - - - - ) - let prompt: PromptRef | undefined const args = useArgs() const local = useLocal() @@ -81,7 +48,6 @@ export function Home() { }, ), ) - const directory = useDirectory() return ( <> @@ -101,7 +67,6 @@ export function Home() { prompt = r promptRef.set(r) }} - hint={Hint} workspaceID={route.workspaceID} placeholders={placeholder} /> @@ -111,28 +76,8 @@ export function Home() { - - {directory()} - - - - - - - - - 0 ? theme.success : theme.textMuted }}>⊙ - - - {connectedMcpCount()} MCP - - /status - - - - - {Installation.VERSION} - + + ) diff --git a/packages/plugin/src/tui.ts b/packages/plugin/src/tui.ts index bbf3494909..b082f6abe4 100644 --- a/packages/plugin/src/tui.ts +++ b/packages/plugin/src/tui.ts @@ -296,6 +296,7 @@ export type TuiSlotMap = { workspace_id?: string } home_bottom: {} + home_footer: {} sidebar_title: { session_id: string title: string From c2f78224ae59263eada831051a6ece1c65126b1a Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Mon, 30 Mar 2026 08:50:42 -0500 Subject: [PATCH 010/161] chore(app): cleanup (#20062) --- .../composer/session-question-dock.tsx | 167 ++-- packages/app/src/pages/session/file-tabs.tsx | 273 +++--- .../pages/session/use-session-commands.tsx | 794 ++++++++++-------- 3 files changed, 664 insertions(+), 570 deletions(-) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 7ba07b15d0..ef1e52d264 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -11,6 +11,47 @@ import { useSDK } from "@/context/sdk" const cache = new Map() +function Mark(props: { multi: boolean; picked: boolean; onClick?: (event: MouseEvent) => void }) { + return ( + + ) +} + +function Option(props: { + multi: boolean + picked: boolean + label: string + description?: string + disabled: boolean + onClick: VoidFunction +}) { + return ( + + ) +} + export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => { const sdk = useSDK() const language = useLanguage() @@ -41,6 +82,9 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit return language.t("session.question.progress", { current: n, total: total() }) }) + const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer") + const customPlaceholder = () => language.t("ui.question.custom.placeholder") + const last = createMemo(() => store.tab >= total() - 1) const customUpdate = (value: string, selected: boolean = on()) => { @@ -164,6 +208,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit const submit = () => void reply(questions().map((_, i) => store.answers[i] ?? [])) + const answered = (i: number) => { + if ((store.answers[i]?.length ?? 0) > 0) return true + return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0 + } + + const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false + const pick = (answer: string, custom: boolean = false) => { setStore("answers", store.tab, [answer]) if (custom) setStore("custom", store.tab, answer) @@ -230,6 +281,24 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit customUpdate(input()) } + const resizeInput = (el: HTMLTextAreaElement) => { + el.style.height = "0px" + el.style.height = `${el.scrollHeight}px` + } + + const focusCustom = (el: HTMLTextAreaElement) => { + setTimeout(() => { + el.focus() + resizeInput(el) + }, 0) + } + + const toggleCustomMark = (event: MouseEvent) => { + event.preventDefault() + event.stopPropagation() + customToggle() + } + const next = () => { if (sending()) return if (store.editing) commitCustom() @@ -270,10 +339,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit type="button" data-slot="question-progress-segment" data-active={i() === store.tab} - data-answered={ - (store.answers[i()]?.length ?? 0) > 0 || - (store.customOn[i()] === true && (store.custom[i()] ?? "").trim().length > 0) - } + data-answered={answered(i())} disabled={sending()} onClick={() => jump(i())} aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} @@ -307,43 +373,23 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
- {(opt, i) => { - const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false - return ( - - ) - }} + {(opt, i) => ( + - + - {language.t("ui.messagePart.option.typeOwnAnswer")} - {input() || language.t("ui.question.custom.placeholder")} + {customLabel()} + {input() || customPlaceholder()} } @@ -394,33 +426,13 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit commitCustom() }} > - + - {language.t("ui.messagePart.option.typeOwnAnswer")} + {customLabel()}