Aiden Cline 3 недель назад
Родитель
Сommit
1957e167dc

+ 13 - 6
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<WriteResult, TargetExistsError | FSUtil.Error>
   readonly write: (input: WriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
   /** Write text while retaining an existing UTF-8 BOM and emitting at most one BOM. */
-  readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
+  readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<TextWriteResult, FSUtil.Error>
   /** 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/, ""),
+          }
         }),
       ),
     )

+ 25 - 1
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 })),
                 ),

+ 7 - 2
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)),

+ 24 - 2
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",
               )

+ 6 - 2
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}
         >
-          <Show when={code()}>
+          <Show when={code() || file()?.additions || file()?.deletions}>
             <box paddingLeft={1}>
               <diff
-                diff={createTwoFilesPatch("", stringValue(props.input.path) ?? "", "", code())}
+                diff={patch()}
                 view={view()}
                 filetype={filetype(stringValue(props.input.path))}
                 syntaxStyle={syntax()}