Compare commits

..
5 Commits
60 changed files with 690 additions and 595 deletions
+10 -68
View File
@@ -1,17 +1,17 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions, type Info } from "@opencode-ai/client/effect/service"
import { Service, type DiscoverOptions } from "@opencode-ai/client/effect/service"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Global } from "@opencode-ai/util/global"
import { OPENCODE_CHANNEL, OPENCODE_VERSION } from "./version"
import { AppProcess } from "@opencode-ai/util/process"
import { randomBytes, randomUUID } from "node:crypto"
import path from "node:path"
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } from "effect"
import { Effect, Option, Redacted, Schedule } from "effect"
import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
@@ -120,7 +120,13 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
onListen: (address, shutdown) =>
Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password)
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
return yield* ServiceRegistration.register({
address,
password,
id: instanceID,
file: serviceOptions.file,
shutdown,
})
}),
},
transform,
@@ -157,70 +163,6 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
)
})
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
const register = Effect.fnUntraced(function* (
address: HttpServer.Address,
password: string,
id: string,
file: string,
shutdown: Effect.Effect<void>,
) {
const fs = yield* FileSystem.FileSystem
const temp = file + "." + id + ".tmp"
yield* fs.makeDirectory(path.dirname(file), { recursive: true })
const info = {
id,
version: OPENCODE_VERSION,
url: HttpServer.formatAddress(address),
pid: process.pid,
password,
}
const encoded = yield* encodeInfo(info)
const current = fs.readFileString(file).pipe(Effect.flatMap(decodeInfo))
const owns = (found: Info) =>
found.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, file)))
yield* current.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("managed service registration check failed; shutting down", {
cause,
serviceID: id,
servicePID: process.pid,
registration: file,
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.tap((found) =>
owns(found)
? Effect.void
: Effect.logWarning("managed service registration replaced; shutting down", {
serviceID: id,
servicePID: process.pid,
registration: file,
observedServiceID: found.id,
observedServicePID: found.pid,
observedVersion: found.version,
observedURL: found.url,
}),
),
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(shutdown),
Effect.forkScoped,
)
return current.pipe(
Effect.flatMap((found) => (owns(found) ? fs.remove(file) : Effect.void)),
Effect.ignore,
)
})
const recognizeIncumbent = Effect.fnUntraced(function* (options: DiscoverOptions, hostname: string, port: number) {
const found = yield* Service.incumbent({ ...options, url: serviceURL(hostname, port) }).pipe(
Effect.filterOrFail((value) => value !== undefined),
@@ -0,0 +1,71 @@
export * as ServiceRegistration from "./service-registration"
import { Service, type Info } from "@opencode-ai/client/effect/service"
import path from "node:path"
import { Effect, FileSystem, Schedule, Schema } from "effect"
import { HttpServer } from "effect/unstable/http"
import { OPENCODE_VERSION } from "../version"
const infoJson = Schema.fromJsonString(Service.Info)
const encodeInfo = Schema.encodeEffect(infoJson)
const decodeInfo = Schema.decodeUnknownEffect(infoJson)
export const register = Effect.fnUntraced(function* (options: {
readonly address: HttpServer.Address
readonly password: string
readonly id: string
readonly file: string
readonly shutdown: Effect.Effect<void>
}) {
const fs = yield* FileSystem.FileSystem
const temp = options.file + "." + options.id + ".tmp"
yield* fs.makeDirectory(path.dirname(options.file), { recursive: true })
const info = {
id: options.id,
version: OPENCODE_VERSION,
url: HttpServer.formatAddress(options.address),
pid: process.pid,
password: options.password,
}
const encoded = yield* encodeInfo(info)
const current = fs.readFileString(options.file).pipe(Effect.flatMap(decodeInfo))
const owns = (found: Info) =>
found.id === info.id &&
found.version === info.version &&
found.url === info.url &&
found.pid === info.pid &&
found.password === info.password
yield* fs.writeFileString(temp, encoded, { mode: 0o600 }).pipe(Effect.andThen(fs.rename(temp, options.file)))
yield* current.pipe(
Effect.catchCause((cause) =>
Effect.logWarning("managed service registration check failed; shutting down", {
cause,
serviceID: options.id,
servicePID: process.pid,
registration: options.file,
}).pipe(Effect.andThen(Effect.failCause(cause))),
),
Effect.tap((found) =>
owns(found)
? Effect.void
: Effect.logWarning("managed service registration replaced; shutting down", {
serviceID: options.id,
servicePID: process.pid,
registration: options.file,
observedServiceID: found.id,
observedServicePID: found.pid,
observedVersion: found.version,
observedURL: found.url,
}),
),
Effect.filterOrFail(owns),
Effect.repeat(Schedule.spaced("5 seconds")),
Effect.ignore,
Effect.andThen(options.shutdown),
Effect.forkScoped,
)
return current.pipe(
Effect.flatMap((found) => (owns(found) ? fs.remove(options.file) : Effect.void)),
Effect.ignore,
)
})
+1 -84
View File
@@ -1,97 +1,14 @@
import { afterEach, describe, expect, test } from "bun:test"
import { describe, expect, test } from "bun:test"
import path from "node:path"
type Message = { readonly id?: number; readonly result?: unknown; readonly error?: unknown }
const children: Bun.Subprocess[] = []
afterEach(async () => {
await Promise.all(
children.splice(0).map(async (child) => {
child.kill("SIGKILL")
await child.exited
}),
)
})
describe("acp command", () => {
test("is registered", async () => {
const result = await cli(["--help"])
expect(result.exitCode).toBe(0)
expect(result.stdout).toContain("acp Start an Agent Client Protocol server")
})
test("initializes over ndjson and exits on stdin eof", async () => {
const child = spawn()
const stderr = new Response(child.stderr).text()
await child.stdin.write(
new TextEncoder().encode(
JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: 1,
clientCapabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
},
}) + "\n",
),
)
await child.stdin.flush()
const response = await readMessage(child.stdout)
expect(response.id).toBe(1)
expect(response.error).toBeUndefined()
expect(response.result).toMatchObject({
protocolVersion: 1,
agentCapabilities: { loadSession: true },
agentInfo: { name: "OpenCode" },
})
await child.stdin.end()
const exitCode = await child.exited
const errorOutput = await stderr
if (exitCode !== 0) throw new Error(`ACP exited with ${exitCode}: ${errorOutput}`)
children.splice(children.indexOf(child), 1)
}, 30_000)
})
function spawn() {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", "acp"], {
cwd: path.join(import.meta.dir, "../.."),
stdin: "pipe",
stdout: "pipe",
stderr: "pipe",
})
children.push(child)
return child
}
async function readMessage(stream: ReadableStream<Uint8Array>) {
const reader = stream.getReader()
const decoder = new TextDecoder()
let output = ""
while (true) {
const result = await Promise.race([
reader.read(),
Bun.sleep(20_000).then(() => {
throw new Error("timed out waiting for ACP response")
}),
])
if (result.done) throw new Error(`ACP exited before responding: ${output}`)
output += decoder.decode(result.value, { stream: true })
const newline = output.indexOf("\n")
if (newline === -1) continue
reader.releaseLock()
const message: unknown = JSON.parse(output.slice(0, newline))
if (!isMessage(message)) throw new Error(`invalid ACP response: ${output.slice(0, newline)}`)
return message
}
}
function isMessage(value: unknown): value is Message {
return typeof value === "object" && value !== null
}
async function cli(args: string[]) {
const child = Bun.spawn([process.execPath, "run", "src/index.ts", ...args], {
cwd: path.join(import.meta.dir, "../.."),
@@ -77,13 +77,6 @@ describe("acp lifecycle subprocess", () => {
expect(listed.sessions.some((item) => item.sessionId === session.sessionId)).toBe(false)
}, 60_000)
test("resume capability advertisement", async () => {
await using fixture = await createAcpFixture()
const initialized = await initialize(fixture.spawn())
expect(initialized.agentCapabilities?.sessionCapabilities?.resume).toEqual({})
}, 60_000)
test("resume request returns session config options", async () => {
await using fixture = await createAcpFixture()
const acp = fixture.spawn()
+14 -16
View File
@@ -7,6 +7,7 @@ import type {
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { isolatedEnv } from "../fixture/environment"
type JsonRpcRequest = {
readonly jsonrpc: "2.0"
@@ -100,33 +101,30 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
llm: { requests },
spawn(extraEnv: Record<string, string | undefined> = {}) {
const acp = spawnAcp({
env: {
...process.env,
HOME: root,
env: isolatedEnv(root, {
USERPROFILE: root,
OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_CONTENT: undefined,
OPENCODE_CONFIG_DIR: config,
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_DISABLE_AUTOUPDATE: "true",
OPENCODE_DISABLE_FILEWATCHER: "true",
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_MODELS_PATH: undefined,
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "xdg-config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
...extraEnv,
},
}),
})
processes.add(acp)
return acp
},
async [Symbol.asyncDispose]() {
await Promise.all([...processes].map((process) => process[Symbol.asyncDispose]()))
await llm.stop(true)
await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
const processResults = await Promise.allSettled(
[...processes].map((process) => process.close().catch(() => process[Symbol.asyncDispose]())),
)
const serverResults = await Promise.allSettled([llm.stop(true)])
const directoryResults = await Promise.allSettled([
fs.rm(root, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 }),
])
const failure = [...processResults, ...serverResults, ...directoryResults].find(
(result): result is PromiseRejectedResult => result.status === "rejected",
)
if (failure) throw failure.reason
},
}
}
-1
View File
@@ -349,7 +349,6 @@ test("serializes migration and updates across processes", async () => {
})
try {
await waitForFile(updateReady, update.exited)
expect(await Promise.race([update.exited.then(() => true), Bun.sleep(500).then(() => false)])).toBe(false)
await Bun.write(release, "")
const [migrateCode, updateCode] = await Promise.all([migrate.exited, update.exited])
expect(await new Response(migrate.stderr).text()).toBe("")
+19
View File
@@ -0,0 +1,19 @@
import path from "node:path"
export function isolatedEnv(root: string, overrides: Record<string, string | undefined> = {}) {
return {
...process.env,
HOME: root,
OPENCODE_CONFIG_CONTENT: "{}",
OPENCODE_CONFIG_DIR: path.join(root, "config"),
OPENCODE_DB: path.join(root, "opencode.db"),
OPENCODE_DISABLE_FILEWATCHER: "true",
OPENCODE_DISABLE_MODELS_FETCH: "true",
OPENCODE_TEST_HOME: root,
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_CONFIG_HOME: path.join(root, "xdg-config"),
XDG_DATA_HOME: path.join(root, "data"),
XDG_STATE_HOME: path.join(root, "state"),
...overrides,
}
}
+25 -32
View File
@@ -8,6 +8,7 @@ import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { ServiceConfig } from "../src/services/service-config"
import { ServiceRegistration } from "../src/services/service-registration"
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
@@ -150,20 +151,6 @@ test("preview registration migration never moves stable discovery", async () =>
}
})
test("managed service writes its registration once", async () => {
const service = await startManagedService("opencode-service-once-")
try {
const before = await fs.stat(service.registration)
await Bun.sleep(6_000)
const after = await fs.stat(service.registration)
expect(after.ino).toBe(before.ino)
expect(after.mtimeMs).toBe(before.mtimeMs)
expect(await Bun.file(service.registration).json()).toEqual(service.info)
} finally {
await stopManagedService(service)
}
}, 30_000)
test("deleting a managed service registration stops its owner", async () => {
const service = await startManagedService("opencode-service-delete-")
try {
@@ -455,39 +442,45 @@ test("port contender recognizes an incumbent registered during the bind race", a
}
}, 45_000)
test("stale dead registration is replaced after binding the selected port", async () => {
test("service registration replaces a stale owner with the bound address", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-stale-"))
const port = await availablePort()
const registration = path.join(root, "state", "opencode", "service-local.json")
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.mkdir(path.dirname(registration), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
await fs.writeFile(
registration,
JSON.stringify({ id: "dead", version: "dead", url: `http://127.0.0.1:${port}`, pid: 2_147_483_647 }),
JSON.stringify({ id: "dead", version: "dead", url: "http://127.0.0.1:4321", pid: 2_147_483_647 }),
)
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "../src/index.ts"), "serve", "--service"], {
env: serviceEnv(root),
stderr: "pipe",
stdout: "ignore",
})
try {
const info = await waitForInfo(registration, (value) => value.id !== "dead")
expect(new URL(info.url).port).toBe(String(port))
expect(info.pid).toBe(owner.pid)
await Effect.runPromise(Service.stop({ file: registration }).pipe(Effect.provide(NodeFileSystem.layer)))
await owner.exited
const cleanup = await Effect.runPromise(
ServiceRegistration.register({
address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 4321 },
password: "secret",
id: "owner",
file: registration,
shutdown: Effect.never,
}).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer)),
)
expect(await Bun.file(registration).json()).toEqual({
id: "owner",
version: OPENCODE_VERSION,
url: "http://127.0.0.1:4321",
pid: process.pid,
password: "secret",
})
await Effect.runPromise(cleanup.pipe(Effect.provide(NodeFileSystem.layer)))
expect(await Bun.file(registration).exists()).toBe(false)
} finally {
owner.kill("SIGTERM")
await owner.exited
await fs.rm(root, { recursive: true, force: true })
}
}, 30_000)
})
test("a failed service stays registered and owns the selected port until stopped", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-service-failed-"))
const port = await availablePort()
const database = path.join(root, "database")
await fs.mkdir(database)
await fs.mkdir(path.join(root, "config", "opencode"), { recursive: true })
await fs.writeFile(path.join(root, "config", "opencode", "service-local.json"), JSON.stringify({ port }))
const env = {
...process.env,
HOME: root,
+7 -1
View File
@@ -1,10 +1,14 @@
import { expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { isolatedEnv } from "./fixture/environment"
test("standalone server exits when its owner is killed", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-standalone-"))
const owner = Bun.spawn([process.execPath, path.join(import.meta.dir, "fixture/standalone-owner.ts")], {
cwd: path.join(import.meta.dir, ".."),
env: { ...process.env, OPENCODE_SERVER_USERNAME: "custom" },
env: isolatedEnv(root, { OPENCODE_SERVER_USERNAME: "custom" }),
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
@@ -28,7 +32,9 @@ test("standalone server exits when its owner is killed", async () => {
expect(await waitForExit(pid)).toBe(true)
} finally {
owner.kill("SIGKILL")
await owner.exited
if (running(pid)) process.kill(pid, "SIGKILL")
await fs.rm(root, { recursive: true, force: true })
}
})
@@ -244,7 +244,7 @@ export type PromptFileAttachment = {
export type PromptAgentAttachment = { name: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text: string; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; mention?: PromptMention }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
@@ -2567,7 +2567,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -2836,7 +2835,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
@@ -3105,7 +3103,6 @@ export type SessionImportInput = {
readonly skills?: ReadonlyArray<{
readonly id: string
readonly name: string
readonly text: string
readonly mention?: { readonly start: number; readonly end: number; readonly text: string }
}>
readonly type: "user"
+46 -1
View File
@@ -1,6 +1,6 @@
export * as KV from "./kv.js"
import { eq } from "drizzle-orm"
import { and, asc, eq, gt, gte, lt } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Database } from "./database/database.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
@@ -8,10 +8,27 @@ import { KVTable } from "./kv/sql.js"
export type Value = Schema.Json
export interface Entry {
readonly key: string
readonly value: Value
}
export interface ScanOptions {
readonly prefix: string
readonly after?: string
readonly limit?: number
}
export interface ScanResult {
readonly entries: readonly Entry[]
readonly next?: string
}
export interface Interface {
readonly get: (key: string) => Effect.Effect<Value | undefined>
readonly set: (key: string, value: Value) => Effect.Effect<void>
readonly remove: (key: string) => Effect.Effect<void>
readonly scan: (options: ScanOptions) => Effect.Effect<ScanResult>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/KV") {}
@@ -40,8 +57,36 @@ const layer = Layer.effect(
remove: Effect.fn("KV.remove")(function* (key) {
yield* db.delete(KVTable).where(eq(KVTable.key, key)).run().pipe(Effect.orDie)
}),
scan: Effect.fn("KV.scan")(function* (options) {
const limit = Number.isNaN(options.limit) ? 100 : Math.min(Math.max(Math.floor(options.limit ?? 100), 1), 1000)
const end = prefixEnd(options.prefix)
const rows = yield* db
.select({ key: KVTable.key, value: KVTable.value })
.from(KVTable)
.where(
and(
options.prefix === "" ? undefined : gte(KVTable.key, options.prefix),
end === undefined ? undefined : lt(KVTable.key, end),
options.after === undefined ? undefined : gt(KVTable.key, options.after),
),
)
.orderBy(asc(KVTable.key))
.limit(limit + 1)
.all()
.pipe(Effect.orDie)
const entries = rows.slice(0, limit)
if (rows.length <= limit) return { entries }
return { entries, next: entries[entries.length - 1].key }
}),
})
}),
)
function prefixEnd(prefix: string) {
const points = Array.from(prefix)
const index = points.findLastIndex((value) => value.codePointAt(0)! < 0x10ffff)
if (index < 0) return undefined
return `${points.slice(0, index).join("")}${String.fromCodePoint(points[index].codePointAt(0)! + 1)}`
}
export const node = makeGlobalNode({ service: Service, layer, deps: [Database.node] })
+6 -2
View File
@@ -11,6 +11,7 @@ import { Catalog } from "./catalog.js"
import { Command } from "./command.js"
import { Bus } from "./bus.js"
import { Integration } from "./integration.js"
import { KV } from "./kv.js"
import { MCP } from "./mcp/index.js"
import { Location } from "./location.js"
import { PluginHost } from "./plugin/host.js"
@@ -41,16 +42,18 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const kv = yield* KV.Service
const scope = yield* Scope.make()
const active = new Map<Plugin.ID, { readonly plugin: Versioned; readonly scope: Scope.Closeable }>()
const lock = Semaphore.makeUnsafe(1)
let inventory: Plugin.Info[] = []
let host: Parameters<import("@opencode-ai/plugin/effect/plugin").Plugin["effect"]>[0]
const load = Effect.fnUntraced(function* (plugin: Versioned) {
const child = yield* Scope.fork(scope)
const inherit = yield* State.inherit()
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
const loaded = yield* Effect.suspend(() =>
plugin.effect({ ...host, storage: PluginHost.storage(kv, plugin.id) }),
).pipe(
inherit,
Effect.updateContext((context: Context.Context<never>) =>
Context.make(Scope.Scope, child).pipe(
@@ -189,6 +192,7 @@ export const node = makeLocationNode({
Catalog.node,
Command.node,
Integration.node,
KV.node,
MCP.node,
Location.node,
Reference.node,
+36 -1
View File
@@ -14,6 +14,7 @@ import { Command } from "../command.js"
import { Credential } from "../credential.js"
import { Bus } from "../bus.js"
import { Integration } from "../integration.js"
import { KV } from "../kv.js"
import { Location } from "../location.js"
import { Model } from "../model.js"
import { MCP } from "../mcp/index.js"
@@ -28,7 +29,10 @@ import { WebSearch } from "../websearch.js"
import { PluginHooks } from "./hooks.js"
const mutable = <T>(value: T) => value as DeepMutable<T>
export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../plugin.js").Interface) {
export const make = Effect.fn("PluginHost.make")(function* (
plugin: import("../plugin.js").Interface,
pluginID: string = "test",
) {
const app = yield* App.Metadata
const agents = yield* Agent.Service
const aisdk = yield* AISDK.Service
@@ -36,6 +40,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
const commands = yield* Command.Service
const bus = yield* Bus.Service
const integration = yield* Integration.Service
const kv = yield* KV.Service
const mcp = yield* MCP.Service
const location = yield* Location.Service
const reference = yield* Reference.Service
@@ -340,6 +345,7 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
})
}),
},
storage: storage(kv, pluginID),
shell: {
hook: (name, callback) => hooks.register("shell", name, callback),
},
@@ -406,6 +412,35 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
} satisfies Plugin.Context
})
export function storage(kv: KV.Interface, pluginID: string): Plugin.Context["storage"] {
const namespace = `plugin:${pluginID
.split("")
.map((value) => value.charCodeAt(0).toString(16).padStart(4, "0"))
.join("")}:`
return {
get: (key) => kv.get(namespace + key),
set: (key, value) => kv.set(namespace + key, value),
remove: (key) => kv.remove(namespace + key),
scan: (options) =>
kv
.scan({
prefix: namespace + options.prefix,
after: options.after === undefined ? undefined : namespace + options.after,
limit: options.limit,
})
.pipe(
Effect.map((result) => {
const entries = result.entries.map((entry) => ({
key: entry.key.slice(namespace.length),
value: entry.value,
}))
if (result.next === undefined) return { entries }
return { entries, next: result.next.slice(namespace.length) }
}),
),
}
}
function methodImplementation(input: IntegrationMethodRegistration): Integration.Implementation {
if ("authorize" in input) {
const refresh = input.refresh
-1
View File
@@ -982,7 +982,6 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
return Effect.succeed({
id: skill.id,
name: skill.name,
text: Skill.toModelOutput(skill, []),
mention: attachment.mention,
})
})
+1 -2
View File
@@ -138,8 +138,7 @@ const serialize = (message: SessionMessage.Info) => {
(file) =>
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
) ?? []
const skills = message.skills?.map((skill) => `[Attached skill: ${skill.name}]\n${skill.text}`) ?? []
return [`[User]: ${message.text}`, ...skills, ...files].join("\n")
return [`[User]: ${message.text}`, ...files].join("\n")
}
if (message.type === "location-switched")
return `[User]: The working directory has been changed to ${message.location.directory}.`
@@ -227,7 +227,6 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
]
case "user":
const content = [
...(message.skills ?? []).map((skill) => Message.text(skill.text)),
...(message.text === "" ? [] : [Message.text(message.text)]),
...userAttachmentContent(message.files ?? []),
]
-1
View File
@@ -207,7 +207,6 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
skills: message.skills?.map((skill, index) => ({
...skill,
name: Skill.Name.make(redact("skill-name", String(index), skill.name)),
text: redact("skill", String(index), skill.text),
mention: skill.mention
? { ...skill.mention, text: redact("skill-mention", String(index), skill.mention.text) }
: undefined,
+1
View File
@@ -26,6 +26,7 @@ const render = (skills: ReadonlyArray<Summary>) =>
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
...(skills.length === 0
? ["No skills are currently available."]
: ["<available_skills>", ...entries(skills), "</available_skills>"]),
+2 -2
View File
@@ -12,7 +12,7 @@ export const name = "skill"
const FILE_LIMIT = 10
export const Input = Schema.Struct({
id: Skill.ID.annotate({ description: "The ID of the skill from the available skills list" }),
id: Skill.ID.annotate({ description: "The ID of an available skill or a skill explicitly referenced by the user" }),
})
export const Output = Schema.Struct({
@@ -23,7 +23,7 @@ export const Output = Schema.Struct({
export const description = [
"Load a specialized skill's instructions and resources into the current conversation when the task at hand matches its description.",
"",
"The skill ID must match one of the available skills in the instructions.",
"The skill ID must match an available skill or a skill explicitly referenced by the user.",
].join("\n")
export const toModelOutput = Skill.toModelOutput
@@ -262,7 +262,6 @@ describe("cross-spawn spawner", () => {
Effect.gen(function* () {
if (process.platform === "win32") return
const started = Date.now()
const exit = yield* Effect.exit(
Effect.gen(function* () {
const handle = yield* js('process.on("SIGTERM", () => {}); setInterval(() => {}, 10_000)')
@@ -271,7 +270,6 @@ describe("cross-spawn spawner", () => {
}),
)
expect(Date.now() - started).toBeLessThan(1_000)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}),
)
@@ -35,28 +35,6 @@ const pluginNode = makeLocationNode({
deps: [],
})
describe("Watcher.testLayer", () => {
it.effect("records subscriptions and broadcasts emitted updates through the service", () =>
Effect.gen(function* () {
const watcher = yield* Watcher.Service
const test = yield* Watcher.Test
const updates = yield* watcher.subscribe({ path: "/root", type: "directory" })
const received = yield* updates.pipe(
Stream.take(1),
Stream.runCollect,
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.yieldNow
yield* test.emit({ type: "update", path: "/root/file.md" })
expect(Array.from(yield* Fiber.join(received))).toEqual([{ type: "update", path: "/root/file.md" }])
// subscriptions() reports acquired watches, so paths come back resolved.
expect(yield* test.subscriptions()).toEqual([{ path: path.resolve("/root"), type: "directory" }])
}).pipe(Effect.provide(Watcher.testLayer)),
)
})
function withNative(native: Watcher.NativeInterface) {
return Effect.provide(Watcher.layer().pipe(Layer.provide(Layer.succeed(Watcher.Native, native))))
}
+56
View File
@@ -18,8 +18,64 @@ describe("KV", () => {
yield* kv.set("wellknown:sources", ["https://example.com", "https://example.org"])
expect(yield* kv.get("wellknown:sources")).toEqual(["https://example.com", "https://example.org"])
yield* kv.remove("wellknown:sources")
yield* kv.remove("wellknown:sources")
expect(yield* kv.get("wellknown:sources")).toBeUndefined()
}),
)
it.effect("scans prefixes in deterministic pages", () =>
Effect.gen(function* () {
const kv = yield* KV.Service
const prefix = "scan:%_:/雪/"
yield* Effect.forEach(
[
[`${prefix}beta`, { order: 2 }],
[`${prefix}alpha`, { order: 1 }],
[`${prefix}éclair`, { order: 3 }],
["scan:other", { order: 0 }],
] as const,
([key, value]) => kv.set(key, value),
{ discard: true },
)
const first = yield* kv.scan({ prefix, limit: 2 })
expect(first).toEqual({
entries: [
{ key: `${prefix}alpha`, value: { order: 1 } },
{ key: `${prefix}beta`, value: { order: 2 } },
],
next: `${prefix}beta`,
})
expect(yield* kv.scan({ prefix, after: first.next, limit: 2 })).toEqual({
entries: [{ key: `${prefix}éclair`, value: { order: 3 } }],
})
expect(yield* kv.scan({ prefix: `${prefix}%_` })).toEqual({ entries: [] })
}),
)
it.effect("defaults, normalizes, and caps scan limits", () =>
Effect.gen(function* () {
const kv = yield* KV.Service
const prefix = "scan:limits/"
yield* Effect.forEach(
Array.from({ length: 1001 }, (_, index) => `${prefix}${index.toString().padStart(4, "0")}`),
(key) => kv.set(key, key),
{ discard: true },
)
const defaultPage = yield* kv.scan({ prefix })
expect(defaultPage.entries).toHaveLength(100)
expect(defaultPage.next).toBe(`${prefix}0099`)
const cappedPage = yield* kv.scan({ prefix, limit: 10_000 })
expect(cappedPage.entries).toHaveLength(1000)
expect(cappedPage.next).toBe(`${prefix}0999`)
expect((yield* kv.scan({ prefix, limit: 2.9 })).entries).toHaveLength(2)
expect((yield* kv.scan({ prefix, limit: 0 })).entries).toHaveLength(1)
expect((yield* kv.scan({ prefix, limit: -10 })).entries).toHaveLength(1)
expect((yield* kv.scan({ prefix, limit: Number.NaN })).entries).toHaveLength(100)
}),
)
})
+48
View File
@@ -338,6 +338,54 @@ describe("Plugin", () => {
}),
)
it.effect("provides isolated durable storage for each plugin ID", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const storage = new Map<string, EffectPlugin.Context["storage"]>()
yield* plugins.activate(
["a", "a:b", "雪"].map((id) => ({
id,
version: "1",
effect: (context: EffectPlugin.Context) => Effect.sync(() => storage.set(id, context.storage)),
})),
)
const first = storage.get("a")
const second = storage.get("a:b")
const unicode = storage.get("雪")
if (!first || !second || !unicode) return yield* Effect.die("plugin storage was not activated")
yield* first.set("b:c", { plugin: "a" })
yield* second.set("c", { plugin: "a:b" })
yield* unicode.set("c", { plugin: "雪" })
expect(yield* first.get("b:c")).toEqual({ plugin: "a" })
expect(yield* second.get("c")).toEqual({ plugin: "a:b" })
expect(yield* unicode.get("c")).toEqual({ plugin: "雪" })
expect(yield* first.get("c")).toBeUndefined()
const prefix = "%_:/雪/"
yield* first.set(`${prefix}beta`, [2])
yield* first.set(`${prefix}alpha`, [1])
const firstPage = yield* first.scan({ prefix, limit: 1 })
expect(firstPage).toEqual({ entries: [{ key: `${prefix}alpha`, value: [1] }], next: `${prefix}alpha` })
expect(yield* first.scan({ prefix, after: firstPage.next, limit: 1 })).toEqual({
entries: [{ key: `${prefix}beta`, value: [2] }],
})
expect(yield* first.scan({ prefix: `${prefix}%_` })).toEqual({ entries: [] })
expect(yield* first.scan({ prefix: "" })).toEqual({
entries: [
{ key: `${prefix}alpha`, value: [1] },
{ key: `${prefix}beta`, value: [2] },
{ key: "b:c", value: { plugin: "a" } },
],
})
yield* first.remove("b:c")
yield* first.remove("b:c")
expect(yield* first.get("b:c")).toBeUndefined()
return undefined
}),
)
it.effect("registers location tools through the plugin context", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
+2
View File
@@ -11,6 +11,7 @@ import { FileSystem } from "@opencode-ai/core/filesystem"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Form } from "@opencode-ai/core/form"
import { Integration } from "@opencode-ai/core/integration"
import { KV } from "@opencode-ai/core/kv"
import { Location } from "@opencode-ai/core/location"
import { MCP } from "@opencode-ai/core/mcp/index"
import { Npm } from "@opencode-ai/util/npm"
@@ -52,6 +53,7 @@ export const PluginTestLayer = LayerNode.compile(
Catalog.node,
Command.node,
Integration.node,
KV.node,
MCP.node,
PluginRuntime.node,
PluginHooks.node,
+6
View File
@@ -94,6 +94,12 @@ export function host(overrides: Overrides = {}): Plugin.Context {
transform: () => Effect.die("unused skill.transform"),
reload: () => Effect.die("unused skill.reload"),
},
storage: overrides.storage ?? {
get: () => Effect.die("unused storage.get"),
set: () => Effect.die("unused storage.set"),
remove: () => Effect.die("unused storage.remove"),
scan: () => Effect.die("unused storage.scan"),
},
shell: overrides.shell ?? {
hook: () => Effect.die("unused shell.hook"),
},
+26
View File
@@ -27,6 +27,32 @@ import { host as testHost } from "./host"
const it = testEffect(PluginTestLayer)
describe("fromPromise", () => {
it.effect("adapts plugin storage methods", () =>
Effect.gen(function* () {
const plugins = yield* Plugin.Service
const adapted = PluginPromise.fromPromise(
define({
id: "promise-storage",
setup: async (ctx) => {
expect(await ctx.storage.get("missing")).toBeUndefined()
await ctx.storage.set("items/b", { order: 2 })
await ctx.storage.set("items/a", { order: 1 })
expect(await ctx.storage.get("items/a")).toEqual({ order: 1 })
expect(await ctx.storage.scan({ prefix: "items/", limit: 1 })).toEqual({
entries: [{ key: "items/a", value: { order: 1 } }],
next: "items/a",
})
await ctx.storage.remove("items/a")
await ctx.storage.remove("items/a")
expect(await ctx.storage.get("items/a")).toBeUndefined()
},
}),
)
yield* plugins.activate([{ ...adapted, version: "1" }])
}),
)
it.effect("adapts session creation through the protocol schema", () =>
Effect.gen(function* () {
let seen: unknown
+3 -2
View File
@@ -18,11 +18,12 @@ const waitForFile = (file: string) =>
Effect.promise(async () => {
while (true) {
try {
return await fs.readFile(file, "utf8")
const contents = await fs.readFile(file, "utf8")
if (contents) return contents
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error
await new Promise<void>((resolve) => setTimeout(resolve, 10))
}
await new Promise<void>((resolve) => setTimeout(resolve, 10))
}
})
@@ -205,18 +205,18 @@ Recent work
})
})
test("lowers selected skill instructions with the original user prompt", () => {
test("does not inject skill content for reference-only attachments", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-skill"),
id: id("user-skill-reference"),
type: "user",
text: "Design this API",
text: "Use @api-design",
skills: [
SkillAttachment.make({
id: Skill.ID.make("api-design"),
name: Skill.Name.make("API design"),
text: "Start from the ideal call site.",
mention: { start: 4, end: 15, text: "@api-design" },
}),
],
time: { created },
@@ -225,17 +225,9 @@ Recent work
model,
)
expect(messages).toHaveLength(1)
expect(messages[0]).toMatchObject({
id: id("user-skill"),
role: "user",
content: [
{
type: "text",
text: "Start from the ideal call site.",
},
{ type: "text", text: "Design this API" },
],
content: [{ type: "text", text: "Use @api-design" }],
})
})
+5 -6
View File
@@ -56,7 +56,7 @@ const it = testEffect(
)
describe("Session.skill", () => {
it.effect("attaches a resolved skill snapshot to a normal prompt", () =>
it.effect("keeps skill mentions as references on a normal prompt", () =>
Effect.gen(function* () {
const sessions = yield* Session.Service
const database = yield* Database.Service
@@ -67,8 +67,8 @@ describe("Session.skill", () => {
yield* sessions.prompt({
id,
sessionID: session.id,
text: "Apply this guidance",
skills: [{ id: Skill.ID.make("effect"), mention: { start: 20, end: 27, text: "/effect" } }],
text: "Apply @effect",
skills: [{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } }],
resume: false,
})
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
@@ -77,13 +77,12 @@ describe("Session.skill", () => {
expect.objectContaining({
id,
type: "user",
text: "Apply this guidance",
text: "Apply @effect",
skills: [
{
id: "effect",
name: "Effect",
text: expect.stringContaining("Use Effect"),
mention: { start: 20, end: 27, text: "/effect" },
mention: { start: 6, end: 13, text: "@effect" },
},
],
}),
@@ -59,6 +59,7 @@ describe("SkillInstructions", () => {
[
"Skills provide specialized instructions and workflows for specific tasks.",
"Use the skill tool to load a skill when a task matches its description.",
"When the user references a skill with @skill-id, load that skill with the skill tool.",
"<available_skills>",
" <skill>",
" <id>effect</id>",
+38 -35
View File
@@ -774,42 +774,45 @@ describe("ShellTool", () => {
),
)
it.live("updates and clears a running shell timeout", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const timed = yield* executeTool(
registry,
call({ command: idleCommand, background: true }, "call-updated-timeout"),
)
const timedID = timed.metadata?.shellID
expect(typeof timedID).toBe("string")
if (typeof timedID !== "string") return
const timedShellID = ShellSchema.ID.make(timedID)
yield* shell.timeout(timedShellID, 50)
expect((yield* shell.wait(timedShellID)).status).toBe("timeout")
it.live(
"updates and clears a running shell timeout",
() =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) => {
reset()
return withSession(tmp.path, (registry) =>
Effect.gen(function* () {
const shell = yield* Shell.Service
const timed = yield* executeTool(
registry,
call({ command: idleCommand, background: true }, "call-updated-timeout"),
)
const timedID = timed.metadata?.shellID
expect(typeof timedID).toBe("string")
if (typeof timedID !== "string") return
const timedShellID = ShellSchema.ID.make(timedID)
yield* shell.timeout(timedShellID, 50)
expect((yield* shell.wait(timedShellID)).status).toBe("timeout")
const cleared = yield* executeTool(
registry,
call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"),
)
const clearedID = cleared.metadata?.shellID
expect(typeof clearedID).toBe("string")
if (typeof clearedID !== "string") return
const clearedShellID = ShellSchema.ID.make(clearedID)
yield* shell.timeout(clearedShellID, 0)
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(clearedShellID)).status).toBe("running")
yield* shell.remove(clearedShellID)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
const cleared = yield* executeTool(
registry,
call({ command: idleCommand, timeout: 50, background: true }, "call-cleared-timeout"),
)
const clearedID = cleared.metadata?.shellID
expect(typeof clearedID).toBe("string")
if (typeof clearedID !== "string") return
const clearedShellID = ShellSchema.ID.make(clearedID)
yield* shell.timeout(clearedShellID, 0)
yield* Effect.sleep(Duration.millis(100))
expect((yield* shell.get(clearedShellID)).status).toBe("running")
yield* shell.remove(clearedShellID)
}),
)
},
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]().then(() => undefined)),
),
{ timeout: 15_000 },
)
if (!isWindows) {
@@ -144,12 +144,10 @@ describe("util.effect-flock", () => {
yield* Effect.scoped(
Effect.gen(function* () {
yield* flock.acquire(key, dir)
const started = performance.now()
const error = yield* Effect.scoped(flock.acquire(key, dir, { staleMs: 10_000, timeoutMs: 300 })).pipe(
Effect.flip,
)
expect(error._tag).toBe("LockTimeoutError")
expect(performance.now() - started).toBeLessThan(1_000)
}),
)
yield* Effect.promise(() => fs.rm(tmp, { recursive: true, force: true }))
+24 -21
View File
@@ -434,29 +434,32 @@ describe("Worktree", () => {
}),
)
it.live("refresh ignores stale git worktree registrations", () =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const stale = abs(`${input.root.path}-worktree-stale`)
const target = abs(`${input.root.path}-worktree-after-stale`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
it.live(
"refresh ignores stale git worktree registrations",
() =>
Effect.gen(function* () {
const input = yield* setup()
const worktree = yield* Worktree.Service
const stale = abs(`${input.root.path}-worktree-stale`)
const target = abs(`${input.root.path}-worktree-after-stale`)
yield* Effect.addFinalizer(() =>
Effect.promise(() => fs.rm(target, { recursive: true, force: true })).pipe(Effect.ignore),
)
yield* Effect.promise(() => $`git worktree add --detach ${stale} HEAD`.cwd(input.root.path).quiet())
yield* Effect.promise(() => fs.rm(stale, { recursive: true, force: true }))
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
yield* worktree.refresh({ projectID: input.projectID })
yield* worktree.refresh({ projectID: input.projectID })
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, strategy: null },
{ directory: discovered, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
}),
const discovered = abs(yield* Effect.promise(() => fs.realpath(target)))
expect(yield* stored(input.projectID)).toEqual(
[
{ directory: input.sourceDirectory, strategy: null },
{ directory: discovered, strategy: "git" },
].toSorted((a, b) => a.directory.localeCompare(b.directory)),
)
}),
15_000,
)
it.live("refresh ignores existing directories that are no longer git checkouts", () =>
+8 -1
View File
@@ -94,7 +94,14 @@ export function createMainWindow(id: string = randomUUID()) {
wireFullscreen(win)
loadWindow(win, "index.html")
wireZoom(win)
win.once("ready-to-show", () => win.show())
let revealed = false
const reveal = () => {
if (revealed || win.isDestroyed()) return
revealed = true
win.show()
}
win.once("ready-to-show", reveal)
if (process.platform === "linux") win.webContents.once("did-finish-load", reveal)
return win
}
+1
View File
@@ -1,4 +1,5 @@
export * as Plugin from "./plugin.js"
export type { StorageEntry, StorageScanOptions, StorageScanResult } from "../storage.js"
export { Agent } from "@opencode-ai/schema/agent"
export { Command } from "@opencode-ai/schema/command"
+2
View File
@@ -13,6 +13,7 @@ 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 { StorageDomain } from "./storage.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
@@ -31,6 +32,7 @@ export interface Context {
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
readonly storage: StorageDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
}
+9
View File
@@ -0,0 +1,9 @@
import type { Effect, Schema } from "effect"
import type { StorageScanOptions, StorageScanResult } from "../storage.js"
export interface StorageDomain {
readonly get: (key: string) => Effect.Effect<Schema.Json | undefined>
readonly set: (key: string, value: Schema.Json) => Effect.Effect<void>
readonly remove: (key: string) => Effect.Effect<void>
readonly scan: (options: StorageScanOptions) => Effect.Effect<StorageScanResult>
}
+6
View File
@@ -261,6 +261,12 @@ export function fromPromise(plugin: Plugin) {
transform: transform(host.skill),
reload: () => run(host.skill.reload()),
},
storage: {
get: (key) => run(host.storage.get(key)),
set: (key, value) => run(host.storage.set(key, value)),
remove: (key) => run(host.storage.remove(key)),
scan: (options) => run(host.storage.scan(options)),
},
tool: {
transform: (callback) =>
register(
+1
View File
@@ -1,4 +1,5 @@
export type { PluginOptions } from "../options.js"
export type { StorageEntry, StorageScanOptions, StorageScanResult } from "../storage.js"
export * as Plugin from "./plugin.js"
export { Agent } from "@opencode-ai/schema/agent"
+2
View File
@@ -12,6 +12,7 @@ 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 { StorageDomain } from "./storage.js"
import type { ToolDomain } from "./tool.js"
import type { WebSearchDomain } from "./websearch.js"
@@ -30,6 +31,7 @@ export interface Context {
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
readonly storage: StorageDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
}
+9
View File
@@ -0,0 +1,9 @@
import type { Schema } from "effect"
import type { StorageScanOptions, StorageScanResult } from "../storage.js"
export interface StorageDomain {
readonly get: (key: string) => Promise<Schema.Json | undefined>
readonly set: (key: string, value: Schema.Json) => Promise<void>
readonly remove: (key: string) => Promise<void>
readonly scan: (options: StorageScanOptions) => Promise<StorageScanResult>
}
+17
View File
@@ -0,0 +1,17 @@
import type { Schema } from "effect"
export interface StorageEntry {
readonly key: string
readonly value: Schema.Json
}
export interface StorageScanOptions {
readonly prefix: string
readonly after?: string
readonly limit?: number
}
export interface StorageScanResult {
readonly entries: readonly StorageEntry[]
readonly next?: string
}
-1
View File
@@ -57,7 +57,6 @@ export interface SkillAttachment extends Schema.Schema.Type<typeof SkillAttachme
export const SkillAttachment = Schema.Struct({
id: Skill.ID,
name: Skill.Name,
text: Schema.String,
mention: PromptMention.pipe(optional),
}).annotate({ identifier: "Prompt.SkillAttachment" })
@@ -176,7 +176,7 @@ export function Autocomplete(props: {
const charAfterCursor = displayCharAt(props.value, currentCursorOffset)
const needsSpace = charAfterCursor !== " "
const prefix = part.type === "skill" ? "/" : "@"
const prefix = "@"
const append = prefix + text + (needsSpace ? " " : "")
input.cursorOffset = store.index
@@ -478,6 +478,22 @@ export function Autocomplete(props: {
)
})
const skillOptions = createMemo(() =>
(data.location.skill.list(location.current) ?? []).map(
(skill): AutocompleteOption => ({
display: "@" + skill.id,
description: skill.description,
kind: "skill",
onSelect: () => {
insertPart(skill.id, {
type: "skill",
value: { id: Skill.ID.make(skill.id), mention: { start: 0, end: 0, text: "" } },
})
},
}),
),
)
const referenceAliases = createMemo(() =>
references()
.filter((reference) => !reference.hidden)
@@ -537,11 +553,7 @@ export function Autocomplete(props: {
display: "/" + skill.id,
description: skill.description,
kind: "skill",
onSelect: () =>
insertPart(skill.id, {
type: "skill",
value: { id: Skill.ID.make(skill.id), mention: { start: 0, end: 0, text: "" } },
}),
onSelect: () => insertSlash(skill.id),
})
}
@@ -592,10 +604,10 @@ export function Autocomplete(props: {
const fileOptions: AutocompleteOption[] = store.visible === "reference" ? fileSearch.options : []
const nonFileOptions: AutocompleteOption[] =
store.visible === "reference"
? [...referenceAliasesValue, ...agentsValue, ...mcpResources()]
? [...skillOptions(), ...referenceAliasesValue, ...agentsValue, ...mcpResources()]
: store.index === 0
? [...commandsValue]
: commandsValue.filter((item) => item.kind === "skill")
: []
if (!searchValue) {
return [...nonFileOptions, ...fileOptions]
@@ -30,7 +30,6 @@ import { stringWidth } from "../../util/string-width"
import { createStore, produce, unwrap } from "solid-js/store"
import { emptyPrompt, usePromptHistory, type PromptInfo, type PromptPartRef } from "../../prompt/history"
import { saveDraft, takeDraft } from "./draft-stash"
import { Skill } from "@opencode-ai/schema/skill"
import { computePromptTraits } from "../../prompt/traits"
import { expandPastedTextPlaceholders, expandTrackedPastedText } from "../../prompt/part"
import { usePromptStash } from "../../prompt/stash"
@@ -45,7 +44,6 @@ import { DialogIntegration } from "../dialog-integration"
import { useConnected } from "../use-connected"
import { useToast } from "../../ui/toast"
import { createFadeIn } from "../../util/signal"
import { DialogSkill } from "../dialog-skill"
import { useArgs } from "../../context/args"
import { useConfig } from "../../config"
import { usePromptMove } from "./move"
@@ -582,44 +580,6 @@ export function Prompt(props: PromptProps) {
input.cursorOffset = stringWidth(normalized)
},
},
{
title: "Skills",
name: "prompt.skills",
category: "Prompt",
slash: { name: "skills" },
run: () => {
dialog.replace(() => (
<DialogSkill
location={currentLocation.current}
onSelect={(skill) => {
if (store.prompt.skills?.some((item) => item.id === skill)) return
const text = `/${skill}`
const start = input.cursorOffset
input.insertText(text + " ")
const extmarkId = input.extmarks.create({
start,
end: start + promptOffsetWidth(text),
virtual: true,
styleId: skillStyleId,
typeId: promptPartTypeId,
})
setStore(
produce((draft) => {
draft.prompt.text = input.plainText
const skills = (draft.prompt.skills ??= [])
const index = skills.length
skills.push({
id: Skill.ID.make(skill),
mention: { start, end: start + promptOffsetWidth(text), text },
})
draft.extmarkToPart.set(extmarkId, { type: "skill", index })
}),
)
}}
/>
))
},
},
{
title: "Move session",
desc: "Move to another project dir",
@@ -661,7 +621,6 @@ export function Prompt(props: PromptProps) {
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
"prompt.skills",
"session.interrupt",
"session.background",
"session.move",
+1 -3
View File
@@ -33,12 +33,11 @@ import {
} from "./form.shared"
import type { FormBodyState } from "./form.shared"
import type { RunFooterTheme } from "./theme"
import type { FormCancel, FormReply, MiniFormRequest, RunTuiConfig } from "./types"
import type { FormCancel, FormReply, MiniFormRequest } from "./types"
export function RunFormBody(props: {
request: MiniFormRequest
theme: RunFooterTheme
cursor?: RunTuiConfig["cursor"]
onReply: (input: FormReply) => void | Promise<void>
onCancel: (input: FormCancel) => void | Promise<void>
openExternal?: (url: string) => Promise<unknown>
@@ -321,7 +320,6 @@ export function RunFormBody(props: {
backgroundColor={props.theme.surface}
focusedBackgroundColor={props.theme.surface}
cursorColor={props.theme.text}
cursorStyle={props.cursor}
focused
onSubmit={commitInput}
onContentChange={() => {
+1 -5
View File
@@ -31,7 +31,7 @@ import {
import { footerWidthPolicy } from "./footer.width"
import { toolFiletype } from "./tool"
import { transparent, type RunBlockTheme, type RunFooterTheme } from "./theme"
import type { MiniPermissionRequest, PermissionReply, RunTuiConfig } from "./types"
import type { MiniPermissionRequest, PermissionReply } from "./types"
import { PatchDiff } from "../component/patch-diff"
function buttons(
@@ -74,7 +74,6 @@ function buttons(
/** @internal Exported to test managed textarea submission without permission navigation. */
export function RejectField(props: {
theme: RunFooterTheme
cursor?: RunTuiConfig["cursor"]
text: string
disabled: boolean
onChange: (text: string) => void
@@ -114,7 +113,6 @@ export function RejectField(props: {
backgroundColor={props.theme.surface}
focusedBackgroundColor={props.theme.surface}
cursorColor={props.theme.text}
cursorStyle={props.cursor}
focused={!props.disabled}
onSubmit={props.onConfirm}
onContentChange={() => {
@@ -141,7 +139,6 @@ export function RunPermissionBody(props: {
request: MiniPermissionRequest
directory?: () => string
theme: RunFooterTheme
cursor?: RunTuiConfig["cursor"]
block: RunBlockTheme
onReply: (input: PermissionReply) => void | Promise<void>
mono?: boolean
@@ -330,7 +327,6 @@ export function RunPermissionBody(props: {
<box width={narrow() ? "100%" : undefined} flexGrow={1} flexShrink={1}>
<RejectField
theme={props.theme}
cursor={props.cursor}
text={state().message}
disabled={busy()}
onChange={(text) => {
-3
View File
@@ -42,7 +42,6 @@ import type {
RunPrompt,
RunPromptPart,
RunReference,
RunTuiConfig,
} from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
@@ -176,7 +175,6 @@ export function selectedCommand(text: string, command: RunPrompt["command"]) {
export function RunPromptBody(props: {
theme: () => RunFooterTheme
cursor?: RunTuiConfig["cursor"]
background: () => ColorInput
placeholder: () => StyledText | string
onSubmit: () => void
@@ -241,7 +239,6 @@ export function RunPromptBody(props: {
backgroundColor={props.background()}
focusedBackgroundColor={props.background()}
cursorColor={props.theme().text}
cursorStyle={props.cursor}
onSubmit={props.onSubmit}
onKeyDown={props.onKeyDown}
onPaste={() => {
-1
View File
@@ -335,7 +335,6 @@ export class RunFooter implements FooterApi {
variants: footer.variants,
currentVariant: footer.currentVariant,
theme: footer.theme,
cursor: options.tuiConfig.cursor,
mono: options.mono,
miniSettings: footer.miniSettings,
history: footer.history,
-5
View File
@@ -55,7 +55,6 @@ import type {
RunPrompt,
RunProvider,
RunReference,
RunTuiConfig,
} from "./types"
import type { RunTheme } from "./theme"
@@ -93,7 +92,6 @@ type RunFooterViewProps = {
subagent?: () => FooterSubagentState
queuedPrompts?: () => FooterQueuedPrompt[]
theme: () => RunTheme
cursor?: RunTuiConfig["cursor"]
mono: boolean
miniSettings: () => MiniSettings
history?: () => RunPrompt[]
@@ -735,7 +733,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<Match when={active().type === "prompt" && route().type === "composer"}>
<RunPromptBody
theme={theme}
cursor={props.cursor}
background={() => runTheme().background}
placeholder={composer.placeholder}
onSubmit={composer.onSubmit}
@@ -885,7 +882,6 @@ export function RunFooterView(props: RunFooterViewProps) {
request={permission()!.request}
directory={props.directory}
theme={theme()}
cursor={props.cursor}
block={block()}
onReply={props.onPermissionReply}
mono={props.mono}
@@ -897,7 +893,6 @@ export function RunFooterView(props: RunFooterViewProps) {
<RunFormBody
request={value.request}
theme={theme()}
cursor={props.cursor}
state={formStates.get(value.request.id) ?? createFormBodyState(value.request)}
onState={(state) => {
if (!formsAbsent && !settledForms.has(state.formID))
+1 -1
View File
@@ -392,7 +392,7 @@ export type FormCancel = {
location?: LocationRef
}
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini" | "session" | "cursor">
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini" | "session">
export type MiniSettings = {
thinking: "show" | "hide"
+18 -6
View File
@@ -585,12 +585,24 @@ async function resolvePlugin(
if (!entrypoint) return { status: "unsupported" as const }
// Content remains stable across the several mtimes one save may expose to
// filesystem watchers, while the generation keeps reverted modules fresh.
const version = local ? freshSpecifier(entrypoint, await sourceGeneration(entrypoint)) : entrypoint
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(version)
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
let generation = local ? await sourceGeneration(entrypoint) : undefined
while (true) {
const version = generation === undefined ? entrypoint : freshSpecifier(entrypoint, generation)
if (previous && previous.version === version && sameOptions(previous.options, options))
return { status: "unchanged" as const, plugin: previous.plugin, version }
const mod: { readonly default?: unknown } = await import(version)
if (generation !== undefined) {
const observed = await sourceGeneration(entrypoint)
// In-place saves can change the file between hashing and import. Retry
// so setup always runs under the generation of the imported bytes.
if (generation !== observed) {
generation = observed
continue
}
}
if (!isPlugin(mod.default)) throw new Error(`Invalid V2 TUI plugin module: ${spec}`)
return { status: "loaded" as const, plugin: mod.default, version }
}
}
function toRegistration(item: Desired): Registration {
+42 -5
View File
@@ -340,16 +340,36 @@ export function FormPrompt(props: {
pick(row.value)
}
function pasteCustom(value: string) {
const current = answerField()
if (!current || textual() || !custom() || confirm()) return false
setStore("selected", rows().length)
updateCustom(current, input() + value)
setStore("editing", true)
return true
}
usePaste((event) => {
if (keymap.mode.current() !== FORM_MODE) return
const current = answerField()
if (!current || textual() || !custom() || confirm()) return
if (!pasteCustom(stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n"))) return
event.preventDefault()
setStore("selected", rows().length)
updateCustom(current, input() + stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n"))
setStore("editing", true)
})
function pasteClipboard() {
return clipboard
.read()
.then((content) => {
if (content?.mime !== "text/plain") return
const value = stripAnsiSequences(content.data).replace(/\r\n?/g, "\n")
if (store.editing || textual()) {
textarea?.insertText(value)
return
}
pasteCustom(value)
})
.catch(toast.error)
}
function commitInput(text: string) {
const current = answerField()
if (!current) return false
@@ -505,6 +525,23 @@ export function FormPrompt(props: {
onMount(() => onCleanup(keymap.mode.push(FORM_MODE)))
Keymap.createLayer(() => ({
mode: FORM_MODE,
enabled: !confirm() && answerField() !== undefined,
commands: [
{
id: "prompt.paste",
title: "Paste from clipboard",
group: "Form",
run: (_input, event) => {
event?.preventDefault()
event?.stopPropagation()
return pasteClipboard()
},
},
],
}))
Keymap.createLayer(() => ({
mode: FORM_MODE,
priority: 1,
+36 -2
View File
@@ -14,7 +14,13 @@ import { TestTuiContexts } from "../../fixture/tui-environment"
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
import { createApi, createEventStream, createFetch } from "../../fixture/tui-client"
async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"], height = 20) {
async function mountForm(
root: string,
width = 80,
fields?: FormWithLocation["fields"],
height = 20,
clipboardText?: string,
) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -58,7 +64,7 @@ async function mountForm(root: string, width = 80, fields?: FormWithLocation["fi
}}
clipboard={{
async read() {
return undefined
return clipboardText === undefined ? undefined : { data: clipboardText, mime: "text/plain" }
},
write(text) {
copied.push(text)
@@ -217,6 +223,34 @@ test("pasting on a custom choice opens its editor without submitting", async ()
}
})
test("clipboard shortcut opens a custom choice editor without submitting", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(
tmp.path,
80,
[
{
key: "target",
type: "string",
options: [{ value: "staging", label: "Staging" }],
custom: true,
},
],
20,
"production\nwest",
)
try {
prompt.app.mockInput.pressKey("v", { ctrl: true })
await prompt.app.waitFor(() => prompt.app.renderer.currentFocusedEditor?.plainText === "production\nwest")
await prompt.app.waitForFrame((frame) => frame.includes("production"))
expect(prompt.app.captureCharFrame()).not.toContain("Type your own answer")
expect(prompt.replies).toEqual([])
} finally {
prompt.app.renderer.destroy()
}
})
test("typing a custom multiselect answer selects it before commit", async () => {
await using tmp = await tmpdir()
const prompt = await mountForm(tmp.path, 80, [
@@ -1,98 +0,0 @@
import { describe, expect, test } from "bun:test"
// Regression test for the prompt submit race in
// packages/tui/src/component/prompt/index.tsx (`submit`).
//
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
// Enter, or the input's native onSubmit racing another dispatch) each
// passed the `if (!store.prompt.text) return false` guard, each
// `await client.api.session.create(...)`, and each only captured
// `inputText = store.prompt.text` AFTER that await. The first invocation
// finished, sent the prompt, and cleared the store; the second invocation,
// now past its await, read the cleared store and sent an empty prompt to a
// second freshly-created session - leaving an orphaned session with the
// user's actual text and a phantom session visible to the user containing
// only an assistant reply.
//
// `submitMirror` below has the exact shape of the production `submit()`
// after the fix: an in-flight `submitting` guard wraps the original body.
// Two concurrent invocations must result in exactly one submission carrying
// the user's text, with no empty-text submission.
type Store = { input: string }
type SubmitResult = { sessionID: string; text: string }
type Harness = {
store: Store
submissions: SubmitResult[]
createSession(): Promise<string>
sendPrompt(sessionID: string, text: string): Promise<void>
}
function createHarness(opts: { sessionCreateDelayMs: number }): Harness {
let sessionCounter = 0
const submissions: SubmitResult[] = []
return {
store: { input: "" },
submissions,
async createSession() {
sessionCounter += 1
const id = `ses_${sessionCounter}`
await Bun.sleep(opts.sessionCreateDelayMs)
return id
},
async sendPrompt(sessionID, text) {
submissions.push({ sessionID, text })
},
}
}
function createSubmit() {
let submitting = false
return async function submit(h: Harness) {
if (submitting) return false
submitting = true
try {
if (!h.store.input) return false
const sessionID = await h.createSession()
const inputText = h.store.input
await h.sendPrompt(sessionID, inputText)
h.store.input = ""
return true
} finally {
submitting = false
}
}
}
describe("Prompt.submit race", () => {
test("concurrent submits must not lose the user's text", async () => {
const submit = createSubmit()
const h = createHarness({ sessionCreateDelayMs: 5 })
h.store.input = "Hello there."
// Two invocations back-to-back, mimicking a double-Enter.
await Promise.all([submit(h), submit(h)])
// Every submission that did make it through must carry the actual user
// text, and no submission may have an empty text payload.
expect(h.submissions.every((s) => s.text === "Hello there.")).toBe(true)
expect(h.submissions.some((s) => s.text === "")).toBe(false)
})
test("a sequential second submit after clear is a no-op, not a phantom session", async () => {
const submit = createSubmit()
const h = createHarness({ sessionCreateDelayMs: 1 })
h.store.input = "Hello there."
await submit(h)
// After the first submission completes, the store is cleared; a second
// Enter on an empty input must not create a phantom session.
await submit(h)
expect(h.submissions).toHaveLength(1)
expect(h.submissions[0].text).toBe("Hello there.")
})
})
-6
View File
@@ -1,6 +0,0 @@
import { expect, test } from "bun:test"
import { run } from "../src"
test("exports the canonical application lifecycle", () => {
expect(typeof run).toBe("function")
})
@@ -169,7 +169,6 @@ async function renderFooter(
subagent={subagents}
queuedPrompts={() => input.queuedPrompts ?? []}
theme={input.theme ?? (() => RUN_THEME_FALLBACK)}
cursor={config.cursor}
mono={input.mono ?? false}
miniSettings={miniSettings}
onSubmit={input.onSubmit ?? (() => true)}
@@ -343,18 +342,6 @@ test("direct footer composer area does not adopt footer surface", async () => {
}
})
test("direct footer composer uses the configured cursor style", async () => {
const cursor = { style: "underline" as const, blinking: false }
const app = await renderFooter({ tuiConfig: { ...tuiConfig, cursor } })
try {
await app.renderOnce()
expect(app.renderer.currentFocusedEditor?.cursorStyle).toEqual(cursor)
} finally {
app.cleanup()
}
})
test("run entry content updates when live commit text changes", async () => {
const [commit, setCommit] = createSignal<StreamCommit>({
kind: "tool",
+58 -2
View File
@@ -8,9 +8,8 @@ import { pathToFileURL } from "node:url"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
function lifecycleSource(marker: string, id: string, version: string) {
function lifecyclePluginSource(marker: string, id: string, version: string) {
return `
import { appendFile } from "node:fs/promises"
export default {
id: ${JSON.stringify(id)},
setup: async () => {
@@ -21,6 +20,29 @@ export default {
`
}
function lifecycleSource(marker: string, id: string, version: string) {
return `
import { appendFile } from "node:fs/promises"
${lifecyclePluginSource(marker, id, version)}
`
}
function gatedLifecycleSource(marker: string, ready: string, gate: string, id: string, version: string) {
return `
import { access, appendFile } from "node:fs/promises"
await appendFile(${JSON.stringify(ready)}, "ready\\n")
while (true) {
try {
await access(${JSON.stringify(gate)})
break
} catch {
await new Promise((resolve) => setTimeout(resolve, 10))
}
}
${lifecyclePluginSource(marker, id, version)}
`
}
async function until(read: () => Promise<string>, expected: (value: string | undefined) => boolean) {
let value: string | undefined
for (let attempt = 0; attempt < 200; attempt++) {
@@ -174,6 +196,40 @@ test("editing a discovered TUI plugin hot-reloads its fresh module", async () =>
await app.task
})
test("does not activate a local plugin whose source changes during import", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
await mkdir(directory, { recursive: true })
const marker = path.join(tmp.path, "marker.txt")
const ready = path.join(tmp.path, "ready.txt")
const gate = path.join(tmp.path, "gate.txt")
const source = path.join(directory, "hot.ts")
await writeFile(source, lifecycleSource(marker, "test.hot", "v1"))
await using app = await bootApp(tmp.path)
const read = () => readFile(marker, "utf8")
expect(await until(read, (value) => value === "v1:setup\n")).toBe("v1:setup\n")
await writeFile(source, gatedLifecycleSource(marker, ready, gate, "test.hot", "v2"))
try {
expect(
await until(
() => readFile(ready, "utf8"),
(value) => value === "ready\n",
),
).toBe("ready\n")
await writeFile(source, lifecycleSource(marker, "test.hot", "v3"))
await writeFile(gate, "open")
expect(await until(read, (value) => value?.includes("v3:setup") ?? false)).toBe("v1:setup\nv1:cleanup\nv3:setup\n")
} finally {
await writeFile(gate, "open")
}
process.emit("SIGHUP")
await app.task
})
test("a plugin whose slot render throws does not take down the TUI", async () => {
await using tmp = await tmpdir()
const directory = path.join(tmp.path, ".opencode", "plugins", "tui")
@@ -1,4 +1,3 @@
import { expect, test } from "bun:test"
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
const text = {
@@ -51,25 +50,8 @@ const definition = {
"@context:overlay": { background: { default: "$hue.neutral.300" } },
} satisfies ThemeDefinition
const document = { version: 2, light: definition, dark: definition } satisfies ThemeDocument
const lightOnly = { version: 2, light: definition } satisfies ThemeDocument
const darkOnly = { version: 2, dark: definition } satisfies ThemeDocument
export const document = { version: 2, light: definition, dark: definition } satisfies ThemeDocument
export const lightOnly = { version: 2, light: definition } satisfies ThemeDocument
export const darkOnly = { version: 2, dark: definition } satisfies ThemeDocument
// @ts-expect-error A theme document must provide at least one mode.
const empty = { version: 2 } satisfies ThemeDocument
test("supports property-first definitions, variants, states, and contexts", () => {
expect(text.action.primary.$hovered).toBe("$hue.neutral.200")
expect(text.action.primary.$pressed).toBe("$hue.neutral.300")
expect(text.formfield.$selected).toBe("$hue.neutral.100")
expect(background.action.destructive.default).toBe("$hue.red.600")
expect(background.action.primary.$selected).toBe("$hue.interactive.700")
expect(background.formfield.$hovered).toBe("$hue.neutral.200")
expect(background.surface.offset).toBe("$hue.neutral.200")
expect(definition["@context:elevated"].text?.default).toBe("$hue.neutral.800")
expect(definition["@context:overlay"].background?.default).toBe("$hue.neutral.300")
expect(definition.categorical).toEqual(["blue", "accent"])
expect(document.light).toBe(definition)
expect(lightOnly.light).toBe(definition)
expect(darkOnly.dark).toBe(definition)
expect(empty.version).toBe(2)
})
export const empty = { version: 2 } satisfies ThemeDocument
-45
View File
@@ -1,45 +0,0 @@
import { expect, test } from "bun:test"
import { createRoot } from "solid-js"
import { createAnimatable, spring, tween } from "../../src/ui/animation"
test("animates numeric objects and arrays to their targets", async () => {
let dispose = () => {}
const visual = createRoot((nextDispose) => {
dispose = nextDispose
return createAnimatable(
{ widths: [8, 8], selection: 0 },
{ transition: tween({ duration: 0.02, ease: (progress) => progress }) },
)
})
try {
visual.animate({ widths: [12, 4], selection: 1 })
await Bun.sleep(80)
expect(visual.value()).toEqual({ widths: [12, 4], selection: 1 })
} finally {
dispose()
}
})
test("retains spring state while retargeting and supports immediate jumps", async () => {
let dispose = () => {}
const visual = createRoot((nextDispose) => {
dispose = nextDispose
return createAnimatable({ value: 0 }, { transition: spring({ visualDuration: 0.02 }) })
})
try {
visual.animate({ value: 1 })
await Bun.sleep(20)
const target = visual.value().value
visual.animate({ value: target })
await Bun.sleep(20)
expect(visual.value().value).not.toBe(target)
await Bun.sleep(80)
expect(visual.value().value).toBeCloseTo(target)
visual.jump({ value: 0 })
expect(visual.value().value).toBe(0)
} finally {
dispose()
}
})