@geminilight/mindos 0.5.9 → 0.5.10

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 (49) hide show
  1. package/README.md +1 -1
  2. package/app/app/api/skills/route.ts +1 -1
  3. package/app/app/globals.css +10 -2
  4. package/app/app/login/page.tsx +1 -1
  5. package/app/app/view/[...path]/ViewPageClient.tsx +6 -1
  6. package/app/app/view/[...path]/not-found.tsx +1 -1
  7. package/app/components/AskModal.tsx +4 -4
  8. package/app/components/Breadcrumb.tsx +2 -2
  9. package/app/components/DirView.tsx +6 -6
  10. package/app/components/FileTree.tsx +2 -2
  11. package/app/components/HomeContent.tsx +7 -7
  12. package/app/components/OnboardingView.tsx +1 -1
  13. package/app/components/SearchModal.tsx +1 -1
  14. package/app/components/SettingsModal.tsx +2 -2
  15. package/app/components/SetupWizard.tsx +1 -1400
  16. package/app/components/Sidebar.tsx +4 -4
  17. package/app/components/SidebarLayout.tsx +9 -0
  18. package/app/components/SyncStatusBar.tsx +3 -3
  19. package/app/components/TableOfContents.tsx +1 -1
  20. package/app/components/UpdateBanner.tsx +1 -1
  21. package/app/components/ask/FileChip.tsx +1 -1
  22. package/app/components/ask/MentionPopover.tsx +4 -4
  23. package/app/components/ask/MessageList.tsx +1 -1
  24. package/app/components/ask/SessionHistory.tsx +2 -2
  25. package/app/components/renderers/config/ConfigRenderer.tsx +1 -1
  26. package/app/components/renderers/csv/BoardView.tsx +2 -2
  27. package/app/components/renderers/csv/ConfigPanel.tsx +5 -5
  28. package/app/components/renderers/csv/GalleryView.tsx +1 -1
  29. package/app/components/renderers/graph/GraphRenderer.tsx +1 -1
  30. package/app/components/renderers/summary/SummaryRenderer.tsx +1 -1
  31. package/app/components/renderers/workflow/WorkflowRenderer.tsx +2 -2
  32. package/app/components/settings/KnowledgeTab.tsx +1 -1
  33. package/app/components/settings/McpTab.tsx +27 -23
  34. package/app/components/settings/PluginsTab.tsx +4 -4
  35. package/app/components/settings/Primitives.tsx +1 -1
  36. package/app/components/settings/SyncTab.tsx +8 -8
  37. package/app/components/setup/StepAI.tsx +67 -0
  38. package/app/components/setup/StepAgents.tsx +237 -0
  39. package/app/components/setup/StepDots.tsx +39 -0
  40. package/app/components/setup/StepKB.tsx +237 -0
  41. package/app/components/setup/StepPorts.tsx +121 -0
  42. package/app/components/setup/StepReview.tsx +211 -0
  43. package/app/components/setup/StepSecurity.tsx +78 -0
  44. package/app/components/setup/constants.tsx +13 -0
  45. package/app/components/setup/index.tsx +464 -0
  46. package/app/components/setup/types.ts +53 -0
  47. package/app/lib/i18n.ts +4 -4
  48. package/package.json +1 -1
  49. package/skills/project-wiki/SKILL.md +92 -63
@@ -0,0 +1,67 @@
1
+ 'use client';
2
+
3
+ import { Brain, Zap, SkipForward, CheckCircle2 } from 'lucide-react';
4
+ import { Field, Input, ApiKeyInput } from '@/components/settings/Primitives';
5
+ import type { SetupState, SetupMessages } from './types';
6
+
7
+ export interface StepAIProps {
8
+ state: SetupState;
9
+ update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
10
+ s: SetupMessages;
11
+ }
12
+
13
+ export default function StepAI({ state, update, s }: StepAIProps) {
14
+ const providers = [
15
+ { id: 'anthropic' as const, icon: <Brain size={18} />, label: 'Anthropic', desc: 'Claude — claude-sonnet-4-6' },
16
+ { id: 'openai' as const, icon: <Zap size={18} />, label: 'OpenAI', desc: 'GPT or any OpenAI-compatible API' },
17
+ { id: 'skip' as const, icon: <SkipForward size={18} />, label: s.aiSkipTitle, desc: s.aiSkipDesc },
18
+ ];
19
+ return (
20
+ <div className="space-y-5">
21
+ <div className="grid grid-cols-1 gap-3">
22
+ {providers.map(p => (
23
+ <button key={p.id} onClick={() => update('provider', p.id)}
24
+ className="flex items-start gap-3 p-4 rounded-xl border text-left transition-all duration-150"
25
+ style={{
26
+ background: state.provider === p.id ? 'var(--amber-dim)' : 'var(--card)',
27
+ borderColor: state.provider === p.id ? 'var(--amber)' : 'var(--border)',
28
+ }}>
29
+ <span className="mt-0.5" style={{ color: state.provider === p.id ? 'var(--amber)' : 'var(--muted-foreground)' }}>
30
+ {p.icon}
31
+ </span>
32
+ <div>
33
+ <p className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>{p.label}</p>
34
+ <p className="text-xs mt-0.5" style={{ color: 'var(--muted-foreground)' }}>{p.desc}</p>
35
+ </div>
36
+ {state.provider === p.id && (
37
+ <CheckCircle2 size={16} className="ml-auto mt-0.5 shrink-0" style={{ color: 'var(--amber)' }} />
38
+ )}
39
+ </button>
40
+ ))}
41
+ </div>
42
+ {state.provider !== 'skip' && (
43
+ <div className="space-y-4 pt-2">
44
+ <Field label={s.apiKey}>
45
+ <ApiKeyInput
46
+ value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
47
+ onChange={v => update(state.provider === 'anthropic' ? 'anthropicKey' : 'openaiKey', v)}
48
+ placeholder={state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
49
+ />
50
+ </Field>
51
+ <Field label={s.model}>
52
+ <Input
53
+ value={state.provider === 'anthropic' ? state.anthropicModel : state.openaiModel}
54
+ onChange={e => update(state.provider === 'anthropic' ? 'anthropicModel' : 'openaiModel', e.target.value)}
55
+ />
56
+ </Field>
57
+ {state.provider === 'openai' && (
58
+ <Field label={s.baseUrl} hint={s.baseUrlHint}>
59
+ <Input value={state.openaiBaseUrl} onChange={e => update('openaiBaseUrl', e.target.value)}
60
+ placeholder="https://api.openai.com/v1" />
61
+ </Field>
62
+ )}
63
+ </div>
64
+ )}
65
+ </div>
66
+ );
67
+ }
@@ -0,0 +1,237 @@
1
+ 'use client';
2
+
3
+ import { useState, useMemo } from 'react';
4
+ import {
5
+ Loader2, CheckCircle2, XCircle, Brain, ChevronDown,
6
+ } from 'lucide-react';
7
+ import { Field, Select } from '@/components/settings/Primitives';
8
+ import type { SetupMessages, McpMessages, Template, AgentEntry, AgentInstallStatus } from './types';
9
+
10
+ export interface StepAgentsProps {
11
+ agents: AgentEntry[];
12
+ agentsLoading: boolean;
13
+ selectedAgents: Set<string>;
14
+ setSelectedAgents: React.Dispatch<React.SetStateAction<Set<string>>>;
15
+ agentTransport: 'auto' | 'stdio' | 'http';
16
+ setAgentTransport: (v: 'auto' | 'stdio' | 'http') => void;
17
+ agentScope: 'global' | 'project';
18
+ setAgentScope: (v: 'global' | 'project') => void;
19
+ agentStatuses: Record<string, AgentInstallStatus>;
20
+ s: SetupMessages;
21
+ settingsMcp: McpMessages;
22
+ template: Template;
23
+ }
24
+
25
+ export default function StepAgents({
26
+ agents, agentsLoading, selectedAgents, setSelectedAgents,
27
+ agentTransport, setAgentTransport, agentScope, setAgentScope,
28
+ agentStatuses, s, settingsMcp, template,
29
+ }: StepAgentsProps) {
30
+ const toggleAgent = (key: string) => {
31
+ setSelectedAgents(prev => {
32
+ const next = new Set(prev);
33
+ if (next.has(key)) next.delete(key); else next.add(key);
34
+ return next;
35
+ });
36
+ };
37
+
38
+ const [showOtherAgents, setShowOtherAgents] = useState(false);
39
+ const [showAdvanced, setShowAdvanced] = useState(false);
40
+
41
+ const getEffectiveTransport = (agent: AgentEntry) => {
42
+ if (agentTransport === 'auto') return agent.preferredTransport;
43
+ return agentTransport;
44
+ };
45
+
46
+ const getStatusBadge = (key: string, agent: AgentEntry) => {
47
+ const st = agentStatuses[key];
48
+ if (st) {
49
+ if (st.state === 'installing') return (
50
+ <span className="flex items-center gap-1 text-xs" style={{ color: 'var(--muted-foreground)' }}>
51
+ <Loader2 size={10} className="animate-spin" /> {s.agentInstalling}
52
+ </span>
53
+ );
54
+ if (st.state === 'ok') return (
55
+ <span className="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
56
+ style={{ background: 'color-mix(in srgb, var(--success) 12%, transparent)', color: 'var(--success)' }}>
57
+ <CheckCircle2 size={10} /> {s.agentStatusOk}
58
+ </span>
59
+ );
60
+ if (st.state === 'error') return (
61
+ <span className="flex items-center gap-1 text-xs px-1.5 py-0.5 rounded"
62
+ style={{ background: 'color-mix(in srgb, var(--error) 10%, transparent)', color: 'var(--error)' }}>
63
+ <XCircle size={10} /> {s.agentStatusError}
64
+ {st.message && <span className="ml-1 text-2xs">({st.message})</span>}
65
+ </span>
66
+ );
67
+ }
68
+ if (agent.installed) return (
69
+ <span className="text-xs px-1.5 py-0.5 rounded"
70
+ style={{ background: 'color-mix(in srgb, var(--success) 12%, transparent)', color: 'var(--success)' }}>
71
+ {settingsMcp.installed}
72
+ </span>
73
+ );
74
+ if (agent.present) return (
75
+ <span className="text-xs px-1.5 py-0.5 rounded"
76
+ style={{ background: 'var(--amber-dim)', color: 'var(--amber)' }}>
77
+ {s.agentDetected}
78
+ </span>
79
+ );
80
+ return (
81
+ <span className="text-xs px-1.5 py-0.5 rounded"
82
+ style={{ background: 'color-mix(in srgb, var(--muted-foreground) 10%, transparent)', color: 'var(--muted-foreground)' }}>
83
+ {s.agentNotFound}
84
+ </span>
85
+ );
86
+ };
87
+
88
+ const { detected, other } = useMemo(() => ({
89
+ detected: agents.filter(a => a.installed || a.present),
90
+ other: agents.filter(a => !a.installed && !a.present),
91
+ }), [agents]);
92
+
93
+ const renderAgentRow = (agent: AgentEntry, i: number) => (
94
+ <label key={agent.key}
95
+ className="flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors"
96
+ style={{
97
+ background: i % 2 === 0 ? 'var(--card)' : 'transparent',
98
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
99
+ }}>
100
+ <input
101
+ type="checkbox"
102
+ checked={selectedAgents.has(agent.key)}
103
+ onChange={() => toggleAgent(agent.key)}
104
+ style={{ accentColor: 'var(--amber)' }}
105
+ disabled={agentStatuses[agent.key]?.state === 'installing'}
106
+ />
107
+ <span className="text-sm flex-1" style={{ color: 'var(--foreground)' }}>{agent.name}</span>
108
+ <span className="text-2xs px-1.5 py-0.5 rounded font-mono"
109
+ style={{ background: 'color-mix(in srgb, var(--muted-foreground) 8%, transparent)', color: 'var(--muted-foreground)' }}>
110
+ {getEffectiveTransport(agent)}
111
+ </span>
112
+ {getStatusBadge(agent.key, agent)}
113
+ </label>
114
+ );
115
+
116
+ return (
117
+ <div className="space-y-5">
118
+ <p className="text-sm" style={{ color: 'var(--muted-foreground)' }}>{s.agentToolsHint}</p>
119
+ {agentsLoading ? (
120
+ <div className="flex items-center gap-2 py-4" style={{ color: 'var(--muted-foreground)' }}>
121
+ <Loader2 size={14} className="animate-spin" />
122
+ <span className="text-sm">{s.agentToolsLoading}</span>
123
+ </div>
124
+ ) : agents.length === 0 ? (
125
+ <p className="text-sm py-4 text-center" style={{ color: 'var(--muted-foreground)' }}>
126
+ {s.agentToolsEmpty}
127
+ </p>
128
+ ) : (
129
+ <>
130
+ {/* Badge legend */}
131
+ <div className="flex items-center gap-4 text-2xs" style={{ color: 'var(--muted-foreground)' }}>
132
+ <span className="flex items-center gap-1">
133
+ <span className="inline-block w-1.5 h-1.5 rounded-full" style={{ background: 'var(--success)' }} />
134
+ {s.badgeInstalled}
135
+ </span>
136
+ <span className="flex items-center gap-1">
137
+ <span className="inline-block w-1.5 h-1.5 rounded-full" style={{ background: 'var(--amber)' }} />
138
+ {s.badgeDetected}
139
+ </span>
140
+ <span className="flex items-center gap-1">
141
+ <span className="inline-block w-1.5 h-1.5 rounded-full" style={{ background: 'var(--muted-foreground)' }} />
142
+ {s.badgeNotFound}
143
+ </span>
144
+ </div>
145
+
146
+ {/* Detected agents — always visible */}
147
+ {detected.length > 0 ? (
148
+ <div className="rounded-xl border overflow-hidden" style={{ borderColor: 'var(--border)' }}>
149
+ {detected.map((agent, i) => renderAgentRow(agent, i))}
150
+ </div>
151
+ ) : (
152
+ <p className="text-xs py-2" style={{ color: 'var(--muted-foreground)' }}>
153
+ {s.agentNoneDetected}
154
+ </p>
155
+ )}
156
+ {/* Other agents — collapsed by default */}
157
+ {other.length > 0 && (
158
+ <div>
159
+ <button
160
+ type="button"
161
+ onClick={() => setShowOtherAgents(!showOtherAgents)}
162
+ aria-expanded={showOtherAgents}
163
+ className="flex items-center gap-1.5 text-xs py-1.5 transition-colors"
164
+ style={{ color: 'var(--muted-foreground)' }}>
165
+ <ChevronDown size={12} className={`transition-transform ${showOtherAgents ? 'rotate-180' : ''}`} />
166
+ {s.agentShowMore(other.length)}
167
+ </button>
168
+ {showOtherAgents && (
169
+ <div className="rounded-xl border overflow-hidden mt-1" style={{ borderColor: 'var(--border)' }}>
170
+ {other.map((agent, i) => renderAgentRow(agent, i))}
171
+ </div>
172
+ )}
173
+ </div>
174
+ )}
175
+ {/* Skill context + auto-install hint */}
176
+ <div className="space-y-1.5">
177
+ <p className="text-xs" style={{ color: 'var(--muted-foreground)' }}>
178
+ {s.skillWhat}
179
+ </p>
180
+ <div className="flex items-center gap-2 px-3 py-2 rounded-lg text-xs"
181
+ style={{ background: 'color-mix(in srgb, var(--muted-foreground) 6%, transparent)', color: 'var(--muted-foreground)' }}>
182
+ <Brain size={13} className="shrink-0" />
183
+ <span>{s.skillAutoHint(template === 'zh' ? 'mindos-zh' : 'mindos')}</span>
184
+ </div>
185
+ </div>
186
+ {/* Advanced options — collapsed by default */}
187
+ <div>
188
+ <button
189
+ type="button"
190
+ onClick={() => setShowAdvanced(!showAdvanced)}
191
+ aria-expanded={showAdvanced}
192
+ className="flex items-center gap-1.5 text-xs py-1.5 transition-colors"
193
+ style={{ color: 'var(--muted-foreground)' }}>
194
+ <ChevronDown size={12} className={`transition-transform ${showAdvanced ? 'rotate-180' : ''}`} />
195
+ {s.agentAdvanced}
196
+ </button>
197
+ {showAdvanced && (
198
+ <div className="grid grid-cols-2 gap-4 mt-2">
199
+ <Field label={s.agentTransport}>
200
+ <Select value={agentTransport} onChange={e => setAgentTransport(e.target.value as 'auto' | 'stdio' | 'http')}>
201
+ <option value="auto">{s.agentTransportAuto}</option>
202
+ <option value="stdio">{settingsMcp.transportStdio}</option>
203
+ <option value="http">{settingsMcp.transportHttp}</option>
204
+ </Select>
205
+ </Field>
206
+ <Field label={s.agentScope}>
207
+ <Select value={agentScope} onChange={e => setAgentScope(e.target.value as 'global' | 'project')}>
208
+ <option value="global">{s.agentScopeGlobal}</option>
209
+ <option value="project">{s.agentScopeProject}</option>
210
+ </Select>
211
+ </Field>
212
+ </div>
213
+ )}
214
+ </div>
215
+ <div className="flex gap-2 mt-1">
216
+ <button
217
+ type="button"
218
+ onClick={() => setSelectedAgents(new Set(
219
+ agents.filter(a => a.installed || a.present).map(a => a.key)
220
+ ))}
221
+ className="text-xs px-2.5 py-1 rounded-md border transition-colors hover:bg-muted/50"
222
+ style={{ borderColor: 'var(--amber)', color: 'var(--amber)' }}>
223
+ {s.agentSelectDetected}
224
+ </button>
225
+ <button
226
+ type="button"
227
+ onClick={() => setSelectedAgents(new Set())}
228
+ className="text-xs px-2.5 py-1 rounded-md border transition-colors hover:bg-muted/50"
229
+ style={{ borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}>
230
+ {s.agentSkipLater}
231
+ </button>
232
+ </div>
233
+ </>
234
+ )}
235
+ </div>
236
+ );
237
+ }
@@ -0,0 +1,39 @@
1
+ 'use client';
2
+
3
+ export interface StepDotsProps {
4
+ step: number;
5
+ setStep: (s: number) => void;
6
+ stepTitles: readonly string[];
7
+ disabled?: boolean;
8
+ }
9
+
10
+ export default function StepDots({ step, setStep, stepTitles, disabled }: StepDotsProps) {
11
+ return (
12
+ <div className="flex items-center gap-2 mb-8" role="navigation" aria-label="Setup steps">
13
+ {stepTitles.map((title: string, i: number) => (
14
+ <div key={i} className="flex items-center gap-2">
15
+ {i > 0 && <div className="w-8 h-px" style={{ background: i <= step ? 'var(--amber)' : 'var(--border)' }} />}
16
+ <button onClick={() => setStep(i)}
17
+ aria-current={i === step ? 'step' : undefined}
18
+ aria-label={title}
19
+ className="flex items-center gap-1.5 p-1 -m-1 disabled:cursor-not-allowed disabled:opacity-60"
20
+ disabled={disabled || i >= step}>
21
+ <div
22
+ className="w-6 h-6 rounded-full text-xs font-medium flex items-center justify-center transition-colors"
23
+ style={{
24
+ background: i <= step ? 'var(--amber)' : 'var(--muted)',
25
+ color: i <= step ? 'var(--amber-foreground)' : 'var(--muted-foreground)',
26
+ opacity: i <= step ? 1 : 0.5,
27
+ }}>
28
+ {i + 1}
29
+ </div>
30
+ <span className="text-xs hidden sm:inline"
31
+ style={{ color: i === step ? 'var(--foreground)' : 'var(--muted-foreground)', opacity: i <= step ? 1 : 0.5 }}>
32
+ {title}
33
+ </span>
34
+ </button>
35
+ </div>
36
+ ))}
37
+ </div>
38
+ );
39
+ }
@@ -0,0 +1,237 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef } from 'react';
4
+ import { Field } from '@/components/settings/Primitives';
5
+ import type { Messages } from '@/lib/i18n';
6
+ import type { SetupState } from './types';
7
+ import { TEMPLATES } from './constants';
8
+
9
+ // Derive parent dir from current input for ls — supports both / and \ separators
10
+ function getParentDir(p: string): string {
11
+ if (!p.trim()) return '';
12
+ const trimmed = p.trim();
13
+ // Already a directory (ends with separator)
14
+ if (trimmed.endsWith('/') || trimmed.endsWith('\\')) return trimmed;
15
+ // Find last separator (/ or \)
16
+ const lastSlash = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
17
+ return lastSlash >= 0 ? trimmed.slice(0, lastSlash + 1) : '';
18
+ }
19
+
20
+ export interface StepKBProps {
21
+ state: SetupState;
22
+ update: <K extends keyof SetupState>(key: K, val: SetupState[K]) => void;
23
+ t: Messages;
24
+ homeDir: string;
25
+ }
26
+
27
+ export default function StepKB({ state, update, t, homeDir }: StepKBProps) {
28
+ const s = t.setup;
29
+ // Build platform-aware placeholder, e.g. /Users/alice/MindOS/mind or C:\Users\alice\MindOS\mind
30
+ // Windows homedir always contains \, e.g. C:\Users\Alice — safe to detect by separator
31
+ const sep = homeDir.includes('\\') ? '\\' : '/';
32
+ const placeholder = homeDir !== '~' ? [homeDir, 'MindOS', 'mind'].join(sep) : s.kbPathDefault;
33
+ const [pathInfo, setPathInfo] = useState<{ exists: boolean; empty: boolean; count: number } | null>(null);
34
+ const [suggestions, setSuggestions] = useState<string[]>([]);
35
+ const [showSuggestions, setShowSuggestions] = useState(false);
36
+ const [activeSuggestion, setActiveSuggestion] = useState(-1);
37
+ const [showTemplatePickerAnyway, setShowTemplatePickerAnyway] = useState(false);
38
+ const inputRef = useRef<HTMLInputElement>(null);
39
+ const justSelectedRef = useRef(false);
40
+
41
+ // Debounced autocomplete
42
+ useEffect(() => {
43
+ // Skip when a suggestion was just selected — prevents dropdown flicker
44
+ if (justSelectedRef.current) { justSelectedRef.current = false; return; }
45
+ if (!state.mindRoot.trim()) { setSuggestions([]); return; }
46
+ const timer = setTimeout(() => {
47
+ const parent = getParentDir(state.mindRoot) || homeDir;
48
+ fetch('/api/setup/ls', {
49
+ method: 'POST',
50
+ headers: { 'Content-Type': 'application/json' },
51
+ body: JSON.stringify({ path: parent }),
52
+ })
53
+ .then(r => r.json())
54
+ .then(d => {
55
+ if (!d.dirs?.length) { setSuggestions([]); return; }
56
+ // Normalize parent to end with a separator (preserve existing / or \)
57
+ const endsWithSep = parent.endsWith('/') || parent.endsWith('\\');
58
+ const localSep = parent.includes('\\') ? '\\' : '/';
59
+ const parentNorm = endsWithSep ? parent : parent + localSep;
60
+ const typed = state.mindRoot.trim();
61
+ const full: string[] = (d.dirs as string[]).map((dir: string) => parentNorm + dir);
62
+ const endsWithAnySep = typed.endsWith('/') || typed.endsWith('\\');
63
+ const filtered = endsWithAnySep ? full : full.filter(f => f.startsWith(typed));
64
+ setSuggestions(filtered.slice(0, 8));
65
+ setShowSuggestions(filtered.length > 0);
66
+ setActiveSuggestion(-1);
67
+ })
68
+ .catch(e => { console.warn('[SetupWizard] autocomplete fetch failed:', e); setSuggestions([]); });
69
+ }, 300);
70
+ return () => clearTimeout(timer);
71
+ }, [state.mindRoot, homeDir]);
72
+
73
+ // Debounced path check
74
+ useEffect(() => {
75
+ if (!state.mindRoot.trim()) { setPathInfo(null); return; }
76
+ const timer = setTimeout(() => {
77
+ fetch('/api/setup/check-path', {
78
+ method: 'POST',
79
+ headers: { 'Content-Type': 'application/json' },
80
+ body: JSON.stringify({ path: state.mindRoot }),
81
+ })
82
+ .then(r => r.json())
83
+ .then(d => {
84
+ setPathInfo(d);
85
+ setShowTemplatePickerAnyway(false);
86
+ // Non-empty directory: default to skip template (user can opt-in to merge)
87
+ if (d?.exists && !d.empty) update('template', '');
88
+ })
89
+ .catch(e => { console.warn('[SetupWizard] check-path failed:', e); setPathInfo(null); });
90
+ }, 600);
91
+ return () => clearTimeout(timer);
92
+ }, [state.mindRoot, update]);
93
+
94
+ const hideSuggestions = () => {
95
+ setSuggestions([]);
96
+ setShowSuggestions(false);
97
+ setActiveSuggestion(-1);
98
+ };
99
+
100
+ const selectSuggestion = (val: string) => {
101
+ justSelectedRef.current = true;
102
+ update('mindRoot', val);
103
+ hideSuggestions();
104
+ inputRef.current?.focus();
105
+ };
106
+
107
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
108
+ if (!showSuggestions || suggestions.length === 0) return;
109
+ if (e.key === 'ArrowDown') {
110
+ e.preventDefault();
111
+ setActiveSuggestion(i => Math.min(i + 1, suggestions.length - 1));
112
+ } else if (e.key === 'ArrowUp') {
113
+ e.preventDefault();
114
+ setActiveSuggestion(i => Math.max(i - 1, -1));
115
+ } else if (e.key === 'Enter' && activeSuggestion >= 0) {
116
+ e.preventDefault();
117
+ selectSuggestion(suggestions[activeSuggestion]);
118
+ } else if (e.key === 'Escape') {
119
+ setShowSuggestions(false);
120
+ }
121
+ };
122
+
123
+ return (
124
+ <div className="space-y-6">
125
+ <Field label={s.kbPath} hint={s.kbPathHint}>
126
+ <div className="relative">
127
+ <input
128
+ ref={inputRef}
129
+ value={state.mindRoot}
130
+ onChange={e => { update('mindRoot', e.target.value); setShowSuggestions(true); }}
131
+ onKeyDown={handleKeyDown}
132
+ onBlur={() => setTimeout(() => hideSuggestions(), 150)}
133
+ onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
134
+ placeholder={placeholder}
135
+ className="w-full px-3 py-2 text-sm rounded-lg border outline-none transition-colors focus-visible:ring-1 focus-visible:ring-ring"
136
+ style={{
137
+ background: 'var(--input, var(--card))',
138
+ borderColor: 'var(--border)',
139
+ color: 'var(--foreground)',
140
+ }}
141
+ />
142
+ {showSuggestions && suggestions.length > 0 && (
143
+ <div
144
+ role="listbox"
145
+ className="absolute z-50 left-0 right-0 top-full mt-1 rounded-lg border overflow-auto"
146
+ style={{
147
+ background: 'var(--card)',
148
+ borderColor: 'var(--border)',
149
+ boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
150
+ maxHeight: '220px',
151
+ }}>
152
+ {suggestions.map((suggestion, i) => (
153
+ <button
154
+ key={suggestion}
155
+ type="button"
156
+ role="option"
157
+ aria-selected={i === activeSuggestion}
158
+ onMouseDown={() => selectSuggestion(suggestion)}
159
+ className="w-full text-left px-3 py-2 text-sm font-mono transition-colors"
160
+ style={{
161
+ background: i === activeSuggestion ? 'var(--muted)' : 'transparent',
162
+ color: 'var(--foreground)',
163
+ borderTop: i > 0 ? '1px solid var(--border)' : undefined,
164
+ }}>
165
+ {suggestion}
166
+ </button>
167
+ ))}
168
+ </div>
169
+ )}
170
+ </div>
171
+ {/* Recommended default — one-click accept */}
172
+ {state.mindRoot !== placeholder && placeholder !== s.kbPathDefault && (
173
+ <button type="button"
174
+ onClick={() => update('mindRoot', placeholder)}
175
+ className="mt-1.5 px-2.5 py-1 text-xs rounded-md border transition-colors hover:bg-muted/50"
176
+ style={{ borderColor: 'var(--amber)', color: 'var(--amber)' }}>
177
+ {s.kbPathUseDefault(placeholder)}
178
+ </button>
179
+ )}
180
+ </Field>
181
+ {/* Template selection — conditional on directory state */}
182
+ {pathInfo && pathInfo.exists && !pathInfo.empty && !showTemplatePickerAnyway ? (
183
+ <div>
184
+ <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
185
+ <div className="rounded-lg border p-3 text-sm" style={{ borderColor: 'var(--amber)', background: 'color-mix(in srgb, var(--amber) 6%, transparent)' }}>
186
+ <p style={{ color: 'var(--amber)' }}>
187
+ {s.kbPathHasFiles(pathInfo.count)}
188
+ </p>
189
+ <div className="flex gap-2 mt-2">
190
+ <button type="button"
191
+ onClick={() => update('template', '')}
192
+ className="px-2.5 py-1 text-xs rounded-md border transition-colors"
193
+ style={{
194
+ borderColor: 'var(--amber)',
195
+ color: state.template === '' ? 'var(--background)' : 'var(--amber)',
196
+ background: state.template === '' ? 'var(--amber)' : 'transparent',
197
+ }}>
198
+ {state.template === '' ? <>{s.kbTemplateSkip} ✓</> : s.kbTemplateSkip}
199
+ </button>
200
+ <button type="button"
201
+ onClick={() => setShowTemplatePickerAnyway(true)}
202
+ className="px-2.5 py-1 text-xs rounded-md border transition-colors hover:bg-muted/50"
203
+ style={{ borderColor: 'var(--border)', color: 'var(--muted-foreground)' }}>
204
+ {s.kbTemplateMerge}
205
+ </button>
206
+ </div>
207
+ </div>
208
+ </div>
209
+ ) : (
210
+ <div>
211
+ <label className="text-sm text-foreground font-medium mb-3 block">{s.template}</label>
212
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
213
+ {TEMPLATES.map(tpl => (
214
+ <button key={tpl.id} onClick={() => update('template', tpl.id)}
215
+ className="flex flex-col items-start gap-2 p-4 rounded-xl border text-left transition-all duration-150"
216
+ style={{
217
+ background: state.template === tpl.id ? 'var(--amber-dim)' : 'var(--card)',
218
+ borderColor: state.template === tpl.id ? 'var(--amber)' : 'var(--border)',
219
+ }}>
220
+ <div className="flex items-center gap-2">
221
+ <span style={{ color: 'var(--amber)' }}>{tpl.icon}</span>
222
+ <span className="text-sm font-medium" style={{ color: 'var(--foreground)' }}>
223
+ {t.onboarding.templates[tpl.id as 'en' | 'zh' | 'empty'].title}
224
+ </span>
225
+ </div>
226
+ <div className="w-full rounded-lg px-2.5 py-1.5 text-xs leading-relaxed font-display"
227
+ style={{ background: 'var(--muted)', color: 'var(--muted-foreground)' }}>
228
+ {tpl.dirs.map(d => <div key={d}>{d}</div>)}
229
+ </div>
230
+ </button>
231
+ ))}
232
+ </div>
233
+ </div>
234
+ )}
235
+ </div>
236
+ );
237
+ }