692 lines
15 KiB
Go
692 lines
15 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
|
|
"reasonix/internal/i18n"
|
|
"reasonix/internal/skill"
|
|
)
|
|
|
|
var scopePriority = map[skill.Scope]int{
|
|
skill.ScopeProject: 0,
|
|
skill.ScopeCustom: 1,
|
|
skill.ScopeGlobal: 2,
|
|
skill.ScopeBuiltin: 3,
|
|
}
|
|
|
|
type skillPickerMode string
|
|
|
|
const (
|
|
pickerSkills skillPickerMode = "skills"
|
|
pickerSources skillPickerMode = "sources"
|
|
pickerSourceSkills skillPickerMode = "source-skills"
|
|
pickerDetail skillPickerMode = "detail"
|
|
pickerConfirmDelete skillPickerMode = "confirm-delete"
|
|
)
|
|
|
|
type skillPicker struct {
|
|
mode skillPickerMode
|
|
skills []skill.Skill
|
|
roots []skillRootLine
|
|
enabled map[string]bool
|
|
originalEnabled map[string]bool
|
|
query string
|
|
sel int
|
|
sourceSel int
|
|
sourceSkillSel int
|
|
showDiagnostics bool
|
|
searchActive bool
|
|
detailSkill skill.Skill
|
|
detailBack skillPickerMode
|
|
detailAction int
|
|
confirm int
|
|
deleteSkill skill.Skill
|
|
}
|
|
|
|
type skillRootLine struct {
|
|
dir string
|
|
scope skill.Scope
|
|
status skill.PathStatus
|
|
skills int
|
|
configured bool
|
|
diagnostic bool
|
|
}
|
|
|
|
func (m *chatTUI) openSkillPicker() {
|
|
st := m.skillStore()
|
|
skills := st.List()
|
|
if m.ctrl != nil {
|
|
skills = m.ctrl.AllSkills()
|
|
}
|
|
if len(skills) == 0 {
|
|
m.notice(i18n.M.ListSkillsNone)
|
|
return
|
|
}
|
|
sorted := sortedSkills(skills)
|
|
enabled := map[string]bool{}
|
|
original := map[string]bool{}
|
|
disabled := m.disabledSkillNames()
|
|
for _, sk := range sorted {
|
|
on := !disabled[sk.Name]
|
|
enabled[sk.Name] = on
|
|
original[sk.Name] = on
|
|
}
|
|
m.skillPick = &skillPicker{
|
|
mode: pickerSkills,
|
|
skills: sorted,
|
|
roots: skillRootLines(st, sorted),
|
|
enabled: enabled,
|
|
originalEnabled: original,
|
|
}
|
|
}
|
|
|
|
func (m chatTUI) handleSkillPickerKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|
p := m.skillPick
|
|
if p == nil {
|
|
return m, nil
|
|
}
|
|
|
|
if p.searchActive {
|
|
switch msg.String() {
|
|
case "esc":
|
|
p.searchActive = false
|
|
return m, nil
|
|
case "enter":
|
|
return m.saveSkillPick()
|
|
case "backspace":
|
|
if len(p.query) > 0 {
|
|
p.query = p.query[:len(p.query)-1]
|
|
p.sel = clampSel(p.sel, p.filteredSkills())
|
|
}
|
|
return m, nil
|
|
case "up", "k":
|
|
if p.sel > 0 {
|
|
p.sel--
|
|
}
|
|
return m, nil
|
|
case "down", "j":
|
|
filtered := p.filteredSkills()
|
|
if p.sel < len(filtered)-1 {
|
|
p.sel++
|
|
}
|
|
return m, nil
|
|
default:
|
|
if t := msg.Text; t != "" {
|
|
p.query += t
|
|
} else if s := msg.String(); len(s) == 1 && s[0] >= 32 && s[0] < 127 {
|
|
p.query += s
|
|
}
|
|
p.sel = clampSel(p.sel, p.filteredSkills())
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
switch p.mode {
|
|
case pickerSkills:
|
|
return m.handleSkillPickerSkillsKey(msg)
|
|
case pickerSources:
|
|
return m.handleSkillPickerSourcesKey(msg)
|
|
case pickerSourceSkills:
|
|
return m.handleSkillPickerSourceSkillsKey(msg)
|
|
case pickerDetail:
|
|
return m.handleSkillPickerDetailKey(msg)
|
|
case pickerConfirmDelete:
|
|
return m.handleSkillPickerConfirmDeleteKey(msg)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatTUI) handleSkillPickerSkillsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|
p := m.skillPick
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.skillPick = nil
|
|
case "up", "k":
|
|
if p.sel > 0 {
|
|
p.sel--
|
|
}
|
|
case "down", "j":
|
|
if p.sel < len(p.skills)-1 {
|
|
p.sel++
|
|
}
|
|
case "enter":
|
|
return m.saveSkillPick()
|
|
case " ", "space":
|
|
p.toggleSelectedSkill()
|
|
case "right", "l":
|
|
if sk, ok := p.selectedSkill(); ok {
|
|
p.openDetail(sk, pickerSkills)
|
|
}
|
|
case "/":
|
|
p.searchActive = true
|
|
p.query = ""
|
|
case "s":
|
|
p.mode = pickerSources
|
|
p.searchActive = false
|
|
p.sourceSel = 0
|
|
case "r":
|
|
m.rescanSkills()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatTUI) handleSkillPickerSourcesKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|
p := m.skillPick
|
|
visible := p.visibleRoots()
|
|
switch msg.String() {
|
|
case "esc":
|
|
m.skillPick = nil
|
|
case "up", "k":
|
|
if p.sourceSel > 0 {
|
|
p.sourceSel--
|
|
}
|
|
case "down", "j":
|
|
if p.sourceSel < len(visible)-1 {
|
|
p.sourceSel++
|
|
}
|
|
case "enter", "right", "l":
|
|
if len(visible) > 0 {
|
|
p.mode = pickerSourceSkills
|
|
p.sourceSkillSel = 0
|
|
}
|
|
case "d":
|
|
p.showDiagnostics = !p.showDiagnostics
|
|
p.sourceSel = clampSel(p.sourceSel, visible)
|
|
case "s":
|
|
p.mode = pickerSkills
|
|
p.sourceSel = 0
|
|
p.showDiagnostics = false
|
|
case "r":
|
|
m.rescanSkills()
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatTUI) handleSkillPickerSourceSkillsKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|
p := m.skillPick
|
|
skills := p.selectedRootSkills()
|
|
switch msg.String() {
|
|
case "esc", "left", "h":
|
|
p.mode = pickerSources
|
|
case "up", "k":
|
|
if p.sourceSkillSel > 0 {
|
|
p.sourceSkillSel--
|
|
}
|
|
case "down", "j":
|
|
if p.sourceSkillSel < len(skills)-1 {
|
|
p.sourceSkillSel++
|
|
}
|
|
case "enter", "right", "l":
|
|
if p.sourceSkillSel >= 0 && p.sourceSkillSel < len(skills) {
|
|
p.openDetail(skills[p.sourceSkillSel], pickerSourceSkills)
|
|
}
|
|
case " ", "space":
|
|
if p.sourceSkillSel >= 0 && p.sourceSkillSel < len(skills) {
|
|
p.toggleSkill(skills[p.sourceSkillSel].Name)
|
|
}
|
|
}
|
|
p.sourceSkillSel = clampSel(p.sourceSkillSel, skills)
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatTUI) handleSkillPickerDetailKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|
p := m.skillPick
|
|
actions := skillActionsFor(p.detailSkill)
|
|
p.detailAction = clampInt(p.detailAction, len(actions))
|
|
switch msg.String() {
|
|
case "esc", "left", "h":
|
|
p.mode = p.detailBack
|
|
p.detailAction = 0
|
|
case "up", "k":
|
|
if p.detailAction > 0 {
|
|
p.detailAction--
|
|
}
|
|
case "down", "j":
|
|
if p.detailAction < len(actions)-1 {
|
|
p.detailAction++
|
|
}
|
|
case "enter":
|
|
if len(actions) > 0 {
|
|
return m.applySkillAction(p.detailSkill, actions[p.detailAction])
|
|
}
|
|
case " ", "space":
|
|
p.toggleSkill(p.detailSkill.Name)
|
|
}
|
|
p.detailAction = clampInt(p.detailAction, len(actions))
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatTUI) handleSkillPickerConfirmDeleteKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
|
p := m.skillPick
|
|
switch msg.String() {
|
|
case "up", "k", "down", "j":
|
|
if p.confirm == 0 {
|
|
p.confirm = 1
|
|
} else {
|
|
p.confirm = 0
|
|
}
|
|
case "y":
|
|
p.confirm = 0
|
|
return m.deleteSkillPick(p.deleteSkill)
|
|
case "n", "esc":
|
|
p.mode = pickerDetail
|
|
case "enter":
|
|
if p.confirm == 0 {
|
|
return m.deleteSkillPick(p.deleteSkill)
|
|
}
|
|
p.mode = pickerDetail
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatTUI) saveSkillPick() (tea.Model, tea.Cmd) {
|
|
p := m.skillPick
|
|
if p == nil {
|
|
return m, nil
|
|
}
|
|
changes := p.changedEnabled()
|
|
m.skillPick = nil
|
|
if len(changes) == 0 {
|
|
m.notice(i18n.M.SkillPickerNoChanges)
|
|
return m, nil
|
|
}
|
|
m.skillSaveEnabledChanges(changes)
|
|
if m.pendingModelSwitch != nil {
|
|
return m, m.pendingModelSwitch
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func (m chatTUI) deleteSkillPick(sk skill.Skill) (tea.Model, tea.Cmd) {
|
|
p := m.skillPick
|
|
target, ok, err := skillDeleteTarget(sk)
|
|
if err != nil {
|
|
m.notice("skill delete: " + err.Error())
|
|
if p != nil {
|
|
p.mode = pickerDetail
|
|
}
|
|
return m, nil
|
|
}
|
|
if !ok {
|
|
m.notice("skill delete: built-in skills cannot be removed")
|
|
if p != nil {
|
|
p.mode = pickerDetail
|
|
}
|
|
return m, nil
|
|
}
|
|
if err := os.RemoveAll(target); err != nil {
|
|
m.notice("skill delete: " + err.Error())
|
|
if p != nil {
|
|
p.mode = pickerDetail
|
|
}
|
|
return m, nil
|
|
}
|
|
if p != nil {
|
|
delete(p.enabled, sk.Name)
|
|
delete(p.originalEnabled, sk.Name)
|
|
p.mode = pickerSkills
|
|
p.detailAction = 0
|
|
}
|
|
m.notice(fmt.Sprintf(i18n.M.SkillPickerDeletedFmt, sk.Name))
|
|
m.refreshSkillPickerData()
|
|
m.scheduleSkillSessionRefresh("skill delete", "deleted skill "+sk.Name+" — refreshing session")
|
|
return m, m.pendingModelSwitch
|
|
}
|
|
|
|
func (m *chatTUI) rescanSkills() {
|
|
m.refreshSkillPickerData()
|
|
m.notice(i18n.M.SkillPickerRescanned)
|
|
}
|
|
|
|
func (m *chatTUI) refreshSkillPickerData() {
|
|
st := m.skillStore()
|
|
skills := st.List()
|
|
m.skills = skills
|
|
if m.skillPick != nil {
|
|
sorted := sortedSkills(skills)
|
|
m.skillPick.skills = sorted
|
|
m.skillPick.roots = skillRootLines(st, sorted)
|
|
m.skillPick.syncEnabledMaps(sorted, m.disabledSkillNames())
|
|
if m.skillPick.searchActive && m.skillPick.query != "" {
|
|
m.skillPick.sel = clampSel(m.skillPick.sel, m.skillPick.filteredSkills())
|
|
} else {
|
|
m.skillPick.sel = clampSel(m.skillPick.sel, sorted)
|
|
}
|
|
m.skillPick.sourceSel = clampSel(m.skillPick.sourceSel, m.skillPick.visibleRoots())
|
|
m.skillPick.sourceSkillSel = clampSel(m.skillPick.sourceSkillSel, m.skillPick.selectedRootSkills())
|
|
}
|
|
}
|
|
|
|
func (p *skillPicker) filteredSkills() []skill.Skill {
|
|
if p.query == "" {
|
|
return p.skills
|
|
}
|
|
q := strings.ToLower(p.query)
|
|
var out []skill.Skill
|
|
for _, s := range p.skills {
|
|
if strings.Contains(strings.ToLower(s.SlashName()), q) ||
|
|
strings.Contains(strings.ToLower(s.Plugin), q) ||
|
|
strings.Contains(strings.ToLower(s.Description), q) {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *skillPicker) visibleRoots() []skillRootLine {
|
|
if p.showDiagnostics {
|
|
return p.roots
|
|
}
|
|
var out []skillRootLine
|
|
for _, r := range p.roots {
|
|
if !r.diagnostic || r.configured || r.skills > 0 {
|
|
out = append(out, r)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (p *skillPicker) selectedSkill() (skill.Skill, bool) {
|
|
if p == nil {
|
|
return skill.Skill{}, false
|
|
}
|
|
skills := p.skills
|
|
if p.searchActive && p.query != "" {
|
|
skills = p.filteredSkills()
|
|
}
|
|
if len(skills) == 0 {
|
|
return skill.Skill{}, false
|
|
}
|
|
p.sel = clampSel(p.sel, skills)
|
|
return skills[p.sel], true
|
|
}
|
|
|
|
func (p *skillPicker) toggleSelectedSkill() {
|
|
if sk, ok := p.selectedSkill(); ok {
|
|
p.toggleSkill(sk.Name)
|
|
}
|
|
}
|
|
|
|
func (p *skillPicker) openDetail(sk skill.Skill, back skillPickerMode) {
|
|
p.detailSkill = sk
|
|
p.detailBack = back
|
|
p.detailAction = 0
|
|
p.mode = pickerDetail
|
|
}
|
|
|
|
func (p *skillPicker) skillEnabled(name string) bool {
|
|
if p == nil || p.enabled == nil {
|
|
return true
|
|
}
|
|
if enabled, ok := p.enabled[name]; ok {
|
|
return enabled
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (p *skillPicker) toggleSkill(name string) {
|
|
if p.enabled == nil {
|
|
p.enabled = map[string]bool{}
|
|
}
|
|
if p.originalEnabled == nil {
|
|
p.originalEnabled = map[string]bool{}
|
|
}
|
|
if _, ok := p.originalEnabled[name]; !ok {
|
|
p.originalEnabled[name] = p.skillEnabled(name)
|
|
}
|
|
p.enabled[name] = !p.skillEnabled(name)
|
|
}
|
|
|
|
func (p *skillPicker) changedEnabled() map[string]bool {
|
|
changes := map[string]bool{}
|
|
if p == nil {
|
|
return changes
|
|
}
|
|
for name, enabled := range p.enabled {
|
|
original, ok := p.originalEnabled[name]
|
|
if !ok {
|
|
original = true
|
|
}
|
|
if enabled != original {
|
|
changes[name] = enabled
|
|
}
|
|
}
|
|
return changes
|
|
}
|
|
|
|
func (p *skillPicker) syncEnabledMaps(skills []skill.Skill, disabled map[string]bool) {
|
|
nextEnabled := map[string]bool{}
|
|
nextOriginal := map[string]bool{}
|
|
for _, sk := range skills {
|
|
enabled := !disabled[sk.Name]
|
|
if p.enabled != nil {
|
|
if existing, ok := p.enabled[sk.Name]; ok {
|
|
enabled = existing
|
|
}
|
|
}
|
|
original := !disabled[sk.Name]
|
|
if p.originalEnabled != nil {
|
|
if existing, ok := p.originalEnabled[sk.Name]; ok {
|
|
original = existing
|
|
}
|
|
}
|
|
nextEnabled[sk.Name] = enabled
|
|
nextOriginal[sk.Name] = original
|
|
}
|
|
p.enabled = nextEnabled
|
|
p.originalEnabled = nextOriginal
|
|
}
|
|
|
|
func (p *skillPicker) selectedRoot() (skillRootLine, bool) {
|
|
if p == nil {
|
|
return skillRootLine{}, false
|
|
}
|
|
roots := p.visibleRoots()
|
|
if len(roots) == 0 {
|
|
return skillRootLine{}, false
|
|
}
|
|
p.sourceSel = clampSel(p.sourceSel, roots)
|
|
return roots[p.sourceSel], true
|
|
}
|
|
|
|
func (p *skillPicker) selectedRootSkills() []skill.Skill {
|
|
root, ok := p.selectedRoot()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var out []skill.Skill
|
|
for _, sk := range p.skills {
|
|
if skillInRoot(sk, root) {
|
|
out = append(out, sk)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func skillInRoot(sk skill.Skill, root skillRootLine) bool {
|
|
if root.scope == skill.ScopeBuiltin {
|
|
return sk.Scope == skill.ScopeBuiltin
|
|
}
|
|
if sk.Scope != root.scope || sk.Path == "" || sk.Scope == skill.ScopeBuiltin {
|
|
return false
|
|
}
|
|
cleanPath := filepath.Clean(sk.Path)
|
|
cleanRoot := filepath.Clean(root.dir)
|
|
prefix := cleanRoot + string(filepath.Separator)
|
|
return cleanPath == cleanRoot || strings.HasPrefix(cleanPath, prefix)
|
|
}
|
|
|
|
func skillRootLines(st *skill.Store, skills []skill.Skill) []skillRootLine {
|
|
storeRoots := st.Roots()
|
|
lines := make([]skillRootLine, len(storeRoots))
|
|
for i, r := range storeRoots {
|
|
lines[i] = skillRootLine{
|
|
dir: r.Dir,
|
|
scope: r.Scope,
|
|
status: r.Status,
|
|
}
|
|
}
|
|
for _, s := range skills {
|
|
if s.Scope == skill.ScopeBuiltin {
|
|
continue
|
|
}
|
|
cleanPath := filepath.Clean(s.Path)
|
|
for i := range lines {
|
|
if lines[i].scope != s.Scope {
|
|
continue
|
|
}
|
|
// Trailing separator: directory-boundary match so /skills can't match /skills-extra.
|
|
prefix := filepath.Clean(lines[i].dir) + string(filepath.Separator)
|
|
if strings.HasPrefix(cleanPath, prefix) {
|
|
lines[i].skills++
|
|
break
|
|
}
|
|
}
|
|
}
|
|
for i := range lines {
|
|
if lines[i].scope == skill.ScopeCustom {
|
|
lines[i].configured = true
|
|
} else {
|
|
lines[i].diagnostic = true
|
|
}
|
|
}
|
|
builtinCount := 0
|
|
for _, s := range skills {
|
|
if s.Scope == skill.ScopeBuiltin {
|
|
builtinCount++
|
|
}
|
|
}
|
|
if builtinCount > 0 {
|
|
lines = append(lines, skillRootLine{
|
|
dir: i18n.M.SkillPickerBuiltinSource,
|
|
scope: skill.ScopeBuiltin,
|
|
status: skill.StatusOK,
|
|
skills: builtinCount,
|
|
diagnostic: true,
|
|
})
|
|
}
|
|
return lines
|
|
}
|
|
|
|
type skillActionKind string
|
|
|
|
const (
|
|
skillActionToggle skillActionKind = "toggle"
|
|
skillActionDelete skillActionKind = "delete"
|
|
)
|
|
|
|
type skillActionItem struct {
|
|
kind skillActionKind
|
|
label string
|
|
}
|
|
|
|
func skillActionsFor(s skill.Skill) []skillActionItem {
|
|
actions := []skillActionItem{{
|
|
kind: skillActionToggle,
|
|
label: i18n.M.SkillPickerActionToggle,
|
|
}}
|
|
if _, ok, _ := skillDeleteTarget(s); ok {
|
|
actions = append(actions, skillActionItem{
|
|
kind: skillActionDelete,
|
|
label: i18n.M.SkillPickerActionDelete,
|
|
})
|
|
}
|
|
return actions
|
|
}
|
|
|
|
func (m chatTUI) applySkillAction(sk skill.Skill, action skillActionItem) (tea.Model, tea.Cmd) {
|
|
p := m.skillPick
|
|
if p == nil {
|
|
return m, nil
|
|
}
|
|
switch action.kind {
|
|
case skillActionToggle:
|
|
p.toggleSkill(sk.Name)
|
|
case skillActionDelete:
|
|
p.deleteSkill = sk
|
|
p.confirm = 1
|
|
p.mode = pickerConfirmDelete
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func skillDeleteTarget(s skill.Skill) (string, bool, error) {
|
|
if s.Scope == skill.ScopeBuiltin {
|
|
return "", false, nil
|
|
}
|
|
path := strings.TrimSpace(s.Path)
|
|
if path == "" || path == "(builtin)" {
|
|
return "", false, nil
|
|
}
|
|
clean := filepath.Clean(path)
|
|
info, err := os.Stat(clean)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
if filepath.Base(clean) == skill.SkillFile {
|
|
dir := filepath.Dir(clean)
|
|
if dir == "." || dir == string(filepath.Separator) {
|
|
return "", false, fmt.Errorf("refusing to remove unsafe skill directory %q", dir)
|
|
}
|
|
return dir, true, nil
|
|
}
|
|
if info.Mode().IsRegular() {
|
|
return clean, true, nil
|
|
}
|
|
return "", false, fmt.Errorf("skill path is not removable: %s", clean)
|
|
}
|
|
|
|
func skillDeleteTargetLabel(s skill.Skill) string {
|
|
target, ok, _ := skillDeleteTarget(s)
|
|
if !ok {
|
|
return ""
|
|
}
|
|
return target
|
|
}
|
|
|
|
func sortedSkills(skills []skill.Skill) []skill.Skill {
|
|
sorted := make([]skill.Skill, len(skills))
|
|
copy(sorted, skills)
|
|
sort.SliceStable(sorted, func(i, j int) bool {
|
|
pi := scopePriority[sorted[i].Scope]
|
|
pj := scopePriority[sorted[j].Scope]
|
|
if pi != pj {
|
|
return pi < pj
|
|
}
|
|
return sorted[i].Name < sorted[j].Name
|
|
})
|
|
return sorted
|
|
}
|
|
|
|
func clampSel[T any](sel int, items []T) int {
|
|
if len(items) == 0 {
|
|
return 0
|
|
}
|
|
if sel < 0 {
|
|
return 0
|
|
}
|
|
if sel >= len(items) {
|
|
return len(items) - 1
|
|
}
|
|
return sel
|
|
}
|
|
|
|
func clampInt(sel, total int) int {
|
|
if total <= 0 {
|
|
return 0
|
|
}
|
|
if sel < 0 {
|
|
return 0
|
|
}
|
|
if sel >= total {
|
|
return total - 1
|
|
}
|
|
return sel
|
|
}
|