From d480f5c449ee83c392e4cedf2d67ea2b6bbc2073 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 11 Aug 2026 23:32:49 -0400 Subject: [PATCH] feat(tui): surface plugin failures --- packages/tui/src/app.tsx | 2 +- .../src/component/dialog-error-details.tsx | 85 +++++++++++++ packages/tui/src/component/dialog-mcp.tsx | 90 +------------- .../tui/src/feature-plugins/home/footer.tsx | 51 +++++++- .../src/feature-plugins/system/plugins.tsx | 116 +++++++++++++++--- packages/tui/src/plugin/context.tsx | 11 +- .../test/feature-plugins/home-footer.test.ts | 13 ++ 7 files changed, 258 insertions(+), 110 deletions(-) create mode 100644 packages/tui/src/component/dialog-error-details.tsx create mode 100644 packages/tui/test/feature-plugins/home-footer.test.ts diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index c5a299b94b..93bd6ca73d 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -499,7 +499,7 @@ function App(props: { pair?: DialogPairCredentials }) { toast.show({ variant: "error", title: `MCP server failed: ${server.name}`, - message: "Open MCP servers to view details.", + message: "Run /mcps to view details.", }) } }) diff --git a/packages/tui/src/component/dialog-error-details.tsx b/packages/tui/src/component/dialog-error-details.tsx new file mode 100644 index 0000000000..0dde159474 --- /dev/null +++ b/packages/tui/src/component/dialog-error-details.tsx @@ -0,0 +1,85 @@ +import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" +import { useKeyboard, useTerminalDimensions } from "@opentui/solid" +import { createMemo, createSignal, onMount } from "solid-js" +import { useConfig } from "../config" +import { useClipboard } from "../context/clipboard" +import { Keymap } from "../context/keymap" +import { getScrollAcceleration } from "../util/scroll" +import { useDialog } from "../ui/dialog" +import { useTheme } from "../context/theme" +import { useToast } from "../ui/toast" + +export function DialogErrorDetails(props: { title: string; error: string; onBack: () => void }) { + const dialog = useDialog() + const clipboard = useClipboard() + const toast = useToast() + const theme = useTheme("elevated") + const overlayTheme = useTheme("overlay") + const dimensions = useTerminalDimensions() + const config = useConfig().data + const [copied, setCopied] = createSignal(false) + const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5)) + let scroll: ScrollBoxRenderable | undefined + + onMount(() => dialog.setSize("large")) + + const copy = () => { + void clipboard + .write(props.error) + .then(() => setCopied(true)) + .catch(toast.error) + } + + Keymap.createLayer(() => ({ + mode: "modal", + commands: [{ bind: "escape", title: "Back", group: "Dialog", run: props.onBack }], + })) + + useKeyboard((event) => { + if (event.name === "c") return copy() + if (event.name === "up") return scroll?.scrollBy(-1) + if (event.name === "down") return scroll?.scrollBy(1) + if (event.name === "pageup") return scroll?.scrollBy(-height()) + if (event.name === "pagedown") return scroll?.scrollBy(height()) + if (event.name === "home") return scroll?.scrollTo(0) + if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight) + }) + + return ( + + + + {props.title} + + + esc back + + + ✗ Failed + + (scroll = element)} + height={height()} + scrollbarOptions={{ visible: false }} + scrollAcceleration={getScrollAcceleration(config)} + > + + {props.error} + + + + + ↑↓ scroll + + {copied() ? "✓ copied" : "c copy details"} + + + + ) +} diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index a2999a081e..fe2e333181 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js" +import { createEffect, createMemo, createSignal, Show } from "solid-js" import { useData } from "../context/data" import { useClient } from "../context/client" import { Keymap } from "../context/keymap" @@ -6,13 +6,10 @@ import { pipe, sortBy } from "remeda" import { DialogSelect } from "../ui/dialog-select" import { useDialog } from "../ui/dialog" import { useTheme } from "../context/theme" -import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core" +import { TextAttributes } from "@opentui/core" import type { McpServer } from "@opencode-ai/client" -import { useClipboard } from "../context/clipboard" import { useToast } from "../ui/toast" -import { useKeyboard, useTerminalDimensions } from "@opentui/solid" -import { useConfig } from "../config" -import { getScrollAcceleration } from "../util/scroll" +import { DialogErrorDetails } from "./dialog-error-details" function statusError(status: McpServer["status"]) { if (status.status === "failed") return status.error @@ -143,8 +140,9 @@ export function DialogMcp() { } > {(server) => ( - { setDetail() dialog.setSize("medium") @@ -155,79 +153,3 @@ export function DialogMcp() { ) } - -function DialogMcpError(props: { server: McpServer; onBack: () => void }) { - const dialog = useDialog() - const clipboard = useClipboard() - const toast = useToast() - const theme = useTheme("elevated") - const overlayTheme = useTheme("overlay") - const dimensions = useTerminalDimensions() - const config = useConfig().data - const [copied, setCopied] = createSignal(false) - const error = () => statusError(props.server.status) ?? "Unknown MCP connection error" - const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5)) - let scroll: ScrollBoxRenderable | undefined - - onMount(() => dialog.setSize("large")) - - const copy = () => { - void clipboard - .write(error()) - .then(() => setCopied(true)) - .catch(toast.error) - } - - Keymap.createLayer(() => ({ - mode: "modal", - commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }], - })) - - useKeyboard((event) => { - if (event.name === "c") return copy() - if (event.name === "up") return scroll?.scrollBy(-1) - if (event.name === "down") return scroll?.scrollBy(1) - if (event.name === "pageup") return scroll?.scrollBy(-height()) - if (event.name === "pagedown") return scroll?.scrollBy(height()) - if (event.name === "home") return scroll?.scrollTo(0) - if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight) - }) - - return ( - - - - MCP server: {props.server.name} - - - esc back - - - ✗ Failed - - (scroll = element)} - height={height()} - scrollbarOptions={{ visible: false }} - scrollAcceleration={getScrollAcceleration(config)} - > - - {error()} - - - - - ↑↓ scroll - - {copied() ? "✓ copied" : "c copy details"} - - - - ) -} diff --git a/packages/tui/src/feature-plugins/home/footer.tsx b/packages/tui/src/feature-plugins/home/footer.tsx index 0c540692ee..ad09b01e8b 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,10 +1,21 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { createMemo, Match, Show, Switch } from "solid-js" import { useTerminalDimensions } from "@opentui/solid" +import { usePlugin } from "../../plugin/context" + +export function homeFooterVisibility(width: number) { + return { + mcpCommand: width >= 64, + pluginCommand: width >= 80, + version: width >= 64, + } +} function Mcp(props: { context: Plugin.Context }) { + const dimensions = useTerminalDimensions() + const visibility = createMemo(() => homeFooterVisibility(dimensions().width)) const list = createMemo(() => props.context.data.location.mcp.server.list(props.context.location) ?? []) - const failed = createMemo(() => list().some((item) => item.status.status === "failed")) + const failed = createMemo(() => list().filter((item) => item.status.status === "failed").length) const count = createMemo(() => list().filter((item) => item.status.status === "connected").length) return ( @@ -14,6 +25,7 @@ function Mcp(props: { context: Plugin.Context }) { + {failed()} MCP failed ⊙{" "} + {count()} MCP - {count()} MCP - /status + + /mcps + + + + ) +} + +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) + + return ( + + + + + {failed()} plugin{failed() === 1 ? "" : "s"} failed + + + /plugins + ) @@ -36,6 +71,7 @@ function Mcp(props: { context: Plugin.Context }) { function View(props: { context: Plugin.Context }) { const dimensions = useTerminalDimensions() + const visibility = createMemo(() => homeFooterVisibility(dimensions().width)) return ( = 12 && dimensions().width >= 44}> @@ -50,10 +86,13 @@ function View(props: { context: Plugin.Context }) { gap={2} > + - - {props.context.app.version} - + + + {props.context.app.version} + + ) diff --git a/packages/tui/src/feature-plugins/system/plugins.tsx b/packages/tui/src/feature-plugins/system/plugins.tsx index 676ee3e475..a539f8a0e7 100644 --- a/packages/tui/src/feature-plugins/system/plugins.tsx +++ b/packages/tui/src/feature-plugins/system/plugins.tsx @@ -1,22 +1,26 @@ import { Plugin } from "@opencode-ai/plugin/tui" -import { createMemo, createSignal } from "solid-js" +import { createEffect, createMemo, createSignal, Show } from "solid-js" import { usePlugin } from "../../plugin/context" import { DialogSelect, type DialogSelectOption } from "../../ui/dialog-select" +import { useDialog } from "../../ui/dialog" +import { DialogErrorDetails } from "../../component/dialog-error-details" const id = "opencode.plugins" function View(props: { context: Plugin.Context; plugins: ReturnType }) { const [locked, setLocked] = createSignal(false) - const options = createMemo(() => - props.plugins + const [focused, setFocused] = createSignal() + const [detail, setDetail] = createSignal<{ title: string; error: string }>() + const dialog = useDialog() + const options = createMemo(() => { + const builtins = props.plugins .registered() - .filter((plugin) => plugin.id !== id) - .sort((a, b) => a.id.localeCompare(b.id)) + .filter((plugin) => plugin.id !== id && plugin.source === "builtin") .map( (plugin): DialogSelectOption => ({ title: plugin.id, value: plugin.id, - category: plugin.source === "builtin" ? "Built-in" : "External", + category: "Built-in", footer: ( ), }), - ), - ) + ) + const external = props.plugins + .list() + .filter((plugin) => plugin.status !== "unsupported") + .map( + (plugin): DialogSelectOption => ({ + title: plugin.id ?? plugin.target, + value: plugin.id ?? plugin.target, + category: "External", + searchText: plugin.target, + footer: ( + + {plugin.status} + + ), + }), + ) + return [...builtins, ...external].sort((a, b) => a.title.localeCompare(b.title)) + }) + + const failure = (value: string | undefined) => + props.plugins.list().find((plugin) => { + if (plugin.status !== "failed") return false + return (plugin.id ?? plugin.target) === value + }) + + createEffect(() => { + if (focused()) return + const first = options()[0] + if (first) setFocused(first.value) + }) const toggle = (plugin: DialogSelectOption) => { if (locked()) return @@ -51,15 +93,56 @@ function View(props: { context: Plugin.Context; plugins: ReturnType setLocked(false)) } + const select = (plugin: DialogSelectOption) => { + const failed = failure(plugin.value) + if (!failed || failed.status !== "failed") return toggle(plugin) + setDetail({ title: failed.target, error: failed.error }) + } + return ( - + + setFocused(option.value)} + actions={[ + { + title: "toggle", + command: "plugins.toggle", + disabled: (option) => { + const failed = failure(option?.value) + return Boolean(failed && !("id" in failed && failed.id)) + }, + onTrigger: toggle, + }, + ]} + onSelect={select} + footer={ + + enter to view error + + } + /> + } + > + {(item) => ( + { + setDetail() + dialog.setSize("medium") + }} + /> + )} + + ) } @@ -72,6 +155,7 @@ function Commands(props: { context: Plugin.Context }) { id: "plugins.list", title: "Plugins", group: "System", + slash: { name: "plugins" }, palette: true, run() { props.context.ui.dialog.show(() => ) diff --git a/packages/tui/src/plugin/context.tsx b/packages/tui/src/plugin/context.tsx index a8053f4a25..ee96a4e8ba 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -34,7 +34,7 @@ export interface PackageResolver { type State = | { readonly target: string; readonly id: string; readonly status: "active" | "inactive" } | { readonly target: string; readonly status: "unsupported" } - | { readonly target: string; readonly status: "failed"; readonly error: string } + | { readonly target: string; readonly id?: string; readonly status: "failed"; readonly error: string } type RegisteredPlugin = { readonly id: string @@ -271,6 +271,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d if (!local && !previous) npmFailures.set(target, resolved.error) failures.push({ target, + id: previous?.plugin.id, status: "failed", error: previous?.active ? `${resolved.error} (previous version still active)` : resolved.error, }) @@ -376,7 +377,7 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d // A failed reload keeps this item running; the failure entry covers it. if (failedTargets.has(item.target)) return [] const error = errors.get(item.plugin.id) - if (error) return [{ target: item.target, status: "failed", error }] + if (error) return [{ target: item.target, id: item.plugin.id, status: "failed", error }] const status = store.registrations[item.plugin.id]?.active ? "active" : "inactive" return [{ target: item.target, id: item.plugin.id, status }] }), @@ -390,7 +391,11 @@ export function PluginProvider(props: ParentProps<{ packages: PackageResolver; d (prev) => prev.status === "failed" && prev.target === state.target && prev.error === state.error, ) ) - host.toast.show({ variant: "error", title: "Plugin", message: `${state.target}: ${state.error}` }) + host.toast.show({ + variant: "error", + title: `Plugin failed: ${state.target}`, + message: "Run /plugins to view details.", + }) setStore("states", reconcileStore(states)) } const slotItems = new WeakMap>() diff --git a/packages/tui/test/feature-plugins/home-footer.test.ts b/packages/tui/test/feature-plugins/home-footer.test.ts new file mode 100644 index 0000000000..d2c6544d40 --- /dev/null +++ b/packages/tui/test/feature-plugins/home-footer.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, test } from "bun:test" +import { homeFooterVisibility } from "../../src/feature-plugins/home/footer" + +describe("home footer visibility", () => { + test("keeps failure labels readable at the minimum supported width", () => { + expect(homeFooterVisibility(44)).toEqual({ mcpCommand: false, pluginCommand: false, version: false }) + }) + + test("adds secondary hints as space becomes available", () => { + expect(homeFooterVisibility(64)).toEqual({ mcpCommand: true, pluginCommand: false, version: true }) + expect(homeFooterVisibility(80)).toEqual({ mcpCommand: true, pluginCommand: true, version: true }) + }) +})