返回 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 := sel - limit/2
127 if start < 0 {
128 start = 0
129 }
130 if start+limit > total {
131 start = total - limit
132 }
133 return start, start + limit
134 }
135
136 func renderSkillSearchBox(query string, active bool, w int) string {
137 boxWidth := max(8, w-4)
138 innerWidth := max(1, boxWidth-4)
139 text := "/ " + i18n.M.SkillPickerSearchPlaceholder
140 if active || query != "" {
141 text = "/ " + query
142 }
143 text = padRight(viewCompactText(text, innerWidth), innerWidth)
144 var b strings.Builder
145 b.WriteString(dim(" ╭" + strings.Repeat("─", boxWidth-2) + "╮"))
146 b.WriteByte('\n')
147 b.WriteString(dim(" │ " + text + " │"))
148 b.WriteByte('\n')
149 b.WriteString(dim(" ╰" + strings.Repeat("─", boxWidth-2) + "╯"))
150 b.WriteByte('\n')
151 return b.String()
152 }
153
154 func (m chatTUI) renderSkillPickerSources() string {
155 p := m.skillPick
156 var b strings.Builder
157
158 b.WriteString(accent(i18n.M.SkillPickerSourceTitle))
159 summary := skillSourceSummary(p.roots)
160 if summary != "" {
161 b.WriteString(" " + dim(summary))
162 }
163 b.WriteByte('\n')
164
165 roots := p.visibleRoots()
166 for i, r := range roots {
167 label := sourceRowLabel(r, m.width)
168 b.WriteString(rowLine(i == p.sourceSel, i+1, "", label, false))
169 b.WriteByte('\n')
170 }
171
172 if p.showDiagnostics {
173 b.WriteString(dim(" " + i18n.M.SkillPickerDiagShown))
174 } else {
175 b.WriteString(dim(" " + i18n.M.SkillPickerDiagHidden))
176 }
177 return b.String()
178 }
179
180 func (m chatTUI) renderSkillPickerSourceSkills() string {
181 p := m.skillPick
182 var b strings.Builder
183 root, ok := p.selectedRoot()
184 if !ok {
185 p.mode = pickerSources
186 return m.renderSkillPickerSources()
187 }
188 skills := p.selectedRootSkills()
189 b.WriteString(accent(i18n.M.SkillPickerSourceTitle))
190 b.WriteString(" " + dim(viewCompactPath(root.dir, max(8, m.width-18))))
191 b.WriteByte('\n')
192 b.WriteByte('\n')
193 if len(skills) == 0 {
194 b.WriteString(dim(" " + i18n.M.SkillPickerSourceSkillsEmpty))
195 b.WriteByte('\n')
196 return b.String()
197 }
198 start, end := skillListWindow(p.sourceSkillSel, len(skills), m.skillPickerVisibleRows())
199 if start > 0 {
200 b.WriteString(dim(" " + fmt.Sprintf(i18n.M.SkillPickerMoreAboveFmt, start)))
201 b.WriteByte('\n')
202 }
203 for i := start; i < end; i++ {
204 b.WriteString(renderSkillRow(i+1, i == p.sourceSkillSel, skills[i], p.skillEnabled(skills[i].Name), m.width))
205 b.WriteByte('\n')
206 }
207 if end < len(skills) {
208 b.WriteString(dim(" " + fmt.Sprintf(i18n.M.SkillPickerMoreBelowFmt, len(skills)-end)))
209 b.WriteByte('\n')
210 }
211 return b.String()
212 }
213
214 func (m chatTUI) renderSkillPickerDetail() string {
215 p := m.skillPick
216 var b strings.Builder
217 b.WriteString(renderSkillDetailHeader(p.detailSkill, m.width))
218 b.WriteByte('\n')
219 b.WriteByte('\n')
220 enabled := i18n.M.SkillPickerDisabledLabel
221 if p.skillEnabled(p.detailSkill.Name) {
222 enabled = i18n.M.SkillPickerAvailableLabel
223 }
224 b.WriteString(dim(" " + i18n.M.SkillPickerStatusLabel + ": " + enabled))
225 b.WriteByte('\n')
226 actions := skillActionsFor(p.detailSkill)
227 for i, action := range actions {
228 b.WriteString(rowLine(i == p.detailAction, i+1, "", action.label, false))
229 b.WriteByte('\n')
230 }
231 if body := renderSkillBodyPreview(p.detailSkill, m.width, 6); body != "" {
232 b.WriteByte('\n')
233 b.WriteString(body)
234 }
235 return b.String()
236 }
237
238 func (m chatTUI) renderSkillPickerConfirmDelete() string {
239 p := m.skillPick
240 var b strings.Builder
241 b.WriteString(accent(fmt.Sprintf(i18n.M.SkillPickerDeleteTitleFmt, p.deleteSkill.Name)))
242 b.WriteByte('\n')
243 path := skillDeleteTargetLabel(p.deleteSkill)
244 if path != "" {
245 b.WriteString(dim(" " + viewCompactPath(path, max(8, m.width-4))))
246 b.WriteByte('\n')
247 }
248 b.WriteByte('\n')
249 b.WriteString(rowLine(p.confirm == 0, 1, "", i18n.M.SkillPickerDeleteConfirm, false))
250 b.WriteByte('\n')
251 b.WriteString(rowLine(p.confirm == 1, 2, "", i18n.M.SkillPickerDeleteCancel, false))
252 return b.String()
253 }
254
255 func renderSkillRow(num int, selected bool, s skill.Skill, enabled bool, w int) string {
256 prefix := " "
257 if selected {
258 prefix = accent(" › ")
259 }
260 nameWidth := min(30, max(14, w/3))
261 name := compactMiddle(s.SlashName(), nameWidth)
262 if selected {
263 name = bold(name)
264 }
265 name = padRight(name, nameWidth)
266 status := "✓ " + i18n.M.SkillPickerAvailableLabel
267 if enabled {
268 status = viewStatus(status)
269 } else {
270 status = viewMeta("○ " + i18n.M.SkillPickerDisabledLabel)
271 }
272 meta := skillRowMeta(s)
273 number := fmt.Sprintf("%2d. ", num)
274 line := fmt.Sprintf("%s%s%s · %s · %s", prefix, number, name, status, viewMeta(meta))
275 if visibleWidth(line) > w {
276 line = viewCompactText(line, w)
277 }
278
279 if selected {
280 return reverse(padRight(line, w))
281 }
282 return line
283 }
284
285 func skillGroupLabel(sc skill.Scope) string {
286 return titleText(scopeLabel(sc)) + " skills"
287 }
288
289 func skillRowMeta(s skill.Skill) string {
290 parts := []string{scopeLabel(s.Scope)}
291 if s.RunAs == skill.RunSubagent {
292 parts = append(parts, i18n.M.SkillPickerSubagent)
293 }
294 parts = append(parts, fmt.Sprintf(i18n.M.SkillPickerTokenFmt, approxSkillTokens(s)))
295 return strings.Join(parts, " · ")
296 }
297
298 func approxSkillTokens(s skill.Skill) int {
299 text := strings.TrimSpace(s.Body)
300 if text == "" {
301 text = strings.TrimSpace(s.Description)
302 }
303 if text == "" {
304 return 0
305 }
306 estimate := max(len([]rune(text))/4, len(strings.Fields(text)))
307 if estimate <= 10 {
308 return 10
309 }
310 return ((estimate + 9) / 10) * 10
311 }
312
313 func sourceRowLabel(r skillRootLine, w int) string {
314 path := viewCompactPath(r.dir, max(8, w-40))
315 scope := dim(scopeLabel(r.scope))
316 status := statusLabel(r.status)
317 if r.status == skill.StatusOK {
318 status = accent(status)
319 } else {
320 status = dim(status)
321 }
322 skills := dim(fmt.Sprintf("%d %s", r.skills, i18n.M.SkillPickerSkillsUnit))
323 return fmt.Sprintf("%s %s %s %s", path, scope, status, skills)
324 }
325
326 func skillPickerSummary(p *skillPicker) string {
327 if len(p.skills) == 0 {
328 return ""
329 }
330 if p.searchActive && p.query != "" {
331 filtered := p.filteredSkills()
332 return fmt.Sprintf(i18n.M.SkillPickerMatchingFmt, len(filtered), len(p.skills))
333 }
334 counts := map[skill.Scope]int{}
335 for _, s := range p.skills {
336 counts[s.Scope]++
337 }
338 var parts []string
339 parts = append(parts, fmt.Sprintf(i18n.M.SkillPickerAvailableFmt, len(p.skills)))
340 for _, sc := range []skill.Scope{skill.ScopeProject, skill.ScopeCustom, skill.ScopeGlobal, skill.ScopeBuiltin} {
341 if n, ok := counts[sc]; ok && n > 0 {
342 parts = append(parts, fmt.Sprintf("%d %s", n, scopeLabel(sc)))
343 }
344 }
345 return strings.Join(parts, " · ")
346 }
347
348 func skillSourceSummary(roots []skillRootLine) string {
349 active := 0
350 for _, r := range roots {
351 if r.skills > 0 {
352 active++
353 }
354 }
355 if active == 0 {
356 return ""
357 }
358 return fmt.Sprintf(i18n.M.SkillPickerSourceActiveFmt, active)
359 }
360
361 func renderSkillDetail(s skill.Skill, w int) string {
362 var b strings.Builder
363 b.WriteString(renderSkillDetailHeader(s, w))
364 if body := renderSkillBodyPreview(s, w, 12); body != "" {
365 b.WriteByte('\n')
366 b.WriteString(body)
367 }
368 return b.String()
369 }
370
371 func renderSkillDetailHeader(s skill.Skill, w int) string {
372 var b strings.Builder
373 b.WriteString(accent("/" + s.SlashName()))
374 b.WriteByte('\n')
375
376 meta := fmt.Sprintf(i18n.M.SkillPickerDetailMetaFmt, scopeLabel(s.Scope), string(s.RunAs))
377 b.WriteString(dim(" " + meta))
378 b.WriteByte('\n')
379
380 if s.Path != "" && s.Scope != skill.ScopeBuiltin {
381 b.WriteString(dim(" " + viewCompactPath(s.Path, max(8, w-4))))
382 b.WriteByte('\n')
383 }
384
385 if strings.TrimSpace(s.Description) != "" {
386 b.WriteByte('\n')
387 b.WriteString(viewCompactText(s.Description, max(8, w-4)))
388 b.WriteByte('\n')
389 }
390
391 return b.String()
392 }
393
394 func renderSkillBodyPreview(s skill.Skill, w, maxLines int) string {
395 body, extra := viewBodyPreview(s.Body, maxLines)
396 if body == "" {
397 return ""
398 }
399 var b strings.Builder
400 b.WriteString(dim(viewProtectLines(body, w)))
401 b.WriteByte('\n')
402 if extra > 0 {
403 b.WriteString(viewMore(extra, i18n.M.SkillPickerLinesUnit))
404 b.WriteByte('\n')
405 }
406 return b.String()
407 }
408
409 func scopeLabel(sc skill.Scope) string {
410 switch sc {
411 case skill.ScopeProject:
412 return i18n.M.SkillPickerScopeProject
413 case skill.ScopeCustom:
414 return i18n.M.SkillPickerScopeCustom
415 case skill.ScopeGlobal:
416 return i18n.M.SkillPickerScopeGlobal
417 case skill.ScopeBuiltin:
418 return i18n.M.SkillPickerScopeBuiltin
419 default:
420 return string(sc)
421 }
422 }
423
424 func statusLabel(st skill.PathStatus) string {
425 switch st {
426 case skill.StatusOK:
427 return i18n.M.SkillPickerStatusOK
428 case skill.StatusMissing:
429 return i18n.M.SkillPickerStatusMissing
430 case skill.StatusNotDirectory:
431 return i18n.M.SkillPickerStatusNotDir
432 case skill.StatusUnreadable:
433 return i18n.M.SkillPickerStatusUnreadable
434 default:
435 return string(st)
436 }
437 }
438
438 lines GO