feat(tui): polish plugin dialog sizing, actions, and local footer

This commit is contained in:
Dax Raad 2026-09-02 21:55:39 -04:00
parent 962c26bdf2
commit 2b87169cc1
2 changed files with 38 additions and 36 deletions

View file

@ -1,5 +1,6 @@
import type { PluginInfo } from "@opencode-ai/client"
import { Plugin } from "@opencode-ai/plugin/tui"
import path from "path"
import { createEffect, createMemo, createResource, createSignal, onCleanup, onMount, Show } from "solid-js"
import { DialogErrorDetails } from "../../component/dialog-error-details"
import { Spinner } from "../../component/spinner"
@ -37,7 +38,7 @@ export function PluginsDialog(props: {
() => (props.server ? undefined : (props.context.location ?? props.context.data.location.default())),
(location) => props.context.client.plugin.list({ location }).then((result) => result.data),
)
onMount(() => dialog.setSize("medium"))
onMount(() => dialog.setSize("large"))
onCleanup(props.context.data.on("plugin.updated", () => void refetch()))
const updating = (entry: Entry) =>
pending().includes(entry.key) ||
@ -167,13 +168,6 @@ export function PluginsDialog(props: {
.check({ location: props.context.location ?? props.context.data.location.default() })
.then((result) => {
mutate(result.data)
const count = result.data.filter(
(plugin) => plugin.source.type === "package" && plugin.source.outdated === true,
).length
props.context.ui.toast.show({
variant: count ? "info" : "success",
message: count ? `${count} plugin update${count === 1 ? "" : "s"} available` : "All plugins are up to date",
})
})
.catch((cause) => {
props.context.ui.toast.show({
@ -218,19 +212,7 @@ export function PluginsDialog(props: {
}}
actions={[
{
title: toggleTitle(),
command: "plugins.toggle",
hidden: !focusedTui(),
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
},
{
title: "update",
command: "dialog.plugins.update",
hidden: !updatable(focusedEntry()),
onTrigger: (option) => update(entries().find((entry) => entry.key === option.value)),
},
{
title: checking() ? "checking" : "check",
title: checking() ? "checking for updates" : "check for updates",
command: "dialog.plugins.check",
selection: "none",
hidden: !entries().some(
@ -239,6 +221,20 @@ export function PluginsDialog(props: {
disabled: checking(),
onTrigger: check,
},
{
title: toggleTitle(),
command: "plugins.toggle",
side: "right",
hidden: !focusedTui(),
onTrigger: (option) => toggle(entries().find((entry) => entry.key === option.value)),
},
{
title: "update",
command: "dialog.plugins.update",
side: "right",
hidden: !updatable(focusedEntry()),
onTrigger: (option) => update(entries().find((entry) => entry.key === option.value)),
},
]}
footer={
<Show when={pluginError(focusedEntry())}>
@ -262,7 +258,7 @@ export function PluginsDialog(props: {
context={`Plugin: ${label(entry(), props.context)}\nStatus: failed\nRuntime: ${entry().runtime}\nSource: ${pluginSource(entry(), props.context)}`}
onBack={() => {
setDetail()
dialog.setSize("medium")
dialog.setSize("large")
}}
/>
)}
@ -287,6 +283,14 @@ function source(plugin: PluginInfo, context: Plugin.Context) {
return plugin.source.type
}
function isLocal(entry: Entry) {
if (entry.runtime === "server") return entry.plugin.source.type === "local"
const target = entry.target
return (
target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target)
)
}
function status(entry: Entry) {
if (entry.runtime === "server") return entry.plugin.state.status
return entry.status
@ -299,6 +303,7 @@ function outdated(entry: Entry) {
function footer(entry: Entry) {
const details = [
...(status(entry) === "active" ? [] : [status(entry)]),
...(isLocal(entry) ? ["local"] : []),
...(entry.runtime === "server" && entry.plugin.source.type === "package" && entry.plugin.source.version
? [displayVersion(entry.plugin.source.version)]
: []),

View file

@ -93,7 +93,7 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec
const app = await testRender(() => <Harness />, { width: 80, height: 20, kittyKeyboard: true })
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Plugins"))
await app.waitForFrame((frame) => frame.includes("team.plugins") || frame.includes("local.plugin"))
return { app, requests, toasts }
}
@ -102,16 +102,16 @@ test("checking for updates refreshes the inventory and reveals the update action
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(true)] })
try {
const initial = await fixture.app.waitForFrame((frame) => frame.includes("dadba13"))
expect(initial).toContain("check ctrl+r")
expect(initial).not.toContain("update available")
expect(initial).not.toContain("ctrl+u")
// The update action starts hidden: triggering it before a check issues no request.
fixture.app.mockInput.pressKey("u", { ctrl: true })
await fixture.app.flush()
expect(fixture.requests).toEqual([])
fixture.app.mockInput.pressKey("r", { ctrl: true })
const checked = await fixture.app.waitForFrame((frame) => frame.includes("update available"))
expect(checked).toContain("ctrl+u")
await fixture.app.waitFor(() => fixture.requests.length === 1)
expect(fixture.requests).toEqual([{ path: "/api/plugin/check", body: {} }])
expect(fixture.toasts).toEqual([{ variant: "info", message: "1 plugin update available" }])
// Let the check response apply before triggering the now-enabled update.
await fixture.app.flush()
fixture.app.mockInput.pressKey("u", { ctrl: true })
await fixture.app.waitFor(() => fixture.requests.length === 2)
@ -126,12 +126,12 @@ test("checking for updates reports an up-to-date inventory", async () => {
const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(false)] })
try {
await fixture.app.waitForFrame((frame) => frame.includes("dadba13"))
fixture.app.mockInput.pressKey("r", { ctrl: true })
await fixture.app.waitFor(() => fixture.toasts.length === 1)
await fixture.app.waitFor(() => fixture.requests.length === 1)
await fixture.app.flush()
expect(fixture.toasts).toEqual([{ variant: "success", message: "All plugins are up to date" }])
expect(fixture.requests).toEqual([{ path: "/api/plugin/check", body: {} }])
expect(fixture.toasts).toEqual([])
} finally {
fixture.app.renderer.destroy()
}
@ -148,9 +148,6 @@ test("the check action stays hidden without package plugins", async () => {
const fixture = await renderPlugins(tmp.path, { list: [local], check: [] })
try {
const frame = await fixture.app.waitForFrame((frame) => frame.includes("local.plugin"))
expect(frame).not.toContain("ctrl+r")
fixture.app.mockInput.pressKey("r", { ctrl: true })
await fixture.app.flush()
expect(fixture.requests).toEqual([])