Compare 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 Clineandopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> 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 Langtonandopencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> 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
36 changed files with 139 additions and 761 deletions
+16 -16
View File
@@ -27,7 +27,7 @@
}, },
"packages/app": { "packages/app": {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -81,7 +81,7 @@
}, },
"packages/console/app": { "packages/console/app": {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@cloudflare/vite-plugin": "1.15.2", "@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1", "@ibm/plex": "6.4.1",
@@ -115,7 +115,7 @@
}, },
"packages/console/core": { "packages/console/core": {
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@aws-sdk/client-sts": "3.782.0", "@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1", "@jsx-email/render": "1.1.1",
@@ -142,7 +142,7 @@
}, },
"packages/console/function": { "packages/console/function": {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "3.0.64", "@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/openai": "3.0.48", "@ai-sdk/openai": "3.0.48",
@@ -166,7 +166,7 @@
}, },
"packages/console/mail": { "packages/console/mail": {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
@@ -190,7 +190,7 @@
}, },
"packages/desktop": { "packages/desktop": {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -223,7 +223,7 @@
}, },
"packages/desktop-electron": { "packages/desktop-electron": {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@opencode-ai/app": "workspace:*", "@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
@@ -255,7 +255,7 @@
}, },
"packages/enterprise": { "packages/enterprise": {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@opencode-ai/ui": "workspace:*", "@opencode-ai/ui": "workspace:*",
"@opencode-ai/util": "workspace:*", "@opencode-ai/util": "workspace:*",
@@ -284,7 +284,7 @@
}, },
"packages/function": { "packages/function": {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@octokit/auth-app": "8.0.1", "@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:", "@octokit/rest": "catalog:",
@@ -300,7 +300,7 @@
}, },
"packages/opencode": { "packages/opencode": {
"name": "opencode", "name": "opencode",
"version": "1.3.17", "version": "1.4.0",
"bin": { "bin": {
"opencode": "./bin/opencode", "opencode": "./bin/opencode",
}, },
@@ -436,7 +436,7 @@
}, },
"packages/plugin": { "packages/plugin": {
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"zod": "catalog:", "zod": "catalog:",
@@ -470,7 +470,7 @@
}, },
"packages/sdk/js": { "packages/sdk/js": {
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"cross-spawn": "catalog:", "cross-spawn": "catalog:",
}, },
@@ -485,7 +485,7 @@
}, },
"packages/slack": { "packages/slack": {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1", "@slack/bolt": "^3.17.1",
@@ -520,7 +520,7 @@
}, },
"packages/ui": { "packages/ui": {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@kobalte/core": "catalog:", "@kobalte/core": "catalog:",
"@opencode-ai/sdk": "workspace:*", "@opencode-ai/sdk": "workspace:*",
@@ -569,7 +569,7 @@
}, },
"packages/util": { "packages/util": {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"zod": "catalog:", "zod": "catalog:",
}, },
@@ -580,7 +580,7 @@
}, },
"packages/web": { "packages/web": {
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@astrojs/cloudflare": "12.6.3", "@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1", "@astrojs/markdown-remark": "6.3.1",
+4 -4
View File
@@ -1,8 +1,8 @@
{ {
"nodeModules": { "nodeModules": {
"x86_64-linux": "sha256-r1+AehuOGIOaaxfXkQGracT/6OdFRn5Ub8s7H+MeKFY=", "x86_64-linux": "sha256-85wpU1oCWbthPleNIOj5d5AOuuYZ6rM7gMLZR6YJ2WU=",
"aarch64-linux": "sha256-WkMSRF/ZJLyzxNBjpiMR459C9G0NVOEw31tm8roPneA=", "aarch64-linux": "sha256-C3A56SDQGJquCpIRj2JhIzr4A7N4cc9lxtEjl8bXDeM=",
"aarch64-darwin": "sha256-Z127cxFpTl8Ml7PB3CG9TcCU08oYCPuk0FECK2MQ2CI=", "aarch64-darwin": "sha256-/Ij3qhGRrcLlMfl9uEacDNnGK5URxhctuQFBW4Njrog=",
"x86_64-darwin": "sha256-pkRoFtnVjyl+5fm+rrFyRnEwvptxylnFxPAcEv4ZOCg=" "x86_64-darwin": "sha256-10sOPuN4eZ75orw4FI8ztCq1+AKS2e8aAfg3Z6Yn56w="
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/app", "name": "@opencode-ai/app",
"version": "1.3.17", "version": "1.4.0",
"description": "", "description": "",
"type": "module", "type": "module",
"exports": { "exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-app", "name": "@opencode-ai/console-app",
"version": "1.3.17", "version": "1.4.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core", "name": "@opencode-ai/console-core",
"version": "1.3.17", "version": "1.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-function", "name": "@opencode-ai/console-function",
"version": "1.3.17", "version": "1.4.0",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/console-mail", "name": "@opencode-ai/console-mail",
"version": "1.3.17", "version": "1.4.0",
"dependencies": { "dependencies": {
"@jsx-email/all": "2.2.3", "@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3", "@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop-electron", "name": "@opencode-ai/desktop-electron",
"private": true, "private": true,
"version": "1.3.17", "version": "1.4.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"homepage": "https://opencode.ai", "homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "@opencode-ai/desktop", "name": "@opencode-ai/desktop",
"private": true, "private": true,
"version": "1.3.17", "version": "1.4.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/enterprise", "name": "@opencode-ai/enterprise",
"version": "1.3.17", "version": "1.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "opencode" id = "opencode"
name = "OpenCode" name = "OpenCode"
description = "The open source coding agent." description = "The open source coding agent."
version = "1.3.17" version = "1.4.0"
schema_version = 1 schema_version = 1
authors = ["Anomaly"] authors = ["Anomaly"]
repository = "https://github.com/anomalyco/opencode" repository = "https://github.com/anomalyco/opencode"
@@ -11,26 +11,26 @@ name = "OpenCode"
icon = "./icons/opencode.svg" icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64] [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" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64] [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" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64] [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" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64] [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" cmd = "./opencode"
args = ["acp"] args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64] [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" cmd = "./opencode.exe"
args = ["acp"] args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/function", "name": "@opencode-ai/function",
"version": "1.3.17", "version": "1.4.0",
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"private": true, "private": true,
"type": "module", "type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"version": "1.3.17", "version": "1.4.0",
"name": "opencode", "name": "opencode",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
@@ -8,7 +8,6 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { DialogVariant } from "./dialog-variant" import { DialogVariant } from "./dialog-variant"
import { useKeybind } from "../context/keybind" import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort" import * as fuzzysort from "fuzzysort"
import { consoleManagedProviderLabel } from "@tui/util/provider-origin"
export function useConnected() { export function useConnected() {
const sync = useSync() const sync = useSync()
@@ -47,11 +46,7 @@ export function DialogModel(props: { providerID?: string }) {
key: item, key: item,
value: { providerID: provider.id, modelID: model.id }, value: { providerID: provider.id, modelID: model.id },
title: model.name ?? item.modelID, title: model.name ?? item.modelID,
description: consoleManagedProviderLabel( description: provider.name,
sync.data.console_state.consoleManagedProviders,
provider.id,
provider.name,
),
category, category,
disabled: provider.id === "opencode" && model.id.includes("-nano"), disabled: provider.id === "opencode" && model.id.includes("-nano"),
footer: model.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, 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) description: favorites.some((item) => item.providerID === provider.id && item.modelID === model)
? "(Favorite)" ? "(Favorite)"
: undefined, : undefined,
category: connected() category: connected() ? provider.name : undefined,
? consoleManagedProviderLabel(sync.data.console_state.consoleManagedProviders, provider.id, provider.name)
: undefined,
disabled: provider.id === "opencode" && model.includes("-nano"), disabled: provider.id === "opencode" && model.includes("-nano"),
footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined, footer: info.cost?.input === 0 && provider.id === "opencode" ? "Free" : undefined,
onSelect() { onSelect() {
@@ -142,7 +135,7 @@ export function DialogModel(props: { providerID?: string }) {
const title = createMemo(() => { const title = createMemo(() => {
const value = provider() const value = provider()
if (!value) return "Select model" 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) { function onSelect(providerID: string, modelID: string) {
@@ -13,7 +13,7 @@ import { DialogModel } from "./dialog-model"
import { useKeyboard } from "@opentui/solid" import { useKeyboard } from "@opentui/solid"
import { Clipboard } from "@tui/util/clipboard" import { Clipboard } from "@tui/util/clipboard"
import { useToast } from "../ui/toast" 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> = { const PROVIDER_PRIORITY: Record<string, number> = {
opencode: 0, opencode: 0,
@@ -49,11 +49,7 @@ export function createDialogProviderOptions() {
}[provider.id], }[provider.id],
footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined, footer: consoleManaged ? sync.data.console_state.activeOrgName : undefined,
category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other", category: provider.id in PROVIDER_PRIORITY ? "Popular" : "Other",
gutter: consoleManaged ? ( gutter: connected ? <text fg={theme.success}></text> : undefined,
<text fg={theme.textMuted}>{CONSOLE_MANAGED_ICON}</text>
) : connected ? (
<text fg={theme.success}></text>
) : undefined,
async onSelect() { async onSelect() {
if (consoleManaged) return if (consoleManaged) return
@@ -36,7 +36,6 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv" import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings" import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill" import { DialogSkill } from "../dialog-skill"
import { CONSOLE_MANAGED_ICON, consoleManagedProviderLabel } from "@tui/util/provider-origin"
export type PromptProps = { export type PromptProps = {
sessionID?: string sessionID?: string
@@ -96,15 +95,8 @@ export function Prompt(props: PromptProps) {
const list = createMemo(() => props.placeholders?.normal ?? []) const list = createMemo(() => props.placeholders?.normal ?? [])
const shell = createMemo(() => props.placeholders?.shell ?? []) const shell = createMemo(() => props.placeholders?.shell ?? [])
const [auto, setAuto] = createSignal<AutocompleteRef>() const [auto, setAuto] = createSignal<AutocompleteRef>()
const activeOrgName = createMemo(() => sync.data.console_state.activeOrgName) const currentProviderLabel = createMemo(() => local.model.parsed().provider)
const canSwitchOrgs = createMemo(() => sync.data.console_state.switchableOrgCount > 1) const hasRightContent = createMemo(() => Boolean(props.right))
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()))
function promptModelWarning() { function promptModelWarning() {
toast.show({ toast.show({
@@ -1120,17 +1112,6 @@ export function Prompt(props: PromptProps) {
<Show when={hasRightContent()}> <Show when={hasRightContent()}>
<box flexDirection="row" gap={1} alignItems="center"> <box flexDirection="row" gap={1} alignItems="center">
{props.right} {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> </box>
</Show> </Show>
</box> </box>
@@ -1162,7 +1143,7 @@ export function Prompt(props: PromptProps) {
} }
/> />
</box> </box>
<box flexDirection="row" justifyContent="space-between"> <box width="100%" flexDirection="row" justifyContent="space-between">
<Show when={status().type !== "idle"} fallback={props.hint ?? <text />}> <Show when={status().type !== "idle"} fallback={props.hint ?? <text />}>
<box <box
flexDirection="row" flexDirection="row"
@@ -1,5 +1,3 @@
export const CONSOLE_MANAGED_ICON = "⌂"
const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) => const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
Array.isArray(consoleManagedProviders) Array.isArray(consoleManagedProviders)
? consoleManagedProviders.includes(providerID) ? consoleManagedProviders.includes(providerID)
@@ -7,14 +5,3 @@ const contains = (consoleManagedProviders: string[] | ReadonlySet<string>, provi
export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) => export const isConsoleManagedProvider = (consoleManagedProviders: string[] | ReadonlySet<string>, providerID: string) =>
contains(consoleManagedProviders, providerID) 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)}`
@@ -28,7 +28,7 @@ export const ProviderRoutes = lazy(() =>
"application/json": { "application/json": {
schema: resolver( schema: resolver(
z.object({ z.object({
all: ModelsDev.Provider.array(), all: Provider.Info.array(),
default: z.record(z.string(), z.string()), default: z.record(z.string(), z.string()),
connected: z.array(z.string()), connected: z.array(z.string()),
}), }),
@@ -906,7 +906,7 @@ export const SessionRoutes = lazy(() =>
description: "Created message", description: "Created message",
content: { content: {
"application/json": { "application/json": {
schema: resolver(MessageV2.Assistant), schema: resolver(MessageV2.WithParts),
}, },
}, },
}, },
@@ -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( await Bun.write(
path.join(toolsDir, "cowsay.ts"), path.join(toolsDir, "cowsay.ts"),
[ [
+15 -73
View File
@@ -17,58 +17,25 @@ const ctx = {
ask: async () => {}, ask: async () => {},
} }
type TimerID = ReturnType<typeof setTimeout> async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
using server = Bun.serve({ port: 0, fetch })
async function withFetch( await fn(server.url)
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
}
} }
describe("tool.webfetch", () => { describe("tool.webfetch", () => {
test("returns image responses as file attachments", async () => { test("returns image responses as file attachments", async () => {
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]) const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
await withFetch( await withFetch(
async () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }), () => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
async () => { async (url) => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await WebFetchTool.init() 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.output).toBe("Image fetched successfully")
expect(result.attachments).toBeDefined() expect(result.attachments).toBeDefined()
expect(result.attachments?.length).toBe(1) expect(result.attachments?.length).toBe(1)
@@ -87,17 +54,17 @@ describe("tool.webfetch", () => {
test("keeps svg as text output", async () => { test("keeps svg as text output", async () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>' const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>'
await withFetch( await withFetch(
async () => () =>
new Response(svg, { new Response(svg, {
status: 200, status: 200,
headers: { "content-type": "image/svg+xml; charset=UTF-8" }, headers: { "content-type": "image/svg+xml; charset=UTF-8" },
}), }),
async () => { async (url) => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await WebFetchTool.init() 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.output).toContain("<svg")
expect(result.attachments).toBeUndefined() expect(result.attachments).toBeUndefined()
}, },
@@ -108,17 +75,17 @@ describe("tool.webfetch", () => {
test("keeps text responses as text output", async () => { test("keeps text responses as text output", async () => {
await withFetch( await withFetch(
async () => () =>
new Response("hello from webfetch", { new Response("hello from webfetch", {
status: 200, status: 200,
headers: { "content-type": "text/plain; charset=utf-8" }, headers: { "content-type": "text/plain; charset=utf-8" },
}), }),
async () => { async (url) => {
await Instance.provide({ await Instance.provide({
directory: projectRoot, directory: projectRoot,
fn: async () => { fn: async () => {
const webfetch = await WebFetchTool.init() 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.output).toBe("hello from webfetch")
expect(result.attachments).toBeUndefined() 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])
})
})
}) })
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin", "name": "@opencode-ai/plugin",
"version": "1.3.17", "version": "1.4.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://json.schemastore.org/package.json", "$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk", "name": "@opencode-ai/sdk",
"version": "1.3.17", "version": "1.4.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+5 -63
View File
@@ -3936,7 +3936,10 @@ export type SessionShellResponses = {
/** /**
* Created message * Created message
*/ */
200: AssistantMessage 200: {
info: Message
parts: Array<Part>
}
} }
export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses] export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses]
@@ -4212,68 +4215,7 @@ export type ProviderListResponses = {
* List of providers * List of providers
*/ */
200: { 200: {
all: Array<{ all: Array<Provider>
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
}
}
}
}
}>
default: { default: {
[key: string]: string [key: string]: string
} }
+14 -206
View File
@@ -4098,7 +4098,19 @@
"content": { "content": {
"application/json": { "application/json": {
"schema": { "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": { "all": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "$ref": "#/components/schemas/Provider"
"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"]
} }
}, },
"default": { "default": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/slack", "name": "@opencode-ai/slack",
"version": "1.3.17", "version": "1.4.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/ui", "name": "@opencode-ai/ui",
"version": "1.3.17", "version": "1.4.0",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"exports": { "exports": {
+2 -26
View File
@@ -1,4 +1,4 @@
import { DIFFS_TAG_NAME, FileDiff, VirtualizedFileDiff } from "@pierre/diffs" import { DIFFS_TAG_NAME, FileDiff } from "@pierre/diffs"
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js" import { createEffect, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Dynamic, isServer } from "solid-js/web" import { Dynamic, isServer } from "solid-js/web"
@@ -13,7 +13,6 @@ import {
notifyShadowReady, notifyShadowReady,
observeViewerScheme, observeViewerScheme,
} from "../pierre/file-runtime" } from "../pierre/file-runtime"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { File, type DiffFileProps, type FileProps } from "./file" import { File, type DiffFileProps, type FileProps } from "./file"
type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T> type DiffPreload<T> = PreloadMultiFileDiffResult<T> | PreloadFileDiffResult<T>
@@ -26,7 +25,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
let container!: HTMLDivElement let container!: HTMLDivElement
let fileDiffRef!: HTMLElement let fileDiffRef!: HTMLElement
let fileDiffInstance: FileDiff<T> | undefined let fileDiffInstance: FileDiff<T> | undefined
let sharedVirtualizer: NonNullable<ReturnType<typeof acquireVirtualizer>> | undefined
const ready = createReadyWatcher() const ready = createReadyWatcher()
const workerPool = useWorkerPool(props.diffStyle) const workerPool = useWorkerPool(props.diffStyle)
@@ -51,14 +49,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined 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 setSelectedLines = (range: DiffFileProps<T>["selectedLines"], attempt = 0) => {
const diff = fileDiffInstance const diff = fileDiffInstance
if (!diff) return if (!diff) return
@@ -92,20 +82,8 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
onCleanup(observeViewerScheme(() => fileDiffRef)) onCleanup(observeViewerScheme(() => fileDiffRef))
const virtualizer = getVirtualizer()
const annotations = local.annotations ?? local.preloadedDiff.annotations ?? [] const annotations = local.annotations ?? local.preloadedDiff.annotations ?? []
fileDiffInstance = virtualizer fileDiffInstance = new FileDiff<T>(
? new VirtualizedFileDiff<T>(
{
...createDefaultOptions(props.diffStyle),
...others,
...(local.preloadedDiff.options ?? {}),
},
virtualizer,
virtualMetrics,
workerPool,
)
: new FileDiff<T>(
{ {
...createDefaultOptions(props.diffStyle), ...createDefaultOptions(props.diffStyle),
...others, ...others,
@@ -163,8 +141,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
onCleanup(() => { onCleanup(() => {
clearReadyWatcher(ready) clearReadyWatcher(ready)
fileDiffInstance?.cleanUp() fileDiffInstance?.cleanUp()
sharedVirtualizer?.release()
sharedVirtualizer = undefined
}) })
return ( return (
+4 -120
View File
@@ -1,6 +1,5 @@
import { sampledChecksum } from "@opencode-ai/util/encode" import { sampledChecksum } from "@opencode-ai/util/encode"
import { import {
DEFAULT_VIRTUAL_FILE_METRICS,
type DiffLineAnnotation, type DiffLineAnnotation,
type FileContents, type FileContents,
type FileDiffMetadata, type FileDiffMetadata,
@@ -10,10 +9,6 @@ import {
type FileOptions, type FileOptions,
type LineAnnotation, type LineAnnotation,
type SelectedLineRange, type SelectedLineRange,
type VirtualFileMetrics,
VirtualizedFile,
VirtualizedFileDiff,
Virtualizer,
} from "@pierre/diffs" } from "@pierre/diffs"
import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { createMediaQuery } from "@solid-primitives/media" import { createMediaQuery } from "@solid-primitives/media"
@@ -40,19 +35,10 @@ import {
readShadowLineSelection, readShadowLineSelection,
} from "../pierre/file-selection" } from "../pierre/file-selection"
import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge" import { createLineNumberSelectionBridge, restoreShadowTextSelection } from "../pierre/selection-bridge"
import { acquireVirtualizer, virtualMetrics } from "../pierre/virtualizer"
import { getWorkerPool } from "../pierre/worker" import { getWorkerPool } from "../pierre/worker"
import { FileMedia, type FileMediaOptions } from "./file-media" import { FileMedia, type FileMediaOptions } from "./file-media"
import { FileSearchBar } from "./file-search" 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> = { type SharedProps<T> = {
annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[] annotations?: LineAnnotation<T>[] | DiffLineAnnotation<T>[]
selectedLines?: SelectedLineRange | null selectedLines?: SelectedLineRange | null
@@ -386,11 +372,6 @@ type AnnotationTarget<A> = {
rerender: () => void rerender: () => void
} }
type VirtualStrategy = {
get: () => Virtualizer | undefined
cleanup: () => void
}
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) { function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
return useFileViewer({ return useFileViewer({
enableLineSelection: config.enableLineSelection, enableLineSelection: config.enableLineSelection,
@@ -532,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) { function parseLine(node: HTMLElement) {
if (!node.dataset.line) return if (!node.dataset.line) return
const value = parseInt(node.dataset.line, 10) const value = parseInt(node.dataset.line, 10)
@@ -688,7 +611,7 @@ function ViewerShell(props: {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function TextViewer<T>(props: TextFileProps<T>) { function TextViewer<T>(props: TextFileProps<T>) {
let instance: PierreFile<T> | VirtualizedFile<T> | undefined let instance: PierreFile<T> | undefined
let viewer!: Viewer let viewer!: Viewer
const [local, others] = splitProps(props, textKeys) const [local, others] = splitProps(props, textKeys)
@@ -707,34 +630,12 @@ function TextViewer<T>(props: TextFileProps<T>) {
return Math.max(1, total) 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 lineFromMouseEvent = (event: MouseEvent): MouseHit => mouseHit(event, parseLine)
const applySelection = (range: SelectedLineRange | null) => { const applySelection = (range: SelectedLineRange | null) => {
const current = instance const current = instance
if (!current) return false if (!current) return false
if (virtual()) {
current.setSelectedLines(range)
return true
}
const root = viewer.getRoot() const root = viewer.getRoot()
if (!root) return false if (!root) return false
@@ -833,10 +734,7 @@ function TextViewer<T>(props: TextFileProps<T>) {
const notify = () => { const notify = () => {
notifyRendered({ notifyRendered({
viewer, viewer,
isReady: (root) => { isReady: (root) => root.querySelectorAll("[data-line]").length >= lineCount(),
if (virtual()) return root.querySelector("[data-line]") != null
return root.querySelectorAll("[data-line]").length >= lineCount()
},
onReady: () => { onReady: () => {
applySelection(viewer.lastSelection) applySelection(viewer.lastSelection)
viewer.find.refresh({ reset: true }) viewer.find.refresh({ reset: true })
@@ -855,17 +753,11 @@ function TextViewer<T>(props: TextFileProps<T>) {
createEffect(() => { createEffect(() => {
const opts = options() const opts = options()
const workerPool = getWorkerPool("unified") const workerPool = getWorkerPool("unified")
const isVirtual = virtual()
const virtualizer = virtuals.get()
renderViewer({ renderViewer({
viewer, viewer,
current: instance, current: instance,
create: () => create: () => new PierreFile<T>(opts, workerPool),
isVirtual && virtualizer
? new VirtualizedFile<T>(opts, virtualizer, codeMetrics, workerPool)
: new PierreFile<T>(opts, workerPool),
assign: (value) => { assign: (value) => {
instance = value instance = value
}, },
@@ -892,7 +784,6 @@ function TextViewer<T>(props: TextFileProps<T>) {
onCleanup(() => { onCleanup(() => {
instance?.cleanUp() instance?.cleanUp()
instance = undefined instance = undefined
virtuals.cleanup()
}) })
return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} /> return <ViewerShell mode="text" viewer={viewer} class={local.class} classList={local.classList} />
@@ -988,8 +879,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
adapter, adapter,
) )
const virtuals = createSharedVirtualStrategy(() => viewer.container)
const large = createMemo(() => { const large = createMemo(() => {
if (local.fileDiff) { if (local.fileDiff) {
const before = local.fileDiff.deletionLines.join("") const before = local.fileDiff.deletionLines.join("")
@@ -1052,7 +941,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
createEffect(() => { createEffect(() => {
const opts = options() const opts = options()
const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle) const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle)
const virtualizer = virtuals.get()
const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : ""
const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" const afterContents = typeof local.after?.contents === "string" ? local.after.contents : ""
const done = preserve(viewer) const done = preserve(viewer)
@@ -1067,10 +955,7 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
renderViewer({ renderViewer({
viewer, viewer,
current: instance, current: instance,
create: () => create: () => new FileDiff<T>(opts, workerPool),
virtualizer
? new VirtualizedFileDiff<T>(opts, virtualizer, virtualMetrics, workerPool)
: new FileDiff<T>(opts, workerPool),
assign: (value) => { assign: (value) => {
instance = value instance = value
}, },
@@ -1108,7 +993,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
onCleanup(() => { onCleanup(() => {
instance?.cleanUp() instance?.cleanUp()
instance = undefined instance = undefined
virtuals.cleanup()
dragSide = undefined dragSide = undefined
dragEndSide = undefined dragEndSide = undefined
}) })
@@ -26,7 +26,6 @@ import type { LineCommentEditorProps } from "./line-comment"
import { normalize, text, type ViewDiff } from "./session-diff" import { normalize, text, type ViewDiff } from "./session-diff"
const MAX_DIFF_CHANGED_LINES = 500 const MAX_DIFF_CHANGED_LINES = 500
const REVIEW_MOUNT_MARGIN = 300
export type SessionReviewDiffStyle = "unified" | "split" export type SessionReviewDiffStyle = "unified" | "split"
@@ -139,14 +138,11 @@ type SessionReviewSelection = {
export const SessionReview = (props: SessionReviewProps) => { export const SessionReview = (props: SessionReviewProps) => {
let scroll: HTMLDivElement | undefined let scroll: HTMLDivElement | undefined
let focusToken = 0 let focusToken = 0
let frame: number | undefined
const i18n = useI18n() const i18n = useI18n()
const fileComponent = useFileComponent() const fileComponent = useFileComponent()
const anchors = new Map<string, HTMLElement>() const anchors = new Map<string, HTMLElement>()
const nodes = new Map<string, HTMLDivElement>()
const [store, setStore] = createStore({ const [store, setStore] = createStore({
open: [] as string[], open: [] as string[],
visible: {} as Record<string, boolean>,
force: {} as Record<string, boolean>, force: {} as Record<string, boolean>,
selection: null as SessionReviewSelection | null, selection: null as SessionReviewSelection | null,
commenting: null as SessionReviewSelection | null, commenting: null as SessionReviewSelection | null,
@@ -174,44 +170,7 @@ export const SessionReview = (props: SessionReviewProps) => {
const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified") const diffStyle = () => props.diffStyle ?? (props.split ? "split" : "unified")
const hasDiffs = () => files().length > 0 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) => { const handleScroll: JSX.EventHandler<HTMLDivElement, Event> = (event) => {
queue()
const next = props.onScroll const next = props.onScroll
if (!next) return if (!next) return
if (Array.isArray(next)) { if (Array.isArray(next)) {
@@ -222,21 +181,9 @@ export const SessionReview = (props: SessionReviewProps) => {
;(next as JSX.EventHandler<HTMLDivElement, Event>)(event) ;(next as JSX.EventHandler<HTMLDivElement, Event>)(event)
} }
onCleanup(() => {
if (frame === undefined) return
cancelAnimationFrame(frame)
})
createEffect(() => {
props.open
files()
queue()
})
const handleChange = (next: string[]) => { const handleChange = (next: string[]) => {
props.onOpenChange?.(next) props.onOpenChange?.(next)
if (props.open === undefined) setStore("open", next) if (props.open === undefined) setStore("open", next)
queue()
} }
const handleExpandOrCollapseAll = () => { const handleExpandOrCollapseAll = () => {
@@ -350,7 +297,6 @@ export const SessionReview = (props: SessionReviewProps) => {
viewportRef={(el) => { viewportRef={(el) => {
scroll = el scroll = el
props.scrollRef?.(el) props.scrollRef?.(el)
queue()
}} }}
onScroll={handleScroll} onScroll={handleScroll}
classList={{ classList={{
@@ -363,11 +309,9 @@ export const SessionReview = (props: SessionReviewProps) => {
<Accordion multiple value={open()} onChange={handleChange}> <Accordion multiple value={open()} onChange={handleChange}>
<For each={items()}> <For each={items()}>
{(diff) => { {(diff) => {
let wrapper: HTMLDivElement | undefined
const file = diff.file const file = diff.file
const expanded = createMemo(() => open().includes(file)) const expanded = createMemo(() => open().includes(file))
const mounted = createMemo(() => expanded() && (!!store.visible[file] || pinned(file)))
const force = () => !!store.force[file] const force = () => !!store.force[file]
const comments = createMemo(() => grouped().get(file) ?? []) const comments = createMemo(() => grouped().get(file) ?? [])
@@ -458,8 +402,6 @@ export const SessionReview = (props: SessionReviewProps) => {
onCleanup(() => { onCleanup(() => {
anchors.delete(file) anchors.delete(file)
nodes.delete(file)
queue()
}) })
const handleLineSelected = (range: SelectedLineRange | null) => { const handleLineSelected = (range: SelectedLineRange | null) => {
@@ -542,21 +484,11 @@ export const SessionReview = (props: SessionReviewProps) => {
<div <div
data-slot="session-review-diff-wrapper" data-slot="session-review-diff-wrapper"
ref={(el) => { ref={(el) => {
wrapper = el
anchors.set(file, el) anchors.set(file, el)
nodes.set(file, el)
queue()
}} }}
> >
<Show when={expanded()}> <Show when={expanded()}>
<Switch> <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()}> <Match when={tooLarge()}>
<div data-slot="session-review-large-diff"> <div data-slot="session-review-large-diff">
<div data-slot="session-review-large-diff-title"> <div data-slot="session-review-large-diff-title">
+7 -1
View File
@@ -94,9 +94,15 @@
[data-slot="session-turn-diffs-header"] { [data-slot="session-turn-diffs-header"] {
display: flex; display: flex;
align-items: baseline; align-items: center;
gap: 8px; gap: 8px;
padding-top: 4px;
padding-bottom: 12px; 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"] { [data-slot="session-turn-diffs-label"] {
+1 -1
View File
@@ -447,7 +447,7 @@ export function SessionTurn(
<div data-component="session-turn-diffs-content"> <div data-component="session-turn-diffs-content">
<Accordion <Accordion
multiple multiple
style={{ "--sticky-accordion-offset": "40px" }} style={{ "--sticky-accordion-offset": "44px" }}
value={expanded()} value={expanded()}
onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])} onChange={(value) => setState("expanded", Array.isArray(value) ? value : value ? [value] : [])}
> >
-100
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)
},
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@opencode-ai/util", "name": "@opencode-ai/util",
"version": "1.3.17", "version": "1.4.0",
"private": true, "private": true,
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "@opencode-ai/web", "name": "@opencode-ai/web",
"type": "module", "type": "module",
"license": "MIT", "license": "MIT",
"version": "1.3.17", "version": "1.4.0",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode", "name": "opencode",
"displayName": "opencode", "displayName": "opencode",
"description": "opencode for VS Code", "description": "opencode for VS Code",
"version": "1.3.17", "version": "1.4.0",
"publisher": "sst-dev", "publisher": "sst-dev",
"repository": { "repository": {
"type": "git", "type": "git",