@geminilight/mindos 0.3.0 → 0.5.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 (80) hide show
  1. package/app/app/api/mcp/agents/route.ts +72 -0
  2. package/app/app/api/mcp/install/route.ts +95 -0
  3. package/app/app/api/mcp/status/route.ts +47 -0
  4. package/app/app/api/setup/check-port/route.ts +41 -0
  5. package/app/app/api/skills/route.ts +208 -0
  6. package/app/app/api/sync/route.ts +54 -3
  7. package/app/app/api/update-check/route.ts +52 -0
  8. package/app/app/globals.css +12 -0
  9. package/app/app/layout.tsx +4 -2
  10. package/app/app/login/page.tsx +20 -13
  11. package/app/app/page.tsx +19 -2
  12. package/app/app/setup/page.tsx +2 -0
  13. package/app/app/view/[...path]/ViewPageClient.tsx +47 -21
  14. package/app/app/view/[...path]/loading.tsx +1 -1
  15. package/app/app/view/[...path]/not-found.tsx +101 -0
  16. package/app/components/AskFab.tsx +1 -1
  17. package/app/components/AskModal.tsx +1 -1
  18. package/app/components/Backlinks.tsx +1 -1
  19. package/app/components/Breadcrumb.tsx +13 -3
  20. package/app/components/CsvView.tsx +5 -6
  21. package/app/components/DirView.tsx +42 -21
  22. package/app/components/FindInPage.tsx +211 -0
  23. package/app/components/HomeContent.tsx +97 -44
  24. package/app/components/JsonView.tsx +1 -2
  25. package/app/components/MarkdownEditor.tsx +1 -2
  26. package/app/components/OnboardingView.tsx +6 -7
  27. package/app/components/SettingsModal.tsx +5 -2
  28. package/app/components/SetupWizard.tsx +499 -172
  29. package/app/components/Sidebar.tsx +1 -1
  30. package/app/components/UpdateBanner.tsx +101 -0
  31. package/app/components/renderers/{AgentInspectorRenderer.tsx → agent-inspector/AgentInspectorRenderer.tsx} +13 -11
  32. package/app/components/renderers/agent-inspector/manifest.ts +14 -0
  33. package/app/components/renderers/{BacklinksRenderer.tsx → backlinks/BacklinksRenderer.tsx} +6 -6
  34. package/app/components/renderers/backlinks/manifest.ts +14 -0
  35. package/app/components/renderers/config/manifest.ts +14 -0
  36. package/app/components/renderers/csv/BoardView.tsx +12 -12
  37. package/app/components/renderers/csv/ConfigPanel.tsx +7 -8
  38. package/app/components/renderers/{CsvRenderer.tsx → csv/CsvRenderer.tsx} +8 -9
  39. package/app/components/renderers/csv/GalleryView.tsx +3 -3
  40. package/app/components/renderers/csv/TableView.tsx +4 -5
  41. package/app/components/renderers/csv/manifest.ts +14 -0
  42. package/app/components/renderers/{DiffRenderer.tsx → diff/DiffRenderer.tsx} +10 -9
  43. package/app/components/renderers/diff/manifest.ts +14 -0
  44. package/app/components/renderers/{GraphRenderer.tsx → graph/GraphRenderer.tsx} +4 -5
  45. package/app/components/renderers/graph/manifest.ts +14 -0
  46. package/app/components/renderers/{SummaryRenderer.tsx → summary/SummaryRenderer.tsx} +6 -6
  47. package/app/components/renderers/summary/manifest.ts +14 -0
  48. package/app/components/renderers/{TimelineRenderer.tsx → timeline/TimelineRenderer.tsx} +6 -6
  49. package/app/components/renderers/timeline/manifest.ts +14 -0
  50. package/app/components/renderers/{TodoRenderer.tsx → todo/TodoRenderer.tsx} +2 -2
  51. package/app/components/renderers/todo/manifest.ts +14 -0
  52. package/app/components/renderers/{WorkflowRenderer.tsx → workflow/WorkflowRenderer.tsx} +13 -13
  53. package/app/components/renderers/workflow/manifest.ts +14 -0
  54. package/app/components/settings/McpTab.tsx +549 -0
  55. package/app/components/settings/SyncTab.tsx +139 -50
  56. package/app/components/settings/types.ts +1 -1
  57. package/app/data/pages/home.png +0 -0
  58. package/app/lib/i18n.ts +226 -19
  59. package/app/lib/renderers/index.ts +20 -89
  60. package/app/lib/renderers/registry.ts +4 -1
  61. package/app/lib/settings.ts +3 -0
  62. package/app/package.json +1 -0
  63. package/app/types/semver.d.ts +8 -0
  64. package/bin/cli.js +137 -24
  65. package/bin/lib/build.js +53 -18
  66. package/bin/lib/colors.js +3 -1
  67. package/bin/lib/config.js +4 -0
  68. package/bin/lib/constants.js +2 -0
  69. package/bin/lib/debug.js +10 -0
  70. package/bin/lib/mcp-install.js +4 -1
  71. package/bin/lib/port.js +8 -2
  72. package/bin/lib/startup.js +21 -20
  73. package/bin/lib/stop.js +41 -3
  74. package/bin/lib/sync.js +65 -53
  75. package/bin/lib/update-check.js +94 -0
  76. package/bin/lib/utils.js +2 -2
  77. package/package.json +1 -1
  78. package/scripts/gen-renderer-index.js +57 -0
  79. package/scripts/setup.js +205 -10
  80. /package/app/components/renderers/{ConfigRenderer.tsx → config/ConfigRenderer.tsx} +0 -0
@@ -0,0 +1,549 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useCallback } from 'react';
4
+ import {
5
+ Plug, CheckCircle2, AlertCircle, Loader2, Copy, Check,
6
+ ChevronDown, ChevronRight, Trash2, Plus, X,
7
+ } from 'lucide-react';
8
+ import { SectionLabel } from './Primitives';
9
+ import { apiFetch } from '@/lib/api';
10
+
11
+ /* ── Types ─────────────────────────────────────────────────────── */
12
+
13
+ interface McpStatus {
14
+ running: boolean;
15
+ transport: string;
16
+ endpoint: string;
17
+ port: number;
18
+ toolCount: number;
19
+ authConfigured: boolean;
20
+ }
21
+
22
+ interface AgentInfo {
23
+ key: string;
24
+ name: string;
25
+ installed: boolean;
26
+ scope?: string;
27
+ transport?: string;
28
+ configPath?: string;
29
+ hasProjectScope: boolean;
30
+ hasGlobalScope: boolean;
31
+ }
32
+
33
+ interface SkillInfo {
34
+ name: string;
35
+ description: string;
36
+ path: string;
37
+ source: 'builtin' | 'user';
38
+ enabled: boolean;
39
+ editable: boolean;
40
+ }
41
+
42
+ interface McpTabProps {
43
+ t: any;
44
+ }
45
+
46
+ /* ── Helpers ───────────────────────────────────────────────────── */
47
+
48
+ function CopyButton({ text, label }: { text: string; label: string }) {
49
+ const [copied, setCopied] = useState(false);
50
+ const handleCopy = async () => {
51
+ try {
52
+ await navigator.clipboard.writeText(text);
53
+ setCopied(true);
54
+ setTimeout(() => setCopied(false), 2000);
55
+ } catch { /* clipboard unavailable */ }
56
+ };
57
+ return (
58
+ <button
59
+ onClick={handleCopy}
60
+ className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
61
+ >
62
+ {copied ? <Check size={11} /> : <Copy size={11} />}
63
+ {copied ? 'Copied!' : label}
64
+ </button>
65
+ );
66
+ }
67
+
68
+ /* ── MCP Server Status ─────────────────────────────────────────── */
69
+
70
+ function ServerStatus({ status, t }: { status: McpStatus | null; t: any }) {
71
+ const m = t.settings?.mcp;
72
+ if (!status) return null;
73
+
74
+ const configSnippet = JSON.stringify({
75
+ mcpServers: {
76
+ mindos: status.running
77
+ ? { url: status.endpoint }
78
+ : { type: 'stdio', command: 'mindos', args: ['mcp'] },
79
+ },
80
+ }, null, 2);
81
+
82
+ return (
83
+ <div className="space-y-3">
84
+ <div className="flex items-center gap-3">
85
+ <div className="w-8 h-8 rounded-lg bg-muted flex items-center justify-center shrink-0">
86
+ <Plug size={16} className="text-muted-foreground" />
87
+ </div>
88
+ <div>
89
+ <h3 className="text-sm font-medium text-foreground">{m?.serverTitle ?? 'MindOS MCP Server'}</h3>
90
+ </div>
91
+ </div>
92
+
93
+ <div className="space-y-1.5 text-sm pl-11">
94
+ <div className="flex items-center gap-2">
95
+ <span className="text-muted-foreground w-20 shrink-0 text-xs">{m?.status ?? 'Status'}</span>
96
+ <span className={`text-xs flex items-center gap-1 ${status.running ? 'text-green-500' : 'text-muted-foreground'}`}>
97
+ <span className={`inline-block w-1.5 h-1.5 rounded-full ${status.running ? 'bg-green-500' : 'bg-muted-foreground'}`} />
98
+ {status.running ? (m?.running ?? 'Running') : (m?.stopped ?? 'Stopped')}
99
+ </span>
100
+ </div>
101
+ <div className="flex items-center gap-2">
102
+ <span className="text-muted-foreground w-20 shrink-0 text-xs">{m?.transport ?? 'Transport'}</span>
103
+ <span className="text-xs font-mono">{status.transport.toUpperCase()}</span>
104
+ </div>
105
+ <div className="flex items-center gap-2">
106
+ <span className="text-muted-foreground w-20 shrink-0 text-xs">{m?.endpoint ?? 'Endpoint'}</span>
107
+ <span className="text-xs font-mono truncate">{status.endpoint}</span>
108
+ </div>
109
+ <div className="flex items-center gap-2">
110
+ <span className="text-muted-foreground w-20 shrink-0 text-xs">{m?.tools ?? 'Tools'}</span>
111
+ <span className="text-xs">{m?.toolsRegistered ? m.toolsRegistered(status.toolCount) : `${status.toolCount} registered`}</span>
112
+ </div>
113
+ <div className="flex items-center gap-2">
114
+ <span className="text-muted-foreground w-20 shrink-0 text-xs">{m?.auth ?? 'Auth'}</span>
115
+ <span className="text-xs">
116
+ {status.authConfigured
117
+ ? <span className="text-green-500">{m?.authSet ?? 'Token set'}</span>
118
+ : <span className="text-muted-foreground">{m?.authNotSet ?? 'No token'}</span>}
119
+ </span>
120
+ </div>
121
+ </div>
122
+
123
+ <div className="flex items-center gap-2 pl-11">
124
+ <CopyButton text={status.endpoint} label={m?.copyEndpoint ?? 'Copy Endpoint'} />
125
+ <CopyButton text={configSnippet} label={m?.copyConfig ?? 'Copy Config'} />
126
+ </div>
127
+ </div>
128
+ );
129
+ }
130
+
131
+ /* ── Agent Install ─────────────────────────────────────────────── */
132
+
133
+ function AgentInstall({ agents, t, onRefresh }: { agents: AgentInfo[]; t: any; onRefresh: () => void }) {
134
+ const m = t.settings?.mcp;
135
+ const [selected, setSelected] = useState<Set<string>>(new Set());
136
+ const [transport, setTransport] = useState<'stdio' | 'http'>('stdio');
137
+ const [httpUrl, setHttpUrl] = useState('http://localhost:8787/mcp');
138
+ const [httpToken, setHttpToken] = useState('');
139
+ const [scopes, setScopes] = useState<Record<string, 'project' | 'global'>>({});
140
+ const [installing, setInstalling] = useState(false);
141
+ const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
142
+
143
+ const toggle = (key: string) => {
144
+ setSelected(prev => {
145
+ const next = new Set(prev);
146
+ if (next.has(key)) next.delete(key); else next.add(key);
147
+ return next;
148
+ });
149
+ };
150
+
151
+ const handleInstall = async () => {
152
+ if (selected.size === 0) return;
153
+ setInstalling(true);
154
+ setMessage(null);
155
+ try {
156
+ const payload = {
157
+ agents: [...selected].map(key => ({
158
+ key,
159
+ scope: scopes[key] || (agents.find(a => a.key === key)?.hasProjectScope ? 'project' : 'global'),
160
+ })),
161
+ transport,
162
+ ...(transport === 'http' ? { url: httpUrl, token: httpToken } : {}),
163
+ };
164
+ const res = await apiFetch<{ results: Array<{ agent: string; status: string; message?: string }> }>('/api/mcp/install', {
165
+ method: 'POST',
166
+ headers: { 'Content-Type': 'application/json' },
167
+ body: JSON.stringify(payload),
168
+ });
169
+ const ok = res.results.filter(r => r.status === 'ok').length;
170
+ const fail = res.results.filter(r => r.status === 'error');
171
+ if (fail.length > 0) {
172
+ setMessage({ type: 'error', text: fail.map(f => `${f.agent}: ${f.message}`).join('; ') });
173
+ } else {
174
+ setMessage({ type: 'success', text: m?.installSuccess ? m.installSuccess(ok) : `${ok} agent(s) configured` });
175
+ }
176
+ setSelected(new Set());
177
+ onRefresh();
178
+ } catch {
179
+ setMessage({ type: 'error', text: m?.installFailed ?? 'Install failed' });
180
+ } finally {
181
+ setInstalling(false);
182
+ setTimeout(() => setMessage(null), 4000);
183
+ }
184
+ };
185
+
186
+ return (
187
+ <div className="space-y-3">
188
+ <SectionLabel>{m?.agentsTitle ?? 'Agent Configuration'}</SectionLabel>
189
+
190
+ {/* Agent list */}
191
+ <div className="space-y-1">
192
+ {agents.map(agent => (
193
+ <div key={agent.key} className="flex items-center gap-3 py-1.5 text-sm">
194
+ <input
195
+ type="checkbox"
196
+ checked={selected.has(agent.key)}
197
+ onChange={() => toggle(agent.key)}
198
+ className="rounded border-border accent-amber-500"
199
+ />
200
+ <span className="w-28 shrink-0 text-xs">{agent.name}</span>
201
+ {agent.installed ? (
202
+ <>
203
+ <span className="text-[10px] px-1.5 py-0.5 rounded bg-green-500/15 text-green-500 font-mono">
204
+ {agent.transport}
205
+ </span>
206
+ <span className="text-[10px] text-muted-foreground">{agent.scope}</span>
207
+ </>
208
+ ) : (
209
+ <span className="text-[10px] text-muted-foreground">{m?.notInstalled ?? 'Not installed'}</span>
210
+ )}
211
+ {/* Scope selector */}
212
+ {selected.has(agent.key) && agent.hasProjectScope && agent.hasGlobalScope && (
213
+ <select
214
+ value={scopes[agent.key] || 'project'}
215
+ onChange={e => setScopes({ ...scopes, [agent.key]: e.target.value as 'project' | 'global' })}
216
+ className="ml-auto text-[10px] px-1.5 py-0.5 rounded border border-border bg-background text-foreground"
217
+ >
218
+ <option value="project">{m?.project ?? 'Project'}</option>
219
+ <option value="global">{m?.global ?? 'Global'}</option>
220
+ </select>
221
+ )}
222
+ </div>
223
+ ))}
224
+ </div>
225
+
226
+ {/* Transport selector */}
227
+ <div className="flex items-center gap-4 text-xs pt-1">
228
+ <label className="flex items-center gap-1.5 cursor-pointer">
229
+ <input
230
+ type="radio"
231
+ name="transport"
232
+ checked={transport === 'stdio'}
233
+ onChange={() => setTransport('stdio')}
234
+ className="accent-amber-500"
235
+ />
236
+ {m?.transportStdio ?? 'stdio (recommended)'}
237
+ </label>
238
+ <label className="flex items-center gap-1.5 cursor-pointer">
239
+ <input
240
+ type="radio"
241
+ name="transport"
242
+ checked={transport === 'http'}
243
+ onChange={() => setTransport('http')}
244
+ className="accent-amber-500"
245
+ />
246
+ {m?.transportHttp ?? 'http'}
247
+ </label>
248
+ </div>
249
+
250
+ {/* HTTP settings */}
251
+ {transport === 'http' && (
252
+ <div className="space-y-2 pl-5 text-xs">
253
+ <div className="space-y-1">
254
+ <label className="text-muted-foreground">{m?.httpUrl ?? 'MCP URL'}</label>
255
+ <input
256
+ type="text"
257
+ value={httpUrl}
258
+ onChange={e => setHttpUrl(e.target.value)}
259
+ className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background font-mono text-foreground outline-none focus:ring-1 focus:ring-ring"
260
+ />
261
+ </div>
262
+ <div className="space-y-1">
263
+ <label className="text-muted-foreground">{m?.httpToken ?? 'Auth Token'}</label>
264
+ <input
265
+ type="password"
266
+ value={httpToken}
267
+ onChange={e => setHttpToken(e.target.value)}
268
+ placeholder="Bearer token"
269
+ className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background font-mono text-foreground outline-none focus:ring-1 focus:ring-ring"
270
+ />
271
+ </div>
272
+ </div>
273
+ )}
274
+
275
+ {/* Install button */}
276
+ <button
277
+ onClick={handleInstall}
278
+ disabled={selected.size === 0 || installing}
279
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
280
+ style={{ background: 'var(--amber)', color: '#131210' }}
281
+ >
282
+ {installing && <Loader2 size={12} className="animate-spin" />}
283
+ {installing ? (m?.installing ?? 'Installing...') : (m?.installSelected ?? 'Install Selected')}
284
+ </button>
285
+
286
+ {/* Message */}
287
+ {message && (
288
+ <div className="flex items-center gap-1.5 text-xs" role="status">
289
+ {message.type === 'success' ? (
290
+ <><CheckCircle2 size={12} className="text-green-500" /><span className="text-green-500">{message.text}</span></>
291
+ ) : (
292
+ <><AlertCircle size={12} className="text-destructive" /><span className="text-destructive">{message.text}</span></>
293
+ )}
294
+ </div>
295
+ )}
296
+ </div>
297
+ );
298
+ }
299
+
300
+ /* ── Skills Section ────────────────────────────────────────────── */
301
+
302
+ function SkillsSection({ t }: { t: any }) {
303
+ const m = t.settings?.mcp;
304
+ const [skills, setSkills] = useState<SkillInfo[]>([]);
305
+ const [loading, setLoading] = useState(true);
306
+ const [expanded, setExpanded] = useState<string | null>(null);
307
+ const [adding, setAdding] = useState(false);
308
+ const [newName, setNewName] = useState('');
309
+ const [newDesc, setNewDesc] = useState('');
310
+ const [newContent, setNewContent] = useState('');
311
+ const [saving, setSaving] = useState(false);
312
+ const [error, setError] = useState('');
313
+
314
+ const fetchSkills = useCallback(async () => {
315
+ try {
316
+ const data = await apiFetch<{ skills: SkillInfo[] }>('/api/skills');
317
+ setSkills(data.skills);
318
+ } catch { /* ignore */ }
319
+ setLoading(false);
320
+ }, []);
321
+
322
+ useEffect(() => { fetchSkills(); }, [fetchSkills]);
323
+
324
+ const handleToggle = async (name: string, enabled: boolean) => {
325
+ try {
326
+ await apiFetch('/api/skills', {
327
+ method: 'POST',
328
+ headers: { 'Content-Type': 'application/json' },
329
+ body: JSON.stringify({ action: 'toggle', name, enabled }),
330
+ });
331
+ setSkills(prev => prev.map(s => s.name === name ? { ...s, enabled } : s));
332
+ } catch { /* ignore */ }
333
+ };
334
+
335
+ const handleDelete = async (name: string) => {
336
+ const confirmMsg = m?.skillDeleteConfirm ? m.skillDeleteConfirm(name) : `Delete skill "${name}"?`;
337
+ if (!confirm(confirmMsg)) return;
338
+ try {
339
+ await apiFetch('/api/skills', {
340
+ method: 'POST',
341
+ headers: { 'Content-Type': 'application/json' },
342
+ body: JSON.stringify({ action: 'delete', name }),
343
+ });
344
+ fetchSkills();
345
+ } catch { /* ignore */ }
346
+ };
347
+
348
+ const handleCreate = async () => {
349
+ if (!newName.trim()) return;
350
+ setSaving(true);
351
+ setError('');
352
+ try {
353
+ await apiFetch('/api/skills', {
354
+ method: 'POST',
355
+ headers: { 'Content-Type': 'application/json' },
356
+ body: JSON.stringify({ action: 'create', name: newName.trim(), description: newDesc.trim(), content: newContent }),
357
+ });
358
+ setAdding(false);
359
+ setNewName('');
360
+ setNewDesc('');
361
+ setNewContent('');
362
+ fetchSkills();
363
+ } catch (err: unknown) {
364
+ setError(err instanceof Error ? err.message : 'Failed to create skill');
365
+ } finally {
366
+ setSaving(false);
367
+ }
368
+ };
369
+
370
+ if (loading) {
371
+ return (
372
+ <div className="flex justify-center py-4">
373
+ <Loader2 size={16} className="animate-spin text-muted-foreground" />
374
+ </div>
375
+ );
376
+ }
377
+
378
+ return (
379
+ <div className="space-y-3">
380
+ <SectionLabel>{m?.skillsTitle ?? 'Skills'}</SectionLabel>
381
+
382
+ {skills.map(skill => (
383
+ <div key={skill.name} className="border border-border rounded-lg overflow-hidden">
384
+ <div
385
+ className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
386
+ onClick={() => setExpanded(expanded === skill.name ? null : skill.name)}
387
+ >
388
+ {expanded === skill.name ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
389
+ <span className="text-xs font-medium flex-1">{skill.name}</span>
390
+ <span className={`text-[10px] px-1.5 py-0.5 rounded ${
391
+ skill.source === 'builtin' ? 'bg-blue-500/15 text-blue-500' : 'bg-purple-500/15 text-purple-500'
392
+ }`}>
393
+ {skill.source === 'builtin' ? (m?.skillBuiltin ?? 'Built-in') : (m?.skillUser ?? 'Custom')}
394
+ </span>
395
+ {/* Toggle */}
396
+ <button
397
+ onClick={e => { e.stopPropagation(); handleToggle(skill.name, !skill.enabled); }}
398
+ className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors ${
399
+ skill.enabled ? 'bg-green-500' : 'bg-muted-foreground/30'
400
+ }`}
401
+ >
402
+ <span className={`inline-block h-3 w-3 rounded-full bg-white transition-transform ${
403
+ skill.enabled ? 'translate-x-3.5' : 'translate-x-0.5'
404
+ }`} />
405
+ </button>
406
+ </div>
407
+
408
+ {expanded === skill.name && (
409
+ <div className="px-3 py-2 border-t border-border text-xs space-y-1.5 bg-muted/20">
410
+ <p className="text-muted-foreground">{skill.description || 'No description'}</p>
411
+ <p className="text-muted-foreground font-mono text-[10px]">{skill.path}</p>
412
+ {skill.editable && (
413
+ <button
414
+ onClick={() => handleDelete(skill.name)}
415
+ className="flex items-center gap-1 text-[10px] text-destructive hover:underline"
416
+ >
417
+ <Trash2 size={10} />
418
+ {m?.deleteSkill ?? 'Delete'}
419
+ </button>
420
+ )}
421
+ </div>
422
+ )}
423
+ </div>
424
+ ))}
425
+
426
+ {/* Add skill form */}
427
+ {adding ? (
428
+ <div className="border border-border rounded-lg p-3 space-y-2">
429
+ <div className="flex items-center justify-between">
430
+ <span className="text-xs font-medium">{m?.addSkill ?? '+ Add Skill'}</span>
431
+ <button onClick={() => setAdding(false)} className="p-0.5 rounded hover:bg-muted text-muted-foreground">
432
+ <X size={12} />
433
+ </button>
434
+ </div>
435
+ <div className="space-y-1">
436
+ <label className="text-[10px] text-muted-foreground">{m?.skillName ?? 'Name'}</label>
437
+ <input
438
+ type="text"
439
+ value={newName}
440
+ onChange={e => setNewName(e.target.value.replace(/[^a-z0-9-]/g, ''))}
441
+ placeholder="my-skill"
442
+ className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background font-mono text-foreground outline-none focus:ring-1 focus:ring-ring"
443
+ />
444
+ </div>
445
+ <div className="space-y-1">
446
+ <label className="text-[10px] text-muted-foreground">{m?.skillDesc ?? 'Description'}</label>
447
+ <input
448
+ type="text"
449
+ value={newDesc}
450
+ onChange={e => setNewDesc(e.target.value)}
451
+ placeholder="What does this skill do?"
452
+ className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus:ring-1 focus:ring-ring"
453
+ />
454
+ </div>
455
+ <div className="space-y-1">
456
+ <label className="text-[10px] text-muted-foreground">{m?.skillContent ?? 'Content'}</label>
457
+ <textarea
458
+ value={newContent}
459
+ onChange={e => setNewContent(e.target.value)}
460
+ rows={6}
461
+ placeholder="Skill instructions (markdown)..."
462
+ className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus:ring-1 focus:ring-ring resize-y font-mono"
463
+ />
464
+ </div>
465
+ {error && (
466
+ <p className="text-[10px] text-destructive flex items-center gap-1">
467
+ <AlertCircle size={10} />
468
+ {error}
469
+ </p>
470
+ )}
471
+ <div className="flex items-center gap-2">
472
+ <button
473
+ onClick={handleCreate}
474
+ disabled={!newName.trim() || saving}
475
+ className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
476
+ style={{ background: 'var(--amber)', color: '#131210' }}
477
+ >
478
+ {saving && <Loader2 size={10} className="animate-spin" />}
479
+ {m?.saveSkill ?? 'Save'}
480
+ </button>
481
+ <button
482
+ onClick={() => setAdding(false)}
483
+ className="px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground transition-colors"
484
+ >
485
+ {m?.cancelSkill ?? 'Cancel'}
486
+ </button>
487
+ </div>
488
+ </div>
489
+ ) : (
490
+ <button
491
+ onClick={() => setAdding(true)}
492
+ className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
493
+ >
494
+ <Plus size={12} />
495
+ {m?.addSkill ?? '+ Add Skill'}
496
+ </button>
497
+ )}
498
+ </div>
499
+ );
500
+ }
501
+
502
+ /* ── Main McpTab ───────────────────────────────────────────────── */
503
+
504
+ export function McpTab({ t }: McpTabProps) {
505
+ const [mcpStatus, setMcpStatus] = useState<McpStatus | null>(null);
506
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
507
+ const [loading, setLoading] = useState(true);
508
+
509
+ const fetchAll = useCallback(async () => {
510
+ try {
511
+ const [statusData, agentsData] = await Promise.all([
512
+ apiFetch<McpStatus>('/api/mcp/status'),
513
+ apiFetch<{ agents: AgentInfo[] }>('/api/mcp/agents'),
514
+ ]);
515
+ setMcpStatus(statusData);
516
+ setAgents(agentsData.agents);
517
+ } catch { /* ignore */ }
518
+ setLoading(false);
519
+ }, []);
520
+
521
+ useEffect(() => { fetchAll(); }, [fetchAll]);
522
+
523
+ if (loading) {
524
+ return (
525
+ <div className="flex justify-center py-8">
526
+ <Loader2 size={18} className="animate-spin text-muted-foreground" />
527
+ </div>
528
+ );
529
+ }
530
+
531
+ return (
532
+ <div className="space-y-6">
533
+ {/* MCP Server Status */}
534
+ <ServerStatus status={mcpStatus} t={t} />
535
+
536
+ {/* Divider */}
537
+ <div className="border-t border-border" />
538
+
539
+ {/* Agent Install */}
540
+ <AgentInstall agents={agents} t={t} onRefresh={fetchAll} />
541
+
542
+ {/* Divider */}
543
+ <div className="border-t border-border" />
544
+
545
+ {/* Skills */}
546
+ <SkillsSection t={t} />
547
+ </div>
548
+ );
549
+ }