mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-20 00:26:02 +00:00
Compare commits
5
Commits
mini-cursor
..
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3876f7aad6 | ||
|
|
d912202cf2 | ||
|
|
f8c46684eb | ||
|
|
c4afbc4aae | ||
|
|
98a9d864e6 |
@@ -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,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()
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("")
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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] })
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ?? []),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>"]),
|
||||
|
||||
@@ -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))))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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>",
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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", () =>
|
||||
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.")
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
|
||||
+4
-22
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user