Skip to content
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 @@ -233,6 +235,7 @@ export class Subscription {
}

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

switch (part.state.status) {
Expand All @@ -258,6 +261,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 @@ -12,6 +12,7 @@ import { exists, readText } from "@/util/filesystem"
import type { ACPSession } from "./session"
import { pendingToolCall, toLocations, 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 @@ -81,8 +82,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