@geminilight/mindos 0.5.70 → 0.6.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 (42) hide show
  1. package/app/app/api/ask/route.ts +122 -92
  2. package/app/app/api/mcp/agents/route.ts +53 -2
  3. package/app/app/api/mcp/status/route.ts +1 -1
  4. package/app/app/api/skills/route.ts +10 -114
  5. package/app/components/ActivityBar.tsx +3 -4
  6. package/app/components/CreateSpaceModal.tsx +31 -6
  7. package/app/components/FileTree.tsx +33 -2
  8. package/app/components/GuideCard.tsx +197 -131
  9. package/app/components/HomeContent.tsx +85 -18
  10. package/app/components/SidebarLayout.tsx +13 -0
  11. package/app/components/SpaceInitToast.tsx +173 -0
  12. package/app/components/agents/AgentDetailContent.tsx +32 -17
  13. package/app/components/agents/AgentsContentPage.tsx +2 -1
  14. package/app/components/agents/AgentsOverviewSection.tsx +1 -14
  15. package/app/components/agents/agents-content-model.ts +16 -8
  16. package/app/components/ask/AskContent.tsx +137 -50
  17. package/app/components/ask/MentionPopover.tsx +16 -8
  18. package/app/components/ask/SlashCommandPopover.tsx +62 -0
  19. package/app/components/settings/KnowledgeTab.tsx +61 -0
  20. package/app/components/walkthrough/steps.ts +11 -6
  21. package/app/hooks/useMention.ts +14 -6
  22. package/app/hooks/useSlashCommand.ts +114 -0
  23. package/app/lib/actions.ts +79 -2
  24. package/app/lib/agent/index.ts +1 -1
  25. package/app/lib/agent/prompt.ts +2 -0
  26. package/app/lib/agent/tools.ts +106 -0
  27. package/app/lib/core/create-space.ts +11 -4
  28. package/app/lib/core/index.ts +1 -1
  29. package/app/lib/i18n-en.ts +51 -46
  30. package/app/lib/i18n-zh.ts +50 -45
  31. package/app/lib/mcp-agents.ts +8 -0
  32. package/app/lib/pdf-extract.ts +33 -0
  33. package/app/lib/pi-integration/extensions.ts +45 -0
  34. package/app/lib/pi-integration/mcporter.ts +219 -0
  35. package/app/lib/pi-integration/session-store.ts +62 -0
  36. package/app/lib/pi-integration/skills.ts +116 -0
  37. package/app/lib/settings.ts +1 -1
  38. package/app/next-env.d.ts +1 -1
  39. package/app/next.config.ts +1 -1
  40. package/app/package.json +2 -0
  41. package/mcp/src/index.ts +29 -0
  42. package/package.json +1 -1
@@ -1,14 +1,17 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useLayoutEffect, useRef, useState, useCallback } from 'react';
4
- import { Sparkles, Send, AtSign, Paperclip, StopCircle, RotateCcw, History, X, Maximize2, Minimize2, PanelRight, AppWindow } from 'lucide-react';
4
+ import { Sparkles, Send, Paperclip, StopCircle, RotateCcw, History, X, Zap, Maximize2, Minimize2, PanelRight, AppWindow } from 'lucide-react';
5
5
  import { useLocale } from '@/lib/LocaleContext';
6
6
  import type { Message } from '@/lib/types';
7
7
  import { useAskSession } from '@/hooks/useAskSession';
8
8
  import { useFileUpload } from '@/hooks/useFileUpload';
9
9
  import { useMention } from '@/hooks/useMention';
10
+ import { useSlashCommand } from '@/hooks/useSlashCommand';
11
+ import type { SlashItem } from '@/hooks/useSlashCommand';
10
12
  import MessageList from '@/components/ask/MessageList';
11
13
  import MentionPopover from '@/components/ask/MentionPopover';
14
+ import SlashCommandPopover from '@/components/ask/SlashCommandPopover';
12
15
  import SessionHistory from '@/components/ask/SessionHistory';
13
16
  import FileChip from '@/components/ask/FileChip';
14
17
  import { consumeUIMessageStream } from '@/lib/agent/stream-consumer';
@@ -173,9 +176,12 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
173
176
  const [showHistory, setShowHistory] = useState(false);
174
177
  const [isDragOver, setIsDragOver] = useState(false);
175
178
 
179
+ const [selectedSkill, setSelectedSkill] = useState<SlashItem | null>(null);
180
+
176
181
  const session = useAskSession(currentFile);
177
182
  const upload = useFileUpload();
178
183
  const mention = useMention();
184
+ const slash = useSlashCommand();
179
185
 
180
186
  useEffect(() => {
181
187
  const handler = (e: Event) => {
@@ -203,6 +209,8 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
203
209
  setAttachedFiles(currentFile ? [currentFile] : []);
204
210
  upload.clearAttachments();
205
211
  mention.resetMention();
212
+ slash.resetSlash();
213
+ setSelectedSkill(null);
206
214
  setShowHistory(false);
207
215
  } else if (!visible && variant === 'modal') {
208
216
  // Modal: abort streaming on close
@@ -226,12 +234,13 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
226
234
  const handler = (e: KeyboardEvent) => {
227
235
  if (e.key === 'Escape') {
228
236
  if (mention.mentionQuery !== null) { mention.resetMention(); return; }
237
+ if (slash.slashQuery !== null) { slash.resetSlash(); return; }
229
238
  onClose();
230
239
  }
231
240
  };
232
241
  window.addEventListener('keydown', handler);
233
242
  return () => window.removeEventListener('keydown', handler);
234
- }, [variant, visible, onClose, mention]);
243
+ }, [variant, visible, onClose, mention, slash]);
235
244
 
236
245
  useEffect(() => {
237
246
  if (!isPanel) return;
@@ -252,29 +261,67 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
252
261
  }, [input, isPanel, isLoading, visible, panelComposerHeight]);
253
262
 
254
263
  const mentionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
255
- const handleInputChange = useCallback((val: string) => {
264
+ const slashTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
265
+ const handleInputChange = useCallback((val: string, cursorPos?: number) => {
256
266
  setInput(val);
267
+ const pos = cursorPos ?? val.length;
257
268
  if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current);
258
- mentionTimerRef.current = setTimeout(() => mention.updateMentionFromInput(val), 80);
259
- }, [mention]);
269
+ if (slashTimerRef.current) clearTimeout(slashTimerRef.current);
270
+ mentionTimerRef.current = setTimeout(() => mention.updateMentionFromInput(val, pos), 80);
271
+ slashTimerRef.current = setTimeout(() => slash.updateSlashFromInput(val, pos), 80);
272
+ }, [mention, slash]);
260
273
 
261
274
  useEffect(() => {
262
- return () => { if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current); };
275
+ return () => {
276
+ if (mentionTimerRef.current) clearTimeout(mentionTimerRef.current);
277
+ if (slashTimerRef.current) clearTimeout(slashTimerRef.current);
278
+ };
263
279
  }, []);
264
280
 
265
281
  const selectMention = useCallback((filePath: string) => {
266
- const atIdx = input.lastIndexOf('@');
267
- setInput(input.slice(0, atIdx));
282
+ const el = inputRef.current;
283
+ const cursorPos = el
284
+ ? (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement ? el.selectionStart ?? input.length : input.length)
285
+ : input.length;
286
+ const before = input.slice(0, cursorPos);
287
+ const atIdx = before.lastIndexOf('@');
288
+ const newVal = input.slice(0, atIdx) + input.slice(cursorPos);
289
+ setInput(newVal);
268
290
  mention.resetMention();
269
291
  if (!attachedFiles.includes(filePath)) {
270
292
  setAttachedFiles(prev => [...prev, filePath]);
271
293
  }
272
- setTimeout(() => inputRef.current?.focus(), 0);
294
+ setTimeout(() => {
295
+ inputRef.current?.focus();
296
+ inputRef.current?.setSelectionRange(atIdx, atIdx);
297
+ }, 0);
273
298
  }, [input, attachedFiles, mention]);
274
299
 
300
+ const selectSlashCommand = useCallback((item: SlashItem) => {
301
+ const el = inputRef.current;
302
+ const cursorPos = el
303
+ ? (el instanceof HTMLTextAreaElement || el instanceof HTMLInputElement ? el.selectionStart ?? input.length : input.length)
304
+ : input.length;
305
+ const before = input.slice(0, cursorPos);
306
+ const slashIdx = before.lastIndexOf('/');
307
+ const newVal = input.slice(0, slashIdx) + input.slice(cursorPos);
308
+ setInput(newVal);
309
+ setSelectedSkill(item);
310
+ slash.resetSlash();
311
+ setTimeout(() => {
312
+ inputRef.current?.focus();
313
+ inputRef.current?.setSelectionRange(slashIdx, slashIdx);
314
+ }, 0);
315
+ }, [input, slash]);
316
+
275
317
  const handleInputKeyDown = useCallback(
276
318
  (e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => {
277
319
  if (mention.mentionQuery !== null) {
320
+ if (e.key === 'Escape') {
321
+ e.preventDefault();
322
+ mention.resetMention();
323
+ return;
324
+ }
278
325
  if (e.key === 'ArrowDown') {
279
326
  e.preventDefault();
280
327
  mention.navigateMention('down');
@@ -290,29 +337,51 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
290
337
  }
291
338
  return;
292
339
  }
293
- // Panel: multiline input — Enter sends, Shift+Enter inserts newline (textarea default).
340
+ if (slash.slashQuery !== null) {
341
+ if (e.key === 'Escape') {
342
+ e.preventDefault();
343
+ slash.resetSlash();
344
+ return;
345
+ }
346
+ if (e.key === 'ArrowDown') {
347
+ e.preventDefault();
348
+ slash.navigateSlash('down');
349
+ } else if (e.key === 'ArrowUp') {
350
+ e.preventDefault();
351
+ slash.navigateSlash('up');
352
+ } else if (e.key === 'Enter' || e.key === 'Tab') {
353
+ if (e.key === 'Enter' && e.shiftKey) return;
354
+ if (slash.slashResults.length > 0) {
355
+ e.preventDefault();
356
+ selectSlashCommand(slash.slashResults[slash.slashIndex]);
357
+ }
358
+ }
359
+ return;
360
+ }
294
361
  if (variant === 'panel' && e.key === 'Enter' && !e.shiftKey && !isLoading && input.trim()) {
295
362
  e.preventDefault();
296
363
  (e.currentTarget as HTMLTextAreaElement).form?.requestSubmit();
297
364
  }
298
365
  },
299
- [mention, selectMention, variant, isLoading, input],
366
+ [mention, selectMention, slash, selectSlashCommand, variant, isLoading, input],
300
367
  );
301
368
 
302
369
  const handleStop = useCallback(() => { abortRef.current?.abort(); }, []);
303
370
 
304
371
  const handleSubmit = useCallback(async (e: React.FormEvent) => {
305
372
  e.preventDefault();
306
- if (mention.mentionQuery !== null) return;
373
+ if (mention.mentionQuery !== null || slash.slashQuery !== null) return;
307
374
  const text = input.trim();
308
375
  if (!text || isLoading) return;
309
376
 
310
- // Attach current timestamp so backend knows EXACTLY when the user typed this message
311
- const userMsg: Message = { role: 'user', content: text, timestamp: Date.now() };
377
+ const content = selectedSkill
378
+ ? `Use the skill ${selectedSkill.name}: ${text}`
379
+ : text;
380
+ const userMsg: Message = { role: 'user', content, timestamp: Date.now() };
312
381
  const requestMessages = [...session.messages, userMsg];
313
- // And for the incoming assistant response, give it an initial timestamp
314
382
  session.setMessages([...requestMessages, { role: 'assistant', content: '', timestamp: Date.now() }]);
315
383
  setInput('');
384
+ setSelectedSkill(null);
316
385
  if (onFirstMessage && !firstMessageFired.current) {
317
386
  firstMessageFired.current = true;
318
387
  onFirstMessage();
@@ -410,7 +479,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
410
479
  setIsLoading(false);
411
480
  abortRef.current = null;
412
481
  }
413
- }, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, t.ask.errorNoResponse, t.ask.stopped, onFirstMessage]);
482
+ }, [input, session, isLoading, currentFile, attachedFiles, upload.localAttachments, mention.mentionQuery, slash.slashQuery, selectedSkill, t.ask.errorNoResponse, t.ask.stopped, onFirstMessage]);
414
483
 
415
484
  const handleResetSession = useCallback(() => {
416
485
  if (isLoading) return;
@@ -419,9 +488,11 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
419
488
  setAttachedFiles(currentFile ? [currentFile] : []);
420
489
  upload.clearAttachments();
421
490
  mention.resetMention();
491
+ slash.resetSlash();
492
+ setSelectedSkill(null);
422
493
  setShowHistory(false);
423
494
  setTimeout(() => inputRef.current?.focus(), 0);
424
- }, [isLoading, currentFile, session, upload, mention]);
495
+ }, [isLoading, currentFile, session, upload, mention, slash]);
425
496
 
426
497
  const handleDragOver = useCallback((e: React.DragEvent) => {
427
498
  if (e.dataTransfer.types.includes('text/mindos-path')) {
@@ -449,8 +520,10 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
449
520
  setAttachedFiles(currentFile ? [currentFile] : []);
450
521
  upload.clearAttachments();
451
522
  mention.resetMention();
523
+ slash.resetSlash();
524
+ setSelectedSkill(null);
452
525
  setTimeout(() => inputRef.current?.focus(), 0);
453
- }, [session, currentFile, upload, mention]);
526
+ }, [session, currentFile, upload, mention, slash]);
454
527
 
455
528
  const iconSize = isPanel ? 13 : 14;
456
529
  const inputIconSize = isPanel ? 14 : 15;
@@ -520,6 +593,27 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
520
593
  labels={{ connecting: t.ask.connecting, thinking: t.ask.thinking, generating: t.ask.generating }}
521
594
  />
522
595
 
596
+ {/* Popovers — rendered outside overflow containers so they can extend freely */}
597
+ {mention.mentionQuery !== null && mention.mentionResults.length > 0 && (
598
+ <div className="shrink-0 px-2 pb-1">
599
+ <MentionPopover
600
+ results={mention.mentionResults}
601
+ selectedIndex={mention.mentionIndex}
602
+ onSelect={selectMention}
603
+ />
604
+ </div>
605
+ )}
606
+
607
+ {slash.slashQuery !== null && slash.slashResults.length > 0 && (
608
+ <div className="shrink-0 px-2 pb-1">
609
+ <SlashCommandPopover
610
+ results={slash.slashResults}
611
+ selectedIndex={slash.slashIndex}
612
+ onSelect={selectSlashCommand}
613
+ />
614
+ </div>
615
+ )}
616
+
523
617
  {/* Input area — panel: fixed-height shell + top drag handle (persisted); modal: simple block */}
524
618
  <div
525
619
  className={cn(
@@ -558,7 +652,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
558
652
  {attachedFiles.length > 0 && (
559
653
  <div className={cn('shrink-0', isPanel ? 'px-3 pt-2 pb-1' : 'px-4 pt-2.5 pb-1')}>
560
654
  <div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
561
- {isPanel ? 'Context' : 'Knowledge Base Context'}
655
+ {t.ask.attachFile}
562
656
  </div>
563
657
  <div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
564
658
  {attachedFiles.map(f => (
@@ -571,7 +665,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
571
665
  {upload.localAttachments.length > 0 && (
572
666
  <div className={cn('shrink-0', isPanel ? 'px-3 pb-1' : 'px-4 pb-1')}>
573
667
  <div className={`text-muted-foreground/70 mb-1 ${isPanel ? 'text-[10px]' : 'text-xs'}`}>
574
- {isPanel ? 'Uploaded' : 'Uploaded Files'}
668
+ {t.ask.uploadedFiles}
575
669
  </div>
576
670
  <div className={`flex flex-wrap ${isPanel ? 'gap-1' : 'gap-1.5'}`}>
577
671
  {upload.localAttachments.map((f, idx) => (
@@ -581,16 +675,25 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
581
675
  </div>
582
676
  )}
583
677
 
584
- {upload.uploadError && (
585
- <div className={cn('shrink-0 pb-1 text-xs text-error', isPanel ? 'px-3' : 'px-4')}>{upload.uploadError}</div>
678
+ {selectedSkill && (
679
+ <div className={cn('shrink-0', isPanel ? 'px-3 pt-1.5 pb-1' : 'px-4 pt-2 pb-1')}>
680
+ <span className="inline-flex items-center gap-1.5 pl-2 pr-1 py-1 rounded-md text-xs bg-[var(--amber)]/10 border border-[var(--amber)]/25 text-foreground">
681
+ <Zap size={11} className="text-[var(--amber)] shrink-0" />
682
+ <span className="font-medium">{selectedSkill.name}</span>
683
+ <button
684
+ type="button"
685
+ onClick={() => { setSelectedSkill(null); inputRef.current?.focus(); }}
686
+ className="p-0.5 rounded text-muted-foreground hover:text-foreground transition-colors shrink-0"
687
+ aria-label={`Remove skill ${selectedSkill.name}`}
688
+ >
689
+ <X size={10} />
690
+ </button>
691
+ </span>
692
+ </div>
586
693
  )}
587
694
 
588
- {mention.mentionQuery !== null && mention.mentionResults.length > 0 && (
589
- <MentionPopover
590
- results={mention.mentionResults}
591
- selectedIndex={mention.mentionIndex}
592
- onSelect={selectMention}
593
- />
695
+ {upload.uploadError && (
696
+ <div className={cn('shrink-0 pb-1 text-xs text-error', isPanel ? 'px-3' : 'px-4')}>{upload.uploadError}</div>
594
697
  )}
595
698
 
596
699
  <form
@@ -618,32 +721,13 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
618
721
  }}
619
722
  />
620
723
 
621
- <button
622
- type="button"
623
- onClick={() => {
624
- const el = inputRef.current;
625
- if (!el) return;
626
- const pos = el.selectionStart ?? input.length;
627
- const newVal = input.slice(0, pos) + '@' + input.slice(pos);
628
- handleInputChange(newVal);
629
- setTimeout(() => {
630
- el.focus();
631
- el.setSelectionRange(pos + 1, pos + 1);
632
- }, 0);
633
- }}
634
- className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0"
635
- title="@ mention file"
636
- >
637
- <AtSign size={inputIconSize} />
638
- </button>
639
-
640
724
  {isPanel ? (
641
725
  <textarea
642
726
  ref={(el) => {
643
727
  inputRef.current = el;
644
728
  }}
645
729
  value={input}
646
- onChange={e => handleInputChange(e.target.value)}
730
+ onChange={e => handleInputChange(e.target.value, e.target.selectionStart ?? undefined)}
647
731
  onKeyDown={handleInputKeyDown}
648
732
  placeholder={t.ask.placeholder}
649
733
  rows={1}
@@ -655,7 +739,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
655
739
  inputRef.current = el;
656
740
  }}
657
741
  value={input}
658
- onChange={e => handleInputChange(e.target.value)}
742
+ onChange={e => handleInputChange(e.target.value, e.target.selectionStart ?? undefined)}
659
743
  onKeyDown={handleInputKeyDown}
660
744
  placeholder={t.ask.placeholder}
661
745
  className="flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground outline-none min-w-0"
@@ -667,7 +751,7 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
667
751
  <StopCircle size={inputIconSize} />
668
752
  </button>
669
753
  ) : (
670
- <button type="submit" disabled={!input.trim() || mention.mentionQuery !== null} className="p-1.5 rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0 bg-[var(--amber)] text-[var(--amber-foreground)]">
754
+ <button type="submit" disabled={!input.trim() || mention.mentionQuery !== null || slash.slashQuery !== null} className="p-1.5 rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-opacity shrink-0 bg-[var(--amber)] text-[var(--amber-foreground)]">
671
755
  <Send size={isPanel ? 13 : 14} />
672
756
  </button>
673
757
  )}
@@ -696,6 +780,9 @@ export default function AskContent({ visible, currentFile, initialMessage, onFir
696
780
  <span suppressHydrationWarning>
697
781
  <kbd className="font-mono">@</kbd> {t.ask.attachFile}
698
782
  </span>
783
+ <span suppressHydrationWarning>
784
+ <kbd className="font-mono">/</kbd> {t.ask.skillsHint}
785
+ </span>
699
786
  {!isPanel && (
700
787
  <span suppressHydrationWarning>
701
788
  <kbd className="font-mono">ESC</kbd> {t.search.close}
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
 
3
3
  import { useEffect, useRef } from 'react';
4
- import { FileText, Table } from 'lucide-react';
4
+ import { FileText, Table, FolderOpen } from 'lucide-react';
5
5
 
6
6
  interface MentionPopoverProps {
7
7
  results: string[];
@@ -22,10 +22,16 @@ export default function MentionPopover({ results, selectedIndex, onSelect }: Men
22
22
  if (results.length === 0) return null;
23
23
 
24
24
  return (
25
- <div className="mx-4 mb-1 border border-border rounded-lg bg-card shadow-lg overflow-hidden">
26
- <div ref={listRef} className="max-h-[240px] overflow-y-auto">
25
+ <div className="border border-border rounded-lg bg-card shadow-lg overflow-hidden">
26
+ <div className="px-3 py-1.5 border-b border-border flex items-center gap-1.5">
27
+ <FolderOpen size={11} className="text-muted-foreground/50" />
28
+ <span className="text-2xs font-medium text-muted-foreground/70 uppercase tracking-wider">Files</span>
29
+ <span className="text-2xs text-muted-foreground/40 ml-auto">{results.length}</span>
30
+ </div>
31
+ <div ref={listRef} className="max-h-[360px] overflow-y-auto">
27
32
  {results.map((f, idx) => {
28
33
  const name = f.split('/').pop() ?? f;
34
+ const dir = f.split('/').slice(0, -1).join('/');
29
35
  const isCsv = name.endsWith('.csv');
30
36
  return (
31
37
  <button
@@ -46,15 +52,17 @@ export default function MentionPopover({ results, selectedIndex, onSelect }: Men
46
52
  ) : (
47
53
  <FileText size={13} className="text-muted-foreground shrink-0" />
48
54
  )}
49
- <span className="truncate flex-1">{name}</span>
50
- <span className="text-2xs text-muted-foreground/50 truncate max-w-[140px] shrink-0">
51
- {f.split('/').slice(0, -1).join('/')}
52
- </span>
55
+ <span className="truncate font-medium flex-1">{name}</span>
56
+ {dir && (
57
+ <span className="text-2xs text-muted-foreground/40 truncate max-w-[140px] shrink-0">
58
+ {dir}
59
+ </span>
60
+ )}
53
61
  </button>
54
62
  );
55
63
  })}
56
64
  </div>
57
- <div className="px-3 py-1.5 border-t border-border flex gap-3 text-2xs text-muted-foreground/50 shrink-0">
65
+ <div className="px-3 py-1.5 border-t border-border flex gap-3 text-2xs text-muted-foreground/40 shrink-0">
58
66
  <span>↑↓ navigate</span>
59
67
  <span>↵ select</span>
60
68
  <span>ESC dismiss</span>
@@ -0,0 +1,62 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useRef } from 'react';
4
+ import { Zap } from 'lucide-react';
5
+ import type { SlashItem } from '@/hooks/useSlashCommand';
6
+
7
+ interface SlashCommandPopoverProps {
8
+ results: SlashItem[];
9
+ selectedIndex: number;
10
+ onSelect: (item: SlashItem) => void;
11
+ }
12
+
13
+ export default function SlashCommandPopover({ results, selectedIndex, onSelect }: SlashCommandPopoverProps) {
14
+ const listRef = useRef<HTMLDivElement>(null);
15
+
16
+ useEffect(() => {
17
+ const container = listRef.current;
18
+ if (!container) return;
19
+ const selected = container.children[selectedIndex] as HTMLElement | undefined;
20
+ selected?.scrollIntoView({ block: 'nearest' });
21
+ }, [selectedIndex]);
22
+
23
+ if (results.length === 0) return null;
24
+
25
+ return (
26
+ <div className="border border-border rounded-lg bg-card shadow-lg overflow-hidden">
27
+ <div className="px-3 py-1.5 border-b border-border flex items-center gap-1.5">
28
+ <Zap size={11} className="text-[var(--amber)]/50" />
29
+ <span className="text-2xs font-medium text-muted-foreground/70 uppercase tracking-wider">Skills</span>
30
+ <span className="text-2xs text-muted-foreground/40 ml-auto">{results.length}</span>
31
+ </div>
32
+ <div ref={listRef} className="max-h-[360px] overflow-y-auto">
33
+ {results.map((item, idx) => (
34
+ <button
35
+ key={item.name}
36
+ type="button"
37
+ onMouseDown={(e) => {
38
+ e.preventDefault();
39
+ onSelect(item);
40
+ }}
41
+ className={`w-full flex items-center gap-2.5 px-3 py-2 text-left transition-colors ${
42
+ idx === selectedIndex
43
+ ? 'bg-accent text-foreground'
44
+ : 'text-muted-foreground hover:bg-muted'
45
+ }`}
46
+ >
47
+ <Zap size={13} className="text-[var(--amber)] shrink-0" />
48
+ <span className="text-sm font-medium shrink-0">/{item.name}</span>
49
+ {item.description && (
50
+ <span className="text-2xs text-muted-foreground/50 truncate min-w-0 flex-1">{item.description}</span>
51
+ )}
52
+ </button>
53
+ ))}
54
+ </div>
55
+ <div className="px-3 py-1.5 border-t border-border flex gap-3 text-2xs text-muted-foreground/40 shrink-0">
56
+ <span>↑↓ navigate</span>
57
+ <span>↵ / Tab select</span>
58
+ <span>ESC dismiss</span>
59
+ </div>
60
+ </div>
61
+ );
62
+ }
@@ -7,11 +7,27 @@ import { Field, Input, EnvBadge, SectionLabel, Toggle } from './Primitives';
7
7
  import { apiFetch } from '@/lib/api';
8
8
  import { copyToClipboard } from '@/lib/clipboard';
9
9
  import { formatBytes, formatUptime } from '@/lib/format';
10
+ import { setShowHiddenFiles } from '@/components/FileTree';
11
+ import { scanExampleFilesAction, cleanupExamplesAction } from '@/lib/actions';
10
12
 
11
13
  export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
12
14
  const env = data.envOverrides ?? {};
13
15
  const k = t.settings.knowledge;
14
16
 
17
+ // Hidden files toggle
18
+ const [showHidden, setShowHidden] = useState(() =>
19
+ typeof window !== 'undefined' && localStorage.getItem('show-hidden-files') === 'true'
20
+ );
21
+
22
+ // Example files cleanup
23
+ const [exampleCount, setExampleCount] = useState<number | null>(null);
24
+ const [cleaningUp, setCleaningUp] = useState(false);
25
+ const [cleanupResult, setCleanupResult] = useState<number | null>(null);
26
+
27
+ useEffect(() => {
28
+ scanExampleFilesAction().then(r => setExampleCount(r.files.length)).catch(() => {});
29
+ }, []);
30
+
15
31
  // Guide state toggle
16
32
  const [guideActive, setGuideActive] = useState<boolean | null>(null);
17
33
  const [guideDismissed, setGuideDismissed] = useState(false);
@@ -130,6 +146,51 @@ export function KnowledgeTab({ data, setData, t }: KnowledgeTabProps) {
130
146
  />
131
147
  </Field>
132
148
 
149
+ <div className="flex items-center justify-between">
150
+ <div>
151
+ <div className="text-sm text-foreground">{k.showHiddenFiles}</div>
152
+ <div className="text-xs text-muted-foreground mt-0.5">{k.showHiddenFilesHint}</div>
153
+ </div>
154
+ <Toggle checked={showHidden} onChange={() => {
155
+ const next = !showHidden;
156
+ setShowHidden(next);
157
+ setShowHiddenFiles(next);
158
+ }} />
159
+ </div>
160
+
161
+ {exampleCount !== null && exampleCount > 0 && cleanupResult === null && (
162
+ <div className="flex items-center justify-between">
163
+ <div>
164
+ <div className="text-sm text-foreground">{k.cleanupExamples}</div>
165
+ <div className="text-xs text-muted-foreground mt-0.5">{k.cleanupExamplesHint}</div>
166
+ </div>
167
+ <button
168
+ onClick={async () => {
169
+ if (!confirm(k.cleanupExamplesConfirm(exampleCount))) return;
170
+ setCleaningUp(true);
171
+ const r = await cleanupExamplesAction();
172
+ setCleaningUp(false);
173
+ if (r.success) {
174
+ setCleanupResult(r.deleted);
175
+ setExampleCount(0);
176
+ }
177
+ }}
178
+ disabled={cleaningUp}
179
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border text-muted-foreground hover:text-foreground hover:bg-muted transition-colors shrink-0 disabled:opacity-50"
180
+ >
181
+ {cleaningUp ? <Loader2 size={12} className="animate-spin" /> : <Trash2 size={12} />}
182
+ {k.cleanupExamplesButton}
183
+ <span className="ml-1 tabular-nums text-2xs opacity-70">{exampleCount}</span>
184
+ </button>
185
+ </div>
186
+ )}
187
+ {cleanupResult !== null && (
188
+ <div className="flex items-center gap-2 text-xs text-success">
189
+ <Check size={14} />
190
+ {k.cleanupExamplesDone(cleanupResult)}
191
+ </div>
192
+ )}
193
+
133
194
  <div className="border-t border-border pt-5">
134
195
  <SectionLabel>Security</SectionLabel>
135
196
  </div>
@@ -1,10 +1,9 @@
1
1
  /** Walkthrough step anchors — these data-walkthrough attributes are added to target components */
2
2
  export type WalkthroughAnchor =
3
- | 'activity-bar'
4
3
  | 'files-panel'
5
4
  | 'ask-button'
6
- | 'search-button'
7
- | 'settings-button';
5
+ | 'agents-panel'
6
+ | 'echo-panel';
8
7
 
9
8
  export interface WalkthroughStep {
10
9
  anchor: WalkthroughAnchor;
@@ -12,10 +11,16 @@ export interface WalkthroughStep {
12
11
  position: 'right' | 'bottom';
13
12
  }
14
13
 
14
+ /**
15
+ * 4-step value-driven walkthrough aligned with the Dual-Layer Wedge strategy:
16
+ * 0. Project Memory (foundation)
17
+ * 1. AI That Already Knows You (wedge)
18
+ * 2. Multi-Agent Sharing (differentiation)
19
+ * 3. Echo — Cognitive Compound Interest (retention seed)
20
+ */
15
21
  export const walkthroughSteps: WalkthroughStep[] = [
16
- { anchor: 'activity-bar', position: 'right' },
17
22
  { anchor: 'files-panel', position: 'right' },
18
23
  { anchor: 'ask-button', position: 'right' },
19
- { anchor: 'search-button', position: 'right' },
20
- { anchor: 'settings-button', position: 'right' },
24
+ { anchor: 'agents-panel', position: 'right' },
25
+ { anchor: 'echo-panel', position: 'right' },
21
26
  ];
@@ -27,19 +27,27 @@ export function useMention() {
27
27
  }, [loadFiles]);
28
28
 
29
29
  const updateMentionFromInput = useCallback(
30
- (val: string) => {
31
- const atIdx = val.lastIndexOf('@');
30
+ (val: string, cursorPos?: number) => {
31
+ const pos = cursorPos ?? val.length;
32
+ const before = val.slice(0, pos);
33
+ const atIdx = before.lastIndexOf('@');
32
34
  if (atIdx === -1) {
33
35
  setMentionQuery(null);
34
36
  return;
35
37
  }
36
- const before = val[atIdx - 1];
37
- if (atIdx > 0 && before !== ' ') {
38
+ if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') {
38
39
  setMentionQuery(null);
39
40
  return;
40
41
  }
41
- const query = val.slice(atIdx + 1).toLowerCase();
42
- const filtered = allFiles.filter((f) => f.toLowerCase().includes(query)).slice(0, 30);
42
+ const query = before.slice(atIdx + 1);
43
+ if (query.includes(' ') || query.includes('\n')) {
44
+ setMentionQuery(null);
45
+ setMentionResults([]);
46
+ setMentionIndex(0);
47
+ return;
48
+ }
49
+ const q = query.toLowerCase();
50
+ const filtered = allFiles.filter((f) => f.toLowerCase().includes(q)).slice(0, 30);
43
51
  if (filtered.length === 0) {
44
52
  setMentionQuery(null);
45
53
  setMentionResults([]);