fix(tui): improve MCP error details (#35263)
This commit is contained in:
@@ -409,8 +409,8 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi
|
|||||||
else
|
else
|
||||||
toast.show({
|
toast.show({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: "MCP server failed to connect",
|
title: `MCP server failed: ${server.name}`,
|
||||||
message: `${server.name}: ${status.error}`,
|
message: "Open MCPs to view details.",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
|
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { useData } from "../context/data"
|
import { useData } from "../context/data"
|
||||||
import { pipe, sortBy } from "remeda"
|
import { pipe, sortBy } from "remeda"
|
||||||
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
|
import { DialogSelect } from "../ui/dialog-select"
|
||||||
import { useDialog } from "../ui/dialog"
|
import { useDialog } from "../ui/dialog"
|
||||||
import { useTheme, type Theme } from "../context/theme"
|
import { useTheme, type Theme } from "../context/theme"
|
||||||
import { TextAttributes } from "@opentui/core"
|
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
||||||
import type { McpServer } from "@opencode-ai/sdk/v2"
|
import type { McpServer } from "@opencode-ai/sdk/v2"
|
||||||
|
import { useClipboard } from "../context/clipboard"
|
||||||
|
import { useToast } from "../ui/toast"
|
||||||
|
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
||||||
|
import { useTuiConfig } from "../config"
|
||||||
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
|
import { useBindings } from "../keymap"
|
||||||
|
|
||||||
// Sort by how much attention a server needs: auth prompts first, then failures,
|
// Sort by how much attention a server needs: auth prompts first, then failures,
|
||||||
// then healthy servers, and intentionally-off servers last.
|
// then healthy servers, and intentionally-off servers last.
|
||||||
@@ -31,9 +36,8 @@ export function DialogMcp() {
|
|||||||
const data = useData()
|
const data = useData()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const { theme } = useTheme()
|
const { theme } = useTheme()
|
||||||
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
|
|
||||||
const [focused, setFocused] = createSignal<string>()
|
const [focused, setFocused] = createSignal<string>()
|
||||||
const [, setRef] = createSignal<DialogSelectRef<unknown>>()
|
const [detail, setDetail] = createSignal<McpServer>()
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
dialog.setSize("large")
|
dialog.setSize("large")
|
||||||
@@ -66,9 +70,6 @@ export function DialogMcp() {
|
|||||||
{meta.icon} {meta.label}
|
{meta.icon} {meta.label}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
details: meta.error && expanded[server.name] ? [meta.error] : undefined,
|
|
||||||
detailsColor: theme.error,
|
|
||||||
detailsWrap: true,
|
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -79,24 +80,103 @@ export function DialogMcp() {
|
|||||||
return server ? statusMeta(server.status, theme).error : undefined
|
return server ? statusMeta(server.status, theme).error : undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const open = (name: string | undefined) => {
|
||||||
|
const server = servers().find((entry) => entry.name === name)
|
||||||
|
if (!server || !statusMeta(server.status, theme).error) return
|
||||||
|
setDetail(server)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DialogSelect
|
<box>
|
||||||
ref={setRef}
|
<Show
|
||||||
title="MCPs"
|
when={detail()}
|
||||||
options={options()}
|
fallback={
|
||||||
preserveSelection
|
<DialogSelect
|
||||||
onMove={(option) => setFocused(option.value as string)}
|
title="MCPs"
|
||||||
onSelect={(option) => {
|
options={options()}
|
||||||
const name = option.value as string
|
current={focused()}
|
||||||
const server = servers().find((entry) => entry.name === name)
|
preserveSelection
|
||||||
if (!server || !statusMeta(server.status, theme).error) return
|
onMove={(option) => setFocused(option.value as string)}
|
||||||
setExpanded(name, (open) => !open)
|
onSelect={(option) => open(option.value as string)}
|
||||||
}}
|
footer={
|
||||||
footer={
|
<Show when={focusedError()}>
|
||||||
<Show when={focusedError()}>
|
<text fg={theme.textMuted}>enter to view error</text>
|
||||||
<text fg={theme.textMuted}>enter to {expanded[focused()!] ? "hide" : "view"} error</text>
|
</Show>
|
||||||
</Show>
|
}
|
||||||
}
|
/>
|
||||||
/>
|
}
|
||||||
|
>
|
||||||
|
{(server) => <DialogMcpError server={server()} onBack={() => setDetail()} />}
|
||||||
|
</Show>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
||||||
|
const dialog = useDialog()
|
||||||
|
const clipboard = useClipboard()
|
||||||
|
const toast = useToast()
|
||||||
|
const { theme } = useTheme()
|
||||||
|
const dimensions = useTerminalDimensions()
|
||||||
|
const tuiConfig = useTuiConfig()
|
||||||
|
const [copied, setCopied] = createSignal(false)
|
||||||
|
const error = () => statusMeta(props.server.status, theme).error ?? "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 = () => {
|
||||||
|
if (!clipboard.write) return
|
||||||
|
void clipboard
|
||||||
|
.write(error())
|
||||||
|
.then(() => setCopied(true))
|
||||||
|
.catch(toast.error)
|
||||||
|
}
|
||||||
|
|
||||||
|
useBindings(() => ({
|
||||||
|
bindings: [{ key: "escape", desc: "Back to MCP servers", group: "Dialog", cmd: 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 (
|
||||||
|
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||||
|
MCP / {props.server.name}
|
||||||
|
</text>
|
||||||
|
<text fg={theme.textMuted} onMouseUp={props.onBack}>
|
||||||
|
esc back
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
<text fg={theme.error}>✗ Failed</text>
|
||||||
|
<box backgroundColor={theme.backgroundElement} paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1}>
|
||||||
|
<scrollbox
|
||||||
|
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
||||||
|
height={height()}
|
||||||
|
scrollbarOptions={{ visible: false }}
|
||||||
|
scrollAcceleration={getScrollAcceleration(tuiConfig)}
|
||||||
|
>
|
||||||
|
<text fg={theme.text} wrapMode="word">
|
||||||
|
{error()}
|
||||||
|
</text>
|
||||||
|
</scrollbox>
|
||||||
|
</box>
|
||||||
|
<box flexDirection="row" justifyContent="space-between">
|
||||||
|
<text fg={theme.textMuted}>↑↓ scroll</text>
|
||||||
|
<text fg={theme.textMuted} onMouseUp={copy}>
|
||||||
|
{copied() ? "✓ copied" : "c copy details"}
|
||||||
|
</text>
|
||||||
|
</box>
|
||||||
|
</box>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user