From 2dcfc89fab71afb11038a9ff15f3dd1650c2dffd Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 3 Sep 2026 13:17:10 -0400 Subject: [PATCH] feat(tui): announce server plugin failures (#47103) Show grouped server plugin failure notices with an Open plugins action, retain the home failure count, and reveal failed built-ins in the plugin dialog. Keep unchanged failures quiet across inventory refreshes and reconnects. --- .../tui/src/feature-plugins/home/footer.tsx | 6 +- .../src/feature-plugins/system/plugins.tsx | 8 +- packages/tui/src/plugin/context.tsx | 67 +++++++---- packages/tui/test/app-lifecycle.test.tsx | 111 ++++++++++++++++++ 4 files changed, 164 insertions(+), 28 deletions(-) diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 3a2e70f90be..06222c60ea6 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -52,7 +52,11 @@ function Plugins(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() const visibility = createMemo(() => homeFooterVisibility(dimensions().width)) const plugins = usePlugin() - const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length) + const failed = createMemo( + () => + plugins.list().filter((item) => item.status === "failed").length + + plugins.server().filter((item) => item.state.status === "failed").length, + ) return ( diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index 9b3dc65378d..f1814871714 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -79,7 +79,9 @@ export function PluginsDialog(props: { ...serverEntries.sort((a, b) => label(a, props.context).localeCompare(label(b, props.context))), ] }) - const visibleEntries = createMemo(() => entries().filter((entry) => showInternal() || !entry.internal)) + const visibleEntries = createMemo(() => + entries().filter((entry) => showInternal() || !entry.internal || status(entry) === "failed"), + ) createEffect(() => { if (visibleEntries().some((entry) => entry.key === focused())) return const first = visibleEntries().find((entry) => entry.runtime === "tui") ?? visibleEntries()[0] @@ -286,9 +288,7 @@ function source(plugin: PluginInfo, context: Plugin.Context) { 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) - ) + return target.startsWith("file://") || target.startsWith("./") || target.startsWith("../") || path.isAbsolute(target) } function status(entry: Entry) { diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index 9e99c0e2ae0..22b29084fc6 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -53,6 +53,7 @@ type RegisteredPlugin = { type Value = { readonly ready: () => boolean readonly list: () => ReadonlyArray + readonly server: () => readonly PluginInfo[] readonly registered: () => ReadonlyArray readonly route: (id: string, name: string) => Page["render"] | undefined readonly slots: { @@ -90,13 +91,15 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir const lifecycle = useTuiLifecycle() const client = useClient() const data = useData() - const [serverPlugins, setServerPlugins] = createSignal< - ReadonlyArray< - PluginInfo & { readonly state: { readonly status: "active" } } & { - readonly source: { readonly type: "package" } | { readonly type: "local" } - } - > - >([]) + const [serverPlugins, setServerPlugins] = createSignal([]) + const serverTuiPlugins = createMemo(() => + serverPlugins().filter( + (plugin): plugin is PluginInfo & { readonly source: { readonly type: "package" } | { readonly type: "local" } } => + plugin.state.status === "active" && + plugin.features.tui === true && + (plugin.source.type === "package" || plugin.source.type === "local"), + ), + ) const directory = config.path ? path.dirname(config.path) : process.cwd() const [store, setStore] = createStore({ ready: false, @@ -257,7 +260,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir install: true, optional: true, })), - ...serverPlugins().map((plugin) => ({ + ...serverTuiPlugins().map((plugin) => ({ entry: plugin.source.type === "package" ? plugin.source.target : path.dirname(plugin.source.path), install: false, optional: true, @@ -473,7 +476,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir const resolved = createMemo(() => resolveSlots({ paths: new Set(Object.keys(mounted)), claims: claims() })) createEffect( on( - () => JSON.stringify([serverPlugins(), config.data.plugins ?? []]), + () => JSON.stringify([serverTuiPlugins(), config.data.plugins ?? []]), () => { npmFailures.clear() void enqueue(reconcile).then( @@ -486,20 +489,26 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir const syncServerPlugins = () => client.api.plugin .list({ location: data.location.default() }) - .then((response) => - setServerPlugins( - response.data.filter( - ( - plugin, - ): plugin is PluginInfo & { readonly state: { readonly status: "active" } } & { - readonly source: { readonly type: "package" } | { readonly type: "local" } - } => - plugin.state.status === "active" && - plugin.features.tui === true && - (plugin.source.type === "package" || plugin.source.type === "local"), - ), - ), - ) + .then((response) => { + const failed = response.data.filter( + (plugin) => + plugin.state.status === "failed" && + !serverPlugins().some( + (previous) => + serverPluginName(previous) === serverPluginName(plugin) && isDeepEqual(previous.state, plugin.state), + ), + ) + setServerPlugins(response.data) + const first = failed[0] + if (!first) return + host.toast.show({ + variant: "error", + title: failed.length === 1 ? `Plugin failed: ${serverPluginName(first)}` : `${failed.length} plugins failed`, + message: + (failed.length > 1 ? `${failed.map(serverPluginName).join(", ")}\n` : "") + "Run /plugins to view details.", + action: { label: "Open plugins", run: () => host.keymap.dispatch("plugins.list") }, + }) + }) .catch(() => undefined) createEffect( on( @@ -538,6 +547,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir value={{ ready: () => store.ready, list: () => store.states, + server: serverPlugins, registered: () => Object.entries(store.registrations).map(([id, plugin]) => ({ id, @@ -558,6 +568,17 @@ export function PluginProvider(props: ParentProps<{ packages: PackageSource; dir ) } +function serverPluginName(plugin: PluginInfo) { + return ( + plugin.id ?? + (plugin.source.type === "package" + ? plugin.source.target + : plugin.source.type === "local" + ? plugin.source.path + : plugin.source.type) + ) +} + async function disposeAll(cleanups: Dispose[]) { const failures: unknown[] = [] for (const cleanup of cleanups.splice(0).reverse()) await cleanup().catch((error) => failures.push(error)) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index f995e48713f..8b8d0df66f4 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -9,6 +9,7 @@ import { createEventStream, createFetch, directory, json, type FetchHandler } fr import { tmpdir } from "./fixture/fixture" import type { TuiInput } from "../src/app" import type { Config } from "../src/config" +import type { PluginInfo } from "@opencode-ai/client" test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => { await using state = await tmpdir() @@ -1420,6 +1421,116 @@ test.each([100, 44])( }, ) +test.each([ + [100, true], + [44, true], + [100, false], + [44, false], +] as const)("server plugin failures are visible at width %s (already failed: %s)", async (width, initial) => { + await using state = await tmpdir() + const failure: PluginInfo["state"] = { + status: "failed", + error: "Plugin disabled after command.transform failed. Check server logs for details.", + ref: "err_fixture", + } + let inventory: PluginInfo[] = [ + { + id: "broken", + source: { type: "builtin" }, + features: { server: true }, + state: initial ? failure : { status: "active" }, + }, + { id: "healthy", source: { type: "builtin" }, features: { server: true }, state: { status: "active" } }, + ] + let requests = 0 + await using setup = await createAppFixture({ + width, + state: state.path, + fetch: (url) => { + if (url.pathname !== "/api/plugin") return undefined + requests++ + return json({ + location: { directory, project: { id: "proj_test", directory, canonical: directory } }, + data: inventory, + }) + }, + }) + await setup.ready + await setup.waitForFrame((frame) => frame.includes("commands")) + if (!initial) { + expect(setup.captureCharFrame()).not.toContain("Plugin failed") + inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin)) + setup.events.emit({ id: "evt_failure", created: 1, type: "plugin.updated", data: {} }) + } + await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken")) + expect(setup.captureCharFrame()).toContain("/plugins") + expect(setup.captureCharFrame()).toContain("1 plugin failed") + + const lines = setup.captureCharFrame().split("\n") + const row = lines.findIndex((line) => line.includes("Open plugins")) + expect(row).toBeGreaterThanOrEqual(0) + const line = lines[row] + if (!line) throw new Error("Open plugins action is missing") + await setup.mockMouse.click(line.indexOf("Open plugins"), row) + await setup.waitForFrame((frame) => frame.includes("ctrl+a") && frame.includes("broken")) + expect(setup.captureCharFrame()).toContain("broken") + expect(setup.captureCharFrame()).not.toContain("healthy") + setup.mockInput.pressEnter() + await setup.waitForFrame((frame) => frame.includes("Server plugin error") && frame.includes("transform failed")) + expect(setup.captureCharFrame()).toContain("Plugin disabled") + expect(setup.captureCharFrame()).toContain("transform failed") + expect(setup.captureCharFrame()).toContain("err_fixture") + setup.mockInput.pressEscape() + await setup.waitForFrame((frame) => frame.includes("ctrl+a")) + setup.mockInput.pressEscape() + await setup.waitForFrame((frame) => !frame.includes("ctrl+a")) + expect(setup.captureCharFrame()).toContain("1 plugin failed") + + const seen = requests + setup.events.emit({ id: "evt_repeat", created: 2, type: "plugin.updated", data: {} }) + setup.events.emit({ id: "evt_reconnect", type: "server.connected", data: {} }) + await setup.waitFor(() => requests >= seen + 2) + await setup.flush() + expect(setup.captureCharFrame()).not.toContain("Plugin failed:") + + inventory = inventory.map((plugin) => ({ ...plugin, state: { status: "active" } })) + setup.events.emit({ id: "evt_recovered", created: 3, type: "plugin.updated", data: {} }) + await setup.waitForFrame((frame) => !frame.includes("1 plugin failed")) + inventory = inventory.map((plugin) => (plugin.id === "broken" ? { ...plugin, state: failure } : plugin)) + setup.events.emit({ id: "evt_failed_again", created: 4, type: "plugin.updated", data: {} }) + await setup.waitForFrame((frame) => frame.includes("Plugin failed:") && frame.includes("broken")) +}) + +test("server plugin failures share one notice and use source names before an ID is known", async () => { + await using state = await tmpdir() + await using setup = await createAppFixture({ + state: state.path, + fetch: (url) => + url.pathname === "/api/plugin" + ? json({ + location: { directory, project: { id: "proj_test", directory, canonical: directory } }, + data: [ + { + source: { type: "package", target: "missing-package" }, + features: {}, + state: { status: "failed", error: "Package missing" }, + }, + { + source: { type: "local", path: "/fixture/broken.ts" }, + features: {}, + state: { status: "failed", error: "Invalid plugin" }, + }, + ], + }) + : undefined, + }) + await setup.ready + await setup.waitForFrame((frame) => frame.includes("2 plugins failed")) + expect(setup.captureCharFrame()).toContain("missing-package") + expect(setup.captureCharFrame()).toContain("/fixture/broken.ts") + expect(setup.captureCharFrame()).toContain("Open plugins") +}) + async function createAppFixture( input: { width?: number