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 { | |||
| 101 | 101 | ||
| 102 | 102 | out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)} | |
| 103 | 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 | 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")) | |
| 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)...) | |
| 109 | 109 | out = append(out, frameRule(th, width)) | |
| 110 | 110 | return out | |
| 111 | 111 | } | |
| @@ -145,20 +145,16 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string { | |||
| 145 | 145 | ||
| 146 | 146 | func (d *skillsDialog) renderBody(th tui.Theme, width int) []string { | |
| 147 | 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 | - | } | |
| 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) | |
| 162 | 158 | ||
| 163 | 159 | const maxRows = 16 | |
| 164 | 160 | if d.scroll > len(bodyLines)-1 { | |
| @@ -171,9 +167,9 @@ func (d *skillsDialog) renderBody(th tui.Theme, width int) []string { | |||
| 171 | 167 | if end > len(bodyLines) { | |
| 172 | 168 | end = len(bodyLines) | |
| 173 | 169 | } | |
| 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]...) | |
| 177 | 173 | if end < len(bodyLines) { | |
| 178 | 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 | 177 | 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 | 195 | func formatSkillRow(s *skills.Skill, maxWidth int) string { | |
| 185 | 196 | left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20)) | |
| 186 | 197 | 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 out | 110 | 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.viewing | 147 | 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 = 16 | 159 | 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 out | 177 | 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.Source | 197 | 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 | + | } | |||