fix(tui): expand MCP server errors in dialog (#35243)
This commit is contained in:
@@ -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" }
|
||||||
|
|||||||
@@ -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>(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user