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

@ -2427,7 +2427,7 @@ export type ServerMcpListOutput = {
readonly name: string
readonly status:
| { readonly status: "connected" }
| { readonly status: "disconnected" }
| { readonly status: "pending" }
| { readonly status: "disabled" }
| { readonly status: "failed"; readonly error: string }
| { readonly status: "needs_auth" }

View file

@ -205,7 +205,7 @@ export const layer = Layer.effect(
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
runtime.set(ServerName.make(name), {
config: { ...server, timeout: { ...timeout, ...server.timeout } },
status: { status: "disconnected" },
status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(),
})
}

View file

@ -7,8 +7,8 @@ import { IntegrationID } from "./integration-id.js"
const Connected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
identifier: "Mcp.Status.Connected",
})
const Disconnected = Schema.Struct({ status: Schema.Literal("disconnected") }).annotate({
identifier: "Mcp.Status.Disconnected",
const Pending = Schema.Struct({ status: Schema.Literal("pending") }).annotate({
identifier: "Mcp.Status.Pending",
})
const Disabled = Schema.Struct({ status: Schema.Literal("disabled") }).annotate({
identifier: "Mcp.Status.Disabled",
@ -27,7 +27,7 @@ const NeedsClientRegistration = Schema.Struct({
export type Status = typeof Status.Type
export const Status = Schema.Union([
Connected,
Disconnected,
Pending,
Disabled,
Failed,
NeedsAuth,

View file

@ -5418,8 +5418,8 @@ export type McpStatusConnected2 = {
status: "connected"
}
export type McpStatusDisconnected = {
status: "disconnected"
export type McpStatusPending = {
status: "pending"
}
export type McpStatusDisabled2 = {
@ -5444,7 +5444,7 @@ export type McpServer = {
name: string
status:
| McpStatusConnected2
| McpStatusDisconnected
| McpStatusPending
| McpStatusDisabled2
| McpStatusFailed2
| McpStatusNeedsAuth2
@ -9242,8 +9242,8 @@ export type McpStatusConnected3 = {
status: "connected"
}
export type McpStatusDisconnected2 = {
status: "disconnected"
export type McpStatusPending2 = {
status: "pending"
}
export type McpStatusDisabled3 = {
@ -9268,7 +9268,7 @@ export type McpServer2 = {
name: string
status:
| McpStatusConnected3
| McpStatusDisconnected2
| McpStatusPending2
| McpStatusDisabled3
| McpStatusFailed3
| McpStatusNeedsAuth3

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

View file

@ -59,6 +59,8 @@ export interface DialogSelectOption<T = any> {
value: T
description?: string
details?: string[]
detailsColor?: RGBA
detailsWrap?: boolean
footer?: JSX.Element | string
titleWidth?: number
truncateTitle?: boolean | "left"
@ -697,8 +699,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<For each={option.details}>
{(detail) => (
<box paddingLeft={3} paddingRight={3}>
<text fg={theme.textMuted} wrapMode="none">
{Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
<text
fg={option.detailsColor ?? theme.textMuted}
wrapMode={option.detailsWrap ? "word" : "none"}
>
{option.detailsWrap
? detail
: Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))}
</text>
</box>
)}