detached 17f39f2

Wrap long lines in the /skills body view

The body view passed the skill description, the source line, and every
line of the markdown body through unwrapped. The renderer truncates any
row wider than the terminal so the frame never soft-wraps, so skill
prose lost everything past the right edge, mid-word.

The dialog now folds what it owns: wrapDialogMutedRows wraps the
description and the source line, the body renders through the shared
renderDialogMarkdownRows helper, and the picker's hint lines fold the
same way. The body scroll window counts the folded rows, so the
remaining-lines hint stays accurate.
commit
17f39f2c1cd15687f7af9c7b0434d2dcda234abc
author
Mike Smith · · 2026-09-27 18:37 UTC
parent
41873f6
signature
unsigned
2 files · +117 -20
M packages/agent/modes/skills_dialog.go +31 -20
@@ -101,11 +101,11 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
101101
102102 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
103103 if d.canPin {
104- out = append(out, " "+th.FG256(th.Muted, "p: project pin, g: global pin (toggle)"))
104+ out = append(out, wrapDialogMutedRows(th, "p: project pin, g: global pin (toggle)", width)...)
105105 }
106106 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"))
107+ out = append(out, wrapDialogMutedRows(th, "no user skills loaded", width)...)
108+ out = append(out, wrapDialogMutedRows(th, "add SKILL.md under $ZOT_HOME/skills, .zot/skills, .claude/skills, or .agents/skills", width)...)
109109 out = append(out, frameRule(th, width))
110110 return out
111111 }
@@ -145,20 +145,16 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
145145
146146 func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
147147 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- }
148+ out := []string{frameHeader(th, "skill: "+s.Name+" (esc / enter to go back)", width)}
149+ out = append(out, wrapDialogMutedRows(th, s.Description, width)...)
150+ out = append(out, wrapDialogMutedRows(th, "source: "+s.Source+" ("+s.Path+")", width)...)
151+ out = append(out, "")
152+
153+ // Fold the markdown body to the dialog width. The renderer
154+ // hard-truncates over-wide rows, and skill bodies are prose that
155+ // routinely exceeds one row, so an unfolded body silently loses
156+ // everything past the right edge.
157+ bodyLines := renderDialogMarkdownRows(s.Body, th, width)
162158
163159 const maxRows = 16
164160 if d.scroll > len(bodyLines)-1 {
@@ -171,9 +167,9 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
171167 if end > len(bodyLines) {
172168 end = len(bodyLines)
173169 }
174- for _, line := range bodyLines[d.scroll:end] {
175- out = append(out, " "+line)
176- }
170+ // renderDialogMarkdownRows already prefixes each row with the
171+ // dialog's text indent.
172+ out = append(out, bodyLines[d.scroll:end]...)
177173 if end < len(bodyLines) {
178174 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))
179175 }
@@ -181,6 +177,21 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
181177 return out
182178 }
183179
180+// wrapDialogMutedRows folds one muted dialog line to the width available
181+// after the dialog's 2-space indent and returns ready-to-print rows.
182+func wrapDialogMutedRows(th tui.Theme, text string, width int) []string {
183+ inner := width - 2
184+ if inner < 1 {
185+ inner = 1
186+ }
187+ wrapped := tui.WrapANSILine(text, inner)
188+ out := make([]string, 0, len(wrapped))
189+ for _, line := range wrapped {
190+ out = append(out, " "+th.FG256(th.Muted, line))
191+ }
192+ return out
193+}
194+
184195 func formatSkillRow(s *skills.Skill, maxWidth int) string {
185196 left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))
186197 source := s.Source
@@ -101,11 +101,11 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
101 101
102 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}102 out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)}
103 if d.canPin {103 if d.canPin {
104- out = append(out, " "+th.FG256(th.Muted, "p: project pin, g: global pin (toggle)"))104+ out = append(out, wrapDialogMutedRows(th, "p: project pin, g: global pin (toggle)", width)...)
105 }105 }
106 if len(d.skills) == 0 {106 if len(d.skills) == 0 {
107- out = append(out, " "+th.FG256(th.Muted, "no user skills loaded"))107+ 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"))108+ 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))109 out = append(out, frameRule(th, width))
110 return out110 return out
111 }111 }
@@ -145,20 +145,16 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string {
145 145
146 func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {146 func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
147 s := d.viewing147 s := d.viewing
148- out := []string{148+ 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),149+ out = append(out, wrapDialogMutedRows(th, s.Description, width)...)
150- " " + th.FG256(th.Muted, s.Description),150+ out = append(out, wrapDialogMutedRows(th, "source: "+s.Source+" ("+s.Path+")", width)...)
151- " " + th.FG256(th.Muted, "source: "+s.Source+" ("+s.Path+")"),151+ out = append(out, "")
152- "",152+
153- }153+ // Fold the markdown body to the dialog width. The renderer
154-154+ // hard-truncates over-wide rows, and skill bodies are prose that
155- rendered := tui.RenderMarkdown(s.Body, th, width-4)155+ // routinely exceeds one row, so an unfolded body silently loses
156- bodyLines := strings.Split(rendered, "\n")156+ // everything past the right edge.
157- for i, l := range bodyLines {157+ bodyLines := renderDialogMarkdownRows(s.Body, th, width)
158- if len(l) > 0 && l[0] == tui.FlushLeftSentinel {
159- bodyLines[i] = l[1:]
160- }
161- }
162 158
163 const maxRows = 16159 const maxRows = 16
164 if d.scroll > len(bodyLines)-1 {160 if d.scroll > len(bodyLines)-1 {
@@ -171,9 +167,9 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
171 if end > len(bodyLines) {167 if end > len(bodyLines) {
172 end = len(bodyLines)168 end = len(bodyLines)
173 }169 }
174- for _, line := range bodyLines[d.scroll:end] {170+ // renderDialogMarkdownRows already prefixes each row with the
175- out = append(out, " "+line)171+ // dialog's text indent.
176- }172+ out = append(out, bodyLines[d.scroll:end]...)
177 if end < len(bodyLines) {173 if end < len(bodyLines) {
178 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))174 out = append(out, " "+th.FG256(th.Muted, fmt.Sprintf("\u2193 %d more lines (down/pgdn)", len(bodyLines)-end)))
179 }175 }
@@ -181,6 +177,21 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string {
181 return out177 return out
182 }178 }
183 179
180+// wrapDialogMutedRows folds one muted dialog line to the width available
181+// after the dialog's 2-space indent and returns ready-to-print rows.
182+func wrapDialogMutedRows(th tui.Theme, text string, width int) []string {
183+ inner := width - 2
184+ if inner < 1 {
185+ inner = 1
186+ }
187+ wrapped := tui.WrapANSILine(text, inner)
188+ out := make([]string, 0, len(wrapped))
189+ for _, line := range wrapped {
190+ out = append(out, " "+th.FG256(th.Muted, line))
191+ }
192+ return out
193+}
194+
184 func formatSkillRow(s *skills.Skill, maxWidth int) string {195 func formatSkillRow(s *skills.Skill, maxWidth int) string {
185 left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))196 left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20))
186 source := s.Source197 source := s.Source
A packages/agent/modes/skills_dialog_test.go +86 -0
@@ -0,0 +1,86 @@
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 TestSkillsDialogWrapsHintLines(t *testing.T) {
72+ const width = 44
73+ d := newSkillsDialog()
74+ d.canPin = true
75+ d.Open(nil)
76+
77+ rows := d.Render(tui.Theme{}, width)
78+ assertRowsFitWidth(t, rows, width)
79+
80+ plain := stripANSIBytes(strings.Join(rows, "\n"))
81+ for _, want := range []string{"project pin", "toggle", "no user skills loaded", "$ZOT_HOME/skills", ".agents/skills"} {
82+ if !strings.Contains(plain, want) {
83+ t.Fatalf("wrapped hint lost %q:\n%s", want, plain)
84+ }
85+ }
86+}
@@ -0,0 +1,86 @@
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 TestSkillsDialogWrapsHintLines(t *testing.T) {
72+ const width = 44
73+ d := newSkillsDialog()
74+ d.canPin = true
75+ d.Open(nil)
76+
77+ rows := d.Render(tui.Theme{}, width)
78+ assertRowsFitWidth(t, rows, width)
79+
80+ plain := stripANSIBytes(strings.Join(rows, "\n"))
81+ for _, want := range []string{"project pin", "toggle", "no user skills loaded", "$ZOT_HOME/skills", ".agents/skills"} {
82+ if !strings.Contains(plain, want) {
83+ t.Fatalf("wrapped hint lost %q:\n%s", want, plain)
84+ }
85+ }
86+}

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