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