Daniel Polito 1 месяц назад
Родитель
Сommit
2333af6ed3

+ 91 - 0
packages/app/src/components/dialog-select-mcp.tsx

@@ -0,0 +1,91 @@
+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"
+
+export const DialogSelectMcp: Component = () => {
+  const sync = useSync()
+  const sdk = useSDK()
+  const [loading, setLoading] = createSignal<string | null>(null)
+
+  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)
+    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.status === "connected").length)
+  const totalCount = createMemo(() => items().length)
+
+  return (
+    <Dialog title="MCPs" description={`${enabledCount()} of ${totalCount()} enabled`}>
+      <List
+        search={{ placeholder: "Search", autofocus: true }}
+        emptyMessage="No MCPs configured"
+        key={(x) => 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 (
+            <div class="w-full flex items-center justify-between gap-x-3">
+              <div class="flex flex-col gap-0.5 min-w-0">
+                <div class="flex items-center gap-2">
+                  <span class="truncate">{i.name}</span>
+                  <Show when={status() === "connected"}>
+                    <span class="text-11-regular text-text-weaker">connected</span>
+                  </Show>
+                  <Show when={status() === "failed"}>
+                    <span class="text-11-regular text-text-weaker">failed</span>
+                  </Show>
+                  <Show when={status() === "needs_auth"}>
+                    <span class="text-11-regular text-text-weaker">needs auth</span>
+                  </Show>
+                  <Show when={status() === "disabled"}>
+                    <span class="text-11-regular text-text-weaker">disabled</span>
+                  </Show>
+                  <Show when={loading() === i.name}>
+                    <span class="text-11-regular text-text-weak">...</span>
+                  </Show>
+                </div>
+                <Show when={error()}>
+                  <span class="text-11-regular text-text-weaker truncate">{error()}</span>
+                </Show>
+              </div>
+              <div onClick={(e) => e.stopPropagation()}>
+                <Switch checked={enabled()} disabled={loading() === i.name} onChange={() => toggle(i.name)} />
+              </div>
+            </div>
+          )
+        }}
+      </List>
+    </Dialog>
+  )
+}

+ 40 - 0
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 (
+    <Show when={lspStats().total > 0}>
+      <Tooltip placement="top" value={tooltipContent()}>
+        <div class="flex items-center gap-1 px-2 cursor-default select-none">
+          <Icon
+            name="code"
+            size="small"
+            classList={{
+              "text-icon-critical-base": lspStats().hasError,
+              "text-icon-success-base": !lspStats().hasError && lspStats().connected > 0,
+            }}
+          />
+          <span class="text-12-regular text-text-weak">{lspStats().connected} LSP</span>
+        </div>
+      </Tooltip>
+    </Show>
+  )
+}

+ 36 - 0
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 (
+    <Show when={mcpStats().total > 0}>
+      <Button variant="ghost" onClick={() => dialog.show(() => <DialogSelectMcp />)}>
+        <Icon
+          name="mcp"
+          size="small"
+          classList={{
+            "text-icon-critical-base": mcpStats().failed,
+            "text-icon-success-base": !mcpStats().failed && mcpStats().enabled > 0,
+          }}
+        />
+        <span class="text-12-regular text-text-weak">{mcpStats().enabled} MCP</span>
+      </Button>
+    </Show>
+  )
+}

+ 14 - 0
packages/app/src/components/status-bar.tsx

@@ -0,0 +1,14 @@
+import { Show, type ParentProps } from "solid-js"
+import { usePlatform } from "@/context/platform"
+
+export function StatusBar(props: ParentProps) {
+  const platform = usePlatform()
+  return (
+    <div class="h-8 w-full shrink-0 flex items-center justify-between px-2 border-t border-border-weak-base bg-background-base">
+      <Show when={platform.version}>
+        <span class="text-12-regular text-text-weak">v{platform.version}</span>
+      </Show>
+      <div class="flex items-center">{props.children}</div>
+    </div>
+  )
+}

+ 10 - 0
packages/app/src/context/global-sync.tsx

@@ -12,6 +12,8 @@ import {
   type ProviderListResponse,
   type ProviderAuthResponse,
   type Command,
+  type McpStatus,
+  type LspStatus,
   createOpencodeClient,
 } from "@opencode-ai/sdk/v2/client"
 import { createStore, produce, reconcile } from "solid-js/store"
@@ -41,6 +43,10 @@ type State = {
   todo: {
     [sessionID: string]: Todo[]
   }
+  mcp: {
+    [name: string]: McpStatus
+  }
+  lsp: LspStatus[]
   limit: number
   message: {
     [sessionID: string]: Message[]
@@ -85,6 +91,8 @@ function createGlobalSync() {
         session_status: {},
         session_diff: {},
         todo: {},
+        mcp: {},
+        lsp: [],
         limit: 5,
         message: {},
         part: {},
@@ -149,6 +157,8 @@ 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 ?? {})),
+      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))

+ 17 - 0
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"
@@ -56,6 +57,9 @@ 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"
+import { SessionLspIndicator } from "@/components/session-lsp-indicator"
 
 export default function Page() {
   const layout = useLayout()
@@ -274,6 +278,15 @@ export default function Page() {
       slash: "model",
       onSelect: () => dialog.show(() => <DialogSelectModel />),
     },
+    {
+      id: "mcp.toggle",
+      title: "Toggle MCPs",
+      description: "Toggle MCPs",
+      category: "MCP",
+      keybind: "mod+;",
+      slash: "mcp",
+      onSelect: () => dialog.show(() => <DialogSelectMcp />),
+    },
     {
       id: "agent.cycle",
       title: "Cycle agent",
@@ -921,6 +934,10 @@ export default function Page() {
           </DragDropProvider>
         </div>
       </Show>
+      <StatusBar>
+        <SessionLspIndicator />
+        <SessionMcpIndicator />
+      </StatusBar>
     </div>
   )
 }

+ 1 - 0
packages/ui/src/components/icon.tsx

@@ -18,6 +18,7 @@ const icons = {
   console: `<path d="M3.75 5.4165L8.33333 9.99984L3.75 14.5832M10.4167 14.5832H16.25" stroke="currentColor" stroke-linecap="square"/>`,
   expand: `<path d="M4.58301 10.4163V15.4163H9.58301M10.4163 4.58301H15.4163V9.58301" stroke="currentColor" stroke-linecap="square"/>`,
   collapse: `<path d="M16.666 8.33398H11.666V3.33398" stroke="currentColor" stroke-linecap="square"/><path d="M8.33398 16.666V11.666H3.33398" stroke="currentColor" stroke-linecap="square"/>`,
+  code: `<path d="M8.7513 7.5013L6.2513 10.0013L8.7513 12.5013M11.2513 7.5013L13.7513 10.0013L11.2513 12.5013M2.91797 2.91797H17.0846V17.0846H2.91797V2.91797Z" stroke="currentColor"/>`,
   "code-lines": `<path d="M2.08325 3.75H11.2499M14.5833 3.75H17.9166M2.08325 10L7.08325 10M10.4166 10L17.9166 10M2.08325 16.25L8.74992 16.25M12.0833 16.25L17.9166 16.25" stroke="currentColor" stroke-linecap="square" stroke-linejoin="round"/>`,
   "circle-ban-sign": `<path d="M15.3675 4.63087L4.55742 15.441M17.9163 9.9987C17.9163 14.371 14.3719 17.9154 9.99967 17.9154C7.81355 17.9154 5.83438 17.0293 4.40175 15.5966C2.96911 14.164 2.08301 12.1848 2.08301 9.9987C2.08301 5.62644 5.62742 2.08203 9.99967 2.08203C12.1858 2.08203 14.165 2.96813 15.5976 4.40077C17.0302 5.8334 17.9163 7.81257 17.9163 9.9987Z" stroke="currentColor" stroke-linecap="round"/>`,
   "edit-small-2": `<path d="M17.0834 17.0833V17.5833H17.5834V17.0833H17.0834ZM2.91675 17.0833H2.41675V17.5833H2.91675V17.0833ZM2.91675 2.91659V2.41659H2.41675V2.91659H2.91675ZM9.58341 3.41659H10.0834V2.41659H9.58341V2.91659V3.41659ZM17.5834 10.4166V9.91659H16.5834V10.4166H17.0834H17.5834ZM10.4167 7.08325L10.0632 6.7297L9.91675 6.87615V7.08325H10.4167ZM10.4167 9.58325H9.91675V10.0833H10.4167V9.58325ZM12.9167 9.58325V10.0833H13.1239L13.2703 9.93681L12.9167 9.58325ZM15.4167 2.08325L15.7703 1.7297L15.4167 1.37615L15.0632 1.7297L15.4167 2.08325ZM17.9167 4.58325L18.2703 4.93681L18.6239 4.58325L18.2703 4.2297L17.9167 4.58325ZM17.0834 17.0833V16.5833H2.91675V17.0833V17.5833H17.0834V17.0833ZM2.91675 17.0833H3.41675V2.91659H2.91675H2.41675V17.0833H2.91675ZM2.91675 2.91659V3.41659H9.58341V2.91659V2.41659H2.91675V2.91659ZM17.0834 10.4166H16.5834V17.0833H17.0834H17.5834V10.4166H17.0834ZM10.4167 7.08325H9.91675V9.58325H10.4167H10.9167V7.08325H10.4167ZM10.4167 9.58325V10.0833H12.9167V9.58325V9.08325H10.4167V9.58325ZM10.4167 7.08325L10.7703 7.43681L15.7703 2.43681L15.4167 2.08325L15.0632 1.7297L10.0632 6.7297L10.4167 7.08325ZM15.4167 2.08325L15.0632 2.43681L17.5632 4.93681L17.9167 4.58325L18.2703 4.2297L15.7703 1.7297L15.4167 2.08325ZM17.9167 4.58325L17.5632 4.2297L12.5632 9.2297L12.9167 9.58325L13.2703 9.93681L18.2703 4.93681L17.9167 4.58325Z" fill="currentColor"/>`,