v0.4.3 2a19df1

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 wrapped lines)
22	maxRows int           // scrollable rows in the body view, 0 uses the default
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 }
41
42// HandleKey advances the dialog state.
43func (d *skillsDialog) HandleKey(k tui.Key) (closed bool) {
44	if !d.Active() {
45		return false
46	}
47
48	if d.viewing != nil {
49		// Body view keys.
50		switch k.Kind {
51		case tui.KeyEsc, tui.KeyEnter:
52			d.viewing = nil
53			d.scroll = 0
54		case tui.KeyUp:
55			if d.scroll > 0 {
56				d.scroll--
57			}
58		case tui.KeyDown:
59			d.scroll++
60		case tui.KeyPageUp:
61			d.scroll -= 8
62			if d.scroll < 0 {
63				d.scroll = 0
64			}
65		case tui.KeyPageDown:
66			d.scroll += 8
67		}
68		return false
69	}
70
71	// List view keys.
72	switch k.Kind {
73	case tui.KeyEsc:
74		d.Close()
75		return true
76	case tui.KeyUp:
77		if d.cursor > 0 {
78			d.cursor--
79		}
80	case tui.KeyDown:
81		if d.cursor < len(d.skills)-1 {
82			d.cursor++
83		}
84	case tui.KeyEnter:
85		if len(d.skills) > 0 {
86			d.viewing = d.skills[d.cursor]
87			d.scroll = 0
88		}
89	}
90	return false
91}
92
93// Render draws the picker or the body view.
94func (d *skillsDialog) Render(th tui.Theme, width int) []string {
95	if !d.Active() {
96		return nil
97	}
98
99	if d.viewing != nil {
100		return d.renderBody(th, width)
101	}
102
103	out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
104	if d.canPin {
105		out = append(out, wrapDialogMutedRows(th, "p: project pin, g: global pin (toggle)", width)...)
106	}
107	if len(d.skills) == 0 {
108		out = append(out, wrapDialogMutedRows(th, "no user skills loaded", width)...)
109		out = append(out, wrapDialogMutedRows(th, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills", width)...)
110		out = append(out, frameRule(th, width))
111		return out
112	}
113
114	const maxRows = 12
115	start, end := visibleWindow(d.cursor, len(d.skills), maxRows)
116	if start > 0 {
117		out = append(out, "  "+th.FG256(th.Muted, fmt.Sprintf("\u2191 %d more above", start)))
118	}
119	for i := start; i < end; i++ {
120		s := d.skills[i]
121		marker := ""
122		if d.canPin {
123			project, global := "-", "-"
124			if slices.Contains(d.pins.Project, s.Name) {
125				project = "p"
126			}
127			if slices.Contains(d.pins.Global, s.Name) {
128				global = "g"
129			}
130			marker = "[" + project + global + "] "
131		}
132		row := marker + formatSkillRow(s, width-2-len(marker))
133		if i == d.cursor {
134			out = append(out, th.PadHighlight("  "+row, width))
135		} else {
136			out = append(out, "  "+th.FG256(th.Muted, row))
137		}
138	}
139	if end < len(d.skills) {
140		out = append(out, "  "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more below", len(d.skills)-end)))
141	}
142	out = append(out, "  "+th.FG256(th.Muted, "run with /skill:<name> [request]"))
143	out = append(out, frameRule(th, width))
144	return out
145}
146
147// fitBodyRows reserves the fixed dialog chrome and the rows outside the dialog.
148// The metadata scrolls with the body so even a long path cannot hide its start.
149func (d *skillsDialog) fitBodyRows(terminalRows, otherRows int) {
150	// Header, rule, scroll hint, two frame gaps, and the renderer's bottom margin.
151	d.maxRows = max(1, min(16, terminalRows-otherRows-6))
152}
153
154func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
155	s := d.viewing
156	out := []string{frameHeader(th, "skill: "+s.Name+"  (esc / enter to go back)", width)}
157	content := wrapDialogMutedRows(th, s.Description, width)
158	content = append(content, wrapDialogMutedRows(th, "source: "+s.Source+"  ("+s.Path+")", width)...)
159	content = append(content, "")
160
161	// Fold the markdown body to the dialog width. The renderer
162	// hard-truncates over-wide rows, and skill bodies are prose that
163	// routinely exceeds one row, so an unfolded body silently loses
164	// everything past the right edge.
165	content = append(content, renderDialogMarkdownRows(s.Body, th, width)...)
166
167	maxRows := d.maxRows
168	if maxRows <= 0 || maxRows > 16 {
169		maxRows = 16
170	}
171	if d.scroll > len(content)-1 {
172		d.scroll = len(content) - 1
173	}
174	if d.scroll < 0 {
175		d.scroll = 0
176	}
177	end := d.scroll + maxRows
178	if end > len(content) {
179		end = len(content)
180	}
181	out = append(out, content[d.scroll:end]...)
182	if end < len(content) {
183		out = append(out, "  "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(content)-end)))
184	}
185	out = append(out, frameRule(th, width))
186	return out
187}
188
189// wrapDialogMutedRows folds one muted dialog line to the width available
190// after the dialog's 2-space indent and returns ready-to-print rows.
191func wrapDialogMutedRows(th tui.Theme, text string, width int) []string {
192	inner := width - 2
193	if inner < 1 {
194		inner = 1
195	}
196	wrapped := tui.WrapANSILine(text, inner)
197	out := make([]string, 0, len(wrapped))
198	for _, line := range wrapped {
199		out = append(out, "  "+th.FG256(th.Muted, line))
200	}
201	return out
202}
203
204func formatSkillRow(s *skills.Skill, maxWidth int) string {
205	left := fmt.Sprintf("%-20s  ", truncateLineSafe(s.Name, 20))
206	source := s.Source
207	if s.DisableModelInvocation {
208		source = "manual, " + source
209	}
210	src := "  " + truncateLineSafe(source, 16)
211	room := maxWidth - len(left) - len(src)
212	if room < 10 {
213		room = 10
214	}
215	desc := s.Description
216	if len(desc) > room {
217		if room <= 3 {
218			desc = strings.Repeat(".", room)
219		} else {
220			desc = desc[:room-3] + "..."
221		}
222	}
223	return left + desc + src
224}
225
226// truncateLineSafe limits s to n runes (not bytes) so multibyte
227// names + sources don't blow past the column.
228func truncateLineSafe(s string, n int) string {
229	r := []rune(s)
230	if len(r) <= n {
231		return s
232	}
233	if n <= 3 {
234		return strings.Repeat(".", n)
235	}
236	return string(r[:n-3]) + "..."
237}
238
239// visibleWindow centers cursor in a window of size n within total
240// items. Returns [start, end) bounds.
241func visibleWindow(cursor, total, n int) (start, end int) {
242	if total <= n {
243		return 0, total
244	}
245	start = cursor - n/2
246	if start < 0 {
247		start = 0
248	}
249	end = start + n
250	if end > total {
251		end = total
252		start = end - n
253	}
254	return
255}

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