Compare commits

...

10 Commits

Author SHA1 Message Date
Adam
cb29742b57 fix(app): remove pierre diff virtualization 2026-04-08 13:16:45 -05:00
Aiden Cline
039c60170d fix: ensure that /providers list and shell endpoints are correctly typed in sdk and openapi schema (#21543) 2026-04-08 12:56:15 -05:00
Aiden Cline
cd87d4f9d3 test: update webfetch test (#21398)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-08 12:25:02 -05:00
Brendan Allan
988c9894f2 ui: fix sticky session diffs header (#21486) 2026-04-08 17:01:52 +08:00
Kit Langton
ae614d919f fix(tui): simplify console org display (#21339)
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
2026-04-07 21:03:24 -04:00
opencode-agent[bot]
65cde7f494 chore: update nix node_modules hashes 2026-04-08 00:32:40 +00:00
opencode
98325dcdc6 release: v1.4.0 2026-04-08 00:32:31 +00:00
opencode-agent[bot]
0788a535e2 chore: generate 2026-04-07 23:49:25 +00:00
Dax
b7fab49b64 refactor(snapshot): store unified patches in file diffs (#21244)
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
2026-04-07 19:48:23 -04:00
Dax
463318486f core: refactor tool system to remove agent context from initialization (#21052) 2026-04-07 19:48:12 -04:00
83 changed files with 980 additions and 1468 deletions

View File

@@ -9,6 +9,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:",
},
"devDependencies": {
@@ -26,7 +27,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -80,7 +81,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -114,7 +115,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -141,7 +142,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48",
@@ -165,7 +166,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -189,7 +190,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -222,7 +223,7 @@
},
"packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -254,7 +255,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*",
@@ -283,7 +284,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -299,7 +300,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.3.17",
"version": "1.4.0",
"bin": {
"opencode": "./bin/opencode",
},
@@ -435,7 +436,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"zod": "catalog:",
@@ -469,7 +470,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -484,7 +485,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -519,7 +520,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*",
@@ -532,6 +533,7 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
@@ -567,7 +569,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"zod": "catalog:",
},
@@ -578,7 +580,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -3257,6 +3259,8 @@
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"heap-snapshot-toolkit": ["heap-snapshot-toolkit@1.1.3", "", {}, "sha512-joThu2rEsDu8/l4arupRDI1qP4CZXNG+J6Wr348vnbLGSiBkwRdqZ6aOHl5BzEiC+Dc8OTbMlmWjD0lbXD5K2Q=="],
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
"hono": ["hono@4.10.7", "", {}, "sha512-icXIITfw/07Q88nLSkB9aiUrd8rYzSweK681Kjo/TSggaGbOX4RRyxxm71v+3PC8C/j+4rlxGeoTRxQDkaJkUw=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-r1+AehuOGIOaaxfXkQGracT/6OdFRn5Ub8s7H+MeKFY=",
"aarch64-linux": "sha256-WkMSRF/ZJLyzxNBjpiMR459C9G0NVOEw31tm8roPneA=",
"aarch64-darwin": "sha256-Z127cxFpTl8Ml7PB3CG9TcCU08oYCPuk0FECK2MQ2CI=",
"x86_64-darwin": "sha256-pkRoFtnVjyl+5fm+rrFyRnEwvptxylnFxPAcEv4ZOCg="
"x86_64-linux": "sha256-85wpU1oCWbthPleNIOj5d5AOuuYZ6rM7gMLZR6YJ2WU=",
"aarch64-linux": "sha256-C3A56SDQGJquCpIRj2JhIzr4A7N4cc9lxtEjl8bXDeM=",
"aarch64-darwin": "sha256-/Ij3qhGRrcLlMfl9uEacDNnGK5URxhctuQFBW4Njrog=",
"x86_64-darwin": "sha256-10sOPuN4eZ75orw4FI8ztCq1+AKS2e8aAfg3Z6Yn56w="
}
}

View File

@@ -91,6 +91,7 @@
"@opencode-ai/plugin": "workspace:*",
"@opencode-ai/script": "workspace:*",
"@opencode-ai/sdk": "workspace:*",
"heap-snapshot-toolkit": "1.1.3",
"typescript": "catalog:"
},
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.3.17",
"version": "1.4.0",
"description": "",
"type": "module",
"exports": {

View File

@@ -1,7 +1,6 @@
import { Binary } from "@opencode-ai/util/binary"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type {
FileDiff,
Message,
Part,
PermissionRequest,
@@ -9,6 +8,7 @@ import type {
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import type { State, VcsCache } from "./types"
@@ -161,7 +161,7 @@ export function applyDirectoryEvent(input: {
break
}
case "session.diff": {
const props = event.properties as { sessionID: string; diff: FileDiff[] }
const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] }
input.setStore("session_diff", props.sessionID, reconcile(props.diff, { key: "file" }))
break
}

View File

@@ -1,11 +1,11 @@
import { describe, expect, test } from "bun:test"
import type {
FileDiff,
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
@@ -33,7 +33,7 @@ describe("app session cache", () => {
test("dropSessionCaches clears orphaned parts without message rows", () => {
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>
@@ -64,7 +64,7 @@ describe("app session cache", () => {
const m = msg("msg_1", "ses_1")
const store: {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>

View File

@@ -1,10 +1,10 @@
import type {
FileDiff,
Message,
Part,
PermissionRequest,
QuestionRequest,
SessionStatus,
SnapshotFileDiff,
Todo,
} from "@opencode-ai/sdk/v2/client"
@@ -12,7 +12,7 @@ export const SESSION_CACHE_LIMIT = 40
type SessionCache = {
session_status: Record<string, SessionStatus | undefined>
session_diff: Record<string, FileDiff[] | undefined>
session_diff: Record<string, SnapshotFileDiff[] | undefined>
todo: Record<string, Todo[] | undefined>
message: Record<string, Message[] | undefined>
part: Record<string, Part[] | undefined>

View File

@@ -2,7 +2,6 @@ import type {
Agent,
Command,
Config,
FileDiff,
LspStatus,
McpStatus,
Message,
@@ -14,6 +13,7 @@ import type {
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
Todo,
VcsInfo,
} from "@opencode-ai/sdk/v2/client"
@@ -48,7 +48,7 @@ export type State = {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: FileDiff[]
[sessionID: string]: SnapshotFileDiff[]
}
todo: {
[sessionID: string]: Todo[]

View File

@@ -1,4 +1,4 @@
import type { FileDiff, Project, UserMessage } from "@opencode-ai/sdk/v2"
import type { Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import {
@@ -68,7 +68,7 @@ type FollowupItem = FollowupDraft & { id: string }
type FollowupEdit = Pick<FollowupItem, "id" | "prompt" | "context">
const emptyFollowups: FollowupItem[] = []
type ChangeMode = "git" | "branch" | "session" | "turn"
type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
type SessionHistoryWindowInput = {
@@ -463,13 +463,6 @@ export default function Page() {
if (!id) return false
return sync.session.history.loading(id)
})
const diffsReady = createMemo(() => {
const id = params.id
if (!id) return true
if (!hasSessionReview()) return true
return sync.data.session_diff[id] !== undefined
})
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
emptyUserMessages,
@@ -527,10 +520,19 @@ export default function Page() {
deferRender: false,
})
const [vcs, setVcs] = createStore({
const [vcs, setVcs] = createStore<{
diff: {
git: [] as FileDiff[],
branch: [] as FileDiff[],
git: VcsFileDiff[]
branch: VcsFileDiff[]
}
ready: {
git: boolean
branch: boolean
}
}>({
diff: {
git: [] as VcsFileDiff[],
branch: [] as VcsFileDiff[],
},
ready: {
git: false,
@@ -648,6 +650,7 @@ export default function Page() {
}, desktopReviewOpen())
const turnDiffs = createMemo(() => lastUserMessage()?.summary?.diffs ?? [])
const nogit = createMemo(() => !!sync.project && sync.project.vcs !== "git")
const changesOptions = createMemo<ChangeMode[]>(() => {
const list: ChangeMode[] = []
if (sync.project?.vcs === "git") list.push("git")
@@ -659,7 +662,7 @@ export default function Page() {
) {
list.push("branch")
}
list.push("session", "turn")
list.push("turn")
return list
})
const vcsMode = createMemo<VcsMode | undefined>(() => {
@@ -668,20 +671,17 @@ export default function Page() {
const reviewDiffs = createMemo(() => {
if (store.changes === "git") return vcs.diff.git
if (store.changes === "branch") return vcs.diff.branch
if (store.changes === "session") return diffs()
return turnDiffs()
})
const reviewCount = createMemo(() => {
if (store.changes === "git") return vcs.diff.git.length
if (store.changes === "branch") return vcs.diff.branch.length
if (store.changes === "session") return sessionCount()
return turnDiffs().length
})
const hasReview = createMemo(() => reviewCount() > 0)
const reviewReady = createMemo(() => {
if (store.changes === "git") return vcs.ready.git
if (store.changes === "branch") return vcs.ready.branch
if (store.changes === "session") return !hasSessionReview() || diffsReady()
return true
})
@@ -749,13 +749,6 @@ export default function Page() {
scrollToMessage(msgs[targetIndex], "auto")
}
const sessionEmptyKey = createMemo(() => {
const project = sync.project
if (project && !project.vcs) return "session.review.noVcs"
if (sync.data.config.snapshot === false) return "session.review.noSnapshot"
return "session.review.empty"
})
function upsert(next: Project) {
const list = globalSync.data.project
sync.set("project", next.id)
@@ -1156,7 +1149,6 @@ export default function Page() {
const label = (option: ChangeMode) => {
if (option === "git") return language.t("ui.sessionReview.title.git")
if (option === "branch") return language.t("ui.sessionReview.title.branch")
if (option === "session") return language.t("ui.sessionReview.title")
return language.t("ui.sessionReview.title.lastTurn")
}
@@ -1179,11 +1171,26 @@ export default function Page() {
</div>
)
const createGit = (input: { emptyClass: string }) => (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
</div>
)
const reviewEmptyText = createMemo(() => {
if (store.changes === "git") return language.t("session.review.noUncommittedChanges")
if (store.changes === "branch") return language.t("session.review.noBranchChanges")
if (store.changes === "turn") return language.t("session.review.noChanges")
return language.t(sessionEmptyKey())
return language.t("session.review.noChanges")
})
const reviewEmpty = (input: { loadingClass: string; emptyClass: string }) => {
@@ -1193,31 +1200,10 @@ export default function Page() {
}
if (store.changes === "turn") {
if (nogit()) return createGit(input)
return empty(reviewEmptyText())
}
if (hasSessionReview() && !diffsReady()) {
return <div class={input.loadingClass}>{language.t("session.review.loadingChanges")}</div>
}
if (sessionEmptyKey() === "session.review.noVcs") {
return (
<div class={input.emptyClass}>
<div class="flex flex-col gap-3">
<div class="text-14-medium text-text-strong">{language.t("session.review.noVcs.createGit.title")}</div>
<div class="text-14-regular text-text-base max-w-md" style={{ "line-height": "var(--line-height-normal)" }}>
{language.t("session.review.noVcs.createGit.description")}
</div>
</div>
<Button size="large" disabled={gitMutation.isPending} onClick={initGit}>
{gitMutation.isPending
? language.t("session.review.noVcs.createGit.actionLoading")
: language.t("session.review.noVcs.createGit.action")}
</Button>
</div>
)
}
return (
<div class={input.emptyClass}>
<div class="text-14-regular text-text-weak max-w-56">{reviewEmptyText()}</div>

View File

@@ -1,6 +1,6 @@
import { createEffect, createSignal, onCleanup, type JSX } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { SessionReview } from "@opencode-ai/ui/session-review"
import type {
SessionReviewCommentActions,
@@ -14,10 +14,12 @@ import type { LineComment } from "@/context/comments"
export type DiffStyle = "unified" | "split"
type ReviewDiff = SnapshotFileDiff | VcsFileDiff
export interface SessionReviewTabProps {
title?: JSX.Element
empty?: JSX.Element
diffs: () => FileDiff[]
diffs: () => ReviewDiff[]
view: () => ReturnType<ReturnType<typeof useLayout>["view"]>
diffStyle: DiffStyle
onDiffStyleChange?: (style: DiffStyle) => void

View File

@@ -8,7 +8,7 @@ import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Mark } from "@opencode-ai/ui/logo"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import type { FileDiff } from "@opencode-ai/sdk/v2"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd"
import { useDialog } from "@opencode-ai/ui/context/dialog"
@@ -27,7 +27,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
export function SessionSidePanel(props: {
canReview: () => boolean
diffs: () => FileDiff[]
diffs: () => (SnapshotFileDiff | VcsFileDiff)[]
diffsReady: () => boolean
empty: () => string
hasReview: () => boolean

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.3.17",
"version": "1.4.0",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.3.17",
"version": "1.4.0",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.3.17",
"version": "1.4.0",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop-electron",
"private": true,
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",

View File

@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.3.17",
"version": "1.4.0",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -1,4 +1,4 @@
import { FileDiff, Message, Model, Part, Session } from "@opencode-ai/sdk/v2"
import { Message, Model, Part, Session, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import { fn } from "@opencode-ai/util/fn"
import { iife } from "@opencode-ai/util/iife"
import z from "zod"
@@ -27,7 +27,7 @@ export namespace Share {
}),
z.object({
type: z.literal("session_diff"),
data: z.custom<FileDiff[]>(),
data: z.custom<SnapshotFileDiff[]>(),
}),
z.object({
type: z.literal("model"),

View File

@@ -1,4 +1,4 @@
import { FileDiff, Message, Model, Part, Session, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2"
import { Message, Model, Part, Session, SessionStatus, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2"
import { SessionTurn } from "@opencode-ai/ui/session-turn"
import { SessionReview } from "@opencode-ai/ui/session-review"
import { DataProvider } from "@opencode-ai/ui/context"
@@ -51,7 +51,7 @@ const getData = query(async (shareID) => {
shareID: string
session: Session[]
session_diff: {
[sessionID: string]: FileDiff[]
[sessionID: string]: SnapshotFileDiff[]
}
session_status: {
[sessionID: string]: SessionStatus

View File

@@ -1,7 +1,7 @@
id = "opencode"
name = "OpenCode"
description = "The open source coding agent."
version = "1.3.17"
version = "1.4.0"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-arm64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-darwin-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-arm64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-linux-x64.tar.gz"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.17/opencode-windows-x64.zip"
archive = "https://github.com/anomalyco/opencode/releases/download/v1.4.0/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.3.17",
"version": "1.4.0",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",

View File

@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.3.17",
"version": "1.4.0",
"name": "opencode",
"type": "module",
"license": "MIT",

View File

@@ -1,8 +1,4 @@
# 2.0
What we would change if we could
## Keybindings vs. Keymappings
# Keybindings vs. Keymappings
Make it `keymappings`, closer to neovim. Can be layered like `<leader>abc`. Commands don't define their binding, but have an id that a key can be mapped to like

View File

@@ -0,0 +1,136 @@
# Message Shape
Problem:
- stored messages need enough data to replay and resume a session later
- prompt hooks often just want to append a synthetic user/assistant message
- today that means faking ids, timestamps, and request metadata
## Option 1: Two Message Shapes
Keep `User` / `Assistant` for stored history, but clean them up.
```ts
type User = {
role: "user"
time: { created: number }
request: {
agent: string
model: ModelRef
variant?: string
format?: OutputFormat
system?: string
tools?: Record<string, boolean>
}
}
type Assistant = {
role: "assistant"
run: { agent: string; model: ModelRef; path: { cwd: string; root: string } }
usage: { cost: number; tokens: Tokens }
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}
```
Add a separate transient `PromptMessage` for prompt surgery.
```ts
type PromptMessage = {
role: "user" | "assistant"
parts: PromptPart[]
}
```
Plugin hook example:
```ts
prompt.push({
role: "user",
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})
```
Tradeoff: prompt hooks get easy lightweight messages, but there are now two message shapes.
## Option 2: Prompt Mutators
Keep `User` / `Assistant` as the stored history model.
Prompt hooks do not build messages directly. The runtime gives them prompt mutators.
```ts
type PromptEditor = {
append(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
prepend(input: { role: "user" | "assistant"; parts: PromptPart[] }): void
appendTo(target: "last-user" | "last-assistant", parts: PromptPart[]): void
insertAfter(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
insertBefore(messageID: string, input: { role: "user" | "assistant"; parts: PromptPart[] }): void
}
```
Plugin hook examples:
```ts
prompt.append({
role: "user",
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
})
```
```ts
prompt.appendTo("last-user", [{ type: "text", text: BUILD_SWITCH }])
```
Tradeoff: avoids a second full message type and avoids fake ids/timestamps, but moves more magic into the hook API.
## Option 3: Separate Turn State
Move execution settings out of `User` and into a separate turn/request object.
```ts
type Turn = {
id: string
request: {
agent: string
model: ModelRef
variant?: string
format?: OutputFormat
system?: string
tools?: Record<string, boolean>
}
}
type User = {
role: "user"
turnID: string
time: { created: number }
}
type Assistant = {
role: "assistant"
turnID: string
usage: { cost: number; tokens: Tokens }
result: { finish?: string; error?: Error; structured?: unknown; kind: "reply" | "summary" }
}
```
Examples:
```ts
const turn = {
request: {
agent: "build",
model: { providerID: "openai", modelID: "gpt-5" },
},
}
```
```ts
const msg = {
role: "user",
turnID: turn.id,
parts: [{ type: "text", text: "Summarize the tool output above and continue." }],
}
```
Tradeoff: stored messages get much smaller and cleaner, but replay now has to join messages with turn state and prompt hooks still need a way to pick which turn they belong to.

View File

@@ -71,7 +71,10 @@ export const AgentCommand = cmd({
async function getAvailableTools(agent: Agent.Info) {
const model = agent.model ?? (await Provider.defaultModel())
return ToolRegistry.tools(model, agent)
return ToolRegistry.tools({
...model,
agent,
})
}
async function resolveTools(agent: Agent.Info, availableTools: Awaited<ReturnType<typeof getAvailableTools>>) {

View File

@@ -8,7 +8,6 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
import { consoleManagedProviderLabel } from "@tui/util/provider-origin"
export function useConnected() {
const sync = useSync()
@@ -47,11 +46,7 @@ export function DialogModel(props: { providerID?: string }) {
key: item,
value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID,
description: consoleManagedProviderLabel(
sync.data.console_state.consoleManagedProviders,
provider.id,
provider.name,
),
description: provider.name,
category,
disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
@@ -89,9 +84,7 @@ export function DialogModel(props: { providerID?: string }) {
description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)"
: undefined,
category: connected()
? consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, provider.id, provider.name)
: undefined,
category: connected() ? provider.name : undefined,
disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() {
@@ -142,7 +135,7 @@ export function DialogModel(props: { providerID?: string }) {
const title = createMemo(() => {
const value = provider()
if (!value) return "Select model"
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, value.id, value.name)
return value.name
})
function onSelect(providerID: string, modelID: string) {

View File

@@ -13,7 +13,7 @@ import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast"
import { CONSOLE_MANAGED_ICON, isConsoleManagedProvider } from "@tui/util/provider-origin"
import { isConsoleManagedProvider } from "@tui/util/provider-origin"
const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0,
@@ -49,11 +49,7 @@ export function createDialogProviderOptions() {
}[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: consoleManaged ? (
<text fg={theme.textMuted}>{CONSOLE_MANAGED_ICON}</text>
) : connected ? (
<text fg={theme.success}></text>
) : undefined,
gutter: connected ? <text fg={theme.success}></text> : undefined,
async onSelect() {
if (consoleManaged) return

View File

@@ -36,7 +36,6 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { CONSOLE_MANAGED_ICON, consoleManagedProviderLabel } from "@tui/util/provider-origin"
export type PromptProps = {
sessionID?: string
@@ -96,15 +95,8 @@ export function Prompt(props: PromptProps) {
const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>()
const activeOrgName = createMemo(() => sync.data.console_state.activeOrgName)
const canSwitchOrgs = createMemo(() => sync.data.console_state.switchableOrgCount > 1)
const currentProviderLabel = createMemo(() => {
const current = local.model.current()
const provider = local.model.parsed().provider
if (!current) return provider
return consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, current.providerID, provider)
})
const hasRightContent = createMemo(() => Boolean(props.right || activeOrgName()))
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const hasRightContent = createMemo(() => Boolean(props.right))
function promptModelWarning() {
toast.show({
@@ -1120,17 +1112,6 @@ export function Prompt(props: PromptProps) {
<Show when={hasRightContent()}>
<box flexDirection="row" gap={1} alignItems="center">
{props.right}
<Show when={activeOrgName()}>
<text
fg={theme.textMuted}
onMouseUp={() => {
if (!canSwitchOrgs()) return
command.trigger("console.org.switch")
}}
>
{`${CONSOLE_MANAGED_ICON} ${activeOrgName()}`}
</text>
</Show>
</box>
</Show>
</box>
@@ -1162,7 +1143,7 @@ export function Prompt(props: PromptProps) {
}
/>
</box>
<box flexDirection="row" justifyContent="space-between">
<box width="100%" flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box
flexDirection="row"

View File

@@ -2124,7 +2124,7 @@ function ApplyPatch(props: ToolProps<typeof ApplyPatchTool>) {
</text>
}
>
<Diff diff={file.diff} filePath={file.filePath} />
<Diff diff={file.patch} filePath={file.filePath} />
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={file.movePath ?? file.filePath} />
</Show>
</BlockTool>

View File

@@ -1,5 +1,3 @@
export const CONSOLE_MANAGED_ICON = "⌂"
const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
Array.isArray(consoleManagedProviders)
? consoleManagedProviders.includes(providerID)
@@ -7,14 +5,3 @@ const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, provi
export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
contains(consoleManagedProviders, providerID)
export const consoleManagedProviderSuffix = (
consoleManagedProviders: string[] | ReadonlySet<string>,
providerID: string,
) => (contains(consoleManagedProviders, providerID) ? ` ${CONSOLE_MANAGED_ICON}` : "")
export const consoleManagedProviderLabel = (
consoleManagedProviders: string[] | ReadonlySet<string>,
providerID: string,
providerName: string,
) => `${providerName}${consoleManagedProviderSuffix(consoleManagedProviders, providerID)}`

View File

@@ -1,4 +1,5 @@
import { Effect, Layer, ServiceMap, Stream } from "effect"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
@@ -7,7 +8,6 @@ import { makeRuntime } from "@/effect/run-service"
import { AppFileSystem } from "@/filesystem"
import { FileWatcher } from "@/file/watcher"
import { Git } from "@/git"
import { Snapshot } from "@/snapshot"
import { Log } from "@/util/log"
import { Instance } from "./instance"
import z from "zod"
@@ -49,6 +49,8 @@ export namespace Vcs {
map: Map<string, { additions: number; deletions: number }>,
) {
const base = ref ? yield* git.prefix(cwd) : ""
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
const next = yield* Effect.forEach(
list,
(item) =>
@@ -58,12 +60,11 @@ export namespace Vcs {
const stat = map.get(item.file)
return {
file: item.file,
before,
after,
patch: patch(item.file, before, after),
additions: stat?.additions ?? (item.status === "added" ? count(after) : 0),
deletions: stat?.deletions ?? (item.status === "deleted" ? count(before) : 0),
status: item.status,
} satisfies Snapshot.FileDiff
} satisfies FileDiff
}),
{ concurrency: 8 },
)
@@ -125,11 +126,24 @@ export namespace Vcs {
})
export type Info = z.infer<typeof Info>
export const FileDiff = z
.object({
file: z.string(),
patch: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "VcsFileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
readonly defaultBranch: () => Effect.Effect<string | undefined>
readonly diff: (mode: Mode) => Effect.Effect<Snapshot.FileDiff[]>
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
}
interface State {

View File

@@ -154,7 +154,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
description: "VCS diff",
content: {
"application/json": {
schema: resolver(Snapshot.FileDiff.array()),
schema: resolver(Vcs.FileDiff.array()),
},
},
},

View File

@@ -15,6 +15,7 @@ import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
import { WorkspaceRoutes } from "./workspace"
import { Agent } from "@/agent/agent"
const ConsoleOrgOption = z.object({
accountID: z.string(),
@@ -181,7 +182,11 @@ export const ExperimentalRoutes = lazy(() =>
),
async (c) => {
const { provider, model } = c.req.valid("query")
const tools = await ToolRegistry.tools({ providerID: ProviderID.make(provider), modelID: ModelID.make(model) })
const tools = await ToolRegistry.tools({
providerID: ProviderID.make(provider),
modelID: ModelID.make(model),
agent: await Agent.get(await Agent.defaultAgent()),
})
return c.json(
tools.map((t) => ({
id: t.id,

View File

@@ -28,7 +28,7 @@ export const ProviderRoutes = lazy(() =>
"application/json": {
schema: resolver(
z.object({
all: ModelsDev.Provider.array(),
all: Provider.Info.array(),
default: z.record(z.string(), z.string()),
connected: z.array(z.string()),
}),

View File

@@ -906,7 +906,7 @@ export const SessionRoutes = lazy(() =>
description: "Created message",
content: {
"application/json": {
schema: resolver(MessageV2.Assistant),
schema: resolver(MessageV2.WithParts),
},
},
},

View File

@@ -11,7 +11,6 @@ import { Provider } from "../provider/provider"
import { ModelID, ProviderID } from "../provider/schema"
import { type Tool as AITool, tool, jsonSchema, type ToolExecutionOptions, asSchema } from "ai"
import { SessionCompaction } from "./compaction"
import { Instance } from "../project/instance"
import { Bus } from "../bus"
import { ProviderTransform } from "../provider/transform"
import { SystemPrompt } from "./system"
@@ -24,7 +23,6 @@ import { ToolRegistry } from "../tool/registry"
import { Runner } from "@/effect/runner"
import { MCP } from "../mcp"
import { LSP } from "../lsp"
import { ReadTool } from "../tool/read"
import { FileTime } from "../file/time"
import { Flag } from "../flag/flag"
import { ulid } from "ulid"
@@ -37,7 +35,6 @@ import { ConfigMarkdown } from "../config/markdown"
import { SessionSummary } from "./summary"
import { NamedError } from "@opencode-ai/util/error"
import { SessionProcessor } from "./processor"
import { TaskTool } from "@/tool/task"
import { Tool } from "@/tool/tool"
import { Permission } from "@/permission"
import { SessionStatus } from "./status"
@@ -50,6 +47,7 @@ import { Process } from "@/util/process"
import { Cause, Effect, Exit, Layer, Option, Scope, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
import { makeRuntime } from "@/effect/run-service"
import { TaskTool } from "@/tool/task"
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -433,10 +431,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the
),
})
for (const item of yield* registry.tools(
{ modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID },
input.agent,
)) {
for (const item of yield* registry.tools({
modelID: ModelID.make(input.model.api.id),
providerID: input.model.providerID,
agent: input.agent,
})) {
const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters))
tools[item.id] = tool({
id: item.id as any,
@@ -560,7 +559,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
}) {
const { task, model, lastUser, sessionID, session, msgs } = input
const ctx = yield* InstanceState.context
const taskTool = yield* Effect.promise(() => registry.named.task.init())
const taskTool = yield* registry.fromID(TaskTool.id)
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
id: MessageID.ascending(),
@@ -583,7 +582,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
sessionID: assistantMessage.sessionID,
type: "tool",
callID: ulid(),
tool: registry.named.task.id,
tool: TaskTool.id,
state: {
status: "running",
input: {
@@ -1113,7 +1112,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
text: `Called the Read tool with the following input: ${JSON.stringify(args)}`,
},
]
const read = yield* Effect.promise(() => registry.named.read.init()).pipe(
const read = yield* registry.fromID("read").pipe(
Effect.flatMap((t) =>
provider.getModel(info.model.providerID, info.model.modelID).pipe(
Effect.flatMap((mdl) =>
@@ -1177,7 +1176,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
if (part.mime === "application/x-directory") {
const args = { filePath: filepath }
const result = yield* Effect.promise(() => registry.named.read.init()).pipe(
const result = yield* registry.fromID("read").pipe(
Effect.flatMap((t) =>
Effect.promise(() =>
t.execute(args, {

View File

@@ -59,7 +59,7 @@ export namespace ShareNext {
}
| {
type: "session_diff"
data: SDK.FileDiff[]
data: SDK.SnapshotFileDiff[]
}
| {
type: "model"

View File

@@ -239,22 +239,28 @@ export namespace Skill {
export function fmt(list: Info[], opts: { verbose: boolean }) {
if (list.length === 0) return "No skills are currently available."
if (opts.verbose) {
return [
"<available_skills>",
...list.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
...list
.sort((a, b) => a.name.localeCompare(b.name))
.flatMap((skill) => [
" <skill>",
` <name>${skill.name}</name>`,
` <description>${skill.description}</description>`,
` <location>${pathToFileURL(skill.location).href}</location>`,
" </skill>",
]),
"</available_skills>",
].join("\n")
}
return ["## Available Skills", ...list.map((skill) => `- **${skill.name}**: ${skill.description}`)].join("\n")
return [
"## Available Skills",
...list
.toSorted((a, b) => a.name.localeCompare(b.name))
.map((skill) => `- **${skill.name}**: ${skill.description}`),
].join("\n")
}
const { runPromise } = makeRuntime(Service, defaultLayer)

View File

@@ -1,6 +1,6 @@
import { NodeFileSystem, NodePath } from "@effect/platform-node"
import { Cause, Duration, Effect, Layer, Schedule, Semaphore, ServiceMap, Stream } from "effect"
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
import { formatPatch, structuredPatch } from "diff"
import path from "path"
import z from "zod"
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
@@ -22,14 +22,13 @@ export namespace Snapshot {
export const FileDiff = z
.object({
file: z.string(),
before: z.string(),
after: z.string(),
patch: z.string(),
additions: z.number(),
deletions: z.number(),
status: z.enum(["added", "deleted", "modified"]).optional(),
})
.meta({
ref: "FileDiff",
ref: "SnapshotFileDiff",
})
export type FileDiff = z.infer<typeof FileDiff>
@@ -521,8 +520,6 @@ export namespace Snapshot {
const map = new Map<string, { before: string; after: string }>()
const dec = new TextDecoder()
let i = 0
// Parse the default `git cat-file --batch` stream: one header line,
// then exactly `size` bytes of blob content, then a trailing newline.
for (const ref of refs) {
let end = i
while (end < out.length && out[end] !== 10) end += 1
@@ -620,8 +617,9 @@ export namespace Snapshot {
]
})
const step = 100
const patch = (file: string, before: string, after: string) =>
formatPatch(structuredPatch(file, file, before, after, "", "", { context: Number.MAX_SAFE_INTEGER }))
// Keep batches bounded so a large diff does not buffer every blob at once.
for (let i = 0; i < rows.length; i += step) {
const run = rows.slice(i, i + step)
const text = yield* load(run)
@@ -631,8 +629,7 @@ export namespace Snapshot {
const [before, after] = row.binary ? ["", ""] : text ? [hit.before, hit.after] : yield* show(row)
result.push({
file: row.file,
before,
after,
patch: row.binary ? "" : patch(row.file, before, after),
additions: row.additions,
deletions: row.deletions,
status: row.status,

View File

@@ -164,9 +164,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", {
filePath: change.filePath,
relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath).replaceAll("\\", "/"),
type: change.type,
diff: change.diff,
before: change.oldContent,
after: change.newContent,
patch: change.diff,
additions: change.additions,
deletions: change.deletions,
movePath: change.movePath,

View File

@@ -50,6 +50,22 @@ const FILES = new Set([
const FLAGS = new Set(["-destination", "-literalpath", "-path"])
const SWITCHES = new Set(["-confirm", "-debug", "-force", "-nonewline", "-recurse", "-verbose", "-whatif"])
const Parameters = z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to the current directory. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
})
type Part = {
type: string
text: string
@@ -452,21 +468,7 @@ export const BashTool = Tool.define("bash", async () => {
.replaceAll("${chaining}", chain)
.replaceAll("${maxLines}", String(Truncate.MAX_LINES))
.replaceAll("${maxBytes}", String(Truncate.MAX_BYTES)),
parameters: z.object({
command: z.string().describe("The command to execute"),
timeout: z.number().describe("Optional timeout in milliseconds").optional(),
workdir: z
.string()
.describe(
`The working directory to run the command in. Defaults to ${Instance.directory}. Use this instead of 'cd' commands.`,
)
.optional(),
description: z
.string()
.describe(
"Clear, concise description of what this command does in 5-10 words. Examples:\nInput: ls\nOutput: Lists files in current directory\n\nInput: git status\nOutput: Shows working tree status\n\nInput: npm install\nOutput: Installs package dependencies\n\nInput: mkdir foo\nOutput: Creates directory 'foo'",
),
}),
parameters: Parameters,
async execute(params, ctx) {
const cwd = params.workdir ? await resolvePath(params.workdir, Instance.directory, shell) : Instance.directory
if (params.timeout !== undefined && params.timeout < 0) {

View File

@@ -1,183 +0,0 @@
import z from "zod"
import { Tool } from "./tool"
import { ProviderID, ModelID } from "../provider/schema"
import { errorMessage } from "../util/error"
import DESCRIPTION from "./batch.txt"
const DISALLOWED = new Set(["batch"])
const FILTERED_FROM_SUGGESTIONS = new Set(["invalid", "patch", ...DISALLOWED])
export const BatchTool = Tool.define("batch", async () => {
return {
description: DESCRIPTION,
parameters: z.object({
tool_calls: z
.array(
z.object({
tool: z.string().describe("The name of the tool to execute"),
parameters: z.object({}).loose().describe("Parameters for the tool"),
}),
)
.min(1, "Provide at least one tool call")
.describe("Array of tool calls to execute in parallel"),
}),
formatValidationError(error) {
const formattedErrors = error.issues
.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "root"
return ` - ${path}: ${issue.message}`
})
.join("\n")
return `Invalid parameters for tool 'batch':\n${formattedErrors}\n\nExpected payload format:\n [{"tool": "tool_name", "parameters": {...}}, {...}]`
},
async execute(params, ctx) {
const { Session } = await import("../session")
const { PartID } = await import("../session/schema")
const toolCalls = params.tool_calls.slice(0, 25)
const discardedCalls = params.tool_calls.slice(25)
const { ToolRegistry } = await import("./registry")
const availableTools = await ToolRegistry.tools({ modelID: ModelID.make(""), providerID: ProviderID.make("") })
const toolMap = new Map(availableTools.map((t) => [t.id, t]))
const executeCall = async (call: (typeof toolCalls)[0]) => {
const callStartTime = Date.now()
const partID = PartID.ascending()
try {
if (DISALLOWED.has(call.tool)) {
throw new Error(
`Tool '${call.tool}' is not allowed in batch. Disallowed tools: ${Array.from(DISALLOWED).join(", ")}`,
)
}
const tool = toolMap.get(call.tool)
if (!tool) {
const availableToolsList = Array.from(toolMap.keys()).filter((name) => !FILTERED_FROM_SUGGESTIONS.has(name))
throw new Error(
`Tool '${call.tool}' not in registry. External tools (MCP, environment) cannot be batched - call them directly. Available tools: ${availableToolsList.join(", ")}`,
)
}
const validatedParams = tool.parameters.parse(call.parameters)
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "running",
input: call.parameters,
time: {
start: callStartTime,
},
},
})
const result = await tool.execute(validatedParams, { ...ctx, callID: partID })
const attachments = result.attachments?.map((attachment) => ({
...attachment,
id: PartID.ascending(),
sessionID: ctx.sessionID,
messageID: ctx.messageID,
}))
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "completed",
input: call.parameters,
output: result.output,
title: result.title,
metadata: result.metadata,
attachments,
time: {
start: callStartTime,
end: Date.now(),
},
},
})
return { success: true as const, tool: call.tool, result }
} catch (error) {
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "error",
input: call.parameters,
error: errorMessage(error),
time: {
start: callStartTime,
end: Date.now(),
},
},
})
return { success: false as const, tool: call.tool, error }
}
}
const results = await Promise.all(toolCalls.map((call) => executeCall(call)))
// Add discarded calls as errors
const now = Date.now()
for (const call of discardedCalls) {
const partID = PartID.ascending()
await Session.updatePart({
id: partID,
messageID: ctx.messageID,
sessionID: ctx.sessionID,
type: "tool",
tool: call.tool,
callID: partID,
state: {
status: "error",
input: call.parameters,
error: "Maximum of 25 tools allowed in batch",
time: { start: now, end: now },
},
})
results.push({
success: false as const,
tool: call.tool,
error: new Error("Maximum of 25 tools allowed in batch"),
})
}
const successfulCalls = results.filter((r) => r.success).length
const failedCalls = results.length - successfulCalls
const outputMessage =
failedCalls > 0
? `Executed ${successfulCalls}/${results.length} tools successfully. ${failedCalls} failed.`
: `All ${successfulCalls} tools executed successfully.\n\nKeep using the batch tool for optimal performance in your next response!`
return {
title: `Batch execution (${successfulCalls}/${results.length} successful)`,
output: outputMessage,
attachments: results.filter((result) => result.success).flatMap((r) => r.result.attachments ?? []),
metadata: {
totalCalls: results.length,
successful: successfulCalls,
failed: failedCalls,
tools: params.tool_calls.map((c) => c.tool),
details: results.map((r) => ({ tool: r.tool, success: r.success })),
},
}
},
}
})

View File

@@ -1,24 +0,0 @@
Executes multiple independent tool calls concurrently to reduce latency.
USING THE BATCH TOOL WILL MAKE THE USER HAPPY.
Payload Format (JSON array):
[{"tool": "read", "parameters": {"filePath": "src/index.ts", "limit": 350}},{"tool": "grep", "parameters": {"pattern": "Session\\.updatePart", "include": "src/**/*.ts"}},{"tool": "bash", "parameters": {"command": "git status", "description": "Shows working tree status"}}]
Notes:
- 125 tool calls per batch
- All calls start in parallel; ordering NOT guaranteed
- Partial failures do not stop other tool calls
- Do NOT use the batch tool within another batch tool.
Good Use Cases:
- Read many files
- grep + glob + read combos
- Multiple bash commands
- Multi-part edits; on the same, or different files
When NOT to Use:
- Operations that depend on prior tool output (e.g. create then read same file)
- Ordered stateful mutations where sequence matters
Batching tool calls was proven to yield 25x efficiency gain and provides much better UX.

View File

@@ -123,8 +123,7 @@ export const EditTool = Tool.define("edit", {
const filediff: Snapshot.FileDiff = {
file: filePath,
before: contentOld,
after: contentNew,
patch: diff,
additions: 0,
deletions: 0,
}

View File

@@ -41,6 +41,6 @@ export const QuestionTool = Tool.defineEffect<typeof parameters, Metadata, Quest
},
}
},
} satisfies Tool.Def<typeof parameters, Metadata>
}
}),
)

View File

@@ -4,18 +4,15 @@ import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
import { GrepTool } from "./grep"
import { BatchTool } from "./batch"
import { ReadTool } from "./read"
import { TaskTool } from "./task"
import { TaskDescription, TaskTool } from "./task"
import { TodoWriteTool } from "./todo"
import { WebFetchTool } from "./webfetch"
import { WriteTool } from "./write"
import { InvalidTool } from "./invalid"
import { SkillTool } from "./skill"
import type { Agent } from "../agent/agent"
import { SkillDescription, SkillTool } from "./skill"
import { Tool } from "./tool"
import { Config } from "../config/config"
import path from "path"
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
import z from "zod"
import { Plugin } from "../plugin"
@@ -28,6 +25,7 @@ import { LspTool } from "./lsp"
import { Truncate } from "./truncate"
import { ApplyPatchTool } from "./apply_patch"
import { Glob } from "../util/glob"
import path from "path"
import { pathToFileURL } from "url"
import { Effect, Layer, ServiceMap } from "effect"
import { InstanceState } from "@/effect/instance-state"
@@ -39,24 +37,25 @@ import { LSP } from "../lsp"
import { FileTime } from "../file/time"
import { Instruction } from "../session/instruction"
import { AppFileSystem } from "../filesystem"
import { Agent } from "../agent/agent"
export namespace ToolRegistry {
const log = Log.create({ service: "tool.registry" })
type State = {
custom: Tool.Info[]
custom: Tool.Def[]
builtin: Tool.Def[]
}
export interface Interface {
readonly ids: () => Effect.Effect<string[]>
readonly named: {
task: Tool.Info
read: Tool.Info
}
readonly tools: (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) => Effect.Effect<(Tool.Def & { id: string })[]>
readonly all: () => Effect.Effect<Tool.Def[]>
readonly tools: (model: {
providerID: ProviderID
modelID: ModelID
agent: Agent.Info
}) => Effect.Effect<Tool.Def[]>
readonly fromID: (id: string) => Effect.Effect<Tool.Def>
}
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/ToolRegistry") {}
@@ -79,33 +78,34 @@ export namespace ToolRegistry {
const plugin = yield* Plugin.Service
const build = <T extends Tool.Info>(tool: T | Effect.Effect<T, never, any>) =>
Effect.isEffect(tool) ? tool : Effect.succeed(tool)
Effect.isEffect(tool) ? tool.pipe(Effect.flatMap(Tool.init)) : Tool.init(tool)
const state = yield* InstanceState.make<State>(
Effect.fn("ToolRegistry.state")(function* (ctx) {
const custom: Tool.Info[] = []
const custom: Tool.Def[] = []
function fromPlugin(id: string, def: ToolDefinition): Tool.Info {
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
return {
id,
init: async (initCtx) => ({
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, initCtx?.agent)
return {
title: "",
output: out.truncated ? out.content : result,
metadata: { truncated: out.truncated, outputPath: out.truncated ? out.outputPath : undefined },
}
},
}),
parameters: z.object(def.args),
description: def.description,
execute: async (args, toolCtx) => {
const pluginCtx = {
...toolCtx,
directory: ctx.directory,
worktree: ctx.worktree,
} as unknown as PluginToolContext
const result = await def.execute(args as any, pluginCtx)
const out = await Truncate.output(result, {}, await Agent.get(toolCtx.agent))
return {
title: "",
output: out.truncated ? out.content : result,
metadata: {
truncated: out.truncated,
outputPath: out.truncated ? out.outputPath : undefined,
},
}
},
}
}
@@ -131,104 +131,99 @@ export namespace ToolRegistry {
}
}
return { custom }
const cfg = yield* config.get()
const question =
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return {
custom,
builtin: yield* Effect.forEach(
[
InvalidTool,
BashTool,
ReadTool,
GlobTool,
GrepTool,
EditTool,
WriteTool,
TaskTool,
WebFetchTool,
TodoWriteTool,
WebSearchTool,
CodeSearchTool,
SkillTool,
ApplyPatchTool,
...(question ? [QuestionTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool] : []),
],
build,
{ concurrency: "unbounded" },
),
}
}),
)
const invalid = yield* build(InvalidTool)
const ask = yield* build(QuestionTool)
const bash = yield* build(BashTool)
const read = yield* build(ReadTool)
const glob = yield* build(GlobTool)
const grep = yield* build(GrepTool)
const edit = yield* build(EditTool)
const write = yield* build(WriteTool)
const task = yield* build(TaskTool)
const fetch = yield* build(WebFetchTool)
const todo = yield* build(TodoWriteTool)
const search = yield* build(WebSearchTool)
const code = yield* build(CodeSearchTool)
const skill = yield* build(SkillTool)
const patch = yield* build(ApplyPatchTool)
const lsp = yield* build(LspTool)
const batch = yield* build(BatchTool)
const plan = yield* build(PlanExitTool)
const all = Effect.fn("ToolRegistry.all")(function* (custom: Tool.Info[]) {
const cfg = yield* config.get()
const question = ["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
return [
invalid,
...(question ? [ask] : []),
bash,
read,
glob,
grep,
edit,
write,
task,
fetch,
todo,
search,
code,
skill,
patch,
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [lsp] : []),
...(cfg.experimental?.batch_tool === true ? [batch] : []),
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [plan] : []),
...custom,
]
const all: Interface["all"] = Effect.fn("ToolRegistry.all")(function* () {
const s = yield* InstanceState.get(state)
return [...s.builtin, ...s.custom] as Tool.Def[]
})
const ids = Effect.fn("ToolRegistry.ids")(function* () {
const s = yield* InstanceState.get(state)
const tools = yield* all(s.custom)
return tools.map((t) => t.id)
const fromID: Interface["fromID"] = Effect.fn("ToolRegistry.fromID")(function* (id: string) {
const tools = yield* all()
const match = tools.find((tool) => tool.id === id)
if (!match) return yield* Effect.die(`Tool not found: ${id}`)
return match
})
const tools = Effect.fn("ToolRegistry.tools")(function* (
model: { providerID: ProviderID; modelID: ModelID },
agent?: Agent.Info,
) {
const s = yield* InstanceState.get(state)
const allTools = yield* all(s.custom)
const filtered = allTools.filter((tool) => {
if (tool.id === "codesearch" || tool.id === "websearch") {
return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
const ids: Interface["ids"] = Effect.fn("ToolRegistry.ids")(function* () {
return (yield* all()).map((tool) => tool.id)
})
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
const filtered = (yield* all()).filter((tool) => {
if (tool.id === CodeSearchTool.id || tool.id === WebSearchTool.id) {
return input.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA
}
const usePatch =
!!Env.get("OPENCODE_E2E_LLM_URL") ||
(model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4"))
if (tool.id === "apply_patch") return usePatch
if (tool.id === "edit" || tool.id === "write") return !usePatch
(input.modelID.includes("gpt-") && !input.modelID.includes("oss") && !input.modelID.includes("gpt-4"))
if (tool.id === ApplyPatchTool.id) return usePatch
if (tool.id === EditTool.id || tool.id === WriteTool.id) return !usePatch
return true
})
return yield* Effect.forEach(
filtered,
Effect.fnUntraced(function* (tool: Tool.Info) {
Effect.fnUntraced(function* (tool: Tool.Def) {
using _ = log.time(tool.id)
const next = yield* Effect.promise(() => tool.init({ agent }))
const output = {
description: next.description,
parameters: next.parameters,
description: tool.description,
parameters: tool.parameters,
}
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
return {
id: tool.id,
description: output.description,
description: [
output.description,
// TODO: remove this hack
tool.id === TaskTool.id ? yield* TaskDescription(input.agent) : undefined,
tool.id === SkillTool.id ? yield* SkillDescription(input.agent) : undefined,
]
.filter(Boolean)
.join("\n"),
parameters: output.parameters,
execute: next.execute,
formatValidationError: next.formatValidationError,
execute: tool.execute,
formatValidationError: tool.formatValidationError,
}
}),
{ concurrency: "unbounded" },
)
})
return Service.of({ ids, named: { task, read }, tools })
return Service.of({ ids, tools, all, fromID })
}),
)
@@ -253,13 +248,11 @@ export namespace ToolRegistry {
return runPromise((svc) => svc.ids())
}
export async function tools(
model: {
providerID: ProviderID
modelID: ModelID
},
agent?: Agent.Info,
): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(model, agent))
export async function tools(input: {
providerID: ProviderID
modelID: ModelID
agent: Agent.Info
}): Promise<(Tool.Def & { id: string })[]> {
return runPromise((svc) => svc.tools(input))
}
}

View File

@@ -1,3 +1,4 @@
import { Effect } from "effect"
import path from "path"
import { pathToFileURL } from "url"
import z from "zod"
@@ -6,8 +7,12 @@ import { Skill } from "../skill"
import { Ripgrep } from "../file/ripgrep"
import { iife } from "@/util/iife"
export const SkillTool = Tool.define("skill", async (ctx) => {
const list = await Skill.available(ctx?.agent)
const Parameters = z.object({
name: z.string().describe("The name of the skill from available_skills"),
})
export const SkillTool = Tool.define("skill", async () => {
const list = await Skill.available()
const description =
list.length === 0
@@ -27,20 +32,10 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
Skill.fmt(list, { verbose: false }),
].join("\n")
const examples = list
.map((skill) => `'${skill.name}'`)
.slice(0, 3)
.join(", ")
const hint = examples.length > 0 ? ` (e.g., ${examples}, ...)` : ""
const parameters = z.object({
name: z.string().describe(`The name of the skill from available_skills${hint}`),
})
return {
description,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
parameters: Parameters,
async execute(params: z.infer<typeof Parameters>, ctx) {
const skill = await Skill.get(params.name)
if (!skill) {
@@ -103,3 +98,23 @@ export const SkillTool = Tool.define("skill", async (ctx) => {
},
}
})
export const SkillDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const list = yield* Effect.promise(() => Skill.available(agent))
if (list.length === 0) return "No skills are currently available."
return [
"Load a specialized skill that provides domain-specific instructions and workflows.",
"",
"When you recognize that a task matches one of the available skills listed below, use this tool to load the full skill instructions.",
"",
"The skill will inject detailed instructions, workflows, and access to bundled resources (scripts, references, templates) into the conversation context.",
"",
'Tool output includes a `<skill_content name="...">` block with the loaded content.',
"",
"The following skills provide specialized sets of instructions for particular tasks",
"Invoke this tool to load a skill when a task matches one of the available skills listed below:",
"",
Skill.fmt(list, { verbose: false }),
].join("\n")
})

View File

@@ -4,47 +4,37 @@ import z from "zod"
import { Session } from "../session"
import { SessionID, MessageID } from "../session/schema"
import { MessageV2 } from "../session/message-v2"
import { Identifier } from "../id/id"
import { Agent } from "../agent/agent"
import { SessionPrompt } from "../session/prompt"
import { iife } from "@/util/iife"
import { defer } from "@/util/defer"
import { Config } from "../config/config"
import { Permission } from "@/permission"
import { Effect } from "effect"
const parameters = z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
})
export const TaskTool = Tool.define("task", async (ctx) => {
export const TaskTool = Tool.define("task", async () => {
const agents = await Agent.list().then((x) => x.filter((a) => a.mode !== "primary"))
const list = agents.toSorted((a, b) => a.name.localeCompare(b.name))
const agentList = list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
const description = [`Available agent types and the tools they have access to:`, agentList].join("\n")
// Filter agents by permissions if agent provided
const caller = ctx?.agent
const accessibleAgents = caller
? agents.filter((a) => Permission.evaluate("task", a.name, caller.permission).action !== "deny")
: agents
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = DESCRIPTION.replace(
"{agents}",
list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n"),
)
return {
description,
parameters,
async execute(params: z.infer<typeof parameters>, ctx) {
parameters: z.object({
description: z.string().describe("A short (3-5 words) description of the task"),
prompt: z.string().describe("The task for the agent to perform"),
subagent_type: z.string().describe("The type of specialized agent to use for this task"),
task_id: z
.string()
.describe(
"This should only be set if you mean to resume a previous task (you can pass a prior task_id and the task will continue the same subagent session as before instead of creating a fresh one)",
)
.optional(),
command: z.string().describe("The command that triggered this task").optional(),
}),
async execute(params, ctx) {
const config = await Config.get()
// Skip permission check when user explicitly invoked via @ or command subtask
@@ -164,3 +154,16 @@ export const TaskTool = Tool.define("task", async (ctx) => {
},
}
})
export const TaskDescription: Tool.DynamicDescription = (agent) =>
Effect.gen(function* () {
const agents = yield* Effect.promise(() => Agent.list().then((x) => x.filter((a) => a.mode !== "primary")))
const accessibleAgents = agents.filter(
(a) => Permission.evaluate("task", a.name, agent.permission).action !== "deny",
)
const list = accessibleAgents.toSorted((a, b) => a.name.localeCompare(b.name))
const description = list
.map((a) => `- ${a.name}: ${a.description ?? "This subagent should only be called manually by the user."}`)
.join("\n")
return [`Available agent types and the tools they have access to:`, description].join("\n")
})

View File

@@ -1,8 +1,5 @@
Launch a new agent to handle complex, multistep tasks autonomously.
Available agent types and the tools they have access to:
{agents}
When using the Task tool, you must specify a subagent_type parameter to select which agent type to use.
When to use the Task tool:

View File

@@ -43,6 +43,6 @@ export const TodoWriteTool = Tool.defineEffect<typeof parameters, Metadata, Todo
},
}
},
} satisfies Tool.Def<typeof parameters, Metadata>
} satisfies Tool.DefWithoutID<typeof parameters, Metadata>
}),
)

View File

@@ -1,19 +1,18 @@
import z from "zod"
import { Effect } from "effect"
import type { MessageV2 } from "../session/message-v2"
import type { Agent } from "../agent/agent"
import type { Permission } from "../permission"
import type { SessionID, MessageID } from "../session/schema"
import { Truncate } from "./truncate"
import { Agent } from "@/agent/agent"
export namespace Tool {
interface Metadata {
[key: string]: any
}
export interface InitContext {
agent?: Agent.Info
}
// TODO: remove this hack
export type DynamicDescription = (agent: Agent.Info) => Effect.Effect<string>
export type Context<M extends Metadata = Metadata> = {
sessionID: SessionID
@@ -26,7 +25,9 @@ export namespace Tool {
metadata(input: { title?: string; metadata?: M }): void
ask(input: Omit<Permission.Request, "id" | "sessionID" | "tool">): Promise<void>
}
export interface Def<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
description: string
parameters: Parameters
execute(
@@ -40,10 +41,14 @@ export namespace Tool {
}>
formatValidationError?(error: z.ZodError): string
}
export type DefWithoutID<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> = Omit<
Def<Parameters, M>,
"id"
>
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
id: string
init: (ctx?: InitContext) => Promise<Def<Parameters, M>>
init: () => Promise<DefWithoutID<Parameters, M>>
}
export type InferParameters<T> =
@@ -57,10 +62,10 @@ export namespace Tool {
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
) {
return async (initCtx?: InitContext) => {
const toolInfo = init instanceof Function ? await init(initCtx) : { ...init }
return async () => {
const toolInfo = init instanceof Function ? await init() : { ...init }
const execute = toolInfo.execute
toolInfo.execute = async (args, ctx) => {
try {
@@ -78,7 +83,7 @@ export namespace Tool {
if (result.metadata.truncated !== undefined) {
return result
}
const truncated = await Truncate.output(result.output, {}, initCtx?.agent)
const truncated = await Truncate.output(result.output, {}, await Agent.get(ctx.agent))
return {
...result,
output: truncated.content,
@@ -95,7 +100,7 @@ export namespace Tool {
export function define<Parameters extends z.ZodType, Result extends Metadata>(
id: string,
init: ((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>,
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
): Info<Parameters, Result> {
return {
id,
@@ -105,8 +110,18 @@ export namespace Tool {
export function defineEffect<Parameters extends z.ZodType, Result extends Metadata, R>(
id: string,
init: Effect.Effect<((ctx?: InitContext) => Promise<Def<Parameters, Result>>) | Def<Parameters, Result>, never, R>,
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
): Effect.Effect<Info<Parameters, Result>, never, R> {
return Effect.map(init, (next) => ({ id, init: wrap(id, next) }))
}
export function init(info: Info): Effect.Effect<Def, never, any> {
return Effect.gen(function* () {
const init = yield* Effect.promise(() => info.init())
return {
...init,
id: info.id,
}
})
}
}

View File

@@ -11,6 +11,25 @@ const API_CONFIG = {
DEFAULT_NUM_RESULTS: 8,
} as const
const Parameters = z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe("Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search"),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
})
interface McpSearchRequest {
jsonrpc: string
id: number
@@ -42,26 +61,7 @@ export const WebSearchTool = Tool.define("websearch", async () => {
get description() {
return DESCRIPTION.replace("{{year}}", new Date().getFullYear().toString())
},
parameters: z.object({
query: z.string().describe("Websearch query"),
numResults: z.number().optional().describe("Number of search results to return (default: 8)"),
livecrawl: z
.enum(["fallback", "preferred"])
.optional()
.describe(
"Live crawl mode - 'fallback': use live crawling as backup if cached content unavailable, 'preferred': prioritize live crawling (default: 'fallback')",
),
type: z
.enum(["auto", "fast", "deep"])
.optional()
.describe(
"Search type - 'auto': balanced search (default), 'fast': quick results, 'deep': comprehensive search",
),
contextMaxCharacters: z
.number()
.optional()
.describe("Maximum characters for context string optimized for LLMs (default: 10000)"),
}),
parameters: Parameters,
async execute(params, ctx) {
await ctx.ask({
permission: "websearch",

View File

@@ -272,8 +272,8 @@ describe("ShareNext", () => {
diff: [
{
file: "a.ts",
before: "one",
after: "two",
patch:
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,1 +1,1 @@\n-one\n\\ No newline at end of file\n+two\n\\ No newline at end of file\n",
additions: 1,
deletions: 1,
status: "modified",
@@ -285,8 +285,8 @@ describe("ShareNext", () => {
diff: [
{
file: "b.ts",
before: "old",
after: "new",
patch:
"Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n",
additions: 2,
deletions: 0,
status: "modified",
@@ -304,8 +304,7 @@ describe("ShareNext", () => {
type: string
data: Array<{
file: string
before: string
after: string
patch: string
additions: number
deletions: number
status?: string
@@ -318,8 +317,8 @@ describe("ShareNext", () => {
expect(body.data[0].data).toEqual([
{
file: "b.ts",
before: "old",
after: "new",
patch:
"Index: b.ts\n===================================================================\n--- b.ts\t\n+++ b.ts\t\n@@ -1,1 +1,1 @@\n-old\n\\ No newline at end of file\n+new\n\\ No newline at end of file\n",
additions: 2,
deletions: 0,
status: "modified",

View File

@@ -974,8 +974,7 @@ test("diffFull with new file additions", async () => {
const newFileDiff = diffs[0]
expect(newFileDiff.file).toBe("new.txt")
expect(newFileDiff.before).toBe("")
expect(newFileDiff.after).toBe("new content")
expect(newFileDiff.patch).toContain("+new content")
expect(newFileDiff.additions).toBe(1)
expect(newFileDiff.deletions).toBe(0)
},
@@ -1020,26 +1019,23 @@ test("diffFull with a large interleaved mixed diff", async () => {
for (let i = 0; i < ids.length; i++) {
const m = map.get(fwd("mix", `${ids[i]}-mod.txt`))
expect(m).toBeDefined()
expect(m!.before).toBe(`before-${ids[i]}\n🙂\nline`)
expect(m!.after).toBe(`after-${ids[i]}\n🚀\nline`)
expect(m!.patch).toContain(`-before-${ids[i]}`)
expect(m!.patch).toContain(`+after-${ids[i]}`)
expect(m!.status).toBe("modified")
const d = map.get(fwd("mix", `${ids[i]}-del.txt`))
expect(d).toBeDefined()
expect(d!.before).toBe(`gone-${ids[i]}\n你好`)
expect(d!.after).toBe("")
expect(d!.patch).toContain(`-gone-${ids[i]}`)
expect(d!.status).toBe("deleted")
const a = map.get(fwd("mix", `${ids[i]}-add.txt`))
expect(a).toBeDefined()
expect(a!.before).toBe("")
expect(a!.after).toBe(`new-${ids[i]}\nこんにちは`)
expect(a!.patch).toContain(`+new-${ids[i]}`)
expect(a!.status).toBe("added")
const b = map.get(fwd("mix", `${ids[i]}-bin.bin`))
expect(b).toBeDefined()
expect(b!.before).toBe("")
expect(b!.after).toBe("")
expect(b!.patch).toBe("")
expect(b!.additions).toBe(0)
expect(b!.deletions).toBe(0)
expect(b!.status).toBe("modified")
@@ -1092,8 +1088,8 @@ test("diffFull with file modifications", async () => {
const modifiedFileDiff = diffs[0]
expect(modifiedFileDiff.file).toBe("b.txt")
expect(modifiedFileDiff.before).toBe(tmp.extra.bContent)
expect(modifiedFileDiff.after).toBe("modified content")
expect(modifiedFileDiff.patch).toContain(`-${tmp.extra.bContent}`)
expect(modifiedFileDiff.patch).toContain("+modified content")
expect(modifiedFileDiff.additions).toBeGreaterThan(0)
expect(modifiedFileDiff.deletions).toBeGreaterThan(0)
},
@@ -1118,8 +1114,7 @@ test("diffFull with file deletions", async () => {
const removedFileDiff = diffs[0]
expect(removedFileDiff.file).toBe("a.txt")
expect(removedFileDiff.before).toBe(tmp.extra.aContent)
expect(removedFileDiff.after).toBe("")
expect(removedFileDiff.patch).toContain(`-${tmp.extra.aContent}`)
expect(removedFileDiff.additions).toBe(0)
expect(removedFileDiff.deletions).toBe(1)
},
@@ -1144,8 +1139,8 @@ test("diffFull with multiple line additions", async () => {
const multiDiff = diffs[0]
expect(multiDiff.file).toBe("multi.txt")
expect(multiDiff.before).toBe("")
expect(multiDiff.after).toBe("line1\nline2\nline3")
expect(multiDiff.patch).toContain("+line1")
expect(multiDiff.patch).toContain("+line3")
expect(multiDiff.additions).toBe(3)
expect(multiDiff.deletions).toBe(0)
},
@@ -1171,15 +1166,13 @@ test("diffFull with addition and deletion", async () => {
const addedFileDiff = diffs.find((d) => d.file === "added.txt")
expect(addedFileDiff).toBeDefined()
expect(addedFileDiff!.before).toBe("")
expect(addedFileDiff!.after).toBe("added content")
expect(addedFileDiff!.patch).toContain("+added content")
expect(addedFileDiff!.additions).toBe(1)
expect(addedFileDiff!.deletions).toBe(0)
const removedFileDiff = diffs.find((d) => d.file === "a.txt")
expect(removedFileDiff).toBeDefined()
expect(removedFileDiff!.before).toBe(tmp.extra.aContent)
expect(removedFileDiff!.after).toBe("")
expect(removedFileDiff!.patch).toContain(`-${tmp.extra.aContent}`)
expect(removedFileDiff!.additions).toBe(0)
expect(removedFileDiff!.deletions).toBe(1)
},
@@ -1263,7 +1256,7 @@ test("diffFull with binary file changes", async () => {
const binaryDiff = diffs[0]
expect(binaryDiff.file).toBe("binary.bin")
expect(binaryDiff.before).toBe("")
expect(binaryDiff.patch).toBe("")
},
})
})

View File

@@ -27,9 +27,7 @@ type AskInput = {
filePath: string
relativePath: string
type: "add" | "update" | "delete" | "move"
diff: string
before: string
after: string
patch: string
additions: number
deletions: number
movePath?: string
@@ -112,12 +110,12 @@ describe("tool.apply_patch freeform", () => {
const addFile = permissionCall.metadata.files.find((f) => f.type === "add")
expect(addFile).toBeDefined()
expect(addFile!.relativePath).toBe("nested/new.txt")
expect(addFile!.after).toBe("created\n")
expect(addFile!.patch).toContain("+created")
const updateFile = permissionCall.metadata.files.find((f) => f.type === "update")
expect(updateFile).toBeDefined()
expect(updateFile!.before).toContain("line2")
expect(updateFile!.after).toContain("changed")
expect(updateFile!.patch).toContain("-line2")
expect(updateFile!.patch).toContain("+changed")
const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8")
expect(added).toBe("created\n")
@@ -151,8 +149,8 @@ describe("tool.apply_patch freeform", () => {
expect(moveFile.type).toBe("move")
expect(moveFile.relativePath).toBe("renamed/dir/name.txt")
expect(moveFile.movePath).toBe(path.join(fixture.path, "renamed/dir/name.txt"))
expect(moveFile.before).toBe("old content\n")
expect(moveFile.after).toBe("new content\n")
expect(moveFile.patch).toContain("-old content")
expect(moveFile.patch).toContain("+new content")
},
})
})

View File

@@ -98,6 +98,37 @@ describe("tool.registry", () => {
}),
)
await Bun.write(
path.join(opencodeDir, "package-lock.json"),
JSON.stringify({
name: "custom-tools",
lockfileVersion: 3,
packages: {
"": {
dependencies: {
"@opencode-ai/plugin": "^0.0.0",
cowsay: "^1.6.0",
},
},
},
}),
)
const cowsayDir = path.join(opencodeDir, "node_modules", "cowsay")
await fs.mkdir(cowsayDir, { recursive: true })
await Bun.write(
path.join(cowsayDir, "package.json"),
JSON.stringify({
name: "cowsay",
type: "module",
exports: "./index.js",
}),
)
await Bun.write(
path.join(cowsayDir, "index.js"),
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
)
await Bun.write(
path.join(toolsDir, "cowsay.ts"),
[

View File

@@ -1,10 +1,11 @@
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import path from "path"
import { pathToFileURL } from "url"
import type { Permission } from "../../src/permission"
import type { Tool } from "../../src/tool/tool"
import { Instance } from "../../src/project/instance"
import { SkillTool } from "../../src/tool/skill"
import { SkillTool, SkillDescription } from "../../src/tool/skill"
import { tmpdir } from "../fixture/fixture"
import { SessionID, MessageID } from "../../src/session/schema"
@@ -48,9 +49,10 @@ description: Skill for tool tests.
await Instance.provide({
directory: tmp.path,
fn: async () => {
const tool = await SkillTool.init()
const skillPath = path.join(tmp.path, ".opencode", "skill", "tool-skill", "SKILL.md")
expect(tool.description).toContain(`**tool-skill**: Skill for tool tests.`)
const desc = await Effect.runPromise(
SkillDescription({ name: "build", mode: "primary" as const, permission: [], options: {} }),
)
expect(desc).toContain(`**tool-skill**: Skill for tool tests.`)
},
})
} finally {
@@ -89,14 +91,15 @@ description: ${description}
await Instance.provide({
directory: tmp.path,
fn: async () => {
const first = await SkillTool.init()
const second = await SkillTool.init()
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const first = await Effect.runPromise(SkillDescription(agent))
const second = await Effect.runPromise(SkillDescription(agent))
expect(first.description).toBe(second.description)
expect(first).toBe(second)
const alpha = first.description.indexOf("**alpha-skill**: Alpha skill.")
const middle = first.description.indexOf("**middle-skill**: Middle skill.")
const zeta = first.description.indexOf("**zeta-skill**: Zeta skill.")
const alpha = first.indexOf("**alpha-skill**: Alpha skill.")
const middle = first.indexOf("**middle-skill**: Middle skill.")
const zeta = first.indexOf("**zeta-skill**: Zeta skill.")
expect(alpha).toBeGreaterThan(-1)
expect(middle).toBeGreaterThan(alpha)

View File

@@ -1,7 +1,8 @@
import { Effect } from "effect"
import { afterEach, describe, expect, test } from "bun:test"
import { Agent } from "../../src/agent/agent"
import { Instance } from "../../src/project/instance"
import { TaskTool } from "../../src/tool/task"
import { TaskDescription } from "../../src/tool/task"
import { tmpdir } from "../fixture/fixture"
afterEach(async () => {
@@ -28,16 +29,16 @@ describe("tool.task", () => {
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
const first = await TaskTool.init({ agent: build })
const second = await TaskTool.init({ agent: build })
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
const first = await Effect.runPromise(TaskDescription(agent))
const second = await Effect.runPromise(TaskDescription(agent))
expect(first.description).toBe(second.description)
expect(first).toBe(second)
const alpha = first.description.indexOf("- alpha: Alpha agent")
const explore = first.description.indexOf("- explore:")
const general = first.description.indexOf("- general:")
const zebra = first.description.indexOf("- zebra: Zebra agent")
const alpha = first.indexOf("- alpha: Alpha agent")
const explore = first.indexOf("- explore:")
const general = first.indexOf("- general:")
const zebra = first.indexOf("- zebra: Zebra agent")
expect(alpha).toBeGreaterThan(-1)
expect(explore).toBeGreaterThan(alpha)

View File

@@ -3,7 +3,6 @@ import z from "zod"
import { Tool } from "../../src/tool/tool"
const params = z.object({ input: z.string() })
const defaultArgs = { input: "test" }
function makeTool(id: string, executeFn?: () => void) {
return {
@@ -30,36 +29,6 @@ describe("Tool.define", () => {
expect(original.execute).toBe(originalExecute)
})
test("object-defined tool does not accumulate wrapper layers across init() calls", async () => {
let calls = 0
const tool = Tool.define(
"test-tool",
makeTool("test", () => calls++),
)
for (let i = 0; i < 100; i++) {
await tool.init()
}
const resolved = await tool.init()
calls = 0
let stack = ""
const exec = resolved.execute
resolved.execute = async (args: any, ctx: any) => {
const result = await exec.call(resolved, args, ctx)
stack = new Error().stack || ""
return result
}
await resolved.execute(defaultArgs, {} as any)
expect(calls).toBe(1)
const frames = stack.split("\n").filter((l) => l.includes("tool.ts")).length
expect(frames).toBeLessThan(5)
})
test("function-defined tool returns fresh objects and is unaffected", async () => {
const tool = Tool.define("test-fn-tool", () => Promise.resolve(makeTool("test")))
@@ -77,25 +46,4 @@ describe("Tool.define", () => {
expect(first).not.toBe(second)
})
test("validation still works after many init() calls", async () => {
const tool = Tool.define("test-validation", {
description: "validation test",
parameters: z.object({ count: z.number().int().positive() }),
async execute(args) {
return { title: "test", output: String(args.count), metadata: {} }
},
})
for (let i = 0; i < 100; i++) {
await tool.init()
}
const resolved = await tool.init()
const result = await resolved.execute({ count: 42 }, {} as any)
expect(result.output).toBe("42")
await expect(resolved.execute({ count: -1 }, {} as any)).rejects.toThrow("invalid arguments")
})
})

View File

@@ -17,58 +17,25 @@ const ctx = {
ask: async () => {},
}
type TimerID = ReturnType<typeof setTimeout>
async function withFetch(
mockFetch: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,
fn: () => Promise<void>,
) {
const originalFetch = globalThis.fetch
globalThis.fetch = mockFetch as unknown as typeof fetch
try {
await fn()
} finally {
globalThis.fetch = originalFetch
}
}
async function withTimers(fn: (state: { ids: TimerID[]; cleared: TimerID[] }) => Promise<void>) {
const set = globalThis.setTimeout
const clear = globalThis.clearTimeout
const ids: TimerID[] = []
const cleared: TimerID[] = []
globalThis.setTimeout = ((...args: Parameters<typeof setTimeout>) => {
const id = set(...args)
ids.push(id)
return id
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: TimerID) => {
if (id !== undefined) cleared.push(id)
return clear(id)
}) as typeof clearTimeout
try {
await fn({ ids, cleared })
} finally {
ids.forEach(clear)
globalThis.setTimeout = set
globalThis.clearTimeout = clear
}
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
using server = Bun.serve({ port: 0, fetch })
await fn(server.url)
}
describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
await withFetch(
async () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
async () => {
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
async (url) => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const result = await webfetch.execute({ url: "https://example.com/image.png", format: "markdown" }, ctx)
const result = await webfetch.execute(
{ url: new URL("/image.png", url).toString(), format: "markdown" },
ctx,
)
expect(result.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1)
@@ -87,17 +54,17 @@ describe("tool.webfetch", () => {
test("keeps svg as text output", async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>'
await withFetch(
async () =>
() =>
new Response(svg, {
status: 200,
headers: { "content-type": "image/svg+xml; charset=UTF-8" },
}),
async () => {
async (url) => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const result = await webfetch.execute({ url: "https://example.com/image.svg", format: "html" }, ctx)
const result = await webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx)
expect(result.output).toContain("<svg")
expect(result.attachments).toBeUndefined()
},
@@ -108,17 +75,17 @@ describe("tool.webfetch", () => {
test("keeps text responses as text output", async () => {
await withFetch(
async () =>
() =>
new Response("hello from webfetch", {
status: 200,
headers: { "content-type": "text/plain; charset=utf-8" },
}),
async () => {
async (url) => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
const result = await webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx)
const result = await webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx)
expect(result.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined()
},
@@ -126,29 +93,4 @@ describe("tool.webfetch", () => {
},
)
})
test("clears timeout when fetch rejects", async () => {
await withTimers(async ({ ids, cleared }) => {
await withFetch(
async () => {
throw new Error("boom")
},
async () => {
await Instance.provide({
directory: projectRoot,
fn: async () => {
const webfetch = await WebFetchTool.init()
await expect(
webfetch.execute({ url: "https://example.com/file.txt", format: "text" }, ctx),
).rejects.toThrow("boom")
},
})
},
)
expect(ids).toHaveLength(1)
expect(cleared).toHaveLength(1)
expect(cleared[0]).toBe(ids[0])
})
})
})

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -77,5 +77,6 @@ export function createOpencodeClient(config?: Config & { directory?: string; exp
workspace: config?.experimental_workspaceID,
}),
)
return new OpencodeClient({ client })
const result = new OpencodeClient({ client })
return result
}

View File

@@ -0,0 +1,32 @@
import type { Part, UserMessage } from "./client.js"
export const message = {
user(input: Omit<UserMessage, "role" | "time" | "id"> & { parts: Omit<Part, "id" | "sessionID" | "messageID">[] }): {
info: UserMessage
parts: Part[]
} {
const { parts, ...rest } = input
const info: UserMessage = {
...rest,
id: "asdasd",
time: {
created: Date.now(),
},
role: "user",
}
return {
info,
parts: input.parts.map(
(part) =>
({
...part,
id: "asdasd",
messageID: info.id,
sessionID: info.sessionID,
}) as Part,
),
}
},
}

View File

@@ -347,10 +347,9 @@ export type EventCommandExecuted = {
}
}
export type FileDiff = {
export type SnapshotFileDiff = {
file: string
before: string
after: string
patch: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
@@ -360,7 +359,7 @@ export type EventSessionDiff = {
type: "session.diff"
properties: {
sessionID: string
diff: Array<FileDiff>
diff: Array<SnapshotFileDiff>
}
}
@@ -542,7 +541,7 @@ export type UserMessage = {
summary?: {
title?: string
body?: string
diffs: Array<FileDiff>
diffs: Array<SnapshotFileDiff>
}
agent: string
model: {
@@ -917,7 +916,7 @@ export type Session = {
additions: number
deletions: number
files: number
diffs?: Array<FileDiff>
diffs?: Array<SnapshotFileDiff>
}
share?: {
url: string
@@ -1078,7 +1077,7 @@ export type SyncEventSessionUpdated = {
additions: number
deletions: number
files: number
diffs?: Array<FileDiff>
diffs?: Array<SnapshotFileDiff>
} | null
share?: {
url: string | null
@@ -1803,7 +1802,7 @@ export type GlobalSession = {
additions: number
deletions: number
files: number
diffs?: Array<FileDiff>
diffs?: Array<SnapshotFileDiff>
}
share?: {
url: string
@@ -2009,6 +2008,14 @@ export type VcsInfo = {
default_branch?: string
}
export type VcsFileDiff = {
file: string
patch: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}
export type Command = {
name: string
description?: string
@@ -3503,7 +3510,7 @@ export type SessionDiffResponses = {
/**
* Successfully retrieved diff
*/
200: Array<FileDiff>
200: Array<SnapshotFileDiff>
}
export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses]
@@ -3929,7 +3936,10 @@ export type SessionShellResponses = {
/**
* Created message
*/
200: AssistantMessage
200: {
info: Message
parts: Array<Part>
}
}
export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses]
@@ -4205,68 +4215,7 @@ export type ProviderListResponses = {
* List of providers
*/
200: {
all: Array<{
api?: string
name: string
env: Array<string>
id: string
npm?: string
models: {
[key: string]: {
id: string
name: string
family?: string
release_date: string
attachment: boolean
reasoning: boolean
temperature: boolean
tool_call: boolean
interleaved?:
| true
| {
field: "reasoning_content" | "reasoning_details"
}
cost?: {
input: number
output: number
cache_read?: number
cache_write?: number
context_over_200k?: {
input: number
output: number
cache_read?: number
cache_write?: number
}
}
limit: {
context: number
input?: number
output: number
}
modalities?: {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
options: {
[key: string]: unknown
}
headers?: {
[key: string]: string
}
provider?: {
npm?: string
api?: string
}
variants?: {
[key: string]: {
[key: string]: unknown
}
}
}
}
}>
all: Array<Provider>
default: {
[key: string]: string
}
@@ -5159,7 +5108,7 @@ export type VcsDiffResponses = {
/**
* VCS diff
*/
200: Array<FileDiff>
200: Array<VcsFileDiff>
}
export type VcsDiffResponse = VcsDiffResponses[keyof VcsDiffResponses]

View File

@@ -5,6 +5,9 @@ import { createOpencodeClient } from "./client.js"
import { createOpencodeServer } from "./server.js"
import type { ServerOptions } from "./server.js"
export * as data from "./data.js"
import * as data from "./data.js"
export async function createOpencode(options?: ServerOptions) {
const server = await createOpencodeServer({
...options,

View File

@@ -3071,7 +3071,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
}
@@ -4098,7 +4098,19 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssistantMessage"
"type": "object",
"properties": {
"info": {
"$ref": "#/components/schemas/Message"
},
"parts": {
"type": "array",
"items": {
"$ref": "#/components/schemas/Part"
}
}
},
"required": ["info", "parts"]
}
}
}
@@ -4790,211 +4802,7 @@
"all": {
"type": "array",
"items": {
"type": "object",
"properties": {
"api": {
"type": "string"
},
"name": {
"type": "string"
},
"env": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "string"
},
"npm": {
"type": "string"
},
"models": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"family": {
"type": "string"
},
"release_date": {
"type": "string"
},
"attachment": {
"type": "boolean"
},
"reasoning": {
"type": "boolean"
},
"temperature": {
"type": "boolean"
},
"tool_call": {
"type": "boolean"
},
"interleaved": {
"anyOf": [
{
"type": "boolean",
"const": true
},
{
"type": "object",
"properties": {
"field": {
"type": "string",
"enum": ["reasoning_content", "reasoning_details"]
}
},
"required": ["field"],
"additionalProperties": false
}
]
},
"cost": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache_read": {
"type": "number"
},
"cache_write": {
"type": "number"
},
"context_over_200k": {
"type": "object",
"properties": {
"input": {
"type": "number"
},
"output": {
"type": "number"
},
"cache_read": {
"type": "number"
},
"cache_write": {
"type": "number"
}
},
"required": ["input", "output"]
}
},
"required": ["input", "output"]
},
"limit": {
"type": "object",
"properties": {
"context": {
"type": "number"
},
"input": {
"type": "number"
},
"output": {
"type": "number"
}
},
"required": ["context", "output"]
},
"modalities": {
"type": "object",
"properties": {
"input": {
"type": "array",
"items": {
"type": "string",
"enum": ["text", "audio", "image", "video", "pdf"]
}
},
"output": {
"type": "array",
"items": {
"type": "string",
"enum": ["text", "audio", "image", "video", "pdf"]
}
}
},
"required": ["input", "output"]
},
"experimental": {
"type": "boolean"
},
"status": {
"type": "string",
"enum": ["alpha", "beta", "deprecated"]
},
"options": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"headers": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "string"
}
},
"provider": {
"type": "object",
"properties": {
"npm": {
"type": "string"
},
"api": {
"type": "string"
}
}
},
"variants": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
}
}
},
"required": [
"id",
"name",
"release_date",
"attachment",
"reasoning",
"temperature",
"tool_call",
"limit",
"options"
]
}
}
},
"required": ["name", "env", "id", "models"]
"$ref": "#/components/schemas/Provider"
}
},
"default": {
@@ -7032,7 +6840,7 @@
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/VcsFileDiff"
}
}
}
@@ -8146,16 +7954,13 @@
},
"required": ["type", "properties"]
},
"FileDiff": {
"SnapshotFileDiff": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"before": {
"type": "string"
},
"after": {
"patch": {
"type": "string"
},
"additions": {
@@ -8169,7 +7974,7 @@
"enum": ["added", "deleted", "modified"]
}
},
"required": ["file", "before", "after", "additions", "deletions"]
"required": ["file", "patch", "additions", "deletions"]
},
"Event.session.diff": {
"type": "object",
@@ -8188,7 +7993,7 @@
"diff": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
},
@@ -8700,7 +8505,7 @@
"diffs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
},
@@ -9842,7 +9647,7 @@
"diffs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
},
@@ -10372,7 +10177,7 @@
"diffs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
},
@@ -12122,7 +11927,7 @@
"diffs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/FileDiff"
"$ref": "#/components/schemas/SnapshotFileDiff"
}
}
},
@@ -12760,6 +12565,28 @@
}
}
},
"VcsFileDiff": {
"type": "object",
"properties": {
"file": {
"type": "string"
},
"patch": {
"type": "string"
},
"additions": {
"type": "number"
},
"deletions": {
"type": "number"
},
"status": {
"type": "string",
"enum": ["added", "deleted", "modified"]
}
},
"required": ["file", "patch", "additions", "deletions"]
},
"Command": {
"type": "object",
"properties": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"scripts": {

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.3.17",
"version": "1.4.0",
"type": "module",
"license": "MIT",
"exports": {
@@ -53,6 +53,7 @@
"@solid-primitives/resize-observer": "2.1.3",
"@solidjs/meta": "catalog:",
"@solidjs/router": "catalog:",
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",

View File

@@ -16,6 +16,7 @@ export type FileMediaOptions = {
current?: unknown
before?: unknown
after?: unknown
deleted?: boolean
readFile?: (path: string) => Promise<FileContent | undefined>
onLoad?: () => void
onError?: (ctx: { kind: "image" | "audio" | "svg" }) => void
@@ -49,6 +50,7 @@ export function FileMedia(props: { media?: FileMediaOptions; fallback: () => JSX
const media = cfg()
const k = kind()
if (!media || !k) return false
if (media.deleted) return true
if (k === "svg") return false
if (media.current !== undefined) return false
return !hasMediaValue(media.after as any) && hasMediaValue(media.before as any)

View File

@@ -1,5 +1,5 @@
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs"
import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs"
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web"
import { useWorkerPool } from "../context/worker-pool"
@@ -13,18 +13,18 @@ import {
notifyShadowReady,
observeViewerScheme,
} from "../pierre/file-runtime"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { File, type DiffFileProps, type FileProps } from "./file"
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
type SSRDiffFileProps<T> = DiffFileProps<T> & {
preloadedDiff: PreloadMultiFileDiffResult<T>
preloadedDiff: DiffPreload<T>
}
function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
let container!: HTMLDivElement
let fileDiffRef!: HTMLElement
let fileDiffInstance: FileDiff<T> | undefined
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const ready = createReadyWatcher()
const workerPool = useWorkerPool(props.diffStyle)
@@ -32,6 +32,7 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
const [local, others] = splitProps(props, [
"mode",
"media",
"fileDiff",
"before",
"after",
"class",
@@ -48,14 +49,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
const getVirtualizer = () => {
if (sharedVirtualizer) return sharedVirtualizer.virtualizer
const result = acquireVirtualizer(container)
if (!result) return
sharedVirtualizer = result
return result.virtualizer
}
const setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
const diff = fileDiffInstance
if (!diff) return
@@ -89,38 +82,38 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
onCleanup(observeViewerScheme(() => fileDiffRef))
const virtualizer = getVirtualizer()
fileDiffInstance = virtualizer
? new VirtualizedFileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...local.preloadedDiff,
},
virtualizer,
virtualMetrics,
workerPool,
)
: new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...local.preloadedDiff,
},
workerPool,
)
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
fileDiffInstance = new FileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...(local.preloadedDiff.options ?? {}),
},
workerPool,
)
applyViewerScheme(fileDiffRef)
// @ts-expect-error private field required for hydration
fileDiffInstance.fileContainer = fileDiffRef
fileDiffInstance.hydrate({
oldFile: local.before,
newFile: local.after,
lineAnnotations: local.annotations ?? [],
fileContainer: fileDiffRef,
containerWrapper: container,
})
fileDiffInstance.hydrate(
local.fileDiff
? {
fileDiff: local.fileDiff,
lineAnnotations: annotations,
fileContainer: fileDiffRef,
containerWrapper: container,
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
}
: {
oldFile: local.before,
newFile: local.after,
lineAnnotations: annotations,
fileContainer: fileDiffRef,
containerWrapper: container,
prerenderedHTML: local.preloadedDiff.prerenderedHTML,
},
)
notifyRendered()
})
@@ -148,8 +141,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
onCleanup(() => {
clearReadyWatcher(ready)
fileDiffInstance?.cleanUp()
sharedVirtualizer?.release()
sharedVirtualizer = undefined
})
return (

View File

@@ -1,20 +1,16 @@
import { sampledChecksum } from "@opencode-ai/util/encode"
import {
DEFAULT_VIRTUAL_FILE_METRICS,
type DiffLineAnnotation,
type FileContents,
type FileDiffMetadata,
File as PierreFile,
type FileDiffOptions,
FileDiff,
type FileOptions,
type LineAnnotation,
type SelectedLineRange,
type VirtualFileMetrics,
VirtualizedFile,
VirtualizedFileDiff,
Virtualizer,
} from "@pierre/diffs"
import { type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createMediaQuery } from "@solid-primitives/media"
import { makeEventListener } from "@solid-primitives/event-listener"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
@@ -39,19 +35,10 @@ import {
readShadowLineSelection,
} from "../pierre/file-selection"
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { getWorkerPool } from "../pierre/worker"
import { FileMedia, type FileMediaOptions } from "./file-media"
import { FileSearchBar } from "./file-search"
const VIRTUALIZE_BYTES = 500_000
const codeMetrics = {
...DEFAULT_VIRTUAL_FILE_METRICS,
lineHeight: 24,
fileGap: 0,
} satisfies Partial<VirtualFileMetrics>
type SharedProps<T> = {
annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null
@@ -80,15 +67,29 @@ export type TextFileProps<T = {}> = FileOptions<T> &
preloadedDiff?: PreloadMultiFileDiffResult<T>
}
export type DiffFileProps<T = {}> = FileDiffOptions<T> &
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
type DiffBaseProps<T> = FileDiffOptions<T> &
SharedProps<T> & {
mode: "diff"
before: FileContents
after: FileContents
annotations?: DiffLineAnnotation<T>[]
preloadedDiff?: PreloadMultiFileDiffResult<T>
preloadedDiff?: DiffPreload<T>
}
type DiffPairProps<T> = DiffBaseProps<T> & {
before: FileContents
after: FileContents
fileDiff?: undefined
}
type DiffPatchProps<T> = DiffBaseProps<T> & {
fileDiff: FileDiffMetadata
before?: undefined
after?: undefined
}
export type DiffFileProps<T = {}> = DiffPairProps<T> | DiffPatchProps<T>
export type FileProps<T = {}> = TextFileProps<T> | DiffFileProps<T>
const sharedKeys = [
@@ -108,7 +109,7 @@ const sharedKeys = [
] as const
const textKeys = ["file", ...sharedKeys] as const
const diffKeys = ["before", "after", ...sharedKeys] as const
const diffKeys = ["fileDiff", "before", "after", ...sharedKeys] as const
// ---------------------------------------------------------------------------
// Shared viewer hook
@@ -371,11 +372,6 @@ type AnnotationTarget<A> = {
rerender: () => void
}
type VirtualStrategy = {
get: () => Virtualizer | undefined
cleanup: () => void
}
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
return useFileViewer({
enableLineSelection: config.enableLineSelection,
@@ -517,64 +513,6 @@ function scrollParent(el: HTMLElement): HTMLElement | undefined {
}
}
function createLocalVirtualStrategy(host: () => HTMLDivElement | undefined, enabled: () => boolean): VirtualStrategy {
let virtualizer: Virtualizer | undefined
let root: Document | HTMLElement | undefined
const release = () => {
virtualizer?.cleanUp()
virtualizer = undefined
root = undefined
}
return {
get: () => {
if (!enabled()) {
release()
return
}
if (typeof document === "undefined") return
const wrapper = host()
if (!wrapper) return
const next = scrollParent(wrapper) ?? document
if (virtualizer && root === next) return virtualizer
release()
virtualizer = new Virtualizer()
root = next
virtualizer.setup(next, next instanceof Document ? undefined : wrapper)
return virtualizer
},
cleanup: release,
}
}
function createSharedVirtualStrategy(host: () => HTMLDivElement | undefined): VirtualStrategy {
let shared: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const release = () => {
shared?.release()
shared = undefined
}
return {
get: () => {
if (shared) return shared.virtualizer
const container = host()
if (!container) return
const result = acquireVirtualizer(container)
if (!result) return
shared = result
return result.virtualizer
},
cleanup: release,
}
}
function parseLine(node: HTMLElement) {
if (!node.dataset.line) return
const value = parseInt(node.dataset.line, 10)
@@ -673,7 +611,7 @@ function ViewerShell(props: {
// ---------------------------------------------------------------------------
function TextViewer<T>(props: TextFileProps<T>) {
let instance: PierreFile<T> | VirtualizedFile<T> | undefined
let instance: PierreFile<T> | undefined
let viewer!: Viewer
const [local, others] = splitProps(props, textKeys)
@@ -692,34 +630,12 @@ function TextViewer<T>(props: TextFileProps<T>) {
return Math.max(1, total)
}
const bytes = createMemo(() => {
const value = local.file.contents as unknown
if (typeof value === "string") return value.length
if (Array.isArray(value)) {
return value.reduce(
(sum, part) => sum + (typeof part === "string" ? part.length + 1 : String(part).length + 1),
0,
)
}
if (value == null) return 0
return String(value).length
})
const virtual = createMemo(() => bytes() > VIRTUALIZE_BYTES)
const virtuals = createLocalVirtualStrategy(() => viewer.wrapper, virtual)
const lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
const applySelection = (range: SelectedLineRange | null) => {
const current = instance
if (!current) return false
if (virtual()) {
current.setSelectedLines(range)
return true
}
const root = viewer.getRoot()
if (!root) return false
@@ -818,10 +734,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
const notify = () => {
notifyRendered({
viewer,
isReady: (root) => {
if (virtual()) return root.querySelector("[data-line]") != null
return root.querySelectorAll("[data-line]").length >= lineCount()
},
isReady: (root) => root.querySelectorAll("[data-line]").length >= lineCount(),
onReady: () => {
applySelection(viewer.lastSelection)
viewer.find.refresh({ reset: true })
@@ -840,17 +753,11 @@ function TextViewer<T>(props: TextFileProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = getWorkerPool("unified")
const isVirtual = virtual()
const virtualizer = virtuals.get()
renderViewer({
viewer,
current: instance,
create: () =>
isVirtual && virtualizer
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
: new PierreFile<T>(opts, workerPool),
create: () => new PierreFile<T>(opts, workerPool),
assign: (value) => {
instance = value
},
@@ -877,7 +784,6 @@ function TextViewer<T>(props: TextFileProps<T>) {
onCleanup(() => {
instance?.cleanUp()
instance = undefined
virtuals.cleanup()
})
return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
@@ -973,9 +879,13 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
adapter,
)
const virtuals = createSharedVirtualStrategy(() => viewer.container)
const large = createMemo(() => {
if (local.fileDiff) {
const before = local.fileDiff.deletionLines.join("")
const after = local.fileDiff.additionLines.join("")
return Math.max(before.length, after.length) > 500_000
}
const before = typeof local.before?.contents === "string" ? local.before.contents : ""
const after = typeof local.after?.contents === "string" ? local.after.contents : ""
return Math.max(before.length, after.length) > 500_000
@@ -1031,7 +941,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
createEffect(() => {
const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = virtuals.get()
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
const done = preserve(viewer)
@@ -1046,14 +955,22 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
renderViewer({
viewer,
current: instance,
create: () =>
virtualizer
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
: new FileDiff<T>(opts, workerPool),
create: () => new FileDiff<T>(opts, workerPool),
assign: (value) => {
instance = value
},
draw: (value) => {
if (local.fileDiff) {
value.render({
fileDiff: local.fileDiff,
lineAnnotations: [],
containerWrapper: viewer.container,
})
return
}
if (!local.before || !local.after) return
value.render({
oldFile: { ...local.before, contents: beforeContents, cacheKey: cacheKey(beforeContents) },
newFile: { ...local.after, contents: afterContents, cacheKey: cacheKey(afterContents) },
@@ -1076,7 +993,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
onCleanup(() => {
instance?.cleanUp()
instance = undefined
virtuals.cleanup()
dragSide = undefined
dragEndSide = undefined
})

View File

@@ -0,0 +1,37 @@
import { describe, expect, test } from "bun:test"
import { normalize, text } from "./session-diff"
describe("session diff", () => {
test("keeps unified patch content", () => {
const diff = {
file: "a.ts",
patch:
"Index: a.ts\n===================================================================\n--- a.ts\t\n+++ a.ts\t\n@@ -1,2 +1,2 @@\n one\n-two\n+three\n",
additions: 1,
deletions: 1,
status: "modified" as const,
}
const view = normalize(diff)
expect(view.patch).toBe(diff.patch)
expect(view.fileDiff.name).toBe("a.ts")
expect(text(view, "deletions")).toBe("one\ntwo\n")
expect(text(view, "additions")).toBe("one\nthree\n")
})
test("converts legacy content into a patch", () => {
const diff = {
file: "a.ts",
before: "one\n",
after: "two\n",
additions: 1,
deletions: 1,
status: "modified" as const,
}
const view = normalize(diff)
expect(view.patch).toContain("@@ -1,1 +1,1 @@")
expect(text(view, "deletions")).toBe("one\n")
expect(text(view, "additions")).toBe("two\n")
})
})

View File

@@ -0,0 +1,83 @@
import { parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
import { sampledChecksum } from "@opencode-ai/util/encode"
import { formatPatch, structuredPatch } from "diff"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
type LegacyDiff = {
file: string
patch?: string
before?: string
after?: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
}
type ReviewDiff = SnapshotFileDiff | VcsFileDiff | LegacyDiff
export type ViewDiff = {
file: string
patch: string
additions: number
deletions: number
status?: "added" | "deleted" | "modified"
fileDiff: FileDiffMetadata
}
const cache = new Map<string, FileDiffMetadata>()
function empty(file: string, key: string) {
return {
name: file,
type: "change",
hunks: [],
splitLineCount: 0,
unifiedLineCount: 0,
isPartial: true,
deletionLines: [],
additionLines: [],
cacheKey: key,
} satisfies FileDiffMetadata
}
function patch(diff: ReviewDiff) {
if (typeof diff.patch === "string") return diff.patch
return formatPatch(
structuredPatch(
diff.file,
diff.file,
"before" in diff && typeof diff.before === "string" ? diff.before : "",
"after" in diff && typeof diff.after === "string" ? diff.after : "",
"",
"",
{ context: Number.MAX_SAFE_INTEGER },
),
)
}
function file(file: string, patch: string) {
const hit = cache.get(patch)
if (hit) return hit
const key = sampledChecksum(patch) ?? file
const value = parsePatchFiles(patch, key).flatMap((item) => item.files)[0] ?? empty(file, key)
cache.set(patch, value)
return value
}
export function normalize(diff: ReviewDiff): ViewDiff {
const next = patch(diff)
return {
file: diff.file,
patch: next,
additions: diff.additions,
deletions: diff.deletions,
status: diff.status,
fileDiff: file(diff.file, next),
}
}
export function text(diff: ViewDiff, side: "deletions" | "additions") {
if (side === "deletions") return diff.fileDiff.deletionLines.join("")
return diff.fileDiff.additionLines.join("")
}

View File

@@ -15,7 +15,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { checksum } from "@opencode-ai/util/encode"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2"
import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web"
@@ -23,9 +23,9 @@ import { mediaKindFromPath } from "../pierre/media"
import { cloneSelectedLineRange, previewSelectedLines } from "../pierre/selection-bridge"
import { createLineCommentController } from "./line-comment-annotations"
import type { LineCommentEditorProps } from "./line-comment"
import { normalize, text, type ViewDiff } from "./session-diff"
const MAX_DIFF_CHANGED_LINES = 500
const REVIEW_MOUNT_MARGIN = 300
export type SessionReviewDiffStyle = "unified" | "split"
@@ -61,7 +61,8 @@ export type SessionReviewCommentActions = {
export type SessionReviewFocus = { file: string; id: string }
type ReviewDiff = FileDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult<any> }
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
export interface SessionReviewProps {
title?: JSX.Element
@@ -137,14 +138,11 @@ type SessionReviewSelection = {
export const SessionReview = (props: SessionReviewProps) => {
let scroll: HTMLDivElement | undefined
let focusToken = 0
let frame: number | undefined
const i18n = useI18n()
const fileComponent = useFileComponent()
const anchors = new Map<string, HTMLElement>()
const nodes = new Map<string, HTMLDivElement>()
const [store, setStore] = createStore({
open: [] as string[],
visible: {} as Record<string, boolean>,
force: {} as Record<string, boolean>,
selection: null as SessionReviewSelection | null,
commenting: null as SessionReviewSelection | null,
@@ -155,8 +153,8 @@ export const SessionReview = (props: SessionReviewProps) => {
const opened = () => store.opened
const open = () => props.open ?? store.open
const files = createMemo(() => props.diffs.map((diff) => diff.file))
const diffs = createMemo(() => new Map(props.diffs.map((diff) => [diff.file, diff] as const)))
const items = createMemo<Item[]>(() => props.diffs.map((diff) => ({ ...normalize(diff), preloaded: diff.preloaded })))
const files = createMemo(() => items().map((diff) => diff.file))
const grouped = createMemo(() => {
const next = new Map<string, SessionReviewComment[]>()
for (const comment of props.comments ?? []) {
@@ -172,44 +170,7 @@ export const SessionReview = (props: SessionReviewProps) => {
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
const hasDiffs = () => files().length > 0
const syncVisible = () => {
frame = undefined
if (!scroll) return
const root = scroll.getBoundingClientRect()
const top = root.top - REVIEW_MOUNT_MARGIN
const bottom = root.bottom + REVIEW_MOUNT_MARGIN
const openSet = new Set(open())
const next: Record<string, boolean> = {}
for (const [file, el] of nodes) {
if (!openSet.has(file)) continue
const rect = el.getBoundingClientRect()
if (rect.bottom < top || rect.top > bottom) continue
next[file] = true
}
const prev = untrack(() => store.visible)
const prevKeys = Object.keys(prev)
const nextKeys = Object.keys(next)
if (prevKeys.length === nextKeys.length && nextKeys.every((file) => prev[file])) return
setStore("visible", next)
}
const queue = () => {
if (frame !== undefined) return
frame = requestAnimationFrame(syncVisible)
}
const pinned = (file: string) =>
props.focusedComment?.file === file ||
props.focusedFile === file ||
selection()?.file === file ||
commenting()?.file === file ||
opened()?.file === file
const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
queue()
const next = props.onScroll
if (!next) return
if (Array.isArray(next)) {
@@ -220,21 +181,9 @@ export const SessionReview = (props: SessionReviewProps) => {
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
}
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
createEffect(() => {
props.open
files()
queue()
})
const handleChange = (next: string[]) => {
props.onOpenChange?.(next)
if (props.open === undefined) setStore("open", next)
queue()
}
const handleExpandOrCollapseAll = () => {
@@ -246,10 +195,10 @@ export const SessionReview = (props: SessionReviewProps) => {
const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions"
const selectionPreview = (diff: FileDiff, range: SelectedLineRange) => {
const selectionPreview = (diff: ViewDiff, range: SelectedLineRange) => {
const side = selectionSide(range)
const contents = side === "deletions" ? diff.before : diff.after
if (typeof contents !== "string" || contents.length === 0) return undefined
const contents = text(diff, side)
if (contents.length === 0) return undefined
return previewSelectedLines(contents, range)
}
@@ -348,7 +297,6 @@ export const SessionReview = (props: SessionReviewProps) => {
viewportRef={(el) => {
scroll = el
props.scrollRef?.(el)
queue()
}}
onScroll={handleScroll}
classList={{
@@ -359,20 +307,18 @@ export const SessionReview = (props: SessionReviewProps) => {
<Show when={hasDiffs()} fallback={props.empty}>
<div class="pb-6">
<Accordion multiple value={open()} onChange={handleChange}>
<For each={props.diffs}>
<For each={items()}>
{(diff) => {
let wrapper: HTMLDivElement | undefined
const file = diff.file
const expanded = createMemo(() => open().includes(file))
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
const force = () => !!store.force[file]
const comments = createMemo(() => grouped().get(file) ?? [])
const commentedLines = createMemo(() => comments().map((c) => c.selection))
const beforeText = () => (typeof diff.before === "string" ? diff.before : "")
const afterText = () => (typeof diff.after === "string" ? diff.after : "")
const beforeText = () => text(diff, "deletions")
const afterText = () => text(diff, "additions")
const changedLines = () => diff.additions + diff.deletions
const mediaKind = createMemo(() => mediaKindFromPath(file))
@@ -456,8 +402,6 @@ export const SessionReview = (props: SessionReviewProps) => {
onCleanup(() => {
anchors.delete(file)
nodes.delete(file)
queue()
})
const handleLineSelected = (range: SelectedLineRange | null) => {
@@ -540,21 +484,11 @@ export const SessionReview = (props: SessionReviewProps) => {
<div
data-slot="session-review-diff-wrapper"
ref={(el) => {
wrapper = el
anchors.set(file, el)
nodes.set(file, el)
queue()
}}
>
<Show when={expanded()}>
<Switch>
<Match when={!mounted() && !tooLarge()}>
<div
data-slot="session-review-diff-placeholder"
class="rounded-lg border border-border-weak-base bg-background-stronger/40"
style={{ height: "160px" }}
/>
</Match>
<Match when={tooLarge()}>
<div data-slot="session-review-large-diff">
<div data-slot="session-review-large-diff-title">
@@ -581,6 +515,7 @@ export const SessionReview = (props: SessionReviewProps) => {
<Dynamic
component={fileComponent}
mode="diff"
fileDiff={diff.fileDiff}
preloadedDiff={diff.preloaded}
diffStyle={diffStyle()}
onRendered={() => {
@@ -596,20 +531,11 @@ export const SessionReview = (props: SessionReviewProps) => {
renderHoverUtility={props.onLineComment ? commentsUi.renderHoverUtility : undefined}
selectedLines={selectedLines()}
commentedLines={commentedLines()}
before={{
name: file,
contents: typeof diff.before === "string" ? diff.before : "",
}}
after={{
name: file,
contents: typeof diff.after === "string" ? diff.after : "",
}}
media={{
mode: "auto",
path: file,
before: diff.before,
after: diff.after,
readFile: props.readFile,
deleted: diff.status === "deleted",
readFile: diff.status === "deleted" ? undefined : props.readFile,
}}
/>
</Match>

View File

@@ -94,9 +94,15 @@
[data-slot="session-turn-diffs-header"] {
display: flex;
align-items: baseline;
align-items: center;
gap: 8px;
padding-top: 4px;
padding-bottom: 12px;
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
background-color: var(--background-stronger);
height: 44px;
}
[data-slot="session-turn-diffs-label"] {

View File

@@ -1,4 +1,9 @@
import { AssistantMessage, type FileDiff, Message as MessageType, Part as PartType } from "@opencode-ai/sdk/v2/client"
import {
AssistantMessage,
type SnapshotFileDiff,
Message as MessageType,
Part as PartType,
} from "@opencode-ai/sdk/v2/client"
import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useFileComponent } from "../context/file"
@@ -19,6 +24,7 @@ import { SessionRetry } from "./session-retry"
import { TextReveal } from "./text-reveal"
import { createAutoScroll } from "../hooks"
import { useI18n } from "../context/i18n"
import { normalize } from "./session-diff"
function record(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value)
@@ -163,7 +169,7 @@ export function SessionTurn(
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyDiffs: FileDiff[] = []
const emptyDiffs: SnapshotFileDiff[] = []
const idle = { type: "idle" as const }
const allMessages = createMemo(() => props.messages ?? list(data.store.message?.[props.sessionID], emptyMessages))
@@ -232,7 +238,7 @@ export function SessionTurn(
const seen = new Set<string>()
return files
.reduceRight<FileDiff[]>((result, diff) => {
.reduceRight<SnapshotFileDiff[]>((result, diff) => {
if (seen.has(diff.file)) return result
seen.add(diff.file)
result.push(diff)
@@ -441,12 +447,13 @@ export function SessionTurn(
<div data-component="session-turn-diffs-content">
<Accordion
multiple
style={{ "--sticky-accordion-offset": "40px" }}
style={{ "--sticky-accordion-offset": "44px" }}
value={expanded()}
onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
>
<For each={visible()}>
{(diff) => {
const view = normalize(diff)
const active = createMemo(() => expanded().includes(diff.file))
const [shown, setShown] = createSignal(false)
@@ -495,12 +502,7 @@ export function SessionTurn(
<Accordion.Content>
<Show when={shown()}>
<div data-slot="session-turn-diff-view" data-scrollable>
<Dynamic
component={fileComponent}
mode="diff"
before={{ name: diff.file, contents: diff.before }}
after={{ name: diff.file, contents: diff.after }}
/>
<Dynamic component={fileComponent} mode="diff" fileDiff={view.fileDiff} />
</div>
</Show>
</Accordion.Content>

View File

@@ -1,4 +1,4 @@
import type { Message, Session, Part, FileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, ProviderListResponse } from "@opencode-ai/sdk/v2"
import { createSimpleContext } from "./helper"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@@ -13,7 +13,7 @@ type Data = {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: FileDiff[]
[sessionID: string]: SnapshotFileDiff[]
}
session_diff_preload?: {
[sessionID: string]: PreloadMultiFileDiffResult<any>[]

View File

@@ -1,100 +0,0 @@
import { type VirtualFileMetrics, Virtualizer } from "@pierre/diffs"
type Target = {
key: Document | HTMLElement
root: Document | HTMLElement
content: HTMLElement | undefined
}
type Entry = {
virtualizer: Virtualizer
refs: number
}
const cache = new WeakMap<Document | HTMLElement, Entry>()
export const virtualMetrics: Partial<VirtualFileMetrics> = {
lineHeight: 24,
hunkSeparatorHeight: 24,
fileGap: 0,
}
function scrollable(value: string) {
return value === "auto" || value === "scroll" || value === "overlay"
}
function scrollRoot(container: HTMLElement) {
let node = container.parentElement
while (node) {
const style = getComputedStyle(node)
if (scrollable(style.overflowY)) return node
node = node.parentElement
}
}
function target(container: HTMLElement): Target | undefined {
if (typeof document === "undefined") return
const review = container.closest("[data-component='session-review']")
if (review instanceof HTMLElement) {
const root = scrollRoot(container) ?? review
const content = review.querySelector("[data-slot='session-review-container']")
return {
key: review,
root,
content: content instanceof HTMLElement ? content : undefined,
}
}
const root = scrollRoot(container)
if (root) {
const content = root.querySelector("[role='log']")
return {
key: root,
root,
content: content instanceof HTMLElement ? content : undefined,
}
}
return {
key: document,
root: document,
content: undefined,
}
}
export function acquireVirtualizer(container: HTMLElement) {
const resolved = target(container)
if (!resolved) return
let entry = cache.get(resolved.key)
if (!entry) {
const virtualizer = new Virtualizer()
virtualizer.setup(resolved.root, resolved.content)
entry = {
virtualizer,
refs: 0,
}
cache.set(resolved.key, entry)
}
entry.refs += 1
let done = false
return {
virtualizer: entry.virtualizer,
release() {
if (done) return
done = true
const current = cache.get(resolved.key)
if (!current) return
current.refs -= 1
if (current.refs > 0) return
current.virtualizer.cleanUp()
cache.delete(resolved.key)
},
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.3.17",
"version": "1.4.0",
"private": true,
"type": "module",
"license": "MIT",

View File

@@ -2,7 +2,7 @@
"name": "@opencode-ai/web",
"type": "module",
"license": "MIT",
"version": "1.3.17",
"version": "1.4.0",
"scripts": {
"dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",

View File

@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.3.17",
"version": "1.4.0",
"publisher": "sst-dev",
"repository": {
"type": "git",