From b04d8d53e6c915e96302d19c6c3651f0f2c15e2d Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:19:16 -0500 Subject: [PATCH] fix(tui): expand MCP server errors in dialog (#35243) --- .../client/src/promise/generated/types.ts | 2 +- packages/core/src/mcp/index.ts | 2 +- packages/schema/src/mcp.ts | 6 +- packages/sdk/js/src/v2/gen/types.gen.ts | 12 +-- packages/tui/src/component/dialog-mcp.tsx | 98 ++++++++++++++----- packages/tui/src/ui/dialog-select.tsx | 11 ++- 6 files changed, 93 insertions(+), 38 deletions(-) diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 6d1db75b5e..e0372cd57e 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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" } diff --git a/packages/core/src/mcp/index.ts b/packages/core/src/mcp/index.ts index d4935dc512..ffc7a247fd 100644 --- a/packages/core/src/mcp/index.ts +++ b/packages/core/src/mcp/index.ts @@ -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(), }) } diff --git a/packages/schema/src/mcp.ts b/packages/schema/src/mcp.ts index 421b5aee95..4a7ce15618 100644 --- a/packages/schema/src/mcp.ts +++ b/packages/schema/src/mcp.ts @@ -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, diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 27ca0f00cd..c693369281 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -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 diff --git a/packages/tui/src/component/dialog-mcp.tsx b/packages/tui/src/component/dialog-mcp.tsx index f5f82f2202..004e4a6e15 100644 --- a/packages/tui/src/component/dialog-mcp.tsx +++ b/packages/tui/src/component/dialog-mcp.tsx @@ -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 ✓ Connected - case "failed": - return ✗ {props.status.error} +// 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 ! Needs authentication + return { rank: 0, icon: "!", label: "Needs authentication", color: theme.warning, error: undefined, bold: false } case "needs_client_registration": - return ✗ {props.status.error} - case "disabled": - return ○ Disabled + 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 ○ Disconnected + 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>({}) + const [focused, setFocused] = createSignal() const [, setRef] = createSignal>() - 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: , - 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: ( + + {meta.icon} {meta.label} + + ), + 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 ( { - // 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={ + + enter to {expanded[focused()!] ? "hide" : "view"} error + + } /> ) } diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index dab9103d24..28235531e2 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -59,6 +59,8 @@ export interface DialogSelectOption { 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(props: DialogSelectProps) { {(detail) => ( - - {Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))} + + {option.detailsWrap + ? detail + : Locale.truncateMiddle(detail, Math.max(1, Math.min(76, dimensions().width - 12)))} )}