Compare commits

...

4 Commits

Author SHA1 Message Date
Aiden Cline 3dffa01054 chore: merge v2 2026-07-23 17:41:37 -05:00
Aiden Cline 1957e167dc fix(core): return write diffs 2026-07-23 17:11:35 +00:00
Aiden Cline a8d2e8fa81 fix(tui): highlight written lines 2026-07-23 16:54:23 +00:00
Aiden Cline d0aae842a1 fix(tui): preview written file content 2026-07-23 16:47:10 +00:00
5 changed files with 109 additions and 22 deletions
+13 -6
View File
@@ -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/, ""),
}
}), }),
), ),
) )
+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 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 })),
+7 -2
View File
@@ -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)),
+24 -2
View File
@@ -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",
) )
+40 -11
View File
@@ -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>