Compare commits

..
1 Commits
Author SHA1 Message Date
rekram1-node 2f60a0b2a2 fix(tui): apply cursor config in mini 2026-08-19 21:47:19 +00:00
60 changed files with 595 additions and 690 deletions
+68 -10
View File
@@ -1,17 +1,17 @@
export * as ServerProcess from "./server-process"
import { NodeServices } from "@effect/platform-node"
import { Service, type DiscoverOptions } from "@opencode-ai/client/effect/service"
import { Service, type DiscoverOptions, type Info } 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 { Effect, Option, Redacted, Schedule } from "effect"
import path from "node:path"
import { Effect, FileSystem, Option, Redacted, Schedule, Schema } 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,13 +120,7 @@ const processEffect = Effect.fnUntraced(function* (options: Options) {
onListen: (address, shutdown) =>
Effect.gen(function* () {
if (!config.password) yield* ServiceConfig.password(password)
return yield* ServiceRegistration.register({
address,
password,
id: instanceID,
file: serviceOptions.file,
shutdown,
})
return yield* register(address, password, instanceID, serviceOptions.file, shutdown)
}),
},
transform,
@@ -163,6 +157,70 @@ 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),
@@ -1,71 +0,0 @@
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,
)
})
+84 -1
View File
@@ -1,14 +1,97 @@
import { describe, expect, test } from "bun:test"
import { afterEach, 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,6 +77,13 @@ 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()
+16 -14
View File
@@ -7,7 +7,6 @@ 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"
@@ -101,30 +100,33 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
llm: { requests },
spawn(extraEnv: Record<string, string | undefined> = {}) {
const acp = spawnAcp({
env: isolatedEnv(root, {
env: {
...process.env,
HOME: 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]() {
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
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 })
},
}
}
+1
View File
@@ -349,6 +349,7 @@ 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
@@ -1,19 +0,0 @@
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,
}
}
+32 -25
View File
@@ -8,7 +8,6 @@ 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)
@@ -151,6 +150,20 @@ 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 {
@@ -442,45 +455,39 @@ test("port contender recognizes an incumbent registered during the bind race", a
}
}, 45_000)
test("service registration replaces a stale owner with the bound address", async () => {
test("stale dead registration is replaced after binding the selected port", 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:4321", pid: 2_147_483_647 }),
JSON.stringify({ id: "dead", version: "dead", url: `http://127.0.0.1:${port}`, 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 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)
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
} 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,
+1 -7
View File
@@ -1,14 +1,10 @@
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: isolatedEnv(root, { OPENCODE_SERVER_USERNAME: "custom" }),
env: { ...process.env, OPENCODE_SERVER_USERNAME: "custom" },
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
@@ -32,9 +28,7 @@ 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; mention?: PromptMention }
export type PromptSkillAttachment = { id: string; name: string; text: string; mention?: PromptMention }
export type ToolFileContent = { type: "file"; uri: string; mime: string; name?: string | null }
@@ -2567,6 +2567,7 @@ 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"
@@ -2835,6 +2836,7 @@ 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"
@@ -3103,6 +3105,7 @@ 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"
+1 -46
View File
@@ -1,6 +1,6 @@
export * as KV from "./kv.js"
import { and, asc, eq, gt, gte, lt } from "drizzle-orm"
import { eq } 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,27 +8,10 @@ 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") {}
@@ -57,36 +40,8 @@ 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] })
+2 -6
View File
@@ -11,7 +11,6 @@ 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"
@@ -42,18 +41,16 @@ 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, storage: PluginHost.storage(kv, plugin.id) }),
).pipe(
const loaded = yield* Effect.suspend(() => plugin.effect(host)).pipe(
inherit,
Effect.updateContext((context: Context.Context<never>) =>
Context.make(Scope.Scope, child).pipe(
@@ -192,7 +189,6 @@ export const node = makeLocationNode({
Catalog.node,
Command.node,
Integration.node,
KV.node,
MCP.node,
Location.node,
Reference.node,
+1 -36
View File
@@ -14,7 +14,6 @@ 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"
@@ -29,10 +28,7 @@ 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,
pluginID: string = "test",
) {
export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../plugin.js").Interface) {
const app = yield* App.Metadata
const agents = yield* Agent.Service
const aisdk = yield* AISDK.Service
@@ -40,7 +36,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
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
@@ -345,7 +340,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
})
}),
},
storage: storage(kv, pluginID),
shell: {
hook: (name, callback) => hooks.register("shell", name, callback),
},
@@ -412,35 +406,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
} 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,6 +982,7 @@ const resolvePrompt = Effect.fn("Session.resolvePrompt")(function* (
return Effect.succeed({
id: skill.id,
name: skill.name,
text: Skill.toModelOutput(skill, []),
mention: attachment.mention,
})
})
+2 -1
View File
@@ -138,7 +138,8 @@ const serialize = (message: SessionMessage.Info) => {
(file) =>
`[Attached ${file.mime}: ${file.name ?? (file.source.type === "uri" ? file.source.uri : "inline attachment")}]`,
) ?? []
return [`[User]: ${message.text}`, ...files].join("\n")
const skills = message.skills?.map((skill) => `[Attached skill: ${skill.name}]\n${skill.text}`) ?? []
return [`[User]: ${message.text}`, ...skills, ...files].join("\n")
}
if (message.type === "location-switched")
return `[User]: The working directory has been changed to ${message.location.directory}.`
@@ -227,6 +227,7 @@ 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,6 +207,7 @@ 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,7 +26,6 @@ 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 an available skill or a skill explicitly referenced by the user" }),
id: Skill.ID.annotate({ description: "The ID of the skill from the available skills list" }),
})
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 an available skill or a skill explicitly referenced by the user.",
"The skill ID must match one of the available skills in the instructions.",
].join("\n")
export const toModelOutput = Skill.toModelOutput
@@ -262,6 +262,7 @@ 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)')
@@ -270,6 +271,7 @@ describe("cross-spawn spawner", () => {
}),
)
expect(Date.now() - started).toBeLessThan(1_000)
expect(Exit.isFailure(exit) ? true : exit.value !== ChildProcessSpawner.ExitCode(0)).toBe(true)
}),
)
@@ -35,6 +35,28 @@ 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,64 +18,8 @@ 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,54 +338,6 @@ 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,7 +11,6 @@ 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"
@@ -53,7 +52,6 @@ export const PluginTestLayer = LayerNode.compile(
Catalog.node,
Command.node,
Integration.node,
KV.node,
MCP.node,
PluginRuntime.node,
PluginHooks.node,
-6
View File
@@ -94,12 +94,6 @@ 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,32 +27,6 @@ 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
+2 -3
View File
@@ -18,12 +18,11 @@ const waitForFile = (file: string) =>
Effect.promise(async () => {
while (true) {
try {
const contents = await fs.readFile(file, "utf8")
if (contents) return contents
return await fs.readFile(file, "utf8")
} 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("does not inject skill content for reference-only attachments", () => {
test("lowers selected skill instructions with the original user prompt", () => {
const messages = toLLMMessages(
[
SessionMessage.User.make({
id: id("user-skill-reference"),
id: id("user-skill"),
type: "user",
text: "Use @api-design",
text: "Design this API",
skills: [
SkillAttachment.make({
id: Skill.ID.make("api-design"),
name: Skill.Name.make("API design"),
mention: { start: 4, end: 15, text: "@api-design" },
text: "Start from the ideal call site.",
}),
],
time: { created },
@@ -225,9 +225,17 @@ Recent work
model,
)
expect(messages).toHaveLength(1)
expect(messages[0]).toMatchObject({
id: id("user-skill"),
role: "user",
content: [{ type: "text", text: "Use @api-design" }],
content: [
{
type: "text",
text: "Start from the ideal call site.",
},
{ type: "text", text: "Design this API" },
],
})
})
+6 -5
View File
@@ -56,7 +56,7 @@ const it = testEffect(
)
describe("Session.skill", () => {
it.effect("keeps skill mentions as references on a normal prompt", () =>
it.effect("attaches a resolved skill snapshot to 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 @effect",
skills: [{ id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } }],
text: "Apply this guidance",
skills: [{ id: Skill.ID.make("effect"), mention: { start: 20, end: 27, text: "/effect" } }],
resume: false,
})
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
@@ -77,12 +77,13 @@ describe("Session.skill", () => {
expect.objectContaining({
id,
type: "user",
text: "Apply @effect",
text: "Apply this guidance",
skills: [
{
id: "effect",
name: "Effect",
mention: { start: 6, end: 13, text: "@effect" },
text: expect.stringContaining("Use Effect"),
mention: { start: 20, end: 27, text: "/effect" },
},
],
}),
@@ -59,7 +59,6 @@ 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>",
+35 -38
View File
@@ -774,45 +774,42 @@ 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)),
),
{ timeout: 15_000 },
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)),
),
)
if (!isWindows) {
@@ -144,10 +144,12 @@ 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 }))
+21 -24
View File
@@ -434,32 +434,29 @@ 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)),
)
}),
15_000,
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)),
)
}),
)
it.live("refresh ignores existing directories that are no longer git checkouts", () =>
+1 -8
View File
@@ -94,14 +94,7 @@ export function createMainWindow(id: string = randomUUID()) {
wireFullscreen(win)
loadWindow(win, "index.html")
wireZoom(win)
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)
win.once("ready-to-show", () => win.show())
return win
}
-1
View File
@@ -1,5 +1,4 @@
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,7 +13,6 @@ 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"
@@ -32,7 +31,6 @@ export interface Context {
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
readonly storage: StorageDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
}
-9
View File
@@ -1,9 +0,0 @@
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,12 +261,6 @@ 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,5 +1,4 @@
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,7 +12,6 @@ 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,7 +30,6 @@ export interface Context {
readonly session: SessionDomain
readonly shell: ShellDomain
readonly skill: SkillDomain
readonly storage: StorageDomain
readonly tool: ToolDomain
readonly websearch: WebSearchDomain
}
-9
View File
@@ -1,9 +0,0 @@
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
@@ -1,17 +0,0 @@
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,6 +57,7 @@ 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 = "@"
const prefix = part.type === "skill" ? "/" : "@"
const append = prefix + text + (needsSpace ? " " : "")
input.cursorOffset = store.index
@@ -478,22 +478,6 @@ 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)
@@ -553,7 +537,11 @@ export function Autocomplete(props: {
display: "/" + skill.id,
description: skill.description,
kind: "skill",
onSelect: () => insertSlash(skill.id),
onSelect: () =>
insertPart(skill.id, {
type: "skill",
value: { id: Skill.ID.make(skill.id), mention: { start: 0, end: 0, text: "" } },
}),
})
}
@@ -604,10 +592,10 @@ export function Autocomplete(props: {
const fileOptions: AutocompleteOption[] = store.visible === "reference" ? fileSearch.options : []
const nonFileOptions: AutocompleteOption[] =
store.visible === "reference"
? [...skillOptions(), ...referenceAliasesValue, ...agentsValue, ...mcpResources()]
? [...referenceAliasesValue, ...agentsValue, ...mcpResources()]
: store.index === 0
? [...commandsValue]
: []
: commandsValue.filter((item) => item.kind === "skill")
if (!searchValue) {
return [...nonFileOptions, ...fileOptions]
@@ -30,6 +30,7 @@ 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"
@@ -44,6 +45,7 @@ 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"
@@ -580,6 +582,44 @@ 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",
@@ -621,6 +661,7 @@ export function Prompt(props: PromptProps) {
"prompt.stash",
"prompt.stash.pop",
"prompt.stash.list",
"prompt.skills",
"session.interrupt",
"session.background",
"session.move",
+3 -1
View File
@@ -33,11 +33,12 @@ import {
} from "./form.shared"
import type { FormBodyState } from "./form.shared"
import type { RunFooterTheme } from "./theme"
import type { FormCancel, FormReply, MiniFormRequest } from "./types"
import type { FormCancel, FormReply, MiniFormRequest, RunTuiConfig } 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>
@@ -320,6 +321,7 @@ export function RunFormBody(props: {
backgroundColor={props.theme.surface}
focusedBackgroundColor={props.theme.surface}
cursorColor={props.theme.text}
cursorStyle={props.cursor}
focused
onSubmit={commitInput}
onContentChange={() => {
+5 -1
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 } from "./types"
import type { MiniPermissionRequest, PermissionReply, RunTuiConfig } from "./types"
import { PatchDiff } from "../component/patch-diff"
function buttons(
@@ -74,6 +74,7 @@ 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
@@ -113,6 +114,7 @@ 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={() => {
@@ -139,6 +141,7 @@ export function RunPermissionBody(props: {
request: MiniPermissionRequest
directory?: () => string
theme: RunFooterTheme
cursor?: RunTuiConfig["cursor"]
block: RunBlockTheme
onReply: (input: PermissionReply) => void | Promise<void>
mono?: boolean
@@ -327,6 +330,7 @@ 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,6 +42,7 @@ import type {
RunPrompt,
RunPromptPart,
RunReference,
RunTuiConfig,
} from "./types"
const AUTOCOMPLETE_ROWS = FOOTER_MENU_ROWS
@@ -175,6 +176,7 @@ export function selectedCommand(text: string, command: RunPrompt["command"]) {
export function RunPromptBody(props: {
theme: () => RunFooterTheme
cursor?: RunTuiConfig["cursor"]
background: () => ColorInput
placeholder: () => StyledText | string
onSubmit: () => void
@@ -239,6 +241,7 @@ 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,6 +335,7 @@ 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,6 +55,7 @@ import type {
RunPrompt,
RunProvider,
RunReference,
RunTuiConfig,
} from "./types"
import type { RunTheme } from "./theme"
@@ -92,6 +93,7 @@ type RunFooterViewProps = {
subagent?: () => FooterSubagentState
queuedPrompts?: () => FooterQueuedPrompt[]
theme: () => RunTheme
cursor?: RunTuiConfig["cursor"]
mono: boolean
miniSettings: () => MiniSettings
history?: () => RunPrompt[]
@@ -733,6 +735,7 @@ 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}
@@ -882,6 +885,7 @@ export function RunFooterView(props: RunFooterViewProps) {
request={permission()!.request}
directory={props.directory}
theme={theme()}
cursor={props.cursor}
block={block()}
onReply={props.onPermissionReply}
mono={props.mono}
@@ -893,6 +897,7 @@ 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">
export type RunTuiConfig = Pick<Config.Resolved, "keybinds" | "leader" | "theme" | "mini" | "session" | "cursor">
export type MiniSettings = {
thinking: "show" | "hide"
+6 -18
View File
@@ -585,24 +585,12 @@ 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.
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 }
}
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 }
}
function toRegistration(item: Desired): Registration {
+5 -42
View File
@@ -340,36 +340,16 @@ 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
if (!pasteCustom(stripAnsiSequences(decodePasteBytes(event.bytes)).replace(/\r\n?/g, "\n"))) return
const current = answerField()
if (!current || textual() || !custom() || confirm()) 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
@@ -525,23 +505,6 @@ 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,
+2 -36
View File
@@ -14,13 +14,7 @@ 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,
clipboardText?: string,
) {
async function mountForm(root: string, width = 80, fields?: FormWithLocation["fields"], height = 20) {
const state = path.join(root, "state")
await mkdir(state, { recursive: true })
@@ -64,7 +58,7 @@ async function mountForm(
}}
clipboard={{
async read() {
return clipboardText === undefined ? undefined : { data: clipboardText, mime: "text/plain" }
return undefined
},
write(text) {
copied.push(text)
@@ -223,34 +217,6 @@ 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, [
@@ -0,0 +1,98 @@
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
@@ -0,0 +1,6 @@
import { expect, test } from "bun:test"
import { run } from "../src"
test("exports the canonical application lifecycle", () => {
expect(typeof run).toBe("function")
})
@@ -169,6 +169,7 @@ 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)}
@@ -342,6 +343,18 @@ 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",
+2 -58
View File
@@ -8,8 +8,9 @@ import { pathToFileURL } from "node:url"
import { createEventStream, createFetch, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
function lifecyclePluginSource(marker: string, id: string, version: string) {
function lifecycleSource(marker: string, id: string, version: string) {
return `
import { appendFile } from "node:fs/promises"
export default {
id: ${JSON.stringify(id)},
setup: async () => {
@@ -20,29 +21,6 @@ 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++) {
@@ -196,40 +174,6 @@ 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,3 +1,4 @@
import { expect, test } from "bun:test"
import type { BackgroundDefinition, TextDefinition, ThemeDefinition, ThemeDocument } from "@opencode-ai/theme/tui"
const text = {
@@ -50,8 +51,25 @@ const definition = {
"@context:overlay": { background: { default: "$hue.neutral.300" } },
} satisfies ThemeDefinition
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
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
// @ts-expect-error A theme document must provide at least one mode.
export const empty = { version: 2 } satisfies ThemeDocument
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)
})
+45
View File
@@ -0,0 +1,45 @@
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()
}
})