v0.3.97 c2267b3

skills_dialog.go RawHistoryFolder

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

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