Compare commits

..
Author SHA1 Message Date
James Long 04c23b8846 feat(tui): expose native OpenCode theme 2026-07-23 16:35:57 +00:00
9 changed files with 36 additions and 114 deletions
+6 -13
View File
@@ -44,11 +44,6 @@ 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
@@ -61,7 +56,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<TextWriteResult, FSUtil.Error>
readonly writeTextPreservingBom: (input: TextWriteInput) => Effect.Effect<WriteResult, FSUtil.Error>
/** Commit only if an existing target still has the expected bytes. */
readonly writeIfUnchanged: (
input: ConditionalWriteInput,
@@ -117,13 +112,11 @@ const layer = Layer.effect(
const current = yield* fs
.readFile(input.target.canonical)
.pipe(Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(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/, ""),
}
yield* fs.writeWithDirs(
input.target.canonical,
joinBom(next.text, Boolean(current && hasUtf8Bom(current)) || next.bom),
)
return writeResult(input.target, current !== undefined)
}),
),
)
+1 -25
View File
@@ -8,8 +8,6 @@ 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"
@@ -32,7 +30,6 @@ 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
@@ -87,28 +84,7 @@ export const Plugin = {
agent: context.agent,
source,
})
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
return yield* files.writeTextPreservingBom({ target, content: input.content })
}).pipe(
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
),
+2 -7
View File
@@ -80,14 +80,9 @@ describe("FileMutation", () => {
const created = yield* (yield* LocationMutation.Service).resolve({ path: "created.txt" })
const files = yield* FileMutation.Service
const preservedResult = yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
const createdResult = yield* files.writeTextPreservingBom({
target: created,
content: "\uFEFF\uFEFF\uFEFFcreated",
})
yield* files.writeTextPreservingBom({ target: preserved, content: "\uFEFFafter" })
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)),
+2 -24
View File
@@ -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).toMatchObject({
expect(settled).toEqual({
result: { type: "text", value: "Created file successfully: src/new.txt" },
output: {
structured: {
@@ -128,14 +128,6 @@ 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" }],
},
@@ -164,21 +156,7 @@ 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,
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(settled.output?.structured).toMatchObject({ resource: "existing.txt", existed: true })
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
"after",
)
+11 -40
View File
@@ -61,7 +61,6 @@ 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"
@@ -2648,56 +2647,28 @@ 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={complete()}>
<Match when={props.metadata.diagnostics !== undefined}>
<BlockTool
path={{
label: props.metadata.existed === false ? "# Created" : "# Wrote",
value: pathFormatter.format(stringValue(props.input.path)),
}}
path={{ label: "# Wrote", value: pathFormatter.format(stringValue(props.input.path)) }}
part={props.part}
>
<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>
<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>
<Diagnostics diagnostics={props.metadata.diagnostics} filePath={stringValue(props.input.path) ?? ""} />
</BlockTool>
</Match>
+6 -3
View File
@@ -1,11 +1,13 @@
import { Schema } from "effect"
import { resolveThemeColors } from "./resolve"
import { DEFAULT_THEMES, type Theme, type ThemeV1Json } from "./v1"
import { DEFAULT_THEMES_V1, type Theme, type ThemeV1Json } from "./v1"
import { DEFAULT_THEMES_V2 } from "./v2/defaults"
import { resolveThemeDocument, themeDecodeError } from "./v2/resolve"
import { ThemeDocument } from "./v2/schema"
import { migrateV1 } from "./v2/v1-migrate"
export { DEFAULT_THEMES, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1"
export { DEFAULT_THEMES, DEFAULT_THEMES_V1, generateSyntax, selectedForeground, type Theme, type ThemeV1Json } from "./v1"
export { DEFAULT_THEMES_V2 } from "./v2/defaults"
export { resolveThemeDocument, type ThemeDocument }
export type ThemeDocumentSource = Record<string, unknown>
@@ -20,7 +22,8 @@ const decodeThemeDocument = Schema.decodeUnknownSync(ThemeDocument)
function listThemes() {
// Priority: defaults < plugin installs < custom files < generated system.
const themes: Record<string, ThemeDocumentSource> = {
...DEFAULT_THEMES,
...DEFAULT_THEMES_V1,
...DEFAULT_THEMES_V2,
...pluginThemes,
...customThemes,
}
+3 -1
View File
@@ -108,7 +108,7 @@ export type ThemeV1Json = {
}
}
export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
export const DEFAULT_THEMES_V1: Record<string, ThemeV1Json> = {
aura,
ayu,
catppuccin,
@@ -144,6 +144,8 @@ export const DEFAULT_THEMES: Record<string, ThemeV1Json> = {
carbonfox,
}
export const DEFAULT_THEMES = DEFAULT_THEMES_V1
export function selectedForeground(theme: Theme, bg?: RGBA): RGBA {
if (theme._hasSelectedListItemText) return theme.selectedListItemText
+4
View File
@@ -438,3 +438,7 @@ export const DEFAULT_THEME = {
},
},
} satisfies ThemeDocument
export const DEFAULT_THEMES_V2 = {
"opencode-v2": DEFAULT_THEME,
} as const satisfies Record<string, ThemeDocument>
+1 -1
View File
@@ -43,6 +43,6 @@ export type {
ResolvedThemeView,
StatefulColor,
} from "./types"
export { DEFAULT_CATEGORICAL } from "./defaults"
export { DEFAULT_CATEGORICAL, DEFAULT_THEME, DEFAULT_THEMES_V2 } from "./defaults"
export { migrateV1 } from "./v1-migrate"
export { selectTheme, selectThemeMode, supportsThemeMode, themeModes } from "./select"