返回 DeepSeek-Reasonix
skill_picker_view.go
根目录 / internal / cli / skill_picker_view.go
1 package cli
2
3 import (
4 "fmt"
5 "strings"
6
7 "reasonix/internal/i18n"
8 "reasonix/internal/skill"
9 )
10
11 const (
12 skillDialogMinRows = 8
13 skillDialogMaxRows = 18
14 )
15
16 func (m chatTUI) renderSkillPicker() string {
17 p := m.skillPick
18 if p == nil {
19 return ""
20 }
21 w := max(viewWidth(m.width), 40)
22 switch p.mode {
23 case pickerSkills:
24 return managerContentPanelStyle(w).Render(m.renderSkillPickerSkills())
25 case pickerSources:
26 return managerContentPanelStyle(w).Render(m.renderSkillPickerSources())
27 case pickerSourceSkills:
28 return managerContentPanelStyle(w).Render(m.renderSkillPickerSourceSkills())
29 case pickerDetail:
30 return managerContentPanelStyle(w).Render(m.renderSkillPickerDetail())
31 case pickerConfirmDelete:
32 return managerContentPanelStyle(w).Render(m.renderSkillPickerConfirmDelete())
33 }
34 return ""
35 }
36
37 func (m chatTUI) skillPickerFooterHint() string {
38 if m.skillPick == nil {
39 return ""
40 }
41 switch m.skillPick.mode {
42 case pickerSkills:
43 return i18n.M.SkillPickerHint
44 case pickerSources:
45 return i18n.M.SkillPickerSourceHint
46 case pickerSourceSkills:
47 return i18n.M.SkillPickerSourceSkillsHint
48 case pickerDetail:
49 return i18n.M.SkillPickerDetailHint
50 case pickerConfirmDelete:
51 return i18n.M.SkillPickerDeleteHint
52 default:
53 return ""
54 }
55 }
56
57 func (m chatTUI) renderSkillPickerSkills() string {
58 p := m.skillPick
59 w := max(viewWidth(m.width), 40)
60 var b strings.Builder
61
62 fmt.Fprintf(&b, "%s\n", viewHeader("Manage skills"))
63 if summary := skillPickerSummary(p); summary != "" {
64 fmt.Fprintf(&b, "%s\n", viewMeta(summary))
65 }
66 b.WriteByte('\n')
67 b.WriteString(renderSkillSearchBox(p.query, p.searchActive, w))
68 b.WriteByte('\n')
69
70 skills := p.skills
71 if p.searchActive && p.query != "" {
72 skills = p.filteredSkills()
73 }
74
75 if len(skills) == 0 {
76 b.WriteString(viewMeta(i18n.M.SkillPickerSearchEmpty))
77 b.WriteByte('\n')
78 } else {
79 start, end := skillListWindow(p.sel, len(skills), m.skillPickerVisibleRows())
80 if start > 0 {
81 b.WriteString(viewMeta(fmt.Sprintf(i18n.M.SkillPickerMoreAboveFmt, start)))
82 b.WriteByte('\n')
83 }
84 lastGroup := ""
85 for i := start; i < end; i++ {
86 group := skillGroupLabel(skills[i].Scope)
87 if group != lastGroup {
88 if lastGroup != "" {
89 b.WriteByte('\n')
90 }
91 fmt.Fprintf(&b, " %s\n", bold(group))
92 lastGroup = group
93 }
94 b.WriteString(renderSkillRow(i+1, i == p.sel, skills[i], p.skillEnabled(skills[i].Name), w))
95 b.WriteByte('\n')
96 }
97 if end < len(skills) {
98 b.WriteString(viewMeta(fmt.Sprintf(i18n.M.SkillPickerMoreBelowFmt, len(skills)-end)))
99 b.WriteByte('\n')
100 }
101 }
102
103 return strings.TrimRight(b.String(), "\n")
104 }
105
106 func (m chatTUI) skillPickerVisibleRows() int {
107 if m.height <= 0 {
108 return skillDialogMaxRows
109 }
110 return min(skillDialogMaxRows, max(skillDialogMinRows, m.height-14))
111 }
112
113 func skillListWindow(sel, total, limit int) (int, int) {
114 if total <= 0 {
115 return 0, 0
116 }
117 if limit <= 0 || limit >= total {
118 return 0, total
119 }
120 if sel < 0 {
121 sel = 0
122 }
123 if sel >= total {
124 sel = total - 1
125 }
126 start := max(sel-limit/2, 0)
127 if start+limit > total {
128 start = total - limit
129 }
130 return start, start + limit
131 }
132
133 func renderSkillSearchBox(query string, active bool, w int) string {
134 boxWidth := max(8, w-4)
135 innerWidth := max(1, boxWidth-4)
136 text := "/ " + i18n.M.SkillPickerSearchPlaceholder
137 if active || query != "" {
138 text = "/ " + query
139 }
140 text = padRight(viewCompactText(text, innerWidth), innerWidth)
141 var b strings.Builder
142 b.WriteString(dim(" ╭" + strings.Repeat("─", boxWidth-2) + "╮"))
143 b.WriteByte('\n')
144 b.WriteString(dim(" │ " + text + " │"))
145 b.WriteByte('\n')
146 b.WriteString(dim(" ╰" + strings.Repeat("─", boxWidth-2) + "╯"))
147 b.WriteByte('\n')
148 return b.String()
149 }
150
151 func (m chatTUI) renderSkillPickerSources() string {
152 p := m.skillPick
153 var b strings.Builder
154
155 b.WriteString(accent(i18n.M.SkillPickerSourceTitle))
156 summary := skillSourceSummary(p.roots)
157 if summary != "" {
158 b.WriteString(" " + dim(summary))
159 }
160 b.WriteByte('\n')
161
162 roots := p.visibleRoots()
163 for i, r := range roots {
164 label := sourceRowLabel(r, m.width)
165 b.WriteString(rowLine(i == p.sourceSel, i+1, "", label, false))
166 b.WriteByte('\n')
167 }
168
169 if p.showDiagnostics {
170 b.WriteString(dim(" " + i18n.M.SkillPickerDiagShown))
171 } else {
172 b.WriteString(dim(" " + i18n.M.SkillPickerDiagHidden))
173 }
174 return b.String()
175 }
176
177 func (m chatTUI) renderSkillPickerSourceSkills() string {
178 p := m.skillPick
179 var b strings.Builder
180 root, ok := p.selectedRoot()
181 if !ok {
182 p.mode = pickerSources
183 return m.renderSkillPickerSources()
184 }
185 skills := p.selectedRootSkills()
186 b.WriteString(accent(i18n.M.SkillPickerSourceTitle))
187 b.WriteString(" " + dim(viewCompactPath(root.dir, max(8, m.width-18))))
188 b.WriteByte('\n')
189 b.WriteByte('\n')
190 if len(skills) == 0 {
191 b.WriteString(dim(" " + i18n.M.SkillPickerSourceSkillsEmpty))
192 b.WriteByte('\n')
193 return b.String()
194 }
195 start, end := skillListWindow(p.sourceSkillSel, len(skills), m.skillPickerVisibleRows())
196 if start > 0 {
197 b.WriteString(dim(" " + fmt.Sprintf(i18n.M.SkillPickerMoreAboveFmt, start)))
198 b.WriteByte('\n')
199 }
200 for i := start; i < end; i++ {
201 b.WriteString(renderSkillRow(i+1, i == p.sourceSkillSel, skills[i], p.skillEnabled(skills[i].Name), m.width))
202 b.WriteByte('\n')
203 }
204 if end < len(skills) {
205 b.WriteString(dim(" " + fmt.Sprintf(i18n.M.SkillPickerMoreBelowFmt, len(skills)-end)))
206 b.WriteByte('\n')
207 }
208 return b.String()
209 }
210
211 func (m chatTUI) renderSkillPickerDetail() string {
212 p := m.skillPick
213 var b strings.Builder
214 b.WriteString(renderSkillDetailHeader(p.detailSkill, m.width))
215 b.WriteByte('\n')
216 b.WriteByte('\n')
217 enabled := i18n.M.SkillPickerDisabledLabel
218 if p.skillEnabled(p.detailSkill.Name) {
219 enabled = i18n.M.SkillPickerAvailableLabel
220 }
221 b.WriteString(dim(" " + i18n.M.SkillPickerStatusLabel + ": " + enabled))
222 b.WriteByte('\n')
223 actions := skillActionsFor(p.detailSkill)
224 for i, action := range actions {
225 b.WriteString(rowLine(i == p.detailAction, i+1, "", action.label, false))
226 b.WriteByte('\n')
227 }
228 if body := renderSkillBodyPreview(p.detailSkill, m.width, 6); body != "" {
229 b.WriteByte('\n')
230 b.WriteString(body)
231 }
232 return b.String()
233 }
234
235 func (m chatTUI) renderSkillPickerConfirmDelete() string {
236 p := m.skillPick
237 var b strings.Builder
238 b.WriteString(accent(fmt.Sprintf(i18n.M.SkillPickerDeleteTitleFmt, p.deleteSkill.Name)))
239 b.WriteByte('\n')
240 path := skillDeleteTargetLabel(p.deleteSkill)
241 if path != "" {
242 b.WriteString(dim(" " + viewCompactPath(path, max(8, m.width-4))))
243 b.WriteByte('\n')
244 }
245 b.WriteByte('\n')
246 b.WriteString(rowLine(p.confirm == 0, 1, "", i18n.M.SkillPickerDeleteConfirm, false))
247 b.WriteByte('\n')
248 b.WriteString(rowLine(p.confirm == 1, 2, "", i18n.M.SkillPickerDeleteCancel, false))
249 return b.String()
250 }
251
252 func renderSkillRow(num int, selected bool, s skill.Skill, enabled bool, w int) string {
253 prefix := " "
254 if selected {
255 prefix = accent(" › ")
256 }
257 nameWidth := min(30, max(14, w/3))
258 name := compactMiddle(s.SlashName(), nameWidth)
259 if selected {
260 name = bold(name)
261 }
262 name = padRight(name, nameWidth)
263 status := "✓ " + i18n.M.SkillPickerAvailableLabel
264 if enabled {
265 status = viewStatus(status)
266 } else {
267 status = viewMeta("○ " + i18n.M.SkillPickerDisabledLabel)
268 }
269 meta := skillRowMeta(s)
270 number := fmt.Sprintf("%2d. ", num)
271 line := fmt.Sprintf("%s%s%s · %s · %s", prefix, number, name, status, viewMeta(meta))
272 if visibleWidth(line) > w {
273 line = viewCompactText(line, w)
274 }
275
276 if selected {
277 return reverse(padRight(line, w))
278 }
279 return line
280 }
281
282 func skillGroupLabel(sc skill.Scope) string {
283 return titleText(scopeLabel(sc)) + " skills"
284 }
285
286 func skillRowMeta(s skill.Skill) string {
287 parts := []string{scopeLabel(s.Scope)}
288 if s.RunAs == skill.RunSubagent {
289 parts = append(parts, i18n.M.SkillPickerSubagent)
290 }
291 parts = append(parts, fmt.Sprintf(i18n.M.SkillPickerTokenFmt, approxSkillTokens(s)))
292 return strings.Join(parts, " · ")
293 }
294
295 func approxSkillTokens(s skill.Skill) int {
296 text := strings.TrimSpace(s.Body)
297 if text == "" {
298 text = strings.TrimSpace(s.Description)
299 }
300 if text == "" {
301 return 0
302 }
303 estimate := max(len([]rune(text))/4, len(strings.Fields(text)))
304 if estimate <= 10 {
305 return 10
306 }
307 return ((estimate + 9) / 10) * 10
308 }
309
310 func sourceRowLabel(r skillRootLine, w int) string {
311 path := viewCompactPath(r.dir, max(8, w-40))
312 scope := dim(scopeLabel(r.scope))
313 status := statusLabel(r.status)
314 if r.status == skill.StatusOK {
315 status = accent(status)
316 } else {
317 status = dim(status)
318 }
319 skills := dim(fmt.Sprintf("%d %s", r.skills, i18n.M.SkillPickerSkillsUnit))
320 return fmt.Sprintf("%s %s %s %s", path, scope, status, skills)
321 }
322
323 func skillPickerSummary(p *skillPicker) string {
324 if len(p.skills) == 0 {
325 return ""
326 }
327 if p.searchActive && p.query != "" {
328 filtered := p.filteredSkills()
329 return fmt.Sprintf(i18n.M.SkillPickerMatchingFmt, len(filtered), len(p.skills))
330 }
331 counts := map[skill.Scope]int{}
332 for _, s := range p.skills {
333 counts[s.Scope]++
334 }
335 var parts []string
336 parts = append(parts, fmt.Sprintf(i18n.M.SkillPickerAvailableFmt, len(p.skills)))
337 for _, sc := range []skill.Scope{skill.ScopeProject, skill.ScopeCustom, skill.ScopeGlobal, skill.ScopeBuiltin} {
338 if n, ok := counts[sc]; ok && n > 0 {
339 parts = append(parts, fmt.Sprintf("%d %s", n, scopeLabel(sc)))
340 }
341 }
342 return strings.Join(parts, " · ")
343 }
344
345 func skillSourceSummary(roots []skillRootLine) string {
346 active := 0
347 for _, r := range roots {
348 if r.skills > 0 {
349 active++
350 }
351 }
352 if active == 0 {
353 return ""
354 }
355 return fmt.Sprintf(i18n.M.SkillPickerSourceActiveFmt, active)
356 }
357
358 func renderSkillDetail(s skill.Skill, w int) string {
359 var b strings.Builder
360 b.WriteString(renderSkillDetailHeader(s, w))
361 if body := renderSkillBodyPreview(s, w, 12); body != "" {
362 b.WriteByte('\n')
363 b.WriteString(body)
364 }
365 return b.String()
366 }
367
368 func renderSkillDetailHeader(s skill.Skill, w int) string {
369 var b strings.Builder
370 b.WriteString(accent("/" + s.SlashName()))
371 b.WriteByte('\n')
372
373 meta := fmt.Sprintf(i18n.M.SkillPickerDetailMetaFmt, scopeLabel(s.Scope), string(s.RunAs))
374 b.WriteString(dim(" " + meta))
375 b.WriteByte('\n')
376
377 if s.Path != "" && s.Scope != skill.ScopeBuiltin {
378 b.WriteString(dim(" " + viewCompactPath(s.Path, max(8, w-4))))
379 b.WriteByte('\n')
380 }
381
382 if strings.TrimSpace(s.Description) != "" {
383 b.WriteByte('\n')
384 b.WriteString(viewCompactText(s.Description, max(8, w-4)))
385 b.WriteByte('\n')
386 }
387
388 return b.String()
389 }
390
391 func renderSkillBodyPreview(s skill.Skill, w, maxLines int) string {
392 body, extra := viewBodyPreview(s.Body, maxLines)
393 if body == "" {
394 return ""
395 }
396 var b strings.Builder
397 b.WriteString(dim(viewProtectLines(body, w)))
398 b.WriteByte('\n')
399 if extra > 0 {
400 b.WriteString(viewMore(extra, i18n.M.SkillPickerLinesUnit))
401 b.WriteByte('\n')
402 }
403 return b.String()
404 }
405
406 func scopeLabel(sc skill.Scope) string {
407 switch sc {
408 case skill.ScopeProject:
409 return i18n.M.SkillPickerScopeProject
410 case skill.ScopeCustom:
411 return i18n.M.SkillPickerScopeCustom
412 case skill.ScopeGlobal:
413 return i18n.M.SkillPickerScopeGlobal
414 case skill.ScopeBuiltin:
415 return i18n.M.SkillPickerScopeBuiltin
416 default:
417 return string(sc)
418 }
419 }
420
421 func statusLabel(st skill.PathStatus) string {
422 switch st {
423 case skill.StatusOK:
424 return i18n.M.SkillPickerStatusOK
425 case skill.StatusMissing:
426 return i18n.M.SkillPickerStatusMissing
427 case skill.StatusNotDirectory:
428 return i18n.M.SkillPickerStatusNotDir
429 case skill.StatusUnreadable:
430 return i18n.M.SkillPickerStatusUnreadable
431 default:
432 return string(st)
433 }
434 }
435
435 lines GO