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 })
+ })
+})