fix(tui): expand MCP server errors in dialog (#35243)
This commit is contained in:
parent
7bd3f8ac83
commit
b04d8d53e6
6 changed files with 92 additions and 37 deletions
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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>(),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue