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 wrapped lines)
22 maxRows int // scrollable rows in the body view, 0 uses the default
23}
24
25func newSkillsDialog() *skillsDialog { return &skillsDialog{} }
26
27// Open populates and shows the dialog with the given snapshot.
28func (d *skillsDialog) Open(s []*skills.Skill) {
29 d.active = true
30 d.skills = s
31 d.cursor = 0
32 d.viewing = nil
33 d.scroll = 0
34}
35
36// Close hides the dialog.
37func (d *skillsDialog) Close() { d.active = false }
38
39// Active reports whether the dialog is visible.
40func (d *skillsDialog) Active() bool { return d != nil && d.active }
41
42// HandleKey advances the dialog state.
43func (d *skillsDialog) HandleKey(k tui.Key) (closed bool) {
44 if !d.Active() {
45 return false
46 }
47
48 if d.viewing != nil {
49 // Body view keys.
50 switch k.Kind {
51 case tui.KeyEsc, tui.KeyEnter:
52 d.viewing = nil
53 d.scroll = 0
54 case tui.KeyUp:
55 if d.scroll > 0 {
56 d.scroll--
57 }
58 case tui.KeyDown:
59 d.scroll++
60 case tui.KeyPageUp:
61 d.scroll -= 8
62 if d.scroll < 0 {
63 d.scroll = 0
64 }
65 case tui.KeyPageDown:
66 d.scroll += 8
67 }
68 return false
69 }
70
71 // List view keys.
72 switch k.Kind {
73 case tui.KeyEsc:
74 d.Close()
75 return true
76 case tui.KeyUp:
77 if d.cursor > 0 {
78 d.cursor--
79 }
80 case tui.KeyDown:
81 if d.cursor < len(d.skills)-1 {
82 d.cursor++
83 }
84 case tui.KeyEnter:
85 if len(d.skills) > 0 {
86 d.viewing = d.skills[d.cursor]
87 d.scroll = 0
88 }
89 }
90 return false
91}
92
93// Render draws the picker or the body view.
94func (d *skillsDialog) Render(th tui.Theme, width int) []string {
95 if !d.Active() {
96 return nil
97 }
98
99 if d.viewing != nil {
100 return d.renderBody(th, width)
101 }
102
103 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
104 if d.canPin {
105 out = append(out, wrapDialogMutedRows(th, "p: project pin, g: global pin (toggle)", width)...)
106 }
107 if len(d.skills) == 0 {
108 out = append(out, wrapDialogMutedRows(th, "no user skills loaded", width)...)
109 out = append(out, wrapDialogMutedRows(th, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills", width)...)
110 out = append(out, frameRule(th, width))
111 return out
112 }
113
114 const maxRows = 12
115 start, end := visibleWindow(d.cursor, len(d.skills), maxRows)
116 if start > 0 {
117 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2191 %d more above", start)))
118 }
119 for i := start; i < end; i++ {
120 s := d.skills[i]
121 marker := ""
122 if d.canPin {
123 project, global := "-", "-"
124 if slices.Contains(d.pins.Project, s.Name) {
125 project = "p"
126 }
127 if slices.Contains(d.pins.Global, s.Name) {
128 global = "g"
129 }
130 marker = "[" + project + global + "] "
131 }
132 row := marker + formatSkillRow(s, width-2-len(marker))
133 if i == d.cursor {
134 out = append(out, th.PadHighlight(" "+row, width))
135 } else {
136 out = append(out, " "+th.FG256(th.Muted, row))
137 }
138 }
139 if end < len(d.skills) {
140 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))
141 }
142 out = append(out, " "+th.FG256(th.Muted, "run with /skill:<name> [request]"))
143 out = append(out, frameRule(th, width))
144 return out
145}
146
147// fitBodyRows reserves the fixed dialog chrome and the rows outside the dialog.
148// The metadata scrolls with the body so even a long path cannot hide its start.
149func (d *skillsDialog) fitBodyRows(terminalRows, otherRows int) {
150 // Header, rule, scroll hint, two frame gaps, and the renderer's bottom margin.
151 d.maxRows = max(1, min(16, terminalRows-otherRows-6))
152}
153
154func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
155 s := d.viewing
156 out := []string{frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width)}
157 content := wrapDialogMutedRows(th, s.Description, width)
158 content = append(content, wrapDialogMutedRows(th, "source: "+s.Source+" ("+s.Path+")", width)...)
159 content = append(content, "")
160
161 // Fold the markdown body to the dialog width. The renderer
162 // hard-truncates over-wide rows, and skill bodies are prose that
163 // routinely exceeds one row, so an unfolded body silently loses
164 // everything past the right edge.
165 content = append(content, renderDialogMarkdownRows(s.Body, th, width)...)
166
167 maxRows := d.maxRows
168 if maxRows <= 0 || maxRows > 16 {
169 maxRows = 16
170 }
171 if d.scroll > len(content)-1 {
172 d.scroll = len(content) - 1
173 }
174 if d.scroll < 0 {
175 d.scroll = 0
176 }
177 end := d.scroll + maxRows
178 if end > len(content) {
179 end = len(content)
180 }
181 out = append(out, content[d.scroll:end]...)
182 if end < len(content) {
183 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(content)-end)))
184 }
185 out = append(out, frameRule(th, width))
186 return out
187}
188
189// wrapDialogMutedRows folds one muted dialog line to the width available
190// after the dialog's 2-space indent and returns ready-to-print rows.
191func wrapDialogMutedRows(th tui.Theme, text string, width int) []string {
192 inner := width - 2
193 if inner < 1 {
194 inner = 1
195 }
196 wrapped := tui.WrapANSILine(text, inner)
197 out := make([]string, 0, len(wrapped))
198 for _, line := range wrapped {
199 out = append(out, " "+th.FG256(th.Muted, line))
200 }
201 return out
202}
203
204func formatSkillRow(s *skills.Skill, maxWidth int) string {
205 left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))
206 source := s.Source
207 if s.DisableModelInvocation {
208 source = "manual, " + source
209 }
210 src := " " + truncateLineSafe(source, 16)
211 room := maxWidth - len(left) - len(src)
212 if room < 10 {
213 room = 10
214 }
215 desc := s.Description
216 if len(desc) > room {
217 if room <= 3 {
218 desc = strings.Repeat(".", room)
219 } else {
220 desc = desc[:room-3] + "..."
221 }
222 }
223 return left + desc + src
224}
225
226// truncateLineSafe limits s to n runes (not bytes) so multibyte
227// names + sources don't blow past the column.
228func truncateLineSafe(s string, n int) string {
229 r := []rune(s)
230 if len(r) <= n {
231 return s
232 }
233 if n <= 3 {
234 return strings.Repeat(".", n)
235 }
236 return string(r[:n-3]) + "..."
237}
238
239// visibleWindow centers cursor in a window of size n within total
240// items. Returns [start, end) bounds.
241func visibleWindow(cursor, total, n int) (start, end int) {
242 if total <= n {
243 return 0, total
244 }
245 start = cursor - n/2
246 if start < 0 {
247 start = 0
248 }
249 end = start + n
250 if end > total {
251 end = total
252 start = end - n
253 }
254 return
255}