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