Files
2026-07-13 13:00:08 +08:00

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
}