@geminilight/mindos 0.5.18 → 0.5.20

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 (38) hide show
  1. package/app/app/api/ask/route.ts +5 -4
  2. package/app/app/api/file/route.ts +35 -11
  3. package/app/app/api/setup/route.ts +64 -1
  4. package/app/app/api/skills/route.ts +22 -3
  5. package/app/app/globals.css +1 -0
  6. package/app/components/AskFab.tsx +49 -3
  7. package/app/components/AskModal.tsx +11 -2
  8. package/app/components/GuideCard.tsx +361 -0
  9. package/app/components/HomeContent.tsx +2 -2
  10. package/app/components/Sidebar.tsx +21 -1
  11. package/app/components/ask/ToolCallBlock.tsx +2 -1
  12. package/app/components/settings/KnowledgeTab.tsx +64 -2
  13. package/app/components/settings/McpTab.tsx +286 -56
  14. package/app/components/setup/StepAI.tsx +9 -1
  15. package/app/components/setup/index.tsx +4 -0
  16. package/app/components/setup/types.ts +2 -0
  17. package/app/hooks/useAskModal.ts +46 -0
  18. package/app/lib/agent/stream-consumer.ts +4 -2
  19. package/app/lib/agent/tools.ts +26 -12
  20. package/app/lib/fs.ts +9 -1
  21. package/app/lib/i18n.ts +16 -0
  22. package/app/lib/settings.ts +29 -0
  23. package/app/next-env.d.ts +1 -1
  24. package/app/next.config.ts +7 -0
  25. package/bin/cli.js +135 -9
  26. package/bin/lib/build.js +2 -7
  27. package/bin/lib/mcp-spawn.js +2 -13
  28. package/bin/lib/utils.js +23 -0
  29. package/package.json +1 -1
  30. package/scripts/setup.js +13 -0
  31. package/skills/mindos/SKILL.md +10 -168
  32. package/skills/mindos-zh/SKILL.md +14 -172
  33. package/skills/project-wiki/SKILL.md +80 -74
  34. package/skills/project-wiki/references/file-reference.md +6 -2
  35. package/templates/skill-rules/en/skill-rules.md +222 -0
  36. package/templates/skill-rules/en/user-rules.md +20 -0
  37. package/templates/skill-rules/zh/skill-rules.md +222 -0
  38. package/templates/skill-rules/zh/user-rules.md +20 -0
@@ -1,11 +1,14 @@
1
1
  'use client';
2
2
 
3
- import { useState, useEffect, useCallback } from 'react';
3
+ import { useState, useEffect, useCallback, useMemo } from 'react';
4
4
  import {
5
5
  Plug, CheckCircle2, AlertCircle, Loader2, Copy, Check,
6
- ChevronDown, ChevronRight, Trash2, Plus, X,
6
+ ChevronDown, ChevronRight, Trash2, Plus, X, Search, Pencil,
7
7
  } from 'lucide-react';
8
8
  import { apiFetch } from '@/lib/api';
9
+ import dynamic from 'next/dynamic';
10
+
11
+ const MarkdownView = dynamic(() => import('@/components/MarkdownView'), { ssr: false });
9
12
 
10
13
  /* ── Types ─────────────────────────────────────────────────────── */
11
14
 
@@ -454,6 +457,12 @@ function AgentInstall({ agents, t, onRefresh }: { agents: AgentInfo[]; t: any; o
454
457
 
455
458
  /* ── Skills Section ────────────────────────────────────────────── */
456
459
 
460
+ const SKILL_TEMPLATES: Record<string, (name: string) => string> = {
461
+ general: (n: string) => `---\nname: ${n}\ndescription: >\n Describe WHEN the agent should use this\n skill. Be specific about trigger conditions.\n---\n\n# Instructions\n\n## Context\n<!-- Background knowledge for the agent -->\n\n## Steps\n1. \n2. \n\n## Rules\n<!-- Constraints, edge cases, formats -->\n- `,
462
+ 'tool-use': (n: string) => `---\nname: ${n}\ndescription: >\n Describe WHEN the agent should use this\n skill. Be specific about trigger conditions.\n---\n\n# Instructions\n\n## Available Tools\n<!-- List tools the agent can use -->\n- \n\n## When to Use\n<!-- Conditions that trigger this skill -->\n\n## Output Format\n<!-- Expected response structure -->\n`,
463
+ workflow: (n: string) => `---\nname: ${n}\ndescription: >\n Describe WHEN the agent should use this\n skill. Be specific about trigger conditions.\n---\n\n# Instructions\n\n## Trigger\n<!-- What triggers this workflow -->\n\n## Steps\n1. \n2. \n\n## Validation\n<!-- How to verify success -->\n\n## Rollback\n<!-- What to do on failure -->\n`,
464
+ };
465
+
457
466
  function SkillsSection({ t }: { t: any }) {
458
467
  const m = t.settings?.mcp;
459
468
  const [skills, setSkills] = useState<SkillInfo[]>([]);
@@ -461,11 +470,19 @@ function SkillsSection({ t }: { t: any }) {
461
470
  const [expanded, setExpanded] = useState<string | null>(null);
462
471
  const [adding, setAdding] = useState(false);
463
472
  const [newName, setNewName] = useState('');
464
- const [newDesc, setNewDesc] = useState('');
465
473
  const [newContent, setNewContent] = useState('');
466
474
  const [saving, setSaving] = useState(false);
467
475
  const [error, setError] = useState('');
468
476
 
477
+ // New state for search, grouping, full content, editing
478
+ const [search, setSearch] = useState('');
479
+ const [builtinCollapsed, setBuiltinCollapsed] = useState(true);
480
+ const [editing, setEditing] = useState<string | null>(null);
481
+ const [editContent, setEditContent] = useState('');
482
+ const [fullContent, setFullContent] = useState<Record<string, string>>({});
483
+ const [loadingContent, setLoadingContent] = useState<string | null>(null);
484
+ const [selectedTemplate, setSelectedTemplate] = useState<'general' | 'tool-use' | 'workflow'>('general');
485
+
469
486
  const fetchSkills = useCallback(async () => {
470
487
  try {
471
488
  const data = await apiFetch<{ skills: SkillInfo[] }>('/api/skills');
@@ -476,6 +493,16 @@ function SkillsSection({ t }: { t: any }) {
476
493
 
477
494
  useEffect(() => { fetchSkills(); }, [fetchSkills]);
478
495
 
496
+ // Filtered + grouped
497
+ const filtered = useMemo(() => {
498
+ if (!search) return skills;
499
+ const q = search.toLowerCase();
500
+ return skills.filter(s => s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q));
501
+ }, [skills, search]);
502
+
503
+ const customSkills = useMemo(() => filtered.filter(s => s.source === 'user'), [filtered]);
504
+ const builtinSkills = useMemo(() => filtered.filter(s => s.source === 'builtin'), [filtered]);
505
+
479
506
  const handleToggle = async (name: string, enabled: boolean) => {
480
507
  try {
481
508
  await apiFetch('/api/skills', {
@@ -496,23 +523,84 @@ function SkillsSection({ t }: { t: any }) {
496
523
  headers: { 'Content-Type': 'application/json' },
497
524
  body: JSON.stringify({ action: 'delete', name }),
498
525
  });
526
+ setFullContent(prev => { const n = { ...prev }; delete n[name]; return n; });
527
+ if (editing === name) setEditing(null);
528
+ if (expanded === name) setExpanded(null);
499
529
  fetchSkills();
500
530
  } catch { /* ignore */ }
501
531
  };
502
532
 
533
+ const loadFullContent = async (name: string) => {
534
+ if (fullContent[name]) return;
535
+ setLoadingContent(name);
536
+ try {
537
+ const data = await apiFetch<{ content: string }>('/api/skills', {
538
+ method: 'POST',
539
+ headers: { 'Content-Type': 'application/json' },
540
+ body: JSON.stringify({ action: 'read', name }),
541
+ });
542
+ setFullContent(prev => ({ ...prev, [name]: data.content }));
543
+ } catch {
544
+ // Store empty marker so UI shows "No description" rather than stuck loading
545
+ setFullContent(prev => ({ ...prev, [name]: '' }));
546
+ } finally {
547
+ setLoadingContent(null);
548
+ }
549
+ };
550
+
551
+ const handleExpand = (name: string) => {
552
+ const next = expanded === name ? null : name;
553
+ setExpanded(next);
554
+ if (next) loadFullContent(name);
555
+ if (editing && editing !== name) setEditing(null);
556
+ };
557
+
558
+ const handleEditStart = (name: string) => {
559
+ setEditing(name);
560
+ setEditContent(fullContent[name] || '');
561
+ };
562
+
563
+ const handleEditSave = async (name: string) => {
564
+ setSaving(true);
565
+ try {
566
+ await apiFetch('/api/skills', {
567
+ method: 'POST',
568
+ headers: { 'Content-Type': 'application/json' },
569
+ body: JSON.stringify({ action: 'update', name, content: editContent }),
570
+ });
571
+ setFullContent(prev => ({ ...prev, [name]: editContent }));
572
+ setEditing(null);
573
+ fetchSkills(); // refresh description from updated frontmatter
574
+ } catch { /* ignore */ } finally {
575
+ setSaving(false);
576
+ }
577
+ };
578
+
579
+ const handleEditCancel = () => {
580
+ setEditing(null);
581
+ setEditContent('');
582
+ };
583
+
584
+ const getTemplate = (skillName: string, tmpl?: string) => {
585
+ const key = tmpl || selectedTemplate;
586
+ const fn = SKILL_TEMPLATES[key] || SKILL_TEMPLATES.general;
587
+ return fn(skillName || 'my-skill');
588
+ };
589
+
503
590
  const handleCreate = async () => {
504
591
  if (!newName.trim()) return;
505
592
  setSaving(true);
506
593
  setError('');
507
594
  try {
595
+ // Content is the full SKILL.md (with frontmatter)
596
+ const content = newContent || getTemplate(newName.trim());
508
597
  await apiFetch('/api/skills', {
509
598
  method: 'POST',
510
599
  headers: { 'Content-Type': 'application/json' },
511
- body: JSON.stringify({ action: 'create', name: newName.trim(), description: newDesc.trim(), content: newContent }),
600
+ body: JSON.stringify({ action: 'create', name: newName.trim(), content }),
512
601
  });
513
602
  setAdding(false);
514
603
  setNewName('');
515
- setNewDesc('');
516
604
  setNewContent('');
517
605
  fetchSkills();
518
606
  } catch (err: unknown) {
@@ -522,6 +610,25 @@ function SkillsSection({ t }: { t: any }) {
522
610
  }
523
611
  };
524
612
 
613
+ // Sync template name when newName changes (only if content matches a template)
614
+ const handleNameChange = (val: string) => {
615
+ const cleaned = val.replace(/[^a-z0-9-]/g, '');
616
+ const oldTemplate = getTemplate(newName || 'my-skill');
617
+ if (!newContent || newContent === oldTemplate) {
618
+ setNewContent(getTemplate(cleaned || 'my-skill'));
619
+ }
620
+ setNewName(cleaned);
621
+ };
622
+
623
+ const handleTemplateChange = (tmpl: 'general' | 'tool-use' | 'workflow') => {
624
+ const oldTemplate = getTemplate(newName || 'my-skill', selectedTemplate);
625
+ setSelectedTemplate(tmpl);
626
+ // Only replace content if it matches the old template (user hasn't customized)
627
+ if (!newContent || newContent === oldTemplate) {
628
+ setNewContent(getTemplate(newName || 'my-skill', tmpl));
629
+ }
630
+ };
631
+
525
632
  if (loading) {
526
633
  return (
527
634
  <div className="flex justify-center py-4">
@@ -530,8 +637,128 @@ function SkillsSection({ t }: { t: any }) {
530
637
  );
531
638
  }
532
639
 
640
+ const renderSkillRow = (skill: SkillInfo) => (
641
+ <div key={skill.name} className="border border-border rounded-lg overflow-hidden">
642
+ <div
643
+ className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
644
+ onClick={() => handleExpand(skill.name)}
645
+ >
646
+ {expanded === skill.name ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
647
+ <span className="text-xs font-medium flex-1">{skill.name}</span>
648
+ <span className={`text-2xs px-1.5 py-0.5 rounded ${
649
+ skill.source === 'builtin' ? 'bg-blue-500/15 text-blue-500' : 'bg-purple-500/15 text-purple-500'
650
+ }`}>
651
+ {skill.source === 'builtin' ? (m?.skillBuiltin ?? 'Built-in') : (m?.skillUser ?? 'Custom')}
652
+ </span>
653
+ <button
654
+ onClick={e => { e.stopPropagation(); handleToggle(skill.name, !skill.enabled); }}
655
+ className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors ${
656
+ skill.enabled ? 'bg-success' : 'bg-muted-foreground/30'
657
+ }`}
658
+ >
659
+ <span className={`inline-block h-3 w-3 rounded-full bg-white transition-transform ${
660
+ skill.enabled ? 'translate-x-3.5' : 'translate-x-0.5'
661
+ }`} />
662
+ </button>
663
+ </div>
664
+
665
+ {expanded === skill.name && (
666
+ <div className="px-3 py-2 border-t border-border text-xs space-y-2 bg-muted/20">
667
+ <p className="text-muted-foreground">{skill.description || 'No description'}</p>
668
+ <p className="text-muted-foreground font-mono text-2xs">{skill.path}</p>
669
+
670
+ {/* Full content display / edit */}
671
+ {loadingContent === skill.name ? (
672
+ <div className="flex items-center gap-1.5 text-muted-foreground">
673
+ <Loader2 size={10} className="animate-spin" />
674
+ <span className="text-2xs">Loading...</span>
675
+ </div>
676
+ ) : fullContent[skill.name] ? (
677
+ <div className="space-y-1.5">
678
+ <div className="flex items-center justify-between">
679
+ <span className="text-2xs text-muted-foreground font-medium">{m?.skillContent ?? 'Content'}</span>
680
+ <div className="flex items-center gap-2">
681
+ {skill.editable && editing !== skill.name && (
682
+ <button
683
+ onClick={() => handleEditStart(skill.name)}
684
+ className="flex items-center gap-1 text-2xs text-muted-foreground hover:text-foreground transition-colors"
685
+ >
686
+ <Pencil size={10} />
687
+ {m?.editSkill ?? 'Edit'}
688
+ </button>
689
+ )}
690
+ {skill.editable && (
691
+ <button
692
+ onClick={() => handleDelete(skill.name)}
693
+ className="flex items-center gap-1 text-2xs text-destructive hover:underline"
694
+ >
695
+ <Trash2 size={10} />
696
+ {m?.deleteSkill ?? 'Delete'}
697
+ </button>
698
+ )}
699
+ </div>
700
+ </div>
701
+
702
+ {editing === skill.name ? (
703
+ <div className="space-y-1.5">
704
+ <textarea
705
+ value={editContent}
706
+ onChange={e => setEditContent(e.target.value)}
707
+ rows={Math.min(20, (editContent.match(/\n/g) || []).length + 3)}
708
+ className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y font-mono"
709
+ />
710
+ <div className="flex items-center gap-2">
711
+ <button
712
+ onClick={() => handleEditSave(skill.name)}
713
+ disabled={saving}
714
+ className="flex items-center gap-1 px-2.5 py-1 text-xs rounded-md disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
715
+ style={{ background: 'var(--amber)', color: 'var(--amber-foreground)' }}
716
+ >
717
+ {saving && <Loader2 size={10} className="animate-spin" />}
718
+ {m?.saveSkill ?? 'Save'}
719
+ </button>
720
+ <button
721
+ onClick={handleEditCancel}
722
+ className="px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground transition-colors"
723
+ >
724
+ {m?.cancelSkill ?? 'Cancel'}
725
+ </button>
726
+ </div>
727
+ </div>
728
+ ) : (
729
+ <div className="w-full rounded-md border border-border bg-background/50 max-h-[300px] overflow-y-auto px-2.5 py-1.5 text-xs [&_.prose]:max-w-none [&_.prose]:text-xs [&_h1]:text-sm [&_h2]:text-xs [&_h3]:text-xs [&_pre]:text-2xs [&_code]:text-2xs">
730
+ <MarkdownView content={fullContent[skill.name].replace(/^---\n[\s\S]*?\n---\n*/, '')} />
731
+ </div>
732
+ )}
733
+ </div>
734
+ ) : null}
735
+ </div>
736
+ )}
737
+ </div>
738
+ );
739
+
533
740
  return (
534
741
  <div className="space-y-3 pt-2">
742
+ {/* Search */}
743
+ <div className="relative">
744
+ <Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
745
+ <input
746
+ type="text"
747
+ value={search}
748
+ onChange={e => setSearch(e.target.value)}
749
+ placeholder={m?.searchSkills ?? 'Search skills...'}
750
+ className="w-full pl-7 pr-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
751
+ />
752
+ {search && (
753
+ <button
754
+ onClick={() => setSearch('')}
755
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
756
+ >
757
+ <X size={10} />
758
+ </button>
759
+ )}
760
+ </div>
761
+
535
762
  {/* Skill language switcher */}
536
763
  {(() => {
537
764
  const mindosEnabled = skills.find(s => s.name === 'mindos')?.enabled ?? true;
@@ -575,56 +802,49 @@ function SkillsSection({ t }: { t: any }) {
575
802
  );
576
803
  })()}
577
804
 
578
- {skills.map(skill => (
579
- <div key={skill.name} className="border border-border rounded-lg overflow-hidden">
805
+ {/* Empty search result */}
806
+ {filtered.length === 0 && search && (
807
+ <p className="text-xs text-muted-foreground text-center py-3">
808
+ {m?.noSkillsMatch ? m.noSkillsMatch(search) : `No skills match "${search}"`}
809
+ </p>
810
+ )}
811
+
812
+ {/* Custom group — always open */}
813
+ {customSkills.length > 0 && (
814
+ <div className="space-y-1.5">
815
+ <div className="flex items-center gap-2 text-xs font-medium text-muted-foreground">
816
+ <span>{m?.customGroup ?? 'Custom'} ({customSkills.length})</span>
817
+ </div>
818
+ <div className="space-y-1.5">
819
+ {customSkills.map(renderSkillRow)}
820
+ </div>
821
+ </div>
822
+ )}
823
+
824
+ {/* Built-in group — collapsible, default collapsed */}
825
+ {builtinSkills.length > 0 && (
826
+ <div className="space-y-1.5">
580
827
  <div
581
- className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-muted/50 transition-colors"
582
- onClick={() => setExpanded(expanded === skill.name ? null : skill.name)}
828
+ className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
829
+ onClick={() => setBuiltinCollapsed(!builtinCollapsed)}
583
830
  >
584
- {expanded === skill.name ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
585
- <span className="text-xs font-medium flex-1">{skill.name}</span>
586
- <span className={`text-2xs px-1.5 py-0.5 rounded ${
587
- skill.source === 'builtin' ? 'bg-blue-500/15 text-blue-500' : 'bg-purple-500/15 text-purple-500'
588
- }`}>
589
- {skill.source === 'builtin' ? (m?.skillBuiltin ?? 'Built-in') : (m?.skillUser ?? 'Custom')}
590
- </span>
591
- {/* Toggle */}
592
- <button
593
- onClick={e => { e.stopPropagation(); handleToggle(skill.name, !skill.enabled); }}
594
- className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors ${
595
- skill.enabled ? 'bg-success' : 'bg-muted-foreground/30'
596
- }`}
597
- >
598
- <span className={`inline-block h-3 w-3 rounded-full bg-white transition-transform ${
599
- skill.enabled ? 'translate-x-3.5' : 'translate-x-0.5'
600
- }`} />
601
- </button>
831
+ {builtinCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
832
+ <span>{m?.builtinGroup ?? 'Built-in'} ({builtinSkills.length})</span>
602
833
  </div>
603
-
604
- {expanded === skill.name && (
605
- <div className="px-3 py-2 border-t border-border text-xs space-y-1.5 bg-muted/20">
606
- <p className="text-muted-foreground">{skill.description || 'No description'}</p>
607
- <p className="text-muted-foreground font-mono text-2xs">{skill.path}</p>
608
- {skill.editable && (
609
- <button
610
- onClick={() => handleDelete(skill.name)}
611
- className="flex items-center gap-1 text-2xs text-destructive hover:underline"
612
- >
613
- <Trash2 size={10} />
614
- {m?.deleteSkill ?? 'Delete'}
615
- </button>
616
- )}
834
+ {!builtinCollapsed && (
835
+ <div className="space-y-1.5">
836
+ {builtinSkills.map(renderSkillRow)}
617
837
  </div>
618
838
  )}
619
839
  </div>
620
- ))}
840
+ )}
621
841
 
622
- {/* Add skill form */}
842
+ {/* Add skill form — template-based */}
623
843
  {adding ? (
624
844
  <div className="border border-border rounded-lg p-3 space-y-2">
625
845
  <div className="flex items-center justify-between">
626
846
  <span className="text-xs font-medium">{m?.addSkill ?? '+ Add Skill'}</span>
627
- <button onClick={() => setAdding(false)} className="p-0.5 rounded hover:bg-muted text-muted-foreground">
847
+ <button onClick={() => { setAdding(false); setNewName(''); setNewContent(''); setError(''); }} className="p-0.5 rounded hover:bg-muted text-muted-foreground">
628
848
  <X size={12} />
629
849
  </button>
630
850
  </div>
@@ -633,27 +853,37 @@ function SkillsSection({ t }: { t: any }) {
633
853
  <input
634
854
  type="text"
635
855
  value={newName}
636
- onChange={e => setNewName(e.target.value.replace(/[^a-z0-9-]/g, ''))}
856
+ onChange={e => handleNameChange(e.target.value)}
637
857
  placeholder="my-skill"
638
858
  className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background font-mono text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
639
859
  />
640
860
  </div>
641
861
  <div className="space-y-1">
642
- <label className="text-2xs text-muted-foreground">{m?.skillDesc ?? 'Description'}</label>
643
- <input
644
- type="text"
645
- value={newDesc}
646
- onChange={e => setNewDesc(e.target.value)}
647
- placeholder="What does this skill do?"
648
- className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring"
649
- />
862
+ <label className="text-2xs text-muted-foreground">{m?.skillTemplate ?? 'Template'}</label>
863
+ <div className="flex rounded-md border border-border overflow-hidden w-fit">
864
+ {(['general', 'tool-use', 'workflow'] as const).map((tmpl, i) => (
865
+ <button
866
+ key={tmpl}
867
+ onClick={() => handleTemplateChange(tmpl)}
868
+ className={`px-2.5 py-1 text-xs transition-colors ${i > 0 ? 'border-l border-border' : ''} ${
869
+ selectedTemplate === tmpl
870
+ ? 'bg-amber-500/15 text-amber-600 font-medium'
871
+ : 'text-muted-foreground hover:bg-muted'
872
+ }`}
873
+ >
874
+ {tmpl === 'general' ? (m?.skillTemplateGeneral ?? 'General')
875
+ : tmpl === 'tool-use' ? (m?.skillTemplateToolUse ?? 'Tool-use')
876
+ : (m?.skillTemplateWorkflow ?? 'Workflow')}
877
+ </button>
878
+ ))}
879
+ </div>
650
880
  </div>
651
881
  <div className="space-y-1">
652
882
  <label className="text-2xs text-muted-foreground">{m?.skillContent ?? 'Content'}</label>
653
883
  <textarea
654
884
  value={newContent}
655
885
  onChange={e => setNewContent(e.target.value)}
656
- rows={6}
886
+ rows={16}
657
887
  placeholder="Skill instructions (markdown)..."
658
888
  className="w-full px-2.5 py-1.5 text-xs rounded-md border border-border bg-background text-foreground outline-none focus-visible:ring-1 focus-visible:ring-ring resize-y font-mono"
659
889
  />
@@ -675,7 +905,7 @@ function SkillsSection({ t }: { t: any }) {
675
905
  {m?.saveSkill ?? 'Save'}
676
906
  </button>
677
907
  <button
678
- onClick={() => setAdding(false)}
908
+ onClick={() => { setAdding(false); setNewName(''); setNewContent(''); setError(''); }}
679
909
  className="px-2.5 py-1 text-xs rounded-md border border-border text-muted-foreground hover:text-foreground transition-colors"
680
910
  >
681
911
  {m?.cancelSkill ?? 'Cancel'}
@@ -684,7 +914,7 @@ function SkillsSection({ t }: { t: any }) {
684
914
  </div>
685
915
  ) : (
686
916
  <button
687
- onClick={() => setAdding(true)}
917
+ onClick={() => { setAdding(true); setSelectedTemplate('general'); setNewContent(getTemplate('my-skill', 'general')); }}
688
918
  className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
689
919
  >
690
920
  <Plus size={12} />
@@ -45,8 +45,16 @@ export default function StepAI({ state, update, s }: StepAIProps) {
45
45
  <ApiKeyInput
46
46
  value={state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey}
47
47
  onChange={v => update(state.provider === 'anthropic' ? 'anthropicKey' : 'openaiKey', v)}
48
- placeholder={state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...'}
48
+ placeholder={
49
+ (state.provider === 'anthropic' ? state.anthropicKeyMask : state.openaiKeyMask)
50
+ || (state.provider === 'anthropic' ? 'sk-ant-...' : 'sk-...')
51
+ }
49
52
  />
53
+ {(state.provider === 'anthropic' ? state.anthropicKeyMask : state.openaiKeyMask) && !(state.provider === 'anthropic' ? state.anthropicKey : state.openaiKey) && (
54
+ <p className="text-xs mt-1" style={{ color: 'var(--muted-foreground)' }}>
55
+ {s.apiKeyExisting ?? 'Existing key configured. Leave blank to keep it.'}
56
+ </p>
57
+ )}
50
58
  </Field>
51
59
  <Field label={s.model}>
52
60
  <Input
@@ -129,9 +129,11 @@ export default function SetupWizard() {
129
129
  provider: 'anthropic',
130
130
  anthropicKey: '',
131
131
  anthropicModel: 'claude-sonnet-4-6',
132
+ anthropicKeyMask: '',
132
133
  openaiKey: '',
133
134
  openaiModel: 'gpt-5.4',
134
135
  openaiBaseUrl: '',
136
+ openaiKeyMask: '',
135
137
  webPort: 3000,
136
138
  mcpPort: 8787,
137
139
  authToken: '',
@@ -172,8 +174,10 @@ export default function SetupWizard() {
172
174
  webPassword: data.webPassword || prev.webPassword,
173
175
  provider: (data.provider === 'anthropic' || data.provider === 'openai') ? data.provider : prev.provider,
174
176
  anthropicModel: data.anthropicModel || prev.anthropicModel,
177
+ anthropicKeyMask: data.anthropicApiKey || '',
175
178
  openaiModel: data.openaiModel || prev.openaiModel,
176
179
  openaiBaseUrl: data.openaiBaseUrl ?? prev.openaiBaseUrl,
180
+ openaiKeyMask: data.openaiApiKey || '',
177
181
  }));
178
182
  // Generate a new token only if none exists yet
179
183
  if (!data.authToken) {
@@ -14,9 +14,11 @@ export interface SetupState {
14
14
  provider: 'anthropic' | 'openai' | 'skip';
15
15
  anthropicKey: string;
16
16
  anthropicModel: string;
17
+ anthropicKeyMask: string; // masked existing key from server (display only)
17
18
  openaiKey: string;
18
19
  openaiModel: string;
19
20
  openaiBaseUrl: string;
21
+ openaiKeyMask: string; // masked existing key from server (display only)
20
22
  webPort: number;
21
23
  mcpPort: number;
22
24
  authToken: string;
@@ -0,0 +1,46 @@
1
+ 'use client';
2
+
3
+ import { useSyncExternalStore, useCallback } from 'react';
4
+
5
+ /**
6
+ * Lightweight pub/sub store for cross-component AskModal control.
7
+ * Replaces KeyboardEvent dispatch pattern with typed, testable API.
8
+ * No external dependencies (no zustand needed).
9
+ */
10
+
11
+ interface AskModalState {
12
+ open: boolean;
13
+ initialMessage: string;
14
+ source: 'user' | 'guide' | 'guide-next'; // who triggered the open
15
+ }
16
+
17
+ let state: AskModalState = { open: false, initialMessage: '', source: 'user' };
18
+ const listeners = new Set<() => void>();
19
+
20
+ function emit() { listeners.forEach(l => l()); }
21
+ function subscribe(listener: () => void) {
22
+ listeners.add(listener);
23
+ return () => { listeners.delete(listener); };
24
+ }
25
+ function getSnapshot() { return state; }
26
+
27
+ export function openAskModal(message = '', source: AskModalState['source'] = 'user') {
28
+ state = { open: true, initialMessage: message, source };
29
+ emit();
30
+ }
31
+
32
+ export function closeAskModal() {
33
+ state = { open: false, initialMessage: '', source: 'user' };
34
+ emit();
35
+ }
36
+
37
+ export function useAskModal() {
38
+ const snap = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
39
+ return {
40
+ open: snap.open,
41
+ initialMessage: snap.initialMessage,
42
+ source: snap.source,
43
+ openWith: useCallback((message: string, source: AskModalState['source'] = 'user') => openAskModal(message, source), []),
44
+ close: useCallback(() => closeAskModal(), []),
45
+ };
46
+ }
@@ -138,7 +138,9 @@ export async function consumeUIMessageStream(
138
138
  case 'tool-output-available': {
139
139
  const tc = toolCalls.get(chunk.toolCallId as string);
140
140
  if (tc) {
141
- tc.output = typeof chunk.output === 'string' ? chunk.output : JSON.stringify(chunk.output);
141
+ tc.output = chunk.output != null
142
+ ? (typeof chunk.output === 'string' ? chunk.output : JSON.stringify(chunk.output))
143
+ : '';
142
144
  tc.state = 'done';
143
145
  changed = true;
144
146
  }
@@ -148,7 +150,7 @@ export async function consumeUIMessageStream(
148
150
  case 'tool-input-error': {
149
151
  const tc = toolCalls.get(chunk.toolCallId as string);
150
152
  if (tc) {
151
- tc.output = (chunk.errorText as string) ?? 'Error';
153
+ tc.output = (chunk.errorText as string) ?? (chunk.error as string) ?? 'Tool error';
152
154
  tc.state = 'error';
153
155
  changed = true;
154
156
  }