@geminilight/mindos 0.5.20 → 0.5.22

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 (46) hide show
  1. package/app/app/api/ask/route.ts +343 -178
  2. package/app/app/api/monitoring/route.ts +95 -0
  3. package/app/components/SettingsModal.tsx +58 -58
  4. package/app/components/settings/AgentsTab.tsx +240 -0
  5. package/app/components/settings/AiTab.tsx +4 -25
  6. package/app/components/settings/AppearanceTab.tsx +31 -13
  7. package/app/components/settings/KnowledgeTab.tsx +13 -28
  8. package/app/components/settings/McpAgentInstall.tsx +227 -0
  9. package/app/components/settings/McpServerStatus.tsx +172 -0
  10. package/app/components/settings/McpSkillsSection.tsx +583 -0
  11. package/app/components/settings/McpTab.tsx +17 -959
  12. package/app/components/settings/MonitoringTab.tsx +202 -0
  13. package/app/components/settings/PluginsTab.tsx +4 -27
  14. package/app/components/settings/Primitives.tsx +69 -0
  15. package/app/components/settings/ShortcutsTab.tsx +2 -4
  16. package/app/components/settings/SyncTab.tsx +8 -24
  17. package/app/components/settings/types.ts +116 -2
  18. package/app/instrumentation.ts +7 -2
  19. package/app/lib/agent/context.ts +151 -87
  20. package/app/lib/agent/index.ts +5 -3
  21. package/app/lib/agent/log.ts +1 -0
  22. package/app/lib/agent/model.ts +76 -10
  23. package/app/lib/agent/skill-rules.ts +70 -0
  24. package/app/lib/agent/stream-consumer.ts +73 -77
  25. package/app/lib/agent/to-agent-messages.ts +106 -0
  26. package/app/lib/agent/tools.ts +260 -266
  27. package/app/lib/api.ts +12 -3
  28. package/app/lib/core/csv.ts +2 -1
  29. package/app/lib/core/fs-ops.ts +7 -6
  30. package/app/lib/core/index.ts +1 -1
  31. package/app/lib/core/lines.ts +7 -6
  32. package/app/lib/core/search-index.ts +174 -0
  33. package/app/lib/core/search.ts +30 -1
  34. package/app/lib/core/security.ts +6 -3
  35. package/app/lib/errors.ts +108 -0
  36. package/app/lib/fs.ts +6 -3
  37. package/app/lib/i18n-en.ts +523 -0
  38. package/app/lib/i18n-zh.ts +548 -0
  39. package/app/lib/i18n.ts +4 -963
  40. package/app/lib/metrics.ts +81 -0
  41. package/app/next-env.d.ts +1 -1
  42. package/app/next.config.ts +1 -1
  43. package/app/package-lock.json +3258 -3093
  44. package/app/package.json +6 -3
  45. package/bin/cli.js +7 -4
  46. package/package.json +4 -1
@@ -0,0 +1,95 @@
1
+ export const dynamic = 'force-dynamic';
2
+ import { NextResponse } from 'next/server';
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { getMindRoot } from '@/lib/fs';
6
+ import { metrics } from '@/lib/metrics';
7
+
8
+ // Aligned with IGNORED_DIRS in lib/fs.ts and lib/core/tree.ts
9
+ const IGNORED_DIRS = new Set(['.git', 'node_modules', 'app', '.next', '.DS_Store', 'mcp']);
10
+
11
+ /**
12
+ * Recursively count files and sum their sizes under a directory.
13
+ * Skips directories in IGNORED_DIRS (aligned with the rest of the codebase).
14
+ */
15
+ function walkStats(dir: string): { fileCount: number; totalSizeBytes: number } {
16
+ let fileCount = 0;
17
+ let totalSizeBytes = 0;
18
+
19
+ function walk(current: string) {
20
+ let entries: fs.Dirent[];
21
+ try {
22
+ entries = fs.readdirSync(current, { withFileTypes: true });
23
+ } catch {
24
+ return;
25
+ }
26
+ for (const entry of entries) {
27
+ if (IGNORED_DIRS.has(entry.name)) continue;
28
+ const fullPath = path.join(current, entry.name);
29
+ if (entry.isDirectory()) {
30
+ walk(fullPath);
31
+ } else if (entry.isFile()) {
32
+ try {
33
+ const stat = fs.statSync(fullPath);
34
+ fileCount++;
35
+ totalSizeBytes += stat.size;
36
+ } catch { /* skip unreadable files */ }
37
+ }
38
+ }
39
+ }
40
+
41
+ walk(dir);
42
+ return { fileCount, totalSizeBytes };
43
+ }
44
+
45
+ // ── TTL cache for walkStats (avoid blocking event loop every 5s poll) ──
46
+ let cachedKbStats: { fileCount: number; totalSizeBytes: number } | null = null;
47
+ let cachedKbStatsTs = 0;
48
+ const KB_STATS_TTL = 30_000; // 30s
49
+
50
+ function getCachedKbStats(mindRoot: string): { fileCount: number; totalSizeBytes: number } {
51
+ const now = Date.now();
52
+ if (cachedKbStats && now - cachedKbStatsTs < KB_STATS_TTL) return cachedKbStats;
53
+ cachedKbStats = walkStats(mindRoot);
54
+ cachedKbStatsTs = now;
55
+ return cachedKbStats;
56
+ }
57
+
58
+ export async function GET() {
59
+ const snap = metrics.getSnapshot();
60
+ const mem = process.memoryUsage();
61
+ const mindRoot = getMindRoot();
62
+
63
+ const kbStats = getCachedKbStats(mindRoot);
64
+
65
+ // Detect MCP status from environment / config
66
+ const mcpPort = Number(process.env.MCP_PORT) || 3457;
67
+
68
+ return NextResponse.json({
69
+ system: {
70
+ uptimeMs: Date.now() - snap.processStartTime,
71
+ memory: {
72
+ heapUsed: mem.heapUsed,
73
+ heapTotal: mem.heapTotal,
74
+ rss: mem.rss,
75
+ },
76
+ nodeVersion: process.version,
77
+ },
78
+ application: {
79
+ agentRequests: snap.agentRequests,
80
+ toolExecutions: snap.toolExecutions,
81
+ totalTokens: snap.totalTokens,
82
+ avgResponseTimeMs: snap.avgResponseTimeMs,
83
+ errors: snap.errors,
84
+ },
85
+ knowledgeBase: {
86
+ root: mindRoot,
87
+ fileCount: kbStats.fileCount,
88
+ totalSizeBytes: kbStats.totalSizeBytes,
89
+ },
90
+ mcp: {
91
+ running: true, // If this endpoint responds, the server is running
92
+ port: mcpPort,
93
+ },
94
+ });
95
+ }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useState, useCallback } from 'react';
4
- import { X, Settings, Save, Loader2, AlertCircle, CheckCircle2, RotateCcw } from 'lucide-react';
3
+ import { useEffect, useState, useCallback, useRef } from 'react';
4
+ import { X, Settings, Loader2, AlertCircle, CheckCircle2, RotateCcw, Sparkles, Palette, Database, RefreshCw, Plug, Puzzle, Activity, Users } from 'lucide-react';
5
5
  import { useLocale } from '@/lib/LocaleContext';
6
6
  import { getAllRenderers, loadDisabledState, isRendererEnabled } from '@/lib/renderers/registry';
7
7
  import { apiFetch } from '@/lib/api';
@@ -12,9 +12,10 @@ import { AiTab } from './settings/AiTab';
12
12
  import { AppearanceTab } from './settings/AppearanceTab';
13
13
  import { KnowledgeTab } from './settings/KnowledgeTab';
14
14
  import { PluginsTab } from './settings/PluginsTab';
15
- import { ShortcutsTab } from './settings/ShortcutsTab';
16
15
  import { SyncTab } from './settings/SyncTab';
17
16
  import { McpTab } from './settings/McpTab';
17
+ import { MonitoringTab } from './settings/MonitoringTab';
18
+ import { AgentsTab } from './settings/AgentsTab';
18
19
 
19
20
  interface SettingsModalProps {
20
21
  open: boolean;
@@ -28,6 +29,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
28
29
  const [saving, setSaving] = useState(false);
29
30
  const [status, setStatus] = useState<'idle' | 'saved' | 'error' | 'load-error'>('idle');
30
31
  const { t, locale, setLocale } = useLocale();
32
+ const saveTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
33
+ const dataLoaded = useRef(false);
31
34
 
32
35
  // Appearance state (localStorage-based)
33
36
  const [font, setFont] = useState('lora');
@@ -37,8 +40,8 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
37
40
  const [pluginStates, setPluginStates] = useState<Record<string, boolean>>({});
38
41
 
39
42
  useEffect(() => {
40
- if (!open) return;
41
- apiFetch<SettingsData>('/api/settings').then(setData).catch(() => setStatus('load-error'));
43
+ if (!open) { dataLoaded.current = false; return; }
44
+ apiFetch<SettingsData>('/api/settings').then(d => { setData(d); dataLoaded.current = true; }).catch(() => setStatus('load-error'));
42
45
  setFont(localStorage.getItem('prose-font') ?? 'lora');
43
46
  setContentWidth(localStorage.getItem('content-width') ?? '780px');
44
47
  const stored = localStorage.getItem('theme');
@@ -81,14 +84,14 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
81
84
  return () => window.removeEventListener('keydown', handler);
82
85
  }, [open, onClose]);
83
86
 
84
- const handleSave = useCallback(async () => {
85
- if (!data) return;
87
+ // Auto-save with debounce when data changes
88
+ const doSave = useCallback(async (d: SettingsData) => {
86
89
  setSaving(true);
87
90
  try {
88
91
  await apiFetch('/api/settings', {
89
92
  method: 'POST',
90
93
  headers: { 'Content-Type': 'application/json' },
91
- body: JSON.stringify({ ai: data.ai, agent: data.agent, mindRoot: data.mindRoot, webPassword: data.webPassword, authToken: data.authToken }),
94
+ body: JSON.stringify({ ai: d.ai, agent: d.agent, mindRoot: d.mindRoot, webPassword: d.webPassword, authToken: d.authToken }),
92
95
  });
93
96
  setStatus('saved');
94
97
  setTimeout(() => setStatus('idle'), 2500);
@@ -98,7 +101,14 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
98
101
  } finally {
99
102
  setSaving(false);
100
103
  }
101
- }, [data]);
104
+ }, []);
105
+
106
+ useEffect(() => {
107
+ if (!data || !dataLoaded.current) return;
108
+ clearTimeout(saveTimer.current);
109
+ saveTimer.current = setTimeout(() => doSave(data), 800);
110
+ return () => clearTimeout(saveTimer.current);
111
+ }, [data, doSave]);
102
112
 
103
113
  const updateAi = useCallback((patch: Partial<AiSettings>) => {
104
114
  setData(d => d ? { ...d, ai: { ...d.ai, ...patch } } : d);
@@ -117,36 +127,30 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
117
127
  openai: { apiKey: '', model: '', baseUrl: '' },
118
128
  },
119
129
  };
130
+ // Set defaults — auto-save will persist them
120
131
  setData(d => d ? { ...d, ai: defaults } : d);
121
- setSaving(true);
122
- try {
123
- await apiFetch('/api/settings', {
124
- method: 'POST',
125
- headers: { 'Content-Type': 'application/json' },
126
- body: JSON.stringify({ ai: defaults, mindRoot: data.mindRoot }),
127
- });
128
- setStatus('saved');
129
- } catch {
130
- setStatus('error');
131
- } finally {
132
- setSaving(false);
133
- }
134
- apiFetch<SettingsData>('/api/settings').then(setData).catch(() => setStatus('error'));
135
- setTimeout(() => setStatus('idle'), 2500);
132
+ // 🟢 MINOR #4: Refetch after auto-save completes (800ms debounce + 500ms save operation)
133
+ // Rather than magic 1200ms, wait for save to finish before refetching env-resolved values
134
+ const DEBOUNCE_DELAY = 800;
135
+ const SAVE_OPERATION_TIME = 500;
136
+ setTimeout(() => {
137
+ apiFetch<SettingsData>('/api/settings').then(d => { setData(d); }).catch(() => setStatus('error'));
138
+ }, DEBOUNCE_DELAY + SAVE_OPERATION_TIME);
136
139
  }, [data]);
137
140
 
138
141
  if (!open) return null;
139
142
 
140
143
  const env = data?.envOverrides ?? {};
141
144
 
142
- const TABS: { id: Tab; label: string }[] = [
143
- { id: 'ai', label: t.settings.tabs.ai },
144
- { id: 'appearance', label: t.settings.tabs.appearance },
145
- { id: 'knowledge', label: t.settings.tabs.knowledge },
146
- { id: 'sync', label: t.settings.tabs.sync ?? 'Sync' },
147
- { id: 'mcp', label: t.settings.tabs.mcp ?? 'MCP' },
148
- { id: 'plugins', label: t.settings.tabs.plugins },
149
- { id: 'shortcuts', label: t.settings.tabs.shortcuts },
145
+ const TABS: { id: Tab; label: string; icon: React.ReactNode }[] = [
146
+ { id: 'ai', label: t.settings.tabs.ai, icon: <Sparkles size={13} /> },
147
+ { id: 'appearance', label: t.settings.tabs.appearance, icon: <Palette size={13} /> },
148
+ { id: 'knowledge', label: t.settings.tabs.knowledge, icon: <Database size={13} /> },
149
+ { id: 'sync', label: t.settings.tabs.sync ?? 'Sync', icon: <RefreshCw size={13} /> },
150
+ { id: 'mcp', label: t.settings.tabs.mcp ?? 'MCP', icon: <Plug size={13} /> },
151
+ { id: 'plugins', label: t.settings.tabs.plugins, icon: <Puzzle size={13} /> },
152
+ { id: 'monitoring', label: t.settings.tabs.monitoring, icon: <Activity size={13} /> },
153
+ { id: 'agents', label: t.settings.tabs.agents ?? 'Agents', icon: <Users size={13} /> },
150
154
  ];
151
155
 
152
156
  return (
@@ -177,12 +181,13 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
177
181
  <button
178
182
  key={t.id}
179
183
  onClick={() => setTab(t.id)}
180
- className={`px-3 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
184
+ className={`flex items-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 -mb-px whitespace-nowrap ${
181
185
  tab === t.id
182
186
  ? 'border-amber-500 text-foreground'
183
187
  : 'border-transparent text-muted-foreground hover:text-foreground'
184
188
  }`}
185
189
  >
190
+ {t.icon}
186
191
  {t.label}
187
192
  </button>
188
193
  ))}
@@ -196,7 +201,7 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
196
201
  <p className="text-sm text-destructive font-medium">Failed to load settings</p>
197
202
  <p className="text-xs text-muted-foreground">Check that the server is running and AUTH_TOKEN is configured correctly.</p>
198
203
  </div>
199
- ) : !data && tab !== 'shortcuts' && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' ? (
204
+ ) : !data && tab !== 'appearance' && tab !== 'mcp' && tab !== 'sync' && tab !== 'agents' ? (
200
205
  <div className="flex justify-center py-8">
201
206
  <Loader2 size={18} className="animate-spin text-muted-foreground" />
202
207
  </div>
@@ -206,54 +211,49 @@ export default function SettingsModal({ open, onClose, initialTab }: SettingsMod
206
211
  {tab === 'appearance' && <AppearanceTab font={font} setFont={setFont} contentWidth={contentWidth} setContentWidth={setContentWidth} dark={dark} setDark={setDark} locale={locale} setLocale={setLocale} t={t} />}
207
212
  {tab === 'knowledge' && data && <KnowledgeTab data={data} setData={setData} t={t} />}
208
213
  {tab === 'plugins' && <PluginsTab pluginStates={pluginStates} setPluginStates={setPluginStates} t={t} />}
209
- {tab === 'shortcuts' && <ShortcutsTab t={t} />}
210
214
  {tab === 'sync' && <SyncTab t={t} />}
211
215
  {tab === 'mcp' && <McpTab t={t} />}
216
+ {tab === 'monitoring' && <MonitoringTab t={t} />}
217
+ {tab === 'agents' && <AgentsTab t={t} />}
212
218
  </>
213
219
  )}
214
220
  </div>
215
221
 
216
- {/* Footer */}
222
+ {/* Footer — status bar + contextual actions */}
217
223
  {(tab === 'ai' || tab === 'knowledge') && (
218
- <div className="px-5 py-3 border-t border-border shrink-0 flex items-center justify-between">
224
+ <div className="px-5 py-2.5 border-t border-border shrink-0 flex items-center justify-between">
219
225
  <div className="flex items-center gap-3">
220
226
  {tab === 'ai' && Object.values(env).some(Boolean) && (
221
227
  <button
222
228
  onClick={restoreFromEnv}
223
229
  disabled={saving || !data}
224
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
230
+ className="flex items-center gap-1.5 px-3 py-1 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
225
231
  >
226
- <RotateCcw size={13} />
232
+ <RotateCcw size={12} />
227
233
  {t.settings.ai.restoreFromEnv}
228
234
  </button>
229
235
  )}
230
236
  {tab === 'knowledge' && (
231
237
  <a
232
238
  href="/setup?force=1"
233
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
239
+ className="flex items-center gap-1.5 px-3 py-1 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
234
240
  >
235
- <RotateCcw size={13} />
241
+ <RotateCcw size={12} />
236
242
  {t.settings.reconfigure}
237
243
  </a>
238
244
  )}
239
- <div className="flex items-center gap-1.5 text-xs">
240
- {status === 'saved' && (
241
- <><CheckCircle2 size={13} className="text-success" /><span className="text-success">{t.settings.saved}</span></>
242
- )}
243
- {status === 'error' && (
244
- <><AlertCircle size={13} className="text-destructive" /><span className="text-destructive">{t.settings.saveFailed}</span></>
245
- )}
246
- </div>
247
245
  </div>
248
- <button
249
- onClick={handleSave}
250
- disabled={saving || !data}
251
- className="flex items-center gap-1.5 px-4 py-1.5 text-sm rounded-lg disabled:opacity-40 disabled:cursor-not-allowed transition-opacity"
252
- style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
253
- >
254
- {saving ? <Loader2 size={13} className="animate-spin" /> : <Save size={13} />}
255
- {t.settings.save}
256
- </button>
246
+ <div className="flex items-center gap-1.5 text-xs" role="status" aria-live="polite">
247
+ {saving && (
248
+ <><Loader2 size={12} className="animate-spin text-muted-foreground" /><span className="text-muted-foreground">{t.settings.save}...</span></>
249
+ )}
250
+ {status === 'saved' && (
251
+ <><CheckCircle2 size={12} className="text-success" /><span className="text-success">{t.settings.saved}</span></>
252
+ )}
253
+ {status === 'error' && (
254
+ <><AlertCircle size={12} className="text-destructive" /><span className="text-destructive">{t.settings.saveFailed}</span></>
255
+ )}
256
+ </div>
257
257
  </div>
258
258
  )}
259
259
  </div>
@@ -0,0 +1,240 @@
1
+ import { useState, useEffect, useCallback, useRef } from 'react';
2
+ import { Loader2, RefreshCw, ChevronDown, ChevronRight, ExternalLink } from 'lucide-react';
3
+ import { apiFetch } from '@/lib/api';
4
+ import type { McpStatus, AgentInfo } from './types';
5
+ import type { Messages } from '@/lib/i18n';
6
+
7
+ interface AgentsTabProps {
8
+ t: Messages;
9
+ }
10
+
11
+ export function AgentsTab({ t }: AgentsTabProps) {
12
+ const [agents, setAgents] = useState<AgentInfo[]>([]);
13
+ const [mcpStatus, setMcpStatus] = useState<McpStatus | null>(null);
14
+ const [loading, setLoading] = useState(true);
15
+ const [error, setError] = useState(false);
16
+ const [refreshing, setRefreshing] = useState(false);
17
+ const [showNotDetected, setShowNotDetected] = useState(false);
18
+ const intervalRef = useRef<ReturnType<typeof setInterval>>(undefined);
19
+
20
+ const a = t.settings?.agents as Record<string, unknown> | undefined;
21
+
22
+ // i18n helpers with fallbacks
23
+ const txt = (key: string, fallback: string) => (a?.[key] as string) ?? fallback;
24
+ const txtFn = <T,>(key: string, fallback: (v: T) => string) =>
25
+ (a?.[key] as ((v: T) => string) | undefined) ?? fallback;
26
+
27
+ const fetchAll = useCallback(async (silent = false) => {
28
+ if (!silent) setError(false);
29
+ try {
30
+ const [statusData, agentsData] = await Promise.all([
31
+ apiFetch<McpStatus>('/api/mcp/status'),
32
+ apiFetch<{ agents: AgentInfo[] }>('/api/mcp/agents'),
33
+ ]);
34
+ setMcpStatus(statusData);
35
+ setAgents(agentsData.agents);
36
+ setError(false);
37
+ } catch {
38
+ if (!silent) setError(true);
39
+ }
40
+ setLoading(false);
41
+ setRefreshing(false);
42
+ }, []);
43
+
44
+ // Initial fetch + 30s auto-refresh
45
+ useEffect(() => {
46
+ fetchAll();
47
+ intervalRef.current = setInterval(() => fetchAll(true), 30_000);
48
+ return () => clearInterval(intervalRef.current);
49
+ }, [fetchAll]);
50
+
51
+ const handleRefresh = () => {
52
+ setRefreshing(true);
53
+ fetchAll();
54
+ };
55
+
56
+ if (loading) {
57
+ return (
58
+ <div className="flex justify-center py-8">
59
+ <Loader2 size={18} className="animate-spin text-muted-foreground" />
60
+ </div>
61
+ );
62
+ }
63
+
64
+ if (error && agents.length === 0) {
65
+ return (
66
+ <div className="flex flex-col items-center gap-3 py-8 text-center">
67
+ <p className="text-sm text-destructive">{txt('fetchError', 'Failed to load agent data')}</p>
68
+ <button
69
+ onClick={handleRefresh}
70
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
71
+ >
72
+ <RefreshCw size={12} />
73
+ {txt('refresh', 'Refresh')}
74
+ </button>
75
+ </div>
76
+ );
77
+ }
78
+
79
+ // Group agents
80
+ const connected = agents.filter(a => a.present && a.installed);
81
+ const detected = agents.filter(a => a.present && !a.installed);
82
+ const notFound = agents.filter(a => !a.present);
83
+
84
+ return (
85
+ <div className="space-y-5">
86
+ {/* MCP Server Status */}
87
+ <div className="rounded-xl border border-border bg-card p-4 flex items-center justify-between">
88
+ <div className="flex items-center gap-3">
89
+ <span className="text-sm font-medium text-foreground">{txt('mcpServer', 'MCP Server')}</span>
90
+ {mcpStatus?.running ? (
91
+ <span className="flex items-center gap-1.5 text-xs">
92
+ <span className="w-2 h-2 rounded-full bg-emerald-500 inline-block" />
93
+ <span className="text-emerald-600 dark:text-emerald-400">
94
+ {txt('running', 'Running')} {txtFn<number>('onPort', (p) => `on :${p}`)(mcpStatus.port)}
95
+ </span>
96
+ </span>
97
+ ) : (
98
+ <span className="flex items-center gap-1.5 text-xs">
99
+ <span className="w-2 h-2 rounded-full bg-zinc-400 inline-block" />
100
+ <span className="text-muted-foreground">{txt('stopped', 'Not running')}</span>
101
+ </span>
102
+ )}
103
+ </div>
104
+ <button
105
+ onClick={handleRefresh}
106
+ disabled={refreshing}
107
+ className="flex items-center gap-1.5 px-2.5 py-1 text-xs rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted disabled:opacity-50 transition-colors"
108
+ >
109
+ <RefreshCw size={12} className={refreshing ? 'animate-spin' : ''} />
110
+ {refreshing ? txt('refreshing', 'Refreshing...') : txt('refresh', 'Refresh')}
111
+ </button>
112
+ </div>
113
+
114
+ {/* Connected Agents */}
115
+ {connected.length > 0 && (
116
+ <section>
117
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
118
+ {txtFn<number>('connectedCount', (n) => `Connected (${n})`)(connected.length)}
119
+ </h3>
120
+ <div className="space-y-2">
121
+ {connected.map(agent => (
122
+ <AgentCard key={agent.key} agent={agent} status="connected" />
123
+ ))}
124
+ </div>
125
+ </section>
126
+ )}
127
+
128
+ {/* Detected but not configured */}
129
+ {detected.length > 0 && (
130
+ <section>
131
+ <h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3">
132
+ {txtFn<number>('detectedCount', (n) => `Detected but not configured (${n})`)(detected.length)}
133
+ </h3>
134
+ <div className="space-y-2">
135
+ {detected.map(agent => (
136
+ <AgentCard
137
+ key={agent.key}
138
+ agent={agent}
139
+ status="detected"
140
+ connectLabel={txt('connect', 'Connect')}
141
+ />
142
+ ))}
143
+ </div>
144
+ </section>
145
+ )}
146
+
147
+ {/* Not Detected */}
148
+ {notFound.length > 0 && (
149
+ <section>
150
+ <button
151
+ onClick={() => setShowNotDetected(!showNotDetected)}
152
+ className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground uppercase tracking-wider mb-3 hover:text-foreground transition-colors"
153
+ >
154
+ {showNotDetected ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
155
+ {txtFn<number>('notDetectedCount', (n) => `Not Detected (${n})`)(notFound.length)}
156
+ </button>
157
+ {showNotDetected && (
158
+ <div className="space-y-2">
159
+ {notFound.map(agent => (
160
+ <AgentCard key={agent.key} agent={agent} status="notFound" />
161
+ ))}
162
+ </div>
163
+ )}
164
+ </section>
165
+ )}
166
+
167
+ {/* Empty state */}
168
+ {agents.length === 0 && (
169
+ <p className="text-sm text-muted-foreground text-center py-4">
170
+ {txt('noAgents', 'No agents detected on this machine.')}
171
+ </p>
172
+ )}
173
+
174
+ {/* Auto-refresh hint */}
175
+ <p className="text-[10px] text-muted-foreground/60 text-center">
176
+ {txt('autoRefresh', 'Auto-refresh every 30s')}
177
+ </p>
178
+ </div>
179
+ );
180
+ }
181
+
182
+ /* ── Agent Card ──────────────────────────────────────────────── */
183
+
184
+ function AgentCard({
185
+ agent,
186
+ status,
187
+ connectLabel,
188
+ }: {
189
+ agent: AgentInfo;
190
+ status: 'connected' | 'detected' | 'notFound';
191
+ connectLabel?: string;
192
+ }) {
193
+ const dot =
194
+ status === 'connected' ? 'bg-emerald-500' :
195
+ status === 'detected' ? 'bg-amber-500' :
196
+ 'bg-zinc-400';
197
+
198
+ return (
199
+ <div className="rounded-lg border border-border bg-card/50 px-4 py-3 flex items-center justify-between gap-3">
200
+ <div className="flex items-center gap-3 min-w-0">
201
+ <span className={`w-2 h-2 rounded-full shrink-0 ${dot}`} />
202
+ <span className="text-sm font-medium text-foreground truncate">{agent.name}</span>
203
+ {status === 'connected' && (
204
+ <div className="flex items-center gap-2 text-[11px] text-muted-foreground">
205
+ <span className="px-1.5 py-0.5 rounded bg-muted">{agent.transport}</span>
206
+ <span className="px-1.5 py-0.5 rounded bg-muted">{agent.scope}</span>
207
+ {agent.configPath && (
208
+ <span className="truncate max-w-[200px]" title={agent.configPath}>
209
+ {agent.configPath.replace(/^.*[/\\]/, '')}
210
+ </span>
211
+ )}
212
+ </div>
213
+ )}
214
+ </div>
215
+ {status === 'detected' && (
216
+ <a
217
+ href="#"
218
+ onClick={(e) => {
219
+ e.preventDefault();
220
+ // Navigate to MCP tab by dispatching a custom event
221
+ const settingsModal = document.querySelector('[role="dialog"][aria-label="Settings"]');
222
+ if (settingsModal) {
223
+ const mcpBtn = settingsModal.querySelectorAll('button');
224
+ for (const btn of mcpBtn) {
225
+ if (btn.textContent?.trim() === 'MCP') {
226
+ btn.click();
227
+ break;
228
+ }
229
+ }
230
+ }
231
+ }}
232
+ className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-lg bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20 transition-colors shrink-0"
233
+ >
234
+ {connectLabel ?? 'Connect'}
235
+ <ExternalLink size={11} />
236
+ </a>
237
+ )}
238
+ </div>
239
+ );
240
+ }
@@ -2,8 +2,8 @@
2
2
 
3
3
  import { useState, useRef, useCallback, useEffect } from 'react';
4
4
  import { AlertCircle, Loader2 } from 'lucide-react';
5
- import type { AiSettings, AgentSettings, ProviderConfig, SettingsData } from './types';
6
- import { Field, Select, Input, EnvBadge, ApiKeyInput } from './Primitives';
5
+ import type { AiSettings, AgentSettings, ProviderConfig, SettingsData, AiTabProps } from './types';
6
+ import { Field, Select, Input, EnvBadge, ApiKeyInput, Toggle } from './Primitives';
7
7
 
8
8
  type TestState = 'idle' | 'testing' | 'ok' | 'error';
9
9
  type ErrorCode = 'auth_error' | 'model_not_found' | 'rate_limited' | 'network_error' | 'unknown';
@@ -15,7 +15,7 @@ interface TestResult {
15
15
  code?: ErrorCode;
16
16
  }
17
17
 
18
- function errorMessage(t: any, code?: ErrorCode): string {
18
+ function errorMessage(t: AiTabProps['t'], code?: ErrorCode): string {
19
19
  switch (code) {
20
20
  case 'auth_error': return t.settings.ai.testKeyAuthError;
21
21
  case 'model_not_found': return t.settings.ai.testKeyModelNotFound;
@@ -25,13 +25,6 @@ function errorMessage(t: any, code?: ErrorCode): string {
25
25
  }
26
26
  }
27
27
 
28
- interface AiTabProps {
29
- data: SettingsData;
30
- updateAi: (patch: Partial<AiSettings>) => void;
31
- updateAgent: (patch: Partial<AgentSettings>) => void;
32
- t: any;
33
- }
34
-
35
28
  export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
36
29
  const env = data.envOverrides ?? {};
37
30
  const envVal = data.envValues ?? {};
@@ -262,21 +255,7 @@ export function AiTab({ data, updateAi, updateAgent, t }: AiTabProps) {
262
255
  <div className="text-sm text-foreground">{t.settings.agent.thinking}</div>
263
256
  <div className="text-xs text-muted-foreground mt-0.5">{t.settings.agent.thinkingHint}</div>
264
257
  </div>
265
- <button
266
- type="button"
267
- role="switch"
268
- aria-checked={data.agent?.enableThinking ?? false}
269
- onClick={() => updateAgent({ enableThinking: !(data.agent?.enableThinking ?? false) })}
270
- className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
271
- data.agent?.enableThinking ? 'bg-amber-500' : 'bg-muted'
272
- }`}
273
- >
274
- <span
275
- className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
276
- data.agent?.enableThinking ? 'translate-x-4' : 'translate-x-0'
277
- }`}
278
- />
279
- </button>
258
+ <Toggle checked={data.agent?.enableThinking ?? false} onChange={() => updateAgent({ enableThinking: !(data.agent?.enableThinking ?? false) })} />
280
259
  </div>
281
260
 
282
261
  {data.agent?.enableThinking && (