diff --git a/packages/core/src/file-mutation.ts b/packages/core/src/file-mutation.ts index d11144d576..ab6cee41d4 100644 --- a/packages/core/src/file-mutation.ts +++ b/packages/core/src/file-mutation.ts @@ -44,6 +44,11 @@ export interface WriteResult { readonly existed: boolean } +export interface TextWriteResult extends WriteResult { + readonly before: string + readonly after: string +} + export interface RemoveResult { readonly operation: "remove" readonly target: string @@ -56,7 +61,7 @@ export interface Interface { readonly create: (input: WriteInput) => Effect.Effect readonly write: (input: WriteInput) => Effect.Effect /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */ - readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect + readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect /** Commit only if an existing target still has the expected bytes. */ readonly writeIfUnchanged: ( input: ConditionalWriteInput, @@ -112,11 +117,13 @@ const layer = Layer.effect( const current = yield* fs .readFile(input.target.canonical) .pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined))) - yield* fs.writeWithDirs( - input.target.canonical, - joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom), - ) - return writeResult(input.target, current !== undefined) + const content = joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom) + yield* fs.writeWithDirs(input.target.canonical, content) + return { + ...writeResult(input.target, current !== undefined), + before: current ? new TextDecoder().decode(current).replace(/^\uFEFF/, "") : "", + after: content.replace(/^\uFEFF/, ""), + } }), ), ) diff --git a/packages/core/src/tool/write.ts b/packages/core/src/tool/write.ts index 6c8041ff42..18c989b9e5 100644 --- a/packages/core/src/tool/write.ts +++ b/packages/core/src/tool/write.ts @@ -8,6 +8,8 @@ export * as WriteTool from "./write" import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin" import { ToolFailure } from "@opencode-ai/ai" +import { FileDiff } from "@opencode-ai/schema/file-diff" +import { createTwoFilesPatch, diffLines } from "diff" import { Effect, Schema } from "effect" import { FileMutation } from "../file-mutation" import { LocationMutation } from "../location-mutation" @@ -30,6 +32,7 @@ export const Output = Schema.Struct({ target: Schema.String, resource: Schema.String, existed: Schema.Boolean, + files: Schema.Array(FileDiff.Info), }) export type Output = typeof Output.Type @@ -84,7 +87,28 @@ export const Plugin = { agent: context.agent, source, }) - return yield* files.writeTextPreservingBom({ target, content: input.content }) + const result = yield* files.writeTextPreservingBom({ target, content: input.content }) + const counts = diffLines(result.before, result.after).reduce( + (total, item) => ({ + additions: total.additions + (item.added ? (item.count ?? 0) : 0), + deletions: total.deletions + (item.removed ? (item.count ?? 0) : 0), + }), + { additions: 0, deletions: 0 }, + ) + return { + operation: result.operation, + target: result.target, + resource: result.resource, + existed: result.existed, + files: [ + { + file: result.resource, + patch: createTwoFilesPatch(result.resource, result.resource, result.before, result.after), + status: result.existed ? "modified" : "added", + ...counts, + }, + ], + } satisfies Output }).pipe( Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })), ), diff --git a/packages/core/test/file-mutation.test.ts b/packages/core/test/file-mutation.test.ts index 73f5a7a4dc..3dd5ebaf8b 100644 --- a/packages/core/test/file-mutation.test.ts +++ b/packages/core/test/file-mutation.test.ts @@ -80,9 +80,14 @@ describe("FileMutation", () => { const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" }) const files = yield* FileMutation.Service - yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" }) - yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" }) + const preservedResult = yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" }) + const createdResult = yield* files.writeTextPreservingBom({ + target: created, + content: "\uFEFF\uFEFF\uFEFFcreated", + }) + expect(preservedResult).toMatchObject({ existed: true, before: "before", after: "after" }) + expect(createdResult).toMatchObject({ existed: false, before: "", after: "created" }) expect(yield* Effect.promise(() => fs.readFile(preservedPath, "utf8"))).toBe("\uFEFFafter") expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated") }).pipe(provide(directory)), diff --git a/packages/core/test/tool-write.test.ts b/packages/core/test/tool-write.test.ts index 67a001e0eb..a42d69423b 100644 --- a/packages/core/test/tool-write.test.ts +++ b/packages/core/test/tool-write.test.ts @@ -120,7 +120,7 @@ describe("WriteTool", () => { Effect.gen(function* () { expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"]) const settled = yield* settleTool(registry, call({ path: "src/new.txt", content: "created" })) - expect(settled).toEqual({ + expect(settled).toMatchObject({ result: { type: "text", value: "Created file successfully: src/new.txt" }, output: { structured: { @@ -128,6 +128,14 @@ describe("WriteTool", () => { target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"), resource: "src/new.txt", existed: false, + files: [ + { + file: "src/new.txt", + status: "added", + additions: 1, + deletions: 0, + }, + ], }, content: [{ type: "text", text: "Created file successfully: src/new.txt" }], }, @@ -156,7 +164,21 @@ describe("WriteTool", () => { Effect.andThen((settled) => Effect.gen(function* () { expect(settled.result).toEqual({ type: "text", value: "Wrote file successfully: existing.txt" }) - expect(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true }) + expect(settled.output?.structured).toMatchObject({ + resource: "existing.txt", + existed: true, + files: [ + { + file: "existing.txt", + status: "modified", + additions: 1, + deletions: 1, + }, + ], + }) + const structured = settled.output?.structured as WriteTool.Output + expect(structured.files[0]?.patch).toContain("-before") + expect(structured.files[0]?.patch).toContain("+after") expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe( "after", ) diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index 602534ffc5..6a9c903e78 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -2654,6 +2654,10 @@ function Write(props: ToolProps) { const code = createMemo(() => { return stringValue(props.input.content) ?? "" }) + const file = createMemo(() => parseApplyPatchFiles(props.metadata.files)[0]) + const patch = createMemo( + () => file()?.patch ?? createTwoFilesPatch("", stringValue(props.input.path) ?? "", "", code()), + ) const complete = createMemo(() => props.part.state.status === "completed") const view = createMemo(() => { if (ctx.config.diffs?.view === "unified") return "unified" @@ -2671,10 +2675,10 @@ function Write(props: ToolProps) { }} part={props.part} > - +