package cli import ( "fmt" "strings" "reasonix/internal/i18n" "reasonix/internal/skill" ) const ( skillDialogMinRows = 8 skillDialogMaxRows = 18 ) func (m chatTUI) renderSkillPicker() string { p := m.skillPick if p == nil { return "" } w := max(viewWidth(m.width), 40) switch p.mode { case pickerSkills: return managerContentPanelStyle(w).Render(m.renderSkillPickerSkills()) case pickerSources: return managerContentPanelStyle(w).Render(m.renderSkillPickerSources()) case pickerSourceSkills: return managerContentPanelStyle(w).Render(m.renderSkillPickerSourceSkills()) case pickerDetail: return managerContentPanelStyle(w).Render(m.renderSkillPickerDetail()) case pickerConfirmDelete: return managerContentPanelStyle(w).Render(m.renderSkillPickerConfirmDelete()) } return "" } func (m chatTUI) skillPickerFooterHint() string { if m.skillPick == nil { return "" } switch m.skillPick.mode { case pickerSkills: return i18n.M.SkillPickerHint case pickerSources: return i18n.M.SkillPickerSourceHint case pickerSourceSkills: return i18n.M.SkillPickerSourceSkillsHint case pickerDetail: return i18n.M.SkillPickerDetailHint case pickerConfirmDelete: return i18n.M.SkillPickerDeleteHint default: return "" } } func (m chatTUI) renderSkillPickerSkills() string { p := m.skillPick w := max(viewWidth(m.width), 40) var b strings.Builder fmt.Fprintf(&b, "%s\n", viewHeader("Manage skills")) if summary := skillPickerSummary(p); summary != "" { fmt.Fprintf(&b, "%s\n", viewMeta(summary)) } b.WriteByte('\n') b.WriteString(renderSkillSearchBox(p.query, p.searchActive, w)) b.WriteByte('\n') skills := p.skills if p.searchActive && p.query != "" { skills = p.filteredSkills() } if len(skills) == 0 { b.WriteString(viewMeta(i18n.M.SkillPickerSearchEmpty)) b.WriteByte('\n') } else { start, end := skillListWindow(p.sel, len(skills), m.skillPickerVisibleRows()) if start > 0 { b.WriteString(viewMeta(fmt.Sprintf(i18n.M.SkillPickerMoreAboveFmt, start))) b.WriteByte('\n') } lastGroup := "" for i := start; i < end; i++ { group := skillGroupLabel(skills[i].Scope) if group != lastGroup { if lastGroup != "" { b.WriteByte('\n') } fmt.Fprintf(&b, " %s\n", bold(group)) lastGroup = group } b.WriteString(renderSkillRow(i+1, i == p.sel, skills[i], p.skillEnabled(skills[i].Name), w)) b.WriteByte('\n') } if end < len(skills) { b.WriteString(viewMeta(fmt.Sprintf(i18n.M.SkillPickerMoreBelowFmt, len(skills)-end))) b.WriteByte('\n') } } return strings.TrimRight(b.String(), "\n") } func (m chatTUI) skillPickerVisibleRows() int { if m.height <= 0 { return skillDialogMaxRows } return min(skillDialogMaxRows, max(skillDialogMinRows, m.height-14)) } func skillListWindow(sel, total, limit int) (int, int) { if total <= 0 { return 0, 0 } if limit <= 0 || limit >= total { return 0, total } if sel < 0 { sel = 0 } if sel >= total { sel = total - 1 } start := sel - limit/2 if start < 0 { start = 0 } if start+limit > total { start = total - limit } return start, start + limit } func renderSkillSearchBox(query string, active bool, w int) string { boxWidth := max(8, w-4) innerWidth := max(1, boxWidth-4) text := "/ " + i18n.M.SkillPickerSearchPlaceholder if active || query != "" { text = "/ " + query } text = padRight(viewCompactText(text, innerWidth), innerWidth) var b strings.Builder b.WriteString(dim(" ╭" + strings.Repeat("─", boxWidth-2) + "╮")) b.WriteByte('\n') b.WriteString(dim(" │ " + text + " │")) b.WriteByte('\n') b.WriteString(dim(" ╰" + strings.Repeat("─", boxWidth-2) + "╯")) b.WriteByte('\n') return b.String() } func (m chatTUI) renderSkillPickerSources() string { p := m.skillPick var b strings.Builder b.WriteString(accent(i18n.M.SkillPickerSourceTitle)) summary := skillSourceSummary(p.roots) if summary != "" { b.WriteString(" " + dim(summary)) } b.WriteByte('\n') roots := p.visibleRoots() for i, r := range roots { label := sourceRowLabel(r, m.width) b.WriteString(rowLine(i == p.sourceSel, i+1, "", label, false)) b.WriteByte('\n') } if p.showDiagnostics { b.WriteString(dim(" " + i18n.M.SkillPickerDiagShown)) } else { b.WriteString(dim(" " + i18n.M.SkillPickerDiagHidden)) } return b.String() } func (m chatTUI) renderSkillPickerSourceSkills() string { p := m.skillPick var b strings.Builder root, ok := p.selectedRoot() if !ok { p.mode = pickerSources return m.renderSkillPickerSources() } skills := p.selectedRootSkills() b.WriteString(accent(i18n.M.SkillPickerSourceTitle)) b.WriteString(" " + dim(viewCompactPath(root.dir, max(8, m.width-18)))) b.WriteByte('\n') b.WriteByte('\n') if len(skills) == 0 { b.WriteString(dim(" " + i18n.M.SkillPickerSourceSkillsEmpty)) b.WriteByte('\n') return b.String() } start, end := skillListWindow(p.sourceSkillSel, len(skills), m.skillPickerVisibleRows()) if start > 0 { b.WriteString(dim(" " + fmt.Sprintf(i18n.M.SkillPickerMoreAboveFmt, start))) b.WriteByte('\n') } for i := start; i < end; i++ { b.WriteString(renderSkillRow(i+1, i == p.sourceSkillSel, skills[i], p.skillEnabled(skills[i].Name), m.width)) b.WriteByte('\n') } if end < len(skills) { b.WriteString(dim(" " + fmt.Sprintf(i18n.M.SkillPickerMoreBelowFmt, len(skills)-end))) b.WriteByte('\n') } return b.String() } func (m chatTUI) renderSkillPickerDetail() string { p := m.skillPick var b strings.Builder b.WriteString(renderSkillDetailHeader(p.detailSkill, m.width)) b.WriteByte('\n') b.WriteByte('\n') enabled := i18n.M.SkillPickerDisabledLabel if p.skillEnabled(p.detailSkill.Name) { enabled = i18n.M.SkillPickerAvailableLabel } b.WriteString(dim(" " + i18n.M.SkillPickerStatusLabel + ": " + enabled)) b.WriteByte('\n') actions := skillActionsFor(p.detailSkill) for i, action := range actions { b.WriteString(rowLine(i == p.detailAction, i+1, "", action.label, false)) b.WriteByte('\n') } if body := renderSkillBodyPreview(p.detailSkill, m.width, 6); body != "" { b.WriteByte('\n') b.WriteString(body) } return b.String() } func (m chatTUI) renderSkillPickerConfirmDelete() string { p := m.skillPick var b strings.Builder b.WriteString(accent(fmt.Sprintf(i18n.M.SkillPickerDeleteTitleFmt, p.deleteSkill.Name))) b.WriteByte('\n') path := skillDeleteTargetLabel(p.deleteSkill) if path != "" { b.WriteString(dim(" " + viewCompactPath(path, max(8, m.width-4)))) b.WriteByte('\n') } b.WriteByte('\n') b.WriteString(rowLine(p.confirm == 0, 1, "", i18n.M.SkillPickerDeleteConfirm, false)) b.WriteByte('\n') b.WriteString(rowLine(p.confirm == 1, 2, "", i18n.M.SkillPickerDeleteCancel, false)) return b.String() } func renderSkillRow(num int, selected bool, s skill.Skill, enabled bool, w int) string { prefix := " " if selected { prefix = accent(" › ") } nameWidth := min(30, max(14, w/3)) name := compactMiddle(s.SlashName(), nameWidth) if selected { name = bold(name) } name = padRight(name, nameWidth) status := "✓ " + i18n.M.SkillPickerAvailableLabel if enabled { status = viewStatus(status) } else { status = viewMeta("○ " + i18n.M.SkillPickerDisabledLabel) } meta := skillRowMeta(s) number := fmt.Sprintf("%2d. ", num) line := fmt.Sprintf("%s%s%s · %s · %s", prefix, number, name, status, viewMeta(meta)) if visibleWidth(line) > w { line = viewCompactText(line, w) } if selected { return reverse(padRight(line, w)) } return line } func skillGroupLabel(sc skill.Scope) string { return titleText(scopeLabel(sc)) + " skills" } func skillRowMeta(s skill.Skill) string { parts := []string{scopeLabel(s.Scope)} if s.RunAs == skill.RunSubagent { parts = append(parts, i18n.M.SkillPickerSubagent) } parts = append(parts, fmt.Sprintf(i18n.M.SkillPickerTokenFmt, approxSkillTokens(s))) return strings.Join(parts, " · ") } func approxSkillTokens(s skill.Skill) int { text := strings.TrimSpace(s.Body) if text == "" { text = strings.TrimSpace(s.Description) } if text == "" { return 0 } estimate := max(len([]rune(text))/4, len(strings.Fields(text))) if estimate <= 10 { return 10 } return ((estimate + 9) / 10) * 10 } func sourceRowLabel(r skillRootLine, w int) string { path := viewCompactPath(r.dir, max(8, w-40)) scope := dim(scopeLabel(r.scope)) status := statusLabel(r.status) if r.status == skill.StatusOK { status = accent(status) } else { status = dim(status) } skills := dim(fmt.Sprintf("%d %s", r.skills, i18n.M.SkillPickerSkillsUnit)) return fmt.Sprintf("%s %s %s %s", path, scope, status, skills) } func skillPickerSummary(p *skillPicker) string { if len(p.skills) == 0 { return "" } if p.searchActive && p.query != "" { filtered := p.filteredSkills() return fmt.Sprintf(i18n.M.SkillPickerMatchingFmt, len(filtered), len(p.skills)) } counts := map[skill.Scope]int{} for _, s := range p.skills { counts[s.Scope]++ } var parts []string parts = append(parts, fmt.Sprintf(i18n.M.SkillPickerAvailableFmt, len(p.skills))) for _, sc := range []skill.Scope{skill.ScopeProject, skill.ScopeCustom, skill.ScopeGlobal, skill.ScopeBuiltin} { if n, ok := counts[sc]; ok && n > 0 { parts = append(parts, fmt.Sprintf("%d %s", n, scopeLabel(sc))) } } return strings.Join(parts, " · ") } func skillSourceSummary(roots []skillRootLine) string { active := 0 for _, r := range roots { if r.skills > 0 { active++ } } if active == 0 { return "" } return fmt.Sprintf(i18n.M.SkillPickerSourceActiveFmt, active) } func renderSkillDetail(s skill.Skill, w int) string { var b strings.Builder b.WriteString(renderSkillDetailHeader(s, w)) if body := renderSkillBodyPreview(s, w, 12); body != "" { b.WriteByte('\n') b.WriteString(body) } return b.String() } func renderSkillDetailHeader(s skill.Skill, w int) string { var b strings.Builder b.WriteString(accent("/" + s.SlashName())) b.WriteByte('\n') meta := fmt.Sprintf(i18n.M.SkillPickerDetailMetaFmt, scopeLabel(s.Scope), string(s.RunAs)) b.WriteString(dim(" " + meta)) b.WriteByte('\n') if s.Path != "" && s.Scope != skill.ScopeBuiltin { b.WriteString(dim(" " + viewCompactPath(s.Path, max(8, w-4)))) b.WriteByte('\n') } if strings.TrimSpace(s.Description) != "" { b.WriteByte('\n') b.WriteString(viewCompactText(s.Description, max(8, w-4))) b.WriteByte('\n') } return b.String() } func renderSkillBodyPreview(s skill.Skill, w, maxLines int) string { body, extra := viewBodyPreview(s.Body, maxLines) if body == "" { return "" } var b strings.Builder b.WriteString(dim(viewProtectLines(body, w))) b.WriteByte('\n') if extra > 0 { b.WriteString(viewMore(extra, i18n.M.SkillPickerLinesUnit)) b.WriteByte('\n') } return b.String() } func scopeLabel(sc skill.Scope) string { switch sc { case skill.ScopeProject: return i18n.M.SkillPickerScopeProject case skill.ScopeCustom: return i18n.M.SkillPickerScopeCustom case skill.ScopeGlobal: return i18n.M.SkillPickerScopeGlobal case skill.ScopeBuiltin: return i18n.M.SkillPickerScopeBuiltin default: return string(sc) } } func statusLabel(st skill.PathStatus) string { switch st { case skill.StatusOK: return i18n.M.SkillPickerStatusOK case skill.StatusMissing: return i18n.M.SkillPickerStatusMissing case skill.StatusNotDirectory: return i18n.M.SkillPickerStatusNotDir case skill.StatusUnreadable: return i18n.M.SkillPickerStatusUnreadable default: return string(st) } }