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>()