From f6333546f8397cb51bde011fe97e948e2b6b8daa Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:10:01 +0530 Subject: [PATCH] fix(tui): disambiguate plugin actions (#48354) Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com> --- packages/tui/src/config/keybind.ts | 1 + .../src/feature-plugins/system/plugins.tsx | 11 ++- .../tui/test/cli/tui/plugins-dialog.test.tsx | 92 +++++++++++++++---- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/packages/tui/src/config/keybind.ts b/packages/tui/src/config/keybind.ts index 53a5e92af04..94f5daaa06e 100644 --- a/packages/tui/src/config/keybind.ts +++ b/packages/tui/src/config/keybind.ts @@ -274,6 +274,7 @@ export const Definitions = { "permission.prompt.fullscreen": keybind("ctrl+f", "Toggle permission prompt fullscreen"), "plugins.toggle": keybind("return", "Toggle plugin"), "dialog.mcp.toggle": keybind("space", "Toggle MCP server"), + "dialog.plugins.error": keybind("space", "View plugin error"), "dialog.plugins.install": keybind("shift+i", "Install plugin from plugin dialog"), "dialog.plugins.update": keybind("ctrl+u", "Update plugin from plugin dialog"), "dialog.plugins.check": keybind("ctrl+r", "Check for plugin updates from plugin dialog"), diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index b26582e7bb0..93aee7cc66e 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -223,6 +223,15 @@ export function PluginsDialog(props: { disabled: checking(), onTrigger: check, }, + { + title: "view error", + command: "dialog.plugins.error", + hidden: !pluginError(focusedTui()), + onTrigger: (option) => { + const entry = entries().find((entry) => entry.key === option.value) + if (pluginError(entry)) setDetail(entry) + }, + }, { title: toggleTitle(), command: "plugins.toggle", @@ -239,7 +248,7 @@ export function PluginsDialog(props: { }, ]} footer={ - + enter diff --git a/packages/tui/test/cli/tui/plugins-dialog.test.tsx b/packages/tui/test/cli/tui/plugins-dialog.test.tsx index 9d9b552f642..a1eac94a9af 100644 --- a/packages/tui/test/cli/tui/plugins-dialog.test.tsx +++ b/packages/tui/test/cli/tui/plugins-dialog.test.tsx @@ -13,6 +13,10 @@ import { ThemeProvider, useThemes } from "../../../src/context/theme" // the context back, so the context must load first exactly as it does in the app. import type { usePlugin } from "../../../src/plugin/context" import "../../../src/plugin/context" +import { ClientProvider } from "../../../src/context/client" +import { DataProvider } from "../../../src/context/data" +import { LocationProvider } from "../../../src/context/location" +import { RouteProvider } from "../../../src/context/route" import { PluginsDialog } from "../../../src/feature-plugins/system/plugins" import { DialogProvider } from "../../../src/ui/dialog" import { ToastProvider } from "../../../src/ui/toast" @@ -32,11 +36,19 @@ function packagePlugin(outdated: boolean): PluginInfo { } } -async function renderPlugins(root: string, inventory: { list: PluginInfo[]; check: PluginInfo[] }) { +async function renderPlugins( + root: string, + inventory: { list: PluginInfo[]; check: PluginInfo[] }, + tui?: { + registered: { id: string; source: "builtin" | "external"; active: boolean }[] + list: { target: string; id?: string; status: "active" | "inactive" | "failed"; error?: string }[] + }, +) { const state = path.join(root, "state") await mkdir(state, { recursive: true }) const requests: { path: string; body: unknown }[] = [] const toasts: ToastOptions[] = [] + const activations: string[] = [] const location = { directory: root, project: { id: "proj_test", directory: root, canonical: root } } const transport = createFetch(async (url, request) => { if (url.pathname === "/api/plugin") return json({ location, data: inventory.list }) @@ -49,13 +61,14 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec return new Response(null, { status: 204 }) } }) + const api = createApi(transport.fetch) function Harness() { function Content() { onCleanup(Keymap.use().mode.push("modal")) const theme = useThemes().currentTokens() const context = { - client: createApi(transport.fetch), + client: api, data: { location: { default: () => ({ directory: root }) }, on: () => () => {} }, get theme() { return theme @@ -66,9 +79,12 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec }, } as unknown as Context const plugins = { - registered: () => [], - list: () => [], - activate: async () => true, + registered: () => tui?.registered ?? [], + list: () => tui?.list ?? [], + activate: async (id: string) => { + activations.push(id) + return true + }, deactivate: async () => true, } as unknown as ReturnType return @@ -77,15 +93,23 @@ async function renderPlugins(root: string, inventory: { list: PluginInfo[]; chec return ( - - - - - - - - - + + + + + + + + + + + + + + + + + ) @@ -93,10 +117,46 @@ 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("team.plugins") || frame.includes("local.plugin")) - return { app, requests, toasts } + const expected = tui?.list[0]?.id ?? inventory.list[0]?.id ?? "local.plugin" + await app.waitForFrame((frame) => frame.includes(expected)) + return { app, requests, toasts, activations } } +test("failed TUI plugins keep enter to enable and use space to show the error", async () => { + await using tmp = await tmpdir() + const fixture = await renderPlugins( + tmp.path, + { list: [], check: [] }, + { + registered: [{ id: "broken.plugin", source: "external", active: false }], + list: [ + { + target: "./broken.ts", + id: "broken.plugin", + status: "failed", + error: "Plugin setup failed", + }, + ], + }, + ) + + try { + await fixture.app.waitForFrame((frame) => frame.includes("broken.plugin") && frame.includes("view error")) + expect(fixture.app.captureCharFrame()).toContain("enable") + + fixture.app.mockInput.pressEnter() + await fixture.app.waitFor(() => fixture.activations.length === 1) + expect(fixture.activations).toEqual(["broken.plugin"]) + + fixture.app.mockInput.pressKey(" ") + await fixture.app.waitForFrame( + (frame) => frame.includes("TUI plugin error") && frame.includes("Plugin setup failed"), + ) + } finally { + fixture.app.renderer.destroy() + } +}) + test("checking for updates refreshes the inventory and reveals the update action", async () => { await using tmp = await tmpdir() const fixture = await renderPlugins(tmp.path, { list: [packagePlugin(false)], check: [packagePlugin(true)] })