v0.50.0 c4ec31f

skills_dialog.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 477

↑↓ moveenter openesc close