208 lines
7.1 KiB
TypeScript
208 lines
7.1 KiB
TypeScript
import { createEffect, createMemo, createSignal, onMount, Show } from "solid-js"
|
|
import { useData } from "../context/data"
|
|
import { useClient } from "../context/client"
|
|
import { Keymap } from "../context/keymap"
|
|
import { pipe, sortBy } from "remeda"
|
|
import { DialogSelect } from "../ui/dialog-select"
|
|
import { useDialog } from "../ui/dialog"
|
|
import { useTheme } from "../context/theme"
|
|
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
|
|
import type { McpServer } from "@opencode-ai/client"
|
|
import { useClipboard } from "../context/clipboard"
|
|
import { useToast } from "../ui/toast"
|
|
import { useKeyboard, useTerminalDimensions } from "@opentui/solid"
|
|
import { useConfig } from "../config"
|
|
import { getScrollAcceleration } from "../util/scroll"
|
|
|
|
function statusError(status: McpServer["status"]) {
|
|
if (status.status === "failed" || status.status === "needs_client_registration") return status.error
|
|
return undefined
|
|
}
|
|
|
|
function Status(props: { enabled: boolean; loading: boolean }) {
|
|
const theme = useTheme("elevated")
|
|
if (props.loading) return <span style={{ fg: theme.text.subdued }}>⋯ Loading</span>
|
|
if (props.enabled) {
|
|
return <span style={{ fg: theme.text.feedback.success.default, attributes: TextAttributes.BOLD }}>✓ Enabled</span>
|
|
}
|
|
return <span style={{ fg: theme.text.subdued }}>○ Disabled</span>
|
|
}
|
|
|
|
export function DialogMcp() {
|
|
const data = useData()
|
|
const dialog = useDialog()
|
|
const client = useClient()
|
|
const toast = useToast()
|
|
const theme = useTheme("elevated")
|
|
const [focused, setFocused] = createSignal<string>()
|
|
const [detail, setDetail] = createSignal<McpServer>()
|
|
const [loading, setLoading] = createSignal<string | null>(null)
|
|
|
|
const servers = createMemo(() =>
|
|
pipe(
|
|
data.location.mcp.server.list() ?? [],
|
|
sortBy((server) => server.name),
|
|
),
|
|
)
|
|
|
|
createEffect(() => {
|
|
if (focused()) return
|
|
const first = servers()[0]
|
|
if (first) setFocused(first.name)
|
|
})
|
|
|
|
const options = createMemo(() => {
|
|
const loadingMcp = loading()
|
|
return servers().map((server) => ({
|
|
value: server.name,
|
|
title: server.name,
|
|
description: server.status.status,
|
|
footer: <Status enabled={server.status.status === "connected"} loading={loadingMcp === server.name} />,
|
|
}))
|
|
})
|
|
|
|
const focusedError = createMemo(() => {
|
|
const name = focused()
|
|
const server = servers().find((entry) => entry.name === name)
|
|
return server ? statusError(server.status) : undefined
|
|
})
|
|
|
|
const open = (name: string | undefined) => {
|
|
const server = servers().find((entry) => entry.name === name)
|
|
if (!server || !statusError(server.status)) return
|
|
setDetail(server)
|
|
}
|
|
|
|
// Connected servers disconnect; everything else (disabled, failed, needs_auth) retries a
|
|
// connection. The mcp.status.changed event refreshes the list, so no manual sync is needed.
|
|
const toggle = (name: string) => {
|
|
if (loading() !== null) return
|
|
const server = servers().find((entry) => entry.name === name)
|
|
if (!server || server.status.status === "pending") return
|
|
setLoading(name)
|
|
const current = data.location.default()
|
|
const input = { server: name, location: { directory: current.directory, workspace: current.workspaceID } }
|
|
const call = server.status.status === "connected" ? client.api.mcp.disconnect(input) : client.api.mcp.connect(input)
|
|
void call.catch(toast.error).finally(() => setLoading(null))
|
|
}
|
|
|
|
return (
|
|
<box>
|
|
<Show
|
|
when={detail()}
|
|
fallback={
|
|
<DialogSelect
|
|
title="MCP servers"
|
|
options={options()}
|
|
current={focused()}
|
|
preserveSelection
|
|
onMove={(option) => setFocused(option.value as string)}
|
|
onSelect={(option) => open(option.value as string)}
|
|
actions={[
|
|
{
|
|
title: "toggle",
|
|
command: "dialog.mcp.toggle",
|
|
onTrigger: (option) => {
|
|
setFocused(option.value as string)
|
|
toggle(option.value as string)
|
|
},
|
|
},
|
|
]}
|
|
footer={
|
|
<Show when={focusedError()}>
|
|
<text fg={theme.text.subdued}>enter to view error</text>
|
|
</Show>
|
|
}
|
|
/>
|
|
}
|
|
>
|
|
{(server) => (
|
|
<DialogMcpError
|
|
server={server()}
|
|
onBack={() => {
|
|
setDetail()
|
|
dialog.setSize("medium")
|
|
}}
|
|
/>
|
|
)}
|
|
</Show>
|
|
</box>
|
|
)
|
|
}
|
|
|
|
function DialogMcpError(props: { server: McpServer; onBack: () => void }) {
|
|
const dialog = useDialog()
|
|
const clipboard = useClipboard()
|
|
const toast = useToast()
|
|
const theme = useTheme("elevated")
|
|
const overlayTheme = useTheme("overlay")
|
|
const dimensions = useTerminalDimensions()
|
|
const config = useConfig().data
|
|
const [copied, setCopied] = createSignal(false)
|
|
const error = () => statusError(props.server.status) ?? "Unknown MCP connection error"
|
|
const height = createMemo(() => Math.max(3, Math.floor(dimensions().height / 2) - 5))
|
|
let scroll: ScrollBoxRenderable | undefined
|
|
|
|
onMount(() => dialog.setSize("large"))
|
|
|
|
const copy = () => {
|
|
if (!clipboard.write) return
|
|
void clipboard
|
|
.write(error())
|
|
.then(() => setCopied(true))
|
|
.catch(toast.error)
|
|
}
|
|
|
|
Keymap.createLayer(() => ({
|
|
mode: "modal",
|
|
commands: [{ bind: "escape", title: "Back to MCP servers", group: "Dialog", run: props.onBack }],
|
|
}))
|
|
|
|
useKeyboard((event) => {
|
|
if (event.name === "c") return copy()
|
|
if (event.name === "up") return scroll?.scrollBy(-1)
|
|
if (event.name === "down") return scroll?.scrollBy(1)
|
|
if (event.name === "pageup") return scroll?.scrollBy(-height())
|
|
if (event.name === "pagedown") return scroll?.scrollBy(height())
|
|
if (event.name === "home") return scroll?.scrollTo(0)
|
|
if (event.name === "end" && scroll) return scroll.scrollTo(scroll.scrollHeight)
|
|
})
|
|
|
|
return (
|
|
<box paddingLeft={4} paddingRight={4} paddingBottom={1} gap={1}>
|
|
<box flexDirection="row" justifyContent="space-between">
|
|
<text attributes={TextAttributes.BOLD} fg={theme.text.default}>
|
|
MCP server: {props.server.name}
|
|
</text>
|
|
<text fg={theme.text.subdued} onMouseUp={props.onBack}>
|
|
esc back
|
|
</text>
|
|
</box>
|
|
<text fg={theme.text.feedback.error.default}>✗ Failed</text>
|
|
<box
|
|
backgroundColor={overlayTheme.background.default}
|
|
paddingLeft={2}
|
|
paddingRight={2}
|
|
paddingTop={1}
|
|
paddingBottom={1}
|
|
>
|
|
<scrollbox
|
|
ref={(element: ScrollBoxRenderable) => (scroll = element)}
|
|
height={height()}
|
|
scrollbarOptions={{ visible: false }}
|
|
scrollAcceleration={getScrollAcceleration(config)}
|
|
>
|
|
<text fg={overlayTheme.text.default} wrapMode="word">
|
|
{error()}
|
|
</text>
|
|
</scrollbox>
|
|
</box>
|
|
<box flexDirection="row" justifyContent="space-between">
|
|
<text fg={theme.text.subdued}>↑↓ scroll</text>
|
|
<text fg={theme.text.subdued} onMouseUp={copy}>
|
|
{copied() ? "✓ copied" : "c copy details"}
|
|
</text>
|
|
</box>
|
|
</box>
|
|
)
|
|
}
|