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