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

438 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}