项目文件夹

文件
2026-07-13 13:00:08 +08:00

438 行
11 KiB
Go

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
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)
}
}