tui plugins (#19347)
This commit is contained in:
72
packages/opencode/test/fixture/flock-worker.ts
Normal file
72
packages/opencode/test/fixture/flock-worker.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from "fs/promises"
|
||||
import { Flock } from "../../src/util/flock"
|
||||
|
||||
type Msg = {
|
||||
key: string
|
||||
dir: string
|
||||
staleMs?: number
|
||||
timeoutMs?: number
|
||||
baseDelayMs?: number
|
||||
maxDelayMs?: number
|
||||
holdMs?: number
|
||||
ready?: string
|
||||
active?: string
|
||||
done?: string
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
function input() {
|
||||
const raw = process.argv[2]
|
||||
if (!raw) {
|
||||
throw new Error("Missing flock worker input")
|
||||
}
|
||||
|
||||
return JSON.parse(raw) as Msg
|
||||
}
|
||||
|
||||
async function job(input: Msg) {
|
||||
if (input.ready) {
|
||||
await fs.writeFile(input.ready, String(process.pid))
|
||||
}
|
||||
|
||||
if (input.active) {
|
||||
await fs.writeFile(input.active, String(process.pid), { flag: "wx" })
|
||||
}
|
||||
|
||||
try {
|
||||
if (input.holdMs && input.holdMs > 0) {
|
||||
await sleep(input.holdMs)
|
||||
}
|
||||
|
||||
if (input.done) {
|
||||
await fs.appendFile(input.done, "1\n")
|
||||
}
|
||||
} finally {
|
||||
if (input.active) {
|
||||
await fs.rm(input.active, { force: true })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const msg = input()
|
||||
|
||||
await Flock.withLock(msg.key, () => job(msg), {
|
||||
dir: msg.dir,
|
||||
staleMs: msg.staleMs,
|
||||
timeoutMs: msg.timeoutMs,
|
||||
baseDelayMs: msg.baseDelayMs,
|
||||
maxDelayMs: msg.maxDelayMs,
|
||||
})
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
process.stderr.write(text)
|
||||
process.exit(1)
|
||||
})
|
||||
93
packages/opencode/test/fixture/plug-worker.ts
Normal file
93
packages/opencode/test/fixture/plug-worker.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import path from "path"
|
||||
|
||||
import { createPlugTask, type PlugCtx, type PlugDeps } from "../../src/cli/cmd/plug"
|
||||
import { Filesystem } from "../../src/util/filesystem"
|
||||
|
||||
type Msg = {
|
||||
dir: string
|
||||
target: string
|
||||
mod: string
|
||||
global?: boolean
|
||||
force?: boolean
|
||||
globalDir?: string
|
||||
vcs?: string
|
||||
worktree?: string
|
||||
directory?: string
|
||||
holdMs?: number
|
||||
}
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
function input() {
|
||||
const raw = process.argv[2]
|
||||
if (!raw) {
|
||||
throw new Error("Missing plug worker input")
|
||||
}
|
||||
|
||||
const msg = JSON.parse(raw) as Partial<Msg>
|
||||
if (!msg.dir || !msg.target || !msg.mod) {
|
||||
throw new Error("Invalid plug worker input")
|
||||
}
|
||||
|
||||
return msg as Msg
|
||||
}
|
||||
|
||||
function deps(msg: Msg): PlugDeps {
|
||||
return {
|
||||
spinner: () => ({
|
||||
start() {},
|
||||
stop() {},
|
||||
}),
|
||||
log: {
|
||||
error() {},
|
||||
info() {},
|
||||
success() {},
|
||||
},
|
||||
resolve: async () => msg.target,
|
||||
readText: (file) => Filesystem.readText(file),
|
||||
write: async (file, text) => {
|
||||
if (msg.holdMs && msg.holdMs > 0) {
|
||||
await sleep(msg.holdMs)
|
||||
}
|
||||
await Filesystem.write(file, text)
|
||||
},
|
||||
exists: (file) => Filesystem.exists(file),
|
||||
files: (dir, name) => [path.join(dir, `${name}.jsonc`), path.join(dir, `${name}.json`)],
|
||||
global: msg.globalDir ?? path.join(msg.dir, ".global"),
|
||||
}
|
||||
}
|
||||
|
||||
function ctx(msg: Msg): PlugCtx {
|
||||
return {
|
||||
vcs: msg.vcs ?? "git",
|
||||
worktree: msg.worktree ?? msg.dir,
|
||||
directory: msg.directory ?? msg.dir,
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const msg = input()
|
||||
const run = createPlugTask(
|
||||
{
|
||||
mod: msg.mod,
|
||||
global: msg.global,
|
||||
force: msg.force,
|
||||
},
|
||||
deps(msg),
|
||||
)
|
||||
|
||||
const ok = await run(ctx(msg))
|
||||
if (!ok) {
|
||||
throw new Error("Plug task failed")
|
||||
}
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
||||
process.stderr.write(text)
|
||||
process.exit(1)
|
||||
})
|
||||
26
packages/opencode/test/fixture/plugin-meta-worker.ts
Normal file
26
packages/opencode/test/fixture/plugin-meta-worker.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
type Msg = {
|
||||
file: string
|
||||
spec: string
|
||||
target: string
|
||||
id: string
|
||||
}
|
||||
|
||||
const raw = process.argv[2]
|
||||
if (!raw) throw new Error("Missing worker payload")
|
||||
|
||||
const value = JSON.parse(raw)
|
||||
if (!value || typeof value !== "object") {
|
||||
throw new Error("Invalid worker payload")
|
||||
}
|
||||
|
||||
const msg = Object.fromEntries(Object.entries(value))
|
||||
if (typeof msg.file !== "string" || typeof msg.spec !== "string" || typeof msg.target !== "string") {
|
||||
throw new Error("Invalid worker payload")
|
||||
}
|
||||
if (typeof msg.id !== "string") throw new Error("Invalid worker payload")
|
||||
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = msg.file
|
||||
|
||||
const { PluginMeta } = await import("../../src/plugin/meta")
|
||||
|
||||
await PluginMeta.touch(msg.spec, msg.target, msg.id)
|
||||
334
packages/opencode/test/fixture/tui-plugin.ts
Normal file
334
packages/opencode/test/fixture/tui-plugin.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { RGBA, type CliRenderer } from "@opentui/core"
|
||||
import { createPluginKeybind } from "../../src/cli/cmd/tui/context/plugin-keybinds"
|
||||
import type { HostPluginApi } from "../../src/cli/cmd/tui/plugin/slots"
|
||||
|
||||
type Count = {
|
||||
event_add: number
|
||||
event_drop: number
|
||||
route_add: number
|
||||
route_drop: number
|
||||
command_add: number
|
||||
command_drop: number
|
||||
}
|
||||
|
||||
function themeCurrent(): HostPluginApi["theme"]["current"] {
|
||||
const a = RGBA.fromInts(0, 120, 240)
|
||||
const b = RGBA.fromInts(120, 120, 120)
|
||||
const c = RGBA.fromInts(230, 230, 230)
|
||||
const d = RGBA.fromInts(120, 30, 30)
|
||||
const e = RGBA.fromInts(140, 100, 40)
|
||||
const f = RGBA.fromInts(20, 140, 80)
|
||||
const g = RGBA.fromInts(20, 80, 160)
|
||||
const h = RGBA.fromInts(40, 40, 40)
|
||||
const i = RGBA.fromInts(60, 60, 60)
|
||||
const j = RGBA.fromInts(80, 80, 80)
|
||||
return {
|
||||
primary: a,
|
||||
secondary: b,
|
||||
accent: a,
|
||||
error: d,
|
||||
warning: e,
|
||||
success: f,
|
||||
info: g,
|
||||
text: c,
|
||||
textMuted: b,
|
||||
selectedListItemText: h,
|
||||
background: h,
|
||||
backgroundPanel: h,
|
||||
backgroundElement: i,
|
||||
backgroundMenu: i,
|
||||
border: j,
|
||||
borderActive: c,
|
||||
borderSubtle: i,
|
||||
diffAdded: f,
|
||||
diffRemoved: d,
|
||||
diffContext: b,
|
||||
diffHunkHeader: b,
|
||||
diffHighlightAdded: f,
|
||||
diffHighlightRemoved: d,
|
||||
diffAddedBg: h,
|
||||
diffRemovedBg: h,
|
||||
diffContextBg: h,
|
||||
diffLineNumber: b,
|
||||
diffAddedLineNumberBg: h,
|
||||
diffRemovedLineNumberBg: h,
|
||||
markdownText: c,
|
||||
markdownHeading: c,
|
||||
markdownLink: a,
|
||||
markdownLinkText: g,
|
||||
markdownCode: f,
|
||||
markdownBlockQuote: e,
|
||||
markdownEmph: e,
|
||||
markdownStrong: c,
|
||||
markdownHorizontalRule: b,
|
||||
markdownListItem: a,
|
||||
markdownListEnumeration: g,
|
||||
markdownImage: a,
|
||||
markdownImageText: g,
|
||||
markdownCodeBlock: c,
|
||||
syntaxComment: b,
|
||||
syntaxKeyword: a,
|
||||
syntaxFunction: g,
|
||||
syntaxVariable: c,
|
||||
syntaxString: f,
|
||||
syntaxNumber: e,
|
||||
syntaxType: a,
|
||||
syntaxOperator: a,
|
||||
syntaxPunctuation: c,
|
||||
thinkingOpacity: 0.6,
|
||||
}
|
||||
}
|
||||
|
||||
type Opts = {
|
||||
client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
|
||||
scopedClient?: HostPluginApi["scopedClient"]
|
||||
workspace?: Partial<HostPluginApi["workspace"]>
|
||||
renderer?: HostPluginApi["renderer"]
|
||||
count?: Count
|
||||
keybind?: Partial<HostPluginApi["keybind"]>
|
||||
tuiConfig?: HostPluginApi["tuiConfig"]
|
||||
app?: Partial<HostPluginApi["app"]>
|
||||
state?: {
|
||||
ready?: HostPluginApi["state"]["ready"]
|
||||
config?: HostPluginApi["state"]["config"]
|
||||
provider?: HostPluginApi["state"]["provider"]
|
||||
path?: HostPluginApi["state"]["path"]
|
||||
vcs?: HostPluginApi["state"]["vcs"]
|
||||
workspace?: Partial<HostPluginApi["state"]["workspace"]>
|
||||
session?: Partial<HostPluginApi["state"]["session"]>
|
||||
part?: HostPluginApi["state"]["part"]
|
||||
lsp?: HostPluginApi["state"]["lsp"]
|
||||
mcp?: HostPluginApi["state"]["mcp"]
|
||||
}
|
||||
theme?: {
|
||||
selected?: string
|
||||
has?: HostPluginApi["theme"]["has"]
|
||||
set?: HostPluginApi["theme"]["set"]
|
||||
install?: HostPluginApi["theme"]["install"]
|
||||
mode?: HostPluginApi["theme"]["mode"]
|
||||
ready?: boolean
|
||||
current?: HostPluginApi["theme"]["current"]
|
||||
}
|
||||
}
|
||||
|
||||
export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
|
||||
const kv: Record<string, unknown> = {}
|
||||
const count = opts.count
|
||||
const ctrl = new AbortController()
|
||||
const own = createOpencodeClient({
|
||||
baseUrl: "http://localhost:4096",
|
||||
})
|
||||
const fallback = () => own
|
||||
const read =
|
||||
typeof opts.client === "function"
|
||||
? opts.client
|
||||
: opts.client
|
||||
? () => opts.client as HostPluginApi["client"]
|
||||
: fallback
|
||||
const client = () => read()
|
||||
const scopedClient = opts.scopedClient ?? ((_workspaceID?: string) => client())
|
||||
const workspace: HostPluginApi["workspace"] = {
|
||||
current: opts.workspace?.current ?? (() => undefined),
|
||||
set: opts.workspace?.set ?? (() => {}),
|
||||
}
|
||||
let depth = 0
|
||||
let size: "medium" | "large" | "xlarge" = "medium"
|
||||
const has = opts.theme?.has ?? (() => false)
|
||||
let selected = opts.theme?.selected ?? "opencode"
|
||||
const key = {
|
||||
match: opts.keybind?.match ?? (() => false),
|
||||
print: opts.keybind?.print ?? ((name: string) => name),
|
||||
}
|
||||
const set =
|
||||
opts.theme?.set ??
|
||||
((name: string) => {
|
||||
if (!has(name)) return false
|
||||
selected = name
|
||||
return true
|
||||
})
|
||||
const renderer: CliRenderer = opts.renderer ?? {
|
||||
...Object.create(null),
|
||||
once(this: CliRenderer) {
|
||||
return this
|
||||
},
|
||||
}
|
||||
|
||||
function kvGet(name: string): unknown
|
||||
function kvGet<Value>(name: string, fallback: Value): Value
|
||||
function kvGet(name: string, fallback?: unknown) {
|
||||
const value = kv[name]
|
||||
if (value === undefined) return fallback
|
||||
return value
|
||||
}
|
||||
|
||||
return {
|
||||
app: {
|
||||
get version() {
|
||||
return opts.app?.version ?? "0.0.0-test"
|
||||
},
|
||||
},
|
||||
get client() {
|
||||
return client()
|
||||
},
|
||||
scopedClient,
|
||||
workspace,
|
||||
event: {
|
||||
on: () => {
|
||||
if (count) count.event_add += 1
|
||||
return () => {
|
||||
if (!count) return
|
||||
count.event_drop += 1
|
||||
}
|
||||
},
|
||||
},
|
||||
renderer,
|
||||
slots: {
|
||||
register: () => "fixture-slot",
|
||||
},
|
||||
plugins: {
|
||||
list: () => [],
|
||||
activate: async () => false,
|
||||
deactivate: async () => false,
|
||||
add: async () => false,
|
||||
install: async () => ({
|
||||
ok: false,
|
||||
message: "not implemented in fixture",
|
||||
}),
|
||||
},
|
||||
lifecycle: {
|
||||
signal: ctrl.signal,
|
||||
onDispose() {
|
||||
return () => {}
|
||||
},
|
||||
},
|
||||
command: {
|
||||
register: () => {
|
||||
if (count) count.command_add += 1
|
||||
return () => {
|
||||
if (!count) return
|
||||
count.command_drop += 1
|
||||
}
|
||||
},
|
||||
trigger: () => {},
|
||||
},
|
||||
route: {
|
||||
register: () => {
|
||||
if (count) count.route_add += 1
|
||||
return () => {
|
||||
if (!count) return
|
||||
count.route_drop += 1
|
||||
}
|
||||
},
|
||||
navigate: () => {},
|
||||
get current() {
|
||||
return { name: "home" }
|
||||
},
|
||||
},
|
||||
ui: {
|
||||
Dialog: () => null,
|
||||
DialogAlert: () => null,
|
||||
DialogConfirm: () => null,
|
||||
DialogPrompt: () => null,
|
||||
DialogSelect: () => null,
|
||||
toast: () => {},
|
||||
dialog: {
|
||||
replace: () => {
|
||||
depth = 1
|
||||
},
|
||||
clear: () => {
|
||||
depth = 0
|
||||
size = "medium"
|
||||
},
|
||||
setSize: (next) => {
|
||||
size = next
|
||||
},
|
||||
get size() {
|
||||
return size
|
||||
},
|
||||
get depth() {
|
||||
return depth
|
||||
},
|
||||
get open() {
|
||||
return depth > 0
|
||||
},
|
||||
},
|
||||
},
|
||||
keybind: {
|
||||
...key,
|
||||
create:
|
||||
opts.keybind?.create ??
|
||||
((defaults, over) => {
|
||||
return createPluginKeybind(key, defaults, over)
|
||||
}),
|
||||
},
|
||||
tuiConfig: opts.tuiConfig ?? {},
|
||||
kv: {
|
||||
get: kvGet,
|
||||
set(name, value) {
|
||||
kv[name] = value
|
||||
},
|
||||
get ready() {
|
||||
return true
|
||||
},
|
||||
},
|
||||
state: {
|
||||
get ready() {
|
||||
return opts.state?.ready ?? true
|
||||
},
|
||||
get config() {
|
||||
return opts.state?.config ?? {}
|
||||
},
|
||||
get provider() {
|
||||
return opts.state?.provider ?? []
|
||||
},
|
||||
get path() {
|
||||
return opts.state?.path ?? { state: "", config: "", worktree: "", directory: "" }
|
||||
},
|
||||
get vcs() {
|
||||
return opts.state?.vcs
|
||||
},
|
||||
workspace: {
|
||||
list: opts.state?.workspace?.list ?? (() => []),
|
||||
get: opts.state?.workspace?.get ?? (() => undefined),
|
||||
},
|
||||
session: {
|
||||
count: opts.state?.session?.count ?? (() => 0),
|
||||
diff: opts.state?.session?.diff ?? (() => []),
|
||||
todo: opts.state?.session?.todo ?? (() => []),
|
||||
messages: opts.state?.session?.messages ?? (() => []),
|
||||
status: opts.state?.session?.status ?? (() => undefined),
|
||||
permission: opts.state?.session?.permission ?? (() => []),
|
||||
question: opts.state?.session?.question ?? (() => []),
|
||||
},
|
||||
part: opts.state?.part ?? (() => []),
|
||||
lsp: opts.state?.lsp ?? (() => []),
|
||||
mcp: opts.state?.mcp ?? (() => []),
|
||||
},
|
||||
theme: {
|
||||
get current() {
|
||||
return opts.theme?.current ?? themeCurrent()
|
||||
},
|
||||
get selected() {
|
||||
return selected
|
||||
},
|
||||
has(name) {
|
||||
return has(name)
|
||||
},
|
||||
set(name) {
|
||||
return set(name)
|
||||
},
|
||||
async install(file) {
|
||||
if (opts.theme?.install) return opts.theme.install(file)
|
||||
throw new Error("base theme.install should not run")
|
||||
},
|
||||
mode() {
|
||||
if (opts.theme?.mode) return opts.theme.mode()
|
||||
return "dark"
|
||||
},
|
||||
get ready() {
|
||||
return opts.theme?.ready ?? true
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
34
packages/opencode/test/fixture/tui-runtime.ts
Normal file
34
packages/opencode/test/fixture/tui-runtime.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { spyOn } from "bun:test"
|
||||
import path from "path"
|
||||
import { TuiConfig } from "../../src/config/tui"
|
||||
|
||||
type PluginSpec = string | [string, Record<string, unknown>]
|
||||
|
||||
export function mockTuiRuntime(dir: string, plugin: PluginSpec[]) {
|
||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(dir, "plugin-meta.json")
|
||||
const meta = Object.fromEntries(
|
||||
plugin.map((item) => {
|
||||
const spec = Array.isArray(item) ? item[0] : item
|
||||
return [
|
||||
spec,
|
||||
{
|
||||
scope: "local" as const,
|
||||
source: path.join(dir, "tui.json"),
|
||||
},
|
||||
]
|
||||
}),
|
||||
)
|
||||
const get = spyOn(TuiConfig, "get").mockResolvedValue({
|
||||
plugin,
|
||||
plugin_meta: meta,
|
||||
})
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => dir)
|
||||
|
||||
return () => {
|
||||
cwd.mockRestore()
|
||||
get.mockRestore()
|
||||
wait.mockRestore()
|
||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user