Compare commits

...
5 changed files with 109 additions and 22 deletions
+13 -6
View File
@@ -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
View File
@@ -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
@@ -82,7 +85,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.map((output) => ({ output, content: toModelOutput(output) })),
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
+7 -2
View File
@@ -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
View File
@@ -120,13 +120,21 @@ describe("WriteTool", () => {
Effect.gen(function* () {
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
expect(settled).toEqual({
expect(settled).toMatchObject({
status: "completed",
output: {
operation: "write",
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", () => {
expect(settled.status).toBe("completed")
if (settled.status !== "completed") return
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
expect(settled.output).toMatchObject({
resource: "existing.txt",
existed: true,
files: [
{
file: "existing.txt",
status: "modified",
additions: 1,
deletions: 1,
},
],
})
const output = settled.output as WriteTool.Output
expect(output.files[0]?.patch).toContain("-before")
expect(output.files[0]?.patch).toContain("+after")
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
"after",
)
+40 -11
View File
@@ -62,6 +62,7 @@ import parsers from "../../parsers-config"
import { errorMessage } from "../../util/error"
import { useToast } from "../../ui/toast"
import stripAnsi from "strip-ansi"
import { createTwoFilesPatch } from "diff"
import { usePromptRef } from "../../context/prompt"
import { projectedPromptInput } from "../../prompt/codec"
import { useEpilogue } from "../../context/epilogue"
@@ -2693,28 +2694,56 @@ function Shell(props: ToolProps) {
}
function Write(props: ToolProps) {
const ctx = use()
const { themeV2, syntax } = useTheme()
const pathFormatter = usePathFormatter()
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"
if (ctx.config.diffs?.view === "split") return "split"
return ctx.width > 120 ? "split" : "unified"
})
return (
<Switch>
<Match when={props.metadata.diagnostics !== undefined}>
<Match when={complete()}>
<BlockTool
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
path={{
label: props.metadata.existed === false ? "# Created" : "# Wrote",
value: pathFormatter.format(stringValue(props.input.path)),
}}
part={props.part}
>
<line_number fg={themeV2.text.subdued} minWidth={3} paddingRight={1}>
<code
conceal={false}
fg={themeV2.text.default}
filetype={filetype(stringValue(props.input.path))}
syntaxStyle={syntax()}
content={code()}
/>
</line_number>
<Show when={code() || file()?.additions || file()?.deletions}>
<box paddingLeft={1}>
<diff
diff={patch()}
view={view()}
filetype={filetype(stringValue(props.input.path))}
syntaxStyle={syntax()}
showLineNumbers={true}
width="100%"
wrapMode={ctx.diffWrapMode()}
fg={themeV2.text.default}
addedBg={themeV2.diff.background.added}
removedBg={themeV2.diff.background.removed}
contextBg={themeV2.diff.background.context}
addedSignColor={themeV2.diff.highlight.added}
removedSignColor={themeV2.diff.highlight.removed}
lineNumberFg={themeV2.diff.lineNumber.text}
lineNumberBg={themeV2.diff.background.context}
addedLineNumberBg={themeV2.diff.lineNumber.background.added}
removedLineNumberBg={themeV2.diff.lineNumber.background.removed}
/>
</box>
</Show>
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.path) ?? ""} />
</BlockTool>
</Match>