diff --git a/bun.lock b/bun.lock
index 77ab24240bb9..64b32feac4eb 100644
--- a/bun.lock
+++ b/bun.lock
@@ -688,7 +688,7 @@
"@tailwindcss/vite": "4.1.11",
"@tsconfig/bun": "1.0.9",
"@tsconfig/node22": "22.0.2",
- "@types/bun": "1.3.11",
+ "@types/bun": "1.3.12",
"@types/cross-spawn": "6.0.6",
"@types/luxon": "3.7.1",
"@types/node": "22.13.9",
@@ -2302,7 +2302,7 @@
"@types/braces": ["@types/braces@3.0.5", "", {}, "sha512-SQFof9H+LXeWNz8wDe7oN5zu7ket0qwMu5vZubW4GCJ8Kkeh6nBWUz87+KTz/G3Kqsrp0j/W253XJb3KMEeg3w=="],
- "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="],
+ "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="],
"@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="],
@@ -2720,7 +2720,7 @@
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
- "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
+ "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
diff --git a/nix/hashes.json b/nix/hashes.json
index 21279a327d0a..c09604610638 100644
--- a/nix/hashes.json
+++ b/nix/hashes.json
@@ -1,8 +1,8 @@
{
"nodeModules": {
- "x86_64-linux": "sha256-NczRp8MPppkqP8PQfWMUWJ/Wofvf2YVy5m4i22Pi3jg=",
- "aarch64-linux": "sha256-QIxGOu8Fj+sWgc9hKvm1BLiIErxEtd17SPlwZGac9sQ=",
- "aarch64-darwin": "sha256-Rb9qbMM+ARn0iBCaZurwcoUBCplbMXEZwrXVKextp3I=",
- "x86_64-darwin": "sha256-KVxOKkaVV7W+K4reEk14MTLgmtoqwCYDqDNXNeS6ync="
+ "x86_64-linux": "sha256-AgHhYsiygxbsBo3JN4HqHXKAwh8n1qeuSCe2qqxlxW4=",
+ "aarch64-linux": "sha256-h2lpWRQ5EDYnjpqZXtUAp1mxKLQxJ4m8MspgSY8Ev78=",
+ "aarch64-darwin": "sha256-xnd91+WyeAqn06run2ajsekxJvTMiLsnqNPe/rR8VTM=",
+ "x86_64-darwin": "sha256-rXpz45IOjGEk73xhP9VY86eOj2CZBg2l1vzwzTIOOOQ="
}
}
diff --git a/package.json b/package.json
index 06bf9c91aef0..f918bcd025f5 100644
--- a/package.json
+++ b/package.json
@@ -4,7 +4,7 @@
"description": "AI-powered development tool",
"private": true,
"type": "module",
- "packageManager": "bun@1.3.11",
+ "packageManager": "bun@1.3.13",
"scripts": {
"dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts",
"dev:desktop": "bun --cwd packages/desktop-electron dev",
@@ -30,7 +30,7 @@
"@effect/opentelemetry": "4.0.0-beta.48",
"@effect/platform-node": "4.0.0-beta.48",
"@npmcli/arborist": "9.4.0",
- "@types/bun": "1.3.11",
+ "@types/bun": "1.3.12",
"@types/cross-spawn": "6.0.6",
"@octokit/rest": "22.0.0",
"@hono/zod-validator": "0.4.2",
diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx
index ea5d70065adc..8eb12daf52e5 100644
--- a/packages/app/src/components/dialog-edit-project.tsx
+++ b/packages/app/src/components/dialog-edit-project.tsx
@@ -12,6 +12,7 @@ import { type LocalProject, getAvatarColors } from "@/context/layout"
import { getFilename } from "@opencode-ai/shared/util/path"
import { Avatar } from "@opencode-ai/ui/avatar"
import { useLanguage } from "@/context/language"
+import { getProjectAvatarSource } from "@/pages/layout/sidebar-items"
const AVATAR_COLOR_KEYS = ["pink", "mint", "orange", "purple", "cyan", "lime"] as const
@@ -26,8 +27,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
const [store, setStore] = createStore({
name: defaultName(),
- color: props.project.icon?.color || "pink",
- iconUrl: props.project.icon?.override || "",
+ color: props.project.icon?.color,
+ iconOverride: props.project.icon?.override,
startup: props.project.commands?.start ?? "",
dragOver: false,
iconHover: false,
@@ -39,7 +40,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
if (!file.type.startsWith("image/")) return
const reader = new FileReader()
reader.onload = (e) => {
- setStore("iconUrl", e.target?.result as string)
+ setStore("iconOverride", e.target?.result as string)
setStore("iconHover", false)
}
reader.readAsDataURL(file)
@@ -68,7 +69,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
}
function clearIcon() {
- setStore("iconUrl", "")
+ setStore("iconOverride", "")
}
const saveMutation = useMutation(() => ({
@@ -81,17 +82,17 @@ export function DialogEditProject(props: { project: LocalProject }) {
projectID: props.project.id,
directory: props.project.worktree,
name,
- icon: { color: store.color, override: store.iconUrl },
+ icon: { color: store.color || "", override: store.iconOverride || "" },
commands: { start },
})
- globalSync.project.icon(props.project.worktree, store.iconUrl || undefined)
+ globalSync.project.icon(props.project.worktree, store.iconOverride || undefined)
dialog.close()
return
}
globalSync.project.meta(props.project.worktree, {
name,
- icon: { color: store.color, override: store.iconUrl || undefined },
+ icon: { color: store.color || undefined, override: store.iconOverride || undefined },
commands: { start: start || undefined },
})
dialog.close()
@@ -130,13 +131,13 @@ export function DialogEditProject(props: { project: LocalProject }) {
classList={{
"border-text-interactive-base bg-surface-info-base/20": store.dragOver,
"border-border-base hover:border-border-strong": !store.dragOver,
- "overflow-hidden": !!store.iconUrl,
+ "overflow-hidden": !!store.iconOverride,
}}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => {
- if (store.iconUrl && store.iconHover) {
+ if (store.iconOverride && store.iconHover) {
clearIcon()
} else {
iconInput?.click()
@@ -144,7 +145,11 @@ export function DialogEditProject(props: { project: LocalProject }) {
}}
>
}
>
-
+ {(src) => (
+
+ )}
@@ -174,8 +181,8 @@ export function DialogEditProject(props: { project: LocalProject }) {
@@ -198,7 +205,7 @@ export function DialogEditProject(props: { project: LocalProject }) {
-
+
@@ -215,7 +222,10 @@ export function DialogEditProject(props: { project: LocalProject }) {
"bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base":
store.color !== color,
}}
- onClick={() => setStore("color", color)}
+ onClick={() => {
+ if (store.color === color && !props.project.icon?.url) return
+ setStore("color", store.color === color ? undefined : color)
+ }}
>
item.name === cmd)) {
setBusy()
try {
@@ -452,7 +452,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
- if (text.startsWith("/")) {
+ if (text.startsWith("/") || text.startsWith("$")) {
const [cmdName, ...args] = text.split(" ")
const commandName = cmdName.slice(1)
const customCommand = sync.data.command.find((c) => c.name === commandName)
diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx
index 74ea2853103a..90e357bd3355 100644
--- a/packages/app/src/context/layout.tsx
+++ b/packages/app/src/context/layout.tsx
@@ -516,7 +516,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
for (const project of projects) {
- if (project.icon?.color) continue
+ if (project.icon?.color || project.icon.url) continue
const worktree = project.worktree
const existing = colors[worktree]
const color = existing ?? pickAvailableColor(used)
diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx
index 45db5b5489bc..5170311a7b32 100644
--- a/packages/app/src/pages/layout/sidebar-items.tsx
+++ b/packages/app/src/pages/layout/sidebar-items.tsx
@@ -19,6 +19,14 @@ import { childSessionOnPath, hasProjectPermissions } from "./helpers"
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
+export function getProjectAvatarSource(id?: string, icon?: { color?: string; url?: string; override?: string }) {
+ return id === OPENCODE_PROJECT_ID
+ ? "https://opencode.ai/favicon.svg"
+ : icon?.color
+ ? undefined
+ : icon?.override || icon?.url
+}
+
export const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => {
const globalSync = useGlobalSync()
const notification = useNotification()
@@ -42,11 +50,7 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx
index 4bcd3c7bde41..34c8636c27c0 100644
--- a/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/dialog-skill.tsx
@@ -1,7 +1,7 @@
import { DialogSelect, type DialogSelectOption } from "@tui/ui/dialog-select"
-import { createResource, createMemo } from "solid-js"
+import { createMemo, onMount } from "solid-js"
import { useDialog } from "@tui/ui/dialog"
-import { useSDK } from "@tui/context/sdk"
+import { useSkillCatalog } from "@tui/context/skills"
export type DialogSkillProps = {
onSelect: (skill: string) => void
@@ -9,16 +9,14 @@ export type DialogSkillProps = {
export function DialogSkill(props: DialogSkillProps) {
const dialog = useDialog()
- const sdk = useSDK()
+ const skillCatalog = useSkillCatalog()
dialog.setSize("large")
-
- const [skills] = createResource(async () => {
- const result = await sdk.client.app.skills()
- return result.data ?? []
+ onMount(() => {
+ void skillCatalog.refresh()
})
const options = createMemo[]>(() => {
- const list = skills() ?? []
+ const list = skillCatalog.skills()
const maxWidth = Math.max(0, ...list.map((s) => s.name.length))
return list.map((skill) => ({
title: skill.name.padEnd(maxWidth),
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
index 305d07622392..40db714ca3c2 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx
@@ -6,6 +6,7 @@ import { createMemo, createResource, createEffect, onMount, onCleanup, Index, Sh
import { createStore } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
import { useSync } from "@tui/context/sync"
+import { useSkillCatalog } from "@tui/context/skills"
import { getScrollAcceleration } from "../../util/scroll"
import { useTuiConfig } from "../../context/tui-config"
import { useTheme, selectedForeground } from "@tui/context/theme"
@@ -51,7 +52,7 @@ function extractLineRange(input: string) {
export type AutocompleteRef = {
onInput: (value: string) => void
onKeyDown: (e: KeyEvent) => void
- visible: false | "@" | "/"
+ visible: false | "@" | "/" | "$"
}
export type AutocompleteOption = {
@@ -75,6 +76,7 @@ export function Autocomplete(props: {
ref: (ref: AutocompleteRef) => void
fileStyleId: number
agentStyleId: number
+ skillStyleId: number
promptPartTypeId: () => number
}) {
const sdk = useSDK()
@@ -84,6 +86,7 @@ export function Autocomplete(props: {
const dimensions = useTerminalDimensions()
const frecency = useFrecency()
const tuiConfig = useTuiConfig()
+ const skillCatalog = useSkillCatalog()
const [store, setStore] = createStore({
index: 0,
@@ -157,7 +160,8 @@ export function Autocomplete(props: {
const charAfterCursor = props.value.at(currentCursorOffset)
const needsSpace = charAfterCursor !== " "
- const append = "@" + text + (needsSpace ? " " : "")
+ const prefix = part.type === "agent" ? "@" : ""
+ const append = prefix + text + (needsSpace ? " " : "")
input.cursorOffset = store.index
const startCursor = input.logicalCursor
@@ -167,7 +171,7 @@ export function Autocomplete(props: {
input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
input.insertText(append)
- const virtualText = "@" + text
+ const virtualText = prefix + text
const extmarkStart = store.index
const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
@@ -221,6 +225,50 @@ export function Autocomplete(props: {
}
}
+ function insertSkill(name: string) {
+ const input = props.input()
+ const currentCursorOffset = input.cursorOffset
+ const charAfterCursor = props.value.at(currentCursorOffset)
+ const needsSpace = charAfterCursor !== " "
+ const virtualText = "$" + name
+ const append = virtualText + (needsSpace ? " " : "")
+
+ input.cursorOffset = store.index
+ const startCursor = input.logicalCursor
+ input.cursorOffset = currentCursorOffset
+ const endCursor = input.logicalCursor
+
+ input.deleteRange(startCursor.row, startCursor.col, endCursor.row, endCursor.col)
+ input.insertText(append)
+
+ const extmarkStart = store.index
+ const extmarkEnd = extmarkStart + Bun.stringWidth(virtualText)
+ const extmarkId = input.extmarks.create({
+ start: extmarkStart,
+ end: extmarkEnd,
+ virtual: true,
+ styleId: props.skillStyleId,
+ typeId: props.promptPartTypeId(),
+ })
+
+ props.setPrompt((draft) => {
+ const partIndex = draft.parts.length
+ draft.parts.push({
+ type: "text",
+ text: virtualText,
+ source: {
+ kind: "skill",
+ text: {
+ start: extmarkStart,
+ end: extmarkEnd,
+ value: virtualText,
+ },
+ },
+ })
+ props.setExtmark(partIndex, extmarkId)
+ })
+ }
+
const [files] = createResource(
() => search(),
async (query) => {
@@ -356,6 +404,30 @@ export function Autocomplete(props: {
)
})
+ const skills = createMemo((): AutocompleteOption[] => {
+ const list = skillCatalog.skills()
+ const results = list.map(
+ (skill): AutocompleteOption => ({
+ display: skill.name,
+ value: skill.name,
+ description: skill.description,
+ onSelect: () => {
+ insertSkill(skill.name)
+ },
+ }),
+ )
+
+ results.sort((a, b) => a.display.localeCompare(b.display))
+
+ const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length
+ if (!max) return results
+ const maxDisplay = Math.min(max!, 20)
+ return results.map((item) => ({
+ ...item,
+ display: item.display.length > 20 ? item.display.slice(0, 17) + "... " : item.display.padEnd(maxDisplay + 1),
+ }))
+ })
+
const commands = createMemo((): AutocompleteOption[] => {
const results: AutocompleteOption[] = [...command.slashes()]
@@ -389,9 +461,14 @@ export function Autocomplete(props: {
const filesValue = files()
const agentsValue = agents()
const commandsValue = commands()
+ const skillsValue = skills()
const mixed: AutocompleteOption[] =
- store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue]
+ store.visible === "@"
+ ? [...agentsValue, ...(filesValue || []), ...mcpResources()]
+ : store.visible === "$"
+ ? [...skillsValue]
+ : [...commandsValue]
const searchValue = search()
@@ -478,12 +555,15 @@ export function Autocomplete(props: {
setStore("selected", 0)
}
- function show(mode: "@" | "/") {
+ function show(mode: "@" | "/" | "$") {
command.keybinds(false)
setStore({
visible: mode,
index: props.input().cursorOffset,
})
+ if (mode === "$") {
+ void skillCatalog.refresh()
+ }
}
function hide() {
@@ -531,15 +611,16 @@ export function Autocomplete(props: {
return
}
- // Check for "@" trigger - find the nearest "@" before cursor with no whitespace between
+ // Check for "@" or "$" trigger - find the nearest trigger before cursor with no whitespace between
const text = value.slice(0, offset)
- const idx = text.lastIndexOf("@")
+ const idx = Math.max(text.lastIndexOf("@"), text.lastIndexOf("$"))
if (idx === -1) return
+ const trigger = text[idx]
const between = text.slice(idx)
const before = idx === 0 ? undefined : value[idx - 1]
if ((before === undefined || /\s/.test(before)) && !between.match(/\s/)) {
- show("@")
+ show(trigger === "$" ? "$" : "@")
setStore("index", idx)
}
},
@@ -592,6 +673,14 @@ export function Autocomplete(props: {
if (canTrigger) show("@")
}
+ if (e.name === "$") {
+ const cursorOffset = props.input().cursorOffset
+ const charBeforeCursor =
+ cursorOffset === 0 ? undefined : props.input().getTextRange(cursorOffset - 1, cursorOffset)
+ const canTrigger = charBeforeCursor === undefined || charBeforeCursor === "" || /\s/.test(charBeforeCursor)
+ if (canTrigger) show("$")
+ }
+
if (e.name === "/") {
if (props.input().cursorOffset === 0) show("/")
}
@@ -602,9 +691,7 @@ export function Autocomplete(props: {
const height = createMemo(() => {
const count = options().length || 1
- if (!store.visible) return Math.min(10, count)
- positionTick()
- return Math.min(10, count, Math.max(1, props.anchor().y))
+ return Math.min(10, count)
})
let scroll: ScrollBoxRenderable
@@ -640,6 +727,9 @@ export function Autocomplete(props: {
{
@@ -655,14 +745,20 @@ export function Autocomplete(props: {
}}
onMouseUp={() => select()}
>
-
+
{option().display}
+
+
+ {" "}
+ {option().description}
+
+
-
-
- {option().description}
-
-
)}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
index 03db74de9496..e3e9226ed94b 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/history.tsx
@@ -13,15 +13,16 @@ export type PromptInfo = {
parts: (
| Omit
| Omit
- | (Omit & {
- source?: {
- text: {
- start: number
- end: number
- value: string
+ | (Omit & {
+ source?: {
+ text: {
+ start: number
+ end: number
+ value: string
+ }
+ kind?: "paste" | "skill"
}
- }
- })
+ })
)[]
}
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
index 2e08e66a4a2d..b71880ff77a5 100644
--- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
@@ -17,6 +17,7 @@ import { createStore, produce, unwrap } from "solid-js/store"
import { useKeybind } from "@tui/context/keybind"
import { usePromptHistory, type PromptInfo } from "./history"
import { assign } from "./part"
+import { expand, has } from "./skill"
import { usePromptStash } from "./stash"
import { DialogStash } from "../dialog-stash"
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
@@ -39,6 +40,7 @@ import { useKV } from "../../context/kv"
import { createFadeIn } from "../../util/signal"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
+import { useSkillCatalog } from "@tui/context/skills"
import { DialogWorkspaceCreate, restoreWorkspaceSession } from "../dialog-workspace-create"
import { DialogWorkspaceUnavailable } from "../dialog-workspace-unavailable"
import { useArgs } from "@tui/context/args"
@@ -106,6 +108,7 @@ export function Prompt(props: PromptProps) {
const renderer = useRenderer()
const { theme, syntax } = useTheme()
const kv = useKV()
+ const skillCatalog = useSkillCatalog()
const animationsEnabled = createMemo(() => kv.get("animations_enabled", true))
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
@@ -129,6 +132,7 @@ export function Prompt(props: PromptProps) {
const fileStyleId = syntax().getStyleId("extmark.file")!
const agentStyleId = syntax().getStyleId("extmark.agent")!
const pasteStyleId = syntax().getStyleId("extmark.paste")!
+ const skillStyleId = syntax().getStyleId("extmark.skill")!
let promptPartTypeId = 0
const event = useEvent()
@@ -517,7 +521,7 @@ export function Prompt(props: PromptProps) {
start = part.source.text.start
end = part.source.text.end
virtualText = part.source.text.value
- styleId = pasteStyleId
+ styleId = part.source.kind === "skill" ? skillStyleId : pasteStyleId
}
if (virtualText) {
@@ -764,6 +768,10 @@ export function Prompt(props: PromptProps) {
})),
})
} else {
+ if (has(inputText)) {
+ await skillCatalog.refresh()
+ inputText = expand(inputText, (name) => skillCatalog.get(name))
+ }
sdk.client.session
.prompt({
sessionID,
@@ -830,6 +838,7 @@ export function Prompt(props: PromptProps) {
type: "text" as const,
text,
source: {
+ kind: "paste",
text: {
start: extmarkStart,
end: extmarkEnd,
@@ -968,6 +977,7 @@ export function Prompt(props: PromptProps) {
value={store.prompt.input}
fileStyleId={fileStyleId}
agentStyleId={agentStyleId}
+ skillStyleId={skillStyleId}
promptPartTypeId={() => promptPartTypeId}
/>
(anchor = r)} visible={props.visible !== false}>
diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/skill.ts b/packages/opencode/src/cli/cmd/tui/component/prompt/skill.ts
new file mode 100644
index 000000000000..ab64959d2c01
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/component/prompt/skill.ts
@@ -0,0 +1,67 @@
+type Item = {
+ name: string
+ content: string
+}
+
+type Span = {
+ start: number
+ end: number
+ text: string
+}
+
+const REGEX = /\$([a-zA-Z][a-zA-Z0-9_-]*)/g
+
+function body(item: Item) {
+ return `## Skill: ${item.name}\n\n${item.content.trim()}`
+}
+
+function wrap(text: string, start: number, end: number, value: string) {
+ const prev = start === 0 ? "" : text[start - 1]
+ const next = text[end] ?? ""
+ const pre = !prev || prev === "\n" ? "" : "\n"
+ const post = !next || /\s/.test(next) ? "" : "\n"
+ return `${pre}${value}${post}`
+}
+
+function gap(text: string, start: number, end: number) {
+ const prev = start === 0 ? "" : text[start - 1]
+ const next = text[end] ?? ""
+ if (prev && !/\s|[(\[]/.test(prev)) return false
+ if (next && !/\s|[)\].,!?:;]/.test(next)) return false
+ return true
+}
+
+export function has(text: string) {
+ for (const match of text.matchAll(REGEX)) {
+ const name = match[1]
+ const start = match.index ?? 0
+ const end = start + match[0].length
+ if (!name || !gap(text, start, end)) continue
+ return true
+ }
+ return false
+}
+
+export function expand(text: string, get: (name: string) => Item | undefined) {
+ const spans: Span[] = []
+
+ for (const match of text.matchAll(REGEX)) {
+ const name = match[1]
+ const start = match.index ?? 0
+ const end = start + match[0].length
+ if (!name || !gap(text, start, end)) continue
+ const item = get(name)
+ if (!item) continue
+ spans.push({
+ start,
+ end,
+ text: wrap(text, start, end, body(item)),
+ })
+ }
+
+ if (spans.length === 0) return text
+
+ return spans
+ .toSorted((a, b) => b.start - a.start)
+ .reduce((acc, item) => acc.slice(0, item.start) + item.text + acc.slice(item.end), text)
+}
diff --git a/packages/opencode/src/cli/cmd/tui/context/skills.tsx b/packages/opencode/src/cli/cmd/tui/context/skills.tsx
new file mode 100644
index 000000000000..d6495da096cd
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/context/skills.tsx
@@ -0,0 +1,110 @@
+import { batch, createEffect, on } from "solid-js"
+import { createStore } from "solid-js/store"
+import { createSimpleContext } from "./helper"
+import { useSDK } from "./sdk"
+import { useProject } from "./project"
+
+export type SkillInfo = {
+ name: string
+ description: string
+ content: string
+ location: string
+}
+
+export const { use: useSkillCatalog, provider: SkillCatalogProvider } = createSimpleContext({
+ name: "SkillCatalog",
+ init: () => {
+ const sdk = useSDK()
+ const project = useProject()
+ const [store, setStore] = createStore({
+ loading: false,
+ workspace: undefined as string | undefined,
+ skills: [] as SkillInfo[],
+ })
+
+ const requests = new Map>()
+ const cache = new Map()
+
+ function key(workspace: string | undefined) {
+ return workspace ?? ""
+ }
+
+ function sync(workspace: string | undefined, skills: SkillInfo[]) {
+ if (project.workspace.current() !== workspace) return
+ batch(() => {
+ setStore("workspace", workspace)
+ setStore("skills", skills)
+ })
+ }
+
+ const load = async (opts?: { refresh?: boolean }) => {
+ const workspace = project.workspace.current()
+ const id = key(workspace)
+ const request = requests.get(id)
+ if (request) return request
+
+ const cached = cache.get(id)
+ if (cached && !opts?.refresh) {
+ sync(workspace, cached)
+ return cached
+ }
+
+ batch(() => {
+ setStore("workspace", workspace)
+ setStore("loading", true)
+ setStore("skills", cached ?? [])
+ })
+
+ const next = sdk.client.app
+ .skills({ workspace })
+ .then((result) => result.data ?? [])
+ .then((skills) => {
+ cache.set(id, skills)
+ sync(workspace, skills)
+ return skills
+ })
+ .catch(() => {
+ const fallback = cache.get(id) ?? (store.workspace === workspace ? store.skills : [])
+ sync(workspace, fallback)
+ return fallback
+ })
+ .finally(() => {
+ requests.delete(id)
+ if (project.workspace.current() !== workspace) return
+ setStore("loading", false)
+ })
+
+ requests.set(id, next)
+ return next
+ }
+
+ const refresh = () => load({ refresh: true })
+
+ createEffect(
+ on(
+ () => project.workspace.current(),
+ () => {
+ void refresh()
+ },
+ { defer: true },
+ ),
+ )
+
+ void load()
+
+ return {
+ data: store,
+ load,
+ refresh,
+ loading() {
+ return store.loading
+ },
+ skills() {
+ return store.skills
+ },
+ get(name: string) {
+ return store.skills.find((skill) => skill.name === name)
+ },
+ }
+ },
+})
diff --git a/packages/opencode/src/cli/cmd/tui/context/theme.tsx b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
index af9582cfb063..5543a726733c 100644
--- a/packages/opencode/src/cli/cmd/tui/context/theme.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/theme.tsx
@@ -773,6 +773,13 @@ function getSyntaxRules(theme: Theme) {
bold: true,
},
},
+ {
+ scope: ["extmark.skill"],
+ style: {
+ foreground: theme.primary,
+ bold: true,
+ },
+ },
{
scope: ["comment"],
style: {
diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
index dda9a5a8ed8e..5fdb01d6d520 100644
--- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
+++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx
@@ -316,6 +316,9 @@ export function DialogSelect(props: DialogSelectProps) {
{
setStore("input", "mouse")
diff --git a/packages/opencode/src/format/index.ts b/packages/opencode/src/format/index.ts
index 85934ce9c9a3..53a2c10119b1 100644
--- a/packages/opencode/src/format/index.ts
+++ b/packages/opencode/src/format/index.ts
@@ -25,7 +25,7 @@ export type Status = z.infer
export interface Interface {
readonly init: () => Effect.Effect
readonly status: () => Effect.Effect
- readonly file: (filepath: string) => Effect.Effect
+ readonly file: (filepath: string) => Effect.Effect
}
export class Service extends Context.Service()("@opencode/Format") {}
@@ -70,16 +70,19 @@ export const layer = Layer.effect(
}
}),
)
- return checks.filter((x) => x.cmd).map((x) => ({ item: x.item, cmd: x.cmd! }))
+ return checks
+ .filter((x): x is { item: Formatter.Info; cmd: string[] } => x.cmd !== false)
+ .map((x) => ({ item: x.item, cmd: x.cmd }))
}
function formatFile(filepath: string) {
return Effect.gen(function* () {
log.info("formatting", { file: filepath })
- const ext = path.extname(filepath)
+ const formatters = yield* Effect.promise(() => getFormatter(path.extname(filepath)))
- for (const { item, cmd } of yield* Effect.promise(() => getFormatter(ext))) {
- if (cmd === false) continue
+ if (!formatters.length) return false
+
+ for (const { item, cmd } of formatters) {
log.info("running", { command: cmd })
const replaced = cmd.map((x) => x.replace("$FILE", filepath))
const dir = yield* InstanceState.directory
@@ -113,6 +116,8 @@ export const layer = Layer.effect(
})
}
}
+
+ return true
})
}
@@ -188,7 +193,7 @@ export const layer = Layer.effect(
const file = Effect.fn("Format.file")(function* (filepath: string) {
const { formatFile } = yield* InstanceState.get(state)
- yield* formatFile(filepath)
+ return yield* formatFile(filepath)
})
return Service.of({ init, status, file })
diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts
index 19e1d7555bb0..3662f9e908ae 100644
--- a/packages/opencode/src/patch/index.ts
+++ b/packages/opencode/src/patch/index.ts
@@ -3,6 +3,7 @@ import * as path from "path"
import * as fs from "fs/promises"
import { readFileSync } from "fs"
import { Log } from "../util"
+import * as Bom from "../util/bom"
const log = Log.create({ service: "patch" })
@@ -305,18 +306,19 @@ export function maybeParseApplyPatch(
interface ApplyPatchFileUpdate {
unified_diff: string
content: string
+ bom: boolean
}
export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFileChunk[]): ApplyPatchFileUpdate {
// Read original file content
- let originalContent: string
+ let originalContent: ReturnType
try {
- originalContent = readFileSync(filePath, "utf-8")
+ originalContent = Bom.split(readFileSync(filePath, "utf-8"))
} catch (error) {
throw new Error(`Failed to read file ${filePath}: ${error}`, { cause: error })
}
- let originalLines = originalContent.split("\n")
+ let originalLines = originalContent.text.split("\n")
// Drop trailing empty element for consistent line counting
if (originalLines.length > 0 && originalLines[originalLines.length - 1] === "") {
@@ -331,14 +333,16 @@ export function deriveNewContentsFromChunks(filePath: string, chunks: UpdateFile
newLines.push("")
}
- const newContent = newLines.join("\n")
+ const next = Bom.split(newLines.join("\n"))
+ const newContent = next.text
// Generate unified diff
- const unifiedDiff = generateUnifiedDiff(originalContent, newContent)
+ const unifiedDiff = generateUnifiedDiff(originalContent.text, newContent)
return {
unified_diff: unifiedDiff,
content: newContent,
+ bom: originalContent.bom || next.bom,
}
}
@@ -553,13 +557,13 @@ export async function applyHunksToFiles(hunks: Hunk[]): Promise {
await fs.mkdir(moveDir, { recursive: true })
}
- await fs.writeFile(hunk.move_path, fileUpdate.content, "utf-8")
+ await fs.writeFile(hunk.move_path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8")
await fs.unlink(hunk.path)
modified.push(hunk.move_path)
log.info(`Moved file: ${hunk.path} -> ${hunk.move_path}`)
} else {
// Regular update
- await fs.writeFile(hunk.path, fileUpdate.content, "utf-8")
+ await fs.writeFile(hunk.path, Bom.join(fileUpdate.content, fileUpdate.bom), "utf-8")
modified.push(hunk.path)
log.info(`Updated file: ${hunk.path}`)
}
diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts
index c61cb7850900..84d314f476ff 100644
--- a/packages/opencode/src/plugin/codex.ts
+++ b/packages/opencode/src/plugin/codex.ts
@@ -374,6 +374,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise {
"gpt-5.3-codex",
"gpt-5.4",
"gpt-5.4-mini",
+ "gpt-5.5",
])
for (const [modelId, model] of Object.entries(provider.models)) {
if (modelId.includes("codex")) continue
diff --git a/packages/opencode/src/server/workspace.ts b/packages/opencode/src/server/workspace.ts
index d30a117d6a4a..87370d1c23c8 100644
--- a/packages/opencode/src/server/workspace.ts
+++ b/packages/opencode/src/server/workspace.ts
@@ -10,6 +10,7 @@ import { Instance } from "@/project/instance"
import { Session } from "@/session"
import { SessionID } from "@/session/schema"
import { AppRuntime } from "@/effect/app-runtime"
+import * as LocalContext from "@/util/local-context"
import { Effect } from "effect"
import { Log } from "@/util"
import { ServerProxy } from "./proxy"
@@ -39,14 +40,23 @@ function getSessionID(url: URL) {
return SessionID.make(id)
}
+function isLocalContextNotFound(err: unknown): err is LocalContext.NotFound {
+ return err instanceof LocalContext.NotFound
+}
+
async function getSessionWorkspace(url: URL) {
const id = getSessionID(url)
if (!id) return null
- const session = await AppRuntime.runPromise(
- Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")),
- ).catch(() => undefined)
- return session?.workspaceID
+ try {
+ const session = await AppRuntime.runPromise(
+ Session.Service.use((svc) => svc.get(id)).pipe(Effect.withSpan("WorkspaceRouter.lookup")),
+ )
+ return session?.workspaceID
+ } catch (err) {
+ if (!isLocalContextNotFound(err)) throw err
+ return null
+ }
}
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts
index 508c72cc8fd9..5ca825a1e195 100644
--- a/packages/opencode/src/session/prompt.ts
+++ b/packages/opencode/src/session/prompt.ts
@@ -116,7 +116,7 @@ export const layer = Layer.effect(
})
const cancel = Effect.fn("SessionPrompt.cancel")(function* (sessionID: SessionID) {
- yield* elog.info("cancel", { sessionID })
+ log.clone().tag("sessionID", sessionID).debug("cancel")
yield* state.cancel(sessionID)
})
@@ -1305,14 +1305,14 @@ NOTE: At any point in time through this workflow you should feel free to ask the
const runLoop: (sessionID: SessionID) => Effect.Effect = Effect.fn("SessionPrompt.run")(
function* (sessionID: SessionID) {
const ctx = yield* InstanceState.context
- const slog = elog.with({ sessionID })
+ const loopLog = log.clone().tag("sessionID", sessionID)
let structured: unknown | undefined
let step = 0
const session = yield* sessions.get(sessionID)
while (true) {
yield* status.set(sessionID, { type: "busy" })
- yield* slog.info("loop", { step })
+ loopLog.debug("loop", { step })
let msgs = yield* MessageV2.filterCompactedEffect(sessionID)
@@ -1348,7 +1348,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
!hasToolCalls &&
lastUser.id < lastAssistant.id
) {
- yield* slog.info("exiting loop")
+ loopLog.debug("exiting loop")
break
}
@@ -1545,7 +1545,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
)
const command = Effect.fn("SessionPrompt.command")(function* (input: CommandInput) {
- yield* elog.info("command", { sessionID: input.sessionID, command: input.command, agent: input.agent })
+ log.clone().tag("sessionID", input.sessionID).debug("command", { command: input.command, agent: input.agent })
const cmd = yield* commands.get(input.command)
if (!cmd) {
const available = (yield* commands.list()).map((c) => c.name)
diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts
index 7da7dd255c52..a4cf1e853f3c 100644
--- a/packages/opencode/src/tool/apply_patch.ts
+++ b/packages/opencode/src/tool/apply_patch.ts
@@ -14,6 +14,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import DESCRIPTION from "./apply_patch.txt"
import { File } from "../file"
import { Format } from "../format"
+import * as Bom from "@/util/bom"
const PatchParams = z.object({
patchText: z.string().describe("The full patch text that describes all changes to be made"),
@@ -59,6 +60,7 @@ export const ApplyPatchTool = Tool.define(
diff: string
additions: number
deletions: number
+ bom: boolean
}> = []
let totalDiff = ""
@@ -72,11 +74,12 @@ export const ApplyPatchTool = Tool.define(
const oldContent = ""
const newContent =
hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n`
- const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent))
+ const next = Bom.split(newContent)
+ const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, next.text))
let additions = 0
let deletions = 0
- for (const change of diffLines(oldContent, newContent)) {
+ for (const change of diffLines(oldContent, next.text)) {
if (change.added) additions += change.count || 0
if (change.removed) deletions += change.count || 0
}
@@ -84,11 +87,12 @@ export const ApplyPatchTool = Tool.define(
fileChanges.push({
filePath,
oldContent,
- newContent,
+ newContent: next.text,
type: "add",
diff,
additions,
deletions,
+ bom: next.bom,
})
totalDiff += diff + "\n"
@@ -104,13 +108,16 @@ export const ApplyPatchTool = Tool.define(
)
}
- const oldContent = yield* afs.readFileString(filePath)
+ const source = yield* Bom.readFile(afs, filePath)
+ const oldContent = source.text
let newContent = oldContent
+ let bom = source.bom
// Apply the update chunks to get new content
try {
const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks)
newContent = fileUpdate.content
+ bom = fileUpdate.bom
} catch (error) {
return yield* Effect.fail(new Error(`apply_patch verification failed: ${error}`))
}
@@ -136,6 +143,7 @@ export const ApplyPatchTool = Tool.define(
diff,
additions,
deletions,
+ bom,
})
totalDiff += diff + "\n"
@@ -143,17 +151,16 @@ export const ApplyPatchTool = Tool.define(
}
case "delete": {
- const contentToDelete = yield* afs
- .readFileString(filePath)
- .pipe(
- Effect.catch((error) =>
- Effect.fail(
- new Error(
- `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
- ),
+ const source = yield* Bom.readFile(afs, filePath).pipe(
+ Effect.catch((error) =>
+ Effect.fail(
+ new Error(
+ `apply_patch verification failed: ${error instanceof Error ? error.message : String(error)}`,
),
),
- )
+ ),
+ )
+ const contentToDelete = source.text
const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, ""))
const deletions = contentToDelete.split("\n").length
@@ -166,6 +173,7 @@ export const ApplyPatchTool = Tool.define(
diff: deleteDiff,
additions: 0,
deletions,
+ bom: source.bom,
})
totalDiff += deleteDiff + "\n"
@@ -207,12 +215,12 @@ export const ApplyPatchTool = Tool.define(
case "add":
// Create parent directories (recursive: true is safe on existing/root dirs)
- yield* afs.writeWithDirs(change.filePath, change.newContent)
+ yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom))
updates.push({ file: change.filePath, event: "add" })
break
case "update":
- yield* afs.writeWithDirs(change.filePath, change.newContent)
+ yield* afs.writeWithDirs(change.filePath, Bom.join(change.newContent, change.bom))
updates.push({ file: change.filePath, event: "change" })
break
@@ -220,7 +228,7 @@ export const ApplyPatchTool = Tool.define(
if (change.movePath) {
// Create parent directories (recursive: true is safe on existing/root dirs)
- yield* afs.writeWithDirs(change.movePath!, change.newContent)
+ yield* afs.writeWithDirs(change.movePath!, Bom.join(change.newContent, change.bom))
yield* afs.remove(change.filePath)
updates.push({ file: change.filePath, event: "unlink" })
updates.push({ file: change.movePath, event: "add" })
@@ -234,7 +242,9 @@ export const ApplyPatchTool = Tool.define(
}
if (edited) {
- yield* format.file(edited)
+ if (yield* format.file(edited)) {
+ yield* Bom.syncFile(afs, edited, change.bom)
+ }
yield* bus.publish(File.Event.Edited, { file: edited })
}
}
diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts
index 2c6c2c13084a..858d14e043fe 100644
--- a/packages/opencode/src/tool/edit.ts
+++ b/packages/opencode/src/tool/edit.ts
@@ -18,6 +18,7 @@ import { Instance } from "../project/instance"
import { Snapshot } from "@/snapshot"
import { assertExternalDirectoryEffect } from "./external-directory"
import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+import * as Bom from "@/util/bom"
function normalizeLineEndings(text: string): string {
return text.replaceAll("\r\n", "\n")
@@ -84,7 +85,11 @@ export const EditTool = Tool.define(
Effect.gen(function* () {
if (params.oldString === "") {
const existed = yield* afs.existsSafe(filePath)
- contentNew = params.newString
+ const source = existed ? yield* Bom.readFile(afs, filePath) : { bom: false, text: "" }
+ const next = Bom.split(params.newString)
+ const desiredBom = source.bom || next.bom
+ contentOld = source.text
+ contentNew = next.text
diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
@@ -95,8 +100,10 @@ export const EditTool = Tool.define(
diff,
},
})
- yield* afs.writeWithDirs(filePath, params.newString)
- yield* format.file(filePath)
+ yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))
+ if (yield* format.file(filePath)) {
+ contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
+ }
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
@@ -108,13 +115,16 @@ export const EditTool = Tool.define(
const info = yield* afs.stat(filePath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!info) throw new Error(`File ${filePath} not found`)
if (info.type === "Directory") throw new Error(`Path is a directory, not a file: ${filePath}`)
- contentOld = yield* afs.readFileString(filePath)
+ const source = yield* Bom.readFile(afs, filePath)
+ contentOld = source.text
const ending = detectLineEnding(contentOld)
const old = convertToLineEnding(normalizeLineEndings(params.oldString), ending)
- const next = convertToLineEnding(normalizeLineEndings(params.newString), ending)
+ const replacement = convertToLineEnding(normalizeLineEndings(params.newString), ending)
- contentNew = replace(contentOld, old, next, params.replaceAll)
+ const next = Bom.split(replace(contentOld, old, replacement, params.replaceAll))
+ const desiredBom = source.bom || next.bom
+ contentNew = next.text
diff = trimDiff(
createTwoFilesPatch(
@@ -134,14 +144,15 @@ export const EditTool = Tool.define(
},
})
- yield* afs.writeWithDirs(filePath, contentNew)
- yield* format.file(filePath)
+ yield* afs.writeWithDirs(filePath, Bom.join(contentNew, desiredBom))
+ if (yield* format.file(filePath)) {
+ contentNew = yield* Bom.syncFile(afs, filePath, desiredBom)
+ }
yield* bus.publish(File.Event.Edited, { file: filePath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filePath,
event: "change",
})
- contentNew = yield* afs.readFileString(filePath)
diff = trimDiff(
createTwoFilesPatch(
filePath,
diff --git a/packages/opencode/src/tool/skill.ts b/packages/opencode/src/tool/skill.ts
index d86faec2b4fc..327c7e0c0d54 100644
--- a/packages/opencode/src/tool/skill.ts
+++ b/packages/opencode/src/tool/skill.ts
@@ -3,11 +3,16 @@ import { pathToFileURL } from "url"
import z from "zod"
import { Effect } from "effect"
import * as Stream from "effect/Stream"
+import * as LocalContext from "@/util/local-context"
import { Ripgrep } from "../file/ripgrep"
import { Skill } from "../skill"
import * as Tool from "./tool"
import DESCRIPTION from "./skill.txt"
+function isLocalContextNotFound(err: unknown): err is LocalContext.NotFound {
+ return err instanceof LocalContext.NotFound
+}
+
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
})
@@ -23,9 +28,23 @@ export const SkillTool = Tool.define(
parameters: Parameters,
execute: (params: z.infer, ctx: Tool.Context) =>
Effect.gen(function* () {
- const info = yield* skill.get(params.name)
+ const info = yield* skill.get(params.name).pipe(
+ Effect.catch((err) => {
+ if (isLocalContextNotFound(err)) {
+ return Effect.fail(new Error("Unable to load skill: instance context not available"))
+ }
+ return Effect.fail(err)
+ }),
+ )
if (!info) {
- const all = yield* skill.all()
+ const all = yield* skill.all().pipe(
+ Effect.catch((err) => {
+ if (isLocalContextNotFound(err)) {
+ return Effect.succeed([])
+ }
+ return Effect.fail(err)
+ }),
+ )
const available = all.map((item) => item.name).join(", ")
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
}
diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts
index 741091b21d3c..79ed58519831 100644
--- a/packages/opencode/src/tool/write.ts
+++ b/packages/opencode/src/tool/write.ts
@@ -13,6 +13,7 @@ import { AppFileSystem } from "@opencode-ai/shared/filesystem"
import { Instance } from "../project/instance"
import { trimDiff } from "./edit"
import { assertExternalDirectoryEffect } from "./external-directory"
+import * as Bom from "@/util/bom"
const MAX_PROJECT_DIAGNOSTICS_FILES = 5
@@ -38,9 +39,13 @@ export const WriteTool = Tool.define(
yield* assertExternalDirectoryEffect(ctx, filepath)
const exists = yield* fs.existsSafe(filepath)
- const contentOld = exists ? yield* fs.readFileString(filepath) : ""
+ const source = exists ? yield* Bom.readFile(fs, filepath) : { bom: false, text: "" }
+ const next = Bom.split(params.content)
+ const desiredBom = source.bom || next.bom
+ const contentOld = source.text
+ const contentNew = next.text
- const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
+ const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, contentNew))
yield* ctx.ask({
permission: "edit",
patterns: [path.relative(Instance.worktree, filepath)],
@@ -51,8 +56,10 @@ export const WriteTool = Tool.define(
},
})
- yield* fs.writeWithDirs(filepath, params.content)
- yield* format.file(filepath)
+ yield* fs.writeWithDirs(filepath, Bom.join(contentNew, desiredBom))
+ if (yield* format.file(filepath)) {
+ yield* Bom.syncFile(fs, filepath, desiredBom)
+ }
yield* bus.publish(File.Event.Edited, { file: filepath })
yield* bus.publish(FileWatcher.Event.Updated, {
file: filepath,
diff --git a/packages/opencode/src/util/bom.ts b/packages/opencode/src/util/bom.ts
new file mode 100644
index 000000000000..484228f3d415
--- /dev/null
+++ b/packages/opencode/src/util/bom.ts
@@ -0,0 +1,31 @@
+import { Effect } from "effect"
+import { AppFileSystem } from "@opencode-ai/shared/filesystem"
+
+const BOM_CODE = 0xfeff
+const BOM = String.fromCharCode(BOM_CODE)
+
+export function split(text: string) {
+ if (text.charCodeAt(0) !== BOM_CODE) return { bom: false, text }
+ return { bom: true, text: text.slice(1) }
+}
+
+export function join(text: string, bom: boolean) {
+ const stripped = split(text).text
+ if (!bom) return stripped
+ return BOM + stripped
+}
+
+export const readFile = Effect.fn("Bom.readFile")(function* (fs: AppFileSystem.Interface, filePath: string) {
+ return split(new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(filePath)))
+})
+
+export const syncFile = Effect.fn("Bom.syncFile")(function* (
+ fs: AppFileSystem.Interface,
+ filePath: string,
+ bom: boolean,
+) {
+ const current = yield* readFile(fs, filePath)
+ if (current.bom === bom) return current.text
+ yield* fs.writeWithDirs(filePath, join(current.text, bom))
+ return current.text
+})
diff --git a/packages/opencode/test/cli/cmd/tui/prompt-skill.test.ts b/packages/opencode/test/cli/cmd/tui/prompt-skill.test.ts
new file mode 100644
index 000000000000..2ba44fdf380e
--- /dev/null
+++ b/packages/opencode/test/cli/cmd/tui/prompt-skill.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from "bun:test"
+import { expand, has } from "../../../../src/cli/cmd/tui/component/prompt/skill"
+
+const get = (name: string) =>
+ ({
+ "git-release": {
+ name: "git-release",
+ content: "Create consistent releases and changelogs.",
+ },
+ "pr-review": {
+ name: "pr-review",
+ content: "Review pull requests.",
+ },
+ })[name]
+
+describe("prompt skill", () => {
+ test("detects raw skill markers", () => {
+ expect(has("use $git-release now")).toBe(true)
+ })
+
+ test("ignores markers embedded in words", () => {
+ expect(has("use$git-release now")).toBe(false)
+ })
+
+ test("ignores numeric dollar amounts", () => {
+ expect(has("budget is $100 now")).toBe(false)
+ })
+
+ test("expands raw skill markers in place", () => {
+ const result = expand("hi $git-release now", get)
+
+ expect(result).toBe("hi \n## Skill: git-release\n\nCreate consistent releases and changelogs. now")
+ })
+
+ test("leaves text unchanged when the skill is missing", () => {
+ expect(expand("hi $missing now", get)).toBe("hi $missing now")
+ })
+
+ test("expands multiple raw skill markers", () => {
+ const result = expand("$git-release and $pr-review", get)
+
+ expect(result).toBe(
+ "## Skill: git-release\n\nCreate consistent releases and changelogs. and \n## Skill: pr-review\n\nReview pull requests.",
+ )
+ })
+
+ test("skips markers embedded in a word", () => {
+ expect(expand("hi$git-release now", get)).toBe("hi$git-release now")
+ })
+})
diff --git a/packages/opencode/test/effect/cross-spawn-spawner.test.ts b/packages/opencode/test/effect/cross-spawn-spawner.test.ts
index 5990635aa211..b4e52529c1de 100644
--- a/packages/opencode/test/effect/cross-spawn-spawner.test.ts
+++ b/packages/opencode/test/effect/cross-spawn-spawner.test.ts
@@ -169,7 +169,9 @@ describe("cross-spawn spawner", () => {
'process.stderr.write("stderr\\n", done)',
].join("\n"),
)
- const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)])
+ const [stdout, stderr] = yield* Effect.all([decodeByteStream(handle.stdout), decodeByteStream(handle.stderr)], {
+ concurrency: 2,
+ })
expect(stdout).toBe("stdout")
expect(stderr).toBe("stderr")
}),
diff --git a/packages/opencode/test/format/format.test.ts b/packages/opencode/test/format/format.test.ts
index 5530e195b268..2f6f235aa165 100644
--- a/packages/opencode/test/format/format.test.ts
+++ b/packages/opencode/test/format/format.test.ts
@@ -126,6 +126,24 @@ describe("Format", () => {
it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void)))
+ it.live("file() returns false when no formatter runs", () =>
+ provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const file = `${dir}/test.txt`
+ yield* Effect.promise(() => Bun.write(file, "x"))
+
+ const formatted = yield* Format.Service.use((fmt) => fmt.file(file))
+ expect(formatted).toBe(false)
+ }),
+ {
+ config: {
+ formatter: false,
+ },
+ },
+ ),
+ )
+
it.live("status() initializes formatter state per directory", () =>
Effect.gen(function* () {
const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
@@ -219,7 +237,7 @@ describe("Format", () => {
yield* Format.Service.use((fmt) =>
Effect.gen(function* () {
yield* fmt.init()
- yield* fmt.file(file)
+ expect(yield* fmt.file(file)).toBe(true)
}),
)
@@ -229,11 +247,21 @@ describe("Format", () => {
config: {
formatter: {
first: {
- command: ["sh", "-c", 'sleep 0.05; v=$(cat "$1"); printf \'%sA\' "$v" > "$1"', "sh", "$FILE"],
+ command: [
+ "node",
+ "-e",
+ "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'A')",
+ "$FILE",
+ ],
extensions: [".seq"],
},
second: {
- command: ["sh", "-c", 'v=$(cat "$1"); printf \'%sB\' "$v" > "$1"', "sh", "$FILE"],
+ command: [
+ "node",
+ "-e",
+ "const fs = require('fs'); const file = process.argv[1]; fs.writeFileSync(file, fs.readFileSync(file, 'utf8') + 'B')",
+ "$FILE",
+ ],
extensions: [".seq"],
},
},
diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts
index 8ffb20f15419..874eef559bca 100644
--- a/packages/opencode/test/session/prompt.test.ts
+++ b/packages/opencode/test/session/prompt.test.ts
@@ -2,6 +2,7 @@ import { NodeFileSystem } from "@effect/platform-node"
import { FetchHttpClient } from "effect/unstable/http"
import { expect } from "bun:test"
import { Cause, Effect, Exit, Fiber, Layer } from "effect"
+import fs from "fs/promises"
import path from "path"
import { fileURLToPath } from "url"
import { NamedError } from "@opencode-ai/shared/util/error"
@@ -513,6 +514,58 @@ it.live("glob tool keeps instance context during prompt runs", () =>
),
)
+it.live("skill tool keeps instance context during model tool execution", () =>
+ provideTmpdirServer(
+ ({ dir, llm }) =>
+ Effect.gen(function* () {
+ const name = "git-release"
+ yield* Effect.promise(() =>
+ fs.mkdir(path.join(dir, ".agents", "skills", name), { recursive: true }).then(() =>
+ Bun.write(
+ path.join(dir, ".agents", "skills", name, "SKILL.md"),
+ `---
+name: git-release
+description: Create consistent releases and changelogs.
+---
+
+# Git Release
+
+Use this skill.
+`,
+ ),
+ ),
+ )
+
+ const prompt = yield* SessionPrompt.Service
+ const sessions = yield* Session.Service
+ const chat = yield* sessions.create({ title: "Skill Tool" })
+
+ yield* prompt.prompt({
+ sessionID: chat.id,
+ agent: "build",
+ noReply: true,
+ parts: [{ type: "text", text: "load the git release skill" }],
+ })
+
+ yield* llm.tool("skill", { name })
+ yield* prompt.loop({ sessionID: chat.id })
+
+ const msgs = yield* MessageV2.filterCompactedEffect(chat.id)
+ const msg = msgs.find((item) => item.info.role === "assistant" && item.parts.some((part) => part.type === "tool"))
+ expect(msg?.info.role).toBe("assistant")
+ if (!msg || msg.info.role !== "assistant") return
+
+ const tool = completedTool(msg.parts)
+ if (!tool) return
+
+ expect(tool.tool).toBe("skill")
+ expect(tool.state.output).toContain(``)
+ expect(tool.state.output).toContain(`# Skill: ${name}`)
+ }),
+ { git: true, config: providerCfg },
+ ),
+)
+
it.live("loop continues when finish is stop but assistant has tool parts", () =>
provideTmpdirServer(
Effect.fnUntraced(function* ({ llm }) {
diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts
index ebfa9a531eec..fa88432136a5 100644
--- a/packages/opencode/test/tool/apply_patch.test.ts
+++ b/packages/opencode/test/tool/apply_patch.test.ts
@@ -195,6 +195,35 @@ describe("tool.apply_patch freeform", () => {
})
})
+ test("does not invent a first-line diff for BOM files", async () => {
+ await using fixture = await tmpdir()
+ const { ctx, calls } = makeCtx()
+
+ await Instance.provide({
+ directory: fixture.path,
+ fn: async () => {
+ const bom = String.fromCharCode(0xfeff)
+ const target = path.join(fixture.path, "example.cs")
+ await fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`, "utf-8")
+
+ const patchText =
+ "*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"
+
+ await execute({ patchText }, ctx)
+
+ expect(calls.length).toBe(1)
+ const shown = calls[0].metadata.files[0]?.patch ?? ""
+ expect(shown).not.toContain(bom)
+ expect(shown).not.toContain("-using System;")
+ expect(shown).not.toContain("+using System;")
+
+ const content = await fs.readFile(target, "utf-8")
+ expect(content.charCodeAt(0)).toBe(0xfeff)
+ expect(content.slice(1)).toBe("using System;\n\nclass Test {}\nclass Next {}\n")
+ },
+ })
+ })
+
test("inserts lines with insert-only hunk", async () => {
await using fixture = await tmpdir()
const { ctx } = makeCtx()
diff --git a/packages/opencode/test/tool/edit.test.ts b/packages/opencode/test/tool/edit.test.ts
index b5fbc0a67dde..82e1b4a7fd4b 100644
--- a/packages/opencode/test/tool/edit.test.ts
+++ b/packages/opencode/test/tool/edit.test.ts
@@ -96,6 +96,37 @@ describe("tool.edit", () => {
})
})
+ test("preserves BOM when oldString is empty on existing files", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "existing.cs")
+ const bom = String.fromCharCode(0xfeff)
+ await fs.writeFile(filepath, `${bom}using System;\n`, "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const edit = await resolve()
+ const result = await Effect.runPromise(
+ edit.execute(
+ {
+ filePath: filepath,
+ oldString: "",
+ newString: "using Up;\n",
+ },
+ ctx,
+ ),
+ )
+
+ expect(result.metadata.diff).toContain("-using System;")
+ expect(result.metadata.diff).toContain("+using Up;")
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content.charCodeAt(0)).toBe(0xfeff)
+ expect(content.slice(1)).toBe("using Up;\n")
+ },
+ })
+ })
+
test("creates new file with nested directories", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
@@ -183,6 +214,38 @@ describe("tool.edit", () => {
})
})
+ test("replaces the first visible line in BOM files", async () => {
+ await using tmp = await tmpdir()
+ const filepath = path.join(tmp.path, "existing.cs")
+ const bom = String.fromCharCode(0xfeff)
+ await fs.writeFile(filepath, `${bom}using System;\nclass Test {}\n`, "utf-8")
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const edit = await resolve()
+ const result = await Effect.runPromise(
+ edit.execute(
+ {
+ filePath: filepath,
+ oldString: "using System;",
+ newString: "using Up;",
+ },
+ ctx,
+ ),
+ )
+
+ expect(result.metadata.diff).toContain("-using System;")
+ expect(result.metadata.diff).toContain("+using Up;")
+ expect(result.metadata.diff).not.toContain(bom)
+
+ const content = await fs.readFile(filepath, "utf-8")
+ expect(content.charCodeAt(0)).toBe(0xfeff)
+ expect(content.slice(1)).toBe("using Up;\nclass Test {}\n")
+ },
+ })
+ })
+
test("throws error when file does not exist", async () => {
await using tmp = await tmpdir()
const filepath = path.join(tmp.path, "nonexistent.txt")
diff --git a/packages/opencode/test/tool/write.test.ts b/packages/opencode/test/tool/write.test.ts
index 50d3b57527f9..36131f9596a3 100644
--- a/packages/opencode/test/tool/write.test.ts
+++ b/packages/opencode/test/tool/write.test.ts
@@ -114,6 +114,54 @@ describe("tool.write", () => {
),
)
+ it.live("preserves BOM when overwriting existing files", () =>
+ provideTmpdirInstance((dir) =>
+ Effect.gen(function* () {
+ const filepath = path.join(dir, "existing.cs")
+ const bom = String.fromCharCode(0xfeff)
+ yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
+
+ yield* run({ filePath: filepath, content: "using Up;\n" })
+
+ const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
+ expect(content.charCodeAt(0)).toBe(0xfeff)
+ expect(content.slice(1)).toBe("using Up;\n")
+ }),
+ ),
+ )
+
+ it.live("restores BOM after formatter strips it", () =>
+ provideTmpdirInstance(
+ (dir) =>
+ Effect.gen(function* () {
+ const filepath = path.join(dir, "formatted.cs")
+ const bom = String.fromCharCode(0xfeff)
+ yield* Effect.promise(() => fs.writeFile(filepath, `${bom}using System;\n`, "utf-8"))
+
+ yield* run({ filePath: filepath, content: "using Up;\n" })
+
+ const content = yield* Effect.promise(() => fs.readFile(filepath, "utf-8"))
+ expect(content.charCodeAt(0)).toBe(0xfeff)
+ expect(content.slice(1)).toBe("using Up;\n")
+ }),
+ {
+ config: {
+ formatter: {
+ stripbom: {
+ extensions: [".cs"],
+ command: [
+ "node",
+ "-e",
+ "const fs = require('fs'); const file = process.argv[1]; let text = fs.readFileSync(file, 'utf8'); if (text.charCodeAt(0) === 0xfeff) text = text.slice(1); fs.writeFileSync(file, text, 'utf8')",
+ "$FILE",
+ ],
+ },
+ },
+ },
+ },
+ ),
+ )
+
it.live("returns diff in metadata for existing files", () =>
provideTmpdirInstance((dir) =>
Effect.gen(function* () {
diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx
index c071db303b7a..72f5730612c5 100644
--- a/packages/ui/src/components/timeline-playground.stories.tsx
+++ b/packages/ui/src/components/timeline-playground.stories.tsx
@@ -318,7 +318,7 @@ const TOOL_SAMPLES = {
tool: "bash",
input: { command: "bun test --filter session", description: "Run session tests" },
output:
- "bun test v1.3.11\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s",
+ "bun test v1.3.13\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s",
title: "Run session tests",
metadata: { command: "bun test --filter session" },
},