detached 2a19df1

Merge pull request #218 from m1k3s0/fix/skills-dialog-wrap

Wrap long lines in the /skills body view
commit
2a19df15ba8278dd6a92240ab6c2ae267c3cf6c3
author
Patric Eckhart · · 2026-09-28 04:54 UTC
committer
GitHub · · 2026-09-28 04:54 UTC
merge of
00f04d7 + f2af6ad · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.4.3
4 files · +190 -29
M docs/skills.md +3 -1
@@ -91,7 +91,9 @@ on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
9191
9292 In zot, run `/skills`. A picker lists every discovered skill with its
9393 description and source path. Press enter on a row to view the full
94-body inline. Press esc to go back.
94+body inline. Long descriptions, source paths, and body lines wrap to fit the
95+terminal. Use up/down or pgup/pgdn to scroll through the preview, including its
96+metadata on short terminals. Press esc to go back.
9597
9698 ## Pinning skills
9799
@@ -91,7 +91,9 @@ on macOS, `~/.local/state/zot` on Linux, or `%LOCALAPPDATA%\zot` on Windows.
91 91
92 In zot, run `/skills`. A picker lists every discovered skill with its92 In zot, run `/skills`. A picker lists every discovered skill with its
93 description and source path. Press enter on a row to view the full93 description and source path. Press enter on a row to view the full
94-body inline. Press esc to go back.94+body inline. Long descriptions, source paths, and body lines wrap to fit the
95+terminal. Use up/down or pgup/pgdn to scroll through the preview, including its
96+metadata on short terminals. Press esc to go back.
95 97
96 ## Pinning skills98 ## Pinning skills
97 99
M packages/agent/modes/interactive.go +10 -0
@@ -1668,6 +1668,16 @@ func (i *Interactive) redraw() {
16681668 }
16691669
16701670 _, rows := i.cfg.Terminal.Size()
1671+ if i.skillsDialog.Active() && i.skillsDialog.viewing != nil {
1672+ // Reflow after the editor, status, and queue are known so the
1673+ // scrollable skill content fits above them even on short terminals.
1674+ i.skillsDialog.fitBodyRows(rows, len(bottom)-len(dialog))
1675+ fitted := padDialogFrame(i.skillsDialog.Render(i.cfg.Theme, cols))
1676+ reflowed := make([]string, 0, len(bottom)-len(dialog)+len(fitted))
1677+ reflowed = append(reflowed, bottom[:1]...)
1678+ reflowed = append(reflowed, fitted...)
1679+ bottom = append(reflowed, bottom[1+len(dialog):]...)
1680+ }
16711681 chatRows := rows - len(bottom)
16721682 if chatRows < 1 {
16731683 chatRows = 1
@@ -1668,6 +1668,16 @@ func (i *Interactive) redraw() {
1668 }1668 }
1669 1669
1670 _, rows := i.cfg.Terminal.Size()1670 _, rows := i.cfg.Terminal.Size()
1671+ if i.skillsDialog.Active() && i.skillsDialog.viewing != nil {
1672+ // Reflow after the editor, status, and queue are known so the
1673+ // scrollable skill content fits above them even on short terminals.
1674+ i.skillsDialog.fitBodyRows(rows, len(bottom)-len(dialog))
1675+ fitted := padDialogFrame(i.skillsDialog.Render(i.cfg.Theme, cols))
1676+ reflowed := make([]string, 0, len(bottom)-len(dialog)+len(fitted))
1677+ reflowed = append(reflowed, bottom[:1]...)
1678+ reflowed = append(reflowed, fitted...)
1679+ bottom = append(reflowed, bottom[1+len(dialog):]...)
1680+ }
1671 chatRows := rows - len(bottom)1681 chatRows := rows - len(bottom)
1672 if chatRows < 1 {1682 if chatRows < 1 {
1673 chatRows = 11683 chatRows = 1
M packages/agent/modes/skills_dialog.go +48 -28
@@ -18,7 +18,8 @@ type skillsDialog struct {
1818 skills []*skills.Skill
1919 cursor int
2020 viewing *skills.Skill // when non-nil, render the body instead of the list
21- scroll int // body view scroll offset (in lines)
21+ scroll int // body view scroll offset (in wrapped lines)
22+ maxRows int // scrollable rows in the body view, 0 uses the default
2223 }
2324
2425 func newSkillsDialog() *skillsDialog { return &skillsDialog{} }
@@ -101,11 +102,11 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
101102
102103 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
103104 if d.canPin {
104- out = append(out, " "+th.FG256(th.Muted, "p: project pin, g: global pin (toggle)"))
105+ out = append(out, wrapDialogMutedRows(th, "p: project pin, g: global pin (toggle)", width)...)
105106 }
106107 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"))
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)...)
109110 out = append(out, frameRule(th, width))
110111 return out
111112 }
@@ -143,44 +144,63 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
143144 return out
144145 }
145146
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.
149+func (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+
146154 func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
147155 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- }
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)...)
162166
163- const maxRows = 16
164- if d.scroll > len(bodyLines)-1 {
165- d.scroll = len(bodyLines) - 1
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
166173 }
167174 if d.scroll < 0 {
168175 d.scroll = 0
169176 }
170177 end := d.scroll + maxRows
171- if end > len(bodyLines) {
172- end = len(bodyLines)
178+ if end > len(content) {
179+ end = len(content)
173180 }
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)))
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)))
179184 }
180185 out = append(out, frameRule(th, width))
181186 return out
182187 }
183188
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.
191+func 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+
184204 func formatSkillRow(s *skills.Skill, maxWidth int) string {
185205 left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))
186206 source := s.Source
@@ -18,7 +18,8 @@ type skillsDialog struct {
18 skills []*skills.Skill18 skills []*skills.Skill
19 cursor int19 cursor int
20 viewing *skills.Skill // when non-nil, render the body instead of the list20 viewing *skills.Skill // when non-nil, render the body instead of the list
21- scroll int // body view scroll offset (in lines)21+ scroll int // body view scroll offset (in wrapped lines)
22+ maxRows int // scrollable rows in the body view, 0 uses the default
22 }23 }
23 24
24 func newSkillsDialog() *skillsDialog { return &skillsDialog{} }25 func newSkillsDialog() *skillsDialog { return &skillsDialog{} }
@@ -101,11 +102,11 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
101 102
102 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}103 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
103 if d.canPin {104 if d.canPin {
104- out = append(out, " "+th.FG256(th.Muted, "p: project pin, g: global pin (toggle)"))105+ out = append(out, wrapDialogMutedRows(th, "p: project pin, g: global pin (toggle)", width)...)
105 }106 }
106 if len(d.skills) == 0 {107 if len(d.skills) == 0 {
107- out = append(out, " "+th.FG256(th.Muted, "no user skills loaded"))108+ out = append(out, wrapDialogMutedRows(th, "no user skills loaded", width)...)
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, wrapDialogMutedRows(th, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills", width)...)
109 out = append(out, frameRule(th, width))110 out = append(out, frameRule(th, width))
110 return out111 return out
111 }112 }
@@ -143,44 +144,63 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
143 return out144 return out
144 }145 }
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.
149+func (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+
146 func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {154 func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
147 s := d.viewing155 s := d.viewing
148- out := []string{156+ out := []string{frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width)}
149- frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width),157+ content := wrapDialogMutedRows(th, s.Description, width)
150- " " + th.FG256(th.Muted, s.Description),158+ content = append(content, wrapDialogMutedRows(th, "source: "+s.Source+" ("+s.Path+")", width)...)
151- " " + th.FG256(th.Muted, "source: "+s.Source+" ("+s.Path+")"),159+ content = append(content, "")
152- "",160+
153- }161+ // Fold the markdown body to the dialog width. The renderer
154-162+ // hard-truncates over-wide rows, and skill bodies are prose that
155- rendered := tui.RenderMarkdown(s.Body, th, width-4)163+ // routinely exceeds one row, so an unfolded body silently loses
156- bodyLines := strings.Split(rendered, "\n")164+ // everything past the right edge.
157- for i, l := range bodyLines {165+ content = append(content, renderDialogMarkdownRows(s.Body, th, width)...)
158- if len(l) > 0 && l[0] == tui.FlushLeftSentinel {
159- bodyLines[i] = l[1:]
160- }
161- }
162 166
163- const maxRows = 16167+ maxRows := d.maxRows
164- if d.scroll > len(bodyLines)-1 {168+ if maxRows <= 0 || maxRows > 16 {
165- d.scroll = len(bodyLines) - 1169+ maxRows = 16
170+ }
171+ if d.scroll > len(content)-1 {
172+ d.scroll = len(content) - 1
166 }173 }
167 if d.scroll < 0 {174 if d.scroll < 0 {
168 d.scroll = 0175 d.scroll = 0
169 }176 }
170 end := d.scroll + maxRows177 end := d.scroll + maxRows
171- if end > len(bodyLines) {178+ if end > len(content) {
172- end = len(bodyLines)179+ end = len(content)
173 }180 }
174- for _, line := range bodyLines[d.scroll:end] {181+ out = append(out, content[d.scroll:end]...)
175- out = append(out, " "+line)182+ if end < len(content) {
176- }183+ out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(content)-end)))
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 }184 }
180 out = append(out, frameRule(th, width))185 out = append(out, frameRule(th, width))
181 return out186 return out
182 }187 }
183 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.
191+func 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+
184 func formatSkillRow(s *skills.Skill, maxWidth int) string {204 func formatSkillRow(s *skills.Skill, maxWidth int) string {
185 left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))205 left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))
186 source := s.Source206 source := s.Source
A packages/agent/modes/skills_dialog_test.go +129 -0
@@ -0,0 +1,129 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/agent/skills"
8+ "github.com/patriceckhart/zot/packages/tui"
9+)
10+
11+// openSkillBody opens the /skills picker on one skill and enters its body
12+// view, the part of the dialog that renders a description, a source path,
13+// and prose that routinely exceed one terminal row.
14+func openSkillBody(t *testing.T, s *skills.Skill) *skillsDialog {
15+ t.Helper()
16+ d := newSkillsDialog()
17+ d.Open([]*skills.Skill{s})
18+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
19+ if d.viewing == nil {
20+ t.Fatal("enter did not open the body view")
21+ }
22+ return d
23+}
24+
25+func TestSkillsDialogBodyWrapsLongLines(t *testing.T) {
26+ const width = 64
27+ description := "A deliberately long skill description that cannot fit on a single row of a narrow terminal window."
28+ path := "/home/someone/.local/state/zot/skills/engineering/deliberately-long-skill-name/SKILL.md"
29+ body := "# Deliberately long\n\n" +
30+ strings.Repeat("skill body prose that has to fold instead of running off the edge. ", 16) +
31+ "and one final tail sentence."
32+
33+ d := openSkillBody(t, &skills.Skill{
34+ Name: "wide",
35+ Description: description,
36+ Body: body,
37+ Path: path,
38+ Source: "global",
39+ })
40+
41+ rows := d.Render(tui.Theme{}, width)
42+ assertRowsFitWidth(t, rows, width)
43+
44+ plain := strings.Join(rows, "\n")
45+ for _, word := range strings.Fields(description) {
46+ if !strings.Contains(plain, word) {
47+ t.Fatalf("wrapped description lost %q:\n%s", word, stripANSIBytes(plain))
48+ }
49+ }
50+ // The source path has no spaces, so it survives only when the dialog
51+ // folds it rather than handing an over-wide row to the renderer. A
52+ // path longer than one row is split mid-token, so compare it with the
53+ // whitespace the fold introduces removed.
54+ got := strings.NewReplacer(" ", "", "\n", "").Replace(stripANSIBytes(plain))
55+ if !strings.Contains(got, path) {
56+ t.Fatalf("wrapped source line lost its path:\n%s", stripANSIBytes(plain))
57+ }
58+
59+ // Folding makes the body taller than the 16-row viewport, so the tail
60+ // must still be reachable by scrolling.
61+ for range 20 {
62+ d.HandleKey(tui.Key{Kind: tui.KeyPageDown})
63+ }
64+ scrolled := d.Render(tui.Theme{}, width)
65+ assertRowsFitWidth(t, scrolled, width)
66+ if got := stripANSIBytes(strings.Join(scrolled, "\n")); !strings.Contains(got, "final tail sentence.") {
67+ t.Fatalf("scrolled body lost its tail:\n%s", got)
68+ }
69+}
70+
71+func TestSkillsDialogShortTerminalKeepsMetadataAndBodyReachable(t *testing.T) {
72+ const width, height, otherRows = 44, 24, 7
73+ d := openSkillBody(t, &skills.Skill{
74+ Name: "wide",
75+ Description: strings.Repeat("description that wraps repeatedly ", 18),
76+ Source: "global",
77+ Path: "/long/skill/path/SKILL.md",
78+ Body: "first body line\nsecond body line\nlast body line",
79+ })
80+ d.fitBodyRows(height, otherRows)
81+ render := func() string {
82+ rows := padDialogFrame(d.Render(tui.Theme{}, width))
83+ // Account for the editor/status band and the renderer's bottom margin.
84+ if len(rows)+otherRows+1 > height {
85+ t.Fatalf("dialog has %d rows with %d reserved, terminal has %d", len(rows), otherRows+1, height)
86+ }
87+ assertRowsFitWidth(t, rows, width)
88+ return stripANSIBytes(strings.Join(rows, "\n"))
89+ }
90+ if got := render(); !strings.Contains(got, "description that wraps") {
91+ t.Fatalf("metadata start not visible: %s", got)
92+ }
93+ foundPath, foundBody := false, false
94+ for range 40 {
95+ visible := render()
96+ foundPath = foundPath || strings.Contains(visible, "/long/skill/path/SKILL.md")
97+ foundBody = foundBody || strings.Contains(visible, "first body line")
98+ if foundPath && foundBody {
99+ break
100+ }
101+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
102+ }
103+ if !foundPath || !foundBody {
104+ t.Fatalf("source and first body line must both be reachable, source=%t body=%t", foundPath, foundBody)
105+ }
106+ for range 40 {
107+ d.HandleKey(tui.Key{Kind: tui.KeyUp})
108+ }
109+ if got := render(); !strings.Contains(got, "description that wraps") {
110+ t.Fatalf("metadata start unreachable after scrolling: %s", got)
111+ }
112+}
113+
114+func TestSkillsDialogWrapsHintLines(t *testing.T) {
115+ const width = 44
116+ d := newSkillsDialog()
117+ d.canPin = true
118+ d.Open(nil)
119+
120+ rows := d.Render(tui.Theme{}, width)
121+ assertRowsFitWidth(t, rows, width)
122+
123+ plain := stripANSIBytes(strings.Join(rows, "\n"))
124+ for _, want := range []string{"project pin", "toggle", "no user skills loaded", "$ZOT_HOME/skills", ".agents/skills"} {
125+ if !strings.Contains(plain, want) {
126+ t.Fatalf("wrapped hint lost %q:\n%s", want, plain)
127+ }
128+ }
129+}
@@ -0,0 +1,129 @@
1+package modes
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/patriceckhart/zot/packages/agent/skills"
8+ "github.com/patriceckhart/zot/packages/tui"
9+)
10+
11+// openSkillBody opens the /skills picker on one skill and enters its body
12+// view, the part of the dialog that renders a description, a source path,
13+// and prose that routinely exceed one terminal row.
14+func openSkillBody(t *testing.T, s *skills.Skill) *skillsDialog {
15+ t.Helper()
16+ d := newSkillsDialog()
17+ d.Open([]*skills.Skill{s})
18+ d.HandleKey(tui.Key{Kind: tui.KeyEnter})
19+ if d.viewing == nil {
20+ t.Fatal("enter did not open the body view")
21+ }
22+ return d
23+}
24+
25+func TestSkillsDialogBodyWrapsLongLines(t *testing.T) {
26+ const width = 64
27+ description := "A deliberately long skill description that cannot fit on a single row of a narrow terminal window."
28+ path := "/home/someone/.local/state/zot/skills/engineering/deliberately-long-skill-name/SKILL.md"
29+ body := "# Deliberately long\n\n" +
30+ strings.Repeat("skill body prose that has to fold instead of running off the edge. ", 16) +
31+ "and one final tail sentence."
32+
33+ d := openSkillBody(t, &skills.Skill{
34+ Name: "wide",
35+ Description: description,
36+ Body: body,
37+ Path: path,
38+ Source: "global",
39+ })
40+
41+ rows := d.Render(tui.Theme{}, width)
42+ assertRowsFitWidth(t, rows, width)
43+
44+ plain := strings.Join(rows, "\n")
45+ for _, word := range strings.Fields(description) {
46+ if !strings.Contains(plain, word) {
47+ t.Fatalf("wrapped description lost %q:\n%s", word, stripANSIBytes(plain))
48+ }
49+ }
50+ // The source path has no spaces, so it survives only when the dialog
51+ // folds it rather than handing an over-wide row to the renderer. A
52+ // path longer than one row is split mid-token, so compare it with the
53+ // whitespace the fold introduces removed.
54+ got := strings.NewReplacer(" ", "", "\n", "").Replace(stripANSIBytes(plain))
55+ if !strings.Contains(got, path) {
56+ t.Fatalf("wrapped source line lost its path:\n%s", stripANSIBytes(plain))
57+ }
58+
59+ // Folding makes the body taller than the 16-row viewport, so the tail
60+ // must still be reachable by scrolling.
61+ for range 20 {
62+ d.HandleKey(tui.Key{Kind: tui.KeyPageDown})
63+ }
64+ scrolled := d.Render(tui.Theme{}, width)
65+ assertRowsFitWidth(t, scrolled, width)
66+ if got := stripANSIBytes(strings.Join(scrolled, "\n")); !strings.Contains(got, "final tail sentence.") {
67+ t.Fatalf("scrolled body lost its tail:\n%s", got)
68+ }
69+}
70+
71+func TestSkillsDialogShortTerminalKeepsMetadataAndBodyReachable(t *testing.T) {
72+ const width, height, otherRows = 44, 24, 7
73+ d := openSkillBody(t, &skills.Skill{
74+ Name: "wide",
75+ Description: strings.Repeat("description that wraps repeatedly ", 18),
76+ Source: "global",
77+ Path: "/long/skill/path/SKILL.md",
78+ Body: "first body line\nsecond body line\nlast body line",
79+ })
80+ d.fitBodyRows(height, otherRows)
81+ render := func() string {
82+ rows := padDialogFrame(d.Render(tui.Theme{}, width))
83+ // Account for the editor/status band and the renderer's bottom margin.
84+ if len(rows)+otherRows+1 > height {
85+ t.Fatalf("dialog has %d rows with %d reserved, terminal has %d", len(rows), otherRows+1, height)
86+ }
87+ assertRowsFitWidth(t, rows, width)
88+ return stripANSIBytes(strings.Join(rows, "\n"))
89+ }
90+ if got := render(); !strings.Contains(got, "description that wraps") {
91+ t.Fatalf("metadata start not visible: %s", got)
92+ }
93+ foundPath, foundBody := false, false
94+ for range 40 {
95+ visible := render()
96+ foundPath = foundPath || strings.Contains(visible, "/long/skill/path/SKILL.md")
97+ foundBody = foundBody || strings.Contains(visible, "first body line")
98+ if foundPath && foundBody {
99+ break
100+ }
101+ d.HandleKey(tui.Key{Kind: tui.KeyDown})
102+ }
103+ if !foundPath || !foundBody {
104+ t.Fatalf("source and first body line must both be reachable, source=%t body=%t", foundPath, foundBody)
105+ }
106+ for range 40 {
107+ d.HandleKey(tui.Key{Kind: tui.KeyUp})
108+ }
109+ if got := render(); !strings.Contains(got, "description that wraps") {
110+ t.Fatalf("metadata start unreachable after scrolling: %s", got)
111+ }
112+}
113+
114+func TestSkillsDialogWrapsHintLines(t *testing.T) {
115+ const width = 44
116+ d := newSkillsDialog()
117+ d.canPin = true
118+ d.Open(nil)
119+
120+ rows := d.Render(tui.Theme{}, width)
121+ assertRowsFitWidth(t, rows, width)
122+
123+ plain := stripANSIBytes(strings.Join(rows, "\n"))
124+ for _, want := range []string{"project pin", "toggle", "no user skills loaded", "$ZOT_HOME/skills", ".agents/skills"} {
125+ if !strings.Contains(plain, want) {
126+ t.Fatalf("wrapped hint lost %q:\n%s", want, plain)
127+ }
128+ }
129+}

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 476

↑↓ moveenter openesc close