1package modes
2
3import (
4 "fmt"
5 "slices"
6 "strings"
7
8 "github.com/patriceckhart/zot/packages/agent/skills"
9 "github.com/patriceckhart/zot/packages/tui"
10)
11
12// skillsDialog lists discovered skills, their pin scopes, and their bodies.
13// Pin persistence is handled by the interactive host.
14type skillsDialog struct {
15 active bool
16 pins skills.Pins
17 canPin bool
18 skills []*skills.Skill
19 cursor int
20 viewing *skills.Skill // when non-nil, render the body instead of the list
21 scroll int // body view scroll offset (in lines)
22}
23
24func newSkillsDialog() *skillsDialog { return &skillsDialog{} }
25
26// Open populates and shows the dialog with the given snapshot.
27func (d *skillsDialog) Open(s []*skills.Skill) {
28 d.active = true
29 d.skills = s
30 d.cursor = 0
31 d.viewing = nil
32 d.scroll = 0
33}
34
35// Close hides the dialog.
36func (d *skillsDialog) Close() { d.active = false }
37
38// Active reports whether the dialog is visible.
39func (d *skillsDialog) Active() bool { return d != nil && d.active }
40
41// HandleKey advances the dialog state.
42func (d *skillsDialog) HandleKey(k tui.Key) (closed bool) {
43 if !d.Active() {
44 return false
45 }
46
47 if d.viewing != nil {
48 // Body view keys.
49 switch k.Kind {
50 case tui.KeyEsc, tui.KeyEnter:
51 d.viewing = nil
52 d.scroll = 0
53 case tui.KeyUp:
54 if d.scroll > 0 {
55 d.scroll--
56 }
57 case tui.KeyDown:
58 d.scroll++
59 case tui.KeyPageUp:
60 d.scroll -= 8
61 if d.scroll < 0 {
62 d.scroll = 0
63 }
64 case tui.KeyPageDown:
65 d.scroll += 8
66 }
67 return false
68 }
69
70 // List view keys.
71 switch k.Kind {
72 case tui.KeyEsc:
73 d.Close()
74 return true
75 case tui.KeyUp:
76 if d.cursor > 0 {
77 d.cursor--
78 }
79 case tui.KeyDown:
80 if d.cursor < len(d.skills)-1 {
81 d.cursor++
82 }
83 case tui.KeyEnter:
84 if len(d.skills) > 0 {
85 d.viewing = d.skills[d.cursor]
86 d.scroll = 0
87 }
88 }
89 return false
90}
91
92// Render draws the picker or the body view.
93func (d *skillsDialog) Render(th tui.Theme, width int) []string {
94 if !d.Active() {
95 return nil
96 }
97
98 if d.viewing != nil {
99 return d.renderBody(th, width)
100 }
101
102 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
103 if d.canPin {
104 out = append(out, " "+th.FG256(th.Muted, "p: project pin, g: global pin (toggle)"))
105 }
106 if len(d.skills) == 0 {
107 out = append(out, " "+th.FG256(th.Muted, "no user skills loaded"))
108 out = append(out, " "+th.FG256(th.Muted, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills"))
109 out = append(out, frameRule(th, width))
110 return out
111 }
112
113 const maxRows = 12
114 start, end := visibleWindow(d.cursor, len(d.skills), maxRows)
115 if start > 0 {
116 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2191 %d more above", start)))
117 }
118 for i := start; i < end; i++ {
119 s := d.skills[i]
120 marker := ""
121 if d.canPin {
122 project, global := "-", "-"
123 if slices.Contains(d.pins.Project, s.Name) {
124 project = "p"
125 }
126 if slices.Contains(d.pins.Global, s.Name) {
127 global = "g"
128 }
129 marker = "[" + project + global + "] "
130 }
131 row := marker + formatSkillRow(s, width-2-len(marker))
132 if i == d.cursor {
133 out = append(out, th.PadHighlight(" "+row, width))
134 } else {
135 out = append(out, " "+th.FG256(th.Muted, row))
136 }
137 }
138 if end < len(d.skills) {
139 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))
140 }
141 out = append(out, " "+th.FG256(th.Muted, "run with /skill:<name> [request]"))
142 out = append(out, frameRule(th, width))
143 return out
144}
145
146func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
147 s := d.viewing
148 out := []string{
149 frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width),
150 " " + th.FG256(th.Muted, s.Description),
151 " " + th.FG256(th.Muted, "source: "+s.Source+" ("+s.Path+")"),
152 "",
153 }
154
155 rendered := tui.RenderMarkdown(s.Body, th, width-4)
156 bodyLines := strings.Split(rendered, "\n")
157 for i, l := range bodyLines {
158 if len(l) > 0 && l[0] == tui.FlushLeftSentinel {
159 bodyLines[i] = l[1:]
160 }
161 }
162
163 const maxRows = 16
164 if d.scroll > len(bodyLines)-1 {
165 d.scroll = len(bodyLines) - 1
166 }
167 if d.scroll < 0 {
168 d.scroll = 0
169 }
170 end := d.scroll + maxRows
171 if end > len(bodyLines) {
172 end = len(bodyLines)
173 }
174 for _, line := range bodyLines[d.scroll:end] {
175 out = append(out, " "+line)
176 }
177 if end < len(bodyLines) {
178 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))
179 }
180 out = append(out, frameRule(th, width))
181 return out
182}
183
184func formatSkillRow(s *skills.Skill, maxWidth int) string {
185 left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))
186 source := s.Source
187 if s.DisableModelInvocation {
188 source = "manual, " + source
189 }
190 src := " " + truncateLineSafe(source, 16)
191 room := maxWidth - len(left) - len(src)
192 if room < 10 {
193 room = 10
194 }
195 desc := s.Description
196 if len(desc) > room {
197 if room <= 3 {
198 desc = strings.Repeat(".", room)
199 } else {
200 desc = desc[:room-3] + "..."
201 }
202 }
203 return left + desc + src
204}
205
206// truncateLineSafe limits s to n runes (not bytes) so multibyte
207// names + sources don't blow past the column.
208func truncateLineSafe(s string, n int) string {
209 r := []rune(s)
210 if len(r) <= n {
211 return s
212 }
213 if n <= 3 {
214 return strings.Repeat(".", n)
215 }
216 return string(r[:n-3]) + "..."
217}
218
219// visibleWindow centers cursor in a window of size n within total
220// items. Returns [start, end) bounds.
221func visibleWindow(cursor, total, n int) (start, end int) {
222 if total <= n {
223 return 0, total
224 }
225 start = cursor - n/2
226 if start < 0 {
227 start = 0
228 }
229 end = start + n
230 if end > total {
231 end = total
232 start = end - n
233 }
234 return
235}