Merge pull request #218 from m1k3s0/fix/skills-dialog-wrap
Wrap long lines in the /skills body view
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. | |||
| 91 | 91 | ||
| 92 | 92 | In zot, run `/skills`. A picker lists every discovered skill with its | |
| 93 | 93 | 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 | 98 | ## Pinning skills | |
| 97 | 99 | ||
| @@ -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 its | 92 | 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 full | 93 | 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 skills | 98 | ## Pinning skills | ||
| 97 | 99 | ||||
M packages/agent/modes/interactive.go +10 -0
| @@ -1668,6 +1668,16 @@ func (i *Interactive) redraw() { | |||
| 1668 | 1668 | } | |
| 1669 | 1669 | ||
| 1670 | 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 | 1681 | chatRows := rows - len(bottom) | |
| 1672 | 1682 | if chatRows < 1 { | |
| 1673 | 1683 | 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 = 1 | 1683 | chatRows = 1 | ||
M packages/agent/modes/skills_dialog.go +48 -28
| @@ -18,7 +18,8 @@ type skillsDialog struct { | |||
| 18 | 18 | skills []*skills.Skill | |
| 19 | 19 | cursor int | |
| 20 | 20 | 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 | 25 | func newSkillsDialog() *skillsDialog { return &skillsDialog{} } | |
| @@ -101,11 +102,11 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string { | |||
| 101 | 102 | ||
| 102 | 103 | out := []string{frameHeader(th, "skills (enter to view, esc to close)", width)} | |
| 103 | 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 | 107 | 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)...) | |
| 109 | 110 | out = append(out, frameRule(th, width)) | |
| 110 | 111 | return out | |
| 111 | 112 | } | |
| @@ -143,44 +144,63 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string { | |||
| 143 | 144 | 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 | 154 | func (d *skillsDialog) renderBody(th tui.Theme, width int) []string { | |
| 147 | 155 | 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)...) | |
| 162 | 166 | ||
| 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 | |
| 166 | 173 | } | |
| 167 | 174 | if d.scroll < 0 { | |
| 168 | 175 | d.scroll = 0 | |
| 169 | 176 | } | |
| 170 | 177 | end := d.scroll + maxRows | |
| 171 | - | if end > len(bodyLines) { | |
| 172 | - | end = len(bodyLines) | |
| 178 | + | if end > len(content) { | |
| 179 | + | end = len(content) | |
| 173 | 180 | } | |
| 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))) | |
| 179 | 184 | } | |
| 180 | 185 | out = append(out, frameRule(th, width)) | |
| 181 | 186 | 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 | 204 | func formatSkillRow(s *skills.Skill, maxWidth int) string { | |
| 185 | 205 | left := fmt.Sprintf("%-20s ", truncateLineSafe(s.Name, 20)) | |
| 186 | 206 | source := s.Source | |
| @@ -18,7 +18,8 @@ type skillsDialog struct { | |||||
| 18 | skills []*skills.Skill | 18 | skills []*skills.Skill | ||
| 19 | cursor int | 19 | cursor int | ||
| 20 | viewing *skills.Skill // when non-nil, render the body instead of the list | 20 | 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 out | 111 | return out | ||
| 111 | } | 112 | } | ||
| @@ -143,44 +144,63 @@ func (d *skillsDialog) Render(th tui.Theme, width int) []string { | |||||
| 143 | return out | 144 | 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.viewing | 155 | 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 = 16 | 167 | + | maxRows := d.maxRows |
| 164 | - | if d.scroll > len(bodyLines)-1 { | 168 | + | if maxRows <= 0 || maxRows > 16 { |
| 165 | - | d.scroll = len(bodyLines) - 1 | 169 | + | 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 = 0 | 175 | d.scroll = 0 | ||
| 169 | } | 176 | } | ||
| 170 | end := d.scroll + maxRows | 177 | 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 out | 186 | 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.Source | 206 | 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 | + | } | |||