diff --git a/STATS.md b/STATS.md index e09c57e8f41..a2041d49ac6 100644 --- a/STATS.md +++ b/STATS.md @@ -202,3 +202,5 @@ | 2026-01-13 | 3,297,078 (+243,484) | 1,595,062 (+41,391) | 4,892,140 (+284,875) | | 2026-01-14 | 3,568,928 (+271,850) | 1,645,362 (+50,300) | 5,214,290 (+322,150) | | 2026-01-16 | 4,121,550 (+552,622) | 1,754,418 (+109,056) | 5,875,968 (+661,678) | +| 2026-01-17 | 4,389,558 (+268,008) | 1,805,315 (+50,897) | 6,194,873 (+318,905) | +| 2026-01-18 | 4,627,623 (+238,065) | 1,839,171 (+33,856) | 6,466,794 (+271,921) | diff --git a/github/README.md b/github/README.md index 8238bdc42aa..17b24ffb1d6 100644 --- a/github/README.md +++ b/github/README.md @@ -91,8 +91,10 @@ This will walk you through installing the GitHub app, creating the workflow, and uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 + use_github_token: true ``` 3. Store the API keys in secrets. In your organization or project **settings**, expand **Secrets and variables** on the left and select **Actions**. Add the required API keys. diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index d0678dc5369..d03d10d0ea7 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -29,7 +29,7 @@ import { Suspense } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) -const Loading = () =>
Loading...
+const Loading = () =>
declare global { interface Window { diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 432e531e192..0e8d69628bb 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -149,7 +149,7 @@ export function DialogSelectFile() { +
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 7070f0c9337..96ed762c448 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -1,15 +1,17 @@ -import { createMemo, createResource, Show } from "solid-js" +import { createEffect, createMemo, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useParams } from "@solidjs/router" import { useLayout } from "@/context/layout" import { useCommand } from "@/context/command" // import { useServer } from "@/context/server" // import { useDialog } from "@opencode-ai/ui/context/dialog" +import { usePlatform } from "@/context/platform" import { useSync } from "@/context/sync" import { useGlobalSDK } from "@/context/global-sdk" import { getFilename } from "@opencode-ai/util/path" import { base64Decode } from "@opencode-ai/util/encode" -import { iife } from "@opencode-ai/util/iife" + import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Button } from "@opencode-ai/ui/button" @@ -26,6 +28,7 @@ export function SessionHeader() { // const server = useServer() // const dialog = useDialog() const sync = useSync() + const platform = usePlatform() const projectDirectory = createMemo(() => base64Decode(params.dir ?? "")) const project = createMemo(() => { @@ -45,6 +48,78 @@ export function SessionHeader() { const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const view = createMemo(() => layout.view(sessionKey())) + const [state, setState] = createStore({ + share: false, + unshare: false, + copied: false, + timer: undefined as number | undefined, + }) + const shareUrl = createMemo(() => currentSession()?.share?.url) + + createEffect(() => { + const url = shareUrl() + if (url) return + if (state.timer) window.clearTimeout(state.timer) + setState({ copied: false, timer: undefined }) + }) + + onCleanup(() => { + if (state.timer) window.clearTimeout(state.timer) + }) + + function shareSession() { + const session = currentSession() + if (!session || state.share) return + setState("share", true) + globalSDK.client.session + .share({ sessionID: session.id, directory: projectDirectory() }) + .catch((error) => { + console.error("Failed to share session", error) + }) + .finally(() => { + setState("share", false) + }) + } + + function unshareSession() { + const session = currentSession() + if (!session || state.unshare) return + setState("unshare", true) + globalSDK.client.session + .unshare({ sessionID: session.id, directory: projectDirectory() }) + .catch((error) => { + console.error("Failed to unshare session", error) + }) + .finally(() => { + setState("unshare", false) + }) + } + + function copyLink() { + const url = shareUrl() + if (!url) return + navigator.clipboard + .writeText(url) + .then(() => { + if (state.timer) window.clearTimeout(state.timer) + setState("copied", true) + const timer = window.setTimeout(() => { + setState("copied", false) + setState("timer", undefined) + }, 3000) + setState("timer", timer) + }) + .catch((error) => { + console.error("Failed to copy share link", error) + }) + } + + function viewShare() { + const url = shareUrl() + if (!url) return + platform.openLink(url) + } + const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) @@ -58,14 +133,14 @@ export function SessionHeader() { class="hidden md:flex w-[320px] p-1 pl-1.5 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-raised-base transition-colors cursor-default hover:bg-surface-raised-base-hover focus:bg-surface-raised-base-hover active:bg-surface-raised-base-active" onClick={() => command.trigger("file.open")} > -
- - +
+ + Search {name()}
- {(keybind) => {keybind()}} + {(keybind) => {keybind()}} )} @@ -159,40 +234,77 @@ export function SessionHeader() {
- - - - } - > - {iife(() => { - const [url] = createResource( - () => currentSession(), - async (session) => { - if (!session) return - let shareURL = session.share?.url - if (!shareURL) { - shareURL = await globalSDK.client.session - .share({ sessionID: session.id, directory: projectDirectory() }) - .then((r) => r.data?.share?.url) - .catch((e) => { - console.error("Failed to share session", e) - return undefined - }) +
+ + + + } + > +
+ + +
} - return shareURL - }, - { initialValue: "" }, - ) - return ( - - {(shareUrl) => } + > +
+ +
+ + +
+
- ) - })} -
+
+
+ + + + + +
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index a93ffc02454..d8dc13e2344 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -1,5 +1,6 @@ import { createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" +import { useDialog } from "@opencode-ai/ui/context/dialog" const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) @@ -122,6 +123,7 @@ export function formatKeybind(config: string): string { export const { use: useCommand, provider: CommandProvider } = createSimpleContext({ name: "Command", init: () => { + const dialog = useDialog() const [registrations, setRegistrations] = createSignal[]>([]) const [suspendCount, setSuspendCount] = createSignal(0) @@ -165,7 +167,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex } const handleKeyDown = (event: KeyboardEvent) => { - if (suspended()) return + if (suspended() || dialog.active) return const paletteKeybinds = parseKeybind("mod+shift+p") if (matchKeybind(paletteKeybinds, event)) { diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 74641a0a243..96f8c63eab2 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -379,6 +379,8 @@ function createGlobalSync() { }), ) } + if (event.properties.info.parentID) break + setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } if (result.found) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index bc62c70232f..56d6bfbf8ca 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -501,7 +501,7 @@ export default function Layout(props: ParentProps) { const [dirStore] = globalSync.child(dir) const dirSessions = dirStore.session .filter((session) => session.directory === dirStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) result.push(...dirSessions) } @@ -510,7 +510,7 @@ export default function Layout(props: ParentProps) { const [projectStore] = globalSync.child(project.worktree) return projectStore.session .filter((session) => session.directory === projectStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) }) @@ -1018,7 +1018,7 @@ export default function Layout(props: ParentProps) { const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) const hasError = createMemo(() => notifications().some((n) => n.type === "error")) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const mask = "radial-gradient(circle 6px at calc(100% - 3px) 3px, transparent 6px, black 6.5px)" + const mask = "radial-gradient(circle 5px at calc(100% - 4px) 4px, transparent 5px, black 5.5px)" const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" return ( @@ -1039,7 +1039,7 @@ export default function Layout(props: ParentProps) { 0 && props.notify}>
- - prefetchSession(props.session, "high")} - onFocus={() => prefetchSession(props.session, "high")} - > -
-
- }> - - - - -
- - -
- - 0}> -
- - -
+
prefetchSession(props.session, "high")} + onFocus={() => prefetchSession(props.session, "high")} + > +
+
+ }> + + + + + +
@@ -1203,7 +1209,7 @@ export default function Layout(props: ParentProps) { const sessions = createMemo(() => workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) const local = createMemo(() => props.directory === props.project.worktree) @@ -1338,6 +1344,8 @@ export default function Layout(props: ParentProps) { const workspaces = createMemo(() => workspaceIds(props.project).slice(0, 2)) const workspaceEnabled = createMemo(() => layout.sidebar.workspaces(props.project.worktree)()) + const [open, setOpen] = createSignal(false) + const label = (directory: string) => { const [data] = globalSync.child(directory) const kind = directory === props.project.worktree ? "local" : "sandbox" @@ -1349,7 +1357,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(directory) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } @@ -1358,7 +1366,7 @@ export default function Layout(props: ParentProps) { const [data] = globalSync.child(props.project.worktree) return data.session .filter((session) => session.directory === data.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions) .slice(0, 2) } @@ -1370,7 +1378,8 @@ export default function Layout(props: ParentProps) { "flex items-center justify-center size-10 p-1 rounded-lg overflow-hidden transition-colors cursor-default": true, "bg-transparent border-2 border-icon-strong-base hover:bg-surface-base-hover": selected(), "bg-transparent border border-transparent hover:bg-surface-base-hover hover:border-border-weak-base": - !selected(), + !selected() && !open(), + "bg-surface-base-hover border border-border-weak-base": !selected() && open(), }} onClick={() => navigateToProject(props.project.worktree)} > @@ -1381,9 +1390,17 @@ export default function Layout(props: ParentProps) { return ( // @ts-ignore
- +
-
Recent sessions
+
{displayName(props.project)}
+
Recent sessions
workspaceStore.session .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID) + .filter((session) => !session.parentID && !session.time?.archived) .toSorted(sortSessions), ) const loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0) @@ -1610,7 +1627,16 @@ export default function Layout(props: ParentProps) { stopPropagation /> - + {project()?.worktree.replace(homedir(), "~")} @@ -1652,7 +1678,7 @@ export default function Layout(props: ParentProps) {
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index aad0b596cb9..f063ce35b40 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -533,10 +533,6 @@ export default function Page() { keybind: "shift+mod+t", onSelect: () => { local.model.variant.cycle() - showToast({ - title: "Thinking effort changed", - description: "The thinking effort has been changed to " + (local.model.variant.current() ?? "Default"), - }) }, }, { @@ -654,6 +650,72 @@ export default function Page() { disabled: !params.id || visibleUserMessages().length === 0, onSelect: () => dialog.show(() => ), }, + ...(sync.data.config.share !== "disabled" + ? [ + { + id: "session.share", + title: "Share session", + description: "Share this session and copy the URL to clipboard", + category: "Session", + slash: "share", + disabled: !params.id || !!info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .share({ sessionID: params.id }) + .then((res) => { + navigator.clipboard.writeText(res.data!.share!.url).catch(() => + showToast({ + title: "Failed to copy URL to clipboard", + variant: "error", + }), + ) + }) + .then(() => + showToast({ + title: "Session shared", + description: "Share URL copied to clipboard!", + variant: "success", + }), + ) + .catch(() => + showToast({ + title: "Failed to share session", + description: "An error occurred while sharing the session", + variant: "error", + }), + ) + }, + }, + { + id: "session.unshare", + title: "Unshare session", + description: "Stop sharing this session", + category: "Session", + slash: "unshare", + disabled: !params.id || !info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .unshare({ sessionID: params.id }) + .then(() => + showToast({ + title: "Session unshared", + description: "Session unshared successfully!", + variant: "success", + }), + ) + .catch(() => + showToast({ + title: "Failed to unshare session", + description: "An error occurred while unsharing the session", + variant: "error", + }), + ) + }, + }, + ] + : []), ]) const handleKeyDown = (event: KeyboardEvent) => { @@ -1091,7 +1153,7 @@ export default function Page() { file.load(path) }} classes={{ - root: "pb-[calc(var(--prompt-height,8rem)+32px)]", + root: "pb-[calc(var(--prompt-height,8rem)+24px)]", header: "px-4", container: "px-4", }} @@ -1237,7 +1299,7 @@ export default function Page() { {/* Prompt input */}
(promptDock = el)} - class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-8 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" + class="absolute inset-x-0 bottom-0 pt-12 pb-4 md:pb-6 flex flex-col justify-center items-center z-50 px-4 md:px-0 bg-gradient-to-t from-background-stronger via-background-stronger to-transparent pointer-events-none" >
{ + if (!(elt instanceof Element)) { + // WebView2 can call into Floating UI with non-elements; fall back to a safe element. + return originalGetComputedStyle(document.documentElement, pseudoElt ?? undefined) + } + return originalGetComputedStyle(elt, pseudoElt ?? undefined) + }) as typeof window.getComputedStyle +} + let update: Update | null = null const createPlatform = (password: Accessor): Platform => ({ @@ -345,8 +357,7 @@ function ServerGate(props: { children: (data: Accessor) => JSX. when={serverData.state !== "pending" && serverData()} fallback={
- -
Initializing...
+
} > diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index f8792393c60..5fca2725587 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -20,7 +20,7 @@ import { } from "@agentclientprotocol/sdk" import { Log } from "../util/log" import { ACPSessionManager } from "./session" -import type { ACPConfig, ACPSessionState } from "./types" +import type { ACPConfig } from "./types" import { Provider } from "../provider/provider" import { Agent as AgentModule } from "../agent/agent" import { Installation } from "@/installation" @@ -29,7 +29,7 @@ import { Config } from "@/config/config" import { Todo } from "@/session/todo" import { z } from "zod" import { LoadAPIKeyError } from "ai" -import type { OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" +import type { Event, OpencodeClient, SessionMessageResponse } from "@opencode-ai/sdk/v2" import { applyPatch } from "diff" export namespace ACP { @@ -47,304 +47,354 @@ export namespace ACP { private connection: AgentSideConnection private config: ACPConfig private sdk: OpencodeClient - private sessionManager + private sessionManager: ACPSessionManager + private eventAbort = new AbortController() + private eventStarted = false + private permissionQueues = new Map>() + private permissionOptions: PermissionOption[] = [ + { optionId: "once", kind: "allow_once", name: "Allow once" }, + { optionId: "always", kind: "allow_always", name: "Always allow" }, + { optionId: "reject", kind: "reject_once", name: "Reject" }, + ] constructor(connection: AgentSideConnection, config: ACPConfig) { this.connection = connection this.config = config this.sdk = config.sdk this.sessionManager = new ACPSessionManager(this.sdk) + this.startEventSubscription() } - private setupEventSubscriptions(session: ACPSessionState) { - const sessionId = session.id - const directory = session.cwd + private startEventSubscription() { + if (this.eventStarted) return + this.eventStarted = true + this.runEventSubscription().catch((error) => { + if (this.eventAbort.signal.aborted) return + log.error("event subscription failed", { error }) + }) + } - const options: PermissionOption[] = [ - { optionId: "once", kind: "allow_once", name: "Allow once" }, - { optionId: "always", kind: "allow_always", name: "Always allow" }, - { optionId: "reject", kind: "reject_once", name: "Reject" }, - ] - this.config.sdk.event.subscribe({ directory }).then(async (events) => { + private async runEventSubscription() { + while (true) { + if (this.eventAbort.signal.aborted) return + const events = await this.sdk.global.event({ + signal: this.eventAbort.signal, + }) for await (const event of events.stream) { - switch (event.type) { - case "permission.asked": - try { - const permission = event.properties - const res = await this.connection - .requestPermission({ - sessionId, - toolCall: { - toolCallId: permission.tool?.callID ?? permission.id, - status: "pending", - title: permission.permission, - rawInput: permission.metadata, - kind: toToolKind(permission.permission), - locations: toLocations(permission.permission, permission.metadata), - }, - options, - }) - .catch(async (error) => { - log.error("failed to request permission from ACP", { - error, - permissionID: permission.id, - sessionID: permission.sessionID, - }) - await this.config.sdk.permission.reply({ - requestID: permission.id, - reply: "reject", - directory, - }) - return + if (this.eventAbort.signal.aborted) return + const payload = (event as any)?.payload + if (!payload) continue + await this.handleEvent(payload as Event).catch((error) => { + log.error("failed to handle event", { error, type: payload.type }) + }) + } + } + } + + private async handleEvent(event: Event) { + switch (event.type) { + case "permission.asked": { + const permission = event.properties + const session = this.sessionManager.tryGet(permission.sessionID) + if (!session) return + + const prev = this.permissionQueues.get(permission.sessionID) ?? Promise.resolve() + const next = prev + .then(async () => { + const directory = session.cwd + + const res = await this.connection + .requestPermission({ + sessionId: permission.sessionID, + toolCall: { + toolCallId: permission.tool?.callID ?? permission.id, + status: "pending", + title: permission.permission, + rawInput: permission.metadata, + kind: toToolKind(permission.permission), + locations: toLocations(permission.permission, permission.metadata), + }, + options: this.permissionOptions, + }) + .catch(async (error) => { + log.error("failed to request permission from ACP", { + error, + permissionID: permission.id, + sessionID: permission.sessionID, }) - if (!res) return - if (res.outcome.outcome !== "selected") { - await this.config.sdk.permission.reply({ + await this.sdk.permission.reply({ requestID: permission.id, reply: "reject", directory, }) - return - } - if (res.outcome.optionId !== "reject" && permission.permission == "edit") { - const metadata = permission.metadata || {} - const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" - const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" - - const content = await Bun.file(filepath).text() - const newContent = getNewContent(content, diff) - - if (newContent) { - this.connection.writeTextFile({ - sessionId: sessionId, - path: filepath, - content: newContent, - }) - } - } - await this.config.sdk.permission.reply({ + return undefined + }) + + if (!res) return + if (res.outcome.outcome !== "selected") { + await this.sdk.permission.reply({ requestID: permission.id, - reply: res.outcome.optionId as "once" | "always" | "reject", + reply: "reject", directory, }) - } catch (err) { - log.error("unexpected error when handling permission", { error: err }) - } finally { - break + return + } + + if (res.outcome.optionId !== "reject" && permission.permission == "edit") { + const metadata = permission.metadata || {} + const filepath = typeof metadata["filepath"] === "string" ? metadata["filepath"] : "" + const diff = typeof metadata["diff"] === "string" ? metadata["diff"] : "" + + const content = await Bun.file(filepath).text() + const newContent = getNewContent(content, diff) + + if (newContent) { + this.connection.writeTextFile({ + sessionId: session.id, + path: filepath, + content: newContent, + }) + } + } + + await this.sdk.permission.reply({ + requestID: permission.id, + reply: res.outcome.optionId as "once" | "always" | "reject", + directory, + }) + }) + .catch((error) => { + log.error("failed to handle permission", { error, permissionID: permission.id }) + }) + .finally(() => { + if (this.permissionQueues.get(permission.sessionID) === next) { + this.permissionQueues.delete(permission.sessionID) } + }) + this.permissionQueues.set(permission.sessionID, next) + return + } + + case "message.part.updated": { + log.info("message part updated", { event: event.properties }) + const props = event.properties + const part = props.part + const session = this.sessionManager.tryGet(part.sessionID) + if (!session) return + const sessionId = session.id + const directory = session.cwd + + const message = await this.sdk.session + .message( + { + sessionID: part.sessionID, + messageID: part.messageID, + directory, + }, + { throwOnError: true }, + ) + .then((x) => x.data) + .catch((error) => { + log.error("unexpected error when fetching message", { error }) + return undefined + }) + + if (!message || message.info.role !== "assistant") return - case "message.part.updated": - log.info("message part updated", { event: event.properties }) - try { - const props = event.properties - const { part } = props - - const message = await this.config.sdk.session - .message( - { - sessionID: part.sessionID, - messageID: part.messageID, - directory, + if (part.type === "tool") { + switch (part.state.status) { + case "pending": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: part.callID, + title: part.tool, + kind: toToolKind(part.tool), + status: "pending", + locations: [], + rawInput: {}, }, - { throwOnError: true }, - ) - .then((x) => x.data) - .catch((err) => { - log.error("unexpected error when fetching message", { error: err }) - return undefined }) + .catch((error) => { + log.error("failed to send tool pending to ACP", { error }) + }) + return - if (!message || message.info.role !== "assistant") return - - if (part.type === "tool") { - switch (part.state.status) { - case "pending": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: part.callID, - title: part.tool, - kind: toToolKind(part.tool), - status: "pending", - locations: [], - rawInput: {}, - }, - }) - .catch((err) => { - log.error("failed to send tool pending to ACP", { error: err }) - }) - break - case "running": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "in_progress", - kind: toToolKind(part.tool), - title: part.tool, - locations: toLocations(part.tool, part.state.input), - rawInput: part.state.input, - }, - }) - .catch((err) => { - log.error("failed to send tool in_progress to ACP", { error: err }) - }) - break - case "completed": - const kind = toToolKind(part.tool) - const content: ToolCallContent[] = [ - { - type: "content", - content: { - type: "text", - text: part.state.output, - }, - }, - ] - - if (kind === "edit") { - const input = part.state.input - const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" - const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" - const newText = - typeof input["newString"] === "string" - ? input["newString"] - : typeof input["content"] === "string" - ? input["content"] - : "" - content.push({ - type: "diff", - path: filePath, - oldText, - newText, - }) - } - - if (part.tool === "todowrite") { - const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) - if (parsedTodos.success) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "plan", - entries: parsedTodos.data.map((todo) => { - const status: PlanEntry["status"] = - todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) - return { - priority: "medium", - status, - content: todo.content, - } - }), - }, - }) - .catch((err) => { - log.error("failed to send session update for todo", { error: err }) - }) - } else { - log.error("failed to parse todo output", { error: parsedTodos.error }) - } - } - - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "completed", - kind, - content, - title: part.state.title, - rawInput: part.state.input, - rawOutput: { - output: part.state.output, - metadata: part.state.metadata, - }, - }, - }) - .catch((err) => { - log.error("failed to send tool completed to ACP", { error: err }) - }) - break - case "error": - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: part.callID, - status: "failed", - kind: toToolKind(part.tool), - title: part.tool, - rawInput: part.state.input, - content: [ - { - type: "content", - content: { - type: "text", - text: part.state.error, - }, - }, - ], - rawOutput: { - error: part.state.error, - }, - }, - }) - .catch((err) => { - log.error("failed to send tool error to ACP", { error: err }) - }) - break - } - } else if (part.type === "text") { - const delta = props.delta - if (delta && part.synthetic !== true) { + case "running": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "in_progress", + kind: toToolKind(part.tool), + title: part.tool, + locations: toLocations(part.tool, part.state.input), + rawInput: part.state.input, + }, + }) + .catch((error) => { + log.error("failed to send tool in_progress to ACP", { error }) + }) + return + + case "completed": { + const kind = toToolKind(part.tool) + const content: ToolCallContent[] = [ + { + type: "content", + content: { + type: "text", + text: part.state.output, + }, + }, + ] + + if (kind === "edit") { + const input = part.state.input + const filePath = typeof input["filePath"] === "string" ? input["filePath"] : "" + const oldText = typeof input["oldString"] === "string" ? input["oldString"] : "" + const newText = + typeof input["newString"] === "string" + ? input["newString"] + : typeof input["content"] === "string" + ? input["content"] + : "" + content.push({ + type: "diff", + path: filePath, + oldText, + newText, + }) + } + + if (part.tool === "todowrite") { + const parsedTodos = z.array(Todo.Info).safeParse(JSON.parse(part.state.output)) + if (parsedTodos.success) { await this.connection .sessionUpdate({ sessionId, update: { - sessionUpdate: "agent_message_chunk", - content: { - type: "text", - text: delta, - }, + sessionUpdate: "plan", + entries: parsedTodos.data.map((todo) => { + const status: PlanEntry["status"] = + todo.status === "cancelled" ? "completed" : (todo.status as PlanEntry["status"]) + return { + priority: "medium", + status, + content: todo.content, + } + }), }, }) - .catch((err) => { - log.error("failed to send text to ACP", { error: err }) + .catch((error) => { + log.error("failed to send session update for todo", { error }) }) + } else { + log.error("failed to parse todo output", { error: parsedTodos.error }) } - } else if (part.type === "reasoning") { - const delta = props.delta - if (delta) { - await this.connection - .sessionUpdate({ - sessionId, - update: { - sessionUpdate: "agent_thought_chunk", + } + + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "completed", + kind, + content, + title: part.state.title, + rawInput: part.state.input, + rawOutput: { + output: part.state.output, + metadata: part.state.metadata, + }, + }, + }) + .catch((error) => { + log.error("failed to send tool completed to ACP", { error }) + }) + return + } + case "error": + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: part.callID, + status: "failed", + kind: toToolKind(part.tool), + title: part.tool, + rawInput: part.state.input, + content: [ + { + type: "content", content: { type: "text", - text: delta, + text: part.state.error, }, }, - }) - .catch((err) => { - log.error("failed to send reasoning to ACP", { error: err }) - }) - } - } - } finally { - break - } + ], + rawOutput: { + error: part.state.error, + }, + }, + }) + .catch((error) => { + log.error("failed to send tool error to ACP", { error }) + }) + return + } + } + + if (part.type === "text") { + const delta = props.delta + if (delta && part.synthetic !== true) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send text to ACP", { error }) + }) + } + return + } + + if (part.type === "reasoning") { + const delta = props.delta + if (delta) { + await this.connection + .sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_thought_chunk", + content: { + type: "text", + text: delta, + }, + }, + }) + .catch((error) => { + log.error("failed to send reasoning to ACP", { error }) + }) + } } + return } - }) + } } async initialize(params: InitializeRequest): Promise { @@ -409,8 +459,6 @@ export namespace ACP { sessionId, }) - this.setupEventSubscriptions(state) - return { sessionId, models: load.models, @@ -436,18 +484,16 @@ export namespace ACP { const model = await defaultModel(this.config, directory) // Store ACP session state - const state = await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) + await this.sessionManager.load(sessionId, params.cwd, params.mcpServers, model) log.info("load_session", { sessionId, mcpServers: params.mcpServers.length }) - const mode = await this.loadSessionMode({ + const result = await this.loadSessionMode({ cwd: directory, mcpServers: params.mcpServers, sessionId, }) - this.setupEventSubscriptions(state) - // Replay session history const messages = await this.sdk.session .messages( @@ -463,12 +509,20 @@ export namespace ACP { return undefined }) + const lastUser = messages?.findLast((m) => m.info.role === "user")?.info + if (lastUser?.role === "user") { + result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` + if (result.modes.availableModes.some((m) => m.id === lastUser.agent)) { + result.modes.currentModeId = lastUser.agent + } + } + for (const msg of messages ?? []) { log.debug("replay message", msg) await this.processMessage(msg) } - return mode + return result } catch (e) { const error = MessageV2.fromError(e, { providerID: this.config.defaultModel?.providerID ?? "unknown", diff --git a/packages/opencode/src/acp/session.ts b/packages/opencode/src/acp/session.ts index 70b65834705..151fa5646ba 100644 --- a/packages/opencode/src/acp/session.ts +++ b/packages/opencode/src/acp/session.ts @@ -13,6 +13,10 @@ export class ACPSessionManager { this.sdk = sdk } + tryGet(sessionId: string): ACPSessionState | undefined { + return this.sessions.get(sessionId) + } + async create(cwd: string, mcpServers: McpServer[], model?: ACPSessionState["model"]): Promise { const session = await this.sdk.session .create( diff --git a/packages/opencode/src/cli/cmd/debug/agent.ts b/packages/opencode/src/cli/cmd/debug/agent.ts index ef6b0c4fc92..d1236ff40bc 100644 --- a/packages/opencode/src/cli/cmd/debug/agent.ts +++ b/packages/opencode/src/cli/cmd/debug/agent.ts @@ -70,8 +70,8 @@ export const AgentCommand = cmd({ }) async function getAvailableTools(agent: Agent.Info) { - const providerID = agent.model?.providerID ?? (await Provider.defaultModel()).providerID - return ToolRegistry.tools(providerID, agent) + const model = agent.model ?? (await Provider.defaultModel()) + return ToolRegistry.tools(model, agent) } async function resolveTools(agent: Agent.Info, availableTools: Awaited>) { diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx index 2ec1fb703f9..4b177e292cf 100644 --- a/packages/opencode/src/cli/cmd/tui/app.tsx +++ b/packages/opencode/src/cli/cmd/tui/app.tsx @@ -288,6 +288,10 @@ function App() { keybind: "session_list", category: "Session", suggested: sync.data.session.length > 0, + slash: { + name: "sessions", + aliases: ["resume", "continue"], + }, onSelect: () => { dialog.replace(() => ) }, @@ -298,6 +302,10 @@ function App() { value: "session.new", keybind: "session_new", category: "Session", + slash: { + name: "new", + aliases: ["clear"], + }, onSelect: () => { const current = promptRef.current // Don't require focus - if there's any text, preserve it @@ -315,26 +323,29 @@ function App() { keybind: "model_list", suggested: true, category: "Agent", + slash: { + name: "models", + }, onSelect: () => { dialog.replace(() => ) }, }, { title: "Model cycle", - disabled: true, value: "model.cycle_recent", keybind: "model_cycle_recent", category: "Agent", + hidden: true, onSelect: () => { local.model.cycle(1) }, }, { title: "Model cycle reverse", - disabled: true, value: "model.cycle_recent_reverse", keybind: "model_cycle_recent_reverse", category: "Agent", + hidden: true, onSelect: () => { local.model.cycle(-1) }, @@ -344,6 +355,7 @@ function App() { value: "model.cycle_favorite", keybind: "model_cycle_favorite", category: "Agent", + hidden: true, onSelect: () => { local.model.cycleFavorite(1) }, @@ -353,6 +365,7 @@ function App() { value: "model.cycle_favorite_reverse", keybind: "model_cycle_favorite_reverse", category: "Agent", + hidden: true, onSelect: () => { local.model.cycleFavorite(-1) }, @@ -362,6 +375,9 @@ function App() { value: "agent.list", keybind: "agent_list", category: "Agent", + slash: { + name: "agents", + }, onSelect: () => { dialog.replace(() => ) }, @@ -370,6 +386,9 @@ function App() { title: "Toggle MCPs", value: "mcp.list", category: "Agent", + slash: { + name: "mcps", + }, onSelect: () => { dialog.replace(() => ) }, @@ -379,7 +398,7 @@ function App() { value: "agent.cycle", keybind: "agent_cycle", category: "Agent", - disabled: true, + hidden: true, onSelect: () => { local.agent.move(1) }, @@ -389,6 +408,7 @@ function App() { value: "variant.cycle", keybind: "variant_cycle", category: "Agent", + hidden: true, onSelect: () => { local.model.variant.cycle() }, @@ -398,7 +418,7 @@ function App() { value: "agent.cycle.reverse", keybind: "agent_cycle_reverse", category: "Agent", - disabled: true, + hidden: true, onSelect: () => { local.agent.move(-1) }, @@ -407,6 +427,9 @@ function App() { title: "Connect provider", value: "provider.connect", suggested: !connected(), + slash: { + name: "connect", + }, onSelect: () => { dialog.replace(() => ) }, @@ -416,6 +439,9 @@ function App() { title: "View status", keybind: "status_view", value: "opencode.status", + slash: { + name: "status", + }, onSelect: () => { dialog.replace(() => ) }, @@ -425,6 +451,9 @@ function App() { title: "Switch theme", value: "theme.switch", keybind: "theme_list", + slash: { + name: "themes", + }, onSelect: () => { dialog.replace(() => ) }, @@ -442,6 +471,9 @@ function App() { { title: "Help", value: "help.show", + slash: { + name: "help", + }, onSelect: () => { dialog.replace(() => ) }, @@ -468,6 +500,10 @@ function App() { { title: "Exit the app", value: "app.exit", + slash: { + name: "exit", + aliases: ["quit", "q"], + }, onSelect: () => exit(), category: "System", }, @@ -508,6 +544,7 @@ function App() { value: "terminal.suspend", keybind: "terminal_suspend", category: "System", + hidden: true, onSelect: () => { process.once("SIGCONT", () => { renderer.resume() diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx index d19e93188b2..38dc402758b 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-command.tsx @@ -16,9 +16,17 @@ import type { KeybindsConfig } from "@opencode-ai/sdk/v2" type Context = ReturnType const ctx = createContext() -export type CommandOption = DialogSelectOption & { +export type Slash = { + name: string + aliases?: string[] +} + +export type CommandOption = DialogSelectOption & { keybind?: keyof KeybindsConfig suggested?: boolean + slash?: Slash + hidden?: boolean + enabled?: boolean } function init() { @@ -26,27 +34,35 @@ function init() { const [suspendCount, setSuspendCount] = createSignal(0) const dialog = useDialog() const keybind = useKeybind() - const options = createMemo(() => { + + const entries = createMemo(() => { const all = registrations().flatMap((x) => x()) - const suggested = all.filter((x) => x.suggested) - return [ - ...suggested.map((x) => ({ - ...x, - category: "Suggested", - value: "suggested." + x.value, - })), - ...all, - ].map((x) => ({ + return all.map((x) => ({ ...x, footer: x.keybind ? keybind.print(x.keybind) : undefined, })) }) + + const isEnabled = (option: CommandOption) => option.enabled !== false + const isVisible = (option: CommandOption) => isEnabled(option) && !option.hidden + + const visibleOptions = createMemo(() => entries().filter((option) => isVisible(option))) + const suggestedOptions = createMemo(() => + visibleOptions() + .filter((option) => option.suggested) + .map((option) => ({ + ...option, + value: `suggested:${option.value}`, + category: "Suggested", + })), + ) const suspended = () => suspendCount() > 0 useKeyboard((evt) => { if (suspended()) return if (dialog.stack.length > 0) return - for (const option of options()) { + for (const option of entries()) { + if (!isEnabled(option)) continue if (option.keybind && keybind.match(option.keybind, evt)) { evt.preventDefault() option.onSelect?.(dialog) @@ -56,20 +72,33 @@ function init() { }) const result = { - trigger(name: string, source?: "prompt") { - for (const option of options()) { + trigger(name: string) { + for (const option of entries()) { if (option.value === name) { - option.onSelect?.(dialog, source) + if (!isEnabled(option)) return + option.onSelect?.(dialog) return } } }, + slashes() { + return visibleOptions().flatMap((option) => { + const slash = option.slash + if (!slash) return [] + return { + display: "/" + slash.name, + description: option.description ?? option.title, + aliases: slash.aliases?.map((alias) => "/" + alias), + onSelect: () => result.trigger(option.value), + } + }) + }, keybinds(enabled: boolean) { setSuspendCount((count) => count + (enabled ? -1 : 1)) }, suspended, show() { - dialog.replace(() => ) + dialog.replace(() => ) }, register(cb: () => CommandOption[]) { const results = createMemo(cb) @@ -78,9 +107,6 @@ function init() { setRegistrations((arr) => arr.filter((x) => x !== results)) }) }, - get options() { - return options() - }, } return result } @@ -104,7 +130,7 @@ export function CommandProvider(props: ParentProps) { if (evt.defaultPrevented) return if (keybind.match("command_list", evt)) { evt.preventDefault() - dialog.replace(() => ) + value.show() return } }) @@ -112,13 +138,11 @@ export function CommandProvider(props: ParentProps) { return {props.children} } -function DialogCommand(props: { options: CommandOption[] }) { +function DialogCommand(props: { options: CommandOption[]; suggestedOptions: CommandOption[] }) { let ref: DialogSelectRef - return ( - (ref = r)} - title="Commands" - options={props.options.filter((x) => !ref?.filter || !x.value.startsWith("suggested."))} - /> - ) + const list = () => { + if (ref?.filter) return props.options + return [...props.suggestedOptions, ...props.options] + } + return (ref = r)} title="Commands" options={list()} /> } diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx index 601eb82bc48..e27c32dfb2e 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/autocomplete.tsx @@ -332,16 +332,15 @@ export function Autocomplete(props: { ) }) - const session = createMemo(() => (props.sessionID ? sync.session.get(props.sessionID) : undefined)) const commands = createMemo((): AutocompleteOption[] => { - const results: AutocompleteOption[] = [] - const s = session() - for (const command of sync.data.command) { + const results: AutocompleteOption[] = [...command.slashes()] + + for (const serverCommand of sync.data.command) { results.push({ - display: "/" + command.name + (command.mcp ? " (MCP)" : ""), - description: command.description, + display: "/" + serverCommand.name + (serverCommand.mcp ? " (MCP)" : ""), + description: serverCommand.description, onSelect: () => { - const newText = "/" + command.name + " " + const newText = "/" + serverCommand.name + " " const cursor = props.input().logicalCursor props.input().deleteRange(0, 0, cursor.row, cursor.col) props.input().insertText(newText) @@ -349,138 +348,9 @@ export function Autocomplete(props: { }, }) } - if (s) { - results.push( - { - display: "/undo", - description: "undo the last message", - onSelect: () => { - command.trigger("session.undo") - }, - }, - { - display: "/redo", - description: "redo the last message", - onSelect: () => command.trigger("session.redo"), - }, - { - display: "/compact", - aliases: ["/summarize"], - description: "compact the session", - onSelect: () => command.trigger("session.compact"), - }, - { - display: "/unshare", - disabled: !s.share, - description: "unshare a session", - onSelect: () => command.trigger("session.unshare"), - }, - { - display: "/rename", - description: "rename session", - onSelect: () => command.trigger("session.rename"), - }, - { - display: "/copy", - description: "copy session transcript to clipboard", - onSelect: () => command.trigger("session.copy"), - }, - { - display: "/export", - description: "export session transcript to file", - onSelect: () => command.trigger("session.export"), - }, - { - display: "/timeline", - description: "jump to message", - onSelect: () => command.trigger("session.timeline"), - }, - { - display: "/fork", - description: "fork from message", - onSelect: () => command.trigger("session.fork"), - }, - { - display: "/thinking", - description: "toggle thinking visibility", - onSelect: () => command.trigger("session.toggle.thinking"), - }, - ) - if (sync.data.config.share !== "disabled") { - results.push({ - display: "/share", - disabled: !!s.share?.url, - description: "share a session", - onSelect: () => command.trigger("session.share"), - }) - } - } - results.push( - { - display: "/new", - aliases: ["/clear"], - description: "create a new session", - onSelect: () => command.trigger("session.new"), - }, - { - display: "/models", - description: "list models", - onSelect: () => command.trigger("model.list"), - }, - { - display: "/agents", - description: "list agents", - onSelect: () => command.trigger("agent.list"), - }, - { - display: "/session", - aliases: ["/resume", "/continue"], - description: "list sessions", - onSelect: () => command.trigger("session.list"), - }, - { - display: "/status", - description: "show status", - onSelect: () => command.trigger("opencode.status"), - }, - { - display: "/mcp", - description: "toggle MCPs", - onSelect: () => command.trigger("mcp.list"), - }, - { - display: "/theme", - description: "toggle theme", - onSelect: () => command.trigger("theme.switch"), - }, - { - display: "/editor", - description: "open editor", - onSelect: () => command.trigger("prompt.editor", "prompt"), - }, - { - display: "/connect", - description: "connect to a provider", - onSelect: () => command.trigger("provider.connect"), - }, - { - display: "/help", - description: "show help", - onSelect: () => command.trigger("help.show"), - }, - { - display: "/commands", - description: "show all commands", - onSelect: () => command.show(), - }, - { - display: "/exit", - aliases: ["/quit", "/q"], - description: "exit the app", - onSelect: () => command.trigger("app.exit"), - }, - ) + results.sort((a, b) => a.display.localeCompare(b.display)) + const max = firstBy(results, [(x) => x.display.length, "desc"])?.display.length if (!max) return results return results.map((item) => ({ @@ -494,9 +364,8 @@ export function Autocomplete(props: { const agentsValue = agents() const commandsValue = commands() - const mixed: AutocompleteOption[] = ( + const mixed: AutocompleteOption[] = store.visible === "@" ? [...agentsValue, ...(filesValue || []), ...mcpResources()] : [...commandsValue] - ).filter((x) => x.disabled !== true) const currentFilter = filter() diff --git a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx index 145fa9da0c3..730da20c265 100644 --- a/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx @@ -157,7 +157,7 @@ export function Prompt(props: PromptProps) { title: "Clear prompt", value: "prompt.clear", category: "Prompt", - disabled: true, + hidden: true, onSelect: (dialog) => { input.extmarks.clear() input.clear() @@ -167,9 +167,9 @@ export function Prompt(props: PromptProps) { { title: "Submit prompt", value: "prompt.submit", - disabled: true, keybind: "input_submit", category: "Prompt", + hidden: true, onSelect: (dialog) => { if (!input.focused) return submit() @@ -179,9 +179,9 @@ export function Prompt(props: PromptProps) { { title: "Paste", value: "prompt.paste", - disabled: true, keybind: "input_paste", category: "Prompt", + hidden: true, onSelect: async () => { const content = await Clipboard.read() if (content?.mime.startsWith("image/")) { @@ -197,8 +197,9 @@ export function Prompt(props: PromptProps) { title: "Interrupt session", value: "session.interrupt", keybind: "session_interrupt", - disabled: status().type === "idle", category: "Session", + hidden: true, + enabled: status().type !== "idle", onSelect: (dialog) => { if (autocomplete.visible) return if (!input.focused) return @@ -229,7 +230,10 @@ export function Prompt(props: PromptProps) { category: "Session", keybind: "editor_open", value: "prompt.editor", - onSelect: async (dialog, trigger) => { + slash: { + name: "editor", + }, + onSelect: async (dialog) => { dialog.clear() // replace summarized text parts with the actual text @@ -242,7 +246,7 @@ export function Prompt(props: PromptProps) { const nonTextParts = store.prompt.parts.filter((p) => p.type !== "text") - const value = trigger === "prompt" ? "" : text + const value = text const content = await Editor.open({ value, renderer }) if (!content) return @@ -432,7 +436,7 @@ export function Prompt(props: PromptProps) { title: "Stash prompt", value: "prompt.stash", category: "Prompt", - disabled: !store.prompt.input, + enabled: !!store.prompt.input, onSelect: (dialog) => { if (!store.prompt.input) return stash.push({ @@ -450,7 +454,7 @@ export function Prompt(props: PromptProps) { title: "Stash pop", value: "prompt.stash.pop", category: "Prompt", - disabled: stash.list().length === 0, + enabled: stash.list().length > 0, onSelect: (dialog) => { const entry = stash.pop() if (entry) { @@ -466,7 +470,7 @@ export function Prompt(props: PromptProps) { title: "Stash list", value: "prompt.stash.list", category: "Prompt", - disabled: stash.list().length === 0, + enabled: stash.list().length > 0, onSelect: (dialog) => { dialog.replace(() => ( - - {keybind.print("variant_cycle")} variants - + 0}> + + {keybind.print("variant_cycle")} variants + + {keybind.print("agent_cycle")} agents diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 63f1d9743bf..d058ce54fb3 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -113,8 +113,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) const file = Bun.file(path.join(Global.Path.state, "model.json")) + const state = { + pending: false, + } function save() { + if (!modelStore.ready) { + state.pending = true + return + } + state.pending = false Bun.write( file, JSON.stringify({ @@ -135,6 +143,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ .catch(() => {}) .finally(() => { setModelStore("ready", true) + if (state.pending) save() }) const args = useArgs() diff --git a/packages/opencode/src/cli/cmd/tui/event.ts b/packages/opencode/src/cli/cmd/tui/event.ts index 7c75523c136..9466ae54f2d 100644 --- a/packages/opencode/src/cli/cmd/tui/event.ts +++ b/packages/opencode/src/cli/cmd/tui/event.ts @@ -16,6 +16,8 @@ export const TuiEvent = { "session.compact", "session.page.up", "session.page.down", + "session.line.up", + "session.line.down", "session.half.page.up", "session.half.page.down", "session.first", diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx index 1d64a2ff156..1294ab849e9 100644 --- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx +++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx @@ -39,7 +39,7 @@ import { TodoWriteTool } from "@/tool/todo" import type { GrepTool } from "@/tool/grep" import type { ListTool } from "@/tool/ls" import type { EditTool } from "@/tool/edit" -import type { PatchTool } from "@/tool/patch" +import type { ApplyPatchTool } from "@/tool/apply_patch" import type { WebFetchTool } from "@/tool/webfetch" import type { TaskTool } from "@/tool/task" import type { QuestionTool } from "@/tool/question" @@ -295,37 +295,39 @@ export function Session() { const command = useCommandDialog() command.register(() => [ - ...(sync.data.config.share !== "disabled" - ? [ - { - title: "Share session", - value: "session.share", - suggested: route.type === "session", - keybind: "session_share" as const, - disabled: !!session()?.share?.url, - category: "Session", - onSelect: async (dialog: any) => { - await sdk.client.session - .share({ - sessionID: route.sessionID, - }) - .then((res) => - Clipboard.copy(res.data!.share!.url).catch(() => - toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }), - ), - ) - .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) - .catch(() => toast.show({ message: "Failed to share session", variant: "error" })) - dialog.clear() - }, - }, - ] - : []), + { + title: "Share session", + value: "session.share", + suggested: route.type === "session", + keybind: "session_share", + category: "Session", + enabled: sync.data.config.share !== "disabled" && !session()?.share?.url, + slash: { + name: "share", + }, + onSelect: async (dialog) => { + await sdk.client.session + .share({ + sessionID: route.sessionID, + }) + .then((res) => + Clipboard.copy(res.data!.share!.url).catch(() => + toast.show({ message: "Failed to copy URL to clipboard", variant: "error" }), + ), + ) + .then(() => toast.show({ message: "Share URL copied to clipboard!", variant: "success" })) + .catch(() => toast.show({ message: "Failed to share session", variant: "error" })) + dialog.clear() + }, + }, { title: "Rename session", value: "session.rename", keybind: "session_rename", category: "Session", + slash: { + name: "rename", + }, onSelect: (dialog) => { dialog.replace(() => ) }, @@ -335,6 +337,9 @@ export function Session() { value: "session.timeline", keybind: "session_timeline", category: "Session", + slash: { + name: "timeline", + }, onSelect: (dialog) => { dialog.replace(() => ( { dialog.replace(() => ( { const selectedModel = local.model.current() if (!selectedModel) { @@ -396,8 +408,11 @@ export function Session() { title: "Unshare session", value: "session.unshare", keybind: "session_unshare", - disabled: !session()?.share?.url, category: "Session", + enabled: !!session()?.share?.url, + slash: { + name: "unshare", + }, onSelect: async (dialog) => { await sdk.client.session .unshare({ @@ -413,6 +428,9 @@ export function Session() { value: "session.undo", keybind: "messages_undo", category: "Session", + slash: { + name: "undo", + }, onSelect: async (dialog) => { const status = sync.data.session_status?.[route.sessionID] if (status?.type !== "idle") await sdk.client.session.abort({ sessionID: route.sessionID }).catch(() => {}) @@ -447,8 +465,11 @@ export function Session() { title: "Redo", value: "session.redo", keybind: "messages_redo", - disabled: !session()?.revert?.messageID, category: "Session", + enabled: !!session()?.revert?.messageID, + slash: { + name: "redo", + }, onSelect: (dialog) => { dialog.clear() const messageID = session()?.revert?.messageID @@ -495,6 +516,10 @@ export function Session() { title: showTimestamps() ? "Hide timestamps" : "Show timestamps", value: "session.toggle.timestamps", category: "Session", + slash: { + name: "timestamps", + aliases: ["toggle-timestamps"], + }, onSelect: (dialog) => { setTimestamps((prev) => (prev === "show" ? "hide" : "show")) dialog.clear() @@ -504,6 +529,10 @@ export function Session() { title: showThinking() ? "Hide thinking" : "Show thinking", value: "session.toggle.thinking", category: "Session", + slash: { + name: "thinking", + aliases: ["toggle-thinking"], + }, onSelect: (dialog) => { setShowThinking((prev) => !prev) dialog.clear() @@ -513,6 +542,9 @@ export function Session() { title: "Toggle diff wrapping", value: "session.toggle.diffwrap", category: "Session", + slash: { + name: "diffwrap", + }, onSelect: (dialog) => { setDiffWrapMode((prev) => (prev === "word" ? "none" : "word")) dialog.clear() @@ -552,7 +584,7 @@ export function Session() { value: "session.page.up", keybind: "messages_page_up", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 2) dialog.clear() @@ -563,18 +595,40 @@ export function Session() { value: "session.page.down", keybind: "messages_page_down", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 2) dialog.clear() }, }, + { + title: "Line up", + value: "session.line.up", + keybind: "messages_line_up", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(-1) + dialog.clear() + }, + }, + { + title: "Line down", + value: "session.line.down", + keybind: "messages_line_down", + category: "Session", + disabled: true, + onSelect: (dialog) => { + scroll.scrollBy(1) + dialog.clear() + }, + }, { title: "Half page up", value: "session.half.page.up", keybind: "messages_half_page_up", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(-scroll.height / 4) dialog.clear() @@ -585,7 +639,7 @@ export function Session() { value: "session.half.page.down", keybind: "messages_half_page_down", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollBy(scroll.height / 4) dialog.clear() @@ -596,7 +650,7 @@ export function Session() { value: "session.first", keybind: "messages_first", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollTo(0) dialog.clear() @@ -607,7 +661,7 @@ export function Session() { value: "session.last", keybind: "messages_last", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { scroll.scrollTo(scroll.scrollHeight) dialog.clear() @@ -618,6 +672,7 @@ export function Session() { value: "session.messages_last_user", keybind: "messages_last_user", category: "Session", + hidden: true, onSelect: () => { const messages = sync.data.message[route.sessionID] if (!messages || !messages.length) return @@ -649,7 +704,7 @@ export function Session() { value: "session.message.next", keybind: "messages_next", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => scrollToMessage("next", dialog), }, { @@ -657,7 +712,7 @@ export function Session() { value: "session.message.previous", keybind: "messages_previous", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => scrollToMessage("prev", dialog), }, { @@ -706,8 +761,10 @@ export function Session() { { title: "Copy session transcript", value: "session.copy", - keybind: "session_copy", category: "Session", + slash: { + name: "copy", + }, onSelect: async (dialog) => { try { const sessionData = session() @@ -735,6 +792,9 @@ export function Session() { value: "session.export", keybind: "session_export", category: "Session", + slash: { + name: "export", + }, onSelect: async (dialog) => { try { const sessionData = session() @@ -793,7 +853,7 @@ export function Session() { value: "session.child.next", keybind: "session_child_cycle", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { moveChild(1) dialog.clear() @@ -804,7 +864,7 @@ export function Session() { value: "session.child.previous", keybind: "session_child_cycle_reverse", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { moveChild(-1) dialog.clear() @@ -815,7 +875,7 @@ export function Session() { value: "session.parent", keybind: "session_parent", category: "Session", - disabled: true, + hidden: true, onSelect: (dialog) => { const parentID = session()?.parentID if (parentID) { @@ -1385,8 +1445,8 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess - - + + @@ -1835,20 +1895,74 @@ function Edit(props: ToolProps) { ) } -function Patch(props: ToolProps) { - const { theme } = useTheme() +function ApplyPatch(props: ToolProps) { + const ctx = use() + const { theme, syntax } = useTheme() + + const files = createMemo(() => props.metadata.files ?? []) + + const view = createMemo(() => { + const diffStyle = ctx.sync.data.config.tui?.diff_style + if (diffStyle === "stacked") return "unified" + return ctx.width > 120 ? "split" : "unified" + }) + + function Diff(p: { diff: string; filePath: string }) { + return ( + + + + ) + } + + function title(file: { type: string; relativePath: string; filePath: string; deletions: number }) { + if (file.type === "delete") return "# Deleted " + file.relativePath + if (file.type === "add") return "# Created " + file.relativePath + if (file.type === "move") return "# Moved " + normalizePath(file.filePath) + " → " + file.relativePath + return "← Patched " + file.relativePath + } + return ( - - - - {props.output?.trim()} - - + 0}> + + {(file) => ( + + + -{file.deletions} line{file.deletions !== 1 ? "s" : ""} + + } + > + + + + )} + - - Patch + + apply_patch diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index f1cdaaa5292..5c37a493dfa 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -38,7 +38,7 @@ export interface DialogSelectOption { disabled?: boolean bg?: RGBA gutter?: JSX.Element - onSelect?: (ctx: DialogContext, trigger?: "prompt") => void + onSelect?: (ctx: DialogContext) => void } export type DialogSelectRef = { diff --git a/packages/opencode/src/cli/cmd/uninstall.ts b/packages/opencode/src/cli/cmd/uninstall.ts index 62210d57586..704d3572bbb 100644 --- a/packages/opencode/src/cli/cmd/uninstall.ts +++ b/packages/opencode/src/cli/cmd/uninstall.ts @@ -133,6 +133,8 @@ async function showRemovalSummary(targets: RemovalTargets, method: Installation. bun: "bun remove -g opencode-ai", yarn: "yarn global remove opencode-ai", brew: "brew uninstall opencode", + choco: "choco uninstall opencode", + scoop: "scoop uninstall opencode", } prompts.log.info(` ✓ Package: ${cmds[method] || method}`) } @@ -182,16 +184,27 @@ async function executeUninstall(method: Installation.Method, targets: RemovalTar bun: ["bun", "remove", "-g", "opencode-ai"], yarn: ["yarn", "global", "remove", "opencode-ai"], brew: ["brew", "uninstall", "opencode"], + choco: ["choco", "uninstall", "opencode"], + scoop: ["scoop", "uninstall", "opencode"], } const cmd = cmds[method] if (cmd) { spinner.start(`Running ${cmd.join(" ")}...`) - const result = await $`${cmd}`.quiet().nothrow() + const result = + method === "choco" + ? await $`echo Y | choco uninstall opencode -y -r`.quiet().nothrow() + : await $`${cmd}`.quiet().nothrow() if (result.exitCode !== 0) { - spinner.stop(`Package manager uninstall failed`, 1) - prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`) - errors.push(`Package manager: exit code ${result.exitCode}`) + spinner.stop(`Package manager uninstall failed: exit code ${result.exitCode}`, 1) + if ( + method === "choco" && + result.stdout.toString("utf8").includes("not running from an elevated command shell") + ) { + prompts.log.warn(`You may need to run '${cmd.join(" ")}' from an elevated command shell`) + } else { + prompts.log.warn(`You may need to run manually: ${cmd.join(" ")}`) + } } else { spinner.stop("Package removed") } diff --git a/packages/opencode/src/cli/cmd/web.ts b/packages/opencode/src/cli/cmd/web.ts index 2c207ecc2f2..5fa2bb42640 100644 --- a/packages/opencode/src/cli/cmd/web.ts +++ b/packages/opencode/src/cli/cmd/web.ts @@ -60,7 +60,11 @@ export const WebCommand = cmd({ } if (opts.mdns) { - UI.println(UI.Style.TEXT_INFO_BOLD + " mDNS: ", UI.Style.TEXT_NORMAL, "opencode.local") + UI.println( + UI.Style.TEXT_INFO_BOLD + " mDNS: ", + UI.Style.TEXT_NORMAL, + `opencode.local:${server.port}`, + ) } // Open localhost in browser diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 1574c644d32..ddb3af4b0a8 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -651,8 +651,14 @@ export namespace Config { session_unshare: z.string().optional().default("none").describe("Unshare current session"), session_interrupt: z.string().optional().default("escape").describe("Interrupt current session"), session_compact: z.string().optional().default("c").describe("Compact the session"), - messages_page_up: z.string().optional().default("pageup").describe("Scroll messages up by one page"), - messages_page_down: z.string().optional().default("pagedown").describe("Scroll messages down by one page"), + messages_page_up: z.string().optional().default("pageup,ctrl+alt+b").describe("Scroll messages up by one page"), + messages_page_down: z + .string() + .optional() + .default("pagedown,ctrl+alt+f") + .describe("Scroll messages down by one page"), + messages_line_up: z.string().optional().default("ctrl+alt+y").describe("Scroll messages up by one line"), + messages_line_down: z.string().optional().default("ctrl+alt+e").describe("Scroll messages down by one line"), messages_half_page_up: z.string().optional().default("ctrl+alt+u").describe("Scroll messages up by half page"), messages_half_page_down: z .string() @@ -1115,6 +1121,7 @@ export namespace Config { } async function load(text: string, configFilepath: string) { + const original = text text = text.replace(/\{env:([^}]+)\}/g, (_, varName) => { return process.env[varName] || "" }) @@ -1184,7 +1191,9 @@ export namespace Config { if (parsed.success) { if (!parsed.data.$schema) { parsed.data.$schema = "https://opencode.ai/config.json" - await Bun.write(configFilepath, JSON.stringify(parsed.data, null, 2)).catch(() => {}) + // Write the $schema to the original text to preserve variables like {env:VAR} + const updated = original.replace(/^\s*\{/, '{\n "$schema": "https://opencode.ai/config.json",') + await Bun.write(configFilepath, updated).catch(() => {}) } const data = parsed.data if (data.plugin) { diff --git a/packages/opencode/src/lsp/server.ts b/packages/opencode/src/lsp/server.ts index 24da77edcfe..e7efd99dcbd 100644 --- a/packages/opencode/src/lsp/server.ts +++ b/packages/opencode/src/lsp/server.ts @@ -1157,10 +1157,24 @@ export namespace LSPServer { await fs.mkdir(distPath, { recursive: true }) const releaseURL = "https://www.eclipse.org/downloads/download.php?file=/jdtls/snapshots/jdt-language-server-latest.tar.gz" - const archivePath = path.join(distPath, "release.tar.gz") - await $`curl -L -o '${archivePath}' '${releaseURL}'`.quiet().nothrow() - await $`tar -xzf ${archivePath}`.cwd(distPath).quiet().nothrow() - await fs.rm(archivePath, { force: true }) + const archiveName = "release.tar.gz" + + log.info("Downloading JDTLS archive", { url: releaseURL, dest: distPath }) + const curlResult = await $`curl -L -o ${archiveName} '${releaseURL}'`.cwd(distPath).quiet().nothrow() + if (curlResult.exitCode !== 0) { + log.error("Failed to download JDTLS", { exitCode: curlResult.exitCode, stderr: curlResult.stderr.toString() }) + return + } + + log.info("Extracting JDTLS archive") + const tarResult = await $`tar -xzf ${archiveName}`.cwd(distPath).quiet().nothrow() + if (tarResult.exitCode !== 0) { + log.error("Failed to extract JDTLS", { exitCode: tarResult.exitCode, stderr: tarResult.stderr.toString() }) + return + } + + await fs.rm(path.join(distPath, archiveName), { force: true }) + log.info("JDTLS download and extraction completed") } const jarFileName = await $`ls org.eclipse.equinox.launcher_*.jar` .cwd(launcherDir) diff --git a/packages/opencode/src/patch/index.ts b/packages/opencode/src/patch/index.ts index 91d52065f6f..0efeff544f6 100644 --- a/packages/opencode/src/patch/index.ts +++ b/packages/opencode/src/patch/index.ts @@ -1,6 +1,7 @@ import z from "zod" import * as path from "path" import * as fs from "fs/promises" +import { readFileSync } from "fs" import { Log } from "../util/log" export namespace Patch { @@ -177,8 +178,18 @@ export namespace Patch { return { content, nextIdx: i } } + function stripHeredoc(input: string): string { + // Match heredoc patterns like: cat <<'EOF'\n...\nEOF or < 0 && pattern[pattern.length - 1] === "") { @@ -371,7 +382,7 @@ export namespace Patch { if (newSlice.length > 0 && newSlice[newSlice.length - 1] === "") { newSlice = newSlice.slice(0, -1) } - found = seekSequence(originalLines, pattern, lineIndex) + found = seekSequence(originalLines, pattern, lineIndex, chunk.is_end_of_file) } if (found !== -1) { @@ -407,28 +418,75 @@ export namespace Patch { return result } - function seekSequence(lines: string[], pattern: string[], startIndex: number): number { - if (pattern.length === 0) return -1 + // Normalize Unicode punctuation to ASCII equivalents (like Rust's normalize_unicode) + function normalizeUnicode(str: string): string { + return str + .replace(/[\u2018\u2019\u201A\u201B]/g, "'") // single quotes + .replace(/[\u201C\u201D\u201E\u201F]/g, '"') // double quotes + .replace(/[\u2010\u2011\u2012\u2013\u2014\u2015]/g, "-") // dashes + .replace(/\u2026/g, "...") // ellipsis + .replace(/\u00A0/g, " ") // non-breaking space + } + + type Comparator = (a: string, b: string) => boolean + + function tryMatch(lines: string[], pattern: string[], startIndex: number, compare: Comparator, eof: boolean): number { + // If EOF anchor, try matching from end of file first + if (eof) { + const fromEnd = lines.length - pattern.length + if (fromEnd >= startIndex) { + let matches = true + for (let j = 0; j < pattern.length; j++) { + if (!compare(lines[fromEnd + j], pattern[j])) { + matches = false + break + } + } + if (matches) return fromEnd + } + } - // Simple substring search implementation + // Forward search from startIndex for (let i = startIndex; i <= lines.length - pattern.length; i++) { let matches = true - for (let j = 0; j < pattern.length; j++) { - if (lines[i + j] !== pattern[j]) { + if (!compare(lines[i + j], pattern[j])) { matches = false break } } - - if (matches) { - return i - } + if (matches) return i } return -1 } + function seekSequence(lines: string[], pattern: string[], startIndex: number, eof = false): number { + if (pattern.length === 0) return -1 + + // Pass 1: exact match + const exact = tryMatch(lines, pattern, startIndex, (a, b) => a === b, eof) + if (exact !== -1) return exact + + // Pass 2: rstrip (trim trailing whitespace) + const rstrip = tryMatch(lines, pattern, startIndex, (a, b) => a.trimEnd() === b.trimEnd(), eof) + if (rstrip !== -1) return rstrip + + // Pass 3: trim (both ends) + const trim = tryMatch(lines, pattern, startIndex, (a, b) => a.trim() === b.trim(), eof) + if (trim !== -1) return trim + + // Pass 4: normalized (Unicode punctuation to ASCII) + const normalized = tryMatch( + lines, + pattern, + startIndex, + (a, b) => normalizeUnicode(a.trim()) === normalizeUnicode(b.trim()), + eof, + ) + return normalized + } + function generateUnifiedDiff(oldContent: string, newContent: string): string { const oldLines = oldContent.split("\n") const newLines = newContent.split("\n") diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index d18098a9c4f..41029ecbbdb 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -10,7 +10,7 @@ export namespace Question { export const Option = z .object({ - label: z.string().describe("Display text (1-5 words, concise)"), + label: z.string().max(30).describe("Display text (1-5 words, concise)"), description: z.string().describe("Explanation of choice"), }) .meta({ @@ -21,7 +21,7 @@ export namespace Question { export const Info = z .object({ question: z.string().describe("Complete question"), - header: z.string().max(12).describe("Very short label (max 12 chars)"), + header: z.string().max(30).describe("Very short label (max 30 chars)"), options: z.array(Option).describe("Available choices"), multiple: z.boolean().optional().describe("Allow selecting multiple choices"), custom: z.boolean().optional().describe("Allow typing a custom answer (default: true)"), diff --git a/packages/opencode/src/server/mdns.ts b/packages/opencode/src/server/mdns.ts index 8bddb910503..953269de444 100644 --- a/packages/opencode/src/server/mdns.ts +++ b/packages/opencode/src/server/mdns.ts @@ -7,15 +7,17 @@ export namespace MDNS { let bonjour: Bonjour | undefined let currentPort: number | undefined - export function publish(port: number, name = "opencode") { + export function publish(port: number) { if (currentPort === port) return if (bonjour) unpublish() try { + const name = `opencode-${port}` bonjour = new Bonjour() const service = bonjour.publish({ name, type: "http", + host: "opencode.local", port, txt: { path: "/" }, }) diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index c6b1d42e8e5..0fb2a5e9d2e 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -74,8 +74,8 @@ export const ExperimentalRoutes = lazy(() => }), ), async (c) => { - const { provider } = c.req.valid("query") - const tools = await ToolRegistry.tools(provider) + const { provider, model } = c.req.valid("query") + const tools = await ToolRegistry.tools({ providerID: provider, modelID: model }) return c.json( tools.map((t) => ({ id: t.id, diff --git a/packages/opencode/src/server/routes/tui.ts b/packages/opencode/src/server/routes/tui.ts index 0577429dd74..8650a0cccf7 100644 --- a/packages/opencode/src/server/routes/tui.ts +++ b/packages/opencode/src/server/routes/tui.ts @@ -275,6 +275,8 @@ export const TuiRoutes = lazy(() => session_compact: "session.compact", messages_page_up: "session.page.up", messages_page_down: "session.page.down", + messages_line_up: "session.line.up", + messages_line_down: "session.line.down", messages_half_page_up: "session.half.page.up", messages_half_page_down: "session.half.page.down", messages_first: "session.first", diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index f0c64b49f81..28dec7f4043 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -562,7 +562,7 @@ export namespace Server { opts.hostname !== "localhost" && opts.hostname !== "::1" if (shouldPublishMDNS) { - MDNS.publish(server.port!, `opencode-${server.port!}`) + MDNS.publish(server.port!) } else if (opts.mdns) { log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish") } diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 8327698fd5f..0d3d25feb8d 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -685,7 +685,10 @@ export namespace SessionPrompt { }, }) - for (const item of await ToolRegistry.tools(input.model.providerID, input.agent)) { + for (const item of await ToolRegistry.tools( + { modelID: input.model.api.id, providerID: input.model.providerID }, + input.agent, + )) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) tools[item.id] = tool({ id: item.id as any, diff --git a/packages/opencode/src/session/prompt/codex.txt b/packages/opencode/src/session/prompt/codex.txt index d26e2e01aa7..daad8237758 100644 --- a/packages/opencode/src/session/prompt/codex.txt +++ b/packages/opencode/src/session/prompt/codex.txt @@ -5,6 +5,7 @@ You are an interactive CLI tool that helps users with software engineering tasks ## Editing constraints - Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them. - Only add comments if they are necessary to make a non-obvious block easier to understand. +- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). ## Tool usage - Prefer specialized tools over shell for file operations: diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts new file mode 100644 index 00000000000..d070eaefa97 --- /dev/null +++ b/packages/opencode/src/tool/apply_patch.ts @@ -0,0 +1,277 @@ +import z from "zod" +import * as path from "path" +import * as fs from "fs/promises" +import { Tool } from "./tool" +import { FileTime } from "../file/time" +import { Bus } from "../bus" +import { FileWatcher } from "../file/watcher" +import { Instance } from "../project/instance" +import { Patch } from "../patch" +import { createTwoFilesPatch, diffLines } from "diff" +import { assertExternalDirectory } from "./external-directory" +import { trimDiff } from "./edit" +import { LSP } from "../lsp" +import { Filesystem } from "../util/filesystem" + +const PatchParams = z.object({ + patchText: z.string().describe("The full patch text that describes all changes to be made"), +}) + +export const ApplyPatchTool = Tool.define("apply_patch", { + description: "Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON.", + parameters: PatchParams, + async execute(params, ctx) { + if (!params.patchText) { + throw new Error("patchText is required") + } + + // Parse the patch to get hunks + let hunks: Patch.Hunk[] + try { + const parseResult = Patch.parsePatch(params.patchText) + hunks = parseResult.hunks + } catch (error) { + throw new Error(`apply_patch verification failed: ${error}`) + } + + if (hunks.length === 0) { + const normalized = params.patchText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").trim() + if (normalized === "*** Begin Patch\n*** End Patch") { + throw new Error("patch rejected: empty patch") + } + throw new Error("apply_patch verification failed: no hunks found") + } + + // Validate file paths and check permissions + const fileChanges: Array<{ + filePath: string + oldContent: string + newContent: string + type: "add" | "update" | "delete" | "move" + movePath?: string + diff: string + additions: number + deletions: number + }> = [] + + let totalDiff = "" + + for (const hunk of hunks) { + const filePath = path.resolve(Instance.directory, hunk.path) + await assertExternalDirectory(ctx, filePath) + + switch (hunk.type) { + case "add": { + const oldContent = "" + const newContent = + hunk.contents.length === 0 || hunk.contents.endsWith("\n") ? hunk.contents : `${hunk.contents}\n` + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, newContent)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + fileChanges.push({ + filePath, + oldContent, + newContent, + type: "add", + diff, + additions, + deletions, + }) + + totalDiff += diff + "\n" + break + } + + case "update": { + // Check if file exists for update + const stats = await fs.stat(filePath).catch(() => null) + if (!stats || stats.isDirectory()) { + throw new Error(`apply_patch verification failed: Failed to read file to update: ${filePath}`) + } + + // Read file and update time tracking (like edit tool does) + await FileTime.assert(ctx.sessionID, filePath) + const oldContent = await fs.readFile(filePath, "utf-8") + let newContent = oldContent + + // Apply the update chunks to get new content + try { + const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) + newContent = fileUpdate.content + } catch (error) { + throw new Error(`apply_patch verification failed: ${error}`) + } + + const diff = trimDiff(createTwoFilesPatch(filePath, filePath, oldContent, newContent)) + + let additions = 0 + let deletions = 0 + for (const change of diffLines(oldContent, newContent)) { + if (change.added) additions += change.count || 0 + if (change.removed) deletions += change.count || 0 + } + + const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined + await assertExternalDirectory(ctx, movePath) + + fileChanges.push({ + filePath, + oldContent, + newContent, + type: hunk.move_path ? "move" : "update", + movePath, + diff, + additions, + deletions, + }) + + totalDiff += diff + "\n" + break + } + + case "delete": { + const contentToDelete = await fs.readFile(filePath, "utf-8").catch((error) => { + throw new Error(`apply_patch verification failed: ${error}`) + }) + const deleteDiff = trimDiff(createTwoFilesPatch(filePath, filePath, contentToDelete, "")) + + const deletions = contentToDelete.split("\n").length + + fileChanges.push({ + filePath, + oldContent: contentToDelete, + newContent: "", + type: "delete", + diff: deleteDiff, + additions: 0, + deletions, + }) + + totalDiff += deleteDiff + "\n" + break + } + } + } + + // Check permissions if needed + await ctx.ask({ + permission: "edit", + patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)), + always: ["*"], + metadata: { + diff: totalDiff, + }, + }) + + // Apply the changes + const changedFiles: string[] = [] + + for (const change of fileChanges) { + switch (change.type) { + case "add": + // Create parent directories (recursive: true is safe on existing/root dirs) + await fs.mkdir(path.dirname(change.filePath), { recursive: true }) + await fs.writeFile(change.filePath, change.newContent, "utf-8") + changedFiles.push(change.filePath) + break + + case "update": + await fs.writeFile(change.filePath, change.newContent, "utf-8") + changedFiles.push(change.filePath) + break + + case "move": + if (change.movePath) { + // Create parent directories (recursive: true is safe on existing/root dirs) + await fs.mkdir(path.dirname(change.movePath), { recursive: true }) + await fs.writeFile(change.movePath, change.newContent, "utf-8") + await fs.unlink(change.filePath) + changedFiles.push(change.movePath) + } + break + + case "delete": + await fs.unlink(change.filePath) + changedFiles.push(change.filePath) + break + } + + // Update file time tracking + FileTime.read(ctx.sessionID, change.filePath) + if (change.movePath) { + FileTime.read(ctx.sessionID, change.movePath) + } + } + + // Publish file change events + for (const filePath of changedFiles) { + await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" }) + } + + // Notify LSP of file changes and collect diagnostics + for (const change of fileChanges) { + if (change.type === "delete") continue + const target = change.movePath ?? change.filePath + await LSP.touchFile(target, true) + } + const diagnostics = await LSP.diagnostics() + + // Generate output summary + const summaryLines = fileChanges.map((change) => { + if (change.type === "add") { + return `A ${path.relative(Instance.worktree, change.filePath)}` + } + if (change.type === "delete") { + return `D ${path.relative(Instance.worktree, change.filePath)}` + } + const target = change.movePath ?? change.filePath + return `M ${path.relative(Instance.worktree, target)}` + }) + let output = `Success. Updated the following files:\n${summaryLines.join("\n")}` + + // Report LSP errors for changed files + const MAX_DIAGNOSTICS_PER_FILE = 20 + for (const change of fileChanges) { + if (change.type === "delete") continue + const target = change.movePath ?? change.filePath + const normalized = Filesystem.normalizePath(target) + const issues = diagnostics[normalized] ?? [] + const errors = issues.filter((item) => item.severity === 1) + if (errors.length > 0) { + const limited = errors.slice(0, MAX_DIAGNOSTICS_PER_FILE) + const suffix = + errors.length > MAX_DIAGNOSTICS_PER_FILE ? `\n... and ${errors.length - MAX_DIAGNOSTICS_PER_FILE} more` : "" + output += `\n\nLSP errors detected in ${path.relative(Instance.worktree, target)}, please fix:\n\n${limited.map(LSP.Diagnostic.pretty).join("\n")}${suffix}\n` + } + } + + // Build per-file metadata for UI rendering + const files = fileChanges.map((change) => ({ + filePath: change.filePath, + relativePath: path.relative(Instance.worktree, change.movePath ?? change.filePath), + type: change.type, + diff: change.diff, + before: change.oldContent, + after: change.newContent, + additions: change.additions, + deletions: change.deletions, + movePath: change.movePath, + })) + + return { + title: output, + metadata: { + diff: totalDiff, + files, + diagnostics, + }, + output, + } + }, +}) diff --git a/packages/opencode/src/tool/apply_patch.txt b/packages/opencode/src/tool/apply_patch.txt new file mode 100644 index 00000000000..1af0606109f --- /dev/null +++ b/packages/opencode/src/tool/apply_patch.txt @@ -0,0 +1 @@ +Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON. diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index ba1b94a3e60..8bffbd54a28 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -37,7 +37,7 @@ export const BatchTool = Tool.define("batch", async () => { const discardedCalls = params.tool_calls.slice(10) const { ToolRegistry } = await import("./registry") - const availableTools = await ToolRegistry.tools("") + const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" }) const toolMap = new Map(availableTools.map((t) => [t.id, t])) const executeCall = async (call: (typeof toolCalls)[0]) => { diff --git a/packages/opencode/src/tool/patch.ts b/packages/opencode/src/tool/patch.ts deleted file mode 100644 index 08a58bfea9c..00000000000 --- a/packages/opencode/src/tool/patch.ts +++ /dev/null @@ -1,201 +0,0 @@ -import z from "zod" -import * as path from "path" -import * as fs from "fs/promises" -import { Tool } from "./tool" -import { FileTime } from "../file/time" -import { Bus } from "../bus" -import { FileWatcher } from "../file/watcher" -import { Instance } from "../project/instance" -import { Patch } from "../patch" -import { createTwoFilesPatch } from "diff" -import { assertExternalDirectory } from "./external-directory" - -const PatchParams = z.object({ - patchText: z.string().describe("The full patch text that describes all changes to be made"), -}) - -export const PatchTool = Tool.define("patch", { - description: - "Apply a patch to modify multiple files. Supports adding, updating, and deleting files with context-aware changes.", - parameters: PatchParams, - async execute(params, ctx) { - if (!params.patchText) { - throw new Error("patchText is required") - } - - // Parse the patch to get hunks - let hunks: Patch.Hunk[] - try { - const parseResult = Patch.parsePatch(params.patchText) - hunks = parseResult.hunks - } catch (error) { - throw new Error(`Failed to parse patch: ${error}`) - } - - if (hunks.length === 0) { - throw new Error("No file changes found in patch") - } - - // Validate file paths and check permissions - const fileChanges: Array<{ - filePath: string - oldContent: string - newContent: string - type: "add" | "update" | "delete" | "move" - movePath?: string - }> = [] - - let totalDiff = "" - - for (const hunk of hunks) { - const filePath = path.resolve(Instance.directory, hunk.path) - await assertExternalDirectory(ctx, filePath) - - switch (hunk.type) { - case "add": - if (hunk.type === "add") { - const oldContent = "" - const newContent = hunk.contents - const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) - - fileChanges.push({ - filePath, - oldContent, - newContent, - type: "add", - }) - - totalDiff += diff + "\n" - } - break - - case "update": - // Check if file exists for update - const stats = await fs.stat(filePath).catch(() => null) - if (!stats || stats.isDirectory()) { - throw new Error(`File not found or is directory: ${filePath}`) - } - - // Read file and update time tracking (like edit tool does) - await FileTime.assert(ctx.sessionID, filePath) - const oldContent = await fs.readFile(filePath, "utf-8") - let newContent = oldContent - - // Apply the update chunks to get new content - try { - const fileUpdate = Patch.deriveNewContentsFromChunks(filePath, hunk.chunks) - newContent = fileUpdate.content - } catch (error) { - throw new Error(`Failed to apply update to ${filePath}: ${error}`) - } - - const diff = createTwoFilesPatch(filePath, filePath, oldContent, newContent) - - const movePath = hunk.move_path ? path.resolve(Instance.directory, hunk.move_path) : undefined - await assertExternalDirectory(ctx, movePath) - - fileChanges.push({ - filePath, - oldContent, - newContent, - type: hunk.move_path ? "move" : "update", - movePath, - }) - - totalDiff += diff + "\n" - break - - case "delete": - // Check if file exists for deletion - await FileTime.assert(ctx.sessionID, filePath) - const contentToDelete = await fs.readFile(filePath, "utf-8") - const deleteDiff = createTwoFilesPatch(filePath, filePath, contentToDelete, "") - - fileChanges.push({ - filePath, - oldContent: contentToDelete, - newContent: "", - type: "delete", - }) - - totalDiff += deleteDiff + "\n" - break - } - } - - // Check permissions if needed - await ctx.ask({ - permission: "edit", - patterns: fileChanges.map((c) => path.relative(Instance.worktree, c.filePath)), - always: ["*"], - metadata: { - diff: totalDiff, - }, - }) - - // Apply the changes - const changedFiles: string[] = [] - - for (const change of fileChanges) { - switch (change.type) { - case "add": - // Create parent directories - const addDir = path.dirname(change.filePath) - if (addDir !== "." && addDir !== "/") { - await fs.mkdir(addDir, { recursive: true }) - } - await fs.writeFile(change.filePath, change.newContent, "utf-8") - changedFiles.push(change.filePath) - break - - case "update": - await fs.writeFile(change.filePath, change.newContent, "utf-8") - changedFiles.push(change.filePath) - break - - case "move": - if (change.movePath) { - // Create parent directories for destination - const moveDir = path.dirname(change.movePath) - if (moveDir !== "." && moveDir !== "/") { - await fs.mkdir(moveDir, { recursive: true }) - } - // Write to new location - await fs.writeFile(change.movePath, change.newContent, "utf-8") - // Remove original - await fs.unlink(change.filePath) - changedFiles.push(change.movePath) - } - break - - case "delete": - await fs.unlink(change.filePath) - changedFiles.push(change.filePath) - break - } - - // Update file time tracking - FileTime.read(ctx.sessionID, change.filePath) - if (change.movePath) { - FileTime.read(ctx.sessionID, change.movePath) - } - } - - // Publish file change events - for (const filePath of changedFiles) { - await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" }) - } - - // Generate output summary - const relativePaths = changedFiles.map((filePath) => path.relative(Instance.worktree, filePath)) - const summary = `${fileChanges.length} files changed` - - return { - title: summary, - metadata: { - diff: totalDiff, - }, - output: `Patch applied successfully. ${summary}:\n${relativePaths.map((p) => ` ${p}`).join("\n")}`, - } - }, -}) diff --git a/packages/opencode/src/tool/patch.txt b/packages/opencode/src/tool/patch.txt deleted file mode 100644 index 88a50f6347a..00000000000 --- a/packages/opencode/src/tool/patch.txt +++ /dev/null @@ -1 +0,0 @@ -do not use diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index 35e378f080b..faa5f72bcce 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -26,6 +26,7 @@ import { Log } from "@/util/log" import { LspTool } from "./lsp" import { Truncate } from "./truncation" import { PlanExitTool, PlanEnterTool } from "./plan" +import { ApplyPatchTool } from "./apply_patch" export namespace ToolRegistry { const log = Log.create({ service: "tool.registry" }) @@ -108,6 +109,7 @@ export namespace ToolRegistry { WebSearchTool, CodeSearchTool, SkillTool, + ApplyPatchTool, ...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [LspTool] : []), ...(config.experimental?.batch_tool === true ? [BatchTool] : []), ...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [PlanExitTool, PlanEnterTool] : []), @@ -119,15 +121,28 @@ export namespace ToolRegistry { return all().then((x) => x.map((t) => t.id)) } - export async function tools(providerID: string, agent?: Agent.Info) { + export async function tools( + model: { + providerID: string + modelID: string + }, + agent?: Agent.Info, + ) { const tools = await all() const result = await Promise.all( tools .filter((t) => { // Enable websearch/codesearch for zen users OR via enable flag if (t.id === "codesearch" || t.id === "websearch") { - return providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA + return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA } + + // use apply tool in same format as codex + const usePatch = + model.modelID.includes("gpt-") && !model.modelID.includes("oss") && !model.modelID.includes("gpt-4") + if (t.id === "apply_patch") return usePatch + if (t.id === "edit" || t.id === "write") return !usePatch + return true }) .map(async (t) => { diff --git a/packages/opencode/test/acp/event-subscription.test.ts b/packages/opencode/test/acp/event-subscription.test.ts new file mode 100644 index 00000000000..8e139ff5973 --- /dev/null +++ b/packages/opencode/test/acp/event-subscription.test.ts @@ -0,0 +1,436 @@ +import { describe, expect, test } from "bun:test" +import { ACP } from "../../src/acp/agent" +import type { AgentSideConnection } from "@agentclientprotocol/sdk" +import type { Event } from "@opencode-ai/sdk/v2" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +type SessionUpdateParams = Parameters[0] +type RequestPermissionParams = Parameters[0] +type RequestPermissionResult = Awaited> + +type GlobalEventEnvelope = { + directory?: string + payload?: Event +} + +type EventController = { + push: (event: GlobalEventEnvelope) => void + close: () => void +} + +function createEventStream() { + const queue: GlobalEventEnvelope[] = [] + const waiters: Array<(value: GlobalEventEnvelope | undefined) => void> = [] + const state = { closed: false } + + const push = (event: GlobalEventEnvelope) => { + const waiter = waiters.shift() + if (waiter) { + waiter(event) + return + } + queue.push(event) + } + + const close = () => { + state.closed = true + for (const waiter of waiters.splice(0)) { + waiter(undefined) + } + } + + const stream = async function* (signal?: AbortSignal) { + while (true) { + if (signal?.aborted) return + const next = queue.shift() + if (next) { + yield next + continue + } + if (state.closed) return + const value = await new Promise((resolve) => { + waiters.push(resolve) + if (!signal) return + signal.addEventListener("abort", () => resolve(undefined), { once: true }) + }) + if (!value) return + yield value + } + } + + return { controller: { push, close } satisfies EventController, stream } +} + +function createFakeAgent() { + const updates = new Map() + const chunks = new Map() + const record = (sessionId: string, type: string) => { + const list = updates.get(sessionId) ?? [] + list.push(type) + updates.set(sessionId, list) + } + + const connection = { + async sessionUpdate(params: SessionUpdateParams) { + const update = params.update + const type = update?.sessionUpdate ?? "unknown" + record(params.sessionId, type) + if (update?.sessionUpdate === "agent_message_chunk") { + const content = update.content + if (content?.type !== "text") return + if (typeof content.text !== "string") return + chunks.set(params.sessionId, (chunks.get(params.sessionId) ?? "") + content.text) + } + }, + async requestPermission(_params: RequestPermissionParams): Promise { + return { outcome: { outcome: "selected", optionId: "once" } } as RequestPermissionResult + }, + } as unknown as AgentSideConnection + + const { controller, stream } = createEventStream() + const calls = { + eventSubscribe: 0, + sessionCreate: 0, + } + + const sdk = { + global: { + event: async (opts?: { signal?: AbortSignal }) => { + calls.eventSubscribe++ + return { stream: stream(opts?.signal) } + }, + }, + session: { + create: async (_params?: any) => { + calls.sessionCreate++ + return { + data: { + id: `ses_${calls.sessionCreate}`, + time: { created: new Date().toISOString() }, + }, + } + }, + get: async (_params?: any) => { + return { + data: { + id: "ses_1", + time: { created: new Date().toISOString() }, + }, + } + }, + messages: async () => { + return { data: [] } + }, + message: async () => { + return { + data: { + info: { + role: "assistant", + }, + }, + } + }, + }, + permission: { + respond: async () => { + return { data: true } + }, + }, + config: { + providers: async () => { + return { + data: { + providers: [ + { + id: "opencode", + name: "opencode", + models: { + "big-pickle": { id: "big-pickle", name: "big-pickle" }, + }, + }, + ], + }, + } + }, + }, + app: { + agents: async () => { + return { + data: [ + { + name: "build", + description: "build", + mode: "agent", + }, + ], + } + }, + }, + command: { + list: async () => { + return { data: [] } + }, + }, + mcp: { + add: async () => { + return { data: true } + }, + }, + } as any + + const agent = new ACP.Agent(connection, { + sdk, + defaultModel: { providerID: "opencode", modelID: "big-pickle" }, + } as any) + + const stop = () => { + controller.close() + ;(agent as any).eventAbort.abort() + } + + return { agent, controller, calls, updates, chunks, stop, sdk, connection } +} + +describe("acp.agent event subscription", () => { + test("routes message.part.updated by the event sessionID (no cross-session pollution)", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, updates, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "message.part.updated", + properties: { + part: { + sessionID: sessionB, + messageID: "msg_1", + type: "text", + synthetic: false, + }, + delta: "hello", + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 10)) + + expect((updates.get(sessionA) ?? []).includes("agent_message_chunk")).toBe(false) + expect((updates.get(sessionB) ?? []).includes("agent_message_chunk")).toBe(true) + + stop() + }, + }) + }) + + test("keeps concurrent sessions isolated when message.part.updated events are interleaved", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, controller, chunks, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + const tokenA = ["ALPHA_", "111", "_X"] + const tokenB = ["BETA_", "222", "_Y"] + + const push = (sessionId: string, messageID: string, delta: string) => { + controller.push({ + directory: cwd, + payload: { + type: "message.part.updated", + properties: { + part: { + sessionID: sessionId, + messageID, + type: "text", + synthetic: false, + }, + delta, + }, + }, + } as any) + } + + push(sessionA, "msg_a", tokenA[0]) + push(sessionB, "msg_b", tokenB[0]) + push(sessionA, "msg_a", tokenA[1]) + push(sessionB, "msg_b", tokenB[1]) + push(sessionA, "msg_a", tokenA[2]) + push(sessionB, "msg_b", tokenB[2]) + + await new Promise((r) => setTimeout(r, 20)) + + const a = chunks.get(sessionA) ?? "" + const b = chunks.get(sessionB) ?? "" + + expect(a).toContain(tokenA.join("")) + expect(b).toContain(tokenB.join("")) + for (const part of tokenB) expect(a).not.toContain(part) + for (const part of tokenA) expect(b).not.toContain(part) + + stop() + }, + }) + }) + + test("does not create additional event subscriptions on repeated loadSession()", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const { agent, calls, stop } = createFakeAgent() + const cwd = "/tmp/opencode-acp-test" + + const sessionId = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + await agent.loadSession({ sessionId, cwd, mcpServers: [] } as any) + + expect(calls.eventSubscribe).toBe(1) + + stop() + }, + }) + }) + + test("permission.asked events are handled and replied", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const permissionReplies: string[] = [] + const { agent, controller, stop, sdk } = createFakeAgent() + sdk.permission.reply = async (params: any) => { + permissionReplies.push(params.requestID) + return { data: true } + } + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + controller.push({ + directory: cwd, + payload: { + type: "permission.asked", + properties: { + id: "perm_1", + sessionID: sessionA, + permission: "bash", + patterns: ["*"], + metadata: {}, + always: [], + }, + }, + } as any) + + await new Promise((r) => setTimeout(r, 20)) + + expect(permissionReplies).toContain("perm_1") + + stop() + }, + }) + }) + + test("permission prompt on session A does not block message updates for session B", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const permissionReplies: string[] = [] + let resolvePermissionA: (() => void) | undefined + const permissionABlocking = new Promise((r) => { + resolvePermissionA = r + }) + + const { agent, controller, chunks, stop, sdk, connection } = createFakeAgent() + + // Make permission request for session A block until we release it + const originalRequestPermission = connection.requestPermission.bind(connection) + let permissionCalls = 0 + connection.requestPermission = async (params: RequestPermissionParams) => { + permissionCalls++ + if (params.sessionId.endsWith("1")) { + await permissionABlocking + } + return originalRequestPermission(params) + } + + sdk.permission.reply = async (params: any) => { + permissionReplies.push(params.requestID) + return { data: true } + } + + const cwd = "/tmp/opencode-acp-test" + + const sessionA = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + const sessionB = await agent.newSession({ cwd, mcpServers: [] } as any).then((x) => x.sessionId) + + // Push permission.asked for session A (will block) + controller.push({ + directory: cwd, + payload: { + type: "permission.asked", + properties: { + id: "perm_a", + sessionID: sessionA, + permission: "bash", + patterns: ["*"], + metadata: {}, + always: [], + }, + }, + } as any) + + // Give time for permission handling to start + await new Promise((r) => setTimeout(r, 10)) + + // Push message for session B while A's permission is pending + controller.push({ + directory: cwd, + payload: { + type: "message.part.updated", + properties: { + part: { + sessionID: sessionB, + messageID: "msg_b", + type: "text", + synthetic: false, + }, + delta: "session_b_message", + }, + }, + } as any) + + // Wait for session B's message to be processed + await new Promise((r) => setTimeout(r, 20)) + + // Session B should have received message even though A's permission is still pending + expect(chunks.get(sessionB) ?? "").toContain("session_b_message") + expect(permissionReplies).not.toContain("perm_a") + + // Release session A's permission + resolvePermissionA!() + await new Promise((r) => setTimeout(r, 20)) + + // Now session A's permission should be replied + expect(permissionReplies).toContain("perm_a") + + stop() + }, + }) + }) +}) diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index 86cadca5d81..0463d29d7c5 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -127,6 +127,44 @@ test("handles environment variable substitution", async () => { } }) +test("preserves env variables when adding $schema to config", async () => { + const originalEnv = process.env["PRESERVE_VAR"] + process.env["PRESERVE_VAR"] = "secret_value" + + try { + await using tmp = await tmpdir({ + init: async (dir) => { + // Config without $schema - should trigger auto-add + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + theme: "{env:PRESERVE_VAR}", + }), + ) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.theme).toBe("secret_value") + + // Read the file to verify the env variable was preserved + const content = await Bun.file(path.join(tmp.path, "opencode.json")).text() + expect(content).toContain("{env:PRESERVE_VAR}") + expect(content).not.toContain("secret_value") + expect(content).toContain("$schema") + }, + }) + } finally { + if (originalEnv !== undefined) { + process.env["PRESERVE_VAR"] = originalEnv + } else { + delete process.env["PRESERVE_VAR"] + } + } +}) + test("handles file inclusion substitution", async () => { await using tmp = await tmpdir({ init: async (dir) => { diff --git a/packages/opencode/test/tool/apply_patch.test.ts b/packages/opencode/test/tool/apply_patch.test.ts new file mode 100644 index 00000000000..d8f05a9d911 --- /dev/null +++ b/packages/opencode/test/tool/apply_patch.test.ts @@ -0,0 +1,515 @@ +import { describe, expect, test } from "bun:test" +import path from "path" +import * as fs from "fs/promises" +import { ApplyPatchTool } from "../../src/tool/apply_patch" +import { Instance } from "../../src/project/instance" +import { FileTime } from "../../src/file/time" +import { tmpdir } from "../fixture/fixture" + +const baseCtx = { + sessionID: "test", + messageID: "", + callID: "", + agent: "build", + abort: AbortSignal.any([]), + metadata: () => {}, +} + +type AskInput = { + permission: string + patterns: string[] + always: string[] + metadata: { diff: string } +} + +type ToolCtx = typeof baseCtx & { + ask: (input: AskInput) => Promise +} + +const execute = async (params: { patchText: string }, ctx: ToolCtx) => { + const tool = await ApplyPatchTool.init() + return tool.execute(params, ctx) +} + +const makeCtx = () => { + const calls: AskInput[] = [] + const ctx: ToolCtx = { + ...baseCtx, + ask: async (input) => { + calls.push(input) + }, + } + + return { ctx, calls } +} + +describe("tool.apply_patch freeform", () => { + test("requires patchText", async () => { + const { ctx } = makeCtx() + await expect(execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required") + }) + + test("rejects invalid patch format", async () => { + const { ctx } = makeCtx() + await expect(execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("apply_patch verification failed") + }) + + test("rejects empty patch", async () => { + const { ctx } = makeCtx() + const emptyPatch = "*** Begin Patch\n*** End Patch" + await expect(execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("patch rejected: empty patch") + }) + + test("applies add/update/delete in one patch", async () => { + await using fixture = await tmpdir() + const { ctx, calls } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const modifyPath = path.join(fixture.path, "modify.txt") + const deletePath = path.join(fixture.path, "delete.txt") + await fs.writeFile(modifyPath, "line1\nline2\n", "utf-8") + await fs.writeFile(deletePath, "obsolete\n", "utf-8") + FileTime.read(ctx.sessionID, modifyPath) + FileTime.read(ctx.sessionID, deletePath) + + const patchText = + "*** Begin Patch\n*** Add File: nested/new.txt\n+created\n*** Delete File: delete.txt\n*** Update File: modify.txt\n@@\n-line2\n+changed\n*** End Patch" + + const result = await execute({ patchText }, ctx) + + expect(result.title).toContain("Success. Updated the following files") + expect(result.output).toContain("Success. Updated the following files") + expect(result.metadata.diff).toContain("Index:") + expect(calls.length).toBe(1) + + const added = await fs.readFile(path.join(fixture.path, "nested", "new.txt"), "utf-8") + expect(added).toBe("created\n") + expect(await fs.readFile(modifyPath, "utf-8")).toBe("line1\nchanged\n") + await expect(fs.readFile(deletePath, "utf-8")).rejects.toThrow() + }, + }) + }) + + test("applies multiple hunks to one file", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "multi.txt") + await fs.writeFile(target, "line1\nline2\nline3\nline4\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = + "*** Begin Patch\n*** Update File: multi.txt\n@@\n-line2\n+changed2\n@@\n-line4\n+changed4\n*** End Patch" + + await execute({ patchText }, ctx) + + expect(await fs.readFile(target, "utf-8")).toBe("line1\nchanged2\nline3\nchanged4\n") + }, + }) + }) + + test("inserts lines with insert-only hunk", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "insert_only.txt") + await fs.writeFile(target, "alpha\nomega\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: insert_only.txt\n@@\n alpha\n+beta\n omega\n*** End Patch" + + await execute({ patchText }, ctx) + + expect(await fs.readFile(target, "utf-8")).toBe("alpha\nbeta\nomega\n") + }, + }) + }) + + test("appends trailing newline on update", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "no_newline.txt") + await fs.writeFile(target, "no newline at end", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = + "*** Begin Patch\n*** Update File: no_newline.txt\n@@\n-no newline at end\n+first line\n+second line\n*** End Patch" + + await execute({ patchText }, ctx) + + const contents = await fs.readFile(target, "utf-8") + expect(contents.endsWith("\n")).toBe(true) + expect(contents).toBe("first line\nsecond line\n") + }, + }) + }) + + test("moves file to a new directory", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const original = path.join(fixture.path, "old", "name.txt") + await fs.mkdir(path.dirname(original), { recursive: true }) + await fs.writeFile(original, "old content\n", "utf-8") + FileTime.read(ctx.sessionID, original) + + const patchText = + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-old content\n+new content\n*** End Patch" + + await execute({ patchText }, ctx) + + const moved = path.join(fixture.path, "renamed", "dir", "name.txt") + await expect(fs.readFile(original, "utf-8")).rejects.toThrow() + expect(await fs.readFile(moved, "utf-8")).toBe("new content\n") + }, + }) + }) + + test("moves file overwriting existing destination", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const original = path.join(fixture.path, "old", "name.txt") + const destination = path.join(fixture.path, "renamed", "dir", "name.txt") + await fs.mkdir(path.dirname(original), { recursive: true }) + await fs.mkdir(path.dirname(destination), { recursive: true }) + await fs.writeFile(original, "from\n", "utf-8") + await fs.writeFile(destination, "existing\n", "utf-8") + FileTime.read(ctx.sessionID, original) + + const patchText = + "*** Begin Patch\n*** Update File: old/name.txt\n*** Move to: renamed/dir/name.txt\n@@\n-from\n+new\n*** End Patch" + + await execute({ patchText }, ctx) + + await expect(fs.readFile(original, "utf-8")).rejects.toThrow() + expect(await fs.readFile(destination, "utf-8")).toBe("new\n") + }, + }) + }) + + test("adds file overwriting existing file", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "duplicate.txt") + await fs.writeFile(target, "old content\n", "utf-8") + + const patchText = "*** Begin Patch\n*** Add File: duplicate.txt\n+new content\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("new content\n") + }, + }) + }) + + test("rejects update when target file is missing", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Update File: missing.txt\n@@\n-nope\n+better\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow( + "apply_patch verification failed: Failed to read file to update", + ) + }, + }) + }) + + test("rejects delete when file is missing", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Delete File: missing.txt\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + }, + }) + }) + + test("rejects delete when target is a directory", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const dirPath = path.join(fixture.path, "dir") + await fs.mkdir(dirPath) + + const patchText = "*** Begin Patch\n*** Delete File: dir\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + }, + }) + }) + + test("rejects invalid hunk header", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = "*** Begin Patch\n*** Frobnicate File: foo\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") + }, + }) + }) + + test("rejects update with missing context", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "modify.txt") + await fs.writeFile(target, "line1\nline2\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: modify.txt\n@@\n-missing\n+changed\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow("apply_patch verification failed") + expect(await fs.readFile(target, "utf-8")).toBe("line1\nline2\n") + }, + }) + }) + + test("verification failure leaves no side effects", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = + "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** Update File: missing.txt\n@@\n-old\n+new\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + + const createdPath = path.join(fixture.path, "created.txt") + await expect(fs.readFile(createdPath, "utf-8")).rejects.toThrow() + }, + }) + }) + + test("supports end of file anchor", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "tail.txt") + await fs.writeFile(target, "alpha\nlast\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: tail.txt\n@@\n-last\n+end\n*** End of File\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("alpha\nend\n") + }, + }) + }) + + test("rejects missing second chunk context", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "two_chunks.txt") + await fs.writeFile(target, "a\nb\nc\nd\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: two_chunks.txt\n@@\n-b\n+B\n\n-d\n+D\n*** End Patch" + + await expect(execute({ patchText }, ctx)).rejects.toThrow() + expect(await fs.readFile(target, "utf-8")).toBe("a\nb\nc\nd\n") + }, + }) + }) + + test("disambiguates change context with @@ header", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "multi_ctx.txt") + await fs.writeFile(target, "fn a\nx=10\ny=2\nfn b\nx=10\ny=20\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + const patchText = "*** Begin Patch\n*** Update File: multi_ctx.txt\n@@ fn b\n-x=10\n+x=11\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("fn a\nx=10\ny=2\nfn b\nx=11\ny=20\n") + }, + }) + }) + + test("EOF anchor matches from end of file first", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "eof_anchor.txt") + // File has duplicate "marker" lines - one in middle, one at end + await fs.writeFile(target, "start\nmarker\nmiddle\nmarker\nend\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // With EOF anchor, should match the LAST "marker" line, not the first + const patchText = + "*** Begin Patch\n*** Update File: eof_anchor.txt\n@@\n-marker\n-end\n+marker-changed\n+end\n*** End of File\n*** End Patch" + + await execute({ patchText }, ctx) + // First marker unchanged, second marker changed + expect(await fs.readFile(target, "utf-8")).toBe("start\nmarker\nmiddle\nmarker-changed\nend\n") + }, + }) + }) + + test("parses heredoc-wrapped patch", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = `cat <<'EOF' +*** Begin Patch +*** Add File: heredoc_test.txt ++heredoc content +*** End Patch +EOF` + + await execute({ patchText }, ctx) + const content = await fs.readFile(path.join(fixture.path, "heredoc_test.txt"), "utf-8") + expect(content).toBe("heredoc content\n") + }, + }) + }) + + test("parses heredoc-wrapped patch without cat", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const patchText = `< { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "trailing_ws.txt") + // File has trailing spaces on some lines + await fs.writeFile(target, "line1 \nline2\nline3 \n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch doesn't have trailing spaces - should still match via rstrip pass + const patchText = "*** Begin Patch\n*** Update File: trailing_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe("line1 \nchanged\nline3 \n") + }, + }) + }) + + test("matches with leading whitespace differences", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "leading_ws.txt") + // File has leading spaces + await fs.writeFile(target, " line1\nline2\n line3\n", "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch without leading spaces - should match via trim pass + const patchText = "*** Begin Patch\n*** Update File: leading_ws.txt\n@@\n-line2\n+changed\n*** End Patch" + + await execute({ patchText }, ctx) + expect(await fs.readFile(target, "utf-8")).toBe(" line1\nchanged\n line3\n") + }, + }) + }) + + test("matches with Unicode punctuation differences", async () => { + await using fixture = await tmpdir() + const { ctx } = makeCtx() + + await Instance.provide({ + directory: fixture.path, + fn: async () => { + const target = path.join(fixture.path, "unicode.txt") + // File has fancy Unicode quotes (U+201C, U+201D) and em-dash (U+2014) + const leftQuote = "\u201C" + const rightQuote = "\u201D" + const emDash = "\u2014" + await fs.writeFile(target, `He said ${leftQuote}hello${rightQuote}\nsome${emDash}dash\nend\n`, "utf-8") + FileTime.read(ctx.sessionID, target) + + // Patch uses ASCII equivalents - should match via normalized pass + // The replacement uses ASCII quotes from the patch (not preserving Unicode) + const patchText = + '*** Begin Patch\n*** Update File: unicode.txt\n@@\n-He said "hello"\n+He said "hi"\n*** End Patch' + + await execute({ patchText }, ctx) + // Result has ASCII quotes because that's what the patch specifies + expect(await fs.readFile(target, "utf-8")).toBe(`He said "hi"\nsome${emDash}dash\nend\n`) + }, + }) + }) +}) diff --git a/packages/opencode/test/tool/patch.test.ts b/packages/opencode/test/tool/patch.test.ts deleted file mode 100644 index 3d3ec574e60..00000000000 --- a/packages/opencode/test/tool/patch.test.ts +++ /dev/null @@ -1,261 +0,0 @@ -import { describe, expect, test } from "bun:test" -import path from "path" -import { PatchTool } from "../../src/tool/patch" -import { Instance } from "../../src/project/instance" -import { tmpdir } from "../fixture/fixture" -import { PermissionNext } from "../../src/permission/next" -import * as fs from "fs/promises" - -const ctx = { - sessionID: "test", - messageID: "", - callID: "", - agent: "build", - abort: AbortSignal.any([]), - metadata: () => {}, - ask: async () => {}, -} - -const patchTool = await PatchTool.init() - -describe("tool.patch", () => { - test("should validate required parameters", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - expect(patchTool.execute({ patchText: "" }, ctx)).rejects.toThrow("patchText is required") - }, - }) - }) - - test("should validate patch format", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - expect(patchTool.execute({ patchText: "invalid patch" }, ctx)).rejects.toThrow("Failed to parse patch") - }, - }) - }) - - test("should handle empty patch", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - const emptyPatch = `*** Begin Patch -*** End Patch` - - expect(patchTool.execute({ patchText: emptyPatch }, ctx)).rejects.toThrow("No file changes found in patch") - }, - }) - }) - - test.skip("should ask permission for files outside working directory", async () => { - await Instance.provide({ - directory: "/tmp", - fn: async () => { - const maliciousPatch = `*** Begin Patch -*** Add File: /etc/passwd -+malicious content -*** End Patch` - patchTool.execute({ patchText: maliciousPatch }, ctx) - // TODO: this sucks - await new Promise((resolve) => setTimeout(resolve, 1000)) - const pending = await PermissionNext.list() - expect(pending.find((p) => p.sessionID === ctx.sessionID)).toBeDefined() - }, - }) - }) - - test("should handle simple add file operation", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: test-file.txt -+Hello World -+This is a test file -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify file was created - const filePath = path.join(fixture.path, "test-file.txt") - const content = await fs.readFile(filePath, "utf-8") - expect(content).toBe("Hello World\nThis is a test file") - }, - }) - }) - - test("should handle file with context update", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: config.js -+const API_KEY = "test-key" -+const DEBUG = false -+const VERSION = "1.0" -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify file was created with correct content - const filePath = path.join(fixture.path, "config.js") - const content = await fs.readFile(filePath, "utf-8") - expect(content).toBe('const API_KEY = "test-key"\nconst DEBUG = false\nconst VERSION = "1.0"') - }, - }) - }) - - test("should handle multiple file operations", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: file1.txt -+Content of file 1 -*** Add File: file2.txt -+Content of file 2 -*** Add File: file3.txt -+Content of file 3 -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("3 files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify all files were created - for (let i = 1; i <= 3; i++) { - const filePath = path.join(fixture.path, `file${i}.txt`) - const content = await fs.readFile(filePath, "utf-8") - expect(content).toBe(`Content of file ${i}`) - } - }, - }) - }) - - test("should create parent directories when adding nested files", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: deep/nested/file.txt -+Deep nested content -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("files changed") - expect(result.output).toContain("Patch applied successfully") - - // Verify nested file was created - const nestedPath = path.join(fixture.path, "deep", "nested", "file.txt") - const exists = await fs - .access(nestedPath) - .then(() => true) - .catch(() => false) - expect(exists).toBe(true) - - const content = await fs.readFile(nestedPath, "utf-8") - expect(content).toBe("Deep nested content") - }, - }) - }) - - test("should generate proper unified diff in metadata", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - // First create a file with simple content - const patchText1 = `*** Begin Patch -*** Add File: test.txt -+line 1 -+line 2 -+line 3 -*** End Patch` - - await patchTool.execute({ patchText: patchText1 }, ctx) - - // Now create an update patch - const patchText2 = `*** Begin Patch -*** Update File: test.txt -@@ - line 1 --line 2 -+line 2 updated - line 3 -*** End Patch` - - const result = await patchTool.execute({ patchText: patchText2 }, ctx) - - expect(result.metadata.diff).toBeDefined() - expect(result.metadata.diff).toContain("@@") - expect(result.metadata.diff).toContain("-line 2") - expect(result.metadata.diff).toContain("+line 2 updated") - }, - }) - }) - - test("should handle complex patch with multiple operations", async () => { - await using fixture = await tmpdir() - - await Instance.provide({ - directory: fixture.path, - fn: async () => { - const patchText = `*** Begin Patch -*** Add File: new.txt -+This is a new file -+with multiple lines -*** Add File: existing.txt -+old content -+new line -+more content -*** Add File: config.json -+{ -+ "version": "1.0", -+ "debug": true -+} -*** End Patch` - - const result = await patchTool.execute({ patchText }, ctx) - - expect(result.title).toContain("3 files changed") - expect(result.metadata.diff).toBeDefined() - expect(result.output).toContain("Patch applied successfully") - - // Verify all files were created - const newPath = path.join(fixture.path, "new.txt") - const newContent = await fs.readFile(newPath, "utf-8") - expect(newContent).toBe("This is a new file\nwith multiple lines") - - const existingPath = path.join(fixture.path, "existing.txt") - const existingContent = await fs.readFile(existingPath, "utf-8") - expect(existingContent).toBe("old content\nnew line\nmore content") - - const configPath = path.join(fixture.path, "config.json") - const configContent = await fs.readFile(configPath, "utf-8") - expect(configContent).toBe('{\n "version": "1.0",\n "debug": true\n}') - }, - }) - }) -}) diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts new file mode 100644 index 00000000000..fa95e9612b6 --- /dev/null +++ b/packages/opencode/test/tool/question.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test" +import { z } from "zod" +import { QuestionTool } from "../../src/tool/question" +import * as QuestionModule from "../../src/question" + +const ctx = { + sessionID: "test-session", + messageID: "test-message", + callID: "test-call", + agent: "test-agent", + abort: AbortSignal.any([]), + metadata: () => {}, + ask: async () => {}, +} + +describe("tool.question", () => { + let askSpy: any + + beforeEach(() => { + askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => { + return [] + }) + }) + + afterEach(() => { + askSpy.mockRestore() + }) + + test("should successfully execute with valid question parameters", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "What is your favorite color?", + header: "Color", + options: [ + { label: "Red", description: "The color of passion" }, + { label: "Blue", description: "The color of sky" }, + ], + multiple: false, + }, + ] + + askSpy.mockResolvedValueOnce([["Red"]]) + + const result = await tool.execute({ questions }, ctx) + expect(askSpy).toHaveBeenCalledTimes(1) + expect(result.title).toBe("Asked 1 question") + }) + + test("should now pass with a header longer than 12 but less than 30 chars", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "What is your favorite animal?", + header: "This Header is Over 12", + options: [{ label: "Dog", description: "Man's best friend" }], + }, + ] + + askSpy.mockResolvedValueOnce([["Dog"]]) + + const result = await tool.execute({ questions }, ctx) + expect(result.output).toContain(`"What is your favorite animal?"="Dog"`) + }) + + test("should throw an Error for header exceeding 30 characters", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "What is your favorite animal?", + header: "This Header is Definitely More Than Thirty Characters Long", + options: [{ label: "Dog", description: "Man's best friend" }], + }, + ] + try { + await tool.execute({ questions }, ctx) + // If it reaches here, the test should fail + expect(true).toBe(false) + } catch (e: any) { + expect(e).toBeInstanceOf(Error) + expect(e.cause).toBeInstanceOf(z.ZodError) + } + }) + + test("should throw an Error for label exceeding 30 characters", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "A question with a very long label", + header: "Long Label", + options: [ + { label: "This is a very, very, very long label that will exceed the limit", description: "A description" }, + ], + }, + ] + try { + await tool.execute({ questions }, ctx) + // If it reaches here, the test should fail + expect(true).toBe(false) + } catch (e: any) { + expect(e).toBeInstanceOf(Error) + expect(e.cause).toBeInstanceOf(z.ZodError) + } + }) +}) diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts index 32f33f66219..ca13e5e93cf 100644 --- a/packages/sdk/js/src/gen/types.gen.ts +++ b/packages/sdk/js/src/gen/types.gen.ts @@ -842,6 +842,14 @@ export type KeybindsConfig = { * Scroll messages down by one page */ messages_page_down?: string + /** + * Scroll messages up by one line + */ + messages_line_up?: string + /** + * Scroll messages down by one line + */ + messages_line_down?: string /** * Scroll messages up by half page */ diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index e47c4f5f7f1..04e7144eb72 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -545,7 +545,7 @@ export type QuestionInfo = { */ question: string /** - * Very short label (max 12 chars) + * Very short label (max 30 chars) */ header: string /** @@ -633,6 +633,14 @@ export type EventTodoUpdated = { } } +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type EventTuiPromptAppend = { type: "tui.prompt.append" properties: { @@ -651,6 +659,8 @@ export type EventTuiCommandExecute = { | "session.compact" | "session.page.up" | "session.page.down" + | "session.line.up" + | "session.line.down" | "session.half.page.up" | "session.half.page.down" | "session.first" @@ -789,14 +799,6 @@ export type EventSessionError = { } } -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type EventVcsBranchUpdated = { type: "vcs.branch.updated" properties: { @@ -878,6 +880,7 @@ export type Event = | EventQuestionRejected | EventSessionCompacted | EventTodoUpdated + | EventFileWatcherUpdated | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow @@ -890,7 +893,6 @@ export type Event = | EventSessionDeleted | EventSessionDiff | EventSessionError - | EventFileWatcherUpdated | EventVcsBranchUpdated | EventPtyCreated | EventPtyUpdated @@ -1019,6 +1021,14 @@ export type KeybindsConfig = { * Scroll messages down by one page */ messages_page_down?: string + /** + * Scroll messages up by one line + */ + messages_line_up?: string + /** + * Scroll messages down by one line + */ + messages_line_down?: string /** * Scroll messages up by half page */ diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 0dc174c1b0a..104cedce1e5 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7163,7 +7163,8 @@ "properties": { "label": { "description": "Display text (1-5 words, concise)", - "type": "string" + "type": "string", + "maxLength": 30 }, "description": { "description": "Explanation of choice", @@ -7180,9 +7181,9 @@ "type": "string" }, "header": { - "description": "Very short label (max 12 chars)", + "description": "Very short label (max 30 chars)", "type": "string", - "maxLength": 12 + "maxLength": 30 }, "options": { "description": "Available choices", @@ -7370,6 +7371,41 @@ }, "required": ["type", "properties"] }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Event.tui.prompt.append": { "type": "object", "properties": { @@ -7411,6 +7447,8 @@ "session.compact", "session.page.up", "session.page.down", + "session.line.up", + "session.line.down", "session.half.page.up", "session.half.page.down", "session.first", @@ -7796,41 +7834,6 @@ }, "required": ["type", "properties"] }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "anyOf": [ - { - "type": "string", - "const": "add" - }, - { - "type": "string", - "const": "change" - }, - { - "type": "string", - "const": "unlink" - } - ] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "Event.vcs.branch.updated": { "type": "object", "properties": { @@ -8052,6 +8055,9 @@ { "$ref": "#/components/schemas/Event.todo.updated" }, + { + "$ref": "#/components/schemas/Event.file.watcher.updated" + }, { "$ref": "#/components/schemas/Event.tui.prompt.append" }, @@ -8088,9 +8094,6 @@ { "$ref": "#/components/schemas/Event.session.error" }, - { - "$ref": "#/components/schemas/Event.file.watcher.updated" - }, { "$ref": "#/components/schemas/Event.vcs.branch.updated" }, @@ -8282,12 +8285,22 @@ }, "messages_page_up": { "description": "Scroll messages up by one page", - "default": "pageup", + "default": "pageup,ctrl+alt+b", "type": "string" }, "messages_page_down": { "description": "Scroll messages down by one page", - "default": "pagedown", + "default": "pagedown,ctrl+alt+f", + "type": "string" + }, + "messages_line_up": { + "description": "Scroll messages up by one line", + "default": "ctrl+alt+y", + "type": "string" + }, + "messages_line_down": { + "description": "Scroll messages down by one line", + "default": "ctrl+alt+e", "type": "string" }, "messages_half_page_up": { diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx index 5be7f95aeec..631b3e33a29 100644 --- a/packages/ui/src/components/list.tsx +++ b/packages/ui/src/components/list.tsx @@ -69,7 +69,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) if (!props.current) return const key = props.key(props.current) requestAnimationFrame(() => { - const element = scrollRef()?.querySelector(`[data-key="${key}"]`) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`) element?.scrollIntoView({ block: "center" }) }) }) @@ -81,7 +81,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void }) scrollRef()?.scrollTo(0, 0) return } - const element = scrollRef()?.querySelector(`[data-key="${active()}"]`) + const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`) element?.scrollIntoView({ block: "center" }) }) diff --git a/packages/ui/src/components/logo.tsx b/packages/ui/src/components/logo.tsx index 5ddf3fba329..26f312bda75 100644 --- a/packages/ui/src/components/logo.tsx +++ b/packages/ui/src/components/logo.tsx @@ -13,6 +13,21 @@ export const Mark = (props: { class?: string }) => { ) } +export const Splash = (props: { class?: string }) => { + return ( + + + + + ) +} + export const Logo = (props: { class?: string }) => { return ( 1 ? "s" : ""}` : undefined, + } case "todowrite": return { icon: "checklist", @@ -1027,6 +1033,94 @@ ToolRegistry.register({ }, }) +interface ApplyPatchFile { + filePath: string + relativePath: string + type: "add" | "update" | "delete" | "move" + diff: string + before: string + after: string + additions: number + deletions: number + movePath?: string +} + +ToolRegistry.register({ + name: "apply_patch", + render(props) { + const diffComponent = useDiffComponent() + const files = createMemo(() => (props.metadata.files ?? []) as ApplyPatchFile[]) + + const subtitle = createMemo(() => { + const count = files().length + if (count === 0) return "" + return `${count} file${count > 1 ? "s" : ""}` + }) + + return ( + + 0}> +
+ + {(file) => ( +
+
+ + + + Deleted + + + + + Created + + + + + Moved + + + + + Patched + + + + {file.relativePath} + + + + + -{file.deletions} + +
+ +
+ +
+
+
+ )} +
+
+ + + ) + }, +}) + ToolRegistry.register({ name: "todowrite", render(props) { diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index c38ee5847db..711047030cc 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -5,6 +5,8 @@ import type { ComponentProps } from "solid-js" export interface TooltipProps extends ComponentProps { value: JSX.Element class?: string + contentClass?: string + contentStyle?: JSX.CSSProperties inactive?: boolean } @@ -30,7 +32,7 @@ export function TooltipKeybind(props: TooltipKeybindProps) { export function Tooltip(props: TooltipProps) { const [open, setOpen] = createSignal(false) - const [local, others] = splitProps(props, ["children", "class", "inactive"]) + const [local, others] = splitProps(props, ["children", "class", "contentClass", "contentStyle", "inactive"]) const c = children(() => local.children) @@ -58,7 +60,12 @@ export function Tooltip(props: TooltipProps) { {c()} - + {others.value} {/* */} diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index b85bd2142fa..22bed7f16a4 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -157,10 +157,10 @@ Configure agents in your `opencode.json` config file: You can also define agents using markdown files. Place them in: -- Global: `~/.config/opencode/agent/` -- Per-project: `.opencode/agent/` +- Global: `~/.config/opencode/agents/` +- Per-project: `.opencode/agents/` -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Reviews code for quality and best practices mode: subagent @@ -419,7 +419,7 @@ You can override these permissions per agent. You can also set permissions in Markdown agents. -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Code review without edits mode: subagent @@ -637,7 +637,7 @@ Do you have an agent you'd like to share? [Submit a PR](https://github.com/anoma ### Documentation agent -```markdown title="~/.config/opencode/agent/docs-writer.md" +```markdown title="~/.config/opencode/agents/docs-writer.md" --- description: Writes and maintains project documentation mode: subagent @@ -659,7 +659,7 @@ Focus on: ### Security auditor -```markdown title="~/.config/opencode/agent/security-auditor.md" +```markdown title="~/.config/opencode/agents/security-auditor.md" --- description: Performs security audits and identifies vulnerabilities mode: subagent diff --git a/packages/web/src/content/docs/commands.mdx b/packages/web/src/content/docs/commands.mdx index 92ca08bd2e9..1d7e4f1c21a 100644 --- a/packages/web/src/content/docs/commands.mdx +++ b/packages/web/src/content/docs/commands.mdx @@ -15,11 +15,11 @@ Custom commands are in addition to the built-in commands like `/init`, `/undo`, ## Create command files -Create markdown files in the `command/` directory to define custom commands. +Create markdown files in the `commands/` directory to define custom commands. -Create `.opencode/command/test.md`: +Create `.opencode/commands/test.md`: -```md title=".opencode/command/test.md" +```md title=".opencode/commands/test.md" --- description: Run tests with coverage agent: build @@ -42,7 +42,7 @@ Use the command by typing `/` followed by the command name. ## Configure -You can add custom commands through the OpenCode config or by creating markdown files in the `command/` directory. +You can add custom commands through the OpenCode config or by creating markdown files in the `commands/` directory. --- @@ -79,10 +79,10 @@ Now you can run this command in the TUI: You can also define commands using markdown files. Place them in: -- Global: `~/.config/opencode/command/` -- Per-project: `.opencode/command/` +- Global: `~/.config/opencode/commands/` +- Per-project: `.opencode/commands/` -```markdown title="~/.config/opencode/command/test.md" +```markdown title="~/.config/opencode/commands/test.md" --- description: Run tests with coverage agent: build @@ -112,7 +112,7 @@ The prompts for the custom commands support several special placeholders and syn Pass arguments to commands using the `$ARGUMENTS` placeholder. -```md title=".opencode/command/component.md" +```md title=".opencode/commands/component.md" --- description: Create a new component --- @@ -138,7 +138,7 @@ You can also access individual arguments using positional parameters: For example: -```md title=".opencode/command/create-file.md" +```md title=".opencode/commands/create-file.md" --- description: Create a new file with content --- @@ -167,7 +167,7 @@ Use _!`command`_ to inject [bash command](/docs/tui#bash-commands) output into y For example, to create a custom command that analyzes test coverage: -```md title=".opencode/command/analyze-coverage.md" +```md title=".opencode/commands/analyze-coverage.md" --- description: Analyze test coverage --- @@ -180,7 +180,7 @@ Based on these results, suggest improvements to increase coverage. Or to review recent changes: -```md title=".opencode/command/review-changes.md" +```md title=".opencode/commands/review-changes.md" --- description: Review recent changes --- @@ -199,7 +199,7 @@ Commands run in your project's root directory and their output becomes part of t Include files in your command using `@` followed by the filename. -```md title=".opencode/command/review-component.md" +```md title=".opencode/commands/review-component.md" --- description: Review component --- diff --git a/packages/web/src/content/docs/config.mdx b/packages/web/src/content/docs/config.mdx index 30edbbd2146..1474cb91558 100644 --- a/packages/web/src/content/docs/config.mdx +++ b/packages/web/src/content/docs/config.mdx @@ -51,6 +51,10 @@ Config sources are loaded in this order (later sources override earlier ones): This means project configs can override global defaults, and global configs can override remote organizational defaults. +:::note +The `.opencode` and `~/.config/opencode` directories use **plural names** for subdirectories: `agents/`, `commands/`, `modes/`, `plugins/`, `skills/`, `tools/`, and `themes/`. Singular names (e.g., `agent/`) are also supported for backwards compatibility. +::: + --- ### Remote @@ -330,7 +334,7 @@ You can configure specialized agents for specific tasks through the `agent` opti } ``` -You can also define agents using markdown files in `~/.config/opencode/agent/` or `.opencode/agent/`. [Learn more here](/docs/agents). +You can also define agents using markdown files in `~/.config/opencode/agents/` or `.opencode/agents/`. [Learn more here](/docs/agents). --- @@ -394,7 +398,7 @@ You can configure custom commands for repetitive tasks through the `command` opt } ``` -You can also define commands using markdown files in `~/.config/opencode/command/` or `.opencode/command/`. [Learn more here](/docs/commands). +You can also define commands using markdown files in `~/.config/opencode/commands/` or `.opencode/commands/`. [Learn more here](/docs/commands). --- @@ -425,6 +429,7 @@ OpenCode will automatically download any new updates when it starts up. You can ``` If you don't want updates but want to be notified when a new version is available, set `autoupdate` to `"notify"`. +Notice that this only works if it was not installed using a package manager such as Homebrew. --- @@ -529,7 +534,7 @@ You can configure MCP servers you want to use through the `mcp` option. [Plugins](/docs/plugins) extend OpenCode with custom tools, hooks, and integrations. -Place plugin files in `.opencode/plugin/` or `~/.config/opencode/plugin/`. You can also load plugins from npm through the `plugin` option. +Place plugin files in `.opencode/plugins/` or `~/.config/opencode/plugins/`. You can also load plugins from npm through the `plugin` option. ```json title="opencode.json" { diff --git a/packages/web/src/content/docs/custom-tools.mdx b/packages/web/src/content/docs/custom-tools.mdx index 2701be65086..e089a035b4b 100644 --- a/packages/web/src/content/docs/custom-tools.mdx +++ b/packages/web/src/content/docs/custom-tools.mdx @@ -17,8 +17,8 @@ Tools are defined as **TypeScript** or **JavaScript** files. However, the tool d They can be defined: -- Locally by placing them in the `.opencode/tool/` directory of your project. -- Or globally, by placing them in `~/.config/opencode/tool/`. +- Locally by placing them in the `.opencode/tools/` directory of your project. +- Or globally, by placing them in `~/.config/opencode/tools/`. --- @@ -26,7 +26,7 @@ They can be defined: The easiest way to create tools is using the `tool()` helper which provides type-safety and validation. -```ts title=".opencode/tool/database.ts" {1} +```ts title=".opencode/tools/database.ts" {1} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -49,7 +49,7 @@ The **filename** becomes the **tool name**. The above creates a `database` tool. You can also export multiple tools from a single file. Each export becomes **a separate tool** with the name **`_`**: -```ts title=".opencode/tool/math.ts" +```ts title=".opencode/tools/math.ts" import { tool } from "@opencode-ai/plugin" export const add = tool({ @@ -112,7 +112,7 @@ export default { Tools receive context about the current session: -```ts title=".opencode/tool/project.ts" {8} +```ts title=".opencode/tools/project.ts" {8} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -136,7 +136,7 @@ You can write your tools in any language you want. Here's an example that adds t First, create the tool as a Python script: -```python title=".opencode/tool/add.py" +```python title=".opencode/tools/add.py" import sys a = int(sys.argv[1]) @@ -146,7 +146,7 @@ print(a + b) Then create the tool definition that invokes it: -```ts title=".opencode/tool/python-add.ts" {10} +```ts title=".opencode/tools/python-add.ts" {10} import { tool } from "@opencode-ai/plugin" export default tool({ @@ -156,7 +156,7 @@ export default tool({ b: tool.schema.number().describe("Second number"), }, async execute(args) { - const result = await Bun.$`python3 .opencode/tool/add.py ${args.a} ${args.b}`.text() + const result = await Bun.$`python3 .opencode/tools/add.py ${args.a} ${args.b}`.text() return result.trim() }, }) diff --git a/packages/web/src/content/docs/github.mdx b/packages/web/src/content/docs/github.mdx index 6e8b9de4d79..a31fe1e7be8 100644 --- a/packages/web/src/content/docs/github.mdx +++ b/packages/web/src/content/docs/github.mdx @@ -180,8 +180,10 @@ jobs: - uses: anomalyco/opencode/github@latest env: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: model: anthropic/claude-sonnet-4-20250514 + use_github_token: true prompt: | Review this pull request: - Check for code quality issues diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 267d194c099..51508a4f864 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -31,8 +31,10 @@ OpenCode has a list of keybinds that you can customize through the OpenCode conf "session_child_cycle": "right", "session_child_cycle_reverse": "left", "session_parent": "up", - "messages_page_up": "pageup", - "messages_page_down": "pagedown", + "messages_page_up": "pageup,ctrl+alt+b", + "messages_page_down": "pagedown,ctrl+alt+f", + "messages_line_up": "ctrl+alt+y", + "messages_line_down": "ctrl+alt+e", "messages_half_page_up": "ctrl+alt+u", "messages_half_page_down": "ctrl+alt+d", "messages_first": "ctrl+g,home", diff --git a/packages/web/src/content/docs/modes.mdx b/packages/web/src/content/docs/modes.mdx index a31a8223b07..57c1c54a956 100644 --- a/packages/web/src/content/docs/modes.mdx +++ b/packages/web/src/content/docs/modes.mdx @@ -87,10 +87,10 @@ Configure modes in your `opencode.json` config file: You can also define modes using markdown files. Place them in: -- Global: `~/.config/opencode/mode/` -- Project: `.opencode/mode/` +- Global: `~/.config/opencode/modes/` +- Project: `.opencode/modes/` -```markdown title="~/.config/opencode/mode/review.md" +```markdown title="~/.config/opencode/modes/review.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.1 @@ -268,9 +268,9 @@ You can create your own custom modes by adding them to the configuration. Here a ### Using markdown files -Create mode files in `.opencode/mode/` for project-specific modes or `~/.config/opencode/mode/` for global modes: +Create mode files in `.opencode/modes/` for project-specific modes or `~/.config/opencode/modes/` for global modes: -```markdown title=".opencode/mode/debug.md" +```markdown title=".opencode/modes/debug.md" --- temperature: 0.1 tools: @@ -294,7 +294,7 @@ Focus on: Do not make any changes to files. Only investigate and report. ``` -```markdown title="~/.config/opencode/mode/refactor.md" +```markdown title="~/.config/opencode/modes/refactor.md" --- model: anthropic/claude-sonnet-4-20250514 temperature: 0.2 diff --git a/packages/web/src/content/docs/permissions.mdx b/packages/web/src/content/docs/permissions.mdx index b4f0691ced7..4df3841e34a 100644 --- a/packages/web/src/content/docs/permissions.mdx +++ b/packages/web/src/content/docs/permissions.mdx @@ -174,7 +174,7 @@ Refer to the [Granular Rules (Object Syntax)](#granular-rules-object-syntax) sec You can also configure agent permissions in Markdown: -```markdown title="~/.config/opencode/agent/review.md" +```markdown title="~/.config/opencode/agents/review.md" --- description: Code review without edits mode: subagent diff --git a/packages/web/src/content/docs/plugins.mdx b/packages/web/src/content/docs/plugins.mdx index bf26744f6c4..66a1b3cad95 100644 --- a/packages/web/src/content/docs/plugins.mdx +++ b/packages/web/src/content/docs/plugins.mdx @@ -19,8 +19,8 @@ There are two ways to load plugins. Place JavaScript or TypeScript files in the plugin directory. -- `.opencode/plugin/` - Project-level plugins -- `~/.config/opencode/plugin/` - Global plugins +- `.opencode/plugins/` - Project-level plugins +- `~/.config/opencode/plugins/` - Global plugins Files in these directories are automatically loaded at startup. @@ -57,8 +57,8 @@ Plugins are loaded from all sources and all hooks run in sequence. The load orde 1. Global config (`~/.config/opencode/opencode.json`) 2. Project config (`opencode.json`) -3. Global plugin directory (`~/.config/opencode/plugin/`) -4. Project plugin directory (`.opencode/plugin/`) +3. Global plugin directory (`~/.config/opencode/plugins/`) +4. Project plugin directory (`.opencode/plugins/`) Duplicate npm packages with the same name and version are loaded once. However, a local plugin and an npm plugin with similar names are both loaded separately. @@ -85,7 +85,7 @@ Local plugins and custom tools can use external npm packages. Add a `package.jso OpenCode runs `bun install` at startup to install these. Your plugins and tools can then import them. -```ts title=".opencode/plugin/my-plugin.ts" +```ts title=".opencode/plugins/my-plugin.ts" import { escape } from "shescape" export const MyPlugin = async (ctx) => { @@ -103,7 +103,7 @@ export const MyPlugin = async (ctx) => { ### Basic structure -```js title=".opencode/plugin/example.js" +```js title=".opencode/plugins/example.js" export const MyPlugin = async ({ project, client, $, directory, worktree }) => { console.log("Plugin initialized!") @@ -215,7 +215,7 @@ Here are some examples of plugins you can use to extend opencode. Send notifications when certain events occur: -```js title=".opencode/plugin/notification.js" +```js title=".opencode/plugins/notification.js" export const NotificationPlugin = async ({ project, client, $, directory, worktree }) => { return { event: async ({ event }) => { @@ -240,7 +240,7 @@ If you’re using the OpenCode desktop app, it can send system notifications aut Prevent opencode from reading `.env` files: -```javascript title=".opencode/plugin/env-protection.js" +```javascript title=".opencode/plugins/env-protection.js" export const EnvProtection = async ({ project, client, $, directory, worktree }) => { return { "tool.execute.before": async (input, output) => { @@ -258,7 +258,7 @@ export const EnvProtection = async ({ project, client, $, directory, worktree }) Plugins can also add custom tools to opencode: -```ts title=".opencode/plugin/custom-tools.ts" +```ts title=".opencode/plugins/custom-tools.ts" import { type Plugin, tool } from "@opencode-ai/plugin" export const CustomToolsPlugin: Plugin = async (ctx) => { @@ -292,7 +292,7 @@ Your custom tools will be available to opencode alongside built-in tools. Use `client.app.log()` instead of `console.log` for structured logging: -```ts title=".opencode/plugin/my-plugin.ts" +```ts title=".opencode/plugins/my-plugin.ts" export const MyPlugin = async ({ client }) => { await client.app.log({ service: "my-plugin", @@ -311,7 +311,7 @@ Levels: `debug`, `info`, `warn`, `error`. See [SDK documentation](https://openco Customize the context included when a session is compacted: -```ts title=".opencode/plugin/compaction.ts" +```ts title=".opencode/plugins/compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CompactionPlugin: Plugin = async (ctx) => { @@ -335,7 +335,7 @@ The `experimental.session.compacting` hook fires before the LLM generates a cont You can also replace the compaction prompt entirely by setting `output.prompt`: -```ts title=".opencode/plugin/custom-compaction.ts" +```ts title=".opencode/plugins/custom-compaction.ts" import type { Plugin } from "@opencode-ai/plugin" export const CustomCompactionPlugin: Plugin = async (ctx) => { diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index e1d684de00a..6022d174a7d 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -558,6 +558,33 @@ Cloudflare AI Gateway lets you access models from OpenAI, Anthropic, Workers AI, --- +### Firmware + +1. Head over to the [Firmware dashboard](https://app.firmware.ai/signup), create an account, and generate an API key. + +2. Run the `/connect` command and search for **Firmware**. + + ```txt + /connect + ``` + +3. Enter your Firmware API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select a model. + + ```txt + /models + ``` + +--- + ### Fireworks AI 1. Head over to the [Fireworks AI console](https://app.fireworks.ai/), create an account, and click **Create API Key**. diff --git a/packages/web/src/content/docs/skills.mdx b/packages/web/src/content/docs/skills.mdx index 54c2c9d06ef..553931eec49 100644 --- a/packages/web/src/content/docs/skills.mdx +++ b/packages/web/src/content/docs/skills.mdx @@ -13,8 +13,8 @@ Skills are loaded on-demand via the native `skill` tool—agents see available s Create one folder per skill name and put a `SKILL.md` inside it. OpenCode searches these locations: -- Project config: `.opencode/skill//SKILL.md` -- Global config: `~/.config/opencode/skill//SKILL.md` +- Project config: `.opencode/skills//SKILL.md` +- Global config: `~/.config/opencode/skills//SKILL.md` - Project Claude-compatible: `.claude/skills//SKILL.md` - Global Claude-compatible: `~/.claude/skills//SKILL.md` @@ -23,9 +23,9 @@ OpenCode searches these locations: ## Understand discovery For project-local paths, OpenCode walks up from your current working directory until it reaches the git worktree. -It loads any matching `skill/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. +It loads any matching `skills/*/SKILL.md` in `.opencode/` and any matching `.claude/skills/*/SKILL.md` along the way. -Global definitions are also loaded from `~/.config/opencode/skill/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. +Global definitions are also loaded from `~/.config/opencode/skills/*/SKILL.md` and `~/.claude/skills/*/SKILL.md`. --- @@ -71,7 +71,7 @@ Keep it specific enough for the agent to choose correctly. ## Use an example -Create `.opencode/skill/git-release/SKILL.md` like this: +Create `.opencode/skills/git-release/SKILL.md` like this: ```markdown ---