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 }) { }} > } > - {language.t("dialog.project.edit.icon.alt")} + {(src) => ( + {language.t("dialog.project.edit.icon.alt")} + )}
@@ -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" }, },