@mohou/panel 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +8 -0
  2. package/lib/types/chrome/hue.d.ts +3 -0
  3. package/lib/types/chrome/hue.d.ts.map +1 -0
  4. package/lib/types/gallery/card-style.d.ts +8 -0
  5. package/lib/types/gallery/card-style.d.ts.map +1 -0
  6. package/lib/types/gallery/client.d.ts +20 -0
  7. package/lib/types/gallery/client.d.ts.map +1 -0
  8. package/lib/types/gallery/desk.d.ts +16 -0
  9. package/lib/types/gallery/desk.d.ts.map +1 -0
  10. package/lib/types/gallery/list.d.ts +37 -0
  11. package/lib/types/gallery/list.d.ts.map +1 -0
  12. package/lib/types/gallery/sort.d.ts +24 -0
  13. package/lib/types/gallery/sort.d.ts.map +1 -0
  14. package/lib/types/gallery/state.d.ts +90 -0
  15. package/lib/types/gallery/state.d.ts.map +1 -0
  16. package/lib/types/gallery/tabs.d.ts +45 -0
  17. package/lib/types/gallery/tabs.d.ts.map +1 -0
  18. package/lib/types/gallery/view.d.ts +57 -0
  19. package/lib/types/gallery/view.d.ts.map +1 -0
  20. package/lib/types/history/client.d.ts +25 -0
  21. package/lib/types/history/client.d.ts.map +1 -0
  22. package/lib/types/history/state.d.ts +39 -0
  23. package/lib/types/history/state.d.ts.map +1 -0
  24. package/lib/types/history/view.d.ts +15 -0
  25. package/lib/types/history/view.d.ts.map +1 -0
  26. package/lib/types/index.d.ts +24 -0
  27. package/lib/types/index.d.ts.map +1 -0
  28. package/lib/types/labels.d.ts +267 -0
  29. package/lib/types/labels.d.ts.map +1 -0
  30. package/lib/types/settings/author-mcp-view.d.ts +15 -0
  31. package/lib/types/settings/author-mcp-view.d.ts.map +1 -0
  32. package/lib/types/settings/author-mcp.d.ts +4 -0
  33. package/lib/types/settings/author-mcp.d.ts.map +1 -0
  34. package/lib/types/settings/client.d.ts +198 -0
  35. package/lib/types/settings/client.d.ts.map +1 -0
  36. package/lib/types/settings/credentials-view.d.ts +15 -0
  37. package/lib/types/settings/credentials-view.d.ts.map +1 -0
  38. package/lib/types/settings/form.d.ts +27 -0
  39. package/lib/types/settings/form.d.ts.map +1 -0
  40. package/lib/types/settings/install-row.d.ts +10 -0
  41. package/lib/types/settings/install-row.d.ts.map +1 -0
  42. package/lib/types/settings/mcp-view.d.ts +14 -0
  43. package/lib/types/settings/mcp-view.d.ts.map +1 -0
  44. package/lib/types/settings/skill-dirs.d.ts +6 -0
  45. package/lib/types/settings/skill-dirs.d.ts.map +1 -0
  46. package/lib/types/settings/skill-install.d.ts +13 -0
  47. package/lib/types/settings/skill-install.d.ts.map +1 -0
  48. package/lib/types/settings/state.d.ts +94 -0
  49. package/lib/types/settings/state.d.ts.map +1 -0
  50. package/lib/types/settings/update-dialog.d.ts +56 -0
  51. package/lib/types/settings/update-dialog.d.ts.map +1 -0
  52. package/lib/types/settings/view.d.ts +26 -0
  53. package/lib/types/settings/view.d.ts.map +1 -0
  54. package/lib/types/storage/client.d.ts +15 -0
  55. package/lib/types/storage/client.d.ts.map +1 -0
  56. package/lib/types/storage/state.d.ts +42 -0
  57. package/lib/types/storage/state.d.ts.map +1 -0
  58. package/lib/types/storage/view.d.ts +16 -0
  59. package/lib/types/storage/view.d.ts.map +1 -0
  60. package/lib/types/surface/state.d.ts +42 -0
  61. package/lib/types/surface/state.d.ts.map +1 -0
  62. package/lib/types/surface/view.d.ts +34 -0
  63. package/lib/types/surface/view.d.ts.map +1 -0
  64. package/lib/types/theme/client.d.ts +45 -0
  65. package/lib/types/theme/client.d.ts.map +1 -0
  66. package/lib/types/theme/menu.d.ts +25 -0
  67. package/lib/types/theme/menu.d.ts.map +1 -0
  68. package/lib/types/theme/state.d.ts +46 -0
  69. package/lib/types/theme/state.d.ts.map +1 -0
  70. package/lib/types/theme/view.d.ts +16 -0
  71. package/lib/types/theme/view.d.ts.map +1 -0
  72. package/lib/types/ui/dialog.d.ts +12 -0
  73. package/lib/types/ui/dialog.d.ts.map +1 -0
  74. package/lib/types/ui/tooltip.d.ts +12 -0
  75. package/lib/types/ui/tooltip.d.ts.map +1 -0
  76. package/package.json +38 -0
  77. package/src/chrome/hue.ts +6 -0
  78. package/src/gallery/card-style.ts +27 -0
  79. package/src/gallery/client.ts +26 -0
  80. package/src/gallery/desk.tsx +54 -0
  81. package/src/gallery/list.ts +54 -0
  82. package/src/gallery/sort.ts +72 -0
  83. package/src/gallery/state.ts +229 -0
  84. package/src/gallery/tabs.ts +85 -0
  85. package/src/gallery/view.tsx +501 -0
  86. package/src/history/client.ts +27 -0
  87. package/src/history/state.ts +90 -0
  88. package/src/history/view.tsx +131 -0
  89. package/src/index.ts +61 -0
  90. package/src/labels.ts +536 -0
  91. package/src/settings/author-mcp-view.tsx +154 -0
  92. package/src/settings/author-mcp.ts +24 -0
  93. package/src/settings/client.ts +205 -0
  94. package/src/settings/credentials-view.tsx +195 -0
  95. package/src/settings/form.ts +27 -0
  96. package/src/settings/install-row.tsx +35 -0
  97. package/src/settings/mcp-view.tsx +793 -0
  98. package/src/settings/skill-dirs.ts +41 -0
  99. package/src/settings/skill-install.tsx +134 -0
  100. package/src/settings/state.ts +225 -0
  101. package/src/settings/update-dialog.tsx +207 -0
  102. package/src/settings/view.tsx +722 -0
  103. package/src/storage/client.ts +16 -0
  104. package/src/storage/state.ts +94 -0
  105. package/src/storage/view.tsx +190 -0
  106. package/src/surface/state.ts +72 -0
  107. package/src/surface/view.tsx +255 -0
  108. package/src/theme/client.ts +34 -0
  109. package/src/theme/menu.tsx +286 -0
  110. package/src/theme/state.ts +113 -0
  111. package/src/theme/view.tsx +73 -0
  112. package/src/ui/dialog.tsx +25 -0
  113. package/src/ui/tooltip.tsx +19 -0
@@ -0,0 +1,793 @@
1
+ import { Settings } from 'lucide-react'
2
+ import { useEffect, useRef, useState, type ReactNode } from 'react'
3
+
4
+ import { panelText, type PanelLabelMode, type PanelLocale } from '../labels.ts'
5
+ import { Dialog } from '../ui/dialog.tsx'
6
+ import type { McpCheckResult, McpServerDraft, PanelMcpFailure, PanelSettingsClient } from './client.ts'
7
+
8
+ type Transport = 'stdio' | 'sse' | 'http'
9
+ type CheckMap = Readonly<Record<string, McpCheckResult>>
10
+
11
+ interface Draft {
12
+ readonly id: string
13
+ readonly description: string
14
+ readonly transport: Transport
15
+ readonly command: string
16
+ readonly args: string
17
+ readonly url: string
18
+ readonly env: string
19
+ readonly headers: string
20
+ readonly editing: string | undefined
21
+ }
22
+
23
+ const emptyDraft: Draft = {
24
+ id: '',
25
+ description: '',
26
+ transport: 'stdio',
27
+ command: '',
28
+ args: '',
29
+ url: '',
30
+ env: '',
31
+ headers: '',
32
+ editing: undefined,
33
+ }
34
+
35
+ /**
36
+ * MCP settings. Cards show status and tools. Add, edit, paste, and import open a dialog.
37
+ * A connection check does not write the file. Import writes only the servers the person keeps.
38
+ * @param props - settings client and chrome locale
39
+ */
40
+ export function McpSettings(props: {
41
+ readonly client: PanelSettingsClient
42
+ readonly locale: PanelLocale
43
+ readonly mode: PanelLabelMode
44
+ }): ReactNode {
45
+ const label = (key: string) => panelText(props.locale, key, props.mode)
46
+ const [servers, setServers] = useState<McpServerDraft[]>([])
47
+ const [unresolved, setUnresolved] = useState<readonly PanelMcpFailure[]>([])
48
+ const [draft, setDraft] = useState<Draft | undefined>(undefined)
49
+ const [paste, setPaste] = useState(false)
50
+ const [candidates, setCandidates] = useState<readonly McpServerDraft[] | undefined>(undefined)
51
+ const [queue, setQueue] = useState<readonly McpServerDraft[]>([])
52
+ const [removing, setRemoving] = useState<string | undefined>(undefined)
53
+ const [checks, setChecks] = useState<CheckMap>({})
54
+ const [busy, setBusy] = useState('')
55
+ const [notice, setNotice] = useState('')
56
+ const [loadFailed, setLoadFailed] = useState(false)
57
+ const [alert, setAlert] = useState<'import' | 'save' | undefined>(undefined)
58
+ const loaded = useRef(false)
59
+ useEffect(() => {
60
+ const list = props.client.listMcp
61
+ if (list === undefined || loaded.current) return
62
+ loaded.current = true
63
+ void list().then(
64
+ (listed) => {
65
+ setServers([...listed.servers])
66
+ setUnresolved([...listed.unresolved])
67
+ },
68
+ () => setLoadFailed(true),
69
+ )
70
+ }, [props.client.listMcp])
71
+ if (props.client.listMcp === undefined) return <p className="text-sm text-muted-foreground">{label('mcp-empty')}</p>
72
+ const write = (next: readonly McpServerDraft[]) => commit(props.client, next, setServers, setUnresolved, setAlert, setNotice, label)
73
+ return (
74
+ <div className="flex flex-col gap-4">
75
+ <div className="flex items-start justify-between gap-3">
76
+ <div>
77
+ <h4 className="m-0">{label('section-mcp')}</h4>
78
+ {servers.length === 0 ? <p className="mt-1 text-xs text-muted-foreground">{label('mcp-empty')}</p> : null}
79
+ {unresolved.length === 0 ? null : <p className="mt-1 text-xs text-destructive">{label('mcp-unresolved-hint')}</p>}
80
+ </div>
81
+ <div className="flex shrink-0 flex-wrap justify-end gap-2">
82
+ <button type="button" className="h-8 rounded-lg border bg-card px-3 text-sm" onClick={() => setPaste(true)}>{label('mcp-paste')}</button>
83
+ <button type="button" className="h-8 rounded-lg border bg-card px-3 text-sm" onClick={() => { void pullSource(props.client, 'pi', setCandidates, setAlert) }}>{label('mcp-from-pi')}</button>
84
+ <label className="inline-flex h-8 cursor-pointer items-center rounded-lg border bg-card px-3 text-sm">
85
+ {label('mcp-from-file')}
86
+ <input type="file" accept="application/json,.json" className="sr-only" onChange={(event) => { void readChosen(event.target.files?.[0], props.client, setCandidates, setAlert) }} />
87
+ </label>
88
+ <button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={() => setDraft(emptyDraft)}>{label('mcp-add')}</button>
89
+ </div>
90
+ </div>
91
+ <ul className="grid grid-cols-1 gap-2 min-[720px]:grid-cols-2">
92
+ {servers.map(server => (
93
+ <ServerCard
94
+ key={server.id}
95
+ server={server}
96
+ failure={unresolved.find(item => item.id === server.id)}
97
+ check={checks[server.id]}
98
+ busy={busy === server.id}
99
+ label={label}
100
+ onEdit={() => setDraft(toDraft(server))}
101
+ onRemove={() => setRemoving(server.id)}
102
+ onToggle={() => {
103
+ const next = servers.map(item => item.id === server.id ? { ...item, enabled: item.enabled === false } : item)
104
+ void write(next)
105
+ }}
106
+ onCheck={() => { void runCheck(props.client, server, setChecks, setBusy, setAlert) }}
107
+ />
108
+ ))}
109
+ </ul>
110
+ {loadFailed ? <p className="text-sm text-muted-foreground">{label('mcp-load-failed')}</p> : null}
111
+ {notice.length > 0 ? <p className="text-xs text-muted-foreground">{notice}</p> : null}
112
+ {draft === undefined ? null : (
113
+ <EditorDialog
114
+ draft={draft}
115
+ label={label}
116
+ onChange={setDraft}
117
+ onCancel={() => {
118
+ setDraft(undefined)
119
+ setQueue([])
120
+ }}
121
+ onSubmit={() => {
122
+ void saveDraft(props.client, servers, draft, setServers, setUnresolved, setAlert, setNotice, label).then((ok) => {
123
+ if (!ok) return
124
+ const next = queue[0]
125
+ setQueue(queue.slice(1))
126
+ setDraft(next === undefined ? undefined : openImported(next, servers))
127
+ })
128
+ }}
129
+ {...props.client.checkMcp === undefined ? {} : { onCheck: props.client.checkMcp }}
130
+ />
131
+ )}
132
+ {paste ? (
133
+ <PasteDialog
134
+ label={label}
135
+ onCancel={() => setPaste(false)}
136
+ onPick={(server) => {
137
+ setPaste(false)
138
+ setDraft(openImported(server, servers))
139
+ }}
140
+ {...props.client.admitMcp === undefined ? {} : { admit: props.client.admitMcp }}
141
+ />
142
+ ) : null}
143
+ {candidates === undefined ? null : (
144
+ <PickDialog
145
+ servers={candidates}
146
+ label={label}
147
+ onCancel={() => setCandidates(undefined)}
148
+ onConfirm={(picked) => {
149
+ if (picked.length === 0) return
150
+ setCandidates(undefined)
151
+ setQueue(picked.slice(1))
152
+ setDraft(openImported(picked[0]!, servers))
153
+ }}
154
+ />
155
+ )}
156
+ {alert === undefined ? null : (
157
+ <Dialog width="sm" onClose={() => setAlert(undefined)}>
158
+ <p className="m-0 text-sm">{label(alert === 'import' ? 'mcp-import-none' : 'save-failed')}</p>
159
+ <div className="mt-4 flex justify-end">
160
+ <button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={() => setAlert(undefined)}>{label('confirm')}</button>
161
+ </div>
162
+ </Dialog>
163
+ )}
164
+ {removing === undefined ? null : (
165
+ <ConfirmDialog
166
+ label={label}
167
+ title={label('delete-confirm')}
168
+ onCancel={() => setRemoving(undefined)}
169
+ onConfirm={() => { void write(servers.filter(item => item.id !== removing)).then((ok) => { if (ok) setRemoving(undefined) }) }}
170
+ />
171
+ )}
172
+ </div>
173
+ )
174
+ }
175
+
176
+ function ServerCard(props: {
177
+ readonly server: McpServerDraft
178
+ readonly check: McpCheckResult | undefined
179
+ /** Set when the last boot left this server out, so the card says why instead of looking live. */
180
+ readonly failure: PanelMcpFailure | undefined
181
+ readonly busy: boolean
182
+ readonly label: (key: string) => string
183
+ readonly onEdit: () => void
184
+ readonly onRemove: () => void
185
+ readonly onToggle: () => void
186
+ readonly onCheck: () => void
187
+ }): ReactNode {
188
+ const enabled = props.server.enabled !== false
189
+ const running = enabled && props.check?.ok === true
190
+ const kind = transportOf(props.server)
191
+ const tools = props.check?.ok === true ? props.check.tools : []
192
+ const shown = tools.slice(0, 4)
193
+ const extra = tools.length - shown.length
194
+ const [openTool, setOpenTool] = useState<string | undefined>(undefined)
195
+ return (
196
+ <li className="flex flex-col gap-2 rounded-2xl border bg-card px-3 py-3">
197
+ <div className="flex items-center justify-between gap-2">
198
+ <span className="min-w-0 truncate text-sm font-semibold">{props.server.id}</span>
199
+ <span className="flex shrink-0 items-center gap-1.5">
200
+ <Switch on={enabled} label={props.label('section-mcp')} onClick={props.onToggle} />
201
+ <button type="button" className="inline-flex size-8 items-center justify-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground" aria-label={props.label('mcp-edit')} onClick={props.onEdit}>
202
+ <Settings size={16} strokeWidth={2} />
203
+ </button>
204
+ </span>
205
+ </div>
206
+ <div className="flex flex-wrap items-center gap-1.5">
207
+ <Status running={running} label={props.label(running ? 'mcp-running' : 'mcp-stopped')} />
208
+ <span className="inline-flex h-6 items-center rounded-full bg-muted px-2 text-[11px] leading-none text-muted-foreground">{props.label(transportKey(kind))}</span>
209
+ </div>
210
+ {props.server.description === undefined ? null : <p className="truncate text-xs text-muted-foreground">{props.server.description}</p>}
211
+ {shown.length === 0 ? null : (
212
+ <div className="flex flex-wrap items-center gap-1.5">
213
+ {shown.map(tool => (
214
+ <button key={tool.name} type="button" className="inline-flex h-6 items-center rounded-full border px-2 text-[11px] leading-none hover:bg-muted" onClick={() => setOpenTool(tool.name)}>{tool.name}</button>
215
+ ))}
216
+ {extra > 0 ? (
217
+ <button type="button" className="inline-flex h-6 items-center rounded-full px-2 text-[11px] leading-none text-muted-foreground hover:bg-muted" onClick={() => setOpenTool(tools[shown.length]?.name ?? tools[0]?.name)}>{countLabel(props.label('mcp-more'), extra)}</button>
218
+ ) : null}
219
+ </div>
220
+ )}
221
+ <div className="flex gap-2">
222
+ <button type="button" className="h-7 rounded-md px-2 text-xs hover:bg-muted" onClick={props.onCheck}>{props.busy ? '…' : props.label('mcp-check')}</button>
223
+ <button type="button" className="h-7 rounded-md px-2 text-xs text-destructive hover:bg-muted" onClick={props.onRemove}>{props.label('delete')}</button>
224
+ </div>
225
+ {props.failure === undefined ? null : (
226
+ <p className="m-0 text-xs text-destructive">{props.label('mcp-unstarted')}: {props.failure.message}</p>
227
+ )}
228
+ {props.check !== undefined && props.check.ok !== true ? <p className="text-xs text-destructive">{props.check.message ?? props.label('mcp-offline')}</p> : null}
229
+ {openTool === undefined ? null : (
230
+ <ToolsDialog
231
+ title={props.server.id}
232
+ tools={tools}
233
+ selected={openTool}
234
+ label={props.label}
235
+ onSelect={setOpenTool}
236
+ onClose={() => setOpenTool(undefined)}
237
+ />
238
+ )}
239
+ </li>
240
+ )
241
+ }
242
+
243
+ function ToolsDialog(props: {
244
+ readonly title: string
245
+ readonly tools: McpCheckResult['tools']
246
+ readonly selected: string
247
+ readonly label: (key: string) => string
248
+ readonly onSelect: (name: string) => void
249
+ readonly onClose: () => void
250
+ }): ReactNode {
251
+ const tool = props.tools.find(item => item.name === props.selected) ?? props.tools[0]
252
+ return (
253
+ <Dialog width="wide" onClose={props.onClose}>
254
+ <div className="flex h-[min(70vh,36rem)] min-h-[24rem] flex-col">
255
+ <div>
256
+ <h5 className="m-0 text-base font-semibold">{props.title}</h5>
257
+ <p className="mt-1 text-xs text-muted-foreground">{`${props.tools.length} ${props.label('mcp-tools')}`}</p>
258
+ </div>
259
+ <div className="mt-4 grid min-h-0 flex-1 grid-cols-1 overflow-hidden rounded-2xl border min-[720px]:grid-cols-[16rem_minmax(0,1fr)]">
260
+ <ul className="max-h-48 overflow-auto border-b bg-muted/40 p-2 min-[720px]:max-h-none min-[720px]:border-r min-[720px]:border-b-0">
261
+ {props.tools.map(item => (
262
+ <li key={item.name}>
263
+ <button
264
+ type="button"
265
+ className={item.name === tool?.name ? 'w-full rounded-lg bg-card px-3 py-2.5 text-left text-sm font-medium shadow-sm' : 'w-full rounded-lg px-3 py-2.5 text-left text-sm text-muted-foreground hover:bg-card/80'}
266
+ onClick={() => props.onSelect(item.name)}
267
+ >
268
+ {item.name}
269
+ </button>
270
+ </li>
271
+ ))}
272
+ </ul>
273
+ <section className="overflow-auto p-6">
274
+ {tool === undefined ? null : (
275
+ <>
276
+ <p className="m-0 text-xs text-muted-foreground">{props.label('mcp-tools')}</p>
277
+ <h6 className="mt-2 text-xl font-semibold tracking-tight">{tool.name}</h6>
278
+ <p className="mt-4 text-sm leading-7 whitespace-pre-wrap text-muted-foreground">{tool.description ?? props.label('mcp-tool-empty')}</p>
279
+ {tool.inputSchema === undefined ? null : <SchemaBlock caption={props.label('mcp-schema-input')} schema={tool.inputSchema} label={props.label} />}
280
+ {tool.outputSchema === undefined ? null : <SchemaBlock caption={props.label('mcp-schema-output')} schema={tool.outputSchema} label={props.label} />}
281
+ </>
282
+ )}
283
+ </section>
284
+ </div>
285
+ </div>
286
+ </Dialog>
287
+ )
288
+ }
289
+
290
+ /**
291
+ * One declared schema: its top-level fields, then the schema the server sent.
292
+ * @param props - caption, schema, and the label lookup
293
+ */
294
+ function SchemaBlock(props: {
295
+ readonly caption: string
296
+ readonly schema: Record<string, unknown>
297
+ readonly label: (key: string) => string
298
+ }): ReactNode {
299
+ const fields = schemaFields(props.schema)
300
+ return (
301
+ <section className="mt-6">
302
+ <p className="m-0 text-xs text-muted-foreground">{props.caption}</p>
303
+ {fields.length === 0 ? (
304
+ <p className="mt-2 text-sm text-muted-foreground">{props.label('mcp-schema-no-fields')}</p>
305
+ ) : (
306
+ <ul className="mt-2 list-none space-y-2 p-0">
307
+ {fields.map(field => (
308
+ <li key={field.name} className="rounded-lg border p-3">
309
+ <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
310
+ <span className="font-mono text-sm">{field.name}</span>
311
+ <span className="text-[11px] text-muted-foreground">{field.type}</span>
312
+ {field.required ? <span className="rounded-full border px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">{props.label('mcp-schema-required')}</span> : null}
313
+ </div>
314
+ {field.description === undefined ? null : <p className="mt-1.5 mb-0 text-xs leading-6 text-muted-foreground">{field.description}</p>}
315
+ </li>
316
+ ))}
317
+ </ul>
318
+ )}
319
+ <pre className="mt-3 max-h-64 overflow-auto rounded-lg border bg-muted/40 p-3 font-mono text-[11px] leading-5 break-all whitespace-pre-wrap">{JSON.stringify(props.schema, null, 2)}</pre>
320
+ </section>
321
+ )
322
+ }
323
+
324
+ /** The top-level fields of an object schema, in the order the server declared them. */
325
+ function schemaFields(schema: Record<string, unknown>): readonly { name: string; type: string; required: boolean; description?: string }[] {
326
+ const properties = recordValue(schema.properties)
327
+ if (properties === undefined) return []
328
+ const required = Array.isArray(schema.required) ? schema.required.filter((item): item is string => typeof item === 'string') : []
329
+ return Object.entries(properties).flatMap(([name, value]) => {
330
+ const field = recordValue(value)
331
+ if (field === undefined) return []
332
+ return [{
333
+ name,
334
+ type: fieldType(field),
335
+ required: required.includes(name),
336
+ ...typeof field.description === 'string' && field.description.length > 0 ? { description: field.description } : {},
337
+ }]
338
+ })
339
+ }
340
+
341
+ function recordValue(value: unknown): Record<string, unknown> | undefined {
342
+ return typeof value === 'object' && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : undefined
343
+ }
344
+
345
+ function fieldType(field: Record<string, unknown>): string {
346
+ const declared = field.type
347
+ if (typeof declared === 'string') return declared
348
+ if (Array.isArray(declared)) {
349
+ const names = declared.filter((item): item is string => typeof item === 'string')
350
+ if (names.length > 0) return names.join(' | ')
351
+ }
352
+ if (field.properties !== undefined) return 'object'
353
+ if (field.items !== undefined) return 'array'
354
+ return 'any'
355
+ }
356
+
357
+ function EditorDialog(props: {
358
+ readonly draft: Draft
359
+ readonly label: (key: string) => string
360
+ readonly onChange: (draft: Draft) => void
361
+ readonly onCancel: () => void
362
+ readonly onSubmit: () => void
363
+ readonly onCheck?: (server: McpServerDraft) => Promise<McpCheckResult>
364
+ }): ReactNode {
365
+ const set = (patch: Partial<Draft>) => props.onChange({ ...props.draft, ...patch })
366
+ const [busy, setBusy] = useState(false)
367
+ const [check, setCheck] = useState<McpCheckResult | undefined>(undefined)
368
+ return (
369
+ <Dialog onClose={props.onCancel}>
370
+ <h5 className="m-0 text-sm font-semibold">{props.draft.editing === undefined ? props.label('mcp-new') : props.label('mcp-edit')}</h5>
371
+ <p className="mt-4 mb-2 text-xs text-muted-foreground">{props.label('mcp-transport')}</p>
372
+ <div className="inline-flex rounded-lg bg-muted p-0.5">
373
+ {(['stdio', 'sse', 'http'] as const).map(kind => (
374
+ <button key={kind} type="button" className="h-7 rounded-md px-3 text-xs data-[on=1]:bg-foreground data-[on=1]:text-background" data-on={props.draft.transport === kind ? '1' : '0'} onClick={() => set({ transport: kind })}>{props.label(transportKey(kind))}</button>
375
+ ))}
376
+ </div>
377
+ <div className="mt-4 grid gap-3 min-[720px]:grid-cols-2">
378
+ <Field label={props.label('mcp-name')} value={props.draft.id} hint={props.label('mcp-name-hint')} onChange={id => set({ id })} />
379
+ <Field label={props.label('mcp-description')} value={props.draft.description} hint={props.label('mcp-desc-hint')} onChange={description => set({ description })} />
380
+ </div>
381
+ {props.draft.transport === 'stdio' ? (
382
+ <div className="mt-3 grid gap-3 min-[720px]:grid-cols-2">
383
+ <Field label={props.label('mcp-command')} value={props.draft.command} hint={props.label('mcp-command-hint')} onChange={command => set({ command })} />
384
+ <Field label={props.label('mcp-args')} value={props.draft.args} hint={props.label('mcp-args-hint')} onChange={args => set({ args })} />
385
+ </div>
386
+ ) : <div className="mt-3"><Field label={props.label('mcp-url')} value={props.draft.url} hint={props.label('mcp-url-hint')} onChange={url => set({ url })} /></div>}
387
+ {props.draft.transport === 'stdio' ? (
388
+ <label className="mt-3 flex flex-col gap-1.5 text-xs text-muted-foreground">
389
+ {props.label('mcp-env')}
390
+ <textarea className="min-h-24 rounded-lg border bg-background px-3 py-2 font-mono text-sm text-foreground outline-none" placeholder={props.label('mcp-env-hint')} value={props.draft.env} onChange={event => set({ env: event.target.value })} />
391
+ <span className="font-sans text-muted-foreground">{props.label('mcp-reference-hint')}</span>
392
+ </label>
393
+ ) : (
394
+ <label className="mt-3 flex flex-col gap-1.5 text-xs text-muted-foreground">
395
+ {props.label('mcp-headers')}
396
+ <textarea className="min-h-24 rounded-lg border bg-background px-3 py-2 font-mono text-sm text-foreground outline-none" placeholder={props.label('mcp-headers-hint')} value={props.draft.headers} onChange={event => set({ headers: event.target.value })} />
397
+ <span className="font-sans text-muted-foreground">{props.label('mcp-reference-hint')}</span>
398
+ </label>
399
+ )}
400
+ {check === undefined ? null : (
401
+ <div className="mt-3">
402
+ {check.ok ? (
403
+ <>
404
+ <p className="m-0 text-sm text-primary">{countLabel(props.label('mcp-check-ok'), check.tools.length)}</p>
405
+ {check.tools.length === 0 ? null : (
406
+ <div className="mt-2 flex flex-wrap gap-1">
407
+ {check.tools.slice(0, 8).map(tool => <span key={tool.name} className="rounded-full border px-2 py-0.5 text-[11px]">{tool.name}</span>)}
408
+ </div>
409
+ )}
410
+ </>
411
+ ) : <p className="m-0 text-sm text-destructive">{check.message ?? props.label('mcp-offline')}</p>}
412
+ </div>
413
+ )}
414
+ <div className="mt-4 flex justify-end gap-2">
415
+ {props.onCheck === undefined ? null : (
416
+ <button type="button" className="mr-auto h-8 rounded-lg border px-3 text-sm disabled:opacity-60" disabled={busy} onClick={() => {
417
+ const checkMcp = props.onCheck
418
+ if (checkMcp === undefined) return
419
+ void testDraft(props.draft, checkMcp, setBusy, setCheck, props.label)
420
+ }}>{busy ? '…' : props.label('mcp-check')}</button>
421
+ )}
422
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={props.onCancel}>{props.label('cancel')}</button>
423
+ <button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={props.onSubmit}>{props.draft.editing === undefined ? props.label('mcp-add') : props.label('save')}</button>
424
+ </div>
425
+ </Dialog>
426
+ )
427
+ }
428
+
429
+ function PasteDialog(props: {
430
+ readonly label: (key: string) => string
431
+ readonly admit?: (text: string) => Promise<readonly McpServerDraft[]>
432
+ readonly onCancel: () => void
433
+ readonly onPick: (server: McpServerDraft) => void
434
+ }): ReactNode {
435
+ const [text, setText] = useState('')
436
+ const [found, setFound] = useState<readonly McpServerDraft[] | undefined>(undefined)
437
+ const [failed, setFailed] = useState('')
438
+ const [busy, setBusy] = useState(false)
439
+ return (
440
+ <Dialog onClose={props.onCancel}>
441
+ <h5 className="m-0 text-sm font-semibold">{props.label('mcp-paste')}</h5>
442
+ <textarea className="mt-3 min-h-36 w-full rounded-lg border bg-background px-3 py-2 font-mono text-xs text-foreground outline-none" value={text} onChange={event => setText(event.target.value)} />
443
+ {failed.length > 0 ? <p className="mt-2 text-sm text-destructive">{failed}</p> : null}
444
+ {found === undefined ? null : (
445
+ <ul className="mt-3 flex max-h-48 flex-col gap-1 overflow-auto">
446
+ {found.map(server => (
447
+ <li key={server.id}>
448
+ <button type="button" className="flex w-full items-baseline gap-2 rounded-lg px-2 py-2 text-left text-sm hover:bg-muted" onClick={() => props.onPick(server)}>
449
+ <span className="font-medium">{server.id}</span>
450
+ <span className="text-xs text-muted-foreground">{props.label(transportKey(transportOf(server)))}</span>
451
+ </button>
452
+ </li>
453
+ ))}
454
+ </ul>
455
+ )}
456
+ <div className="mt-3 flex justify-end gap-2">
457
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={props.onCancel}>{props.label('cancel')}</button>
458
+ <button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground disabled:opacity-60" disabled={busy || props.admit === undefined} onClick={() => {
459
+ const admit = props.admit
460
+ if (admit === undefined) return
461
+ void recognizePaste(text, admit, setBusy, setFailed, setFound, props.onPick, props.label)
462
+ }}>{busy ? '…' : props.label('mcp-admit')}</button>
463
+ </div>
464
+ </Dialog>
465
+ )
466
+ }
467
+
468
+ function PickDialog(props: {
469
+ readonly servers: readonly McpServerDraft[]
470
+ readonly label: (key: string) => string
471
+ readonly onCancel: () => void
472
+ readonly onConfirm: (servers: readonly McpServerDraft[]) => void
473
+ }): ReactNode {
474
+ const [picked, setPicked] = useState<ReadonlySet<string>>(() => new Set(props.servers.map(server => server.id)))
475
+ const chosen = props.servers.filter(server => picked.has(server.id))
476
+ return (
477
+ <Dialog width="wide" onClose={props.onCancel}>
478
+ <h5 className="m-0 text-base font-semibold">{props.label('mcp-pick')}</h5>
479
+ <ul className="mt-4 grid max-h-[60vh] grid-cols-1 gap-2 overflow-auto min-[720px]:grid-cols-2">
480
+ {props.servers.map(server => (
481
+ <li key={server.id}>
482
+ <label className="flex h-full items-start gap-3 rounded-xl border px-3 py-3 text-sm hover:bg-muted">
483
+ <input className="mt-1" type="checkbox" checked={picked.has(server.id)} onChange={() => setPicked(toggleSet(picked, server.id))} />
484
+ <span className="min-w-0">
485
+ <span className="flex items-baseline gap-2">
486
+ <span className="truncate font-medium">{server.id}</span>
487
+ <span className="shrink-0 text-xs text-muted-foreground">{props.label(transportKey(transportOf(server)))}</span>
488
+ </span>
489
+ {server.description === undefined ? null : <span className="mt-1 block text-xs leading-5 text-muted-foreground">{server.description}</span>}
490
+ </span>
491
+ </label>
492
+ </li>
493
+ ))}
494
+ </ul>
495
+ <div className="mt-3 flex justify-end gap-2">
496
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={props.onCancel}>{props.label('cancel')}</button>
497
+ <button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground" onClick={() => props.onConfirm(chosen)}>{props.label('mcp-add')}</button>
498
+ </div>
499
+ </Dialog>
500
+ )
501
+ }
502
+
503
+ function ConfirmDialog(props: {
504
+ readonly title: string
505
+ readonly label: (key: string) => string
506
+ readonly onCancel: () => void
507
+ readonly onConfirm: () => void
508
+ }): ReactNode {
509
+ return (
510
+ <Dialog onClose={props.onCancel}>
511
+ <p className="m-0 text-sm">{props.title}</p>
512
+ <div className="mt-4 flex justify-end gap-2">
513
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm" onClick={props.onCancel}>{props.label('cancel')}</button>
514
+ <button type="button" className="h-8 rounded-lg bg-destructive px-3 text-sm font-semibold text-destructive-foreground" onClick={props.onConfirm}>{props.label('delete')}</button>
515
+ </div>
516
+ </Dialog>
517
+ )
518
+ }
519
+
520
+ function Switch(props: { readonly on: boolean; readonly label: string; readonly onClick: () => void }): ReactNode {
521
+ return (
522
+ <button type="button" role="switch" aria-checked={props.on} aria-label={props.label} className={props.on ? 'relative h-5 w-9 rounded-full bg-foreground' : 'relative h-5 w-9 rounded-full bg-muted'} onClick={props.onClick}>
523
+ <span className={props.on ? 'absolute top-0.5 left-4 size-4 rounded-full bg-background' : 'absolute top-0.5 left-0.5 size-4 rounded-full bg-background'} />
524
+ </button>
525
+ )
526
+ }
527
+
528
+ function Status(props: { readonly running: boolean; readonly label: string }): ReactNode {
529
+ return (
530
+ <span className={props.running ? 'inline-flex h-6 items-center gap-1.5 rounded-full bg-primary/15 px-2 text-[11px] leading-none font-medium text-primary' : 'inline-flex h-6 items-center gap-1.5 rounded-full bg-muted px-2 text-[11px] leading-none text-muted-foreground'}>
531
+ <i className={props.running ? 'size-1.5 rounded-full bg-primary' : 'size-1.5 rounded-full bg-muted-foreground'} />
532
+ {props.label}
533
+ </span>
534
+ )
535
+ }
536
+
537
+ function countLabel(template: string, count: number): string {
538
+ return template.replace('{n}', String(count))
539
+ }
540
+
541
+ function Field(props: {
542
+ readonly label: string
543
+ readonly value: string
544
+ readonly hint: string
545
+ readonly onChange: (value: string) => void
546
+ }): ReactNode {
547
+ return (
548
+ <label className="flex flex-col gap-1.5 text-xs text-muted-foreground">
549
+ {props.label}
550
+ <input className="h-9 rounded-lg border bg-background px-3 text-sm text-foreground outline-none" placeholder={props.hint} value={props.value} onChange={event => props.onChange(event.target.value)} />
551
+ </label>
552
+ )
553
+ }
554
+
555
+ function transportOf(server: McpServerDraft): Transport {
556
+ if (server.url === undefined || server.url.length === 0) return 'stdio'
557
+ return server.transport === 'sse' ? 'sse' : 'http'
558
+ }
559
+
560
+ function transportKey(kind: Transport): 'mcp-stdio' | 'mcp-sse' | 'mcp-http' {
561
+ if (kind === 'sse') return 'mcp-sse'
562
+ if (kind === 'http') return 'mcp-http'
563
+ return 'mcp-stdio'
564
+ }
565
+
566
+ function toggleSet(current: ReadonlySet<string>, id: string): ReadonlySet<string> {
567
+ const next = new Set(current)
568
+ if (next.has(id)) next.delete(id)
569
+ else next.add(id)
570
+ return next
571
+ }
572
+
573
+ function toDraft(server: McpServerDraft): Draft {
574
+ return {
575
+ id: server.id,
576
+ description: server.description ?? '',
577
+ transport: transportOf(server),
578
+ command: server.command ?? '',
579
+ args: (server.args ?? []).join(' '),
580
+ url: server.url ?? '',
581
+ env: Object.entries(server.env ?? {}).map(([key, value]) => `${key}=${value}`).join('\n'),
582
+ headers: Object.entries(server.headers ?? {}).map(([key, value]) => `${key}=${value}`).join('\n'),
583
+ editing: server.id,
584
+ }
585
+ }
586
+
587
+ function fromDraft(draft: Draft): McpServerDraft {
588
+ const env = envOf(draft.env)
589
+ const headers = envOf(draft.headers)
590
+ const description = draft.description.trim()
591
+ const base = {
592
+ id: draft.id.trim(),
593
+ ...description.length > 0 ? { description } : {},
594
+ }
595
+ if (draft.transport === 'stdio') {
596
+ const args = draft.args.split(/\s+/).filter(item => item.length > 0)
597
+ return {
598
+ ...base,
599
+ ...draft.command.trim().length > 0 ? { command: draft.command.trim() } : {},
600
+ ...args.length > 0 ? { args } : {},
601
+ ...Object.keys(env).length > 0 ? { env } : {},
602
+ }
603
+ }
604
+ return {
605
+ ...base,
606
+ ...draft.url.trim().length > 0 ? { url: draft.url.trim() } : {},
607
+ transport: draft.transport === 'sse' ? 'sse' : 'streamable-http',
608
+ ...Object.keys(headers).length > 0 ? { headers } : {},
609
+ }
610
+ }
611
+
612
+ function envOf(text: string): Record<string, string> {
613
+ const env: Record<string, string> = {}
614
+ for (const line of text.split('\n')) {
615
+ const trimmed = line.trim()
616
+ const at = trimmed.indexOf('=')
617
+ if (at <= 0) continue
618
+ env[trimmed.slice(0, at).trim()] = trimmed.slice(at + 1)
619
+ }
620
+ return env
621
+ }
622
+
623
+ function openImported(server: McpServerDraft, existing: readonly McpServerDraft[]): Draft {
624
+ const draft = toDraft(server)
625
+ return existing.some(item => item.id === server.id) ? draft : { ...draft, editing: undefined }
626
+ }
627
+
628
+ async function saveDraft(
629
+ client: PanelSettingsClient,
630
+ servers: readonly McpServerDraft[],
631
+ draft: Draft,
632
+ setServers: (servers: McpServerDraft[]) => void,
633
+ setUnresolved: (failures: readonly PanelMcpFailure[]) => void,
634
+ setAlert: (alert: 'import' | 'save' | undefined) => void,
635
+ setNotice: (message: string) => void,
636
+ label: (key: string) => string,
637
+ ): Promise<boolean> {
638
+ const next = servers.filter(server => server.id !== draft.editing && server.id !== draft.id.trim()).concat(fromDraft(draft))
639
+ return commit(client, next, setServers, setUnresolved, setAlert, setNotice, label)
640
+ }
641
+
642
+ async function commit(
643
+ client: PanelSettingsClient,
644
+ servers: readonly McpServerDraft[],
645
+ setServers: (servers: McpServerDraft[]) => void,
646
+ setUnresolved: (failures: readonly PanelMcpFailure[]) => void,
647
+ setAlert: (alert: 'import' | 'save' | undefined) => void,
648
+ setNotice: (message: string) => void,
649
+ label: (key: string) => string,
650
+ ): Promise<boolean> {
651
+ if (client.writeMcp === undefined) return false
652
+ if (servers.some(server => server.id.length === 0)) {
653
+ setAlert('save')
654
+ return false
655
+ }
656
+ try {
657
+ const written = await client.writeMcp(servers)
658
+ setServers([...servers])
659
+ // The write resolved the file: what it left out is the current answer, not boot's.
660
+ setUnresolved([...written.unresolved])
661
+ setAlert(undefined)
662
+ setNotice(label('mcp-saved'))
663
+ return true
664
+ } catch {
665
+ setAlert('save')
666
+ return false
667
+ }
668
+ }
669
+
670
+ async function testDraft(
671
+ draft: Draft,
672
+ checkMcp: (server: McpServerDraft) => Promise<McpCheckResult>,
673
+ setBusy: (busy: boolean) => void,
674
+ setCheck: (result: McpCheckResult) => void,
675
+ label: (key: string) => string,
676
+ ): Promise<void> {
677
+ const server = fromDraft(draft)
678
+ if (server.id.length === 0) {
679
+ setCheck({ ok: false, tools: [], message: label('mcp-need-name') })
680
+ return
681
+ }
682
+ setBusy(true)
683
+ try {
684
+ setCheck(await checkMcp(server))
685
+ } catch (error) {
686
+ setCheck({
687
+ ok: false,
688
+ tools: [],
689
+ message: error instanceof Error && error.message.length > 0 ? error.message : label('mcp-offline'),
690
+ })
691
+ } finally {
692
+ setBusy(false)
693
+ }
694
+ }
695
+
696
+ async function runCheck(
697
+ client: PanelSettingsClient,
698
+ server: McpServerDraft,
699
+ setChecks: (value: CheckMap | ((current: CheckMap) => CheckMap)) => void,
700
+ setBusy: (id: string) => void,
701
+ setAlert: (alert: 'import' | 'save' | undefined) => void,
702
+ ): Promise<void> {
703
+ if (client.checkMcp === undefined) return
704
+ setBusy(server.id)
705
+ try {
706
+ const result = await client.checkMcp(server)
707
+ setChecks(current => ({ ...current, [server.id]: result }))
708
+ } catch {
709
+ setAlert('save')
710
+ } finally {
711
+ setBusy('')
712
+ }
713
+ }
714
+
715
+ async function pullSource(
716
+ client: PanelSettingsClient,
717
+ source: string,
718
+ onCandidates: (servers: readonly McpServerDraft[]) => void,
719
+ setAlert: (alert: 'import' | 'save' | undefined) => void,
720
+ ): Promise<void> {
721
+ if (client.importMcp === undefined) return
722
+ try {
723
+ const listed = await client.importMcp(source)
724
+ if (listed.length === 0) {
725
+ setAlert('import')
726
+ return
727
+ }
728
+ onCandidates(listed)
729
+ } catch {
730
+ setAlert('import')
731
+ }
732
+ }
733
+
734
+ async function recognizePaste(
735
+ text: string,
736
+ admit: (text: string) => Promise<readonly McpServerDraft[]>,
737
+ setBusy: (busy: boolean) => void,
738
+ setFailed: (message: string) => void,
739
+ setFound: (servers: readonly McpServerDraft[]) => void,
740
+ onPick: (server: McpServerDraft) => void,
741
+ label: (key: string) => string,
742
+ ): Promise<void> {
743
+ setBusy(true)
744
+ setFailed('')
745
+ try {
746
+ const listed = await admit(text)
747
+ if (listed.length === 0) {
748
+ setFailed(label('mcp-import-none'))
749
+ setFound([])
750
+ return
751
+ }
752
+ if (listed.length === 1) {
753
+ onPick(listed[0]!)
754
+ return
755
+ }
756
+ setFound(listed)
757
+ } catch {
758
+ setFailed(label('mcp-import-none'))
759
+ } finally {
760
+ setBusy(false)
761
+ }
762
+ }
763
+
764
+ async function admit(
765
+ client: PanelSettingsClient,
766
+ text: string,
767
+ onCandidates: (servers: readonly McpServerDraft[]) => void,
768
+ setPaste: (open: boolean) => void,
769
+ setAlert: (alert: 'import' | 'save' | undefined) => void,
770
+ ): Promise<void> {
771
+ if (client.admitMcp === undefined) return
772
+ try {
773
+ const listed = await client.admitMcp(text)
774
+ if (listed.length === 0) {
775
+ setAlert('import')
776
+ return
777
+ }
778
+ onCandidates(listed)
779
+ setPaste(false)
780
+ } catch {
781
+ setAlert('import')
782
+ }
783
+ }
784
+
785
+ async function readChosen(
786
+ file: File | undefined,
787
+ client: PanelSettingsClient,
788
+ onCandidates: (servers: readonly McpServerDraft[]) => void,
789
+ setAlert: (alert: 'import' | 'save' | undefined) => void,
790
+ ): Promise<void> {
791
+ if (file === undefined) return
792
+ await admit(client, await file.text(), onCandidates, () => undefined, setAlert)
793
+ }