270 lines
7.1 KiB
TypeScript
270 lines
7.1 KiB
TypeScript
import type { TuiPlugin, TuiPluginApi, TuiPluginStatus } from "@opencode-ai/plugin/tui"
|
|
import type { BuiltinTuiPlugin } from "../builtins"
|
|
import { useTerminalDimensions } from "@opentui/solid"
|
|
import { fileURLToPath } from "url"
|
|
import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select"
|
|
import { Show, createEffect, createMemo, createSignal } from "solid-js"
|
|
import { useBindings } from "../../keymap"
|
|
|
|
const id = "internal:plugin-manager"
|
|
|
|
function state(api: TuiPluginApi, item: TuiPluginStatus) {
|
|
if (!item.enabled) {
|
|
return <span style={{ fg: api.theme.current.textMuted }}>disabled</span>
|
|
}
|
|
|
|
return (
|
|
<span style={{ fg: item.active ? api.theme.current.success : api.theme.current.error }}>
|
|
{item.active ? "active" : "inactive"}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
function source(spec: string) {
|
|
if (!spec.startsWith("file://")) return
|
|
return fileURLToPath(spec)
|
|
}
|
|
|
|
function meta(item: TuiPluginStatus, width: number) {
|
|
if (item.source === "internal") {
|
|
if (width >= 120) return "Built-in plugin"
|
|
return "Built-in"
|
|
}
|
|
const next = source(item.spec)
|
|
if (next) return next
|
|
return item.spec
|
|
}
|
|
|
|
function Install(props: { api: TuiPluginApi }) {
|
|
const [global, setGlobal] = createSignal(false)
|
|
const [busy, setBusy] = createSignal(false)
|
|
|
|
useBindings(() => ({
|
|
enabled: !busy(),
|
|
bindings: [{ key: "tab", desc: "Toggle install scope", group: "Plugins", cmd: () => setGlobal((value) => !value) }],
|
|
}))
|
|
|
|
return (
|
|
<props.api.ui.DialogPrompt
|
|
title="Install plugin"
|
|
placeholder="npm package name"
|
|
busy={busy()}
|
|
busyText="Installing plugin..."
|
|
description={() => (
|
|
<box flexDirection="row" gap={1}>
|
|
<text fg={props.api.theme.current.textMuted}>scope:</text>
|
|
<text fg={busy() ? props.api.theme.current.textMuted : props.api.theme.current.text}>
|
|
{global() ? "global" : "local"}
|
|
</text>
|
|
<Show when={!busy()}>
|
|
<text fg={props.api.theme.current.textMuted}>(tab toggle)</text>
|
|
</Show>
|
|
</box>
|
|
)}
|
|
onConfirm={(raw) => {
|
|
if (busy()) return
|
|
const mod = raw.trim()
|
|
if (!mod) {
|
|
props.api.ui.toast({
|
|
variant: "error",
|
|
message: "Plugin package name is required",
|
|
})
|
|
return
|
|
}
|
|
|
|
setBusy(true)
|
|
void props.api.plugins
|
|
.install(mod, { global: global() })
|
|
.then((out) => {
|
|
if (!out.ok) {
|
|
props.api.ui.toast({
|
|
variant: "error",
|
|
message: out.message,
|
|
})
|
|
if (out.missing) {
|
|
props.api.ui.toast({
|
|
variant: "info",
|
|
message: "Check npm registry/auth settings and try again.",
|
|
})
|
|
}
|
|
show(props.api)
|
|
return
|
|
}
|
|
|
|
props.api.ui.toast({
|
|
variant: "success",
|
|
message: `Installed ${mod} (${global() ? "global" : "local"}: ${out.dir})`,
|
|
})
|
|
if (!out.tui) {
|
|
props.api.ui.toast({
|
|
variant: "info",
|
|
message: "Package has no TUI target to load in this app.",
|
|
})
|
|
show(props.api)
|
|
return
|
|
}
|
|
|
|
return props.api.plugins.add(mod).then((ok) => {
|
|
if (!ok) {
|
|
props.api.ui.toast({
|
|
variant: "warning",
|
|
message: "Installed plugin, but runtime load failed. See console/logs; restart TUI to retry.",
|
|
})
|
|
show(props.api)
|
|
return
|
|
}
|
|
|
|
props.api.ui.toast({
|
|
variant: "success",
|
|
message: `Loaded ${mod} in current session.`,
|
|
})
|
|
show(props.api)
|
|
})
|
|
})
|
|
.finally(() => {
|
|
setBusy(false)
|
|
})
|
|
}}
|
|
onCancel={() => {
|
|
show(props.api)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function row(api: TuiPluginApi, item: TuiPluginStatus, width: number): DialogSelectOption<string> {
|
|
return {
|
|
title: item.id,
|
|
value: item.id,
|
|
category: item.source === "internal" ? "Internal" : "External",
|
|
description: meta(item, width),
|
|
footer: state(api, item),
|
|
disabled: item.id === id,
|
|
}
|
|
}
|
|
|
|
function showInstall(api: TuiPluginApi) {
|
|
api.ui.dialog.replace(() => <Install api={api} />)
|
|
}
|
|
|
|
function View(props: { api: TuiPluginApi }) {
|
|
const size = useTerminalDimensions()
|
|
const [list, setList] = createSignal(props.api.plugins.list())
|
|
const [cur, setCur] = createSignal<string | undefined>()
|
|
const [lock, setLock] = createSignal(false)
|
|
|
|
createEffect(() => {
|
|
const width = size().width
|
|
if (width >= 128) {
|
|
props.api.ui.dialog.setSize("xlarge")
|
|
return
|
|
}
|
|
if (width >= 96) {
|
|
props.api.ui.dialog.setSize("large")
|
|
return
|
|
}
|
|
props.api.ui.dialog.setSize("medium")
|
|
})
|
|
|
|
const rows = createMemo(() =>
|
|
[...list()]
|
|
.sort((a, b) => {
|
|
const x = a.source === "internal" ? 1 : 0
|
|
const y = b.source === "internal" ? 1 : 0
|
|
if (x !== y) return x - y
|
|
return a.id.localeCompare(b.id)
|
|
})
|
|
.map((item) => row(props.api, item, size().width)),
|
|
)
|
|
|
|
const flip = (x: string) => {
|
|
if (lock()) return
|
|
const item = list().find((entry) => entry.id === x)
|
|
if (!item) return
|
|
setLock(true)
|
|
const task = item.active ? props.api.plugins.deactivate(x) : props.api.plugins.activate(x)
|
|
void task
|
|
.then((ok) => {
|
|
if (!ok) {
|
|
props.api.ui.toast({
|
|
variant: "error",
|
|
message: `Failed to update plugin ${item.id}`,
|
|
})
|
|
}
|
|
setList(props.api.plugins.list())
|
|
})
|
|
.finally(() => {
|
|
setLock(false)
|
|
})
|
|
}
|
|
|
|
return (
|
|
<DialogSelect
|
|
title="Plugins"
|
|
options={rows()}
|
|
current={cur()}
|
|
onMove={(item) => setCur(item.value)}
|
|
actions={[
|
|
{
|
|
title: "toggle",
|
|
command: "plugins.toggle",
|
|
hidden: lock(),
|
|
onTrigger: (item) => {
|
|
setCur(item.value)
|
|
flip(item.value)
|
|
},
|
|
},
|
|
{
|
|
title: "install",
|
|
command: "dialog.plugins.install",
|
|
selection: "none",
|
|
hidden: lock(),
|
|
onTrigger: () => {
|
|
showInstall(props.api)
|
|
},
|
|
},
|
|
]}
|
|
onSelect={(item) => {
|
|
setCur(item.value)
|
|
flip(item.value)
|
|
}}
|
|
/>
|
|
)
|
|
}
|
|
|
|
function show(api: TuiPluginApi) {
|
|
api.ui.dialog.replace(() => <View api={api} />)
|
|
}
|
|
|
|
const tui: TuiPlugin = async (api) => {
|
|
api.keymap.registerLayer({
|
|
commands: [
|
|
{
|
|
name: "plugins.list",
|
|
title: "Plugins",
|
|
category: "System",
|
|
namespace: "palette",
|
|
run() {
|
|
show(api)
|
|
},
|
|
},
|
|
{
|
|
name: "plugins.install",
|
|
title: "Install plugin",
|
|
category: "System",
|
|
namespace: "palette",
|
|
run() {
|
|
showInstall(api)
|
|
},
|
|
},
|
|
],
|
|
bindings: api.tuiConfig.keybinds.gather("plugins.palette", ["plugins.list", "plugins.install"]),
|
|
})
|
|
}
|
|
|
|
const plugin: BuiltinTuiPlugin = {
|
|
id,
|
|
tui,
|
|
}
|
|
|
|
export default plugin
|