mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-02 16:26:14 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3dffa01054 | |||
| 1957e167dc | |||
| a8d2e8fa81 | |||
| d0aae842a1 |
@@ -44,6 +44,11 @@ export interface WriteResult {
|
|||||||
readonly existed: boolean
|
readonly existed: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TextWriteResult extends WriteResult {
|
||||||
|
readonly before: string
|
||||||
|
readonly after: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface RemoveResult {
|
export interface RemoveResult {
|
||||||
readonly operation: "remove"
|
readonly operation: "remove"
|
||||||
readonly target: string
|
readonly target: string
|
||||||
@@ -56,7 +61,7 @@ export interface Interface {
|
|||||||
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
|
readonly create: (input: WriteInput) => Effect.Effect<WriteResult, TargetExistsError | FSUtil.Error>
|
||||||
readonly write: (input: WriteInput) => Effect.Effect<WriteResult, 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. */
|
/** 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. */
|
/** Commit only if an existing target still has the expected bytes. */
|
||||||
readonly writeIfUnchanged: (
|
readonly writeIfUnchanged: (
|
||||||
input: ConditionalWriteInput,
|
input: ConditionalWriteInput,
|
||||||
@@ -112,11 +117,13 @@ const layer = Layer.effect(
|
|||||||
const current = yield* fs
|
const current = yield* fs
|
||||||
.readFile(input.target.canonical)
|
.readFile(input.target.canonical)
|
||||||
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)))
|
||||||
yield* fs.writeWithDirs(
|
const content = joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom)
|
||||||
input.target.canonical,
|
yield* fs.writeWithDirs(input.target.canonical, content)
|
||||||
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
|
return {
|
||||||
)
|
...writeResult(input.target, current !== undefined),
|
||||||
return writeResult(input.target, current !== undefined)
|
before: current ? new TextDecoder().decode(current).replace(/^\uFEFF/, "") : "",
|
||||||
|
after: content.replace(/^\uFEFF/, ""),
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ export * as WriteTool from "./write"
|
|||||||
|
|
||||||
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
import type { Context as PluginContext } from "@opencode-ai/plugin/v2/effect/plugin"
|
||||||
import { ToolFailure } from "@opencode-ai/ai"
|
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 { Effect, Schema } from "effect"
|
||||||
import { FileMutation } from "../file-mutation"
|
import { FileMutation } from "../file-mutation"
|
||||||
import { LocationMutation } from "../location-mutation"
|
import { LocationMutation } from "../location-mutation"
|
||||||
@@ -30,6 +32,7 @@ export const Output = Schema.Struct({
|
|||||||
target: Schema.String,
|
target: Schema.String,
|
||||||
resource: Schema.String,
|
resource: Schema.String,
|
||||||
existed: Schema.Boolean,
|
existed: Schema.Boolean,
|
||||||
|
files: Schema.Array(FileDiff.Info),
|
||||||
})
|
})
|
||||||
export type Output = typeof Output.Type
|
export type Output = typeof Output.Type
|
||||||
|
|
||||||
@@ -82,7 +85,28 @@ export const Plugin = {
|
|||||||
agent: context.agent,
|
agent: context.agent,
|
||||||
source,
|
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(
|
}).pipe(
|
||||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||||
|
|||||||
@@ -80,9 +80,14 @@ describe("FileMutation", () => {
|
|||||||
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
|
||||||
const files = yield* FileMutation.Service
|
const files = yield* FileMutation.Service
|
||||||
|
|
||||||
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
|
const preservedResult = yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
|
||||||
yield* files.writeTextPreservingBom({ target: created, content: "\uFEFF\uFEFF\uFEFFcreated" })
|
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(preservedPath, "utf8"))).toBe("\uFEFFafter")
|
||||||
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
|
expect(yield* Effect.promise(() => fs.readFile(created.canonical, "utf8"))).toBe("\uFEFFcreated")
|
||||||
}).pipe(provide(directory)),
|
}).pipe(provide(directory)),
|
||||||
|
|||||||
@@ -120,13 +120,21 @@ describe("WriteTool", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
|
expect((yield* toolDefinitions(registry)).map((tool) => tool.name)).toEqual(["write"])
|
||||||
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
|
const settled = yield* executeTool(registry, call({ path: "src/new.txt", content: "created" }))
|
||||||
expect(settled).toEqual({
|
expect(settled).toMatchObject({
|
||||||
status: "completed",
|
status: "completed",
|
||||||
output: {
|
output: {
|
||||||
operation: "write",
|
operation: "write",
|
||||||
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
|
target: path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt"),
|
||||||
resource: "src/new.txt",
|
resource: "src/new.txt",
|
||||||
existed: false,
|
existed: false,
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
file: "src/new.txt",
|
||||||
|
status: "added",
|
||||||
|
additions: 1,
|
||||||
|
deletions: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
|
content: [{ type: "text", text: "Created file successfully: src/new.txt" }],
|
||||||
})
|
})
|
||||||
@@ -156,7 +164,21 @@ describe("WriteTool", () => {
|
|||||||
expect(settled.status).toBe("completed")
|
expect(settled.status).toBe("completed")
|
||||||
if (settled.status !== "completed") return
|
if (settled.status !== "completed") return
|
||||||
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
|
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(
|
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
|
||||||
"after",
|
"after",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ import parsers from "../../parsers-config"
|
|||||||
import { errorMessage } from "../../util/error"
|
import { errorMessage } from "../../util/error"
|
||||||
import { useToast } from "../../ui/toast"
|
import { useToast } from "../../ui/toast"
|
||||||
import stripAnsi from "strip-ansi"
|
import stripAnsi from "strip-ansi"
|
||||||
|
import { createTwoFilesPatch } from "diff"
|
||||||
import { usePromptRef } from "../../context/prompt"
|
import { usePromptRef } from "../../context/prompt"
|
||||||
import { projectedPromptInput } from "../../prompt/codec"
|
import { projectedPromptInput } from "../../prompt/codec"
|
||||||
import { useEpilogue } from "../../context/epilogue"
|
import { useEpilogue } from "../../context/epilogue"
|
||||||
@@ -2693,28 +2694,56 @@ function Shell(props: ToolProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Write(props: ToolProps) {
|
function Write(props: ToolProps) {
|
||||||
|
const ctx = use()
|
||||||
const { themeV2, syntax } = useTheme()
|
const { themeV2, syntax } = useTheme()
|
||||||
const pathFormatter = usePathFormatter()
|
const pathFormatter = usePathFormatter()
|
||||||
const code = createMemo(() => {
|
const code = createMemo(() => {
|
||||||
return stringValue(props.input.content) ?? ""
|
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 (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={props.metadata.diagnostics !== undefined}>
|
<Match when={complete()}>
|
||||||
<BlockTool
|
<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}
|
part={props.part}
|
||||||
>
|
>
|
||||||
<line_number fg={themeV2.text.subdued} minWidth={3} paddingRight={1}>
|
<Show when={code() || file()?.additions || file()?.deletions}>
|
||||||
<code
|
<box paddingLeft={1}>
|
||||||
conceal={false}
|
<diff
|
||||||
fg={themeV2.text.default}
|
diff={patch()}
|
||||||
filetype={filetype(stringValue(props.input.path))}
|
view={view()}
|
||||||
syntaxStyle={syntax()}
|
filetype={filetype(stringValue(props.input.path))}
|
||||||
content={code()}
|
syntaxStyle={syntax()}
|
||||||
/>
|
showLineNumbers={true}
|
||||||
</line_number>
|
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) ?? ""} />
|
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.path) ?? ""} />
|
||||||
</BlockTool>
|
</BlockTool>
|
||||||
</Match>
|
</Match>
|
||||||
|
|||||||
Reference in New Issue
Block a user