返回 DeepSeek-Reasonix
skill_picker.go
根目录 / internal / cli / skill_picker.go
1 package cli
2
3 import (
4 "fmt"
5 "os"
6 "path/filepath"
7 "sort"
8 "strings"
9
10 tea "charm.land/bubbletea/v2"
11
12 "reasonix/internal/i18n"
13 "reasonix/internal/skill"
14 )
15
16 var scopePriority = map[skill.Scope]int{
17 skill.ScopeProject: 0,
18 skill.ScopeCustom: 1,
19 skill.ScopeGlobal: 2,
20 skill.ScopeBuiltin: 3,
21 }
22
23 type skillPickerMode string
24
25 const (
26 pickerSkills skillPickerMode = "skills"
27 pickerSources skillPickerMode = "sources"
28 pickerSourceSkills skillPickerMode = "source-skills"
29 pickerDetail skillPickerMode = "detail"
30 pickerConfirmDelete skillPickerMode = "confirm-delete"
31 )
32
33 type skillPicker struct {
34 mode skillPickerMode
35 skills []skill.Skill
36 roots []skillRootLine
37 enabled map[string]bool
38 originalEnabled map[string]bool
39 query string
40 sel int
41 sourceSel int
42 sourceSkillSel int
43 showDiagnostics bool
44 searchActive bool
45 detailSkill skill.Skill
46 detailBack skillPickerMode
47 detailAction int
48 confirm int
49 deleteSkill skill.Skill
50 }
51
52 type skillRootLine struct {
53 dir string
54 scope skill.Scope
55 status skill.PathStatus
56 skills int
57 configured bool
58 diagnostic bool
59 }
60
61 func (m *chatTUI) openSkillPicker() {
62 st := m.skillStore()
63 skills := st.List()
64 if m.ctrl != nil {
65 skills = m.ctrl.AllSkills()
66 }
67 if len(skills) == 0 {
68 m.notice(i18n.M.ListSkillsNone)
69 return
70 }
71 sorted := sortedSkills(skills)
72 enabled := map[string]bool{}
73 original := map[string]bool{}
74 disabled := m.disabledSkillNames()
75 for _, sk := range sorted {
76 on := !disabled[sk.Name]
77 enabled[sk.Name] = on
78 original[sk.Name] = on
79 }
80 m.skillPick = &skillPicker{
81 mode: pickerSkills,
82 skills: sorted,
83 roots: skillRootLines(st, sorted),
84 enabled: enabled,
85 originalEnabled: original,
86 }
87 }
88
89 func (m chatTUI) handleSkillPickerKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
90 p := m.skillPick
91 if p == nil {
92 return m, nil
93 }
94
95 if p.searchActive {
96 switch msg.String() {
97 case "esc":
98 p.searchActive = false
99 return m, nil
100 case "enter":
101 return m.saveSkillPick()
102 case "backspace":
103 if len(p.query) > 0 {
104 p.query = p.query[:len(p.query)-1]
105 p.sel = clampSel(p.sel, p.filteredSkills())
106 }
107 return m, nil
108 case "up", "k":
109 if p.sel > 0 {
110 p.sel--
111 }
112 return m, nil
113 case "down", "j":
114 filtered := p.filteredSkills()
115 if p.sel < len(filtered)-1 {
116 p.sel++
117 }
118 return m, nil
119 default:
120 if t := msg.Text; t != "" {
121 p.query += t
122 } else if s := msg.String(); len(s) == 1 && s[0] >= 32 && s[0] < 127 {
123 p.query += s
124 }
125 p.sel = clampSel(p.sel, p.filteredSkills())
126 return m, nil
127 }
128 }
129
130 switch p.mode {
131 case pickerSkills:
132 return m.handleSkillPickerSkillsKey(msg)
133 case pickerSources:
134 return m.handleSkillPickerSourcesKey(msg)
135 case pickerSourceSkills:
136 return m.handleSkillPickerSourceSkillsKey(msg)
137 case pickerDetail:
138 return m.handleSkillPickerDetailKey(msg)
139 case pickerConfirmDelete:
140 return m.handleSkillPickerConfirmDeleteKey(msg)
141 }
142 return m, nil
143 }
144
145 func (m chatTUI) handleSkillPickerSkillsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
146 p := m.skillPick
147 switch msg.String() {
148 case "esc":
149 m.skillPick = nil
150 case "up", "k":
151 if p.sel > 0 {
152 p.sel--
153 }
154 case "down", "j":
155 if p.sel < len(p.skills)-1 {
156 p.sel++
157 }
158 case "enter":
159 return m.saveSkillPick()
160 case " ", "space":
161 p.toggleSelectedSkill()
162 case "right", "l":
163 if sk, ok := p.selectedSkill(); ok {
164 p.openDetail(sk, pickerSkills)
165 }
166 case "/":
167 p.searchActive = true
168 p.query = ""
169 case "s":
170 p.mode = pickerSources
171 p.searchActive = false
172 p.sourceSel = 0
173 case "r":
174 m.rescanSkills()
175 }
176 return m, nil
177 }
178
179 func (m chatTUI) handleSkillPickerSourcesKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
180 p := m.skillPick
181 visible := p.visibleRoots()
182 switch msg.String() {
183 case "esc":
184 m.skillPick = nil
185 case "up", "k":
186 if p.sourceSel > 0 {
187 p.sourceSel--
188 }
189 case "down", "j":
190 if p.sourceSel < len(visible)-1 {
191 p.sourceSel++
192 }
193 case "enter", "right", "l":
194 if len(visible) > 0 {
195 p.mode = pickerSourceSkills
196 p.sourceSkillSel = 0
197 }
198 case "d":
199 p.showDiagnostics = !p.showDiagnostics
200 p.sourceSel = clampSel(p.sourceSel, visible)
201 case "s":
202 p.mode = pickerSkills
203 p.sourceSel = 0
204 p.showDiagnostics = false
205 case "r":
206 m.rescanSkills()
207 }
208 return m, nil
209 }
210
211 func (m chatTUI) handleSkillPickerSourceSkillsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
212 p := m.skillPick
213 skills := p.selectedRootSkills()
214 switch msg.String() {
215 case "esc", "left", "h":
216 p.mode = pickerSources
217 case "up", "k":
218 if p.sourceSkillSel > 0 {
219 p.sourceSkillSel--
220 }
221 case "down", "j":
222 if p.sourceSkillSel < len(skills)-1 {
223 p.sourceSkillSel++
224 }
225 case "enter", "right", "l":
226 if p.sourceSkillSel >= 0 && p.sourceSkillSel < len(skills) {
227 p.openDetail(skills[p.sourceSkillSel], pickerSourceSkills)
228 }
229 case " ", "space":
230 if p.sourceSkillSel >= 0 && p.sourceSkillSel < len(skills) {
231 p.toggleSkill(skills[p.sourceSkillSel].Name)
232 }
233 }
234 p.sourceSkillSel = clampSel(p.sourceSkillSel, skills)
235 return m, nil
236 }
237
238 func (m chatTUI) handleSkillPickerDetailKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
239 p := m.skillPick
240 actions := skillActionsFor(p.detailSkill)
241 p.detailAction = clampInt(p.detailAction, len(actions))
242 switch msg.String() {
243 case "esc", "left", "h":
244 p.mode = p.detailBack
245 p.detailAction = 0
246 case "up", "k":
247 if p.detailAction > 0 {
248 p.detailAction--
249 }
250 case "down", "j":
251 if p.detailAction < len(actions)-1 {
252 p.detailAction++
253 }
254 case "enter":
255 if len(actions) > 0 {
256 return m.applySkillAction(p.detailSkill, actions[p.detailAction])
257 }
258 case " ", "space":
259 p.toggleSkill(p.detailSkill.Name)
260 }
261 p.detailAction = clampInt(p.detailAction, len(actions))
262 return m, nil
263 }
264
265 func (m chatTUI) handleSkillPickerConfirmDeleteKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
266 p := m.skillPick
267 switch msg.String() {
268 case "up", "k", "down", "j":
269 if p.confirm == 0 {
270 p.confirm = 1
271 } else {
272 p.confirm = 0
273 }
274 case "y":
275 p.confirm = 0
276 return m.deleteSkillPick(p.deleteSkill)
277 case "n", "esc":
278 p.mode = pickerDetail
279 case "enter":
280 if p.confirm == 0 {
281 return m.deleteSkillPick(p.deleteSkill)
282 }
283 p.mode = pickerDetail
284 }
285 return m, nil
286 }
287
288 func (m chatTUI) saveSkillPick() (tea.Model, tea.Cmd) {
289 p := m.skillPick
290 if p == nil {
291 return m, nil
292 }
293 changes := p.changedEnabled()
294 m.skillPick = nil
295 if len(changes) == 0 {
296 m.notice(i18n.M.SkillPickerNoChanges)
297 return m, nil
298 }
299 m.skillSaveEnabledChanges(changes)
300 if m.pendingModelSwitch != nil {
301 return m, m.pendingModelSwitch
302 }
303 return m, nil
304 }
305
306 func (m chatTUI) deleteSkillPick(sk skill.Skill) (tea.Model, tea.Cmd) {
307 p := m.skillPick
308 target, ok, err := skillDeleteTarget(sk)
309 if err != nil {
310 m.notice("skill delete: " + err.Error())
311 if p != nil {
312 p.mode = pickerDetail
313 }
314 return m, nil
315 }
316 if !ok {
317 m.notice("skill delete: built-in skills cannot be removed")
318 if p != nil {
319 p.mode = pickerDetail
320 }
321 return m, nil
322 }
323 if err := os.RemoveAll(target); err != nil {
324 m.notice("skill delete: " + err.Error())
325 if p != nil {
326 p.mode = pickerDetail
327 }
328 return m, nil
329 }
330 if p != nil {
331 delete(p.enabled, sk.Name)
332 delete(p.originalEnabled, sk.Name)
333 p.mode = pickerSkills
334 p.detailAction = 0
335 }
336 m.notice(fmt.Sprintf(i18n.M.SkillPickerDeletedFmt, sk.Name))
337 m.refreshSkillPickerData()
338 m.scheduleSkillSessionRefresh("skill delete", "deleted skill "+sk.Name+" — refreshing session")
339 return m, m.pendingModelSwitch
340 }
341
342 func (m *chatTUI) rescanSkills() {
343 m.refreshSkillPickerData()
344 m.notice(i18n.M.SkillPickerRescanned)
345 }
346
347 func (m *chatTUI) refreshSkillPickerData() {
348 st := m.skillStore()
349 skills := st.List()
350 m.skills = skills
351 m.invalidateSlashCatalog()
352 if m.skillPick != nil {
353 sorted := sortedSkills(skills)
354 m.skillPick.skills = sorted
355 m.skillPick.roots = skillRootLines(st, sorted)
356 m.skillPick.syncEnabledMaps(sorted, m.disabledSkillNames())
357 if m.skillPick.searchActive && m.skillPick.query != "" {
358 m.skillPick.sel = clampSel(m.skillPick.sel, m.skillPick.filteredSkills())
359 } else {
360 m.skillPick.sel = clampSel(m.skillPick.sel, sorted)
361 }
362 m.skillPick.sourceSel = clampSel(m.skillPick.sourceSel, m.skillPick.visibleRoots())
363 m.skillPick.sourceSkillSel = clampSel(m.skillPick.sourceSkillSel, m.skillPick.selectedRootSkills())
364 }
365 }
366
367 func (p *skillPicker) filteredSkills() []skill.Skill {
368 if p.query == "" {
369 return p.skills
370 }
371 q := strings.ToLower(p.query)
372 var out []skill.Skill
373 for _, s := range p.skills {
374 if strings.Contains(strings.ToLower(s.SlashName()), q) ||
375 strings.Contains(strings.ToLower(s.Plugin), q) ||
376 strings.Contains(strings.ToLower(s.Description), q) {
377 out = append(out, s)
378 }
379 }
380 return out
381 }
382
383 func (p *skillPicker) visibleRoots() []skillRootLine {
384 if p.showDiagnostics {
385 return p.roots
386 }
387 var out []skillRootLine
388 for _, r := range p.roots {
389 if !r.diagnostic || r.configured || r.skills > 0 {
390 out = append(out, r)
391 }
392 }
393 return out
394 }
395
396 func (p *skillPicker) selectedSkill() (skill.Skill, bool) {
397 if p == nil {
398 return skill.Skill{}, false
399 }
400 skills := p.skills
401 if p.searchActive && p.query != "" {
402 skills = p.filteredSkills()
403 }
404 if len(skills) == 0 {
405 return skill.Skill{}, false
406 }
407 p.sel = clampSel(p.sel, skills)
408 return skills[p.sel], true
409 }
410
411 func (p *skillPicker) toggleSelectedSkill() {
412 if sk, ok := p.selectedSkill(); ok {
413 p.toggleSkill(sk.Name)
414 }
415 }
416
417 func (p *skillPicker) openDetail(sk skill.Skill, back skillPickerMode) {
418 p.detailSkill = sk
419 p.detailBack = back
420 p.detailAction = 0
421 p.mode = pickerDetail
422 }
423
424 func (p *skillPicker) skillEnabled(name string) bool {
425 if p == nil || p.enabled == nil {
426 return true
427 }
428 if enabled, ok := p.enabled[name]; ok {
429 return enabled
430 }
431 return true
432 }
433
434 func (p *skillPicker) toggleSkill(name string) {
435 if p.enabled == nil {
436 p.enabled = map[string]bool{}
437 }
438 if p.originalEnabled == nil {
439 p.originalEnabled = map[string]bool{}
440 }
441 if _, ok := p.originalEnabled[name]; !ok {
442 p.originalEnabled[name] = p.skillEnabled(name)
443 }
444 p.enabled[name] = !p.skillEnabled(name)
445 }
446
447 func (p *skillPicker) changedEnabled() map[string]bool {
448 changes := map[string]bool{}
449 if p == nil {
450 return changes
451 }
452 for name, enabled := range p.enabled {
453 original, ok := p.originalEnabled[name]
454 if !ok {
455 original = true
456 }
457 if enabled != original {
458 changes[name] = enabled
459 }
460 }
461 return changes
462 }
463
464 func (p *skillPicker) syncEnabledMaps(skills []skill.Skill, disabled map[string]bool) {
465 nextEnabled := map[string]bool{}
466 nextOriginal := map[string]bool{}
467 for _, sk := range skills {
468 enabled := !disabled[sk.Name]
469 if p.enabled != nil {
470 if existing, ok := p.enabled[sk.Name]; ok {
471 enabled = existing
472 }
473 }
474 original := !disabled[sk.Name]
475 if p.originalEnabled != nil {
476 if existing, ok := p.originalEnabled[sk.Name]; ok {
477 original = existing
478 }
479 }
480 nextEnabled[sk.Name] = enabled
481 nextOriginal[sk.Name] = original
482 }
483 p.enabled = nextEnabled
484 p.originalEnabled = nextOriginal
485 }
486
487 func (p *skillPicker) selectedRoot() (skillRootLine, bool) {
488 if p == nil {
489 return skillRootLine{}, false
490 }
491 roots := p.visibleRoots()
492 if len(roots) == 0 {
493 return skillRootLine{}, false
494 }
495 p.sourceSel = clampSel(p.sourceSel, roots)
496 return roots[p.sourceSel], true
497 }
498
499 func (p *skillPicker) selectedRootSkills() []skill.Skill {
500 root, ok := p.selectedRoot()
501 if !ok {
502 return nil
503 }
504 var out []skill.Skill
505 for _, sk := range p.skills {
506 if skillInRoot(sk, root) {
507 out = append(out, sk)
508 }
509 }
510 return out
511 }
512
513 func skillInRoot(sk skill.Skill, root skillRootLine) bool {
514 if root.scope == skill.ScopeBuiltin {
515 return sk.Scope == skill.ScopeBuiltin
516 }
517 if sk.Scope != root.scope || sk.Path == "" || sk.Scope == skill.ScopeBuiltin {
518 return false
519 }
520 cleanPath := filepath.Clean(sk.Path)
521 cleanRoot := filepath.Clean(root.dir)
522 prefix := cleanRoot + string(filepath.Separator)
523 return cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix)
524 }
525
526 func skillRootLines(st *skill.Store, skills []skill.Skill) []skillRootLine {
527 storeRoots := st.Roots()
528 lines := make([]skillRootLine, len(storeRoots))
529 for i, r := range storeRoots {
530 lines[i] = skillRootLine{
531 dir: r.Dir,
532 scope: r.Scope,
533 status: r.Status,
534 }
535 }
536 for _, s := range skills {
537 if s.Scope == skill.ScopeBuiltin {
538 continue
539 }
540 cleanPath := filepath.Clean(s.Path)
541 for i := range lines {
542 if lines[i].scope != s.Scope {
543 continue
544 }
545 // Trailing separator: directory-boundary match so /skills can't match /skills-extra.
546 prefix := filepath.Clean(lines[i].dir) + string(filepath.Separator)
547 if strings.HasPrefix(cleanPath, prefix) {
548 lines[i].skills++
549 break
550 }
551 }
552 }
553 for i := range lines {
554 if lines[i].scope == skill.ScopeCustom {
555 lines[i].configured = true
556 } else {
557 lines[i].diagnostic = true
558 }
559 }
560 builtinCount := 0
561 for _, s := range skills {
562 if s.Scope == skill.ScopeBuiltin {
563 builtinCount++
564 }
565 }
566 if builtinCount > 0 {
567 lines = append(lines, skillRootLine{
568 dir: i18n.M.SkillPickerBuiltinSource,
569 scope: skill.ScopeBuiltin,
570 status: skill.StatusOK,
571 skills: builtinCount,
572 diagnostic: true,
573 })
574 }
575 return lines
576 }
577
578 type skillActionKind string
579
580 const (
581 skillActionToggle skillActionKind = "toggle"
582 skillActionDelete skillActionKind = "delete"
583 )
584
585 type skillActionItem struct {
586 kind skillActionKind
587 label string
588 }
589
590 func skillActionsFor(s skill.Skill) []skillActionItem {
591 actions := []skillActionItem{{
592 kind: skillActionToggle,
593 label: i18n.M.SkillPickerActionToggle,
594 }}
595 if _, ok, _ := skillDeleteTarget(s); ok {
596 actions = append(actions, skillActionItem{
597 kind: skillActionDelete,
598 label: i18n.M.SkillPickerActionDelete,
599 })
600 }
601 return actions
602 }
603
604 func (m chatTUI) applySkillAction(sk skill.Skill, action skillActionItem) (tea.Model, tea.Cmd) {
605 p := m.skillPick
606 if p == nil {
607 return m, nil
608 }
609 switch action.kind {
610 case skillActionToggle:
611 p.toggleSkill(sk.Name)
612 case skillActionDelete:
613 p.deleteSkill = sk
614 p.confirm = 1
615 p.mode = pickerConfirmDelete
616 }
617 return m, nil
618 }
619
620 func skillDeleteTarget(s skill.Skill) (string, bool, error) {
621 if s.Scope == skill.ScopeBuiltin {
622 return "", false, nil
623 }
624 path := strings.TrimSpace(s.Path)
625 if path == "" || path == "(builtin)" {
626 return "", false, nil
627 }
628 clean := filepath.Clean(path)
629 info, err := os.Stat(clean)
630 if err != nil {
631 return "", false, err
632 }
633 if filepath.Base(clean) == skill.SkillFile {
634 dir := filepath.Dir(clean)
635 if dir == "." || dir == string(filepath.Separator) {
636 return "", false, fmt.Errorf("refusing to remove unsafe skill directory %q", dir)
637 }
638 return dir, true, nil
639 }
640 if info.Mode().IsRegular() {
641 return clean, true, nil
642 }
643 return "", false, fmt.Errorf("skill path is not removable: %s", clean)
644 }
645
646 func skillDeleteTargetLabel(s skill.Skill) string {
647 target, ok, _ := skillDeleteTarget(s)
648 if !ok {
649 return ""
650 }
651 return target
652 }
653
654 func sortedSkills(skills []skill.Skill) []skill.Skill {
655 sorted := make([]skill.Skill, len(skills))
656 copy(sorted, skills)
657 sort.SliceStable(sorted, func(i, j int) bool {
658 pi := scopePriority[sorted[i].Scope]
659 pj := scopePriority[sorted[j].Scope]
660 if pi != pj {
661 return pi < pj
662 }
663 return sorted[i].Name < sorted[j].Name
664 })
665 return sorted
666 }
667
668 func clampSel[T any](sel int, items []T) int {
669 if len(items) == 0 {
670 return 0
671 }
672 if sel < 0 {
673 return 0
674 }
675 if sel >= len(items) {
676 return len(items) - 1
677 }
678 return sel
679 }
680
681 func clampInt(sel, total int) int {
682 if total <= 0 {
683 return 0
684 }
685 if sel < 0 {
686 return 0
687 }
688 if sel >= total {
689 return total - 1
690 }
691 return sel
692 }
693
693 lines GO