From 4686e8689d7b40e41e9ed5c6576011322e791296 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Thu, 25 Dec 2025 12:51:47 -0300 Subject: [PATCH 1/4] Desktop: MCP UI --- .../app/src/components/dialog-select-mcp.tsx | 115 ++++++++++++++++++ packages/app/src/components/prompt-input.tsx | 2 + .../src/components/session-mcp-indicator.tsx | 36 ++++++ packages/app/src/context/global-sync.tsx | 6 + packages/app/src/pages/session.tsx | 10 ++ 5 files changed, 169 insertions(+) create mode 100644 packages/app/src/components/dialog-select-mcp.tsx create mode 100644 packages/app/src/components/session-mcp-indicator.tsx diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx new file mode 100644 index 00000000000..2dd37f55fcc --- /dev/null +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -0,0 +1,115 @@ +import { Component, createMemo, createSignal, Show } from "solid-js" +import { useSync } from "@/context/sync" +import { useSDK } from "@/context/sdk" +import { Dialog } from "@opencode-ai/ui/dialog" +import { List } from "@opencode-ai/ui/list" +import { Switch } from "@opencode-ai/ui/switch" + +type McpItem = { + name: string + status: string + error?: string + enabled: boolean +} + +export const DialogSelectMcp: Component = () => { + const sync = useSync() + const sdk = useSDK() + const [loading, setLoading] = createSignal(null) + + const items = createMemo(() => { + const mcp = sync.data.mcp ?? {} + console.log("MCP data:", mcp) + return Object.entries(mcp) + .map(([name, status]) => { + console.log(`MCP ${name}:`, status) + return { + name, + status: status.status, + error: status.status === "failed" ? status.error : undefined, + enabled: status.status === "connected", + } + }) + .sort((a, b) => a.name.localeCompare(b.name)) + }) + + const toggle = async (name: string) => { + if (loading()) return + setLoading(name) + try { + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) + } + const result = await sdk.client.mcp.status() + if (result.data) { + sync.set("mcp", result.data) + } + } catch (e) { + console.error("Failed to toggle MCP:", e) + } finally { + setLoading(null) + } + } + + const enabledCount = createMemo(() => items().filter((i) => i.enabled).length) + const totalCount = createMemo(() => items().length) + + return ( + + x?.name ?? ""} + items={items} + filterKeys={["name", "status"]} + sortBy={(a, b) => a.name.localeCompare(b.name)} + onSelect={(x) => { + if (x) toggle(x.name) + }} + > + {(i) => { + const mcpStatus = () => sync.data.mcp[i.name] + const status = () => mcpStatus()?.status + const error = () => { + const s = mcpStatus() + return s?.status === "failed" ? s.error : undefined + } + const enabled = () => status() === "connected" + return ( +
+
+
+ {i.name} + + connected + + + failed + + + needs auth + + + disabled + + + ... + +
+ + {error()} + +
+
e.stopPropagation()}> + toggle(i.name)} /> +
+
+ ) + }} +
+
+ ) +} diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 33e1f48900e..46fdf104621 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -18,6 +18,7 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" +import { SessionMcpIndicator } from "@/components/session-mcp-indicator" import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { persisted } from "@/utils/persist" @@ -1057,6 +1058,7 @@ export const PromptInput: Component = (props) => { +
diff --git a/packages/app/src/components/session-mcp-indicator.tsx b/packages/app/src/components/session-mcp-indicator.tsx new file mode 100644 index 00000000000..17a6f2e1af0 --- /dev/null +++ b/packages/app/src/components/session-mcp-indicator.tsx @@ -0,0 +1,36 @@ +import { createMemo, Show } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { Icon } from "@opencode-ai/ui/icon" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSync } from "@/context/sync" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" + +export function SessionMcpIndicator() { + const sync = useSync() + const dialog = useDialog() + + const mcpStats = createMemo(() => { + const mcp = sync.data.mcp ?? {} + const entries = Object.entries(mcp) + const enabled = entries.filter(([, status]) => status.status === "connected").length + const failed = entries.some(([, status]) => status.status === "failed") + const total = entries.length + return { enabled, failed, total } + }) + + return ( + 0}> + + + ) +} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 10607b1d23f..1538b34493b 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -12,6 +12,7 @@ import { type ProviderListResponse, type ProviderAuthResponse, type Command, + type McpStatus, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -41,6 +42,9 @@ type State = { todo: { [sessionID: string]: Todo[] } + mcp: { + [name: string]: McpStatus + } limit: number message: { [sessionID: string]: Message[] @@ -85,6 +89,7 @@ function createGlobalSync() { session_status: {}, session_diff: {}, todo: {}, + mcp: {}, limit: 5, message: {}, part: {}, @@ -149,6 +154,7 @@ function createGlobalSync() { session: () => loadSessions(directory), status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), + mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})), } await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) .then(() => setStore("ready", true)) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d7eaccc2ad9..baba0ebb00e 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -49,6 +49,7 @@ import { checksum } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" import { DialogSelectModel } from "@/components/dialog-select-model" +import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { useCommand } from "@/context/command" import { useNavigate, useParams } from "@solidjs/router" import { UserMessage } from "@opencode-ai/sdk/v2" @@ -274,6 +275,15 @@ export default function Page() { slash: "model", onSelect: () => dialog.show(() => ), }, + { + id: "mcp.toggle", + title: "Toggle MCPs", + description: "Toggle MCPs", + category: "MCP", + keybind: "mod+;", + slash: "mcp", + onSelect: () => dialog.show(() => ), + }, { id: "agent.cycle", title: "Cycle agent", From ea7db12f10d548f9e0fa0ee6a8042134d99d41e2 Mon Sep 17 00:00:00 2001 From: dbpolito Date: Thu, 25 Dec 2025 12:57:49 -0300 Subject: [PATCH 2/4] Tweak --- .../app/src/components/dialog-select-mcp.tsx | 52 +++++-------------- 1 file changed, 14 insertions(+), 38 deletions(-) diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 2dd37f55fcc..c29cd827e3b 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -5,56 +5,32 @@ import { Dialog } from "@opencode-ai/ui/dialog" import { List } from "@opencode-ai/ui/list" import { Switch } from "@opencode-ai/ui/switch" -type McpItem = { - name: string - status: string - error?: string - enabled: boolean -} - export const DialogSelectMcp: Component = () => { const sync = useSync() const sdk = useSDK() const [loading, setLoading] = createSignal(null) - const items = createMemo(() => { - const mcp = sync.data.mcp ?? {} - console.log("MCP data:", mcp) - return Object.entries(mcp) - .map(([name, status]) => { - console.log(`MCP ${name}:`, status) - return { - name, - status: status.status, - error: status.status === "failed" ? status.error : undefined, - enabled: status.status === "connected", - } - }) - .sort((a, b) => a.name.localeCompare(b.name)) - }) + const items = createMemo(() => + Object.entries(sync.data.mcp ?? {}) + .map(([name, status]) => ({ name, status: status.status })) + .sort((a, b) => a.name.localeCompare(b.name)), + ) const toggle = async (name: string) => { if (loading()) return setLoading(name) - try { - const status = sync.data.mcp[name] - if (status?.status === "connected") { - await sdk.client.mcp.disconnect({ name }) - } else { - await sdk.client.mcp.connect({ name }) - } - const result = await sdk.client.mcp.status() - if (result.data) { - sync.set("mcp", result.data) - } - } catch (e) { - console.error("Failed to toggle MCP:", e) - } finally { - setLoading(null) + const status = sync.data.mcp[name] + if (status?.status === "connected") { + await sdk.client.mcp.disconnect({ name }) + } else { + await sdk.client.mcp.connect({ name }) } + const result = await sdk.client.mcp.status() + if (result.data) sync.set("mcp", result.data) + setLoading(null) } - const enabledCount = createMemo(() => items().filter((i) => i.enabled).length) + const enabledCount = createMemo(() => items().filter((i) => i.status === "connected").length) const totalCount = createMemo(() => items().length) return ( From 78f8ed0fbb5e02d1a825d61f8f401096032d7f0c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 26 Dec 2025 10:40:04 -0600 Subject: [PATCH 3/4] fix(desktop): move mcp status to status bar --- packages/app/src/components/prompt-input.tsx | 2 -- packages/app/src/components/status-bar.tsx | 14 ++++++++++++++ packages/app/src/pages/session.tsx | 5 +++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/components/status-bar.tsx diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 324f9913fa7..03fa02fe35d 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -18,7 +18,6 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectModel } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" -import { SessionMcpIndicator } from "@/components/session-mcp-indicator" import { useProviders } from "@/hooks/use-providers" import { useCommand } from "@/context/command" import { persisted } from "@/utils/persist" @@ -1061,7 +1060,6 @@ export const PromptInput: Component = (props) => { -
+ + v{platform.version} + +
{props.children}
+
+ ) +} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index baba0ebb00e..f6847ea5619 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -57,6 +57,8 @@ import { useSDK } from "@/context/sdk" import { usePrompt } from "@/context/prompt" import { extractPromptFromParts } from "@/utils/prompt" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" +import { StatusBar } from "@/components/status-bar" +import { SessionMcpIndicator } from "@/components/session-mcp-indicator" export default function Page() { const layout = useLayout() @@ -931,6 +933,9 @@ export default function Page() { + + + ) } From baa631f9b2e5d1f485af19c58744044419507fa6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 26 Dec 2025 10:48:49 -0600 Subject: [PATCH 4/4] feat(desktop): add lsp status --- .../src/components/session-lsp-indicator.tsx | 40 +++++++++++++++++++ packages/app/src/context/global-sync.tsx | 4 ++ packages/app/src/pages/session.tsx | 2 + packages/ui/src/components/icon.tsx | 1 + 4 files changed, 47 insertions(+) create mode 100644 packages/app/src/components/session-lsp-indicator.tsx diff --git a/packages/app/src/components/session-lsp-indicator.tsx b/packages/app/src/components/session-lsp-indicator.tsx new file mode 100644 index 00000000000..98d6d6dfd76 --- /dev/null +++ b/packages/app/src/components/session-lsp-indicator.tsx @@ -0,0 +1,40 @@ +import { createMemo, Show } from "solid-js" +import { Icon } from "@opencode-ai/ui/icon" +import { useSync } from "@/context/sync" +import { Tooltip } from "@opencode-ai/ui/tooltip" + +export function SessionLspIndicator() { + const sync = useSync() + + const lspStats = createMemo(() => { + const lsp = sync.data.lsp ?? [] + const connected = lsp.filter((s) => s.status === "connected").length + const hasError = lsp.some((s) => s.status === "error") + const total = lsp.length + return { connected, hasError, total } + }) + + const tooltipContent = createMemo(() => { + const lsp = sync.data.lsp ?? [] + if (lsp.length === 0) return "No LSP servers" + return lsp.map((s) => s.name).join(", ") + }) + + return ( + 0}> + +
+ 0, + }} + /> + {lspStats().connected} LSP +
+
+
+ ) +} diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 1538b34493b..7a9dc8dc425 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -13,6 +13,7 @@ import { type ProviderAuthResponse, type Command, type McpStatus, + type LspStatus, createOpencodeClient, } from "@opencode-ai/sdk/v2/client" import { createStore, produce, reconcile } from "solid-js/store" @@ -45,6 +46,7 @@ type State = { mcp: { [name: string]: McpStatus } + lsp: LspStatus[] limit: number message: { [sessionID: string]: Message[] @@ -90,6 +92,7 @@ function createGlobalSync() { session_diff: {}, todo: {}, mcp: {}, + lsp: [], limit: 5, message: {}, part: {}, @@ -155,6 +158,7 @@ function createGlobalSync() { status: () => sdk.session.status().then((x) => setStore("session_status", x.data!)), config: () => sdk.config.get().then((x) => setStore("config", x.data!)), mcp: () => sdk.mcp.status().then((x) => setStore("mcp", x.data ?? {})), + lsp: () => sdk.lsp.status().then((x) => setStore("lsp", x.data ?? [])), } await Promise.all(Object.values(load).map((p) => retry(p).catch((e) => setGlobalStore("error", e)))) .then(() => setStore("ready", true)) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index f6847ea5619..019cc305c1a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -59,6 +59,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { StatusBar } from "@/components/status-bar" import { SessionMcpIndicator } from "@/components/session-mcp-indicator" +import { SessionLspIndicator } from "@/components/session-lsp-indicator" export default function Page() { const layout = useLayout() @@ -934,6 +935,7 @@ export default function Page() { + diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 45ccee8f9bf..5e1a8e32afc 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -18,6 +18,7 @@ const icons = { console: ``, expand: ``, collapse: ``, + code: ``, "code-lines": ``, "circle-ban-sign": ``, "edit-small-2": ``,