fix(tui): expand MCP server errors in dialog (#35243)

This commit is contained in:
Aiden Cline 2026-07-03 18:19:16 -05:00 committed by GitHub
commit b04d8d53e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 92 additions and 37 deletions

View file

@ -1,54 +1,102 @@
import { createMemo, createSignal } from "solid-js"
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useData } from "../context/data"
import { map, pipe, sortBy } from "remeda"
import { pipe, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select"
import { useTheme } from "../context/theme"
import { useDialog } from "../ui/dialog"
import { useTheme, type Theme } from "../context/theme"
import { TextAttributes } from "@opentui/core"
import type { McpServer } from "@opencode-ai/sdk/v2"
function Status(props: { status: McpServer["status"] }) {
const { theme } = useTheme()
switch (props.status.status) {
case "connected":
return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}> Connected</span>
case "failed":
return <span style={{ fg: theme.error }}> {props.status.error}</span>
// Sort by how much attention a server needs: auth prompts first, then failures,
// then healthy servers, and intentionally-off servers last.
function statusMeta(status: McpServer["status"], theme: Theme) {
switch (status.status) {
case "needs_auth":
return <span style={{ fg: theme.warning }}>! Needs authentication</span>
return { rank: 0, icon: "!", label: "Needs authentication", color: theme.warning, error: undefined, bold: false }
case "needs_client_registration":
return <span style={{ fg: theme.error }}> {props.status.error}</span>
case "disabled":
return <span style={{ fg: theme.textMuted }}> Disabled</span>
return { rank: 1, icon: "✗", label: "Needs registration", color: theme.error, error: status.error, bold: false }
case "failed":
return { rank: 2, icon: "✗", label: "Failed", color: theme.error, error: status.error, bold: false }
case "connected":
return { rank: 3, icon: "✓", label: "Connected", color: theme.success, error: undefined, bold: true }
case "pending":
return { rank: 4, icon: "◌", label: "Pending", color: theme.textMuted, error: undefined, bold: false }
default:
return <span style={{ fg: theme.textMuted }}> Disconnected</span>
return { rank: 5, icon: "○", label: "Disabled", color: theme.textMuted, error: undefined, bold: false }
}
}
export function DialogMcp() {
const data = useData()
const dialog = useDialog()
const { theme } = useTheme()
const [expanded, setExpanded] = createStore<Record<string, boolean>>({})
const [focused, setFocused] = createSignal<string>()
const [, setRef] = createSignal<DialogSelectRef<unknown>>()
const options = createMemo(() =>
onMount(() => {
dialog.setSize("large")
})
const servers = createMemo(() =>
pipe(
data.location.mcp.list() ?? [],
sortBy((server) => server.name),
map((server) => ({
value: server.name,
title: server.name,
footer: <Status status={server.status} />,
category: undefined,
})),
sortBy(
(server) => statusMeta(server.status, theme).rank,
(server) => server.name,
),
),
)
createEffect(() => {
if (focused()) return
const first = servers()[0]
if (first) setFocused(first.name)
})
const options = createMemo(() =>
servers().map((server) => {
const meta = statusMeta(server.status, theme)
return {
value: server.name,
title: server.name,
footer: (
<span style={{ fg: meta.color, attributes: meta.bold ? TextAttributes.BOLD : undefined }}>
{meta.icon} {meta.label}
</span>
),
details: meta.error && expanded[server.name] ? [meta.error] : undefined,
detailsColor: theme.error,
detailsWrap: true,
}
}),
)
const focusedError = createMemo(() => {
const name = focused()
const server = servers().find((entry) => entry.name === name)
return server ? statusMeta(server.status, theme).error : undefined
})
return (
<DialogSelect
ref={setRef}
title="MCPs"
options={options()}
onSelect={() => {
// Read-only view: selection does nothing, the dialog closes on escape.
preserveSelection
onMove={(option) => setFocused(option.value as string)}
onSelect={(option) => {
const name = option.value as string
const server = servers().find((entry) => entry.name === name)
if (!server || !statusMeta(server.status, theme).error) return
setExpanded(name, (open) => !open)
}}
footer={
<Show when={focusedError()}>
<text fg={theme.textMuted}>enter to {expanded[focused()!] ? "hide" : "view"} error</text>
</Show>
}
/>
)
}