Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions packages/core/src/review-overlay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
export * as ReviewOverlay from "./review-overlay"

import { FSUtil } from "./fs-util"

// In-memory staging area for ACP review mode. While enabled, file writes are
// kept here instead of going to disk, and are later sent to the client so they
// show up in the native review UI. `entries` holds the current staged content
// (or a delete marker) per path; `pending` is the queue of writes still to send.
export type Entry = { readonly content: string } | { readonly deleted: true }

export type PendingWrite = { sessionID: string; path: string; content: string }

let enabled = false
let activeSession: string | undefined
const entries = new Map<string, Entry>()
const pending: PendingWrite[] = []

export function setEnabled(value: boolean) {
enabled = value
}

export function isEnabled() {
return enabled
}

export function setActiveSession(sessionID: string | undefined) {
activeSession = sessionID
}

export function stage(path: string, content: string) {
const key = FSUtil.resolve(path)
entries.set(key, { content })
if (activeSession) pending.push({ sessionID: activeSession, path: key, content })
}

export function markDeleted(path: string) {
entries.set(FSUtil.resolve(path), { deleted: true })
}

export function get(path: string) {
return entries.get(FSUtil.resolve(path))
}

export function has(path: string) {
return entries.has(FSUtil.resolve(path))
}

// Recover staged edits that were written before a session was active, so a late
// flush still sends them. Skips paths already queued and delete markers.
export function enqueueUnflushed(sessionID: string) {
const queued = new Set(pending.map((item) => item.path))
for (const [path, entry] of entries) {
if (!("content" in entry)) continue
if (queued.has(path)) continue
pending.push({ sessionID, path, content: entry.content })
queued.add(path)
}
}

export function drainPendingWrites() {
const drained = [...pending]
pending.length = 0
return drained
}

// Drop staged state at the end of a turn but keep review mode enabled.
export function clear() {
entries.clear()
pending.length = 0
activeSession = undefined
}

// Full reset, including disabling review mode. Used on shutdown and in tests.
export function reset() {
enabled = false
activeSession = undefined
entries.clear()
pending.length = 0
}
56 changes: 56 additions & 0 deletions packages/core/test/review-overlay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { describe, expect, it } from "bun:test"
import { ReviewOverlay } from "../src/review-overlay"

describe("ReviewOverlay", () => {
ReviewOverlay.reset()

it("stage and get", () => {
ReviewOverlay.setEnabled(true)
ReviewOverlay.setActiveSession("sess-1")
ReviewOverlay.stage("/tmp/foo.ts", "hello")
expect(ReviewOverlay.get("/tmp/foo.ts")).toEqual({ content: "hello" })
expect(ReviewOverlay.has("/tmp/foo.ts")).toBe(true)
})

it("drainPendingWrites", () => {
ReviewOverlay.reset()
ReviewOverlay.setEnabled(true)
ReviewOverlay.setActiveSession("sess-1")
ReviewOverlay.stage("/tmp/a.ts", "a")
ReviewOverlay.stage("/tmp/b.ts", "b")
const drained = ReviewOverlay.drainPendingWrites()
expect(drained).toEqual([
{ sessionID: "sess-1", path: expect.stringContaining("a.ts"), content: "a" },
{ sessionID: "sess-1", path: expect.stringContaining("b.ts"), content: "b" },
])
expect(ReviewOverlay.drainPendingWrites()).toEqual([])
})

it("markDeleted", () => {
ReviewOverlay.reset()
ReviewOverlay.stage("/tmp/gone.ts", "soon deleted")
ReviewOverlay.markDeleted("/tmp/gone.ts")
expect(ReviewOverlay.get("/tmp/gone.ts")).toEqual({ deleted: true })
expect(ReviewOverlay.has("/tmp/gone.ts")).toBe(true)
})

it("enqueueUnflushed recovers staged content without pending queue", () => {
ReviewOverlay.reset()
ReviewOverlay.setEnabled(true)
ReviewOverlay.stage("/tmp/recover.ts", "staged")
expect(ReviewOverlay.drainPendingWrites()).toEqual([])
ReviewOverlay.enqueueUnflushed("sess-2")
expect(ReviewOverlay.drainPendingWrites()).toEqual([
{ sessionID: "sess-2", path: expect.stringContaining("recover.ts"), content: "staged" },
])
})

it("clear", () => {
ReviewOverlay.reset()
ReviewOverlay.setActiveSession("sess-1")
ReviewOverlay.stage("/tmp/x.ts", "x")
ReviewOverlay.clear()
expect(ReviewOverlay.has("/tmp/x.ts")).toBe(false)
expect(ReviewOverlay.drainPendingWrites()).toEqual([])
})
})
8 changes: 8 additions & 0 deletions packages/opencode/src/acp/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { Effect } from "effect"
import { ACPSession } from "./session"
import { ACPPermission } from "./permission"
import { partsToContentChunks, type ReplayPart } from "./content"
import { ReviewOverlay } from "@opencode-ai/core/review-overlay"
import {
duplicateRunningToolUpdate,
errorToolUpdate,
Expand All @@ -20,6 +21,7 @@ import {
shellOutputSnapshot,
completedToolUpdate,
} from "./tool"
import { flushPendingWrites } from "./review-staging"

type Connection = Pick<AgentSideConnection, "sessionUpdate"> &
Partial<Pick<AgentSideConnection, "requestPermission" | "writeTextFile">>
Expand Down Expand Up @@ -232,6 +234,7 @@ export class Subscription {
}

private async handleToolPart(sessionId: string, part: ToolPart) {
ReviewOverlay.setActiveSession(sessionId)
await this.toolStart(sessionId, part)

switch (part.state.status) {
Expand All @@ -256,6 +259,11 @@ export class Subscription {
}),
},
})
// These tools may have staged edits in the overlay. Send them to the
// client now so they show up in the native review UI as the tool finishes.
if (part.tool === "edit" || part.tool === "write" || part.tool === "apply_patch") {
await flushPendingWrites(this.input.connection, sessionId)
}
return

case "error":
Expand Down
15 changes: 13 additions & 2 deletions packages/opencode/src/acp/permission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { exists, readText } from "@/util/filesystem"
import type { ACPSession } from "./session"
import { toLocations, toToolKind, type ToolInput } from "./tool"
import { Effect } from "effect"
import { isActive } from "./review-mode"

type PermissionEvent = Extract<Event, { type: "permission.asked" }>
type Reply = "once" | "always" | "reject"
Expand Down Expand Up @@ -77,8 +78,18 @@ export class Handler {
return
}

if (permission.permission === "edit") {
await this.writeProposedEdit(session.id, permission.metadata).catch(() => {})
// In review mode the overlay already sends edits to the client, so skip the
// proposed-edit message to avoid showing the same change twice.
if (permission.permission === "edit" && !isActive()) {
await this.writeProposedEdit(session.id, permission.metadata).catch((error: unknown) =>
Effect.runPromise(
Effect.logError("failed to write proposed edit through ACP", {
error,
permissionID: permission.id,
sessionID: permission.sessionID,
}),
),
)
}

await this.reply(permission.id, reply, session.cwd)
Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/src/acp/review-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export * as ACPReviewMode from "./review-mode"

import { Flag, truthy } from "@opencode-ai/core/flag/flag"
import { ReviewOverlay } from "@opencode-ai/core/review-overlay"

let clientWriteTextFile = false
let forcedForAcp = false

export function forceEnableForAcp() {
forcedForAcp = true
syncEnabled()
}

export function setClientWriteTextFileSupported(supported: boolean) {
clientWriteTextFile = supported
syncEnabled()
}

// Review staging only works when the ACP client can receive staged edits via
// `fs/write_text_file`. Without that capability we must fall through to normal
// disk writes; otherwise edits would be staged in-memory and silently dropped
// (never written to disk nor sent to the client).
export function isActive() {
if (Flag.OPENCODE_CLIENT !== "acp") return false
if (!clientWriteTextFile) return false
return forcedForAcp || truthy("OPENCODE_ACP_REVIEW")
}

export function syncEnabled() {
ReviewOverlay.setEnabled(isActive())
}

export function reset() {
clientWriteTextFile = false
forcedForAcp = false
ReviewOverlay.reset()
}
37 changes: 37 additions & 0 deletions packages/opencode/src/acp/review-staging.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export * as ACPReviewStaging from "./review-staging"

import type { AgentSideConnection } from "@agentclientprotocol/sdk"
import { Effect } from "effect"
import { ReviewOverlay } from "@opencode-ai/core/review-overlay"
import { isActive } from "./review-mode"

type Connection = Partial<Pick<AgentSideConnection, "writeTextFile">>

export async function flushPendingWrites(connection: Connection | undefined, sessionID?: string) {
if (!isActive()) return
if (!connection?.writeTextFile) return
if (sessionID) {
ReviewOverlay.setActiveSession(sessionID)
ReviewOverlay.enqueueUnflushed(sessionID)
}

const pending = ReviewOverlay.drainPendingWrites()

for (const item of pending) {
await connection
.writeTextFile({
sessionId: item.sessionID,
path: item.path,
content: item.content,
})
.catch((error: unknown) =>
Effect.runPromise(
Effect.logError("failed to write staged edit through ACP", {
error,
path: item.path,
sessionID: item.sessionID,
}),
),
)
}
}
Loading
Loading