@geminilight/mindos 0.6.28 → 0.6.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +10 -4
  2. package/app/app/api/a2a/agents/route.ts +9 -0
  3. package/app/app/api/a2a/delegations/route.ts +9 -0
  4. package/app/app/api/a2a/discover/route.ts +2 -0
  5. package/app/app/api/a2a/route.ts +6 -6
  6. package/app/app/api/acp/config/route.ts +82 -0
  7. package/app/app/api/acp/detect/route.ts +114 -0
  8. package/app/app/api/acp/install/route.ts +51 -0
  9. package/app/app/api/acp/registry/route.ts +31 -0
  10. package/app/app/api/acp/session/route.ts +185 -0
  11. package/app/app/api/ask/route.ts +116 -13
  12. package/app/app/api/workflows/route.ts +156 -0
  13. package/app/app/layout.tsx +2 -0
  14. package/app/app/page.tsx +7 -2
  15. package/app/components/ActivityBar.tsx +12 -4
  16. package/app/components/AskModal.tsx +4 -1
  17. package/app/components/DirView.tsx +64 -2
  18. package/app/components/FileTree.tsx +40 -10
  19. package/app/components/GuideCard.tsx +7 -17
  20. package/app/components/HomeContent.tsx +1 -0
  21. package/app/components/MarkdownView.tsx +2 -0
  22. package/app/components/Panel.tsx +1 -0
  23. package/app/components/RightAskPanel.tsx +5 -1
  24. package/app/components/SearchModal.tsx +234 -80
  25. package/app/components/SidebarLayout.tsx +6 -0
  26. package/app/components/agents/AgentDetailContent.tsx +266 -52
  27. package/app/components/agents/AgentsContentPage.tsx +32 -6
  28. package/app/components/agents/AgentsPanelA2aTab.tsx +684 -0
  29. package/app/components/agents/AgentsPanelSessionsTab.tsx +166 -0
  30. package/app/components/agents/SkillDetailPopover.tsx +4 -9
  31. package/app/components/agents/agents-content-model.ts +2 -2
  32. package/app/components/ask/AgentSelectorCapsule.tsx +218 -0
  33. package/app/components/ask/AskContent.tsx +197 -239
  34. package/app/components/ask/FileChip.tsx +82 -17
  35. package/app/components/ask/MentionPopover.tsx +21 -3
  36. package/app/components/ask/MessageList.tsx +30 -9
  37. package/app/components/ask/SlashCommandPopover.tsx +21 -3
  38. package/app/components/help/HelpContent.tsx +9 -9
  39. package/app/components/panels/AgentsPanel.tsx +2 -0
  40. package/app/components/panels/AgentsPanelAgentDetail.tsx +5 -8
  41. package/app/components/panels/AgentsPanelHubNav.tsx +16 -2
  42. package/app/components/panels/EchoPanel.tsx +5 -1
  43. package/app/components/panels/EchoSidebarStats.tsx +136 -0
  44. package/app/components/panels/WorkflowsPanel.tsx +206 -0
  45. package/app/components/renderers/workflow-yaml/StepEditor.tsx +157 -0
  46. package/app/components/renderers/workflow-yaml/WorkflowEditor.tsx +201 -0
  47. package/app/components/renderers/workflow-yaml/WorkflowRunner.tsx +226 -0
  48. package/app/components/renderers/workflow-yaml/WorkflowYamlRenderer.tsx +126 -0
  49. package/app/components/renderers/workflow-yaml/execution.ts +177 -0
  50. package/app/components/renderers/workflow-yaml/index.ts +6 -0
  51. package/app/components/renderers/workflow-yaml/manifest.ts +21 -0
  52. package/app/components/renderers/workflow-yaml/parser.ts +172 -0
  53. package/app/components/renderers/workflow-yaml/selectors.tsx +522 -0
  54. package/app/components/renderers/workflow-yaml/serializer.ts +56 -0
  55. package/app/components/renderers/workflow-yaml/types.ts +46 -0
  56. package/app/components/settings/KnowledgeTab.tsx +3 -6
  57. package/app/components/settings/McpSkillsSection.tsx +4 -5
  58. package/app/components/settings/McpTab.tsx +6 -8
  59. package/app/components/setup/StepSecurity.tsx +4 -5
  60. package/app/components/setup/index.tsx +5 -11
  61. package/app/components/ui/Toaster.tsx +39 -0
  62. package/app/hooks/useA2aRegistry.ts +6 -1
  63. package/app/hooks/useAcpConfig.ts +96 -0
  64. package/app/hooks/useAcpDetection.ts +120 -0
  65. package/app/hooks/useAcpRegistry.ts +86 -0
  66. package/app/hooks/useAskModal.ts +12 -5
  67. package/app/hooks/useAskPanel.ts +8 -5
  68. package/app/hooks/useAskSession.ts +19 -2
  69. package/app/hooks/useDelegationHistory.ts +49 -0
  70. package/app/hooks/useImageUpload.ts +152 -0
  71. package/app/lib/a2a/client.ts +49 -5
  72. package/app/lib/a2a/orchestrator.ts +0 -1
  73. package/app/lib/a2a/task-handler.ts +4 -4
  74. package/app/lib/a2a/types.ts +15 -0
  75. package/app/lib/acp/acp-tools.ts +95 -0
  76. package/app/lib/acp/agent-descriptors.ts +274 -0
  77. package/app/lib/acp/bridge.ts +144 -0
  78. package/app/lib/acp/index.ts +40 -0
  79. package/app/lib/acp/registry.ts +202 -0
  80. package/app/lib/acp/session.ts +717 -0
  81. package/app/lib/acp/subprocess.ts +495 -0
  82. package/app/lib/acp/types.ts +274 -0
  83. package/app/lib/agent/model.ts +18 -3
  84. package/app/lib/agent/to-agent-messages.ts +25 -2
  85. package/app/lib/agent/tools.ts +2 -1
  86. package/app/lib/i18n/_core.ts +22 -0
  87. package/app/lib/i18n/index.ts +35 -0
  88. package/app/lib/i18n/modules/ai-chat.ts +215 -0
  89. package/app/lib/i18n/modules/common.ts +71 -0
  90. package/app/lib/i18n/modules/features.ts +153 -0
  91. package/app/lib/i18n/modules/knowledge.ts +429 -0
  92. package/app/lib/i18n/modules/navigation.ts +153 -0
  93. package/app/lib/i18n/modules/onboarding.ts +523 -0
  94. package/app/lib/i18n/modules/panels.ts +1196 -0
  95. package/app/lib/i18n/modules/settings.ts +585 -0
  96. package/app/lib/i18n-en.ts +2 -1518
  97. package/app/lib/i18n-zh.ts +2 -1542
  98. package/app/lib/i18n.ts +3 -6
  99. package/app/lib/pi-integration/skills.ts +21 -6
  100. package/app/lib/renderers/index.ts +2 -2
  101. package/app/lib/settings.ts +10 -0
  102. package/app/lib/toast.ts +79 -0
  103. package/app/lib/types.ts +12 -1
  104. package/app/next-env.d.ts +1 -1
  105. package/app/package.json +3 -1
  106. package/bin/cli.js +25 -25
  107. package/bin/commands/file.js +29 -2
  108. package/bin/commands/space.js +249 -91
  109. package/package.json +1 -1
  110. package/templates/en/.mindos/workflows/Sprint Release.flow.yaml +130 -0
  111. package/templates/zh/.mindos/workflows//345/221/250/350/277/255/344/273/243/346/243/200/346/237/245.flow.yaml +84 -0
  112. package/app/components/renderers/workflow/WorkflowRenderer.tsx +0 -409
  113. package/app/components/renderers/workflow/manifest.ts +0 -14
@@ -0,0 +1,172 @@
1
+ import YAML from 'js-yaml';
2
+ import type { WorkflowYaml, ParseResult, ValidationResult, WorkflowStep } from './types';
3
+
4
+ export function parseWorkflowYaml(content: string): ParseResult {
5
+ try {
6
+ const parsed = YAML.load(content, { schema: YAML.JSON_SCHEMA }) as unknown;
7
+
8
+ if (!parsed) {
9
+ return {
10
+ workflow: null,
11
+ errors: ['File is empty'],
12
+ };
13
+ }
14
+
15
+ if (typeof parsed !== 'object') {
16
+ return {
17
+ workflow: null,
18
+ errors: ['Content is not valid YAML'],
19
+ };
20
+ }
21
+
22
+ const validation = validateWorkflowSchema(parsed);
23
+ if (!validation.valid) {
24
+ return {
25
+ workflow: null,
26
+ errors: validation.errors,
27
+ };
28
+ }
29
+
30
+ return {
31
+ workflow: parsed as WorkflowYaml,
32
+ errors: [],
33
+ };
34
+ } catch (err) {
35
+ return {
36
+ workflow: null,
37
+ errors: [err instanceof Error ? err.message : String(err)],
38
+ };
39
+ }
40
+ }
41
+
42
+ export function validateWorkflowSchema(obj: unknown): ValidationResult {
43
+ const errors: string[] = [];
44
+
45
+ if (!obj || typeof obj !== 'object') {
46
+ return { valid: false, errors: ['Must be a valid YAML object'] };
47
+ }
48
+
49
+ const w = obj as Record<string, any>;
50
+
51
+ // Validate title
52
+ if (!w.title || typeof w.title !== 'string' || !w.title.trim()) {
53
+ errors.push("Missing required field 'title' (string)");
54
+ }
55
+
56
+ // Validate description (optional)
57
+ if (w.description !== undefined && typeof w.description !== 'string') {
58
+ errors.push("'description' must be a string");
59
+ }
60
+
61
+ // Validate skills (optional)
62
+ if (w.skills !== undefined) {
63
+ if (!Array.isArray(w.skills)) {
64
+ errors.push("'skills' must be an array of strings");
65
+ } else if (!w.skills.every((s: any) => typeof s === 'string')) {
66
+ errors.push("'skills' must contain only strings");
67
+ }
68
+ }
69
+
70
+ // Validate tools (optional)
71
+ if (w.tools !== undefined) {
72
+ if (!Array.isArray(w.tools)) {
73
+ errors.push("'tools' must be an array of strings");
74
+ } else if (!w.tools.every((t: any) => typeof t === 'string')) {
75
+ errors.push("'tools' must contain only strings");
76
+ }
77
+ }
78
+
79
+ // Validate steps
80
+ if (!w.steps) {
81
+ errors.push("Missing required field 'steps' (array)");
82
+ } else if (!Array.isArray(w.steps)) {
83
+ errors.push("'steps' must be an array");
84
+ } else if (w.steps.length === 0) {
85
+ errors.push("'steps' must have at least 1 step");
86
+ } else {
87
+ const seenIds = new Set<string>();
88
+ w.steps.forEach((step: any, idx: number) => {
89
+ const stepErrors = validateStep(step, idx);
90
+ errors.push(...stepErrors);
91
+ // Check for duplicate IDs
92
+ const id = step?.id;
93
+ if (typeof id === 'string' && id.trim()) {
94
+ if (seenIds.has(id)) {
95
+ errors.push(`steps[${idx}].id: duplicate id '${id}' (each step must have a unique id)`);
96
+ }
97
+ seenIds.add(id);
98
+ }
99
+ });
100
+ }
101
+
102
+ return {
103
+ valid: errors.length === 0,
104
+ errors,
105
+ };
106
+ }
107
+
108
+ function validateStep(step: unknown, index: number): string[] {
109
+ const errors: string[] = [];
110
+ const prefix = `steps[${index}]`;
111
+
112
+ if (!step || typeof step !== 'object') {
113
+ errors.push(`${prefix}: must be a valid object`);
114
+ return errors;
115
+ }
116
+
117
+ const s = step as Record<string, any>;
118
+
119
+ // Validate id
120
+ if (!s.id || typeof s.id !== 'string' || !s.id.trim()) {
121
+ errors.push(`${prefix}: missing required field 'id' (non-empty string)`);
122
+ } else if (!/^[a-z0-9_-]+$/.test(s.id)) {
123
+ errors.push(`${prefix}.id: '${s.id}' is invalid — use only lowercase letters, numbers, hyphens, underscores (e.g., 'run-tests')`);
124
+ }
125
+
126
+ // Validate name
127
+ if (!s.name || typeof s.name !== 'string' || !s.name.trim()) {
128
+ errors.push(`${prefix}: missing required field 'name' (non-empty string)`);
129
+ }
130
+
131
+ // Validate description (optional)
132
+ if (s.description !== undefined && typeof s.description !== 'string') {
133
+ errors.push(`${prefix}.description: must be a string`);
134
+ }
135
+
136
+ // Validate agent (optional)
137
+ if (s.agent !== undefined && (typeof s.agent !== 'string' || !s.agent.trim())) {
138
+ errors.push(`${prefix}.agent: must be a non-empty string`);
139
+ }
140
+
141
+ // Validate skill (optional)
142
+ if (s.skill !== undefined && (typeof s.skill !== 'string' || !s.skill.trim())) {
143
+ errors.push(`${prefix}.skill: must be a non-empty string`);
144
+ }
145
+
146
+ // Validate tools (optional)
147
+ if (s.tools !== undefined) {
148
+ if (!Array.isArray(s.tools)) {
149
+ errors.push(`${prefix}.tools: must be an array of strings`);
150
+ } else if (!s.tools.every((t: any) => typeof t === 'string')) {
151
+ errors.push(`${prefix}.tools: must contain only strings`);
152
+ }
153
+ }
154
+
155
+ // Validate prompt (required)
156
+ if (!s.prompt || typeof s.prompt !== 'string' || !s.prompt.trim()) {
157
+ errors.push(`${prefix}: missing required field 'prompt' (non-empty string)`);
158
+ }
159
+
160
+ // Validate timeout (optional)
161
+ if (s.timeout !== undefined) {
162
+ if (typeof s.timeout !== 'number' || s.timeout <= 0) {
163
+ errors.push(`${prefix}.timeout: must be a positive number (seconds)`);
164
+ }
165
+ }
166
+
167
+ return errors;
168
+ }
169
+
170
+ export function getStepDescription(step: WorkflowStep): string {
171
+ return step.description || step.prompt.split('\n')[0].slice(0, 100);
172
+ }
@@ -0,0 +1,522 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect, useRef, useCallback } from 'react';
4
+ import { ChevronDown, X, Search, FileText, FolderOpen, Folder, ChevronRight } from 'lucide-react';
5
+
6
+ // ─── Dropdown Shell ───────────────────────────────────────────────────────
7
+
8
+ function Dropdown({ trigger, children, open, onClose }: {
9
+ trigger: React.ReactNode; children: React.ReactNode; open: boolean; onClose: () => void;
10
+ }) {
11
+ const ref = useRef<HTMLDivElement>(null);
12
+ useEffect(() => {
13
+ if (!open) return;
14
+ const handler = (e: MouseEvent) => {
15
+ if (ref.current && !ref.current.contains(e.target as Node)) onClose();
16
+ };
17
+ document.addEventListener('mousedown', handler);
18
+ return () => document.removeEventListener('mousedown', handler);
19
+ }, [open, onClose]);
20
+
21
+ return (
22
+ <div ref={ref} className="relative">
23
+ {trigger}
24
+ {open && (
25
+ <div className="absolute top-full left-0 mt-1 w-full min-w-[220px] max-h-[260px] overflow-y-auto bg-card border border-border rounded-lg shadow-lg z-50">
26
+ {children}
27
+ </div>
28
+ )}
29
+ </div>
30
+ );
31
+ }
32
+
33
+ // ─── Agent Selector ───────────────────────────────────────────────────────
34
+
35
+ const KNOWN_AGENTS = ['cursor', 'claude-code', 'mindos', 'gemini'];
36
+
37
+ export function AgentSelector({ value, onChange }: { value?: string; onChange: (v: string | undefined) => void }) {
38
+ const [open, setOpen] = useState(false);
39
+ const [custom, setCustom] = useState('');
40
+
41
+ const select = (v: string | undefined) => { onChange(v); setOpen(false); };
42
+
43
+ return (
44
+ <Dropdown
45
+ open={open}
46
+ onClose={() => setOpen(false)}
47
+ trigger={
48
+ <button type="button" onClick={() => setOpen(v => !v)}
49
+ className="w-full flex items-center justify-between px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors">
50
+ <span className={value ? 'text-foreground' : 'text-muted-foreground'}>
51
+ {value || 'Select agent'}
52
+ </span>
53
+ <ChevronDown size={12} className="text-muted-foreground" />
54
+ </button>
55
+ }
56
+ >
57
+ <button onClick={() => select(undefined)}
58
+ className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${!value ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground'}`}>
59
+ (none)
60
+ </button>
61
+ {KNOWN_AGENTS.map(a => (
62
+ <button key={a} onClick={() => select(a)}
63
+ className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${value === a ? 'text-[var(--amber)] font-medium' : 'text-foreground'}`}>
64
+ {a}
65
+ </button>
66
+ ))}
67
+ <div className="border-t border-border px-2.5 py-1.5">
68
+ <input type="text" value={custom} onChange={e => setCustom(e.target.value)}
69
+ onKeyDown={e => { if (e.key === 'Enter' && custom.trim()) { select(custom.trim()); setCustom(''); } }}
70
+ placeholder="Custom agent..."
71
+ className="w-full px-2 py-1 text-xs rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
72
+ />
73
+ </div>
74
+ </Dropdown>
75
+ );
76
+ }
77
+
78
+ // ─── Model Selector ───────────────────────────────────────────────────────
79
+
80
+ const KNOWN_MODELS = [
81
+ { group: 'Anthropic', models: ['claude-sonnet-4-6', 'claude-opus-4', 'claude-3.5-sonnet', 'claude-3.5-haiku'] },
82
+ { group: 'OpenAI', models: ['gpt-4o', 'gpt-4o-mini', 'o3', 'o4-mini'] },
83
+ { group: 'Google', models: ['gemini-2.5-pro', 'gemini-2.5-flash'] },
84
+ ];
85
+
86
+ export function ModelSelector({ value, onChange }: { value?: string; onChange: (v: string | undefined) => void }) {
87
+ const [open, setOpen] = useState(false);
88
+ const [custom, setCustom] = useState('');
89
+
90
+ const select = (v: string | undefined) => { onChange(v); setOpen(false); };
91
+
92
+ return (
93
+ <Dropdown
94
+ open={open}
95
+ onClose={() => setOpen(false)}
96
+ trigger={
97
+ <button type="button" onClick={() => setOpen(v => !v)}
98
+ className="w-full flex items-center justify-between px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors">
99
+ <span className={value ? 'text-foreground truncate' : 'text-muted-foreground'}>
100
+ {value || 'Default model'}
101
+ </span>
102
+ {value ? (
103
+ <X size={12} className="text-muted-foreground shrink-0 hover:text-foreground cursor-pointer" onClick={e => { e.stopPropagation(); select(undefined); }} />
104
+ ) : (
105
+ <ChevronDown size={12} className="text-muted-foreground shrink-0" />
106
+ )}
107
+ </button>
108
+ }
109
+ >
110
+ <button onClick={() => select(undefined)}
111
+ className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${!value ? 'text-[var(--amber)] font-medium' : 'text-muted-foreground'}`}>
112
+ (default)
113
+ </button>
114
+ {KNOWN_MODELS.map(g => (
115
+ <div key={g.group}>
116
+ <div className="px-3 py-1 text-2xs font-semibold text-muted-foreground/60 uppercase tracking-wide bg-muted/30">{g.group}</div>
117
+ {g.models.map(m => (
118
+ <button key={m} onClick={() => select(m)}
119
+ className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors ${value === m ? 'text-[var(--amber)] font-medium' : 'text-foreground'}`}>
120
+ {m}
121
+ </button>
122
+ ))}
123
+ </div>
124
+ ))}
125
+ <div className="border-t border-border px-2.5 py-1.5">
126
+ <input type="text" value={custom} onChange={e => setCustom(e.target.value)}
127
+ onKeyDown={e => { if (e.key === 'Enter' && custom.trim()) { select(custom.trim()); setCustom(''); } }}
128
+ placeholder="Custom model ID..."
129
+ className="w-full px-2 py-1 text-xs rounded border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
130
+ />
131
+ </div>
132
+ </Dropdown>
133
+ );
134
+ }
135
+
136
+ // ─── Skills Multi-Select ─────────────────────────────────────────────────
137
+
138
+ interface SkillInfo { name: string; description?: string }
139
+
140
+ /** Cache skill list globally so all SkillsSelector instances share it */
141
+ let _skillsCache: SkillInfo[] | null = null;
142
+ let _skillsFetching = false;
143
+ const _skillsListeners: Array<(skills: SkillInfo[]) => void> = [];
144
+
145
+ function fetchSkillsOnce(cb: (skills: SkillInfo[]) => void) {
146
+ if (_skillsCache) { cb(_skillsCache); return; }
147
+ _skillsListeners.push(cb);
148
+ if (_skillsFetching) return;
149
+ _skillsFetching = true;
150
+ fetch('/api/skills').then(r => r.json()).then(data => {
151
+ _skillsCache = (data.skills ?? []).map((s: { name: string; description?: string }) => ({
152
+ name: s.name, description: s.description,
153
+ }));
154
+ _skillsListeners.forEach(fn => fn(_skillsCache!));
155
+ _skillsListeners.length = 0;
156
+ }).catch(() => {
157
+ _skillsCache = [];
158
+ _skillsListeners.forEach(fn => fn([]));
159
+ _skillsListeners.length = 0;
160
+ });
161
+ }
162
+
163
+ export function SkillsSelector({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
164
+ const [open, setOpen] = useState(false);
165
+ const [skills, setSkills] = useState<SkillInfo[]>(_skillsCache ?? []);
166
+ const [query, setQuery] = useState('');
167
+
168
+ useEffect(() => {
169
+ fetchSkillsOnce(setSkills);
170
+ }, []);
171
+
172
+ const filtered = query
173
+ ? skills.filter(s => s.name.toLowerCase().includes(query.toLowerCase()))
174
+ : skills;
175
+
176
+ const toggle = (name: string) => {
177
+ if (value.includes(name)) {
178
+ onChange(value.filter(v => v !== name));
179
+ } else {
180
+ onChange([...value, name]);
181
+ }
182
+ };
183
+
184
+ const remove = (name: string) => onChange(value.filter(v => v !== name));
185
+
186
+ return (
187
+ <div>
188
+ {/* Selected chips */}
189
+ {value.length > 0 && (
190
+ <div className="flex flex-wrap gap-1 mb-1.5">
191
+ {value.map(s => (
192
+ <span key={s} className="inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-md text-2xs bg-[var(--amber)]/10 text-[var(--amber)] border border-[var(--amber)]/20">
193
+ {s}
194
+ <button onClick={() => remove(s)} className="p-0.5 rounded hover:bg-[var(--amber)]/20 transition-colors">
195
+ <X size={10} />
196
+ </button>
197
+ </span>
198
+ ))}
199
+ </div>
200
+ )}
201
+
202
+ {/* Dropdown trigger */}
203
+ <Dropdown
204
+ open={open}
205
+ onClose={() => { setOpen(false); setQuery(''); }}
206
+ trigger={
207
+ <button type="button" onClick={() => setOpen(v => !v)}
208
+ className="w-full flex items-center justify-between px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors">
209
+ <span className="text-muted-foreground">
210
+ {value.length === 0 ? 'Add skills...' : `${value.length} selected`}
211
+ </span>
212
+ <ChevronDown size={12} className="text-muted-foreground" />
213
+ </button>
214
+ }
215
+ >
216
+ {/* Search */}
217
+ <div className="sticky top-0 bg-card border-b border-border px-2.5 py-1.5">
218
+ <div className="flex items-center gap-1.5 px-2 py-1 rounded border border-border bg-background">
219
+ <Search size={11} className="text-muted-foreground shrink-0" />
220
+ <input type="text" value={query} onChange={e => setQuery(e.target.value)} autoFocus
221
+ placeholder="Search skills..."
222
+ className="flex-1 text-xs bg-transparent text-foreground placeholder:text-muted-foreground focus:outline-none"
223
+ />
224
+ </div>
225
+ </div>
226
+
227
+ {/* Skill list with checkboxes */}
228
+ {filtered.slice(0, 60).map(s => {
229
+ const checked = value.includes(s.name);
230
+ return (
231
+ <button key={s.name} onClick={() => toggle(s.name)}
232
+ className={`w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors flex items-center gap-2 ${checked ? 'bg-[var(--amber)]/5' : ''}`}>
233
+ <span className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 transition-colors ${
234
+ checked ? 'bg-[var(--amber)] border-[var(--amber)] text-white' : 'border-border'
235
+ }`}>
236
+ {checked && <span className="text-[9px]">✓</span>}
237
+ </span>
238
+ <div className="min-w-0 flex-1">
239
+ <span className="block truncate">{s.name}</span>
240
+ {s.description && <span className="block text-2xs text-muted-foreground truncate mt-0.5">{s.description}</span>}
241
+ </div>
242
+ </button>
243
+ );
244
+ })}
245
+ {skills.length === 0 && <div className="px-3 py-2 text-xs text-muted-foreground">Loading...</div>}
246
+ {skills.length > 0 && filtered.length === 0 && (
247
+ <div className="px-3 py-2 text-xs text-muted-foreground">No skills found</div>
248
+ )}
249
+ </Dropdown>
250
+ </div>
251
+ );
252
+ }
253
+
254
+ // ─── Context Selector (file drop zone) ───────────────────────────────────
255
+
256
+ export function ContextSelector({ value, onChange }: { value: string[]; onChange: (v: string[]) => void }) {
257
+ const [dragOver, setDragOver] = useState(false);
258
+ const [manualInput, setManualInput] = useState('');
259
+
260
+ const addPath = useCallback((path: string) => {
261
+ const trimmed = path.trim();
262
+ if (trimmed && !value.includes(trimmed)) {
263
+ onChange([...value, trimmed]);
264
+ }
265
+ }, [value, onChange]);
266
+
267
+ const remove = (path: string) => onChange(value.filter(v => v !== path));
268
+
269
+ const handleDrop = useCallback((e: React.DragEvent) => {
270
+ e.preventDefault();
271
+ setDragOver(false);
272
+ // FileTree drag format: text/mindos-path
273
+ const mindosPath = e.dataTransfer.getData('text/mindos-path');
274
+ if (mindosPath) {
275
+ addPath(mindosPath);
276
+ return;
277
+ }
278
+ // Fallback: plain text
279
+ const text = e.dataTransfer.getData('text/plain');
280
+ if (text) addPath(text);
281
+ }, [addPath]);
282
+
283
+ const handleDragOver = useCallback((e: React.DragEvent) => {
284
+ e.preventDefault();
285
+ e.dataTransfer.dropEffect = 'copy';
286
+ setDragOver(true);
287
+ }, []);
288
+
289
+ const handleDragLeave = useCallback(() => setDragOver(false), []);
290
+
291
+ return (
292
+ <div>
293
+ {/* Selected files */}
294
+ {value.length > 0 && (
295
+ <div className="flex flex-col gap-1 mb-1.5">
296
+ {value.map(p => (
297
+ <div key={p} className="flex items-center gap-1.5 pl-2 pr-1 py-1 rounded-md text-2xs bg-muted/60 border border-border group">
298
+ <FileText size={11} className="text-muted-foreground shrink-0" />
299
+ <span className="text-foreground truncate flex-1" title={p}>{p}</span>
300
+ <button onClick={() => remove(p)} className="p-0.5 rounded hover:bg-[var(--error)]/10 text-muted-foreground hover:text-[var(--error)] opacity-0 group-hover:opacity-100 transition-all">
301
+ <X size={10} />
302
+ </button>
303
+ </div>
304
+ ))}
305
+ </div>
306
+ )}
307
+
308
+ {/* Drop zone */}
309
+ <div
310
+ onDrop={handleDrop}
311
+ onDragOver={handleDragOver}
312
+ onDragLeave={handleDragLeave}
313
+ className={`flex items-center gap-2 px-3 py-2.5 rounded-lg border-2 border-dashed transition-colors ${
314
+ dragOver
315
+ ? 'border-[var(--amber)] bg-[var(--amber)]/5'
316
+ : 'border-border hover:border-muted-foreground/30'
317
+ }`}
318
+ >
319
+ <FolderOpen size={14} className={`shrink-0 ${dragOver ? 'text-[var(--amber)]' : 'text-muted-foreground/40'}`} />
320
+ <span className="text-2xs text-muted-foreground">
321
+ Drag files here from the sidebar
322
+ </span>
323
+ </div>
324
+
325
+ {/* Manual input */}
326
+ <div className="mt-1.5">
327
+ <input type="text" value={manualInput} onChange={e => setManualInput(e.target.value)}
328
+ onKeyDown={e => { if (e.key === 'Enter' && manualInput.trim()) { addPath(manualInput.trim()); setManualInput(''); } }}
329
+ placeholder="Or type a file path..."
330
+ className="w-full px-2.5 py-1 text-2xs rounded-md border border-border bg-background text-foreground placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
331
+ />
332
+ </div>
333
+ </div>
334
+ );
335
+ }
336
+
337
+ // ─── Directory Picker ────────────────────────────────────────────────────
338
+
339
+ async function fetchDirs(dirPath: string): Promise<string[]> {
340
+ try {
341
+ const res = await fetch('/api/setup/ls', {
342
+ method: 'POST',
343
+ headers: { 'Content-Type': 'application/json' },
344
+ body: JSON.stringify({ path: dirPath || '~' }),
345
+ });
346
+ if (!res.ok) return [];
347
+ const data = await res.json();
348
+ return data.dirs ?? [];
349
+ } catch { return []; }
350
+ }
351
+
352
+ /** Get parent directory from a path (supports / and \) */
353
+ function getParentDir(p: string): string {
354
+ const trimmed = p.trim();
355
+ if (!trimmed) return '';
356
+ if (trimmed.endsWith('/') || trimmed.endsWith('\\')) return trimmed;
357
+ const lastSlash = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'));
358
+ return lastSlash >= 0 ? trimmed.slice(0, lastSlash + 1) : '';
359
+ }
360
+
361
+ export function DirPicker({ value, onChange }: { value: string; onChange: (v: string) => void }) {
362
+ // ── Autocomplete state ──
363
+ const [suggestions, setSuggestions] = useState<string[]>([]);
364
+ const [showSuggestions, setShowSuggestions] = useState(false);
365
+ const [activeIdx, setActiveIdx] = useState(-1);
366
+ const justSelectedRef = useRef(false);
367
+ const inputRef = useRef<HTMLInputElement>(null);
368
+
369
+ // ── Browse dialog state ──
370
+ const [browseOpen, setBrowseOpen] = useState(false);
371
+ const [browsePath, setBrowsePath] = useState(value || '~');
372
+ const [browseDirs, setBrowseDirs] = useState<string[]>([]);
373
+ const [browseLoading, setBrowseLoading] = useState(false);
374
+
375
+ // Debounced autocomplete on typing
376
+ useEffect(() => {
377
+ if (justSelectedRef.current) { justSelectedRef.current = false; return; }
378
+ if (!value.trim()) { setSuggestions([]); setShowSuggestions(false); return; }
379
+ const timer = setTimeout(async () => {
380
+ const parent = getParentDir(value) || '~';
381
+ const dirs = await fetchDirs(parent);
382
+ if (!dirs.length) { setSuggestions([]); setShowSuggestions(false); return; }
383
+ const sep = parent.includes('\\') ? '\\' : '/';
384
+ const parentNorm = (parent.endsWith('/') || parent.endsWith('\\')) ? parent : parent + sep;
385
+ const full = dirs.map(d => parentNorm + d);
386
+ const endsWithSep = value.endsWith('/') || value.endsWith('\\');
387
+ const filtered = endsWithSep ? full : full.filter(f => f.startsWith(value.trim()));
388
+ setSuggestions(filtered.slice(0, 15));
389
+ setShowSuggestions(filtered.length > 0);
390
+ setActiveIdx(-1);
391
+ }, 300);
392
+ return () => clearTimeout(timer);
393
+ }, [value]);
394
+
395
+ const hideSuggestions = () => { setSuggestions([]); setShowSuggestions(false); setActiveIdx(-1); };
396
+
397
+ const selectSuggestion = (val: string) => {
398
+ justSelectedRef.current = true;
399
+ onChange(val);
400
+ hideSuggestions();
401
+ inputRef.current?.focus();
402
+ };
403
+
404
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
405
+ if (!showSuggestions || suggestions.length === 0) return;
406
+ if (e.key === 'ArrowDown') {
407
+ e.preventDefault();
408
+ setActiveIdx(i => Math.min(i + 1, suggestions.length - 1));
409
+ } else if (e.key === 'ArrowUp') {
410
+ e.preventDefault();
411
+ setActiveIdx(i => Math.max(i - 1, -1));
412
+ } else if (e.key === 'Enter' && activeIdx >= 0) {
413
+ e.preventDefault();
414
+ selectSuggestion(suggestions[activeIdx]);
415
+ } else if (e.key === 'Escape') {
416
+ hideSuggestions();
417
+ } else if (e.key === 'Tab' && activeIdx >= 0) {
418
+ e.preventDefault();
419
+ selectSuggestion(suggestions[activeIdx]);
420
+ }
421
+ };
422
+
423
+ // Browse dialog
424
+ const loadBrowse = useCallback(async (dirPath: string) => {
425
+ setBrowseLoading(true);
426
+ const result = await fetchDirs(dirPath);
427
+ setBrowseDirs(result);
428
+ setBrowseLoading(false);
429
+ }, []);
430
+
431
+ useEffect(() => {
432
+ if (browseOpen) loadBrowse(browsePath);
433
+ }, [browseOpen, browsePath, loadBrowse]);
434
+
435
+ const navigateInto = (subDir: string) => {
436
+ const next = browsePath === '~' ? `~/${subDir}` : `${browsePath.replace(/\/+$/, '')}/${subDir}`;
437
+ setBrowsePath(next);
438
+ };
439
+
440
+ const selectBrowsed = () => { onChange(browsePath); setBrowseOpen(false); };
441
+
442
+ const goUp = () => {
443
+ const parts = browsePath.split('/');
444
+ if (parts.length <= 1) return;
445
+ parts.pop();
446
+ setBrowsePath(parts.join('/') || '~');
447
+ };
448
+
449
+ return (
450
+ <div className="relative flex gap-1.5">
451
+ {/* Input with autocomplete */}
452
+ <div className="relative flex-1">
453
+ <input
454
+ ref={inputRef}
455
+ type="text"
456
+ value={value}
457
+ onChange={e => { onChange(e.target.value); setShowSuggestions(true); }}
458
+ onKeyDown={handleKeyDown}
459
+ onBlur={() => setTimeout(hideSuggestions, 150)}
460
+ onFocus={() => suggestions.length > 0 && setShowSuggestions(true)}
461
+ placeholder="~/projects/my-app"
462
+ className="w-full px-2.5 py-1.5 text-xs rounded-lg border border-border bg-background text-foreground font-mono placeholder:text-muted-foreground focus:outline-none focus-visible:ring-1 focus-visible:ring-ring"
463
+ />
464
+ {showSuggestions && suggestions.length > 0 && (
465
+ <div role="listbox"
466
+ className="absolute z-50 left-0 right-0 top-full mt-1 rounded-lg border border-border bg-card shadow-lg overflow-auto max-h-[200px]">
467
+ {suggestions.map((s, i) => (
468
+ <button key={s} type="button" role="option" aria-selected={i === activeIdx}
469
+ onMouseDown={() => selectSuggestion(s)}
470
+ className={`w-full text-left px-3 py-1.5 text-xs font-mono transition-colors flex items-center gap-1.5 ${
471
+ i === activeIdx ? 'bg-muted text-foreground' : 'text-foreground hover:bg-muted/50'
472
+ }`}>
473
+ <Folder size={11} className="text-[var(--amber)] shrink-0" />
474
+ <span className="truncate">{s}</span>
475
+ </button>
476
+ ))}
477
+ </div>
478
+ )}
479
+ </div>
480
+
481
+ {/* Browse button */}
482
+ <Dropdown
483
+ open={browseOpen}
484
+ onClose={() => setBrowseOpen(false)}
485
+ trigger={
486
+ <button type="button" onClick={() => { setBrowsePath(value || '~'); setBrowseOpen(v => !v); }}
487
+ className="px-2 py-1.5 rounded-lg border border-border bg-background text-muted-foreground hover:bg-muted hover:text-foreground transition-colors"
488
+ title="Browse directories"
489
+ >
490
+ <FolderOpen size={13} />
491
+ </button>
492
+ }
493
+ >
494
+ <div className="sticky top-0 bg-card border-b border-border px-2.5 py-2 flex items-center gap-1.5">
495
+ <span className="text-2xs font-mono text-muted-foreground truncate flex-1" title={browsePath}>{browsePath}</span>
496
+ <button onClick={selectBrowsed}
497
+ className="px-2 py-0.5 text-2xs rounded font-medium bg-[var(--amber)] text-[var(--amber-foreground)] shrink-0">
498
+ Select
499
+ </button>
500
+ </div>
501
+ {browsePath !== '~' && (
502
+ <button onClick={goUp}
503
+ className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors text-muted-foreground flex items-center gap-1.5">
504
+ <Folder size={12} className="shrink-0" /> ..
505
+ </button>
506
+ )}
507
+ {browseLoading && <div className="px-3 py-2 text-xs text-muted-foreground">Loading...</div>}
508
+ {!browseLoading && browseDirs.length === 0 && (
509
+ <div className="px-3 py-2 text-xs text-muted-foreground">No subdirectories</div>
510
+ )}
511
+ {browseDirs.map(d => (
512
+ <button key={d} onClick={() => navigateInto(d)}
513
+ className="w-full text-left px-3 py-1.5 text-xs hover:bg-muted transition-colors text-foreground flex items-center gap-1.5">
514
+ <Folder size={12} className="text-[var(--amber)] shrink-0" />
515
+ <span className="truncate flex-1">{d}</span>
516
+ <ChevronRight size={10} className="text-muted-foreground/40 shrink-0" />
517
+ </button>
518
+ ))}
519
+ </Dropdown>
520
+ </div>
521
+ );
522
+ }