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
parent 7bd3f8ac83
commit b04d8d53e6
6 changed files with 93 additions and 38 deletions
@@ -2427,7 +2427,7 @@ export type ServerMcpListOutput = {
readonly name: string readonly name: string
readonly status: readonly status:
| { readonly status: "connected" } | { readonly status: "connected" }
| { readonly status: "disconnected" } | { readonly status: "pending" }
| { readonly status: "disabled" } | { readonly status: "disabled" }
| { readonly status: "failed"; readonly error: string } | { readonly status: "failed"; readonly error: string }
| { readonly status: "needs_auth" } | { readonly status: "needs_auth" }
+1 -1
View File
@@ -205,7 +205,7 @@ export const layer = Layer.effect(
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) { for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
runtime.set(ServerName.make(name), { runtime.set(ServerName.make(name), {
config: { ...server, timeout: { ...timeout, ...server.timeout } }, config: { ...server, timeout: { ...timeout, ...server.timeout } },
status: { status: "disconnected" }, status: { status: "pending" },
startup: Deferred.makeUnsafe<void>(), startup: Deferred.makeUnsafe<void>(),
}) })
} }
+3 -3
View File
@@ -7,8 +7,8 @@ import { IntegrationID } from "./integration-id.js"
const Connected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({ const Connected = Schema.Struct({ status: Schema.Literal("connected") }).annotate({
identifier: "Mcp.Status.Connected", identifier: "Mcp.Status.Connected",
}) })
const Disconnected = Schema.Struct({ status: Schema.Literal("disconnected") }).annotate({ const Pending = Schema.Struct({ status: Schema.Literal("pending") }).annotate({
identifier: "Mcp.Status.Disconnected", identifier: "Mcp.Status.Pending",
}) })
const Disabled = Schema.Struct({ status: Schema.Literal("disabled") }).annotate({ const Disabled = Schema.Struct({ status: Schema.Literal("disabled") }).annotate({
identifier: "Mcp.Status.Disabled", identifier: "Mcp.Status.Disabled",
@@ -27,7 +27,7 @@ const NeedsClientRegistration = Schema.Struct({
export type Status = typeof Status.Type export type Status = typeof Status.Type
export const Status = Schema.Union([ export const Status = Schema.Union([
Connected, Connected,
Disconnected, Pending,
Disabled, Disabled,
Failed, Failed,
NeedsAuth, NeedsAuth,
+6 -6
View File
@@ -5418,8 +5418,8 @@ export type McpStatusConnected2 = {
status: "connected" status: "connected"
} }
export type McpStatusDisconnected = { export type McpStatusPending = {
status: "disconnected" status: "pending"
} }
export type McpStatusDisabled2 = { export type McpStatusDisabled2 = {
@@ -5444,7 +5444,7 @@ export type McpServer = {
name: string name: string
status: status:
| McpStatusConnected2 | McpStatusConnected2
| McpStatusDisconnected | McpStatusPending
| McpStatusDisabled2 | McpStatusDisabled2
| McpStatusFailed2 | McpStatusFailed2
| McpStatusNeedsAuth2 | McpStatusNeedsAuth2
@@ -9242,8 +9242,8 @@ export type McpStatusConnected3 = {
status: "connected" status: "connected"
} }
export type McpStatusDisconnected2 = { export type McpStatusPending2 = {
status: "disconnected" status: "pending"
} }
export type McpStatusDisabled3 = { export type McpStatusDisabled3 = {
@@ -9268,7 +9268,7 @@ export type McpServer2 = {
name: string name: string
status: status:
| McpStatusConnected3 | McpStatusConnected3
| McpStatusDisconnected2 | McpStatusPending2
| McpStatusDisabled3 | McpStatusDisabled3
| McpStatusFailed3 | McpStatusFailed3
| McpStatusNeedsAuth3 | McpStatusNeedsAuth3
+73 -25
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 { useData } from "../context/data"
import { map, pipe, sortBy } from "remeda" import { pipe, sortBy } from "remeda"
import { DialogSelect, type DialogSelectRef } from "../ui/dialog-select" 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 { TextAttributes } from "@opentui/core"
import type { McpServer } from "@opencode-ai/sdk/v2" import type { McpServer } from "@opencode-ai/sdk/v2"
function Status(props: { status: McpServer["status"] }) { // Sort by how much attention a server needs: auth prompts first, then failures,
const { theme } = useTheme() // then healthy servers, and intentionally-off servers last.
switch (props.status.status) { function statusMeta(status: McpServer["status"], theme: Theme) {
case "connected": switch (status.status) {
return <span style={{ fg: theme.success, attributes: TextAttributes.BOLD }}> Connected</span>
case "failed":
return <span style={{ fg: theme.error }}> {props.status.error}</span>
case "needs_auth": 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": case "needs_client_registration":
return <span style={{ fg: theme.error }}> {props.status.error}</span> return { rank: 1, icon: "✗", label: "Needs registration", color: theme.error, error: status.error, bold: false }
case "disabled": case "failed":
return <span style={{ fg: theme.textMuted }}> Disabled</span> 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: 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() { export function DialogMcp() {
const data = useData() 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 [, setRef] = createSignal<DialogSelectRef<unknown>>()
const options = createMemo(() => onMount(() => {
dialog.setSize("large")
})
const servers = createMemo(() =>
pipe( pipe(
data.location.mcp.list() ?? [], data.location.mcp.list() ?? [],
sortBy((server) => server.name), sortBy(
map((server) => ({ (server) => statusMeta(server.status, theme).rank,
value: server.name, (server) => server.name,
title: server.name, ),
footer: <Status status={server.status} />,
category: undefined,
})),
), ),
) )
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 ( return (
<DialogSelect <DialogSelect
ref={setRef} ref={setRef}
title="MCPs" title="MCPs"
options={options()} options={options()}
onSelect={() => { preserveSelection
// Read-only view: selection does nothing, the dialog closes on escape. 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>
}
/> />
) )
} }
+9 -2
View File
@@ -59,6 +59,8 @@ export interface DialogSelectOption<T = any> {
value: T value: T
description?: string description?: string
details?: string[] details?: string[]
detailsColor?: RGBA
detailsWrap?: boolean
footer?: JSX.Element | string footer?: JSX.Element | string
titleWidth?: number titleWidth?: number
truncateTitle?: boolean | "left" truncateTitle?: boolean | "left"
@@ -697,8 +699,13 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<For each={option.details}> <For each={option.details}>
{(detail) => ( {(detail) => (
<box paddingLeft={3} paddingRight={3}> <box paddingLeft={3} paddingRight={3}>
<text fg={theme.textMuted} wrapMode="none"> <text
{Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))} 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> </text>
</box> </box>
)} )}