Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/app/src/context/file/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []

Expand Down
8 changes: 5 additions & 3 deletions packages/app/src/context/file/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) : 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
Expand All @@ -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 ""
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/cli/cmd/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)

Expand Down
10 changes: 7 additions & 3 deletions packages/opencode/src/cli/cmd/tui/context/theme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({
})

function init() {
resolveSystemTheme()
if (store.active === "system") resolveSystemTheme()
getCustomThemes()
.then((custom) => {
setStore(
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
46 changes: 38 additions & 8 deletions packages/opencode/src/tool/apply_patch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -180,7 +180,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
metadata: {
filepath: relativePaths.join(", "),
diff: totalDiff,
files,
files: preview,
},
})

Expand Down Expand Up @@ -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")}`

Expand All @@ -271,7 +301,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
return {
title: output,
metadata: {
diff: totalDiff,
diff: totalDiffFinal,
files,
diagnostics,
},
Expand Down
6 changes: 4 additions & 2 deletions packages/opencode/test/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
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")
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/test/tool/apply_patch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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")
})
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"')
} finally {
stop()
}
},
})
})

test("rejects update when target file is missing", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
Expand Down
6 changes: 6 additions & 0 deletions packages/ui/src/components/message-part.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand Down
50 changes: 22 additions & 28 deletions packages/web/src/content/docs/skills.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<available_skills>`, 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.

---

Expand Down Expand Up @@ -120,6 +108,12 @@ The agent loads a skill by calling the tool:
skill({ name: "git-release" })
```

Tool output also includes:

- `<location>` in each listed skill entry
- The skill base directory (for resolving relative paths in the skill)
- A sampled `<skill_files>` section (not a guaranteed full recursive file list)

---

## Configure permissions
Expand Down
Loading
Loading