@ouraihub/aether-ui 0.0.0-stage → 0.1.0

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 (179) hide show
  1. package/LICENSE +789 -0
  2. package/fixtures/model-classify.json +42 -0
  3. package/fixtures/pricing-cost.json +56 -0
  4. package/fixtures/reasoning-infer.json +44 -0
  5. package/fixtures/sse/anthropic-messages/chinese.sse +24 -0
  6. package/fixtures/sse/anthropic-messages/multi-tool.sse +33 -0
  7. package/fixtures/sse/anthropic-messages/single-tool.sse +39 -0
  8. package/fixtures/sse/anthropic-messages/text.sse +27 -0
  9. package/fixtures/sse/anthropic-messages/truncated.sse +21 -0
  10. package/fixtures/sse/anthropic-messages/upstream-error.sse +12 -0
  11. package/fixtures/sse/expect.json +20 -0
  12. package/fixtures/sse/openai-chat/chinese.sse +14 -0
  13. package/fixtures/sse/openai-chat/multi-tool.sse +18 -0
  14. package/fixtures/sse/openai-chat/single-tool.sse +22 -0
  15. package/fixtures/sse/openai-chat/text.sse +14 -0
  16. package/fixtures/sse/openai-chat/truncated.sse +12 -0
  17. package/fixtures/sse/openai-chat/upstream-error.sse +6 -0
  18. package/fixtures/sse/openai-responses/chinese.sse +33 -0
  19. package/fixtures/sse/openai-responses/multi-tool.sse +36 -0
  20. package/fixtures/sse/openai-responses/single-tool.sse +27 -0
  21. package/fixtures/sse/openai-responses/text.sse +33 -0
  22. package/fixtures/sse/openai-responses/truncated.sse +30 -0
  23. package/fixtures/sse/openai-responses/upstream-error.sse +9 -0
  24. package/package.json +71 -3
  25. package/src/CopyInput.svelte +33 -0
  26. package/src/KitNotifier.svelte +33 -0
  27. package/src/chat/ApprovalCard.svelte +23 -0
  28. package/src/chat/ChatPanel.svelte +2097 -0
  29. package/src/chat/MessageItem.svelte +583 -0
  30. package/src/chat/SkillPicker.svelte +126 -0
  31. package/src/chat/ToolCallItem.svelte +23 -0
  32. package/src/chat/UsageDetails.svelte +138 -0
  33. package/src/chat/index.ts +2 -0
  34. package/src/chat/run-session.svelte.ts +556 -0
  35. package/src/components/ui/alert/alert-action.svelte +20 -0
  36. package/src/components/ui/alert/alert-description.svelte +23 -0
  37. package/src/components/ui/alert/alert-title.svelte +23 -0
  38. package/src/components/ui/alert/alert.svelte +43 -0
  39. package/src/components/ui/alert/index.ts +17 -0
  40. package/src/components/ui/alert-dialog/alert-dialog-action.svelte +27 -0
  41. package/src/components/ui/alert-dialog/alert-dialog-cancel.svelte +27 -0
  42. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +32 -0
  43. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +17 -0
  44. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +23 -0
  45. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +20 -0
  46. package/src/components/ui/alert-dialog/alert-dialog-media.svelte +20 -0
  47. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +17 -0
  48. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
  49. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +17 -0
  50. package/src/components/ui/alert-dialog/alert-dialog-trigger.svelte +7 -0
  51. package/src/components/ui/alert-dialog/alert-dialog.svelte +7 -0
  52. package/src/components/ui/alert-dialog/index.ts +40 -0
  53. package/src/components/ui/button/button.svelte +81 -0
  54. package/src/components/ui/button/index.ts +17 -0
  55. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  56. package/src/components/ui/dialog/dialog-content.svelte +48 -0
  57. package/src/components/ui/dialog/dialog-description.svelte +17 -0
  58. package/src/components/ui/dialog/dialog-footer.svelte +32 -0
  59. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  60. package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
  61. package/src/components/ui/dialog/dialog-portal.svelte +7 -0
  62. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  63. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  64. package/src/components/ui/dialog/dialog.svelte +7 -0
  65. package/src/components/ui/dialog/index.ts +34 -0
  66. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
  67. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +46 -0
  68. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +31 -0
  69. package/src/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
  70. package/src/components/ui/dropdown-menu/dropdown-menu-group.svelte +7 -0
  71. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +27 -0
  72. package/src/components/ui/dropdown-menu/dropdown-menu-label.svelte +24 -0
  73. package/src/components/ui/dropdown-menu/dropdown-menu-portal.svelte +7 -0
  74. package/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +17 -0
  75. package/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +36 -0
  76. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  77. package/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +20 -0
  78. package/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +31 -0
  79. package/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
  80. package/src/components/ui/dropdown-menu/dropdown-menu-sub.svelte +7 -0
  81. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
  82. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  83. package/src/components/ui/dropdown-menu/index.ts +54 -0
  84. package/src/components/ui/empty/empty-content.svelte +23 -0
  85. package/src/components/ui/empty/empty-description.svelte +23 -0
  86. package/src/components/ui/empty/empty-header.svelte +20 -0
  87. package/src/components/ui/empty/empty-media.svelte +41 -0
  88. package/src/components/ui/empty/empty-title.svelte +20 -0
  89. package/src/components/ui/empty/empty.svelte +23 -0
  90. package/src/components/ui/empty/index.ts +22 -0
  91. package/src/components/ui/field/field-content.svelte +20 -0
  92. package/src/components/ui/field/field-description.svelte +25 -0
  93. package/src/components/ui/field/field-error.svelte +58 -0
  94. package/src/components/ui/field/field-group.svelte +23 -0
  95. package/src/components/ui/field/field-label.svelte +25 -0
  96. package/src/components/ui/field/field-legend.svelte +24 -0
  97. package/src/components/ui/field/field-separator.svelte +35 -0
  98. package/src/components/ui/field/field-set.svelte +20 -0
  99. package/src/components/ui/field/field-title.svelte +20 -0
  100. package/src/components/ui/field/field.svelte +47 -0
  101. package/src/components/ui/field/index.ts +33 -0
  102. package/src/components/ui/input/index.ts +7 -0
  103. package/src/components/ui/input/input.svelte +48 -0
  104. package/src/components/ui/label/index.ts +7 -0
  105. package/src/components/ui/label/label.svelte +20 -0
  106. package/src/components/ui/select/index.ts +40 -0
  107. package/src/components/ui/select/select-content.svelte +45 -0
  108. package/src/components/ui/select/select-group-heading.svelte +21 -0
  109. package/src/components/ui/select/select-group.svelte +17 -0
  110. package/src/components/ui/select/select-item.svelte +43 -0
  111. package/src/components/ui/select/select-label.svelte +20 -0
  112. package/src/components/ui/select/select-portal.svelte +7 -0
  113. package/src/components/ui/select/select-scroll-down-button.svelte +20 -0
  114. package/src/components/ui/select/select-scroll-up-button.svelte +20 -0
  115. package/src/components/ui/select/select-separator.svelte +18 -0
  116. package/src/components/ui/select/select-trigger.svelte +29 -0
  117. package/src/components/ui/select/select-value.svelte +7 -0
  118. package/src/components/ui/select/select.svelte +11 -0
  119. package/src/components/ui/separator/index.ts +7 -0
  120. package/src/components/ui/separator/separator.svelte +21 -0
  121. package/src/components/ui/sonner/index.ts +1 -0
  122. package/src/components/ui/sonner/sonner.svelte +40 -0
  123. package/src/components/ui/switch/index.ts +7 -0
  124. package/src/components/ui/switch/switch.svelte +34 -0
  125. package/src/components/ui/textarea/index.ts +7 -0
  126. package/src/components/ui/textarea/textarea.svelte +22 -0
  127. package/src/context.ts +34 -0
  128. package/src/dev/Demo.svelte +45 -0
  129. package/src/dev/main.ts +7 -0
  130. package/src/i18n/en.ts +617 -0
  131. package/src/i18n/index.ts +23 -0
  132. package/src/i18n/zh-CN.ts +599 -0
  133. package/src/index.ts +10 -0
  134. package/src/logs/LogPanel.svelte +97 -0
  135. package/src/logs/LogSettings.svelte +56 -0
  136. package/src/logs/index.ts +5 -0
  137. package/src/logs/logger.ts +96 -0
  138. package/src/markdown.ts +8 -0
  139. package/src/mcp/McpClientSettings.svelte +491 -0
  140. package/src/mcp/McpServerSettings.svelte +327 -0
  141. package/src/mcp/config.ts +26 -0
  142. package/src/mcp/index.ts +3 -0
  143. package/src/mcp/input.ts +73 -0
  144. package/src/notify.svelte.ts +43 -0
  145. package/src/ports/contract.ts +165 -0
  146. package/src/ports/fake.ts +484 -0
  147. package/src/ports/index.ts +7 -0
  148. package/src/ports/search.ts +47 -0
  149. package/src/ports/skill-sources.ts +45 -0
  150. package/src/providers/EndpointKeysDialog.svelte +343 -0
  151. package/src/providers/EndpointPanel.svelte +491 -0
  152. package/src/providers/ModelPickerDialog.svelte +187 -0
  153. package/src/providers/ModelSelect.svelte +434 -0
  154. package/src/providers/ModelSettingsDialog.svelte +453 -0
  155. package/src/providers/ProvidersSettings.svelte +631 -0
  156. package/src/providers/RequestConfigDialog.svelte +250 -0
  157. package/src/providers/SecretInput.svelte +276 -0
  158. package/src/providers/draft.ts +329 -0
  159. package/src/providers/edit-session.ts +146 -0
  160. package/src/providers/index.ts +5 -0
  161. package/src/providers/reasoning.ts +41 -0
  162. package/src/providers/revision.svelte.ts +4 -0
  163. package/src/search/SearchSettings.svelte +703 -0
  164. package/src/search/index.ts +13 -0
  165. package/src/search/settings-flow.ts +62 -0
  166. package/src/session.ts +20 -0
  167. package/src/skills/SkillAdd.svelte +347 -0
  168. package/src/skills/SkillAuthoring.svelte +381 -0
  169. package/src/skills/SkillEditor.svelte +94 -0
  170. package/src/skills/SkillPreviewDialog.svelte +122 -0
  171. package/src/skills/SkillsSettings.svelte +140 -0
  172. package/src/skills/error.ts +32 -0
  173. package/src/skills/index.ts +4 -0
  174. package/src/styles/kit.css +137 -0
  175. package/src/styles/tokens.css +22 -0
  176. package/src/tauri/index.ts +282 -0
  177. package/src/utils.ts +22 -0
  178. package/src/web/index.ts +376 -0
  179. package/README.md +0 -3
@@ -0,0 +1,491 @@
1
+ <script lang="ts">
2
+ // External MCP servers the in-app AI may call (docs/16 §4.4, docs/13 §2): list, edit with
3
+ // write-only credentials, test connection, trust/enable/private-network switches, and each
4
+ // tool's approval default. Only enabled + trusted servers' tools reach a run. A server is
5
+ // reached over Streamable HTTP or as a local stdio command.
6
+ import { onDestroy } from 'svelte'
7
+ import { Alert, AlertDescription } from '#lib/components/ui/alert/index.js'
8
+ import { Button } from '#lib/components/ui/button/index.js'
9
+ import * as Dialog from '#lib/components/ui/dialog/index.js'
10
+ import * as Empty from '#lib/components/ui/empty/index.js'
11
+ import * as Field from '#lib/components/ui/field/index.js'
12
+ import { Input } from '#lib/components/ui/input/index.js'
13
+ import CopyInput from '../CopyInput.svelte'
14
+ import * as Select from '#lib/components/ui/select/index.js'
15
+ import { Switch } from '#lib/components/ui/switch/index.js'
16
+ import { Textarea } from '#lib/components/ui/textarea/index.js'
17
+ import { getKitContext } from '../context'
18
+ import { isKitClientError } from '../ports'
19
+ import type { Approval, McpTransport, ServerDto, ServerSave } from '../ports'
20
+ import { createClientSession } from '../session'
21
+ import { mapMcpInput } from './input'
22
+
23
+ const APPROVALS: Approval[] = ['auto', 'ask', 'deny']
24
+ const KINDS: McpTransport['kind'][] = ['http', 'stdio']
25
+ const ARGS_PLACEHOLDER = '-y\n@modelcontextprotocol/server-filesystem'
26
+
27
+ const { ports, i18n, notify, logger } = getKitContext()
28
+ const client = ports.mcpClient
29
+ const t = i18n.t
30
+
31
+ let servers = $state<ServerDto[]>([])
32
+ let busy = $state(false)
33
+ /** Last "test connection" failure per server id. */
34
+ let testErrors = $state<Record<string, string>>({})
35
+
36
+ let editing = $state(false)
37
+ let editId = $state<string | null>(null)
38
+ let name = $state('')
39
+ let slug = $state('')
40
+ let kind = $state<McpTransport['kind']>('http')
41
+ let url = $state('')
42
+ let command = $state('')
43
+ let args = $state('')
44
+ let cwd = $state('')
45
+ let replaceSecret = $state(true)
46
+ let headers = $state('')
47
+ let query = $state('')
48
+ let env = $state('')
49
+ let currentKeys = $state<string[]>([])
50
+ const editSession = createClientSession()
51
+ let editTarget: ServerDto | null = null
52
+ let editorSaving = $state(false)
53
+ let editRevision = 0
54
+ let secretRevision = 0
55
+
56
+ function clearSecrets() {
57
+ headers = ''
58
+ query = ''
59
+ env = ''
60
+ secretRevision++
61
+ }
62
+
63
+ function invalidateEditor() {
64
+ editSession.invalidate()
65
+ editorSaving = false
66
+ clearSecrets()
67
+ }
68
+
69
+ function closeEditor() {
70
+ invalidateEditor()
71
+ editing = false
72
+ editTarget = null
73
+ }
74
+
75
+ function changeKind(next: McpTransport['kind']) {
76
+ if (next === kind) return
77
+ invalidateEditor()
78
+ editRevision++
79
+ kind = next
80
+ }
81
+
82
+ function changeReplacement(next: boolean) {
83
+ invalidateEditor()
84
+ editRevision++
85
+ replaceSecret = next
86
+ }
87
+
88
+ onDestroy(closeEditor)
89
+
90
+ // Actions only render when the port exists; the throw marks a host wiring bug.
91
+ function need() {
92
+ if (!client) throw new Error('mcpClient port missing')
93
+ return client
94
+ }
95
+
96
+ function errorText(error: unknown): string {
97
+ if (!isKitClientError(error)) return t('errors.internal')
98
+ const { reason, field } = error.details
99
+ if (reason === 'credential_query') return t('mcp.client.err.credentialQuery')
100
+ if (reason === 'duplicate_slug') return t('mcp.client.err.duplicateSlug')
101
+ if (reason === 'credential_arg') return t('mcp.client.err.credentialArg')
102
+ if (reason === 'transport_changed') return t('mcp.client.err.transportChanged')
103
+ return field ? `${t(`errors.${error.code}`)} (${field})` : t(`errors.${error.code}`)
104
+ }
105
+
106
+ function fail(error: unknown) {
107
+ logger.warn('mcp.client.ui_failed', { code: isKitClientError(error) ? error.code : 'internal' })
108
+ notify.toast('error', errorText(error))
109
+ }
110
+
111
+ async function act(run: () => Promise<void>) {
112
+ busy = true
113
+ try {
114
+ await run()
115
+ } catch (error) {
116
+ fail(error)
117
+ } finally {
118
+ busy = false
119
+ }
120
+ }
121
+
122
+ $effect(() => {
123
+ if (client) void act(async () => void (servers = await client.list()))
124
+ })
125
+
126
+ function base(server: ServerDto): ServerSave {
127
+ const { id, slug, name, transport, enabled, trusted, allowPrivate } = server
128
+ return { id, slug, name, transport, enabled, trusted, allowPrivate }
129
+ }
130
+
131
+ /** One-line location shown in the list: the URL, or the command line. */
132
+ function location(transport: McpTransport): string {
133
+ return transport.kind === 'http' ? transport.url : [transport.command, ...transport.args].join(' ')
134
+ }
135
+
136
+ const update = (next: ServerSave) => act(async () => void (servers = await need().save(next)))
137
+
138
+ async function setTrusted(server: ServerDto, on: boolean) {
139
+ if (on) {
140
+ const ok = await notify.confirm({
141
+ title: t('mcp.client.trustTitle'),
142
+ body: t('mcp.client.trustBody', { name: server.name }),
143
+ })
144
+ if (!ok) return
145
+ }
146
+ await update({ ...base(server), trusted: on })
147
+ }
148
+
149
+ async function setPrivate(server: ServerDto, on: boolean) {
150
+ if (on) {
151
+ const ok = await notify.confirm({
152
+ title: t('mcp.client.privateTitle'),
153
+ body: t('mcp.client.privateBody', { url: location(server.transport) }),
154
+ danger: true,
155
+ })
156
+ if (!ok) return
157
+ }
158
+ await update({ ...base(server), allowPrivate: on })
159
+ }
160
+
161
+ const test = (server: ServerDto) =>
162
+ act(async () => {
163
+ testErrors = Object.fromEntries(Object.entries(testErrors).filter(([id]) => id !== server.id))
164
+ try {
165
+ await need().refresh(server.id)
166
+ } catch (error) {
167
+ testErrors = { ...testErrors, [server.id]: errorText(error) }
168
+ }
169
+ servers = await need().list()
170
+ })
171
+
172
+ const setApproval = (server: ServerDto, tool: string, approval: Approval) =>
173
+ act(async () => {
174
+ await need().setToolApproval(server.id, tool, approval)
175
+ servers = await need().list()
176
+ })
177
+
178
+ const remove = (server: ServerDto) =>
179
+ act(async () => {
180
+ const ok = await notify.confirm({
181
+ title: t('mcp.client.deleteTitle'),
182
+ body: t('mcp.client.deleteBody', { name: server.name }),
183
+ danger: true,
184
+ })
185
+ if (ok) servers = await need().remove(server.id)
186
+ })
187
+
188
+ function edit(server: ServerDto | null) {
189
+ invalidateEditor()
190
+ editTarget = server
191
+ editRevision++
192
+ const transport = server?.transport
193
+ editId = server?.id ?? null
194
+ name = server?.name ?? ''
195
+ slug = server?.slug ?? ''
196
+ kind = transport?.kind ?? 'http'
197
+ url = transport?.kind === 'http' ? transport.url : ''
198
+ command = transport?.kind === 'stdio' ? transport.command : ''
199
+ args = transport?.kind === 'stdio' ? transport.args.join('\n') : ''
200
+ cwd = transport?.kind === 'stdio' ? (transport.cwd ?? '') : ''
201
+ currentKeys = server?.secretKeys ?? []
202
+ replaceSecret = server === null
203
+ editing = true
204
+ }
205
+
206
+ async function save() {
207
+ const mapped = mapMcpInput(editTarget, {
208
+ name,
209
+ slug,
210
+ kind,
211
+ url,
212
+ command,
213
+ args,
214
+ cwd,
215
+ replaceSecret,
216
+ headers,
217
+ query,
218
+ env,
219
+ })
220
+ if (!mapped.ok) {
221
+ notify.toast('error', t(`mcp.client.err.${mapped.error}`))
222
+ return
223
+ }
224
+ const token = editSession.capture()
225
+ const revision = editRevision
226
+ const sentSecretRevision = secretRevision
227
+ editorSaving = true
228
+ try {
229
+ const next = await need().save(mapped.value)
230
+ if (!editSession.isCurrent(token)) return
231
+ servers = next
232
+ if (editRevision === revision) {
233
+ closeEditor()
234
+ } else {
235
+ // Preserve edits made while saving, but do not retain successfully sent secrets.
236
+ const stored = editTarget
237
+ ? next.find((server) => server.id === editTarget?.id)
238
+ : next.find((server) => server.slug === mapped.value.slug)
239
+ if (stored) {
240
+ editTarget = stored
241
+ editId = stored.id
242
+ currentKeys = stored.secretKeys
243
+ }
244
+ if (sentSecretRevision === secretRevision) {
245
+ clearSecrets()
246
+ replaceSecret = false
247
+ }
248
+ }
249
+ } catch (error) {
250
+ if (editSession.isCurrent(token)) fail(error)
251
+ } finally {
252
+ if (editSession.isCurrent(token)) editorSaving = false
253
+ }
254
+ }
255
+ </script>
256
+
257
+ {#if client}
258
+ <section class="flex flex-col gap-4" aria-label={t('mcp.client.title')}>
259
+ <div class="flex items-center gap-2">
260
+ <p class="text-muted-foreground flex-1 text-sm">{t('mcp.client.hint')}</p>
261
+ <Button disabled={busy} onclick={() => edit(null)}>{t('mcp.client.add')}</Button>
262
+ </div>
263
+ {#if servers.length === 0}
264
+ <Empty.Root>
265
+ <Empty.Header>
266
+ <Empty.Title>{t('mcp.client.empty')}</Empty.Title>
267
+ <Empty.Description>{t('mcp.client.emptyHint')}</Empty.Description>
268
+ </Empty.Header>
269
+ </Empty.Root>
270
+ {/if}
271
+ <ul class="flex flex-col gap-3">
272
+ {#each servers as server (server.id)}
273
+ <li class="flex flex-col gap-3 rounded-md border p-3" aria-label={server.name}>
274
+ <div class="flex items-start gap-3">
275
+ <div class="flex min-w-0 flex-1 flex-col">
276
+ <span class="truncate font-medium"
277
+ >{server.name} <span class="text-muted-foreground">({server.slug})</span></span
278
+ >
279
+ <span class="text-muted-foreground truncate font-mono text-xs"
280
+ >{t(`mcp.client.transport.${server.transport.kind}`)} · {location(server.transport)}</span
281
+ >
282
+ {#if server.secretKeys.length}
283
+ <span class="text-muted-foreground text-xs">
284
+ {t('mcp.client.secretKeys', { keys: server.secretKeys.join(', ') })}
285
+ </span>
286
+ {/if}
287
+ </div>
288
+ <Button size="sm" variant="outline" disabled={busy} onclick={() => void test(server)}>
289
+ {t('mcp.client.test')}
290
+ </Button>
291
+ <Button size="sm" variant="outline" disabled={busy} onclick={() => edit(server)}>
292
+ {t('mcp.client.edit')}
293
+ </Button>
294
+ <Button size="sm" variant="destructive" disabled={busy} onclick={() => void remove(server)}>
295
+ {t('mcp.client.delete')}
296
+ </Button>
297
+ </div>
298
+ <div class="flex flex-wrap gap-6">
299
+ <Field.Field orientation="horizontal" class="w-auto">
300
+ <Switch
301
+ id={`kit-mcp-enabled-${server.id}`}
302
+ checked={server.enabled}
303
+ disabled={busy}
304
+ onCheckedChange={(on) => void update({ ...base(server), enabled: on })}
305
+ />
306
+ <Field.Label for={`kit-mcp-enabled-${server.id}`}>{t('mcp.client.enabled')}</Field.Label>
307
+ </Field.Field>
308
+ <Field.Field orientation="horizontal" class="w-auto">
309
+ <Switch
310
+ id={`kit-mcp-trusted-${server.id}`}
311
+ checked={server.trusted}
312
+ disabled={busy}
313
+ onCheckedChange={(on) => void setTrusted(server, on)}
314
+ />
315
+ <Field.Label for={`kit-mcp-trusted-${server.id}`}>{t('mcp.client.trusted')}</Field.Label>
316
+ </Field.Field>
317
+ {#if server.transport.kind === 'http'}
318
+ <Field.Field orientation="horizontal" class="w-auto">
319
+ <Switch
320
+ id={`kit-mcp-private-${server.id}`}
321
+ checked={server.allowPrivate}
322
+ disabled={busy}
323
+ onCheckedChange={(on) => void setPrivate(server, on)}
324
+ />
325
+ <Field.Label for={`kit-mcp-private-${server.id}`}>{t('mcp.client.allowPrivate')}</Field.Label>
326
+ </Field.Field>
327
+ {/if}
328
+ </div>
329
+ {#if testErrors[server.id]}
330
+ <Alert variant="destructive">
331
+ <AlertDescription>{t('mcp.client.testFailed', { error: testErrors[server.id] ?? '' })}</AlertDescription>
332
+ </Alert>
333
+ {/if}
334
+ {#if server.tools.length}
335
+ <ul class="flex flex-col gap-2" aria-label={t('mcp.client.tools')}>
336
+ {#each server.tools as tool (tool.name)}
337
+ <li class="flex items-center gap-3">
338
+ <div class="flex min-w-0 flex-1 flex-col">
339
+ <span class="truncate font-mono text-sm">{tool.name}</span>
340
+ <span class="text-muted-foreground truncate text-xs">
341
+ {tool.description}{#if tool.readOnly && tool.approval !== 'auto'}
342
+ · {t('mcp.client.readOnlyHint')}{/if}
343
+ </span>
344
+ </div>
345
+ <Select.Root
346
+ type="single"
347
+ value={tool.approval}
348
+ onValueChange={(value) => void setApproval(server, tool.name, value as Approval)}
349
+ >
350
+ <Select.Trigger
351
+ size="sm"
352
+ class="w-28"
353
+ aria-label={t('mcp.client.approvalFor', { tool: tool.name })}
354
+ >
355
+ {t(`mcp.client.approval.${tool.approval}`)}
356
+ </Select.Trigger>
357
+ <Select.Content>
358
+ <Select.Group>
359
+ {#each APPROVALS as approval (approval)}
360
+ <Select.Item value={approval} label={t(`mcp.client.approval.${approval}`)} />
361
+ {/each}
362
+ </Select.Group>
363
+ </Select.Content>
364
+ </Select.Root>
365
+ </li>
366
+ {/each}
367
+ </ul>
368
+ {:else}
369
+ <p class="text-muted-foreground text-sm">{t('mcp.client.noTools')}</p>
370
+ {/if}
371
+ </li>
372
+ {/each}
373
+ </ul>
374
+ </section>
375
+
376
+ <Dialog.Root
377
+ open={editing}
378
+ onOpenChange={(open) => {
379
+ if (!open) closeEditor()
380
+ }}
381
+ >
382
+ <Dialog.Content class="sm:max-w-xl" oninput={() => editRevision++}>
383
+ <Dialog.Header>
384
+ <Dialog.Title>{editId ? t('mcp.client.editTitle') : t('mcp.client.add')}</Dialog.Title>
385
+ </Dialog.Header>
386
+ <Field.Group>
387
+ <div class="flex gap-3">
388
+ <Field.Field class="flex-1">
389
+ <Field.Label for="kit-mcp-name">{t('mcp.client.name')}</Field.Label>
390
+ <Input id="kit-mcp-name" maxlength={200} bind:value={name} />
391
+ </Field.Field>
392
+ <Field.Field class="w-40">
393
+ <Field.Label for="kit-mcp-slug">{t('mcp.client.slug')}</Field.Label>
394
+ <Input id="kit-mcp-slug" class="font-mono" maxlength={24} bind:value={slug} />
395
+ </Field.Field>
396
+ </div>
397
+ <Field.Field>
398
+ <Field.Label for="kit-mcp-transport">{t('mcp.client.transport')}</Field.Label>
399
+ <Select.Root type="single" value={kind} onValueChange={(value) => changeKind(value as McpTransport['kind'])}>
400
+ <Select.Trigger id="kit-mcp-transport" class="w-full">
401
+ {t(`mcp.client.transport.${kind}`)}
402
+ </Select.Trigger>
403
+ <Select.Content>
404
+ <Select.Group>
405
+ {#each KINDS as option (option)}
406
+ <Select.Item value={option} label={t(`mcp.client.transport.${option}`)} />
407
+ {/each}
408
+ </Select.Group>
409
+ </Select.Content>
410
+ </Select.Root>
411
+ </Field.Field>
412
+ {#if kind === 'http'}
413
+ <Field.Field>
414
+ <Field.Label for="kit-mcp-url">{t('mcp.client.url')}</Field.Label>
415
+ <CopyInput id="kit-mcp-url" class="font-mono" placeholder="https://example.com/mcp" bind:value={url} />
416
+ <Field.Description>{t('mcp.client.urlHint')}</Field.Description>
417
+ </Field.Field>
418
+ {:else}
419
+ <Field.Field>
420
+ <Field.Label for="kit-mcp-command">{t('mcp.client.command')}</Field.Label>
421
+ <Input id="kit-mcp-command" class="font-mono" maxlength={1024} placeholder="npx" bind:value={command} />
422
+ </Field.Field>
423
+ <Field.Field>
424
+ <Field.Label for="kit-mcp-args">{t('mcp.client.args')}</Field.Label>
425
+ <Textarea id="kit-mcp-args" class="font-mono text-xs" placeholder={ARGS_PLACEHOLDER} bind:value={args} />
426
+ <Field.Description>{t('mcp.client.argsHint')}</Field.Description>
427
+ </Field.Field>
428
+ <Field.Field>
429
+ <Field.Label for="kit-mcp-cwd">{t('mcp.client.cwd')}</Field.Label>
430
+ <Input id="kit-mcp-cwd" class="font-mono" bind:value={cwd} />
431
+ <Field.Description>{t('mcp.client.cwdHint')}</Field.Description>
432
+ </Field.Field>
433
+ {/if}
434
+ {#if editId}
435
+ <Field.Field orientation="horizontal">
436
+ <Switch id="kit-mcp-replace" checked={replaceSecret} onCheckedChange={changeReplacement} />
437
+ <Field.Label for="kit-mcp-replace">
438
+ {t('mcp.client.replaceSecret', { keys: currentKeys.join(', ') || '—' })}
439
+ </Field.Label>
440
+ </Field.Field>
441
+ {/if}
442
+ {#if replaceSecret && kind === 'http'}
443
+ <Field.Field>
444
+ <Field.Label for="kit-mcp-headers">{t('mcp.client.headers')}</Field.Label>
445
+ <Textarea
446
+ id="kit-mcp-headers"
447
+ class="font-mono text-xs"
448
+ placeholder="Authorization: Bearer …"
449
+ bind:value={headers}
450
+ oninput={() => secretRevision++}
451
+ />
452
+ </Field.Field>
453
+ <Field.Field>
454
+ <Field.Label for="kit-mcp-query">{t('mcp.client.query')}</Field.Label>
455
+ <Textarea
456
+ id="kit-mcp-query"
457
+ class="font-mono text-xs"
458
+ placeholder="ak=…"
459
+ bind:value={query}
460
+ oninput={() => secretRevision++}
461
+ />
462
+ <Field.Description>{t('mcp.client.secretHint')}</Field.Description>
463
+ </Field.Field>
464
+ {:else if replaceSecret}
465
+ <Field.Field>
466
+ <Field.Label for="kit-mcp-env">{t('mcp.client.env')}</Field.Label>
467
+ <Textarea
468
+ id="kit-mcp-env"
469
+ class="font-mono text-xs"
470
+ placeholder="GITHUB_TOKEN=…"
471
+ bind:value={env}
472
+ oninput={() => secretRevision++}
473
+ />
474
+ <Field.Description>{t('mcp.client.envHint')}</Field.Description>
475
+ </Field.Field>
476
+ {/if}
477
+ </Field.Group>
478
+ <Dialog.Footer>
479
+ <Button variant="outline" onclick={closeEditor}>{t('common.cancel')}</Button>
480
+ <Button
481
+ disabled={busy ||
482
+ editorSaving ||
483
+ !name.trim() ||
484
+ !slug.trim() ||
485
+ !(kind === 'http' ? url.trim() : command.trim())}
486
+ onclick={save}>{t('mcp.client.save')}</Button
487
+ >
488
+ </Dialog.Footer>
489
+ </Dialog.Content>
490
+ </Dialog.Root>
491
+ {/if}