mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 22:26:12 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1a02b149c | ||
|
|
94ee274aeb | ||
|
|
3c259fc552 | ||
|
|
210be4b749 | ||
|
|
464649e67e |
@@ -5,6 +5,7 @@ import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { dirname } from "path"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
|
||||
export interface Target {
|
||||
readonly canonical: string
|
||||
@@ -108,13 +109,13 @@ const layer = Layer.effect(
|
||||
const writeTextPreservingBom = Effect.fn("FileMutation.writeTextPreservingBom")((input: TextWriteInput) =>
|
||||
withTargetLock(input.target)(
|
||||
Effect.gen(function* () {
|
||||
const next = splitBom(input.content)
|
||||
const next = Bom.split(input.content)
|
||||
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),
|
||||
Bom.join(next.text, Boolean(current && Bom.has(current)) || next.bom),
|
||||
)
|
||||
return writeResult(input.target, current !== undefined)
|
||||
}),
|
||||
@@ -172,20 +173,6 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
function splitBom(text: string) {
|
||||
const stripped = text.replace(/^\uFEFF+/, "")
|
||||
return { bom: stripped.length !== text.length, text: stripped }
|
||||
}
|
||||
|
||||
function joinBom(text: string, bom: boolean) {
|
||||
const stripped = splitBom(text).text
|
||||
return bom ? `\uFEFF${stripped}` : stripped
|
||||
}
|
||||
|
||||
function hasUtf8Bom(content: Uint8Array) {
|
||||
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||
}
|
||||
|
||||
function sameBytes(left: Uint8Array, right: Uint8Array) {
|
||||
if (left.length !== right.length) return false
|
||||
return left.every((byte, index) => byte === right[index])
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
export * as Formatter from "./formatter"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { Config } from "./config"
|
||||
import { Location } from "./location"
|
||||
import { make, type Info } from "./formatter/builtins"
|
||||
|
||||
export const Status = Schema.Struct({
|
||||
name: Schema.String,
|
||||
extensions: Schema.Array(Schema.String),
|
||||
enabled: Schema.Boolean,
|
||||
}).annotate({ identifier: "FormatterStatus" })
|
||||
export type Status = typeof Status.Type
|
||||
|
||||
export interface Interface {
|
||||
readonly init: () => Effect.Effect<void>
|
||||
readonly status: () => Effect.Effect<Status[]>
|
||||
readonly file: (filepath: string) => Effect.Effect<boolean>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Formatter") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const location = yield* Location.Service
|
||||
const npm = yield* Npm.Service
|
||||
const processes = yield* AppProcess.Service
|
||||
const commands = new Map<string, string[] | false>()
|
||||
let formatters: Info[] = []
|
||||
|
||||
const load = yield* Effect.cached(
|
||||
Effect.gen(function* () {
|
||||
const configured = Config.latest(yield* config.entries(), "formatter")
|
||||
if (!configured) {
|
||||
yield* Effect.logInfo("all formatters are disabled")
|
||||
return
|
||||
}
|
||||
|
||||
const builtIns = make({
|
||||
directory: location.directory,
|
||||
worktree: location.project.directory,
|
||||
fs,
|
||||
npm,
|
||||
processes,
|
||||
})
|
||||
formatters = builtIns
|
||||
if (configured === true) return
|
||||
if (configured.ruff?.disabled || configured.uv?.disabled) {
|
||||
formatters = formatters.filter((formatter) => formatter.name !== "ruff" && formatter.name !== "uv")
|
||||
}
|
||||
|
||||
for (const [name, entry] of Object.entries(configured)) {
|
||||
const index = formatters.findIndex((formatter) => formatter.name === name)
|
||||
if (entry.disabled) {
|
||||
if (index !== -1) formatters.splice(index, 1)
|
||||
continue
|
||||
}
|
||||
|
||||
const builtIn = builtIns.find((formatter) => formatter.name === name)
|
||||
const formatter: Info = {
|
||||
name,
|
||||
extensions: entry.extensions ?? builtIn?.extensions ?? [],
|
||||
environment: { ...builtIn?.environment, ...entry.environment },
|
||||
enabled:
|
||||
builtIn && !entry.command ? builtIn.enabled : Effect.succeed(entry.command ? [...entry.command] : false),
|
||||
}
|
||||
if (index === -1) formatters.push(formatter)
|
||||
else formatters[index] = formatter
|
||||
}
|
||||
}).pipe(Effect.withSpan("Formatter.load")),
|
||||
)
|
||||
|
||||
const command = Effect.fnUntraced(function* (formatter: Info) {
|
||||
const cached = commands.get(formatter.name)
|
||||
if (cached !== undefined) return cached
|
||||
const result = yield* formatter.enabled
|
||||
if (result !== false) commands.set(formatter.name, result)
|
||||
return result
|
||||
})
|
||||
|
||||
const init = Effect.fn("Formatter.init")(function* () {
|
||||
yield* load
|
||||
})
|
||||
|
||||
const status = Effect.fn("Formatter.status")(function* () {
|
||||
yield* load
|
||||
return yield* Effect.forEach(formatters, (formatter) =>
|
||||
command(formatter).pipe(
|
||||
Effect.map((enabled) => ({
|
||||
name: formatter.name,
|
||||
extensions: [...formatter.extensions],
|
||||
enabled: enabled !== false,
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const file = Effect.fn("Formatter.file")(function* (filepath: string) {
|
||||
yield* load
|
||||
const matching = formatters.filter((formatter) =>
|
||||
formatter.extensions.includes(path.extname(filepath)),
|
||||
)
|
||||
|
||||
for (const formatter of matching) {
|
||||
const enabled = yield* command(formatter)
|
||||
if (enabled === false) continue
|
||||
const cmd = enabled.map((argument) => argument.replace("$FILE", filepath))
|
||||
yield* Effect.logInfo("formatting file", { file: filepath, command: cmd })
|
||||
const result = yield* processes
|
||||
.run(
|
||||
ChildProcess.make(cmd[0], cmd.slice(1), {
|
||||
cwd: location.directory,
|
||||
env: formatter.environment,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.catch((error) =>
|
||||
Effect.logError("failed to format file", {
|
||||
file: filepath,
|
||||
command: cmd,
|
||||
error: error.message,
|
||||
}).pipe(Effect.as(undefined)),
|
||||
),
|
||||
)
|
||||
if (!result) continue
|
||||
if (result.exitCode === 0) return true
|
||||
yield* Effect.logError("formatter exited unsuccessfully", {
|
||||
file: filepath,
|
||||
command: cmd,
|
||||
exitCode: result.exitCode,
|
||||
})
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
return Service.of({ init, status, file })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, FSUtil.node, Location.node, Npm.node, AppProcess.node],
|
||||
})
|
||||
@@ -0,0 +1,315 @@
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { AppProcess } from "@opencode-ai/util/process"
|
||||
import { which } from "../util/which"
|
||||
|
||||
export interface Info {
|
||||
readonly name: string
|
||||
readonly environment?: Record<string, string>
|
||||
readonly extensions: readonly string[]
|
||||
readonly enabled: Effect.Effect<string[] | false>
|
||||
}
|
||||
|
||||
export function make(input: {
|
||||
readonly directory: string
|
||||
readonly worktree: string
|
||||
readonly fs: FSUtil.Interface
|
||||
readonly npm: Npm.Interface
|
||||
readonly processes: AppProcess.Interface
|
||||
readonly experimentalOxfmt?: boolean
|
||||
}) {
|
||||
const disabled = false as const
|
||||
const findUp = (target: string) => input.fs.findUp(target, input.directory, input.worktree)
|
||||
const readText = (file: string) => input.fs.readFileString(file).pipe(Effect.orElseSucceed(() => ""))
|
||||
const commandOutput = (command: string[]) =>
|
||||
input.processes
|
||||
.run(
|
||||
ChildProcess.make(command[0], command.slice(1), {
|
||||
cwd: input.directory,
|
||||
extendEnv: true,
|
||||
stdin: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.option)
|
||||
|
||||
const gofmt: Info = {
|
||||
name: "gofmt",
|
||||
extensions: [".go"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("gofmt")
|
||||
return match ? [match, "-w", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const mix: Info = {
|
||||
name: "mix",
|
||||
extensions: [".ex", ".exs", ".eex", ".heex", ".leex", ".neex", ".sface"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("mix")
|
||||
return match ? [match, "format", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const prettier: Info = {
|
||||
name: "prettier",
|
||||
environment: { BUN_BE_BUN: "1" },
|
||||
extensions: [
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".html",
|
||||
".htm",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".vue",
|
||||
".svelte",
|
||||
".json",
|
||||
".jsonc",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".xml",
|
||||
".md",
|
||||
".mdx",
|
||||
".graphql",
|
||||
".gql",
|
||||
],
|
||||
enabled: Effect.gen(function* () {
|
||||
for (const file of yield* findUp("package.json")) {
|
||||
if (!hasDependency(yield* input.fs.readJson(file), "prettier")) continue
|
||||
const bin = yield* input.npm.which("prettier")
|
||||
if (bin) return [bin, "--write", "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const oxfmt: Info = {
|
||||
name: "oxfmt",
|
||||
environment: { BUN_BE_BUN: "1" },
|
||||
extensions: [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts"],
|
||||
enabled: Effect.gen(function* () {
|
||||
for (const file of yield* findUp("package.json")) {
|
||||
if (!hasDependency(yield* input.fs.readJson(file), "oxfmt")) continue
|
||||
const bin = yield* input.npm.which("oxfmt")
|
||||
if (bin) return [bin, "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const biome: Info = {
|
||||
name: "biome",
|
||||
environment: { BUN_BE_BUN: "1" },
|
||||
extensions: [
|
||||
".js",
|
||||
".jsx",
|
||||
".mjs",
|
||||
".cjs",
|
||||
".ts",
|
||||
".tsx",
|
||||
".mts",
|
||||
".cts",
|
||||
".html",
|
||||
".htm",
|
||||
".css",
|
||||
".scss",
|
||||
".sass",
|
||||
".less",
|
||||
".vue",
|
||||
".svelte",
|
||||
".json",
|
||||
".jsonc",
|
||||
".yaml",
|
||||
".yml",
|
||||
".toml",
|
||||
".xml",
|
||||
".md",
|
||||
".mdx",
|
||||
".graphql",
|
||||
".gql",
|
||||
],
|
||||
enabled: Effect.gen(function* () {
|
||||
const found = yield* Effect.forEach(["biome.json", "biome.jsonc"], findUp, { concurrency: "unbounded" })
|
||||
if (!found.some((items) => items.length > 0)) return disabled
|
||||
const bin = yield* input.npm.which("@biomejs/biome")
|
||||
return bin ? [bin, "format", "--write", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const zig: Info = {
|
||||
name: "zig",
|
||||
extensions: [".zig", ".zon"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("zig")
|
||||
return match ? [match, "fmt", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const clang: Info = {
|
||||
name: "clang-format",
|
||||
extensions: [".c", ".cc", ".cpp", ".cxx", ".c++", ".h", ".hh", ".hpp", ".hxx", ".h++", ".ino", ".C", ".H"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".clang-format")).length) return disabled
|
||||
const match = which("clang-format")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const ktlint: Info = {
|
||||
name: "ktlint",
|
||||
extensions: [".kt", ".kts"],
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which("ktlint")
|
||||
return match ? [match, "-F", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const ruff: Info = {
|
||||
name: "ruff",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!which("ruff")) return disabled
|
||||
for (const config of ["pyproject.toml", "ruff.toml", ".ruff.toml"]) {
|
||||
const found = yield* findUp(config)
|
||||
if (!found.length) continue
|
||||
if (config !== "pyproject.toml" || (yield* readText(found[0])).includes("[tool.ruff]")) {
|
||||
return ["ruff", "format", "$FILE"]
|
||||
}
|
||||
}
|
||||
for (const dependency of ["requirements.txt", "pyproject.toml", "Pipfile"]) {
|
||||
const found = yield* findUp(dependency)
|
||||
if (found.length && (yield* readText(found[0])).includes("ruff")) return ["ruff", "format", "$FILE"]
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const air: Info = {
|
||||
name: "air",
|
||||
extensions: [".R"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = which("air")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "--help"])
|
||||
if (output._tag === "None" || output.value.exitCode !== 0) return disabled
|
||||
const first = output.value.stdout.toString("utf8").split("\n")[0]
|
||||
return first.includes("R language") && first.includes("formatter") ? [bin, "format", "$FILE"] : disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const uv: Info = {
|
||||
name: "uv",
|
||||
extensions: [".py", ".pyi"],
|
||||
enabled: Effect.gen(function* () {
|
||||
const bin = which("uv")
|
||||
if (!bin) return disabled
|
||||
const output = yield* commandOutput([bin, "format", "--help"])
|
||||
return output._tag === "Some" && output.value.exitCode === 0
|
||||
? [bin, "format", "--", "$FILE"]
|
||||
: disabled
|
||||
}),
|
||||
}
|
||||
|
||||
const rubocop = executable("rubocop", [".rb", ".rake", ".gemspec", ".ru"], ["--autocorrect", "$FILE"])
|
||||
const standardrb = executable("standardrb", [".rb", ".rake", ".gemspec", ".ru"], ["--fix", "$FILE"])
|
||||
const htmlbeautifier = executable("htmlbeautifier", [".erb", ".html.erb"], ["$FILE"])
|
||||
const dart = executable("dart", [".dart"], ["format", "$FILE"])
|
||||
|
||||
const ocamlformat: Info = {
|
||||
name: "ocamlformat",
|
||||
extensions: [".ml", ".mli"],
|
||||
enabled: Effect.gen(function* () {
|
||||
if (!(yield* findUp(".ocamlformat")).length) return disabled
|
||||
const match = which("ocamlformat")
|
||||
return match ? [match, "-i", "$FILE"] : disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const terraform = executable("terraform", [".tf", ".tfvars"], ["fmt", "$FILE"])
|
||||
const latexindent = executable("latexindent", [".tex"], ["-w", "-s", "$FILE"])
|
||||
const gleam = executable("gleam", [".gleam"], ["format", "$FILE"])
|
||||
const shfmt = executable("shfmt", [".sh", ".bash"], ["-w", "$FILE"])
|
||||
const nixfmt = executable("nixfmt", [".nix"], ["$FILE"])
|
||||
const rustfmt = executable("rustfmt", [".rs"], ["$FILE"])
|
||||
|
||||
const pint: Info = {
|
||||
name: "pint",
|
||||
extensions: [".php"],
|
||||
enabled: Effect.gen(function* () {
|
||||
for (const file of yield* findUp("composer.json")) {
|
||||
const json = yield* input.fs.readJson(file)
|
||||
if (hasRecordKey(json, "require", "laravel/pint") || hasRecordKey(json, "require-dev", "laravel/pint")) {
|
||||
return ["./vendor/bin/pint", "$FILE"]
|
||||
}
|
||||
}
|
||||
return disabled
|
||||
}).pipe(Effect.orElseSucceed(() => disabled)),
|
||||
}
|
||||
|
||||
const ormolu = executable("ormolu", [".hs"], ["-i", "$FILE"])
|
||||
const cljfmt = executable("cljfmt", [".clj", ".cljs", ".cljc", ".edn"], ["fix", "--quiet", "$FILE"])
|
||||
const dfmt = executable("dfmt", [".d"], ["-i", "$FILE"])
|
||||
|
||||
return [
|
||||
gofmt,
|
||||
mix,
|
||||
oxfmt,
|
||||
prettier,
|
||||
biome,
|
||||
zig,
|
||||
clang,
|
||||
ktlint,
|
||||
ruff,
|
||||
air,
|
||||
uv,
|
||||
rubocop,
|
||||
standardrb,
|
||||
htmlbeautifier,
|
||||
dart,
|
||||
ocamlformat,
|
||||
terraform,
|
||||
latexindent,
|
||||
gleam,
|
||||
shfmt,
|
||||
nixfmt,
|
||||
rustfmt,
|
||||
pint,
|
||||
ormolu,
|
||||
cljfmt,
|
||||
dfmt,
|
||||
] satisfies Info[]
|
||||
}
|
||||
|
||||
function executable(name: string, extensions: readonly string[], args: string[]): Info {
|
||||
return {
|
||||
name,
|
||||
extensions,
|
||||
enabled: Effect.sync(() => {
|
||||
const match = which(name)
|
||||
return match ? [match, ...args] : false
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function hasDependency(input: unknown, dependency: string) {
|
||||
return hasRecordKey(input, "dependencies", dependency) || hasRecordKey(input, "devDependencies", dependency)
|
||||
}
|
||||
|
||||
function hasRecordKey(input: unknown, field: string, key: string) {
|
||||
if (!isRecord(input)) return false
|
||||
return isRecord(input[field]) && key in input[field]
|
||||
}
|
||||
|
||||
function isRecord(input: unknown): input is Record<string, unknown> {
|
||||
return Boolean(input && typeof input === "object" && !Array.isArray(input))
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Node } from "@opencode-ai/util/effect/app-node"
|
||||
import { Bus } from "./bus"
|
||||
import { FileMutation } from "./file-mutation"
|
||||
import { Formatter } from "./formatter"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Generate } from "./generate"
|
||||
@@ -73,6 +74,7 @@ const locationServiceNodes = [
|
||||
InstructionDiscovery.node,
|
||||
LocationMutation.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
MCP.node,
|
||||
Permission.node,
|
||||
Tool.node,
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ConfigSkillPlugin } from "../config/plugin/skill"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch"
|
||||
import { Bus } from "../bus"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { Form } from "../form"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -68,6 +69,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
const config = yield* Config.Service
|
||||
const bus = yield* Bus.Service
|
||||
const mutation = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const filesystem = yield* FileSystem.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const global = yield* Global.Service
|
||||
@@ -98,6 +100,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
|
||||
Context.make(Config.Service, config),
|
||||
Context.make(Bus.Service, bus),
|
||||
Context.make(FileMutation.Service, mutation),
|
||||
Context.make(Formatter.Service, formatter),
|
||||
Context.make(FileSystem.Service, filesystem),
|
||||
Context.make(FSUtil.Service, fs),
|
||||
Context.make(Global.Service, global),
|
||||
|
||||
@@ -14,6 +14,7 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
import { Bus } from "../bus"
|
||||
import { FileMutation } from "../file-mutation"
|
||||
import { Formatter } from "../formatter"
|
||||
import { FileSystem } from "../filesystem"
|
||||
import { Watcher } from "../filesystem/watcher"
|
||||
import { Form } from "../form"
|
||||
@@ -318,6 +319,7 @@ export const node = makeLocationNode({
|
||||
Config.node,
|
||||
Bus.node,
|
||||
FileMutation.node,
|
||||
Formatter.node,
|
||||
FileSystem.node,
|
||||
FSUtil.node,
|
||||
Global.node,
|
||||
|
||||
@@ -9,9 +9,11 @@ export * as EditTool from "./edit"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -99,7 +101,6 @@ const findLineOccurrences = (content: string, search: string) => {
|
||||
}
|
||||
|
||||
/** Deferred edit behavior and UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Add formatter integration after formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||
// TODO: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
||||
@@ -109,6 +110,7 @@ export const Plugin = {
|
||||
effect: Effect.fn("EditTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
@@ -167,9 +169,8 @@ export const Plugin = {
|
||||
if (info.type === "Directory") {
|
||||
return yield* new ToolFailure({ message: `Path is a directory, not a file: ${input.path}` })
|
||||
}
|
||||
const bytes = yield* fs.readFile(target.canonical)
|
||||
const bom = bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf
|
||||
const source = new TextDecoder().decode(bom ? bytes.slice(3) : bytes)
|
||||
const original = yield* Bom.readFile(fs, target.canonical)
|
||||
const source = original.text
|
||||
const ending = source.includes(crlf) ? crlf : "\n"
|
||||
const oldString = input.oldString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
const newString = input.newString.replaceAll(crlf, "\n").replaceAll("\n", ending)
|
||||
@@ -201,23 +202,27 @@ export const Plugin = {
|
||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||
source,
|
||||
)
|
||||
const counts = diffLines(source, replaced).reduce(
|
||||
const replacementBom = replaced.startsWith("\uFEFF")
|
||||
const result = yield* files.write({
|
||||
target,
|
||||
content: Bom.join(replaced, original.bom || replacementBom),
|
||||
})
|
||||
const bom = original.bom || replacementBom
|
||||
const formatted = (yield* formatter.file(target.canonical))
|
||||
? yield* Bom.syncFile(fs, target.canonical, bom)
|
||||
: (yield* Bom.readFile(fs, target.canonical)).text
|
||||
const counts = diffLines(source, formatted).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
}),
|
||||
{ additions: 0, deletions: 0 },
|
||||
)
|
||||
const replacementBom = replaced.startsWith("\uFEFF")
|
||||
const result = yield* files.write({
|
||||
target,
|
||||
content: `${bom || replacementBom ? "\uFEFF" : ""}${replacementBom ? replaced.slice(1) : replaced}`,
|
||||
})
|
||||
return {
|
||||
files: [
|
||||
{
|
||||
file: result.resource,
|
||||
patch: createTwoFilesPatch(result.resource, result.resource, source, replaced),
|
||||
patch: createTwoFilesPatch(result.resource, result.resource, source, formatted),
|
||||
status: "modified" as const,
|
||||
...counts,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,9 @@ import { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { PlatformError } from "effect/PlatformError"
|
||||
import path from "path"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { Location } from "../../location"
|
||||
import { Patch } from "@opencode-ai/util/patch"
|
||||
import { Permission } from "../../permission"
|
||||
@@ -68,6 +70,7 @@ export const Plugin = {
|
||||
id: "opencode.tool.patch",
|
||||
effect: Effect.fn("PatchTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const fs = yield* FSUtil.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const location = yield* Location.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
@@ -129,15 +132,16 @@ export const Plugin = {
|
||||
...hunk,
|
||||
target,
|
||||
before: "",
|
||||
after: (hunk.contents.endsWith("\n") || hunk.contents === ""
|
||||
? hunk.contents
|
||||
: `${hunk.contents}\n`
|
||||
).replace(/^\uFEFF/, ""),
|
||||
after: Bom.split(
|
||||
hunk.contents.endsWith("\n") || hunk.contents === ""
|
||||
? hunk.contents
|
||||
: `${hunk.contents}\n`,
|
||||
).text,
|
||||
})
|
||||
return
|
||||
}
|
||||
if (hunk.type === "delete") {
|
||||
const content = yield* fs.readFile(target.canonical).pipe(
|
||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
@@ -145,8 +149,7 @@ export const Plugin = {
|
||||
}),
|
||||
),
|
||||
)
|
||||
const original = new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
|
||||
prepared.push({ ...hunk, target, before: original.replace(/^\uFEFF/, ""), after: "" })
|
||||
prepared.push({ ...hunk, target, before: content.text, after: "" })
|
||||
return
|
||||
}
|
||||
const previous = updates.get(target.canonical)
|
||||
@@ -166,18 +169,17 @@ export const Plugin = {
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: path is a directory`,
|
||||
})
|
||||
}
|
||||
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(
|
||||
yield* fs.readFile(target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
const content = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.mapError(
|
||||
(error) =>
|
||||
new ToolFailure({
|
||||
message: `patch verification failed: Failed to read file to update ${target.canonical}: ${errorMessage(error)}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
return Bom.join(content.text, content.bom)
|
||||
}))
|
||||
const before = original.replace(/^\uFEFF/, "")
|
||||
const before = Bom.split(original).text
|
||||
const update = yield* Effect.try({
|
||||
try: () => Patch.derive(hunk.path, hunk.chunks, original),
|
||||
catch: (error) =>
|
||||
@@ -217,7 +219,7 @@ export const Plugin = {
|
||||
)
|
||||
}
|
||||
|
||||
const patchFiles = prepared.map(patchFile)
|
||||
const patchFiles = prepared.map((change) => patchFile(change))
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [...new Set(targets.map((target) => target.resource))],
|
||||
@@ -295,7 +297,31 @@ export const Plugin = {
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
return { applied, files: patchFiles }
|
||||
const formatted = new Map<string, string>()
|
||||
yield* Effect.forEach(
|
||||
[...new Set(applied.filter((item) => item.type !== "delete").map((item) => item.target))],
|
||||
(target) =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Bom.readFile(fs, target).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to read ${target}`, error)),
|
||||
)
|
||||
formatted.set(
|
||||
target,
|
||||
(yield* formatter.file(target))
|
||||
? yield* Bom.syncFile(fs, target, current.bom).pipe(
|
||||
Effect.mapError((error) => fail(`Failed to sync ${target}`, error)),
|
||||
)
|
||||
: current.text,
|
||||
)
|
||||
}),
|
||||
{ discard: true },
|
||||
)
|
||||
const files = yield* Effect.forEach(prepared, (change) => {
|
||||
if (change.type === "delete") return Effect.succeed(patchFile(change))
|
||||
const target = change.type === "update" && change.moveTarget ? change.moveTarget : change.target
|
||||
return Effect.succeed(patchFile(change, formatted.get(target.canonical)))
|
||||
})
|
||||
return { applied, files }
|
||||
}).pipe(
|
||||
Effect.map((output) => ({
|
||||
output,
|
||||
@@ -337,15 +363,15 @@ function errorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function patchFile(change: Prepared): typeof FileDiff.Info.Type {
|
||||
function patchFile(change: Prepared, after = change.after): typeof FileDiff.Info.Type {
|
||||
const target = (change.type === "update" ? change.moveTarget : undefined)?.resource ?? change.target.resource
|
||||
const patch = trimDiff(
|
||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, change.after),
|
||||
createTwoFilesPatch(change.target.canonical, change.target.canonical, change.before, after),
|
||||
)
|
||||
const counts =
|
||||
change.type === "delete"
|
||||
? { additions: 0, deletions: change.before.split("\n").length }
|
||||
: diffLines(change.before, change.after).reduce(
|
||||
: diffLines(change.before, after).reduce(
|
||||
(result, item) => ({
|
||||
additions: result.additions + (item.added ? (item.count ?? 0) : 0),
|
||||
deletions: result.deletions + (item.removed ? (item.count ?? 0) : 0),
|
||||
|
||||
@@ -198,20 +198,20 @@ export const Plugin = {
|
||||
|
||||
const settleShell = Effect.fn("ShellTool.settleShell")(function* () {
|
||||
const final = yield* shell.wait(info.id)
|
||||
const capture = yield* captureShell()
|
||||
|
||||
// `exit` is optionalKey in the Output schema; a present-but-undefined key
|
||||
// fails output encoding, so omit it when the process has no exit code.
|
||||
if (final.status === "timeout") {
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: `Command exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: false,
|
||||
output: `${capture.output}\n\nCommand exceeded timeout of ${finalTimeout} ms. Retry with a larger timeout if the command is expected to take longer.`,
|
||||
truncated: capture.truncated,
|
||||
timeout: true,
|
||||
status: "completed" as const,
|
||||
}
|
||||
}
|
||||
|
||||
const capture = yield* captureShell()
|
||||
return {
|
||||
...(final.exit !== undefined ? { exit: final.exit } : {}),
|
||||
output: capture.output,
|
||||
|
||||
@@ -13,15 +13,19 @@ export const name = "subagent"
|
||||
|
||||
const NO_TEXT = "Subagent completed without a text response."
|
||||
const backgroundStarted = (sessionID: SessionSchema.ID) =>
|
||||
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes. DO NOT sleep, poll, or proactively check on its progress.`
|
||||
[
|
||||
`The subagent is working in the background (id: ${sessionID}). You will be notified automatically when it finishes.`,
|
||||
"DO NOT sleep, poll for progress, ask the subagent for status, or duplicate this subagent's work; avoid working with the same files or topics it is using.",
|
||||
"Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.",
|
||||
].join("\n")
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
agent: Schema.String.annotate({ description: "The configured agent to run as the subagent" }),
|
||||
description: Schema.String.annotate({ description: "A short description of the subagent's task" }),
|
||||
agent: Schema.String.annotate({ description: "The type of specialized agent to use for this task" }),
|
||||
description: Schema.String.annotate({ description: "A short 3-5 word label for the task, displayed to the user" }),
|
||||
prompt: Schema.String.annotate({ description: "The task for the subagent to perform" }),
|
||||
background: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description:
|
||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT poll its progress.",
|
||||
"Run the subagent in the background and return immediately. You will be notified when it completes. DO NOT sleep, poll, or proactively check on its progress.",
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -31,7 +35,8 @@ export const Output = Schema.Struct({
|
||||
output: Schema.String,
|
||||
})
|
||||
export const description = [
|
||||
"Spawn a subagent: a child session running a configured agent with fresh context.",
|
||||
"Spawns an agent in a child session to work on the specified task.",
|
||||
"Include all relevant context and instructions in the prompt because the child starts with fresh context.",
|
||||
"Foreground (default) runs the subagent to completion and returns its final response.",
|
||||
"Background mode (background=true) launches it asynchronously and returns immediately; you are notified when it finishes.",
|
||||
"Use background only for independent work that can run while you continue elsewhere.",
|
||||
|
||||
@@ -9,7 +9,10 @@ export * as WriteTool from "./write"
|
||||
import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { FileMutation } from "../../file-mutation"
|
||||
import { Formatter } from "../../formatter"
|
||||
import { LocationMutation } from "../../location-mutation"
|
||||
import { Permission } from "../../permission"
|
||||
|
||||
@@ -36,7 +39,6 @@ export const toModelOutput = (output: Output) =>
|
||||
`${output.existed ? "Wrote" : "Created"} file successfully: ${output.resource}`
|
||||
|
||||
/** Deferred write UX integrations remain visible at the model-facing seam. */
|
||||
// TODO: Add formatter integration after formatter runtime exists.
|
||||
// TODO: Publish watcher/file-edit events after watcher integration exists.
|
||||
// TODO: Add snapshots / undo after design exists.
|
||||
// TODO: Add LSP notification and diagnostics after LSP runtime exists.
|
||||
@@ -46,6 +48,8 @@ export const Plugin = {
|
||||
effect: Effect.fn("WriteTool.Plugin")(function* (ctx: PluginContext) {
|
||||
const mutation = yield* LocationMutation.Service
|
||||
const files = yield* FileMutation.Service
|
||||
const formatter = yield* Formatter.Service
|
||||
const fs = yield* FSUtil.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
yield* ctx.tool
|
||||
@@ -82,7 +86,10 @@ 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 bom = (yield* Bom.readFile(fs, target.canonical)).bom
|
||||
if (yield* formatter.file(target.canonical)) yield* Bom.syncFile(fs, target.canonical, bom)
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.map((output) => ({ output, content: toModelOutput(output) })),
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Unable to write ${input.path}`, error })),
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Schema, Stream } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { Npm } from "@opencode-ai/util/npm"
|
||||
import { Config } from "../src/config"
|
||||
import { Formatter } from "../src/formatter"
|
||||
import { Location } from "../src/location"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
type ConfigInput = typeof Config.Info.Encoded
|
||||
|
||||
function formatterLayer(directory: string, configured?: ConfigInput["formatter"]) {
|
||||
const entries =
|
||||
configured === undefined
|
||||
? []
|
||||
: [
|
||||
new Config.Document({
|
||||
type: "document",
|
||||
info: Schema.decodeUnknownSync(Config.Info)({ formatter: configured }),
|
||||
}),
|
||||
]
|
||||
return AppNodeBuilder.build(Formatter.node, [
|
||||
[
|
||||
Config.node,
|
||||
Layer.succeed(
|
||||
Config.Service,
|
||||
Config.Service.of({
|
||||
entries: () => Effect.succeed(entries),
|
||||
changes: () => Stream.empty,
|
||||
}),
|
||||
),
|
||||
],
|
||||
[
|
||||
Location.node,
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of(location({ directory: AbsolutePath.make(directory) })),
|
||||
),
|
||||
],
|
||||
[Npm.node, Layer.mock(Npm.Service, { which: () => Effect.succeed(undefined) })],
|
||||
])
|
||||
}
|
||||
|
||||
function withTemp<A, E, R>(body: (directory: string) => Effect.Effect<A, E, R>) {
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => body(tmp.path),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
}
|
||||
|
||||
describe("Formatter", () => {
|
||||
it.live("status() returns empty list when no formatters are configured", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) => formatter.status()).pipe(Effect.provide(formatterLayer(directory))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() returns built-in formatters when formatter is true", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* formatter.status()
|
||||
const gofmt = statuses.find((item) => item.name === "gofmt")
|
||||
expect(gofmt).toBeDefined()
|
||||
expect(gofmt?.extensions).toContain(".go")
|
||||
}),
|
||||
).pipe(Effect.provide(formatterLayer(directory, true))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() keeps built-in formatters when config object is provided", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* formatter.status()
|
||||
expect(statuses.find((item) => item.name === "gofmt")?.extensions).toContain(".go")
|
||||
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
||||
}),
|
||||
).pipe(Effect.provide(formatterLayer(directory, { gofmt: {} }))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() excludes formatters marked as disabled in config", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) =>
|
||||
Effect.gen(function* () {
|
||||
const statuses = yield* formatter.status()
|
||||
expect(statuses.find((item) => item.name === "gofmt")).toBeUndefined()
|
||||
expect(statuses.find((item) => item.name === "mix")).toBeDefined()
|
||||
}),
|
||||
).pipe(Effect.provide(formatterLayer(directory, { gofmt: { disabled: true } }))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("service initializes without error", () =>
|
||||
withTemp((directory) =>
|
||||
Formatter.Service.use((formatter) => formatter.init()).pipe(Effect.provide(formatterLayer(directory))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("file() returns false when no formatter runs", () =>
|
||||
withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(false)
|
||||
}).pipe(Effect.provide(formatterLayer(directory, false))),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("status() initializes formatter state per directory", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => Promise.all([tmpdir(), tmpdir()])),
|
||||
([off, on]) =>
|
||||
Effect.gen(function* () {
|
||||
const disabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
||||
Effect.provide(formatterLayer(off.path, false)),
|
||||
)
|
||||
const enabled = yield* Formatter.Service.use((formatter) => formatter.status()).pipe(
|
||||
Effect.provide(formatterLayer(on.path, true)),
|
||||
)
|
||||
expect(disabled).toEqual([])
|
||||
expect(enabled.find((item) => item.name === "gofmt")).toBeDefined()
|
||||
}),
|
||||
(directories) =>
|
||||
Effect.promise(() => Promise.all(directories.map((tmp) => tmp[Symbol.asyncDispose]())).then(() => undefined)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("stops after the first matching formatter succeeds", () =>
|
||||
withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.seq")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xA")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(directory, {
|
||||
first: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'A')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
second: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".seq"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("tries the next matching formatter when the first fails", () =>
|
||||
withTemp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
const file = path.join(directory, "test.fallback")
|
||||
yield* Effect.promise(() => fs.writeFile(file, "x"))
|
||||
expect(yield* Formatter.Service.use((formatter) => formatter.file(file))).toBe(true)
|
||||
expect(yield* Effect.promise(() => fs.readFile(file, "utf8"))).toBe("xB")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
formatterLayer(directory, {
|
||||
first: {
|
||||
command: [process.execPath, "-e", "process.exit(1)", "$FILE"],
|
||||
extensions: [".fallback"],
|
||||
},
|
||||
second: {
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"const fs = require('fs'); const file = process.argv.at(-1); fs.appendFileSync(file, 'B')",
|
||||
"$FILE",
|
||||
],
|
||||
extensions: [".fallback"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationMutation } from "@opencode-ai/core/location-mutation"
|
||||
@@ -22,7 +23,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const editToolNode = makeLocationNode({
|
||||
name: "test/edit-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(EditTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, FSUtil.node, Permission.node],
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_edit_tool_test")
|
||||
@@ -31,6 +32,7 @@ const writes: string[] = []
|
||||
let reads = 0
|
||||
let denyAction: string | undefined
|
||||
let afterRead = (_target: string, _content: Uint8Array): Effect.Effect<void> => Effect.void
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
@@ -57,12 +59,17 @@ const permission = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
writes.length = 0
|
||||
reads = 0
|
||||
denyAction = undefined
|
||||
afterRead = () => Effect.void
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
@@ -109,6 +116,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
],
|
||||
),
|
||||
@@ -181,6 +189,39 @@ describe("EditTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns the diff for final formatted content", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "formatted.txt")
|
||||
formatFile = (file) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("after", "AFTER"))
|
||||
return true
|
||||
})
|
||||
return Effect.promise(() => fs.writeFile(target, "before\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call({ path: "formatted.txt", oldString: "before", newString: "after" }),
|
||||
)
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.output.files[0]?.patch).toContain("-before\n+AFTER")
|
||||
expect(settled.metadata?.files?.[0]?.patch).toContain("-before\n+AFTER")
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("AFTER\n")
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("accepts an absolute file path inside the active Location", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -574,6 +615,11 @@ describe("EditTool", () => {
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "windows.txt")
|
||||
formatFile = (file) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
|
||||
return true
|
||||
})
|
||||
return Effect.promise(() => fs.writeFile(target, "\uFEFFbefore\r\nrest\r\n")).pipe(
|
||||
Effect.andThen(
|
||||
withTool(tmp.path, (registry) =>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { systemError } from "effect/PlatformError"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { Permission } from "@opencode-ai/core/permission"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
@@ -21,7 +22,7 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const patchToolNode = makeLocationNode({
|
||||
name: "test/patch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
deps: [Tool.node, FSUtil.node, Location.node, Permission.node],
|
||||
deps: [Tool.node, Formatter.node, FSUtil.node, Location.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_patch_tool_test")
|
||||
@@ -33,6 +34,7 @@ let failWriteTarget: string | undefined
|
||||
let readsBeforeEditApproval = 0
|
||||
let editApproved = false
|
||||
let afterEditApproval = (): Effect.Effect<void> => Effect.void
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
|
||||
const permission = Layer.succeed(
|
||||
Permission.Service,
|
||||
@@ -63,6 +65,10 @@ const permission = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
denyAction = undefined
|
||||
@@ -72,6 +78,7 @@ const reset = () => {
|
||||
readsBeforeEditApproval = 0
|
||||
editApproved = false
|
||||
afterEditApproval = () => Effect.void
|
||||
formatFile = () => Effect.succeed(false)
|
||||
}
|
||||
|
||||
const filesystem = Layer.effect(
|
||||
@@ -135,6 +142,7 @@ const withTool = <A, E, R>(
|
||||
AppNodeBuilder.build(LayerNode.group([Tool.node, patchToolNode]), [
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
]),
|
||||
),
|
||||
@@ -254,6 +262,28 @@ describe("PatchTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("returns file diffs for final formatted content", () =>
|
||||
withTempTool((directory, registry) => {
|
||||
const target = path.join(directory, "formatted.txt")
|
||||
formatFile = (file) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace("created", "FORMATTED"))
|
||||
return true
|
||||
})
|
||||
return Effect.gen(function* () {
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Add File: formatted.txt\n+created\n*** End Patch"),
|
||||
)
|
||||
expect(settled.status).toBe("completed")
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.output.files[0]?.patch).toContain("+FORMATTED")
|
||||
expect(settled.metadata?.files?.[0]?.patch).toContain("+FORMATTED")
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMATTED\n")
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("moves and updates a file", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -552,6 +582,11 @@ describe("PatchTool", () => {
|
||||
const bom = "\uFEFF"
|
||||
const target = path.join(directory, "example.cs")
|
||||
yield* Effect.promise(() => fs.writeFile(target, `${bom}using System;\n\nclass Test {}\n`))
|
||||
formatFile = (file) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(file, (await fs.readFile(file, "utf8")).replace(/^\uFEFF/, ""))
|
||||
return true
|
||||
})
|
||||
const settled = yield* executeTool(
|
||||
registry,
|
||||
call("*** Begin Patch\n*** Update File: example.cs\n@@\n class Test {}\n+class Next {}\n*** End Patch"),
|
||||
|
||||
@@ -157,6 +157,9 @@ const mixedOutputCommand = isWindows
|
||||
? "[Console]::Out.Write('stdout'); Start-Sleep -Milliseconds 50; [Console]::Error.Write('stderr'); Start-Sleep -Milliseconds 100"
|
||||
: "printf stdout; sleep 0.05; printf stderr >&2"
|
||||
const idleCommand = isWindows ? "Start-Sleep -Seconds 60" : "sleep 60"
|
||||
const timeoutOutputCommand = isWindows
|
||||
? "[Console]::Out.Write('before timeout'); Start-Sleep -Seconds 60"
|
||||
: "printf 'before timeout'; sleep 60"
|
||||
const steadyProgressCommand = isWindows
|
||||
? "[Console]::Out.Write('steady'); Start-Sleep -Milliseconds 3400"
|
||||
: "printf steady; sleep 3.4"
|
||||
@@ -461,14 +464,18 @@ describe("ShellTool", () => {
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: idleCommand, timeout: 50 })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
reset()
|
||||
return withSession(tmp.path, (registry) =>
|
||||
executeTool(registry, call({ command: timeoutOutputCommand, timeout: 50 })),
|
||||
).pipe(
|
||||
Effect.andThen((settled) =>
|
||||
Effect.sync(() => {
|
||||
expect(settled.metadata).toMatchObject({ timeout: true, truncated: false })
|
||||
expect(settled.content?.[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("before timeout"),
|
||||
})
|
||||
expect(settled.content?.[1]).toMatchObject({
|
||||
type: "text",
|
||||
text: expect.stringContaining("Command timed out"),
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FileMutation } from "@opencode-ai/core/file-mutation"
|
||||
import { Formatter } from "@opencode-ai/core/formatter"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
@@ -22,12 +23,13 @@ import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "
|
||||
const writeToolNode = makeLocationNode({
|
||||
name: "test/write-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(WriteTool.Plugin)),
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Permission.node],
|
||||
deps: [Tool.node, LocationMutation.node, FileMutation.node, Formatter.node, FSUtil.node, Permission.node],
|
||||
})
|
||||
|
||||
const sessionID = Session.ID.make("ses_write_tool_test")
|
||||
const assertions: Permission.AssertInput[] = []
|
||||
const writes: string[] = []
|
||||
let formatFile = (_target: string): Effect.Effect<boolean> => Effect.succeed(false)
|
||||
let denyAction: string | undefined
|
||||
|
||||
const permission = Layer.succeed(
|
||||
@@ -55,9 +57,14 @@ const permission = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const formatter = Layer.mock(Formatter.Service, {
|
||||
file: (target) => formatFile(target),
|
||||
})
|
||||
|
||||
const reset = () => {
|
||||
assertions.length = 0
|
||||
writes.length = 0
|
||||
formatFile = () => Effect.succeed(false)
|
||||
denyAction = undefined
|
||||
}
|
||||
|
||||
@@ -93,6 +100,7 @@ const withTool = <A, E, R>(directory: string, body: (registry: Tool.Interface) =
|
||||
[
|
||||
[FSUtil.node, filesystem],
|
||||
[Location.node, activeLocation],
|
||||
[Formatter.node, formatter],
|
||||
[Permission.node, permission],
|
||||
],
|
||||
),
|
||||
@@ -140,6 +148,30 @@ describe("WriteTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("formats the committed file", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
reset()
|
||||
const target = path.join(tmp.path, "formatted.txt")
|
||||
formatFile = (file) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(file, (await fs.readFile(file, "utf8")).toUpperCase())
|
||||
return true
|
||||
})
|
||||
return withTool(tmp.path, (registry) =>
|
||||
Effect.gen(function* () {
|
||||
expect(yield* executeTool(registry, call({ path: "formatted.txt", content: "format me" }))).toMatchObject({
|
||||
status: "completed",
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("FORMAT ME")
|
||||
}),
|
||||
)
|
||||
},
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("overwrites a relative existing file and reports that it wrote the file", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -174,6 +206,11 @@ describe("WriteTool", () => {
|
||||
reset()
|
||||
const preserved = path.join(tmp.path, "preserved.txt")
|
||||
const deduplicated = path.join(tmp.path, "deduplicated.txt")
|
||||
formatFile = (target) =>
|
||||
Effect.promise(async () => {
|
||||
await fs.writeFile(target, `\uFEFF\uFEFF\uFEFF${(await fs.readFile(target, "utf8")).replace(/^\uFEFF+/, "")}`)
|
||||
return true
|
||||
})
|
||||
return Effect.promise(() =>
|
||||
Promise.all([fs.writeFile(preserved, "\uFEFFbefore"), fs.writeFile(deduplicated, "\uFEFFbefore")]),
|
||||
).pipe(
|
||||
|
||||
@@ -326,8 +326,17 @@ export const run = Effect.fn("Tui.run")(function* (input: TuiInput) {
|
||||
>
|
||||
<TuiStartupProvider
|
||||
value={{
|
||||
initialRoute: process.env.OPENCODE_SCRAP
|
||||
? { type: "plugin", id: "scrap", name: "scrap" }
|
||||
initialRoute: process.env.OPENCODE_STORY
|
||||
? {
|
||||
type: "plugin",
|
||||
id: "opencode.storybook",
|
||||
name: "storybook",
|
||||
// OPENCODE_STORY=1 opens the index; any other value opens that story.
|
||||
data:
|
||||
process.env.OPENCODE_STORY === "1"
|
||||
? undefined
|
||||
: { story: process.env.OPENCODE_STORY },
|
||||
}
|
||||
: process.env.OPENCODE_ROUTE
|
||||
? JSON.parse(process.env.OPENCODE_ROUTE)
|
||||
: undefined,
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { createAnimatable, spring, tween } from "../ui/animation"
|
||||
import { Locale } from "../util/locale"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import { TabPulse } from "./tab-pulse"
|
||||
import { TabPulse, unreadGlowIntensity } from "./tab-pulse"
|
||||
import { tint } from "../theme/color"
|
||||
|
||||
// A long title fades out over its last cells instead of cutting hard.
|
||||
@@ -182,6 +182,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
return tint(base, theme.raise(theme.background.surface.offset), dragged() ? 1 : selection())
|
||||
})
|
||||
const pulseColor = () => tint(background(), theme.text.default, 0.45)
|
||||
// The edge flash washes toward a brighter stop on the same background-to-text ramp,
|
||||
// so it reads as a lift of the pulse color rather than a different hue.
|
||||
const flashColor = () => tint(background(), theme.text.default, 0.65)
|
||||
const feedbackColor = () => {
|
||||
if (status().attention) return theme.text.feedback.warning.default
|
||||
if (status().unread === "error") return theme.text.feedback.error.default
|
||||
@@ -222,7 +225,6 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
const cut = Math.round(front * Math.max(parts.length, previous.length))
|
||||
return [...parts.slice(0, cut), ...previous.slice(cut)]
|
||||
})
|
||||
const fadedTitleParts = createMemo(() => displayedParts().slice(-FADE_WIDTH))
|
||||
const titleFades = createMemo(
|
||||
() => stringWidth(title()) >= availableTitleWidth() && availableTitleWidth() > FADE_WIDTH,
|
||||
)
|
||||
@@ -230,6 +232,19 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
if (hovered() === tab.sessionID) return theme.text.default
|
||||
return tint(theme.text.subdued, theme.text.default, selection())
|
||||
}
|
||||
// Title characters sitting over the glow tinge toward its color, following the same
|
||||
// spatial falloff as the glow itself; characters beyond the tail stay neutral.
|
||||
const characterColor = (index: number) => {
|
||||
const base = foreground()
|
||||
const color = glows()
|
||||
? tint(base, glowColor(), 0.12 * unreadGlowIntensity(1 + numberWidth() + index, width()))
|
||||
: base
|
||||
if (!titleFades() || index < displayedParts().length - FADE_WIDTH) return color
|
||||
const position = index - (displayedParts().length - FADE_WIDTH)
|
||||
return tint(color, background(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
|
||||
}
|
||||
// The running sweep's level under the number cell, reported by the pulse renderable.
|
||||
const [sweepLevel, setSweepLevel] = createSignal(0)
|
||||
const numberColor = () => {
|
||||
const feedback = feedbackColor()
|
||||
if (feedback) return feedback
|
||||
@@ -237,7 +252,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
hovered() === tab.sessionID && !selected()
|
||||
? foreground()
|
||||
: tint(idleNumber(), activeNumber(), selection())
|
||||
return tint(base, accent(), activity())
|
||||
const color = tint(base, accent(), activity())
|
||||
// The number brightens faintly as the running sweep passes beneath it.
|
||||
return sweepLevel() === 0 ? color : tint(color, theme.text.default, 0.15 * sweepLevel())
|
||||
}
|
||||
const bold = () => (selected() || dragged() ? TextAttributes.BOLD : undefined)
|
||||
const closeColor = () => tint(theme.text.subdued, theme.text.default, 0.6)
|
||||
@@ -271,8 +288,10 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
breathe={status().attention}
|
||||
color={pulseColor()}
|
||||
glowColor={glowColor()}
|
||||
flashColor={flashColor()}
|
||||
completionColor={accent()}
|
||||
backgroundColor={background()}
|
||||
onLevel={setSweepLevel}
|
||||
/>
|
||||
<box zIndex={1} width="100%" flexDirection="row">
|
||||
<text width={1} selectable={false}>
|
||||
@@ -288,22 +307,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati
|
||||
selectable={false}
|
||||
attributes={bold()}
|
||||
>
|
||||
<Show when={titleFades()} fallback={displayedParts().join("")}>
|
||||
{displayedParts().slice(0, -FADE_WIDTH).join("")}
|
||||
<For each={fadedTitleParts()}>
|
||||
{(character, index) => (
|
||||
<span
|
||||
style={{
|
||||
fg: tint(
|
||||
foreground(),
|
||||
background(),
|
||||
0.2 + 0.72 * (index() / Math.max(1, fadedTitleParts().length - 1)),
|
||||
),
|
||||
}}
|
||||
>
|
||||
{character}
|
||||
</span>
|
||||
)}
|
||||
<Show when={glows() || titleFades()} fallback={displayedParts().join("")}>
|
||||
<For each={displayedParts()}>
|
||||
{(character, index) => <span style={{ fg: characterColor(index()) }}>{character}</span>}
|
||||
</For>
|
||||
</Show>
|
||||
</text>
|
||||
|
||||
@@ -9,8 +9,11 @@ type TabPulseOptions = RenderableOptions<TabPulseRenderable> & {
|
||||
breathe?: boolean
|
||||
color?: RGBA
|
||||
glowColor?: RGBA
|
||||
flashColor?: RGBA
|
||||
completionColor?: RGBA
|
||||
backgroundColor?: RGBA
|
||||
/** Reports the running sweep's intensity at the tab number's cell, quantized; 0 when idle. */
|
||||
onLevel?: (level: number) => void
|
||||
}
|
||||
|
||||
const clamp = (value: number) => Math.max(0, Math.min(1, value))
|
||||
@@ -19,10 +22,11 @@ const RUN_DURATION = 2_800
|
||||
const RUN_HEAD = 4
|
||||
const RUN_TAIL = 18
|
||||
const RUN_FADE_OUT = 500
|
||||
const COMPLETION_DURATION = 900
|
||||
const COMPLETION_ATTACK = 0.16
|
||||
const COMPLETION_DURATION = 1_200
|
||||
const COMPLETION_ATTACK = 0.12
|
||||
const COMPLETION_OPACITY = 0.18
|
||||
const EDGE_FLASH_DURATION = 500
|
||||
const EDGE_FLASH_DURATION = 800
|
||||
const EDGE_FLASH_ATTACK = 0.1
|
||||
const EDGE_FLASH_OPACITY = 0.1
|
||||
const GLOW_IGNITION_DURATION = 600
|
||||
const GLOW_IGNITION_PEAK = 1.5
|
||||
@@ -61,9 +65,11 @@ export function blendTabPulseColor(
|
||||
background: RGBA,
|
||||
glowColor: RGBA,
|
||||
runningColor: RGBA,
|
||||
flashColor: RGBA,
|
||||
completionColor: RGBA,
|
||||
glow: number,
|
||||
running: number,
|
||||
flash: number,
|
||||
completion: number,
|
||||
) {
|
||||
output.r = background.r + (glowColor.r - background.r) * glow
|
||||
@@ -72,6 +78,9 @@ export function blendTabPulseColor(
|
||||
output.r += (runningColor.r - output.r) * running
|
||||
output.g += (runningColor.g - output.g) * running
|
||||
output.b += (runningColor.b - output.b) * running
|
||||
output.r += (flashColor.r - output.r) * flash
|
||||
output.g += (flashColor.g - output.g) * flash
|
||||
output.b += (flashColor.b - output.b) * flash
|
||||
output.r += (completionColor.r - output.r) * completion
|
||||
output.g += (completionColor.g - output.g) * completion
|
||||
output.b += (completionColor.b - output.b) * completion
|
||||
@@ -123,6 +132,7 @@ class TabPulseRenderable extends Renderable {
|
||||
private _breathe: boolean
|
||||
private _color: RGBA
|
||||
private _glowColor: RGBA
|
||||
private _flashColor: RGBA
|
||||
private _completionColor: RGBA
|
||||
private _backgroundColor: RGBA
|
||||
private clock = 0
|
||||
@@ -130,11 +140,13 @@ class TabPulseRenderable extends Renderable {
|
||||
private completionPending = false
|
||||
private runFade = new Envelope(RUN_FADE_OUT, fadeOut)
|
||||
private completionPulse = new Envelope(COMPLETION_DURATION, completionPulseOpacity)
|
||||
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, completionPulseOpacity)
|
||||
private edgeFlash = new Envelope(EDGE_FLASH_DURATION, (progress) => attackDecay(progress, EDGE_FLASH_ATTACK, 1, 0))
|
||||
private ignition = new Envelope(GLOW_IGNITION_DURATION, glowIgnitionLevel)
|
||||
private glowOff = new Envelope(GLOW_FADE_OUT, fadeOut)
|
||||
private envelopes = [this.runFade, this.completionPulse, this.edgeFlash, this.ignition, this.glowOff]
|
||||
private renderColor = RGBA.fromInts(0, 0, 0)
|
||||
private _onLevel: ((level: number) => void) | undefined
|
||||
private lastLevel = 0
|
||||
|
||||
constructor(ctx: RenderContext, options: TabPulseOptions = {}) {
|
||||
const enabled = options.enabled ?? true
|
||||
@@ -147,8 +159,21 @@ class TabPulseRenderable extends Renderable {
|
||||
this._breathe = options.breathe ?? false
|
||||
this._color = options.color ?? RGBA.defaultForeground()
|
||||
this._glowColor = options.glowColor ?? this._color
|
||||
this._flashColor = options.flashColor ?? this._color
|
||||
this._completionColor = options.completionColor ?? this._color
|
||||
this._backgroundColor = options.backgroundColor ?? RGBA.defaultBackground()
|
||||
this._onLevel = options.onLevel
|
||||
}
|
||||
|
||||
set onLevel(value: ((level: number) => void) | undefined) {
|
||||
this._onLevel = value
|
||||
}
|
||||
|
||||
private emitLevel(value: number) {
|
||||
const quantized = Math.round(value * 32) / 32
|
||||
if (quantized === this.lastLevel) return
|
||||
this.lastLevel = quantized
|
||||
this._onLevel?.(quantized)
|
||||
}
|
||||
|
||||
private get breathing() {
|
||||
@@ -248,6 +273,12 @@ class TabPulseRenderable extends Renderable {
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set flashColor(value: RGBA) {
|
||||
if (value.equals(this._flashColor)) return
|
||||
this._flashColor = value
|
||||
this.requestRender()
|
||||
}
|
||||
|
||||
set completionColor(value: RGBA) {
|
||||
if (value.equals(this._completionColor)) return
|
||||
this._completionColor = value
|
||||
@@ -283,12 +314,21 @@ class TabPulseRenderable extends Renderable {
|
||||
// The edge flash is a neutral wash on the running stage; the accent completion stage stays reserved for results.
|
||||
const flash = this.edgeFlash.level() * EDGE_FLASH_OPACITY
|
||||
const glowLevel = this.glowLevel()
|
||||
if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) return
|
||||
if (glowLevel === 0 && running === 0 && completion === 0 && flash === 0) {
|
||||
this.emitLevel(0)
|
||||
return
|
||||
}
|
||||
const progress = (this.clock % RUN_DURATION) / RUN_DURATION
|
||||
const start = -RUN_HEAD
|
||||
const end = this.width - 1 + RUN_TAIL
|
||||
const front = start + coast(progress) * (end - start)
|
||||
const secondFront = start + coast((progress + 0.5) % 1) * (end - start)
|
||||
this.emitLevel(
|
||||
running === 0
|
||||
? 0
|
||||
: Math.max(intensityAt(1, front, RUN_HEAD, RUN_TAIL), intensityAt(1, secondFront, RUN_HEAD, RUN_TAIL)) *
|
||||
running,
|
||||
)
|
||||
for (let index = 0; index < this.width; index++) {
|
||||
// Skip per-cell sweep and glow math when that stage is idle, e.g. a steady breathing glow.
|
||||
const sweep =
|
||||
@@ -305,9 +345,11 @@ class TabPulseRenderable extends Renderable {
|
||||
this._backgroundColor,
|
||||
this._glowColor,
|
||||
this._color,
|
||||
this._flashColor,
|
||||
this._completionColor,
|
||||
glowLevel === 0 ? 0 : unreadGlowIntensity(index, this.width) * GLOW_OPACITY * glowLevel,
|
||||
Math.max(sweep, flash),
|
||||
sweep,
|
||||
flash,
|
||||
completion,
|
||||
)
|
||||
buffer.setCell(this.screenX + index, this.screenY, " ", DEFAULT_FOREGROUND, this.renderColor)
|
||||
@@ -331,8 +373,10 @@ export function TabPulse(props: {
|
||||
breathe?: boolean
|
||||
color: RGBA
|
||||
glowColor?: RGBA
|
||||
flashColor?: RGBA
|
||||
completionColor?: RGBA
|
||||
backgroundColor: RGBA
|
||||
onLevel?: (level: number) => void
|
||||
}) {
|
||||
return (
|
||||
<tab_pulse
|
||||
@@ -346,8 +390,10 @@ export function TabPulse(props: {
|
||||
breathe={props.breathe ?? false}
|
||||
color={props.color}
|
||||
glowColor={props.glowColor ?? props.color}
|
||||
flashColor={props.flashColor ?? props.color}
|
||||
completionColor={props.completionColor ?? props.color}
|
||||
backgroundColor={props.backgroundColor}
|
||||
onLevel={props.onLevel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { createSimpleContext } from "./helper"
|
||||
@@ -25,6 +25,7 @@ type ManagedService = {
|
||||
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
const connectTimeout = 2_000
|
||||
const connectionHistoryLimit = 50
|
||||
const eventFlushInterval = 10
|
||||
|
||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||
name: "Client",
|
||||
@@ -34,6 +35,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
||||
const history: ClientConnectionEvent[] = []
|
||||
let api = props.api
|
||||
const events = createGlobalEmitter<ClientEventMap>()
|
||||
let pending: OpenCodeEvent[] = []
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: ClientConnectionStatus
|
||||
attempt: number
|
||||
@@ -49,6 +52,19 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
||||
if (history.length > connectionHistoryLimit) history.shift()
|
||||
}
|
||||
|
||||
function flushEvents() {
|
||||
flushTimer = undefined
|
||||
const queued = pending
|
||||
pending = []
|
||||
batch(() => queued.forEach((event) => events.emit(event.type, event)))
|
||||
}
|
||||
|
||||
function emit(event: OpenCodeEvent) {
|
||||
pending.push(event)
|
||||
if (flushTimer) return
|
||||
flushTimer = setTimeout(flushEvents, eventFlushInterval)
|
||||
}
|
||||
|
||||
async function connect(signal: AbortSignal, attempt: number) {
|
||||
let connectedAt: number | undefined
|
||||
|
||||
@@ -80,7 +96,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
||||
record("connected", attempt)
|
||||
connectedAt = Date.now()
|
||||
log.info("event stream connected")
|
||||
events.emit(first.value.type, first.value)
|
||||
emit(first.value)
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
|
||||
// Forward events until the stream closes or this connection is cancelled.
|
||||
@@ -97,7 +113,7 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
||||
seq: event.value.durable.seq,
|
||||
})
|
||||
|
||||
events.emit(event.value.type, event.value)
|
||||
emit(event.value)
|
||||
}
|
||||
|
||||
return { error: undefined, connectedAt }
|
||||
@@ -154,6 +170,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
stream?.abort()
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
pending = []
|
||||
events.clear()
|
||||
})
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@ function initialRoute(value: unknown): Route | undefined {
|
||||
"name" in value &&
|
||||
typeof value.name === "string"
|
||||
) {
|
||||
const data =
|
||||
"data" in value && typeof value.data === "object" && value.data !== null && !Array.isArray(value.data)
|
||||
? (value.data as Record<string, unknown>)
|
||||
: undefined
|
||||
if (data) return { type: "plugin", id: value.id, name: value.name, data }
|
||||
return { type: "plugin", id: value.id, name: value.name }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,186 +0,0 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createSignal } from "solid-js"
|
||||
import { SessionTabs, type SessionTabsController } from "../../component/session-tabs"
|
||||
import { moveSessionTab, type SessionTab } from "../../context/session-tabs-model"
|
||||
|
||||
type FixtureStatus = ReturnType<SessionTabsController["status"]>
|
||||
|
||||
const FIXTURE_TABS = [
|
||||
{ sessionID: "fixture-1", title: "Implement session tabs" },
|
||||
{ sessionID: "fixture-2", title: "Investigate rendering" },
|
||||
{ sessionID: "fixture-3", title: "A deliberately long session title for truncation" },
|
||||
{ sessionID: "fixture-4", title: "Fix provider state" },
|
||||
{ sessionID: "fixture-5", title: "Review animation" },
|
||||
{ sessionID: "fixture-6", title: "Untitled behavior" },
|
||||
{ sessionID: "fixture-7", title: "Queue follow-up work" },
|
||||
{ sessionID: "fixture-8", title: "Check narrow layout" },
|
||||
{ sessionID: "fixture-9", title: "Profile terminal output" },
|
||||
{ sessionID: "fixture-10", title: "Handle permission" },
|
||||
{ sessionID: "fixture-11", title: "Run focused tests" },
|
||||
{ sessionID: "fixture-12", title: "Prepare review" },
|
||||
]
|
||||
|
||||
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
id: "app.scrap",
|
||||
title: "Open scrap screen",
|
||||
group: "Debug",
|
||||
palette: true,
|
||||
run() {
|
||||
props.context.ui.router.navigate({ type: "plugin", name: "scrap" })
|
||||
props.context.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
return null
|
||||
}
|
||||
|
||||
function Scrap(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme
|
||||
const elevatedTheme = theme.contextual.elevated
|
||||
const [tabs, setTabs] = createSignal<SessionTab[]>(FIXTURE_TABS.slice(0, 6))
|
||||
const [active, setActive] = createSignal<string | undefined>("fixture-2")
|
||||
const [animations, setAnimations] = createSignal(true)
|
||||
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({
|
||||
"fixture-2": { ...EMPTY_STATUS, busy: true },
|
||||
"fixture-3": { ...EMPTY_STATUS, unread: "activity" },
|
||||
"fixture-4": { ...EMPTY_STATUS, unread: "error" },
|
||||
"fixture-5": { ...EMPTY_STATUS, attention: true },
|
||||
"fixture-6": { ...EMPTY_STATUS, busy: true, attention: true },
|
||||
})
|
||||
const controller = {
|
||||
tabs,
|
||||
current: active,
|
||||
status(sessionID) {
|
||||
return statuses()[sessionID] ?? EMPTY_STATUS
|
||||
},
|
||||
move(sessionID, index) {
|
||||
setTabs((current) => moveSessionTab(current, sessionID, index))
|
||||
},
|
||||
select(sessionID) {
|
||||
setActive(sessionID)
|
||||
},
|
||||
close(sessionID?: string) {
|
||||
const target = sessionID ?? active()
|
||||
if (!target) return
|
||||
const items = tabs()
|
||||
const index = items.findIndex((tab) => tab.sessionID === target)
|
||||
if (index === -1) return
|
||||
const next = items.filter((tab) => tab.sessionID !== target)
|
||||
batch(() => {
|
||||
setTabs(next)
|
||||
if (active() === target) setActive(next[index]?.sessionID ?? next[index - 1]?.sessionID)
|
||||
})
|
||||
},
|
||||
} satisfies SessionTabsController
|
||||
|
||||
const cycle = (direction: 1 | -1) => {
|
||||
const items = tabs()
|
||||
if (items.length === 0) return
|
||||
const index = items.findIndex((tab) => tab.sessionID === active())
|
||||
controller.select(items[(index + direction + items.length) % items.length].sessionID)
|
||||
}
|
||||
const updateStatus = (update: (status: FixtureStatus) => FixtureStatus) => {
|
||||
const sessionID = active()
|
||||
if (!sessionID) return
|
||||
setStatuses((current) => ({ ...current, [sessionID]: update(current[sessionID] ?? EMPTY_STATUS) }))
|
||||
}
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
bind: "escape",
|
||||
title: "Back home",
|
||||
group: "Scrap",
|
||||
run() {
|
||||
props.context.ui.router.navigate({ type: "home" })
|
||||
},
|
||||
},
|
||||
{ bind: "h", title: "Previous tab", group: "Scrap", run: () => cycle(-1) },
|
||||
{ bind: "l", title: "Next tab", group: "Scrap", run: () => cycle(1) },
|
||||
{
|
||||
bind: "t",
|
||||
title: "Add tab",
|
||||
group: "Scrap",
|
||||
run() {
|
||||
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
|
||||
if (next) setTabs((current) => [...current, next])
|
||||
},
|
||||
},
|
||||
{ bind: "d", title: "Close tab", group: "Scrap", run: () => controller.close() },
|
||||
{
|
||||
bind: "b",
|
||||
title: "Toggle busy",
|
||||
group: "Scrap",
|
||||
run: () =>
|
||||
updateStatus((status) =>
|
||||
status.busy ? { ...status, busy: false, unread: "activity" } : { ...status, busy: true, unread: undefined },
|
||||
),
|
||||
},
|
||||
{
|
||||
bind: "u",
|
||||
title: "Cycle unread",
|
||||
group: "Scrap",
|
||||
run: () =>
|
||||
updateStatus((status) => ({
|
||||
...status,
|
||||
unread: status.unread === undefined ? "activity" : status.unread === "activity" ? "error" : undefined,
|
||||
})),
|
||||
},
|
||||
{
|
||||
bind: "a",
|
||||
title: "Toggle attention",
|
||||
group: "Scrap",
|
||||
run: () => updateStatus((status) => ({ ...status, attention: !status.attention })),
|
||||
},
|
||||
{
|
||||
bind: "m",
|
||||
title: "Toggle motion",
|
||||
group: "Scrap",
|
||||
run: () => setAnimations((enabled) => !enabled),
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<SessionTabs controller={controller} animations={animations()} />
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={elevatedTheme.background.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={elevatedTheme.text.subdued}>tab playground</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={elevatedTheme.text.subdued}>
|
||||
h/l select | t add | d close | b busy | u unread | a attention | m motion | esc home
|
||||
</text>
|
||||
</box>
|
||||
<box flexGrow={1} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.scrap",
|
||||
setup(context) {
|
||||
context.ui.router.register({ name: "scrap", render: () => <Scrap context={context} /> })
|
||||
context.ui.slot("app", () => <Commands context={context} />)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,142 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { createSignal, For, type JSX } from "solid-js"
|
||||
import { sessionTabsStory } from "./session-tabs"
|
||||
|
||||
/**
|
||||
* A story is a full-screen, fixture-driven simulation of a real production component. Stories own
|
||||
* their entire screen (including any footer) and should bind escape back to the storybook index.
|
||||
*/
|
||||
export type Story = {
|
||||
id: string
|
||||
title: string
|
||||
render: (context: Plugin.Context) => JSX.Element
|
||||
}
|
||||
|
||||
const stories: Story[] = [sessionTabsStory]
|
||||
|
||||
function Commands(props: { context: Plugin.Context }) {
|
||||
props.context.keymap.layer(() => ({
|
||||
mode: "global",
|
||||
commands: [
|
||||
{
|
||||
id: "app.storybook",
|
||||
title: "Open storybook",
|
||||
group: "Debug",
|
||||
palette: true,
|
||||
run() {
|
||||
props.context.ui.router.navigate({ type: "plugin", name: "storybook" })
|
||||
props.context.ui.dialog.clear()
|
||||
},
|
||||
},
|
||||
...stories.map((story) => ({
|
||||
id: `app.storybook.${story.id}`,
|
||||
title: `Storybook: ${story.title}`,
|
||||
group: "Debug",
|
||||
palette: true as const,
|
||||
run() {
|
||||
props.context.ui.router.navigate({ type: "plugin", name: "storybook", data: { story: story.id } })
|
||||
props.context.ui.dialog.clear()
|
||||
},
|
||||
})),
|
||||
],
|
||||
}))
|
||||
return null
|
||||
}
|
||||
|
||||
function StorybookIndex(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme
|
||||
const elevatedTheme = theme.contextual.elevated
|
||||
const [selected, setSelected] = createSignal(0)
|
||||
const open = (story: Story) =>
|
||||
props.context.ui.router.navigate({ type: "plugin", name: "storybook", data: { story: story.id } })
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
bind: "escape",
|
||||
title: "Back home",
|
||||
group: "Storybook",
|
||||
run() {
|
||||
props.context.ui.router.navigate({ type: "home" })
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "up,k",
|
||||
title: "Previous story",
|
||||
group: "Storybook",
|
||||
run: () => setSelected((current) => (current + stories.length - 1) % stories.length),
|
||||
},
|
||||
{
|
||||
bind: "down,j",
|
||||
title: "Next story",
|
||||
group: "Storybook",
|
||||
run: () => setSelected((current) => (current + 1) % stories.length),
|
||||
},
|
||||
{
|
||||
bind: "return",
|
||||
title: "Open story",
|
||||
group: "Storybook",
|
||||
run: () => open(stories[selected()]),
|
||||
},
|
||||
...stories.map((story, index) => ({
|
||||
bind: String(index + 1),
|
||||
title: `Open ${story.title}`,
|
||||
group: "Storybook",
|
||||
run: () => open(story),
|
||||
})),
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<box paddingTop={2} paddingLeft={2} flexDirection="column">
|
||||
<text fg={theme.text.default}>storybook</text>
|
||||
<text fg={theme.text.subdued}>fixture-driven simulations of production components</text>
|
||||
<box height={1} />
|
||||
<For each={stories}>
|
||||
{(story, index) => (
|
||||
<text fg={index() === selected() ? theme.text.default : theme.text.subdued}>
|
||||
{index() === selected() ? "› " : " "}
|
||||
{index() + 1} {story.title}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
<box flexGrow={1} />
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={elevatedTheme.background.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={elevatedTheme.text.subdued}>storybook</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={elevatedTheme.text.subdued}>↑/↓ select | enter open | esc home</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export default Plugin.define({
|
||||
id: "opencode.storybook",
|
||||
setup(context) {
|
||||
context.ui.router.register({
|
||||
name: "storybook",
|
||||
render: (input) => {
|
||||
const story = stories.find((story) => story.id === input.data?.story)
|
||||
if (story) return story.render(context)
|
||||
return <StorybookIndex context={context} />
|
||||
},
|
||||
})
|
||||
context.ui.slot("app", () => <Commands context={context} />)
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,368 @@
|
||||
import { Plugin } from "@opencode-ai/plugin/tui"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { batch, createSignal, For, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { SessionTabs, type SessionTabsController } from "../../../component/session-tabs"
|
||||
import { moveSessionTab } from "../../../context/session-tabs-model"
|
||||
import type { Story } from "./index"
|
||||
|
||||
type FixtureStatus = ReturnType<SessionTabsController["status"]>
|
||||
|
||||
const FIXTURE_TABS = [
|
||||
{ sessionID: "fixture-1", title: "Implement session tabs" },
|
||||
{ sessionID: "fixture-2", title: "Investigate rendering" },
|
||||
{ sessionID: "fixture-3", title: "A deliberately long session title for truncation" },
|
||||
{ sessionID: "fixture-4", title: "Fix provider state" },
|
||||
{ sessionID: "fixture-5", title: "Review animation" },
|
||||
{ sessionID: "fixture-6", title: "Untitled behavior" },
|
||||
{ sessionID: "fixture-7", title: "Queue follow-up work" },
|
||||
{ sessionID: "fixture-8", title: "Check narrow layout" },
|
||||
{ sessionID: "fixture-9", title: "Profile terminal output" },
|
||||
{ sessionID: "fixture-10", title: "Handle permission" },
|
||||
{ sessionID: "fixture-11", title: "Run focused tests" },
|
||||
{ sessionID: "fixture-12", title: "Prepare review" },
|
||||
]
|
||||
|
||||
const EMPTY_STATUS: FixtureStatus = { unread: undefined, attention: false, busy: false }
|
||||
const RUN_DURATION = 1_800
|
||||
const RESUME_DURATION = 900
|
||||
|
||||
// Plausible targets for the fake transcript's tool calls, picked per fixture index.
|
||||
const TRANSCRIPT_FILES = [
|
||||
"packages/tui/src/component/session-tabs.tsx",
|
||||
"packages/tui/src/component/tab-pulse.tsx",
|
||||
"packages/tui/src/context/session-tabs-model.ts",
|
||||
"packages/core/src/session/runner.ts",
|
||||
"packages/server/src/routes/session.ts",
|
||||
"packages/tui/src/ui/animation.ts",
|
||||
]
|
||||
|
||||
function SessionTabsStory(props: { context: Plugin.Context }) {
|
||||
const dimensions = useTerminalDimensions()
|
||||
const theme = props.context.theme
|
||||
const elevatedTheme = theme.contextual.elevated
|
||||
// A keyed store mirrors production: retitles mutate rows in place instead of remounting them.
|
||||
const [tabStore, setTabStore] = createStore<{ items: { sessionID: string; title?: string }[] }>({
|
||||
items: FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })),
|
||||
})
|
||||
const tabs = () => tabStore.items
|
||||
const setItems = (next: { sessionID: string; title?: string }[]) =>
|
||||
setTabStore("items", reconcile(next, { key: "sessionID" }))
|
||||
const [active, setActive] = createSignal<string | undefined>("fixture-1")
|
||||
const [lastEvent, setLastEvent] = createSignal("press space to start a random tab")
|
||||
const [statuses, setStatuses] = createSignal<Record<string, FixtureStatus>>({})
|
||||
// Unread clears on select, so the transcript remembers how each session's last run ended.
|
||||
const [outcomes, setOutcomes] = createSignal<Record<string, "completed" | "failed">>({})
|
||||
const runs = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
onCleanup(() => runs.forEach(clearTimeout))
|
||||
|
||||
const number = (sessionID: string) => tabs().findIndex((tab) => tab.sessionID === sessionID) + 1
|
||||
|
||||
function finishRun(sessionID: string, resumed: boolean) {
|
||||
runs.delete(sessionID)
|
||||
if (!tabs().some((item) => item.sessionID === sessionID)) return
|
||||
const roll = Math.random()
|
||||
// A permission request pauses the still-busy run until the tab is selected.
|
||||
if (!resumed && roll < 0.25) {
|
||||
setStatuses((current) => ({
|
||||
...current,
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), attention: true },
|
||||
}))
|
||||
setLastEvent(`tab ${number(sessionID)} needs input; select it to resolve`)
|
||||
return
|
||||
}
|
||||
const failed = roll >= 0.75
|
||||
const unread = active() === sessionID ? undefined : failed ? ("error" as const) : ("activity" as const)
|
||||
batch(() => {
|
||||
setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" }))
|
||||
setStatuses((current) => ({
|
||||
...current,
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: false, unread },
|
||||
}))
|
||||
// An untitled session earns its title after its first completed run, like a real summarization.
|
||||
const index = number(sessionID) - 1
|
||||
const fixture = FIXTURE_TABS.find((tab) => tab.sessionID === sessionID)
|
||||
if (!failed && fixture && tabs()[index]?.title === undefined) setTabStore("items", index, "title", fixture.title)
|
||||
})
|
||||
setLastEvent(
|
||||
`tab ${number(sessionID)} ${failed ? "failed" : "completed"}${unread ? " (unread)" : " while selected"}`,
|
||||
)
|
||||
}
|
||||
|
||||
const select = (sessionID: string) => {
|
||||
const status = statuses()[sessionID]
|
||||
const resumes = status !== undefined && status.attention && status.busy && !runs.has(sessionID)
|
||||
batch(() => {
|
||||
setActive(sessionID)
|
||||
if (status && (status.unread || status.attention))
|
||||
setStatuses((current) => ({ ...current, [sessionID]: { ...status, unread: undefined, attention: false } }))
|
||||
})
|
||||
if (resumes) {
|
||||
setLastEvent(`tab ${number(sessionID)} input resolved, resuming`)
|
||||
runs.set(
|
||||
sessionID,
|
||||
setTimeout(() => finishRun(sessionID, true), RESUME_DURATION),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const controller = {
|
||||
tabs,
|
||||
current: active,
|
||||
status(sessionID) {
|
||||
return statuses()[sessionID] ?? EMPTY_STATUS
|
||||
},
|
||||
select,
|
||||
move(sessionID: string, index: number) {
|
||||
const next = moveSessionTab(tabs(), sessionID, index)
|
||||
if (next === tabs()) return
|
||||
setItems(next.map((tab) => ({ ...tab })))
|
||||
},
|
||||
close(sessionID?: string) {
|
||||
const target = sessionID ?? active()
|
||||
if (!target) return
|
||||
const items = tabs()
|
||||
const index = items.findIndex((tab) => tab.sessionID === target)
|
||||
if (index === -1) return
|
||||
const next = items.filter((tab) => tab.sessionID !== target).map((tab) => ({ ...tab }))
|
||||
const selected = next[index]?.sessionID ?? next[index - 1]?.sessionID
|
||||
clearTimeout(runs.get(target))
|
||||
runs.delete(target)
|
||||
batch(() => {
|
||||
setItems(next)
|
||||
setStatuses((current) => {
|
||||
const updated = { ...current }
|
||||
delete updated[target]
|
||||
return updated
|
||||
})
|
||||
if (active() === target && selected) select(selected)
|
||||
if (active() === target && !selected) setActive(undefined)
|
||||
})
|
||||
},
|
||||
} satisfies SessionTabsController
|
||||
|
||||
const cycle = (direction: 1 | -1) => {
|
||||
const items = tabs()
|
||||
if (items.length === 0) return
|
||||
const index = items.findIndex((tab) => tab.sessionID === active())
|
||||
select(items[(index + direction + items.length) % items.length].sessionID)
|
||||
}
|
||||
const startRun = (sessionID: string) => {
|
||||
setStatuses((current) => ({
|
||||
...current,
|
||||
[sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: true, unread: undefined },
|
||||
}))
|
||||
setOutcomes((current) => {
|
||||
const next = { ...current }
|
||||
delete next[sessionID]
|
||||
return next
|
||||
})
|
||||
setLastEvent(`tab ${number(sessionID)} running`)
|
||||
runs.set(
|
||||
sessionID,
|
||||
setTimeout(() => finishRun(sessionID, false), RUN_DURATION),
|
||||
)
|
||||
}
|
||||
const randomInactiveTab = () => {
|
||||
const candidates = tabs().filter((tab) => {
|
||||
const status = controller.status(tab.sessionID)
|
||||
return !status.busy && !status.unread && !status.attention
|
||||
})
|
||||
// Untitled sessions run first so their title arrival is easy to trigger.
|
||||
const untitled = candidates.filter((tab) => tab.title === undefined)
|
||||
const pool = untitled.length > 0 ? untitled : candidates
|
||||
return pool[Math.floor(Math.random() * pool.length)]
|
||||
}
|
||||
// A fake transcript for the selected session so tab switches feel like moving between real
|
||||
// sessions; the tail line tracks the live status of the current run.
|
||||
const transcript = () => {
|
||||
const current = active()
|
||||
if (!current) return [{ text: "no session selected", color: theme.text.subdued }]
|
||||
const index = Math.max(
|
||||
0,
|
||||
FIXTURE_TABS.findIndex((fixture) => fixture.sessionID === current),
|
||||
)
|
||||
const fixture = FIXTURE_TABS[index]
|
||||
const status = controller.status(current)
|
||||
const outcome = outcomes()[current]
|
||||
const file = TRANSCRIPT_FILES[index % TRANSCRIPT_FILES.length]
|
||||
const lines = [
|
||||
{ text: `> ${fixture.title}`, color: theme.text.default },
|
||||
{ text: "", color: theme.text.default },
|
||||
]
|
||||
if (!status.busy && outcome === undefined) {
|
||||
lines.push({ text: "no activity yet — press s to run this session", color: theme.text.subdued })
|
||||
return lines
|
||||
}
|
||||
lines.push(
|
||||
{ text: "● Taking a look — reading the relevant code first.", color: theme.text.default },
|
||||
{ text: "", color: theme.text.default },
|
||||
{ text: ` ✱ Read ${file}`, color: theme.text.subdued },
|
||||
{ text: ` ✱ Edit ${file}`, color: theme.text.subdued },
|
||||
{ text: ` ✱ Bash bun run test`, color: theme.text.subdued },
|
||||
{ text: "", color: theme.text.default },
|
||||
)
|
||||
if (status.attention)
|
||||
lines.push({
|
||||
text: "⚠ Permission required: Bash `bun run test` — select this tab to approve",
|
||||
color: theme.text.feedback.warning.default,
|
||||
})
|
||||
else if (status.busy) lines.push({ text: "● Working…", color: theme.text.subdued })
|
||||
else if (outcome === "failed")
|
||||
lines.push({
|
||||
text: `✗ bun run test failed — 3 tests failing in ${file}`,
|
||||
color: theme.text.feedback.error.default,
|
||||
})
|
||||
else
|
||||
lines.push({
|
||||
text: `✓ Done — updated ${file} and the tests pass.`,
|
||||
color: theme.text.feedback.success.default,
|
||||
})
|
||||
return lines
|
||||
}
|
||||
|
||||
const selectedState = () => {
|
||||
const current = active()
|
||||
const status = current ? controller.status(current) : EMPTY_STATUS
|
||||
const activity = status.busy
|
||||
? "running"
|
||||
: status.unread === "activity"
|
||||
? "completed (unread)"
|
||||
: status.unread === "error"
|
||||
? "failed (unread)"
|
||||
: "read"
|
||||
return status.attention ? `${activity} + needs input` : activity
|
||||
}
|
||||
|
||||
props.context.keymap.layer(() => ({
|
||||
commands: [
|
||||
{
|
||||
bind: "escape",
|
||||
title: "Back to storybook",
|
||||
group: "Storybook",
|
||||
run() {
|
||||
props.context.ui.router.navigate({ type: "plugin", name: "storybook" })
|
||||
},
|
||||
},
|
||||
{ bind: "left,h", title: "Previous tab", group: "Storybook", run: () => cycle(-1) },
|
||||
{ bind: "right,l", title: "Next tab", group: "Storybook", run: () => cycle(1) },
|
||||
...Array.from({ length: 10 }, (_, index) => ({
|
||||
bind: String((index + 1) % 10),
|
||||
title: `Select tab ${index + 1}`,
|
||||
group: "Storybook",
|
||||
run() {
|
||||
const tab = tabs()[index]
|
||||
if (tab) select(tab.sessionID)
|
||||
},
|
||||
})),
|
||||
{
|
||||
bind: "space",
|
||||
title: "Start a random tab",
|
||||
group: "Storybook",
|
||||
run() {
|
||||
const tab = randomInactiveTab()
|
||||
if (!tab) {
|
||||
setLastEvent("every tab is busy or unread; select tabs to read them, or press r")
|
||||
return
|
||||
}
|
||||
startRun(tab.sessionID)
|
||||
},
|
||||
},
|
||||
{
|
||||
// Random runs stay off the selected tab, so this is the way to watch the edge flash
|
||||
// and running sweep under the cursor.
|
||||
bind: "s",
|
||||
title: "Run selected tab",
|
||||
group: "Storybook",
|
||||
run() {
|
||||
const current = active()
|
||||
if (!current) return
|
||||
if (controller.status(current).busy) {
|
||||
setLastEvent(`tab ${number(current)} is already running`)
|
||||
return
|
||||
}
|
||||
startRun(current)
|
||||
},
|
||||
},
|
||||
{
|
||||
bind: "t",
|
||||
title: "Add tab",
|
||||
group: "Storybook",
|
||||
run() {
|
||||
const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID))
|
||||
if (!next) {
|
||||
setLastEvent("all fixture tabs are open")
|
||||
return
|
||||
}
|
||||
setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }])
|
||||
select(next.sessionID)
|
||||
setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`)
|
||||
},
|
||||
},
|
||||
{ bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() },
|
||||
{
|
||||
bind: "r",
|
||||
title: "Reset",
|
||||
group: "Storybook",
|
||||
run() {
|
||||
runs.forEach(clearTimeout)
|
||||
runs.clear()
|
||||
batch(() => {
|
||||
setItems(FIXTURE_TABS.slice(0, 6).map((tab) => ({ ...tab })))
|
||||
setStatuses({})
|
||||
setOutcomes({})
|
||||
setActive("fixture-1")
|
||||
})
|
||||
setLastEvent("reset; press space to start a random tab")
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
return (
|
||||
<box
|
||||
width={dimensions().width}
|
||||
height={dimensions().height}
|
||||
flexDirection="column"
|
||||
backgroundColor={theme.background.default}
|
||||
>
|
||||
<SessionTabs controller={controller} />
|
||||
<box height={1} />
|
||||
<box flexGrow={1} paddingLeft={2} paddingRight={2} flexDirection="column">
|
||||
<For each={transcript()}>
|
||||
{(line) => (
|
||||
<text fg={line.color} wrapMode="none" selectable={false}>
|
||||
{line.text || " "}
|
||||
</text>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
<box paddingLeft={2} flexDirection="column">
|
||||
<text fg={theme.text.subdued}>
|
||||
selected: {number(active() ?? "")} | state: {selectedState()}
|
||||
</text>
|
||||
<text fg={theme.text.subdued}>background: {lastEvent()}</text>
|
||||
</box>
|
||||
<box
|
||||
height={1}
|
||||
flexShrink={0}
|
||||
backgroundColor={elevatedTheme.background.default}
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
flexDirection="row"
|
||||
>
|
||||
<text fg={elevatedTheme.text.subdued}>storybook / session tabs</text>
|
||||
<box flexGrow={1} />
|
||||
<text fg={elevatedTheme.text.subdued}>
|
||||
space/s run | t add | d close | r reset | ←/→ 1-0 move | drag reorders | esc back
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
export const sessionTabsStory: Story = {
|
||||
id: "session-tabs",
|
||||
title: "Session tabs",
|
||||
render: (context) => <SessionTabsStory context={context} />,
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import SidebarMcp from "../feature-plugins/sidebar/mcp"
|
||||
import DiffViewer from "../feature-plugins/system/diff-viewer"
|
||||
import Notifications from "../feature-plugins/system/notifications"
|
||||
import Plugins from "../feature-plugins/system/plugins"
|
||||
import Scrap from "../feature-plugins/system/scrap"
|
||||
import Storybook from "../feature-plugins/system/storybook"
|
||||
|
||||
export const builtins = [
|
||||
HomeFooter,
|
||||
@@ -18,6 +18,6 @@ export const builtins = [
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
Plugins,
|
||||
Scrap,
|
||||
Storybook,
|
||||
DiffViewer,
|
||||
]
|
||||
|
||||
@@ -1,634 +0,0 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { afterAll, describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { testRender } from "@opentui/solid"
|
||||
import { onMount } from "solid-js"
|
||||
import type { LogLevel, LogSink } from "../../../src/context/log"
|
||||
import { createApi, createFetch } from "../../fixture/tui-client"
|
||||
|
||||
const packageRoot = process.env.OPENCODE_TUI_ROOT
|
||||
const contextModule = packageRoot
|
||||
? await import(`${packageRoot}/src/context/client.tsx`)
|
||||
: await import("../../../src/context/client")
|
||||
const environmentModule = packageRoot
|
||||
? await import(`${packageRoot}/test/fixture/tui-environment.tsx`)
|
||||
: await import("../../fixture/tui-environment")
|
||||
const { ClientProvider, useClient } = contextModule as typeof import("../../../src/context/client")
|
||||
const { TestTuiContexts } = environmentModule as typeof import("../../fixture/tui-environment")
|
||||
|
||||
type Client = ReturnType<typeof useClient>
|
||||
type Service = {
|
||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
type Observation = {
|
||||
scenario: string
|
||||
value: unknown
|
||||
}
|
||||
|
||||
const observations: Observation[] = []
|
||||
const connected = { id: "evt_connected", type: "server.connected", data: {} } as OpenCodeEvent
|
||||
|
||||
afterAll(async () => {
|
||||
const output = process.env.CLIENT_BEHAVIOR_OUTPUT
|
||||
if (output) await Bun.write(output, `${JSON.stringify(observations, null, 2)}\n`)
|
||||
})
|
||||
|
||||
function observe(scenario: string, value: unknown) {
|
||||
observations.push({ scenario, value })
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown) {
|
||||
if (error instanceof Error) return `${error.name}:${error.message}`
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function history(client: Client) {
|
||||
return client.connection.internal.history().map((event) => ({
|
||||
status: event.data.status,
|
||||
attempt: event.data.attempt,
|
||||
error: event.data.error,
|
||||
}))
|
||||
}
|
||||
|
||||
async function waitFor(check: () => boolean, timeout = 3_000) {
|
||||
const started = Date.now()
|
||||
while (!check()) {
|
||||
if (Date.now() - started > timeout) throw new Error("timed out waiting for condition")
|
||||
await Bun.sleep(5)
|
||||
}
|
||||
}
|
||||
|
||||
function event(type: "vcs" | "update" | "rename", suffix: string): OpenCodeEvent {
|
||||
if (type === "vcs") {
|
||||
return {
|
||||
id: `evt_vcs_${suffix}`,
|
||||
created: 1,
|
||||
type: "vcs.branch.updated",
|
||||
location: { directory: "/tmp/project" },
|
||||
data: { branch: suffix },
|
||||
}
|
||||
}
|
||||
if (type === "update") {
|
||||
return {
|
||||
id: `evt_update_${suffix}`,
|
||||
created: 2,
|
||||
type: "installation.update-available",
|
||||
data: { version: suffix },
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: `evt_rename_${suffix}`,
|
||||
created: 3,
|
||||
type: "session.renamed",
|
||||
durable: { aggregateID: "ses_test", seq: 1, version: 1 },
|
||||
location: { directory: "/tmp/project" },
|
||||
data: { sessionID: "ses_test", title: suffix },
|
||||
}
|
||||
}
|
||||
|
||||
function createStream(options?: { first?: OpenCodeEvent; closeBeforeHandshake?: boolean }) {
|
||||
const encoder = new TextEncoder()
|
||||
const controllers = new Set<ReadableStreamDefaultController<Uint8Array>>()
|
||||
const requests: Request[] = []
|
||||
const aborts: string[] = []
|
||||
let cancellations = 0
|
||||
|
||||
function response(request: Request) {
|
||||
requests.push(request)
|
||||
request.signal.addEventListener("abort", () => aborts.push(normalizeError(request.signal.reason)), { once: true })
|
||||
|
||||
let current: ReadableStreamDefaultController<Uint8Array> | undefined
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
current = controller
|
||||
controllers.add(controller)
|
||||
if (options?.closeBeforeHandshake) {
|
||||
controllers.delete(controller)
|
||||
controller.close()
|
||||
return
|
||||
}
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(options?.first ?? connected)}\n\n`))
|
||||
},
|
||||
cancel() {
|
||||
cancellations += 1
|
||||
if (current) controllers.delete(current)
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
response,
|
||||
emit(value: OpenCodeEvent) {
|
||||
const chunk = encoder.encode(`data: ${JSON.stringify(value)}\n\n`)
|
||||
for (const controller of controllers) controller.enqueue(chunk)
|
||||
},
|
||||
raw(value: string) {
|
||||
const chunk = encoder.encode(value)
|
||||
for (const controller of controllers) controller.enqueue(chunk)
|
||||
},
|
||||
close() {
|
||||
for (const controller of [...controllers]) {
|
||||
controllers.delete(controller)
|
||||
controller.close()
|
||||
}
|
||||
},
|
||||
fail(message: string) {
|
||||
for (const controller of [...controllers]) {
|
||||
controllers.delete(controller)
|
||||
controller.error(new Error(message))
|
||||
}
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
requests: requests.length,
|
||||
requestAborted: requests.map((request) => request.signal.aborted),
|
||||
aborts,
|
||||
cancellations,
|
||||
active: controllers.size,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function apiFor(stream: ReturnType<typeof createStream>) {
|
||||
return createApi(
|
||||
createFetch((url, request) => {
|
||||
if (url.pathname === "/api/event") return stream.response(request)
|
||||
}).fetch,
|
||||
)
|
||||
}
|
||||
|
||||
async function mount(input: {
|
||||
api: OpenCodeClient
|
||||
service?: Service
|
||||
throwOn?: OpenCodeEvent["type"]
|
||||
}) {
|
||||
const seen: Array<{ type: string; status: string }> = []
|
||||
const typed: string[] = []
|
||||
const logs: Array<{ level: LogLevel; message: string; tags: Record<string, unknown> }> = []
|
||||
let initialStatus = ""
|
||||
let client!: Client
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
const log: LogSink = (level, message, tags) => {
|
||||
logs.push({ level, message, tags: { ...tags } })
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts log={log}>
|
||||
<ClientProvider api={input.api} service={input.service}>
|
||||
<Probe
|
||||
onReady={(value) => {
|
||||
client = value
|
||||
initialStatus = value.connection.status()
|
||||
ready()
|
||||
}}
|
||||
onEvent={(value) => {
|
||||
seen.push({ type: value.type, status: client.connection.status() })
|
||||
if (value.type === input.throwOn) throw new Error(`listener failed for ${value.type}`)
|
||||
}}
|
||||
onBranch={(branch) => typed.push(branch)}
|
||||
/>
|
||||
</ClientProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
await mounted
|
||||
|
||||
return { app, client, initialStatus, seen, typed, logs }
|
||||
}
|
||||
|
||||
function Probe(props: {
|
||||
onReady: (client: Client) => void
|
||||
onEvent: (event: OpenCodeEvent) => void
|
||||
onBranch: (branch: string) => void
|
||||
}) {
|
||||
const client = useClient()
|
||||
onMount(() => {
|
||||
client.event.listen(({ details }) => props.onEvent(details))
|
||||
client.event.on("vcs.branch.updated", (value) => props.onBranch(value.data.branch ?? ""))
|
||||
props.onReady(client)
|
||||
})
|
||||
return <box />
|
||||
}
|
||||
|
||||
describe("ClientProvider connection characterization", () => {
|
||||
test("records handshake ordering, event delivery, logging, and active-stream cleanup", async () => {
|
||||
const stream = createStream()
|
||||
const setup = await mount({ api: apiFor(stream) })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
stream.emit(event("vcs", "main"))
|
||||
stream.emit(event("rename", "renamed"))
|
||||
stream.emit(event("update", "2.0.0"))
|
||||
await waitFor(() => setup.seen.length === 4)
|
||||
|
||||
observe("healthy.connected", {
|
||||
initialStatus: setup.initialStatus,
|
||||
finalStatus: setup.client.connection.status(),
|
||||
seen: setup.seen,
|
||||
typed: setup.typed,
|
||||
logs: setup.logs,
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
await waitFor(() => stream.snapshot().requestAborted[0] === true)
|
||||
await Bun.sleep(20)
|
||||
|
||||
observe("healthy.cleanup", {
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
})
|
||||
|
||||
expect(setup.seen.map((item) => item.type)).toEqual([
|
||||
"server.connected",
|
||||
"vcs.branch.updated",
|
||||
"session.renamed",
|
||||
"installation.update-available",
|
||||
])
|
||||
expect(setup.seen.map((item) => item.status)).toEqual(["connecting", "connected", "connected", "connected"])
|
||||
expect(setup.logs.filter((item) => item.message === "event")).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("records an invalid first event", async () => {
|
||||
const stream = createStream({ first: event("vcs", "invalid-handshake") })
|
||||
const setup = await mount({ api: apiFor(stream) })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "reconnecting")
|
||||
observe("handshake.invalid", {
|
||||
status: setup.client.connection.status(),
|
||||
error: setup.client.connection.error(),
|
||||
seen: setup.seen,
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(setup.client.connection.error()).toBe("Event stream did not start with server.connected")
|
||||
})
|
||||
|
||||
test("records EOF before the handshake", async () => {
|
||||
const stream = createStream({ closeBeforeHandshake: true })
|
||||
const setup = await mount({ api: apiFor(stream) })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "reconnecting")
|
||||
observe("handshake.eof", {
|
||||
status: setup.client.connection.status(),
|
||||
error: setup.client.connection.error(),
|
||||
seen: setup.seen,
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(setup.client.connection.error()).toBe("Event stream disconnected")
|
||||
})
|
||||
|
||||
test("records a fetch failure before the handshake", async () => {
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/event") throw new Error("network unavailable")
|
||||
return undefined
|
||||
})
|
||||
const setup = await mount({ api: createApi(calls.fetch) })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "reconnecting")
|
||||
observe("handshake.fetch-error", {
|
||||
status: setup.client.connection.status(),
|
||||
error: setup.client.connection.error(),
|
||||
seen: setup.seen,
|
||||
history: history(setup.client),
|
||||
logs: setup.logs,
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(setup.client.connection.error()).toBe("Transport")
|
||||
})
|
||||
|
||||
test("records the initial connection timeout and request cancellation", async () => {
|
||||
const requests: Request[] = []
|
||||
const calls = createFetch((url, request) => {
|
||||
if (url.pathname !== "/api/event") return
|
||||
requests.push(request)
|
||||
return new Promise<Response>((_, reject) => {
|
||||
request.signal.addEventListener("abort", () => reject(request.signal.reason), { once: true })
|
||||
})
|
||||
})
|
||||
const setup = await mount({ api: createApi(calls.fetch) })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "reconnecting", 3_000)
|
||||
observe("handshake.timeout", {
|
||||
status: setup.client.connection.status(),
|
||||
error: setup.client.connection.error(),
|
||||
requestCount: requests.length,
|
||||
requestAborted: requests.map((request) => request.signal.aborted),
|
||||
abortReasons: requests.map((request) => normalizeError(request.signal.reason)),
|
||||
history: history(setup.client),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(setup.client.connection.error()).toBe("Transport")
|
||||
})
|
||||
|
||||
test("records static transport reconnection after a connected stream closes", async () => {
|
||||
const stream = createStream()
|
||||
const setup = await mount({ api: apiFor(stream) })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
stream.close()
|
||||
await waitFor(() => stream.snapshot().requests === 2, 2_000)
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
|
||||
observe("reconnect.static", {
|
||||
status: setup.client.connection.status(),
|
||||
seen: setup.seen,
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
logs: setup.logs.filter((item) => item.message !== "event"),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(setup.seen.map((item) => item.type)).toEqual(["server.connected", "server.connected"])
|
||||
})
|
||||
|
||||
test("records immediate managed-service replacement", async () => {
|
||||
const initial = createStream()
|
||||
const replacement = createStream()
|
||||
const replacementApi = apiFor(replacement)
|
||||
const reconnectSignals: boolean[] = []
|
||||
const service: Service = {
|
||||
reconnect(signal) {
|
||||
reconnectSignals.push(signal.aborted)
|
||||
return Promise.resolve({ api: replacementApi })
|
||||
},
|
||||
restart: () => Promise.resolve(),
|
||||
}
|
||||
const setup = await mount({ api: apiFor(initial), service })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
initial.close()
|
||||
await waitFor(() => replacement.snapshot().requests === 1)
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
replacement.emit(event("vcs", "replacement"))
|
||||
await waitFor(() => setup.typed.includes("replacement"))
|
||||
|
||||
observe("reconnect.managed-replacement", {
|
||||
status: setup.client.connection.status(),
|
||||
apiReplaced: setup.client.api === replacementApi,
|
||||
reconnectSignals,
|
||||
seen: setup.seen,
|
||||
typed: setup.typed,
|
||||
history: history(setup.client),
|
||||
initial: initial.snapshot(),
|
||||
replacement: replacement.snapshot(),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(setup.client.api).toBe(replacementApi)
|
||||
})
|
||||
|
||||
test("records managed-service resolution failure and delayed retry", async () => {
|
||||
const stream = createStream()
|
||||
let reconnects = 0
|
||||
const service: Service = {
|
||||
reconnect() {
|
||||
reconnects += 1
|
||||
return Promise.reject(new Error("service unavailable"))
|
||||
},
|
||||
restart: () => Promise.resolve(),
|
||||
}
|
||||
const setup = await mount({ api: apiFor(stream), service })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
stream.close()
|
||||
await waitFor(() => stream.snapshot().requests === 2, 2_000)
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
|
||||
observe("reconnect.managed-failure", {
|
||||
reconnects,
|
||||
status: setup.client.connection.status(),
|
||||
seen: setup.seen,
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
resolutionLogs: setup.logs.filter((item) => item.message === "server resolution failed"),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(reconnects).toBe(1)
|
||||
})
|
||||
|
||||
test("records cleanup while the initial fetch is pending", async () => {
|
||||
const requests: Request[] = []
|
||||
const aborts: string[] = []
|
||||
const calls = createFetch((url, request) => {
|
||||
if (url.pathname !== "/api/event") return
|
||||
requests.push(request)
|
||||
return new Promise<Response>((_, reject) => {
|
||||
request.signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
aborts.push(normalizeError(request.signal.reason))
|
||||
reject(request.signal.reason)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
})
|
||||
const setup = await mount({ api: createApi(calls.fetch) })
|
||||
|
||||
await waitFor(() => requests.length === 1)
|
||||
setup.app.renderer.destroy()
|
||||
await waitFor(() => requests[0].signal.aborted)
|
||||
await Bun.sleep(20)
|
||||
|
||||
observe("cleanup.pending-handshake", {
|
||||
status: setup.client.connection.status(),
|
||||
requestAborted: requests[0].signal.aborted,
|
||||
aborts,
|
||||
history: history(setup.client),
|
||||
logs: setup.logs,
|
||||
})
|
||||
|
||||
expect(history(setup.client).map((item) => item.status)).toEqual(["connecting"])
|
||||
})
|
||||
|
||||
test("records an event listener failure as a connection failure", async () => {
|
||||
const stream = createStream()
|
||||
const setup = await mount({ api: apiFor(stream), throwOn: "vcs.branch.updated" })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
stream.emit(event("vcs", "throws"))
|
||||
await waitFor(() => setup.client.connection.status() === "reconnecting")
|
||||
|
||||
observe("listener.failure", {
|
||||
status: setup.client.connection.status(),
|
||||
error: setup.client.connection.error(),
|
||||
seen: setup.seen,
|
||||
typed: setup.typed,
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(setup.client.connection.error()).toBe("listener failed for vcs.branch.updated")
|
||||
})
|
||||
|
||||
test("records stream reader failure after connection", async () => {
|
||||
const stream = createStream()
|
||||
const setup = await mount({ api: apiFor(stream) })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
stream.fail("reader exploded")
|
||||
await waitFor(() => setup.client.connection.status() === "reconnecting")
|
||||
|
||||
observe("stream.reader-failure", {
|
||||
status: setup.client.connection.status(),
|
||||
error: setup.client.connection.error(),
|
||||
seen: setup.seen,
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(setup.client.connection.error()).toBe("Transport")
|
||||
})
|
||||
|
||||
test("records malformed SSE data after connection", async () => {
|
||||
const stream = createStream()
|
||||
const setup = await mount({ api: apiFor(stream) })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
stream.raw("data: not-json\n\n")
|
||||
await waitFor(() => setup.client.connection.status() === "reconnecting")
|
||||
|
||||
observe("stream.malformed-data", {
|
||||
status: setup.client.connection.status(),
|
||||
error: setup.client.connection.error(),
|
||||
seen: setup.seen,
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(setup.client.connection.error()).toBe("MalformedResponse")
|
||||
})
|
||||
|
||||
test("records a server.connected listener failure before connected state publication", async () => {
|
||||
const stream = createStream()
|
||||
const setup = await mount({ api: apiFor(stream), throwOn: "server.connected" })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "reconnecting")
|
||||
observe("listener.connected-failure", {
|
||||
status: setup.client.connection.status(),
|
||||
error: setup.client.connection.error(),
|
||||
seen: setup.seen,
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(history(setup.client).map((item) => item.status)).toEqual(["connecting", "connected", "disconnected"])
|
||||
})
|
||||
|
||||
test("records cleanup during static reconnect backoff", async () => {
|
||||
const stream = createStream()
|
||||
const setup = await mount({ api: apiFor(stream) })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
stream.close()
|
||||
await waitFor(() => setup.client.connection.status() === "reconnecting")
|
||||
setup.app.renderer.destroy()
|
||||
await Bun.sleep(1_050)
|
||||
|
||||
observe("cleanup.reconnect-backoff", {
|
||||
status: setup.client.connection.status(),
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
})
|
||||
|
||||
expect(stream.snapshot().requests).toBe(1)
|
||||
})
|
||||
|
||||
test("records cleanup during managed-service resolution", async () => {
|
||||
const stream = createStream()
|
||||
let resolutionStarted = false
|
||||
let resolutionAborted = false
|
||||
const service: Service = {
|
||||
reconnect(signal) {
|
||||
resolutionStarted = true
|
||||
return new Promise((_, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => {
|
||||
resolutionAborted = true
|
||||
reject(signal.reason)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
},
|
||||
restart: () => Promise.resolve(),
|
||||
}
|
||||
const setup = await mount({ api: apiFor(stream), service })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
stream.close()
|
||||
await waitFor(() => resolutionStarted)
|
||||
setup.app.renderer.destroy()
|
||||
await waitFor(() => resolutionAborted)
|
||||
await Bun.sleep(20)
|
||||
|
||||
observe("cleanup.service-resolution", {
|
||||
resolutionStarted,
|
||||
resolutionAborted,
|
||||
status: setup.client.connection.status(),
|
||||
history: history(setup.client),
|
||||
stream: stream.snapshot(),
|
||||
logs: setup.logs,
|
||||
})
|
||||
|
||||
expect(resolutionAborted).toBe(true)
|
||||
})
|
||||
|
||||
test("records attempt reset after a stable connection", async () => {
|
||||
const streams = [createStream(), createStream(), createStream()]
|
||||
const apis = streams.map(apiFor)
|
||||
let reconnects = 0
|
||||
const service: Service = {
|
||||
reconnect() {
|
||||
const api = apis[Math.min(reconnects + 1, apis.length - 1)]
|
||||
reconnects += 1
|
||||
return Promise.resolve({ api })
|
||||
},
|
||||
restart: () => Promise.resolve(),
|
||||
}
|
||||
const setup = await mount({ api: apis[0], service })
|
||||
|
||||
await waitFor(() => setup.client.connection.status() === "connected")
|
||||
streams[0].close()
|
||||
await waitFor(() => streams[1].snapshot().requests === 1)
|
||||
streams[1].close()
|
||||
await waitFor(() => streams[2].snapshot().requests === 1)
|
||||
await Bun.sleep(1_050)
|
||||
streams[2].close()
|
||||
await waitFor(() => reconnects === 3)
|
||||
|
||||
observe("reconnect.stable-reset", {
|
||||
reconnects,
|
||||
status: setup.client.connection.status(),
|
||||
history: history(setup.client),
|
||||
streams: streams.map((stream) => stream.snapshot()),
|
||||
})
|
||||
|
||||
setup.app.renderer.destroy()
|
||||
expect(history(setup.client).filter((item) => item.status === "disconnected").map((item) => item.attempt)).toEqual([
|
||||
1, 2, 1,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -10,9 +10,9 @@ import { tint } from "../../src/theme/color"
|
||||
|
||||
test("completion pulse rises quickly and fades over the remaining duration", () => {
|
||||
expect(completionPulseOpacity(0)).toBe(0)
|
||||
expect(completionPulseOpacity(0.08)).toBeCloseTo(0.5)
|
||||
expect(completionPulseOpacity(0.16)).toBe(1)
|
||||
expect(completionPulseOpacity(0.58)).toBeCloseTo(0.5)
|
||||
expect(completionPulseOpacity(0.06)).toBeCloseTo(0.5)
|
||||
expect(completionPulseOpacity(0.12)).toBe(1)
|
||||
expect(completionPulseOpacity(0.56)).toBeCloseTo(0.5)
|
||||
expect(completionPulseOpacity(1)).toBe(0)
|
||||
})
|
||||
|
||||
@@ -44,15 +44,33 @@ test("reuses a color while preserving the original glow and pulse blend stages",
|
||||
const background = RGBA.fromHex("#1a1b26")
|
||||
const glowColor = RGBA.fromHex("#82aaff")
|
||||
const runningColor = RGBA.fromHex("#c8d3f5")
|
||||
const flashColor = RGBA.fromHex("#e2e8fb")
|
||||
const completionColor = RGBA.fromHex("#ff9e64")
|
||||
|
||||
for (const glow of [0, 0.08, 0.16]) {
|
||||
for (const running of [0, 0.01, 0.07, 0.14]) {
|
||||
for (const completion of [0, 0.03, 0.09, 0.18]) {
|
||||
blendTabPulseColor(output, background, glowColor, runningColor, completionColor, glow, running, completion)
|
||||
expect(output.buffer).toEqual(
|
||||
tint(tint(tint(background, glowColor, glow), runningColor, running), completionColor, completion).buffer,
|
||||
)
|
||||
for (const flash of [0, 0.05, 0.1]) {
|
||||
for (const completion of [0, 0.03, 0.09, 0.18]) {
|
||||
blendTabPulseColor(
|
||||
output,
|
||||
background,
|
||||
glowColor,
|
||||
runningColor,
|
||||
flashColor,
|
||||
completionColor,
|
||||
glow,
|
||||
running,
|
||||
flash,
|
||||
completion,
|
||||
)
|
||||
expect(output.buffer).toEqual(
|
||||
tint(
|
||||
tint(tint(tint(background, glowColor, glow), runningColor, running), flashColor, flash),
|
||||
completionColor,
|
||||
completion,
|
||||
).buffer,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
export * as Bom from "./bom.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { FSUtil } from "./fs-util.js"
|
||||
|
||||
const code = 0xfeff
|
||||
const value = String.fromCharCode(code)
|
||||
|
||||
export function split(text: string) {
|
||||
const stripped = text.replace(/^\uFEFF+/, "")
|
||||
return { bom: stripped.length !== text.length, text: stripped }
|
||||
}
|
||||
|
||||
export function join(text: string, bom: boolean) {
|
||||
const stripped = split(text).text
|
||||
return bom ? value + stripped : stripped
|
||||
}
|
||||
|
||||
export function has(content: Uint8Array) {
|
||||
return content[0] === 0xef && content[1] === 0xbb && content[2] === 0xbf
|
||||
}
|
||||
|
||||
export const readFile = Effect.fn("Bom.readFile")(function* (fs: FSUtil.Interface, filepath: string) {
|
||||
return split(decode(yield* fs.readFile(filepath)))
|
||||
})
|
||||
|
||||
export const syncFile = Effect.fn("Bom.syncFile")(function* (fs: FSUtil.Interface, filepath: string, bom: boolean) {
|
||||
const decoded = decode(yield* fs.readFile(filepath))
|
||||
const current = split(decoded)
|
||||
const canonical = join(current.text, bom)
|
||||
if (decoded === canonical) return current.text
|
||||
yield* fs.writeWithDirs(filepath, canonical)
|
||||
return current.text
|
||||
})
|
||||
|
||||
function decode(content: Uint8Array) {
|
||||
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(content)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as Patch from "./patch.js"
|
||||
|
||||
import { Result, Schema } from "effect"
|
||||
import { Bom } from "./bom.js"
|
||||
|
||||
export class BoundaryError extends Schema.TaggedErrorClass<BoundaryError>()("Patch.BoundaryError", {
|
||||
boundary: Schema.Literals(["first", "last"]),
|
||||
@@ -125,20 +126,19 @@ export function parse(patchText: string): Result.Result<ReadonlyArray<Hunk>, Par
|
||||
}
|
||||
|
||||
export function derive(path: string, chunks: ReadonlyArray<UpdateFileChunk>, original: string): FileUpdate {
|
||||
const source = splitBom(original)
|
||||
const source = Bom.split(original)
|
||||
const lines = source.text.split("\n")
|
||||
if (lines.at(-1) === "") lines.pop()
|
||||
const replacements = computeReplacements(lines, path, chunks)
|
||||
const updated = [...lines]
|
||||
for (const [start, remove, insert] of replacements.toReversed()) updated.splice(start, remove, ...insert)
|
||||
if (updated.at(-1) !== "") updated.push("")
|
||||
const next = splitBom(updated.join("\n"))
|
||||
const next = Bom.split(updated.join("\n"))
|
||||
return { content: next.text, bom: source.bom || next.bom }
|
||||
}
|
||||
|
||||
export function joinBom(text: string, bom: boolean) {
|
||||
const stripped = splitBom(text).text
|
||||
return bom ? `\uFEFF${stripped}` : stripped
|
||||
return Bom.join(text, bom)
|
||||
}
|
||||
|
||||
function parseAdd(
|
||||
@@ -379,6 +379,4 @@ const normalize = (value: string) =>
|
||||
.replace(/[“”„‟]/g, '"')
|
||||
.replace(/[‐‑‒–—―−]/g, "-")
|
||||
.replace(/[\u00A0\u2002-\u200A\u202F\u205F\u3000]/g, " ")
|
||||
const splitBom = (text: string) =>
|
||||
text.startsWith("\uFEFF") ? { bom: true, text: text.slice(1) } : { bom: false, text }
|
||||
const stripHeredoc = (input: string) => input.match(/^(?:cat\s+)?<<(['"]?)(\w+)\1\s*\n([\s\S]*?)\n\2\s*$/)?.[3] ?? input
|
||||
|
||||
Reference in New Issue
Block a user