tui plugins (#19347)

This commit is contained in:
Sebastian
2026-03-27 15:00:26 +01:00
committed by GitHub
parent d8ad8338f5
commit 6274b0677c
91 changed files with 10544 additions and 898 deletions

View 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)
})

View 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)
})

View 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)

View 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
},
},
}
}

View 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
}
}