v0.29.0 73d6864

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 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}

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