mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-29 22:51:51 -04:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f96d3caaf | |||
| 97786afdd8 | |||
| b1a5e8a6ae | |||
| 0ece10af43 | |||
| d1a02b149c | |||
| 94ee274aeb | |||
| 3c259fc552 | |||
| 210be4b749 | |||
| 464649e67e | |||
| c8dca936b1 | |||
| 4f871906bc | |||
| f7ea2fc346 | |||
| 5cb633a48e |
@@ -136,7 +136,7 @@ describe("acp service lifecycle", () => {
|
||||
method: "POST",
|
||||
path: "/api/session/ses_loaded/fork",
|
||||
query: {},
|
||||
body: {},
|
||||
body: { boundary: { type: "through" } },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const Entry = Schema.Struct({
|
||||
path: Schema.String,
|
||||
description: Schema.String,
|
||||
signature: Schema.String,
|
||||
pinned: Schema.optionalKey(Schema.Boolean),
|
||||
})
|
||||
export type Entry = typeof Entry.Type
|
||||
|
||||
@@ -56,26 +57,39 @@ export function summarize(entries: ReadonlyArray<Entry>, budget = INLINE_BUDGET)
|
||||
if (left.path > right.path) return 1
|
||||
return 0
|
||||
})
|
||||
const ranked = rankListings(listings)
|
||||
const pinned = new Set(
|
||||
namespaceEntries
|
||||
.filter((entry) => entry.pinned)
|
||||
.map((entry) => listings.find((listing) => listing.path === entry.path))
|
||||
.filter((listing) => listing !== undefined),
|
||||
)
|
||||
return {
|
||||
name,
|
||||
listings,
|
||||
selectionOrder: rankListings(listings),
|
||||
selectedListings: new Set<typeof Listing.Type>(),
|
||||
selectionOrder: ranked.filter((candidate) => !pinned.has(candidate.listing)),
|
||||
selectedListings: pinned,
|
||||
selectionIndex: 0,
|
||||
}
|
||||
})
|
||||
|
||||
const active = new Set(namespaces)
|
||||
let remaining = budget
|
||||
let remaining =
|
||||
budget -
|
||||
namespaces
|
||||
.flatMap((namespace) => namespace.listings.filter((listing) => namespace.selectedListings.has(listing)))
|
||||
.reduce((total, listing) => total + Math.round(listing.line.length / CHARACTERS_PER_TOKEN), 0)
|
||||
while (active.size > 0) {
|
||||
for (const namespace of active) {
|
||||
const candidate = namespace.selectionOrder[namespace.selectedListings.size]
|
||||
const candidate = namespace.selectionOrder[namespace.selectionIndex]
|
||||
if (!candidate || candidate.cost > remaining) {
|
||||
active.delete(namespace)
|
||||
continue
|
||||
}
|
||||
namespace.selectedListings.add(candidate.listing)
|
||||
namespace.selectionIndex += 1
|
||||
remaining -= candidate.cost
|
||||
if (namespace.selectedListings.size === namespace.selectionOrder.length) active.delete(namespace)
|
||||
if (namespace.selectionIndex === namespace.selectionOrder.length) active.delete(namespace)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -138,7 +138,14 @@ export const create = (
|
||||
}
|
||||
|
||||
export const catalog = (registrations: ReadonlyMap<string, Info>) => {
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable"))).catalog()
|
||||
const pinned = new Set(
|
||||
Array.from(registrations.values())
|
||||
.filter((registration) => registration.options?.pinned === true)
|
||||
.map(qualifiedName),
|
||||
)
|
||||
return runtime(registrations, () => Effect.fail(toolError("Execute context is unavailable")))
|
||||
.catalog()
|
||||
.map((entry) => ({ ...entry, pinned: pinned.has(entry.path) }))
|
||||
}
|
||||
|
||||
function runtime(
|
||||
@@ -149,11 +156,7 @@ function runtime(
|
||||
const tools: Record<string, Tool.Tool<never>> = {}
|
||||
for (const [name, registration] of registrations) {
|
||||
const child = definition(registration)
|
||||
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
const path =
|
||||
registration.options?.namespace === undefined
|
||||
? normalized
|
||||
: `${registration.options.namespace}.${normalized}`
|
||||
const path = qualifiedName(registration)
|
||||
tools[path] = Tool.make({
|
||||
description: child.description,
|
||||
input: child.inputSchema,
|
||||
@@ -164,6 +167,12 @@ function runtime(
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
}
|
||||
|
||||
function qualifiedName(registration: Info) {
|
||||
const normalized = registration.name.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
if (registration.options?.namespace === undefined) return normalized
|
||||
return `${registration.options.namespace}.${normalized}`
|
||||
}
|
||||
|
||||
// Tool inputs arrive as parsed JSON, so the JSON value cast is a boundary fact.
|
||||
function displayInput(input: unknown): Record<string, typeof Schema.Json.Type> | undefined {
|
||||
if (input === null || input === undefined) return
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as PluginHooks from "./hooks"
|
||||
|
||||
import type { AISDKHooks } from "@opencode-ai/plugin/effect/aisdk"
|
||||
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
|
||||
import type { ShellHooks } from "@opencode-ai/plugin/effect/shell"
|
||||
import type { ToolHooks } from "@opencode-ai/plugin/effect/tool"
|
||||
import { Context, Effect, Layer, Scope } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
@@ -10,6 +11,7 @@ import { State } from "../state"
|
||||
export interface Domains {
|
||||
readonly aisdk: AISDKHooks
|
||||
readonly session: SessionHooks
|
||||
readonly shell: ShellHooks
|
||||
readonly tool: ToolHooks
|
||||
}
|
||||
|
||||
|
||||
@@ -296,6 +296,9 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
||||
})
|
||||
}),
|
||||
},
|
||||
shell: {
|
||||
hook: (name, callback) => hooks.register("shell", name, callback),
|
||||
},
|
||||
tool: {
|
||||
transform: (callback) =>
|
||||
tools
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -326,6 +326,10 @@ export function fromPromise(plugin: Plugin) {
|
||||
),
|
||||
interrupt: (input) => run(host.session.interrupt({ sessionID: Session.ID.make(input.sessionID) })),
|
||||
},
|
||||
shell: {
|
||||
hook: (name, callback) =>
|
||||
register(host.shell.hook(name, (event) => Effect.promise(() => Promise.resolve(callback(event))))),
|
||||
},
|
||||
}
|
||||
|
||||
const cleanup = yield* Effect.promise(() => Promise.resolve(plugin.setup(context2)))
|
||||
|
||||
@@ -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,
|
||||
|
||||
+34
-18
@@ -12,6 +12,8 @@ import { Bus } from "./bus"
|
||||
import { Location } from "./location"
|
||||
import { Global } from "@opencode-ai/util/global"
|
||||
import { ShellSelect } from "./shell/select"
|
||||
import type { ShellCreateBefore } from "@opencode-ai/plugin/effect/shell"
|
||||
import { PluginHooks } from "./plugin/hooks"
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Shell.NotFoundError", {
|
||||
id: Shell.ID,
|
||||
@@ -45,7 +47,10 @@ type Active = {
|
||||
*/
|
||||
export interface Interface {
|
||||
readonly name: () => Effect.Effect<string>
|
||||
readonly create: (input: Shell.CreateInput) => Effect.Effect<Shell.Info>
|
||||
readonly create: <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) => Effect.Effect<Shell.Info, E, R>
|
||||
// Currently running commands only; exited shells are retained for get/output but excluded here.
|
||||
readonly list: () => Effect.Effect<Shell.Info[]>
|
||||
readonly get: (id: Shell.ID) => Effect.Effect<Shell.Info, NotFoundError>
|
||||
@@ -68,6 +73,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
const config = yield* Config.Service
|
||||
const global = yield* Global.Service
|
||||
const appProcess = yield* AppProcess.Service
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const context = yield* Effect.context()
|
||||
const runFork = Effect.runForkWith(context)
|
||||
const sessions = new Map<string, Active>()
|
||||
@@ -172,24 +178,34 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
}
|
||||
})
|
||||
|
||||
const create = Effect.fn("Shell.create")(function* (input: Shell.CreateInput) {
|
||||
const create = Effect.fn("Shell.create")(function* <E = never, R = never>(
|
||||
input: Shell.CreateInput,
|
||||
before?: (input: ShellCreateBefore) => Effect.Effect<void, E, R>,
|
||||
) {
|
||||
const invocation: ShellCreateBefore = {
|
||||
command: input.command,
|
||||
cwd: input.cwd ?? location.directory,
|
||||
timeout: input.timeout,
|
||||
shell: yield* resolve(),
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
},
|
||||
}
|
||||
yield* hooks.trigger("shell", "create.before", invocation)
|
||||
if (before) yield* before(invocation)
|
||||
|
||||
const id = Shell.ID.ascending()
|
||||
const cwd = input.cwd ?? location.directory
|
||||
const shell = yield* resolve()
|
||||
const args = ShellSelect.args(shell, input.command)
|
||||
const args = ShellSelect.args(invocation.shell, invocation.command)
|
||||
const file = path.join(outputDir, `${id}.out`)
|
||||
const env = {
|
||||
...process.env,
|
||||
TERM: "xterm-256color",
|
||||
OPENCODE_TERMINAL: "1",
|
||||
} as Record<string, string>
|
||||
|
||||
const info: Info = {
|
||||
id,
|
||||
status: "running",
|
||||
command: input.command,
|
||||
cwd,
|
||||
shell,
|
||||
command: invocation.command,
|
||||
cwd: invocation.cwd,
|
||||
shell: invocation.shell,
|
||||
file,
|
||||
metadata: input.metadata ?? {},
|
||||
time: { started: Date.now() },
|
||||
@@ -203,9 +219,9 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* appProcess.spawn(
|
||||
ChildProcess.make(shell, args, {
|
||||
cwd,
|
||||
env,
|
||||
ChildProcess.make(invocation.shell, args, {
|
||||
cwd: invocation.cwd,
|
||||
env: invocation.env,
|
||||
stdin: "ignore",
|
||||
detached: process.platform !== "win32",
|
||||
forceKillAfter: Duration.seconds(3),
|
||||
@@ -297,7 +313,7 @@ export const layer = (options?: ShellSelect.Options) => Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
yield* session.timeout(input.timeout)
|
||||
yield* session.timeout(invocation.timeout)
|
||||
|
||||
runFork(
|
||||
handle.exitCode.pipe(
|
||||
@@ -327,7 +343,7 @@ export function configured(options?: ShellSelect.Options) {
|
||||
return makeLocationNode({
|
||||
service: Service,
|
||||
layer: layer(options),
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node],
|
||||
deps: [Bus.node, Location.node, Config.node, Global.node, AppProcess.node, PluginHooks.node],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,14 @@ 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 { createTwoFilesPatch, diffLines } from "diff"
|
||||
import { Bom } from "@opencode-ai/util/bom"
|
||||
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"
|
||||
import { fileDiff } from "./file-diff"
|
||||
|
||||
export const name = "edit"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -151,14 +153,6 @@ export const Plugin = {
|
||||
})
|
||||
}
|
||||
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
const info = yield* fs.stat(target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () =>
|
||||
Effect.fail(new ToolFailure({ message: `File not found: ${input.path}` })),
|
||||
@@ -167,9 +161,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)
|
||||
@@ -183,6 +176,26 @@ export const Plugin = {
|
||||
: findLineOccurrences(source, oldString)
|
||||
const matches = exact.length > 0 ? exact : unicode.length > 0 ? unicode : trailing
|
||||
const replacements = matches.length
|
||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||
.toReversed()
|
||||
.reduce(
|
||||
(content, match) =>
|
||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||
source,
|
||||
)
|
||||
const preview =
|
||||
replacements > 0 && (replacements === 1 || input.replaceAll === true)
|
||||
? fileDiff(target.resource, source, replaced)
|
||||
: undefined
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: preview ? { files: [preview] } : undefined,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source: permissionSource,
|
||||
})
|
||||
if (replacements === 0) {
|
||||
return yield* new ToolFailure({
|
||||
message: `Could not find oldString in ${input.path}. It must match exactly, including whitespace and indentation.`,
|
||||
@@ -193,35 +206,17 @@ export const Plugin = {
|
||||
message: `Found ${replacements} matches for oldString, but expected exactly one. Add more surrounding context to make oldString unique, or set replaceAll to true to replace every occurrence.`,
|
||||
})
|
||||
}
|
||||
|
||||
const replaced = (input.replaceAll === true ? matches : matches.slice(0, 1))
|
||||
.toReversed()
|
||||
.reduce(
|
||||
(content, match) =>
|
||||
`${content.slice(0, match.start)}${newString}${content.slice(match.end)}`,
|
||||
source,
|
||||
)
|
||||
const counts = diffLines(source, replaced).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}`,
|
||||
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
|
||||
return {
|
||||
files: [
|
||||
{
|
||||
file: result.resource,
|
||||
patch: createTwoFilesPatch(result.resource, result.resource, source, replaced),
|
||||
status: "modified" as const,
|
||||
...counts,
|
||||
},
|
||||
],
|
||||
files: [fileDiff(result.resource, source, formatted)],
|
||||
replacements,
|
||||
} satisfies Output
|
||||
}).pipe(
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { FileDiff } from "@opencode-ai/schema/file-diff"
|
||||
import { createTwoFilesPatch, diffLines } from "diff"
|
||||
|
||||
export function fileDiff(
|
||||
file: string,
|
||||
before: string,
|
||||
after: string,
|
||||
status: typeof FileDiff.Info.Type.status = "modified",
|
||||
): typeof FileDiff.Info.Type {
|
||||
const counts = diffLines(before, after).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 },
|
||||
)
|
||||
return {
|
||||
file,
|
||||
patch: createTwoFilesPatch(file, file, before, after),
|
||||
status,
|
||||
...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),
|
||||
|
||||
@@ -146,34 +146,40 @@ export const Plugin = {
|
||||
messageID: context.messageID,
|
||||
callID: context.callID,
|
||||
}
|
||||
const target = yield* mutation.resolve({ path: input.workdir ?? ".", kind: "directory" })
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [input.command],
|
||||
save: [input.command],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
|
||||
const timeout = input.background === true ? (input.timeout ?? 0) : (input.timeout ?? DEFAULT_TIMEOUT_MS)
|
||||
const info = yield* shell.create({
|
||||
command: input.command,
|
||||
cwd: target.canonical,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
})
|
||||
let finalTimeout = timeout
|
||||
const info = yield* shell.create(
|
||||
{
|
||||
command: input.command,
|
||||
cwd: input.workdir,
|
||||
timeout,
|
||||
metadata: { sessionID: context.sessionID },
|
||||
},
|
||||
(invocation) =>
|
||||
Effect.gen(function* () {
|
||||
const target = yield* mutation.resolve({ path: invocation.cwd, kind: "directory" })
|
||||
invocation.cwd = target.canonical
|
||||
finalTimeout = invocation.timeout
|
||||
const external = target.externalDirectory
|
||||
if (external)
|
||||
yield* permission.assert({
|
||||
...LocationMutation.externalDirectoryPermission(external),
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: [invocation.command],
|
||||
save: [invocation.command],
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if ((yield* fsUtil.stat(target.canonical)).type !== "Directory")
|
||||
return yield* Effect.fail(new Error(`Working directory is not a directory: ${target.canonical}`))
|
||||
}),
|
||||
)
|
||||
yield* context.progress({ shellID: info.id })
|
||||
|
||||
const captureShell = Effect.fn("ShellTool.captureShell")(function* () {
|
||||
@@ -192,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 ${timeout} 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,
|
||||
@@ -223,14 +229,14 @@ export const Plugin = {
|
||||
const job = yield* runtime.job.start({
|
||||
id: context.callID,
|
||||
type: name,
|
||||
title: input.command,
|
||||
title: info.command,
|
||||
metadata: { sessionID: context.sessionID, shellID: info.id },
|
||||
run,
|
||||
})
|
||||
|
||||
if (input.background === true) {
|
||||
yield* runtime.job.background(job.id)
|
||||
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
|
||||
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
@@ -244,7 +250,7 @@ export const Plugin = {
|
||||
)
|
||||
if (result?.type === "backgrounded") {
|
||||
yield* shell.timeout(info.id, 0)
|
||||
yield* notifyWhenDone(context.sessionID, context.callID, input.command)
|
||||
yield* notifyWhenDone(context.sessionID, context.callID, info.command)
|
||||
return {
|
||||
output: BACKGROUND_STARTED,
|
||||
shellID: info.id,
|
||||
|
||||
@@ -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,17 +9,20 @@ 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"
|
||||
import { fileDiff } from "./file-diff"
|
||||
|
||||
export const name = "write"
|
||||
|
||||
// TODO: Revisit whether model-facing mutation schemas should prefer absolute `filePath` naming for trained-in compatibility after evaluating model behavior.
|
||||
export const Input = Schema.Struct({
|
||||
path: Schema.String.annotate({
|
||||
description:
|
||||
"File path to write. Relative paths resolve within the active Location. Absolute paths inside that Location are accepted; external absolute paths require external_directory approval.",
|
||||
description: "Path to the file to write to",
|
||||
}),
|
||||
content: Schema.String.annotate({ description: "Content to write to the file" }),
|
||||
})
|
||||
@@ -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
|
||||
@@ -55,7 +59,7 @@ export const Plugin = {
|
||||
name,
|
||||
options: { codemode: false, permission: "edit" },
|
||||
description:
|
||||
"Write content to one file. Relative paths resolve within the active Location. Absolute paths inside the Location are accepted. Explicit external absolute paths require external_directory approval before edit approval.",
|
||||
"Writes a file to the local filesystem, overwriting if one exists.\n\nMissing parent directories are created automatically.\n\nUse this tool to create new files or overwrite existing files. For partial changes, use the edit tool instead.",
|
||||
input: Input,
|
||||
output: Output,
|
||||
execute: (input, context) =>
|
||||
@@ -74,15 +78,29 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
const current = yield* Bom.readFile(fs, target.canonical).pipe(
|
||||
Effect.catchReason("PlatformError", "NotFound", () => Effect.succeed(undefined)),
|
||||
)
|
||||
const next = Bom.split(input.content)
|
||||
const preview = fileDiff(
|
||||
target.resource,
|
||||
current?.text ?? "",
|
||||
next.text,
|
||||
current ? "modified" : "added",
|
||||
)
|
||||
yield* permission.assert({
|
||||
action: "edit",
|
||||
resources: [target.resource],
|
||||
save: ["*"],
|
||||
metadata: { files: [preview] },
|
||||
sessionID: context.sessionID,
|
||||
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 })),
|
||||
|
||||
@@ -16,6 +16,7 @@ describe("CodeMode", () => {
|
||||
description: "Echo text",
|
||||
input: Schema.Struct({ text: Schema.String }),
|
||||
output: Schema.String,
|
||||
options: { pinned: true },
|
||||
execute: ({ text }) => Effect.succeed({ output: text }),
|
||||
}),
|
||||
)
|
||||
@@ -27,6 +28,7 @@ describe("CodeMode", () => {
|
||||
path: "echo",
|
||||
description: "Echo text",
|
||||
signature: "tools.echo(input: {\n text: string,\n}): Promise<string>",
|
||||
pinned: true,
|
||||
},
|
||||
])
|
||||
}).pipe(
|
||||
|
||||
@@ -2,10 +2,11 @@ import { describe, expect, test } from "bun:test"
|
||||
import { CodeModeCatalog } from "@opencode-ai/core/codemode/catalog"
|
||||
import { CodeModeInstructions } from "@opencode-ai/core/codemode/instructions"
|
||||
|
||||
const entry = (path: string, description: string, signature?: string): CodeModeCatalog.Entry => ({
|
||||
const entry = (path: string, description: string, signature?: string, pinned = false): CodeModeCatalog.Entry => ({
|
||||
path,
|
||||
description,
|
||||
signature: signature ?? `tools.${path}(input: {\n q: string,\n}): Promise<string>`,
|
||||
pinned,
|
||||
})
|
||||
|
||||
const lookup = entry(
|
||||
@@ -46,6 +47,30 @@ describe("CodeModeCatalog.summarize", () => {
|
||||
expect(catalog.namespaces.every((namespace) => namespace.entries.length === 0)).toBe(true)
|
||||
})
|
||||
|
||||
test("always retains pinned tools beyond the inline budget", () => {
|
||||
const pinned = [
|
||||
entry("alpha.first", "First", undefined, true),
|
||||
entry("beta.second", "Second", undefined, true),
|
||||
]
|
||||
const catalog = CodeModeCatalog.summarize([...pinned, entry("alpha.unpinned", "Unpinned")], 0)
|
||||
|
||||
expect(catalog.shown).toBe(2)
|
||||
expect(catalog.namespaces.flatMap((namespace) => namespace.entries.map((item) => item.path))).toEqual([
|
||||
"alpha.first",
|
||||
"beta.second",
|
||||
])
|
||||
})
|
||||
|
||||
test("spends the budget remaining after pinned tools on unpinned tools", () => {
|
||||
const pinned = entry("alpha.pinned", "Pinned", undefined, true)
|
||||
const unpinned = entry("beta.unpinned", "Unpinned")
|
||||
const pinCost = Math.round(` - ${pinned.signature} // Pinned`.length / 4)
|
||||
const unpinnedCost = Math.round(` - ${unpinned.signature} // Unpinned`.length / 4)
|
||||
|
||||
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost).shown).toBe(2)
|
||||
expect(CodeModeCatalog.summarize([pinned, unpinned], pinCost + unpinnedCost - 1).shown).toBe(1)
|
||||
})
|
||||
|
||||
test("retains only the rendered portion of inline descriptions", () => {
|
||||
const catalog = CodeModeCatalog.summarize([entry("alpha.one", `Summary\n${"detail".repeat(10_000)}`)])
|
||||
expect(catalog.namespaces[0]?.entries[0]?.line).toEndWith("// Summary")
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -42,4 +42,25 @@ describe("PluginHooks", () => {
|
||||
expect(event.messages).toEqual([Message.user("changed")])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("mutates shell creation input", () =>
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("shell", "create.before", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.command = "echo changed"
|
||||
}),
|
||||
)
|
||||
const event = {
|
||||
command: "echo original",
|
||||
cwd: "/tmp",
|
||||
timeout: 0,
|
||||
shell: "/bin/sh",
|
||||
env: {},
|
||||
}
|
||||
|
||||
expect(yield* hooks.trigger("shell", "create.before", event)).toBe(event)
|
||||
expect(event.command).toBe("echo changed")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -86,6 +86,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
|
||||
transform: () => Effect.die("unused skill.transform"),
|
||||
reload: () => Effect.die("unused skill.reload"),
|
||||
},
|
||||
shell: overrides.shell ?? {
|
||||
hook: () => Effect.die("unused shell.hook"),
|
||||
},
|
||||
tool: overrides.tool ?? {
|
||||
transform: () => Effect.die("unused tool.transform"),
|
||||
hook: () => Effect.die("unused tool.hook"),
|
||||
|
||||
@@ -67,7 +67,7 @@ const transform = (
|
||||
) =>
|
||||
service.transform((draft) =>
|
||||
Object.entries(tools).forEach(([name, tool]) =>
|
||||
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
|
||||
draft.add({ ...tool, name, options: options ?? tool.options }),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -231,7 +231,7 @@ const permission = Layer.succeed(
|
||||
const transformTools = (registry: Tool.Interface, tools: Readonly<Record<string, Info>>, options?: Tool.Options) =>
|
||||
registry.transform((draft) =>
|
||||
Object.entries(tools).forEach(([name, tool]) =>
|
||||
draft.add({ ...tool, name, options: { ...tool.options, ...options } }),
|
||||
draft.add({ ...tool, name, options: options ?? tool.options }),
|
||||
),
|
||||
)
|
||||
const echo = Layer.effectDiscard(
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
),
|
||||
@@ -171,6 +179,17 @@ describe("EditTool", () => {
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(target, "utf8"))).toBe("after\nrest\n")
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["hello.txt"], save: ["*"] }])
|
||||
expect(assertions[0]?.metadata).toMatchObject({
|
||||
files: [
|
||||
{
|
||||
file: "hello.txt",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: expect.stringContaining("-before\n+after"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(writes).toEqual([yield* Effect.promise(() => fs.realpath(target))])
|
||||
}),
|
||||
),
|
||||
@@ -181,6 +200,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()),
|
||||
@@ -302,7 +354,7 @@ describe("EditTool", () => {
|
||||
error: { type: "permission.rejected", message: "Permission denied: edit" },
|
||||
})
|
||||
expect(assertions.map((input) => input.action)).toEqual(["external_directory", "edit"])
|
||||
expect(reads).toBe(0)
|
||||
expect(reads).toBe(1)
|
||||
expect(writes).toEqual([])
|
||||
expect(yield* Effect.promise(() => fs.readFile(external, "utf8"))).toBe("before")
|
||||
}),
|
||||
@@ -313,7 +365,7 @@ describe("EditTool", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("denied edit reads no target content and does not disclose whether oldString matches", () =>
|
||||
it.live("denied edit does not disclose whether oldString matches", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(tmp) => {
|
||||
@@ -339,7 +391,7 @@ describe("EditTool", () => {
|
||||
})
|
||||
expect(missing).toEqual(matching)
|
||||
expect(assertions.map((input) => input.action)).toEqual(["edit", "edit"])
|
||||
expect(reads).toBe(0)
|
||||
expect(reads).toBe(2)
|
||||
expect(writes).toEqual([])
|
||||
}),
|
||||
),
|
||||
@@ -574,6 +626,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],
|
||||
],
|
||||
),
|
||||
@@ -132,6 +140,17 @@ describe("WriteTool", () => {
|
||||
"created",
|
||||
)
|
||||
expect(assertions).toMatchObject([{ sessionID, action: "edit", resources: ["src/new.txt"], save: ["*"] }])
|
||||
expect(assertions[0]?.metadata).toMatchObject({
|
||||
files: [
|
||||
{
|
||||
file: "src/new.txt",
|
||||
status: "added",
|
||||
additions: 1,
|
||||
deletions: 0,
|
||||
patch: expect.stringContaining("+created"),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(writes).toEqual([path.join(yield* Effect.promise(() => fs.realpath(tmp.path)), "src", "new.txt")])
|
||||
}),
|
||||
)
|
||||
@@ -140,6 +159,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()),
|
||||
@@ -155,6 +198,17 @@ describe("WriteTool", () => {
|
||||
if (settled.status !== "completed") return
|
||||
expect(settled.content).toEqual([{ type: "text", text: "Wrote file successfully: existing.txt" }])
|
||||
expect(settled.output).toMatchObject({ resource: "existing.txt", existed: true })
|
||||
expect(assertions[0]?.metadata).toMatchObject({
|
||||
files: [
|
||||
{
|
||||
file: "existing.txt",
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: expect.stringMatching(/-before[\s\S]*\+after/),
|
||||
},
|
||||
],
|
||||
})
|
||||
expect(yield* Effect.promise(() => fs.readFile(path.join(tmp.path, "existing.txt"), "utf8"))).toBe(
|
||||
"after",
|
||||
)
|
||||
@@ -174,6 +228,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(
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { ShellDomain } from "./shell.js"
|
||||
import type { SkillDomain } from "./skill.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { WebSearchDomain } from "./websearch.js"
|
||||
@@ -26,6 +27,7 @@ export interface Context {
|
||||
readonly plugin: PluginApi<unknown>
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
readonly shell: ShellDomain
|
||||
readonly skill: SkillDomain
|
||||
readonly tool: ToolDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface ShellCreateBefore {
|
||||
command: string
|
||||
cwd: string
|
||||
timeout: number
|
||||
shell: string
|
||||
env: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export interface ShellHooks {
|
||||
readonly "create.before": ShellCreateBefore
|
||||
}
|
||||
|
||||
export interface ShellDomain {
|
||||
readonly hook: Hooks<ShellHooks>
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type { EventDomain } from "./event.js"
|
||||
import type { IntegrationDomain } from "./integration.js"
|
||||
import type { ReferenceDomain } from "./reference.js"
|
||||
import type { SessionDomain } from "./session.js"
|
||||
import type { ShellDomain } from "./shell.js"
|
||||
import type { SkillDomain } from "./skill.js"
|
||||
import type { ToolDomain } from "./tool.js"
|
||||
import type { WebSearchDomain } from "./websearch.js"
|
||||
@@ -25,6 +26,7 @@ export interface Context {
|
||||
readonly plugin: PluginApi
|
||||
readonly reference: ReferenceDomain
|
||||
readonly session: SessionDomain
|
||||
readonly shell: ShellDomain
|
||||
readonly skill: SkillDomain
|
||||
readonly tool: ToolDomain
|
||||
readonly websearch: WebSearchDomain
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Hooks } from "./registration.js"
|
||||
|
||||
export interface ShellCreateBefore {
|
||||
command: string
|
||||
cwd: string
|
||||
timeout: number
|
||||
shell: string
|
||||
env: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export interface ShellHooks {
|
||||
readonly "create.before": ShellCreateBefore
|
||||
}
|
||||
|
||||
export interface ShellDomain {
|
||||
readonly hook: Hooks<ShellHooks>
|
||||
}
|
||||
@@ -346,6 +346,25 @@ export interface UI {
|
||||
navigate(destination: Destination): void
|
||||
current(): Route
|
||||
}
|
||||
readonly tabs: {
|
||||
/** Returns whether session tabs are enabled for this TUI. */
|
||||
enabled(): boolean
|
||||
/** Returns the currently open root-session tabs. Reactive when read in a Solid computation. */
|
||||
list(): readonly {
|
||||
readonly sessionID: string
|
||||
readonly title?: string
|
||||
readonly active: boolean
|
||||
readonly busy: boolean
|
||||
readonly attention: boolean
|
||||
readonly unread?: "activity" | "error"
|
||||
}[]
|
||||
/** Opens (or focuses) a tab for a session, adding it when not already open. Returns false when tabs are disabled. */
|
||||
open(sessionID: string): boolean
|
||||
/** Focuses an already-open tab and returns false when it is not open. */
|
||||
focus(sessionID: string): boolean
|
||||
/** Closes an open tab, or the active tab when omitted, and returns false when no tab matched. */
|
||||
close(sessionID?: string): boolean
|
||||
}
|
||||
readonly slot: <Name extends SlotName>(name: Name, render: Slot<Name>) => () => void
|
||||
}
|
||||
|
||||
|
||||
@@ -19,12 +19,23 @@ export interface Context {
|
||||
readonly progress: (update: Metadata) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
interface BaseOptions {
|
||||
readonly namespace?: string
|
||||
readonly codemode?: boolean
|
||||
readonly permission?: string
|
||||
}
|
||||
|
||||
export type Options = BaseOptions &
|
||||
(
|
||||
| {
|
||||
readonly codemode?: true
|
||||
readonly pinned?: boolean
|
||||
}
|
||||
| {
|
||||
readonly codemode: boolean
|
||||
readonly pinned?: never
|
||||
}
|
||||
)
|
||||
|
||||
export type ValueSchema<A = unknown> =
|
||||
| Schema.Codec<A, any>
|
||||
| (StandardSchemaV1<any, A> & StandardJSONSchemaV1<any, A>)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -216,19 +216,34 @@ export function Prompt(props: PromptProps) {
|
||||
})
|
||||
Keymap.createLayer(() => ({
|
||||
mode: "global",
|
||||
enabled: props.sessionID !== undefined,
|
||||
commands: [
|
||||
{
|
||||
id: "session.cd",
|
||||
title: "Change working directory",
|
||||
slash: { name: "cd", arguments: true },
|
||||
run: async (input) => {
|
||||
const sessionID = props.sessionID
|
||||
if (!sessionID) return
|
||||
if (!input?.trim()) {
|
||||
toast.show({ message: "Directory is required", variant: "error" })
|
||||
return
|
||||
}
|
||||
const sessionID = props.sessionID
|
||||
if (!sessionID) {
|
||||
const value = input.trim()
|
||||
const expanded =
|
||||
value === "~" ? paths.home : value.startsWith("~/") ? path.join(paths.home, value.slice(2)) : value
|
||||
const directory = path.resolve(
|
||||
currentLocation.current?.directory ?? data.location.default().directory,
|
||||
expanded,
|
||||
)
|
||||
const location = await client.api.location.get({ location: { directory } }).catch((error) => {
|
||||
toast.show({ title: "Failed to change directory", message: errorMessage(error), variant: "error" })
|
||||
return undefined
|
||||
})
|
||||
if (!location) return
|
||||
move.setDirectory(location.directory, location.directory !== location.project.directory)
|
||||
currentLocation.set(location)
|
||||
return
|
||||
}
|
||||
await client.api.session
|
||||
.move({ sessionID, directory: input })
|
||||
.catch((error) =>
|
||||
|
||||
@@ -158,6 +158,10 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
setCreating(false)
|
||||
}
|
||||
|
||||
function setDirectory(directory: string, subdirectory: boolean) {
|
||||
setDestination({ type: "directory", directory, subdirectory })
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!creating()) {
|
||||
setCreatingDots(3)
|
||||
@@ -176,6 +180,7 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
||||
pending,
|
||||
pendingNew,
|
||||
progress,
|
||||
setDirectory,
|
||||
startSubmit,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useDialog } from "../ui/dialog"
|
||||
import { useToast } from "../ui/toast"
|
||||
import { useAttention } from "../context/attention"
|
||||
import { useStorage } from "../context/storage"
|
||||
import { useSessionTabs } from "../context/session-tabs"
|
||||
import { abbreviateHome } from "../util/path-format"
|
||||
import { builtins } from "./builtins"
|
||||
import { discoverTuiPlugins } from "./discovery"
|
||||
@@ -94,6 +95,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
const toast = useToast()
|
||||
const attention = useAttention()
|
||||
const storage = useStorage()
|
||||
const sessionTabs = useSessionTabs()
|
||||
const directory = config.path ? path.dirname(config.path) : process.cwd()
|
||||
const [store, setStore] = createStore({
|
||||
ready: false,
|
||||
@@ -278,6 +280,33 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver }>
|
||||
return route.data
|
||||
},
|
||||
},
|
||||
tabs: {
|
||||
enabled: sessionTabs.enabled,
|
||||
list: () =>
|
||||
sessionTabs.tabs().map((tab) => ({
|
||||
...tab,
|
||||
active: sessionTabs.current() === tab.sessionID,
|
||||
...sessionTabs.status(tab.sessionID),
|
||||
})),
|
||||
open(sessionID) {
|
||||
if (!sessionTabs.enabled()) return false
|
||||
sessionTabs.select(sessionID)
|
||||
return true
|
||||
},
|
||||
focus(sessionID) {
|
||||
if (!sessionTabs.enabled()) return false
|
||||
if (!sessionTabs.tabs().some((tab) => tab.sessionID === sessionID)) return false
|
||||
sessionTabs.select(sessionID)
|
||||
return true
|
||||
},
|
||||
close(sessionID) {
|
||||
if (!sessionTabs.enabled()) return false
|
||||
const target = sessionID ?? sessionTabs.current()
|
||||
if (!target || !sessionTabs.tabs().some((tab) => tab.sessionID === target)) return false
|
||||
sessionTabs.close(target)
|
||||
return true
|
||||
},
|
||||
},
|
||||
slot(name, render) {
|
||||
if (store.registrations[item.plugin.id]?.slots[name]) throw new Error(`Slot already registered: ${name}`)
|
||||
setStore("registrations", item.plugin.id, "slots", name, () => (input: SlotMap[typeof name]) => (
|
||||
|
||||
@@ -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,
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1398,6 +1398,29 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
{ type: "compaction-queued", inputID: "message-compaction-later" },
|
||||
])
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_step_started",
|
||||
created: 2,
|
||||
type: "session.step.started",
|
||||
durable: durable(sessionID, 3),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_text_started",
|
||||
created: 2,
|
||||
type: "session.text.started",
|
||||
durable: durable(sessionID, 4),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "message-assistant",
|
||||
ordinal: 0,
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_text_ended",
|
||||
created: 2,
|
||||
@@ -1417,7 +1440,7 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
id: "evt_compaction_started",
|
||||
created: 2,
|
||||
type: "session.compaction.started",
|
||||
durable: durable(sessionID, 4),
|
||||
durable: durable(sessionID, 6),
|
||||
data: {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
@@ -1432,7 +1455,7 @@ test("restores queued compaction from durable pending input", async () => {
|
||||
id: "evt_compaction_ended",
|
||||
created: 3,
|
||||
type: "session.compaction.ended",
|
||||
durable: durable(sessionID, 5),
|
||||
durable: durable(sessionID, 7),
|
||||
data: { sessionID, reason: "manual", text: "Summary", recent: "" },
|
||||
})
|
||||
expect(data.session.pending.list(sessionID).map((item) => item.id)).toEqual(["message-compaction-later"])
|
||||
|
||||
@@ -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