From 0bf44de3a0aae0532d2962c6d6ed6152b2cf02cf Mon Sep 17 00:00:00 2001 From: 6crucified <6crucified@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:49:44 +0100 Subject: [PATCH 1/3] Fix import metadata, file refresh, patch diff, and ui/docs regressions --- packages/app/src/context/file/watcher.test.ts | 23 ++++++ packages/app/src/context/file/watcher.ts | 8 +- packages/opencode/src/cli/cmd/import.ts | 4 +- .../src/cli/cmd/tui/context/theme.tsx | 10 ++- packages/opencode/src/tool/apply_patch.ts | 46 ++++++++++-- .../opencode/test/tool/apply_patch.test.ts | 37 +++++++++ packages/ui/src/components/message-part.css | 6 ++ packages/web/src/content/docs/skills.mdx | 50 ++++++------- script/verify-issues.sh | 75 +++++++++++++++++++ 9 files changed, 215 insertions(+), 44 deletions(-) create mode 100755 script/verify-issues.sh diff --git a/packages/app/src/context/file/watcher.test.ts b/packages/app/src/context/file/watcher.test.ts index 9536b52536b6..941f2b612a9a 100644 --- a/packages/app/src/context/file/watcher.test.ts +++ b/packages/app/src/context/file/watcher.test.ts @@ -58,6 +58,29 @@ describe("file watcher invalidation", () => { expect(loads).toEqual(["src/open.ts"]) }) + test("reloads file on direct file.edited events", () => { + const loads: string[] = [] + + invalidateFromWatcher( + { + type: "file.edited", + properties: { + file: "src/edited.ts", + }, + }, + { + normalize: (input) => input, + hasFile: (path) => path === "src/edited.ts", + loadFile: (path) => loads.push(path), + node: () => undefined, + isDirLoaded: () => false, + refreshDir: () => {}, + }, + ) + + expect(loads).toEqual(["src/edited.ts"]) + }) + test("refreshes only changed loaded directory nodes", () => { const refresh: string[] = [] diff --git a/packages/app/src/context/file/watcher.ts b/packages/app/src/context/file/watcher.ts index fbf71992791a..2872a3cae0e7 100644 --- a/packages/app/src/context/file/watcher.ts +++ b/packages/app/src/context/file/watcher.ts @@ -16,13 +16,10 @@ type WatcherOps = { } export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { - if (event.type !== "file.watcher.updated") return const props = typeof event.properties === "object" && event.properties ? (event.properties as Record) : undefined const rawPath = typeof props?.file === "string" ? props.file : undefined - const kind = typeof props?.event === "string" ? props.event : undefined if (!rawPath) return - if (!kind) return const path = ops.normalize(rawPath) if (!path) return @@ -32,6 +29,11 @@ export function invalidateFromWatcher(event: WatcherEvent, ops: WatcherOps) { ops.loadFile(path) } + if (event.type === "file.edited") return + if (event.type !== "file.watcher.updated") return + const kind = typeof props?.event === "string" ? props.event : undefined + if (!kind) return + if (kind === "change") { const dir = (() => { if (path === "") return "" diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 58c1928256a5..2b063fe52c0d 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -131,12 +131,12 @@ export const ImportCommand = cmd({ return } - const row = { ...Session.toRow(exportData.info), project_id: Instance.project.id } + const row = { ...Session.toRow(exportData.info), project_id: Instance.project.id, directory: Instance.worktree } Database.use((db) => db .insert(SessionTable) .values(row) - .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id } }) + .onConflictDoUpdate({ target: SessionTable.id, set: { project_id: row.project_id, directory: row.directory } }) .run(), ) diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx index 2320c08ccc6e..650dc4da23f4 100644 --- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx @@ -295,7 +295,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ }) function init() { - resolveSystemTheme() + if (store.active === "system") resolveSystemTheme() getCustomThemes() .then((custom) => { setStore( @@ -317,13 +317,11 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ onMount(init) function resolveSystemTheme() { - console.log("resolveSystemTheme") renderer .getPalette({ size: 16, }) .then((colors) => { - console.log(colors.palette) if (!colors.palette[0]) { if (store.active === "system") { setStore( @@ -384,6 +382,12 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ set(theme: string) { setStore("active", theme) kv.set("theme", theme) + if (theme === "system") { + setStore("ready", false) + resolveSystemTheme() + return + } + setStore("ready", true) }, get ready() { return store.ready diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 06293b6eba6e..85a3664ceb11 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -159,7 +159,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { } // Build per-file metadata for UI rendering (used for both permission and result) - const files = fileChanges.map((change) => ({ + const preview = fileChanges.map((change) => ({ filePath: change.filePath, relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"), type: change.type, @@ -180,7 +180,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { metadata: { filepath: relativePaths.join(", "), diff: totalDiff, - files, + files: preview, }, }) @@ -239,16 +239,46 @@ export const ApplyPatchTool = Tool.define("apply_patch", { } const diagnostics = await LSP.diagnostics() + // Recompute diffs from final on-disk content (after formatters subscribed to file.edited run). + const files = await Promise.all( + fileChanges.map(async (change) => { + const target = change.movePath ?? change.filePath + const exists = change.type === "delete" ? false : await fs.stat(target).then(() => true).catch(() => false) + const after = exists ? await fs.readFile(target, "utf-8") : "" + const diff = trimDiff(createTwoFilesPatch(change.filePath, target, change.oldContent, after)) + let additions = 0 + let deletions = 0 + for (const item of diffLines(change.oldContent, after)) { + if (item.added) additions += item.count || 0 + if (item.removed) deletions += item.count || 0 + } + return { + filePath: change.filePath, + relativePath: path.relative(Instance.worktree, target).replaceAll("\\", "/"), + type: change.type, + diff, + before: change.oldContent, + after, + additions, + deletions, + movePath: change.movePath, + } + }), + ) + const totalDiffFinal = files + .map((item) => item.diff) + .filter(Boolean) + .join("\n") + // Generate output summary - const summaryLines = fileChanges.map((change) => { + const summaryLines = files.map((change) => { if (change.type === "add") { - return `A ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + return `A ${change.relativePath}` } if (change.type === "delete") { - return `D ${path.relative(Instance.worktree, change.filePath).replaceAll("\\", "/")}` + return `D ${change.relativePath}` } - const target = change.movePath ?? change.filePath - return `M ${path.relative(Instance.worktree, target).replaceAll("\\", "/")}` + return `M ${change.relativePath}` }) let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` @@ -271,7 +301,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { return { title: output, metadata: { - diff: totalDiff, + diff: totalDiffFinal, files, diagnostics, }, diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index f81723fee097..8afcd81b3c45 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -3,6 +3,8 @@ import path from "path" import * as fs from "fs/promises" import { ApplyPatchTool } from "../../src/tool/apply_patch" import { Instance } from "../../src/project/instance" +import { Bus } from "../../src/bus" +import { File } from "../../src/file" import { tmpdir } from "../fixture/fixture" const baseCtx = { @@ -283,6 +285,41 @@ describe("tool.apply_patch freeform", () => { }) }) + test("result diff reflects post-edit formatter changes", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "repro.py") + await fs.writeFile(target, 'def a():\n """x"""\n return \'y\'\n', "utf-8") + + const stop = Bus.subscribe(File.Event.Edited, async (payload) => { + const file = payload.properties.file + if (!file.endsWith("repro.py")) return + const content = await fs.readFile(file, "utf-8") + await fs.writeFile(file, content.replace("'y'", '"y"'), "utf-8") + }) + + try { + const patchText = + "*** Begin Patch\n*** Update File: repro.py\n@@\n- \"\"\"x\"\"\"\n+ \"\"\"x updated\"\"\"\n*** End Patch" + const result = await execute({ patchText }, ctx) + const item = result.metadata.files.find((x: { relativePath: string; filePath: string }) => { + return x.relativePath.endsWith("repro.py") || x.filePath.endsWith("repro.py") + }) + expect(item).toBeDefined() + expect(item.after).toContain('"y"') + expect(item.diff).toContain('+ return "y"') + expect(result.metadata.diff).toContain('+ return "y"') + } finally { + stop() + } + }, + }) + }) + test("rejects update when target file is missing", async () => { await using fixture = await tmpdir() const { ctx } = makeCtx() diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index 3eee45c75fcb..c4f3fd0ef8a6 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -792,11 +792,17 @@ [data-slot="permission-footer-actions"] { display: flex; align-items: center; + justify-content: flex-end; + flex-wrap: wrap; gap: 8px; + row-gap: 8px; + max-width: 100%; [data-component="button"] { padding-left: 12px; padding-right: 12px; + max-width: 100%; + white-space: normal; } } } diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 2ce88ea5682f..7e7490b91b59 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -29,45 +29,33 @@ It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.cla Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md`, `~/.claude/skills/*/SKILL.md`, and `~/.agents/skills/*/SKILL.md`. +You can also add extra discovery locations in `opencode.json`: + +```json +{ + "skills": { + "paths": ["./vendor/skills", "~/my-skills"], + "urls": ["https://example.com/.well-known/skills/"] + } +} +``` + +`skills.urls` are downloaded and cached locally. Current behavior reuses cached files on subsequent runs and does not provide TTL-based auto-refresh. + --- ## Write frontmatter -Each `SKILL.md` must start with YAML frontmatter. -Only these fields are recognized: +Skills are read as Markdown with optional YAML frontmatter. +To be listed in ``, include: - `name` (required) - `description` (required) -- `license` (optional) -- `compatibility` (optional) -- `metadata` (optional, string-to-string map) Unknown frontmatter fields are ignored. --- - -## Validate names - -`name` must: - -- Be 1–64 characters -- Be lowercase alphanumeric with single hyphen separators -- Not start or end with `-` -- Not contain consecutive `--` -- Match the directory name that contains `SKILL.md` - -Equivalent regex: - -```text -^[a-z0-9]+(-[a-z0-9]+)*$ -``` - ---- - -## Follow length rules - -`description` must be 1-1024 characters. -Keep it specific enough for the agent to choose correctly. +Current loader behavior is intentionally permissive and does not enforce strict regex/length constraints for names or descriptions beyond parsing these fields. --- @@ -120,6 +108,12 @@ The agent loads a skill by calling the tool: skill({ name: "git-release" }) ``` +Tool output also includes: + +- `` in each listed skill entry +- The skill base directory (for resolving relative paths in the skill) +- A sampled `` section (not a guaranteed full recursive file list) + --- ## Configure permissions diff --git a/script/verify-issues.sh b/script/verify-issues.sh new file mode 100755 index 000000000000..8681275a76fb --- /dev/null +++ b/script/verify-issues.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +set -euo pipefail + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +ok=1 + +echo "== OpenCode issue verification ==" +echo "root: $root" +echo + +if ! command -v bun >/dev/null 2>&1; then + echo "bun not found in PATH" + echo "install bun, then re-run: ./script/verify-issues.sh" + exit 1 +fi + +run() { + local dir="$1" + local cmd="$2" + echo "-> $dir :: $cmd" + if (cd "$dir" && eval "$cmd"); then + echo "ok" + else + ok=0 + echo "fail" + fi + echo +} + +run "$root/packages/opencode" "bun test test/cli/import.test.ts test/tool/apply_patch.test.ts" +run "$root/packages/app" "bun test src/context/file/watcher.test.ts" + +echo "== Manual checks ==" +echo +echo "#15797 import project assignment" +echo "1. cd into a git-backed repo directory." +echo "2. run: opencode import session.json" +echo "3. open opencode in same dir." +echo "expect: imported session appears in that project (not global)." +echo +echo "#15996 file view auto-refresh" +echo "1. open a file tab in web/desktop." +echo "2. ask AI to edit same file." +echo "expect: tab content refreshes without reopen." +echo +echo "#15897 apply_patch shows formatter changes" +echo "1. create repro.py with single quotes." +echo "2. do apply_patch docstring edit." +echo "expect: returned diff includes formatter side-change (ex: single->double quote)." +echo +echo "#14964 long permission actions visible" +echo "1. trigger long bash permission prompt in web/app." +echo "expect: deny/allow buttons stay visible and wrap, no overflow off-screen." +echo +echo "#14965 startup lag in Ghostty" +echo "1. set non-system theme." +echo "2. launch opencode in Ghostty and compare startup feel." +echo "3. switch to system theme." +echo "expect: system palette resolves only when using system theme." +echo +echo "#15961 skills docs alignment" +echo "open packages/web/src/content/docs/skills.mdx and confirm:" +echo "- skills.paths and skills.urls documented" +echo "- remote cache behavior + no TTL refresh documented" +echo "- permissive loader behavior documented" +echo "- , base dir, sampled documented" +echo + +if [[ "$ok" -eq 1 ]]; then + echo "automated tests: pass" + exit 0 +fi + +echo "automated tests: fail" +exit 2 From bc7290e16673b17e36cfde467206a61428a346c1 Mon Sep 17 00:00:00 2001 From: 6crucified <6crucified@users.noreply.github.com> Date: Wed, 4 Mar 2026 16:54:17 +0100 Subject: [PATCH 2/3] Fix type narrowing in apply_patch formatter test --- packages/opencode/test/tool/apply_patch.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts index 8afcd81b3c45..aa65fea766dc 100644 --- a/packages/opencode/test/tool/apply_patch.test.ts +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -309,7 +309,7 @@ describe("tool.apply_patch freeform", () => { const item = result.metadata.files.find((x: { relativePath: string; filePath: string }) => { return x.relativePath.endsWith("repro.py") || x.filePath.endsWith("repro.py") }) - expect(item).toBeDefined() + if (!item) throw new Error("expected repro.py in metadata files") expect(item.after).toContain('"y"') expect(item.diff).toContain('+ return "y"') expect(result.metadata.diff).toContain('+ return "y"') From 9a35863f4bd8498a5814b9b0cd3ab48d5e241385 Mon Sep 17 00:00:00 2001 From: 6crucified <6crucified@users.noreply.github.com> Date: Wed, 4 Mar 2026 17:03:26 +0100 Subject: [PATCH 3/3] fix(opencode): harden windows test tmp cleanup --- packages/opencode/test/preload.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/test/preload.ts b/packages/opencode/test/preload.ts index 41028633e83e..0ed3059c7d6e 100644 --- a/packages/opencode/test/preload.ts +++ b/packages/opencode/test/preload.ts @@ -13,19 +13,21 @@ afterAll(async () => { Database.close() const busy = (error: unknown) => typeof error === "object" && error !== null && "code" in error && error.code === "EBUSY" + const win = process.platform === "win32" const rm = async (left: number): Promise => { Bun.gc(true) await Bun.sleep(100) return fs.rm(dir, { recursive: true, force: true }).catch((error) => { if (!busy(error)) throw error - if (left <= 1) throw error + if (left <= 1 && !win) throw error + if (left <= 1) return return rm(left - 1) }) } // Windows can keep SQLite WAL handles alive until GC finalizers run, so we // force GC and retry teardown to avoid flaky EBUSY in test cleanup. - await rm(30) + await rm(60) }) process.env["XDG_DATA_HOME"] = path.join(dir, "share")