diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 377ebb598b..f09a94cb38 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -496,7 +496,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..a81995bb24 100644 --- a/packages/tui/src/feature-plugins/home/footer.tsx +++ b/packages/tui/src/feature-plugins/home/footer.tsx @@ -1,10 +1,11 @@ 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" function Mcp(props: { context: Plugin.Context }) { 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 +15,7 @@ function Mcp(props: { context: Plugin.Context }) { + {failed()} MCP failed ⊙{" "} + {count()} MCP - {count()} MCP - /status + /mcps + + + ) +} + +function Plugins(props: { context: Plugin.Context }) { + const plugins = usePlugin() + const failed = createMemo(() => plugins.list().filter((item) => item.status === "failed").length) + + return ( + + + + + {failed()} plugin{failed() === 1 ? "" : "s"} failed + + /plugins ) @@ -50,6 +69,7 @@ function View(props: { context: Plugin.Context }) { gap={2} > + {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..5a950e7dd7 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().map( + (plugin): DialogSelectOption => ({ + title: "id" in plugin ? plugin.id : plugin.target, + value: "id" in plugin ? 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 ("id" in plugin ? 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 +90,53 @@ 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) => Boolean(failure(option?.value)), + onTrigger: toggle, + }, + ]} + onSelect={select} + footer={ + + enter to view error + + } + /> + } + > + {(item) => ( + { + setDetail() + dialog.setSize("medium") + }} + /> + )} + + ) } @@ -72,6 +149,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..3ea3e23a5f 100644 --- a/packages/tui/src/plugin/context.tsx +++ b/packages/tui/src/plugin/context.tsx @@ -390,7 +390,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>()