From 2b87169cc1cb0c932e5b4926d59be3eb901c8de5 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Wed, 2 Sep 2026 21:55:39 -0400 Subject: [PATCH] feat(tui): polish plugin dialog sizing, actions, and local footer --- .../src/feature-plugins/system/plugins.tsx | 49 ++++++++++--------- .../tui/test/cli/tui/plugins-dialog.test.tsx | 25 +++++----- 2 files changed, 38 insertions(+), 36 deletions(-) diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index 0a59ae5bdcb..9b3dc65378d 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -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={ @@ -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)] : []), diff --git a/packages/tui/test/cli/tui/plugins-dialog.test.tsx b/packages/tui/test/cli/tui/plugins-dialog.test.tsx index c444f35f4c0..6ab74cafb1b 100644 --- a/packages/tui/test/cli/tui/plugins-dialog.test.tsx +++ b/packages/tui/test/cli/tui/plugins-dialog.test.tsx @@ -93,7 +93,7 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec const app = await testRender(() => , { 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([])